Merge pull request #73 from aleksilassila/feat/settings-page

Feat/settings page
This commit is contained in:
Aleksi Lassila
2023-08-19 03:14:11 +03:00
committed by GitHub
43 changed files with 4054 additions and 654 deletions

1
.gitignore vendored
View File

@@ -10,3 +10,4 @@ node_modules
.output
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
/config/*.sqlite

0
config/.gitkeep Normal file
View File

1411
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -53,6 +53,9 @@
"hls.js": "^1.4.6",
"openapi-fetch": "^0.2.1",
"radix-icons-svelte": "^1.2.1",
"tailwind-scrollbar-hide": "^1.1.7"
"reflect-metadata": "^0.1.13",
"sqlite3": "^5.1.6",
"tailwind-scrollbar-hide": "^1.1.7",
"typeorm": "^0.3.17"
}
}

View File

@@ -18,6 +18,10 @@ a {
@apply focus-visible:outline outline-2 outline-[#f0cd6dc2] outline-offset-2;
}
.peer-selectable {
@apply peer-focus-visible:outline outline-2 outline-[#f0cd6dc2] outline-offset-2;
}
.selectable-explicit {
@apply focus-within:outline outline-2 outline-[#f0cd6dc2] outline-offset-2;
}

4
src/hooks.server.ts Normal file
View File

@@ -0,0 +1,4 @@
import TypeOrm from '$lib/db';
import 'reflect-metadata';
await TypeOrm.getDb();

View File

@@ -1,88 +1,72 @@
import type { components, paths } from '$lib/apis/jellyfin/jellyfin.generated';
import type { DeviceProfile } from '$lib/apis/jellyfin/playback-profiles';
import { JELLYFIN_API_KEY, JELLYFIN_BASE_URL } from '$lib/constants';
import { settings } from '$lib/stores/settings.store';
import axios from 'axios';
import createClient from 'openapi-fetch';
import { get } from 'svelte/store';
export type JellyfinItem = components['schemas']['BaseItemDto'];
export const jellyfinAvailable = !!JELLYFIN_BASE_URL && !!JELLYFIN_API_KEY;
export const JELLYFIN_DEVICE_ID = 'Reiverr Client';
export const JellyfinApi =
JELLYFIN_BASE_URL && JELLYFIN_API_KEY
? createClient<paths>({
baseUrl: JELLYFIN_BASE_URL,
headers: {
Authorization: `MediaBrowser DeviceId="${JELLYFIN_DEVICE_ID}", Token="${JELLYFIN_API_KEY}"`
}
})
: undefined;
function getJellyfinApi() {
const baseUrl = get(settings)?.jellyfin.baseUrl;
const apiKey = get(settings)?.jellyfin.apiKey;
const userId = get(settings)?.jellyfin.userId;
let userId: string | undefined = undefined;
const getUserId = async () => {
if (userId) return userId;
if (!baseUrl || !apiKey || !userId) return undefined;
const user = JellyfinApi?.get('/Users', {
params: {},
return createClient<paths>({
baseUrl,
headers: {
'cache-control': 'max-age=3600'
Authorization: `MediaBrowser DeviceId="${JELLYFIN_DEVICE_ID}", Token="${apiKey}"`
}
}).then((r) => r.data?.[0]?.Id || '');
userId = await user;
return user;
};
});
}
export const getJellyfinContinueWatching = async (): Promise<JellyfinItem[] | undefined> =>
getUserId().then((userId) =>
userId
? JellyfinApi?.get('/Users/{userId}/Items/Resume', {
params: {
path: {
userId
},
query: {
mediaTypes: ['Video'],
fields: ['ProviderIds']
}
}
}).then((r) => r.data?.Items || [])
: undefined
);
getJellyfinApi()
?.get('/Users/{userId}/Items/Resume', {
params: {
path: {
userId: get(settings)?.jellyfin.userId || ''
},
query: {
mediaTypes: ['Video'],
fields: ['ProviderIds']
}
}
})
.then((r) => r.data?.Items || []);
export const getJellyfinNextUp = async () =>
getUserId().then((userId) =>
userId
? JellyfinApi?.get('/Shows/NextUp', {
params: {
query: {
userId,
fields: ['ProviderIds']
}
}
}).then((r) => r.data?.Items || [])
: undefined
);
getJellyfinApi()
?.get('/Shows/NextUp', {
params: {
query: {
userId: get(settings)?.jellyfin.userId || '',
fields: ['ProviderIds']
}
}
})
.then((r) => r.data?.Items || []);
export const getJellyfinItems = async () =>
getUserId().then((userId) =>
userId
? JellyfinApi?.get('/Users/{userId}/Items', {
params: {
path: {
userId
},
query: {
hasTmdbId: true,
recursive: true,
includeItemTypes: ['Movie', 'Series'],
fields: ['ProviderIds']
}
}
}).then((r) => r.data?.Items || [])
: undefined
);
getJellyfinApi()
?.get('/Users/{userId}/Items', {
params: {
path: {
userId: get(settings)?.jellyfin.userId || ''
},
query: {
hasTmdbId: true,
recursive: true,
includeItemTypes: ['Movie', 'Series'],
fields: ['ProviderIds']
}
}
})
.then((r) => r.data?.Items || []);
// export const getJellyfinSeries = () =>
// JellyfinApi.get('/Users/{userId}/Items', {
@@ -99,24 +83,22 @@ export const getJellyfinItems = async () =>
// }).then((r) => r.data?.Items || []);
export const getJellyfinEpisodes = async () =>
getUserId().then((userId) =>
userId
? JellyfinApi?.get('/Users/{userId}/Items', {
params: {
path: {
userId
},
query: {
recursive: true,
includeItemTypes: ['Episode']
}
},
headers: {
'cache-control': 'max-age=10'
}
}).then((r) => r.data?.Items || [])
: undefined
);
getJellyfinApi()
?.get('/Users/{userId}/Items', {
params: {
path: {
userId: get(settings)?.jellyfin.userId || ''
},
query: {
recursive: true,
includeItemTypes: ['Episode']
}
},
headers: {
'cache-control': 'max-age=10'
}
})
.then((r) => r.data?.Items || []);
// export const getJellyfinEpisodesBySeries = (seriesId: string) =>
// getJellyfinEpisodes().then((items) => items?.filter((i) => i.SeriesId === seriesId) || []);
@@ -125,18 +107,16 @@ export const getJellyfinEpisodes = async () =>
// getJellyfinItems().then((items) => items?.find((i) => i.ProviderIds?.Tmdb == tmdbId));
export const getJellyfinItem = async (itemId: string) =>
getUserId().then((userId) =>
userId
? JellyfinApi?.get('/Users/{userId}/Items/{itemId}', {
params: {
path: {
itemId,
userId
}
}
}).then((r) => r.data)
: undefined
);
getJellyfinApi()
?.get('/Users/{userId}/Items/{itemId}', {
params: {
path: {
itemId,
userId: get(settings)?.jellyfin.userId || ''
}
}
})
.then((r) => r.data);
// export const requestJellyfinItemByTmdbId = () =>
// request((tmdbId: string) => getJellyfinItemByTmdbId(tmdbId));
@@ -147,35 +127,37 @@ export const getJellyfinPlaybackInfo = async (
startTimeTicks = 0,
maxStreamingBitrate = 140000000
) =>
getUserId().then((userId) =>
userId
? JellyfinApi?.post('/Items/{itemId}/PlaybackInfo', {
params: {
path: {
itemId: itemId
},
query: {
userId,
startTimeTicks,
autoOpenLiveStream: true,
maxStreamingBitrate
}
},
body: {
DeviceProfile: playbackProfile
}
}).then((r) => ({
playbackUri:
r.data?.MediaSources?.[0]?.TranscodingUrl ||
`/Videos/${r.data?.MediaSources?.[0].Id}/stream.mp4?Static=true&mediaSourceId=${r.data?.MediaSources?.[0].Id}&deviceId=${JELLYFIN_DEVICE_ID}&api_key=${JELLYFIN_API_KEY}&Tag=${r.data?.MediaSources?.[0].ETag}`,
mediaSourceId: r.data?.MediaSources?.[0]?.Id,
playSessionId: r.data?.PlaySessionId,
directPlay:
!!r.data?.MediaSources?.[0]?.SupportsDirectPlay ||
!!r.data?.MediaSources?.[0]?.SupportsDirectStream
}))
: undefined
);
getJellyfinApi()
?.post('/Items/{itemId}/PlaybackInfo', {
params: {
path: {
itemId: itemId
},
query: {
userId: get(settings)?.jellyfin.userId || '',
startTimeTicks,
autoOpenLiveStream: true,
maxStreamingBitrate
}
},
body: {
DeviceProfile: playbackProfile
}
})
.then((r) => ({
playbackUri:
r.data?.MediaSources?.[0]?.TranscodingUrl ||
`/Videos/${r.data?.MediaSources?.[0].Id}/stream.mp4?Static=true&mediaSourceId=${
r.data?.MediaSources?.[0].Id
}&deviceId=${JELLYFIN_DEVICE_ID}&api_key=${get(settings)?.jellyfin.apiKey}&Tag=${
r.data?.MediaSources?.[0].ETag
}`,
mediaSourceId: r.data?.MediaSources?.[0]?.Id,
playSessionId: r.data?.PlaySessionId,
directPlay:
!!r.data?.MediaSources?.[0]?.SupportsDirectPlay ||
!!r.data?.MediaSources?.[0]?.SupportsDirectStream
}));
export const reportJellyfinPlaybackStarted = (
itemId: string,
@@ -184,20 +166,16 @@ export const reportJellyfinPlaybackStarted = (
audioStreamIndex?: number,
subtitleStreamIndex?: number
) =>
getUserId().then((userId) =>
userId
? JellyfinApi?.post('/Sessions/Playing', {
body: {
CanSeek: true,
ItemId: itemId,
PlaySessionId: sessionId,
MediaSourceId: mediaSourceId,
AudioStreamIndex: 1,
SubtitleStreamIndex: -1
}
})
: undefined
);
getJellyfinApi()?.post('/Sessions/Playing', {
body: {
CanSeek: true,
ItemId: itemId,
PlaySessionId: sessionId,
MediaSourceId: mediaSourceId,
AudioStreamIndex: 1,
SubtitleStreamIndex: -1
}
});
export const reportJellyfinPlaybackProgress = (
itemId: string,
@@ -205,7 +183,7 @@ export const reportJellyfinPlaybackProgress = (
isPaused: boolean,
positionTicks: number
) =>
JellyfinApi?.post('/Sessions/Playing/Progress', {
getJellyfinApi()?.post('/Sessions/Playing/Progress', {
body: {
ItemId: itemId,
PlaySessionId: sessionId,
@@ -221,7 +199,7 @@ export const reportJellyfinPlaybackStopped = (
sessionId: string,
positionTicks: number
) =>
JellyfinApi?.post('/Sessions/Playing/Stopped', {
getJellyfinApi()?.post('/Sessions/Playing/Stopped', {
body: {
ItemId: itemId,
PlaySessionId: sessionId,
@@ -231,7 +209,7 @@ export const reportJellyfinPlaybackStopped = (
});
export const delteActiveEncoding = (playSessionId: string) =>
JellyfinApi?.del('/Videos/ActiveEncodings', {
getJellyfinApi()?.del('/Videos/ActiveEncodings', {
params: {
query: {
deviceId: JELLYFIN_DEVICE_ID,
@@ -241,32 +219,50 @@ export const delteActiveEncoding = (playSessionId: string) =>
});
export const setJellyfinItemWatched = async (jellyfinId: string) =>
getUserId().then((userId) =>
userId
? JellyfinApi?.post('/Users/{userId}/PlayedItems/{itemId}', {
params: {
path: {
userId,
itemId: jellyfinId
},
query: {
datePlayed: new Date().toISOString()
}
}
})
: undefined
);
getJellyfinApi()?.post('/Users/{userId}/PlayedItems/{itemId}', {
params: {
path: {
userId: get(settings)?.jellyfin.userId || '',
itemId: jellyfinId
},
query: {
datePlayed: new Date().toISOString()
}
}
});
export const setJellyfinItemUnwatched = async (jellyfinId: string) =>
getUserId().then((userId) =>
userId
? JellyfinApi?.del('/Users/{userId}/PlayedItems/{itemId}', {
params: {
path: {
userId,
itemId: jellyfinId
}
}
})
: undefined
);
getJellyfinApi()?.del('/Users/{userId}/PlayedItems/{itemId}', {
params: {
path: {
userId: get(settings)?.jellyfin.userId || '',
itemId: jellyfinId
}
}
});
export const getJellyfinHealth = async (
baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined
) =>
axios
.get((baseUrl || get(settings)?.jellyfin.baseUrl) + '/System/Info', {
headers: {
'X-Emby-Token': apiKey || get(settings)?.jellyfin.apiKey
}
})
.then((res) => res.status === 200)
.catch(() => false);
export const getJellyfinUsers = async (
baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined
): Promise<components['schemas']['UserDto'][]> =>
axios
.get((baseUrl || get(settings)?.jellyfin.baseUrl) + '/Users', {
headers: {
'X-Emby-Token': apiKey || get(settings)?.jellyfin.apiKey
}
})
.then((res) => res.data || [])
.catch(() => []);

View File

@@ -1,8 +1,8 @@
import type { components, paths } from '$lib/apis/radarr/radarr.generated';
import { getTmdbMovie } from '$lib/apis/tmdb/tmdbApi';
import { RADARR_API_KEY, RADARR_BASE_URL } from '$lib/constants';
import { settings } from '$lib/stores/settings.store';
import { log } from '$lib/utils';
import axios from 'axios';
import createClient from 'openapi-fetch';
import { get } from 'svelte/store';
@@ -25,31 +25,39 @@ export interface RadarrMovieOptions {
searchNow?: boolean;
}
export const radarrAvailable = !!RADARR_BASE_URL && !!RADARR_API_KEY;
function getRadarrApi() {
const baseUrl = get(settings)?.radarr.baseUrl;
const apiKey = get(settings)?.radarr.apiKey;
export const RadarrApi =
RADARR_BASE_URL && RADARR_API_KEY
? createClient<paths>({
baseUrl: RADARR_BASE_URL,
headers: {
'X-Api-Key': RADARR_API_KEY
}
})
: undefined;
if (!baseUrl || !apiKey) return undefined;
console.log(baseUrl, apiKey);
return createClient<paths>({
baseUrl,
headers: {
'X-Api-Key': apiKey
}
});
}
export const getRadarrMovies = (): Promise<RadarrMovie[]> =>
RadarrApi?.get('/api/v3/movie', {
params: {}
}).then((r) => r.data || []) || Promise.resolve([]);
getRadarrApi()
?.get('/api/v3/movie', {
params: {}
})
.then((r) => r.data || []) || Promise.resolve([]);
export const getRadarrMovieByTmdbId = (tmdbId: string): Promise<RadarrMovie | undefined> =>
RadarrApi?.get('/api/v3/movie', {
params: {
query: {
tmdbId: Number(tmdbId)
getRadarrApi()
?.get('/api/v3/movie', {
params: {
query: {
tmdbId: Number(tmdbId)
}
}
}
}).then((r) => r.data?.find((m) => (m.tmdbId as any) == tmdbId)) || Promise.resolve(undefined);
})
.then((r) => r.data?.find((m) => (m.tmdbId as any) == tmdbId)) || Promise.resolve(undefined);
export const addMovieToRadarr = async (tmdbId: number) => {
const tmdbMovie = await getTmdbMovie(tmdbId);
@@ -60,9 +68,9 @@ export const addMovieToRadarr = async (tmdbId: number) => {
if (!tmdbMovie) throw new Error('Movie not found');
const options: RadarrMovieOptions = {
qualityProfileId: get(settings).radarr.qualityProfileId,
profileId: get(settings).radarr.profileId,
rootFolderPath: get(settings).radarr.rootFolderPath,
qualityProfileId: get(settings)?.radarr.qualityProfileId || 0,
profileId: get(settings)?.radarr.profileId || 0,
rootFolderPath: get(settings)?.radarr.rootFolderPath || '',
minimumAvailability: 'announced',
title: tmdbMovie.title || tmdbMovie.original_title || '',
tmdbId: tmdbMovie.id || 0,
@@ -73,60 +81,70 @@ export const addMovieToRadarr = async (tmdbId: number) => {
};
return (
RadarrApi?.post('/api/v3/movie', {
params: {},
body: options
}).then((r) => r.data) || Promise.resolve(undefined)
getRadarrApi()
?.post('/api/v3/movie', {
params: {},
body: options
})
.then((r) => r.data) || Promise.resolve(undefined)
);
};
export const cancelDownloadRadarrMovie = async (downloadId: number) => {
const deleteResponse = await RadarrApi?.del('/api/v3/queue/{id}', {
params: {
path: {
id: downloadId
},
query: {
blocklist: false,
removeFromClient: true
const deleteResponse = await getRadarrApi()
?.del('/api/v3/queue/{id}', {
params: {
path: {
id: downloadId
},
query: {
blocklist: false,
removeFromClient: true
}
}
}
}).then((r) => log(r));
})
.then((r) => log(r));
return !!deleteResponse?.response.ok;
};
export const fetchRadarrReleases = (movieId: number) =>
RadarrApi?.get('/api/v3/release', { params: { query: { movieId: movieId } } }).then(
(r) => r.data || []
) || Promise.resolve([]);
getRadarrApi()
?.get('/api/v3/release', { params: { query: { movieId: movieId } } })
.then((r) => r.data || []) || Promise.resolve([]);
export const downloadRadarrMovie = (guid: string, indexerId: number) =>
RadarrApi?.post('/api/v3/release', {
params: {},
body: {
indexerId,
guid
}
}).then((res) => res.response.ok) || Promise.resolve(false);
getRadarrApi()
?.post('/api/v3/release', {
params: {},
body: {
indexerId,
guid
}
})
.then((res) => res.response.ok) || Promise.resolve(false);
export const deleteRadarrMovie = (id: number) =>
RadarrApi?.del('/api/v3/moviefile/{id}', {
params: {
path: {
id
getRadarrApi()
?.del('/api/v3/moviefile/{id}', {
params: {
path: {
id
}
}
}
}).then((res) => res.response.ok) || Promise.resolve(false);
})
.then((res) => res.response.ok) || Promise.resolve(false);
export const getRadarrDownloads = (): Promise<RadarrDownload[]> =>
RadarrApi?.get('/api/v3/queue', {
params: {
query: {
includeMovie: true
getRadarrApi()
?.get('/api/v3/queue', {
params: {
query: {
includeMovie: true
}
}
}
}).then((r) => (r.data?.records?.filter((record) => record.movie) as RadarrDownload[]) || []) ||
})
.then((r) => (r.data?.records?.filter((record) => record.movie) as RadarrDownload[]) || []) ||
Promise.resolve([]);
export const getRadarrDownloadsById = (radarrId: number) =>
@@ -136,22 +154,71 @@ export const getRadarrDownloadsByTmdbId = (tmdbId: number) =>
getRadarrDownloads().then((downloads) => downloads.filter((d) => d.movie.tmdbId === tmdbId));
const lookupRadarrMovieByTmdbId = (tmdbId: number) =>
RadarrApi?.get('/api/v3/movie/lookup/tmdb', {
params: {
query: {
tmdbId
getRadarrApi()
?.get('/api/v3/movie/lookup/tmdb', {
params: {
query: {
tmdbId
}
}
}
}).then((r) => r.data as any as RadarrMovie) || Promise.resolve(undefined);
})
.then((r) => r.data as any as RadarrMovie) || Promise.resolve(undefined);
export const getDiskSpace = (): Promise<DiskSpaceInfo[]> =>
RadarrApi?.get('/api/v3/diskspace', {}).then((d) => d.data || []) || Promise.resolve([]);
getRadarrApi()
?.get('/api/v3/diskspace', {})
.then((d) => d.data || []) || Promise.resolve([]);
export const removeFromRadarr = (id: number) =>
RadarrApi?.del('/api/v3/movie/{id}', {
params: {
path: {
id
getRadarrApi()
?.del('/api/v3/movie/{id}', {
params: {
path: {
id
}
}
}
}).then((res) => res.response.ok) || Promise.resolve(false);
})
.then((res) => res.response.ok) || Promise.resolve(false);
export const getRadarrHealth = async (
baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined
) =>
axios
.get((baseUrl || get(settings)?.radarr.baseUrl) + '/api/v3/health', {
headers: {
'X-Api-Key': apiKey || get(settings)?.radarr.apiKey
}
})
.then((res) => res.status === 200)
.catch(() => false);
export const getRadarrRootFolders = async (
baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined
) =>
axios
.get<components['schemas']['RootFolderResource'][]>(
(baseUrl || get(settings)?.sonarr.baseUrl) + '/api/v3/rootFolder',
{
headers: {
'X-Api-Key': apiKey || get(settings)?.sonarr.apiKey
}
}
)
.then((res) => res.data || []);
export const getRadarrQualityProfiles = async (
baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined
) =>
axios
.get<components['schemas']['QualityProfileResource'][]>(
(baseUrl || get(settings)?.sonarr.baseUrl) + '/api/v3/qualityprofile',
{
headers: {
'X-Api-Key': apiKey || get(settings)?.sonarr.apiKey
}
}
)
.then((res) => res.data || []);

View File

@@ -1,10 +1,10 @@
import type { components, paths } from '$lib/apis/sonarr/sonarr.generated';
import { log } from '$lib/utils';
import createClient from 'openapi-fetch';
import { getTmdbSeries } from '../tmdb/tmdbApi';
import { get } from 'svelte/store';
import { settings } from '$lib/stores/settings.store';
import { SONARR_API_KEY, SONARR_BASE_URL } from '$lib/constants';
import { log } from '$lib/utils';
import axios from 'axios';
import createClient from 'openapi-fetch';
import { get } from 'svelte/store';
import { getTmdbSeries } from '../tmdb/tmdbApi';
export type SonarrSeries = components['schemas']['SeriesResource'];
export type SonarrReleaseResource = components['schemas']['ReleaseResource'];
@@ -38,38 +38,42 @@ export interface SonarrSeriesOptions {
};
}
export const sonarrAvailable = !!SONARR_BASE_URL && !!SONARR_API_KEY;
function getSonarrApi() {
const baseUrl = get(settings)?.sonarr.baseUrl;
const apiKey = get(settings)?.sonarr.apiKey;
export const SonarrApi =
SONARR_BASE_URL && SONARR_API_KEY
? createClient<paths>({
baseUrl: SONARR_BASE_URL,
headers: {
'X-Api-Key': SONARR_API_KEY
}
})
: undefined;
if (!baseUrl || !apiKey) return undefined;
return createClient<paths>({
baseUrl,
headers: {
'X-Api-Key': apiKey
}
});
}
export const getSonarrSeries = (): Promise<SonarrSeries[]> =>
SonarrApi?.get('/api/v3/series', {
params: {}
}).then((r) => r.data || []) || Promise.resolve([]);
getSonarrApi()
?.get('/api/v3/series', {
params: {}
})
.then((r) => r.data || []) || Promise.resolve([]);
export const getSonarrSeriesByTvdbId = (tvdbId: number): Promise<SonarrSeries | undefined> =>
SonarrApi?.get('/api/v3/series', {
params: {
query: {
tvdbId: tvdbId
getSonarrApi()
?.get('/api/v3/series', {
params: {
query: {
tvdbId: tvdbId
}
}
}
}).then((r) => r.data?.find((m) => m.tvdbId === tvdbId)) || Promise.resolve(undefined);
export const getRadarrDownloadById = (sonarrId: number) =>
getSonarrDownloads().then((downloads) => downloads.find((d) => d.series.id === sonarrId)) ||
Promise.resolve(undefined);
})
.then((r) => r.data?.find((m) => m.tvdbId === tvdbId)) || Promise.resolve(undefined);
export const getDiskSpace = (): Promise<DiskSpaceInfo[]> =>
SonarrApi?.get('/api/v3/diskspace', {}).then((d) => d.data || []) || Promise.resolve([]);
getSonarrApi()
?.get('/api/v3/diskspace', {})
.then((d) => d.data || []) || Promise.resolve([]);
export const addSeriesToSonarr = async (tmdbId: number) => {
const tmdbSeries = await getTmdbSeries(tmdbId);
@@ -80,103 +84,120 @@ export const addSeriesToSonarr = async (tmdbId: number) => {
const options: SonarrSeriesOptions = {
title: tmdbSeries.name,
tvdbId: tmdbSeries.external_ids.tvdb_id,
qualityProfileId: get(settings).sonarr.qualityProfileId,
qualityProfileId: get(settings)?.sonarr.qualityProfileId || 0,
monitored: false,
addOptions: {
monitor: 'none',
searchForMissingEpisodes: false,
searchForCutoffUnmetEpisodes: false
},
rootFolderPath: get(settings).sonarr.rootFolderPath,
languageProfileId: get(settings).sonarr.languageProfileId,
rootFolderPath: get(settings)?.sonarr.rootFolderPath || '',
languageProfileId: get(settings)?.sonarr.languageProfileId || 0,
seasonFolder: true
};
return SonarrApi?.post('/api/v3/series', {
params: {},
body: options
}).then((r) => r.data);
return getSonarrApi()
?.post('/api/v3/series', {
params: {},
body: options
})
.then((r) => r.data);
};
export const cancelDownloadSonarrEpisode = async (downloadId: number) => {
const deleteResponse = await SonarrApi?.del('/api/v3/queue/{id}', {
params: {
path: {
id: downloadId
},
query: {
blocklist: false,
removeFromClient: true
const deleteResponse = await getSonarrApi()
?.del('/api/v3/queue/{id}', {
params: {
path: {
id: downloadId
},
query: {
blocklist: false,
removeFromClient: true
}
}
}
}).then((r) => log(r));
})
.then((r) => log(r));
return !!deleteResponse?.response.ok;
};
export const downloadSonarrEpisode = (guid: string, indexerId: number) =>
SonarrApi?.post('/api/v3/release', {
params: {},
body: {
indexerId,
guid
}
}).then((res) => res.response.ok) || Promise.resolve(false);
getSonarrApi()
?.post('/api/v3/release', {
params: {},
body: {
indexerId,
guid
}
})
.then((res) => res.response.ok) || Promise.resolve(false);
export const deleteSonarrEpisode = (id: number) =>
SonarrApi?.del('/api/v3/episodefile/{id}', {
params: {
path: {
id
getSonarrApi()
?.del('/api/v3/episodefile/{id}', {
params: {
path: {
id
}
}
}
}).then((res) => res.response.ok) || Promise.resolve(false);
})
.then((res) => res.response.ok) || Promise.resolve(false);
export const getSonarrDownloads = (): Promise<SonarrDownload[]> =>
SonarrApi?.get('/api/v3/queue', {
params: {
query: {
includeEpisode: true,
includeSeries: true
getSonarrApi()
?.get('/api/v3/queue', {
params: {
query: {
includeEpisode: true,
includeSeries: true
}
}
}
}).then(
(r) =>
(r.data?.records?.filter((record) => record.episode && record.series) as SonarrDownload[]) ||
[]
) || Promise.resolve([]);
})
.then(
(r) =>
(r.data?.records?.filter(
(record) => record.episode && record.series
) as SonarrDownload[]) || []
) || Promise.resolve([]);
export const getSonarrDownloadsById = (sonarrId: number) =>
getSonarrDownloads().then((downloads) => downloads.filter((d) => d.seriesId === sonarrId)) ||
Promise.resolve([]);
export const removeFromSonarr = (id: number): Promise<boolean> =>
SonarrApi?.del('/api/v3/series/{id}', {
params: {
path: {
id
getSonarrApi()
?.del('/api/v3/series/{id}', {
params: {
path: {
id
}
}
}
}).then((res) => res.response.ok) || Promise.resolve(false);
})
.then((res) => res.response.ok) || Promise.resolve(false);
export const getSonarrEpisodes = async (seriesId: number) => {
const episodesPromise =
SonarrApi?.get('/api/v3/episode', {
params: {
query: {
seriesId
getSonarrApi()
?.get('/api/v3/episode', {
params: {
query: {
seriesId
}
}
}
}).then((r) => r.data || []) || Promise.resolve([]);
})
.then((r) => r.data || []) || Promise.resolve([]);
const episodeFilesPromise =
SonarrApi?.get('/api/v3/episodefile', {
params: {
query: {
seriesId
getSonarrApi()
?.get('/api/v3/episodefile', {
params: {
query: {
seriesId
}
}
}
}).then((r) => r.data || []) || Promise.resolve([]);
})
.then((r) => r.data || []) || Promise.resolve([]);
const episodes = await episodesPromise;
const episodeFiles = await episodeFilesPromise;
@@ -188,32 +209,96 @@ export const getSonarrEpisodes = async (seriesId: number) => {
};
export const fetchSonarrReleases = async (episodeId: number) =>
SonarrApi?.get('/api/v3/release', {
params: {
query: {
episodeId
getSonarrApi()
?.get('/api/v3/release', {
params: {
query: {
episodeId
}
}
}
}).then((r) => r.data || []) || Promise.resolve([]);
})
.then((r) => r.data || []) || Promise.resolve([]);
export const fetchSonarrSeasonReleases = async (seriesId: number, seasonNumber: number) =>
SonarrApi?.get('/api/v3/release', {
params: {
query: {
seriesId,
seasonNumber
getSonarrApi()
?.get('/api/v3/release', {
params: {
query: {
seriesId,
seasonNumber
}
}
}
}).then((r) => r.data || []) || Promise.resolve([]);
})
.then((r) => r.data || []) || Promise.resolve([]);
export const fetchSonarrEpisodes = async (seriesId: number): Promise<SonarrEpisode[]> => {
return (
SonarrApi?.get('/api/v3/episode', {
params: {
query: {
seriesId
getSonarrApi()
?.get('/api/v3/episode', {
params: {
query: {
seriesId
}
}
}
}).then((r) => r.data || []) || Promise.resolve([])
})
.then((r) => r.data || []) || Promise.resolve([])
);
};
export const getSonarrHealth = async (
baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined
) =>
axios
.get((baseUrl || get(settings)?.sonarr.baseUrl) + '/api/v3/health', {
headers: {
'X-Api-Key': apiKey || get(settings)?.sonarr.apiKey
}
})
.then((res) => res.status === 200)
.catch(() => false);
export const getSonarrRootFolders = async (
baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined
) =>
axios
.get<components['schemas']['RootFolderResource'][]>(
(baseUrl || get(settings)?.sonarr.baseUrl) + '/api/v3/rootFolder',
{
headers: {
'X-Api-Key': apiKey || get(settings)?.sonarr.apiKey
}
}
)
.then((res) => res.data || []);
export const getSonarrQualityProfiles = async (
baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined
) =>
axios
.get<components['schemas']['QualityProfileResource'][]>(
(baseUrl || get(settings)?.sonarr.baseUrl) + '/api/v3/qualityprofile',
{
headers: {
'X-Api-Key': apiKey || get(settings)?.sonarr.apiKey
}
}
)
.then((res) => res.data || []);
export const getSonarrLanguageProfiles = async (
baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined
) =>
axios
.get<components['schemas']['LanguageProfileResource'][]>(
(baseUrl || get(settings)?.sonarr.baseUrl) + '/api/v3/languageprofile',
{
headers: {
'X-Api-Key': apiKey || get(settings)?.sonarr.apiKey
}
}
)
.then((res) => res.data || []);

View File

@@ -1,6 +1,6 @@
import { browser } from '$app/environment';
import { TMDB_API_KEY } from '$lib/constants';
import { getIncludedLanguagesQuery, settings } from '$lib/stores/settings.store';
import { settings } from '$lib/stores/settings.store';
import { formatDateToYearMonthDay } from '$lib/utils';
import createClient from 'openapi-fetch';
import { get } from 'svelte/store';
@@ -70,7 +70,7 @@ export const getTmdbMovie = async (tmdbId: number) =>
},
query: {
append_to_response: 'videos,credits,external_ids,images',
...({ include_image_language: get(settings).language + ',en,null' } as any)
...({ include_image_language: get(settings)?.language + ',en,null' } as any)
}
}
}).then((res) => res.data as TmdbMovieFull2 | undefined);
@@ -101,7 +101,7 @@ export const getTmdbSeries = async (tmdbId: number): Promise<TmdbSeriesFull2 | u
},
query: {
append_to_response: 'videos,aggregate_credits,external_ids,images',
...({ include_image_language: get(settings).language + ',en,null' } as any)
...({ include_image_language: get(settings)?.language + ',en,null' } as any)
}
},
headers: {
@@ -158,7 +158,7 @@ export const getTmdbSeriesBackdrop = async (tmdbId: number) =>
.then(
(r) =>
(
r?.backdrops?.find((b) => b.iso_639_1 === get(settings).language) ||
r?.backdrops?.find((b) => b.iso_639_1 === get(settings)?.language) ||
r?.backdrops?.find((b) => b.iso_639_1 === 'en') ||
r?.backdrops?.find((b) => b.iso_639_1) ||
r?.backdrops?.[0]
@@ -173,7 +173,7 @@ export const getTmdbMovieBackdrop = async (tmdbId: number) =>
.then(
(r) =>
(
r?.backdrops?.find((b) => b.iso_639_1 === get(settings).language) ||
r?.backdrops?.find((b) => b.iso_639_1 === get(settings)?.language) ||
r?.backdrops?.find((b) => b.iso_639_1 === 'en') ||
r?.backdrops?.find((b) => b.iso_639_1) ||
r?.backdrops?.[0]
@@ -193,8 +193,8 @@ export const getTmdbPopularMovies = () =>
TmdbApiOpen.get('/3/movie/popular', {
params: {
query: {
language: get(settings).language,
region: get(settings).region
language: get(settings)?.language,
region: get(settings)?.discover.region
}
}
}).then((res) => res.data?.results || []);
@@ -203,19 +203,7 @@ export const getTmdbPopularSeries = () =>
TmdbApiOpen.get('/3/tv/popular', {
params: {
query: {
language: get(settings).language
}
}
}).then((res) => res.data?.results || []);
export const getTmdbTrendingAll = () =>
TmdbApiOpen.get('/3/trending/all/{time_window}', {
params: {
path: {
time_window: 'day'
},
query: {
language: get(settings).language
language: get(settings)?.language
}
}
}).then((res) => res.data?.results || []);
@@ -229,44 +217,11 @@ export const getTmdbNetworkSeries = (networkId: number) =>
}
}).then((res) => res.data?.results || []);
export const getTmdbDigitalReleases = () =>
TmdbApiOpen.get('/3/discover/movie', {
params: {
query: {
with_release_type: 4,
sort_by: 'popularity.desc',
'release_date.lte': formatDateToYearMonthDay(new Date()),
...getIncludedLanguagesQuery()
}
}
}).then((res) => res.data?.results || []);
export const getTmdbUpcomingMovies = () =>
TmdbApiOpen.get('/3/discover/movie', {
params: {
query: {
'primary_release_date.gte': formatDateToYearMonthDay(new Date()),
sort_by: 'popularity.desc'
// ...getIncludedLanguagesQuery()
}
}
}).then((res) => res.data?.results || []);
export const getTrendingActors = () =>
TmdbApiOpen.get('/3/trending/person/{time_window}', {
params: {
path: {
time_window: 'week'
}
}
}).then((res) => res.data?.results || []);
export const getTmdbGenreMovies = (genreId: number) =>
TmdbApiOpen.get('/3/discover/movie', {
params: {
query: {
with_genres: String(genreId),
...getIncludedLanguagesQuery()
with_genres: String(genreId)
}
}
}).then((res) => res.data?.results || []);

View File

@@ -19,7 +19,7 @@
'bg-white text-zinc-900 font-extrabold backdrop-blur-lg rounded-xl': type === 'primary',
'hover:bg-amber-400 focus-within:bg-amber-400 hover:border-amber-400 focus-within:border-amber-400':
type === 'primary' && !disabled,
'text-zinc-200 bg-zinc-400 bg-opacity-20 backdrop-blur-lg rounded-xl': type === 'secondary',
'text-zinc-200 bg-zinc-600 bg-opacity-20 backdrop-blur-lg rounded-xl': type === 'secondary',
'focus-visible:bg-zinc-200 focus-visible:text-zinc-800 hover:bg-zinc-200 hover:text-zinc-800':
(type === 'secondary' || type === 'tertiary') && !disabled,
'rounded-full': type === 'tertiary',

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { setJellyfinItemUnwatched, setJellyfinItemWatched } from '$lib/apis/jellyfin/jellyfinApi';
import { JELLYFIN_BASE_URL, RADARR_BASE_URL, SONARR_BASE_URL } from '$lib/constants';
import { library, type LibraryItemStore } from '$lib/stores/library.store';
import { settings } from '$lib/stores/settings.store';
import type { TitleType } from '$lib/types';
import ContextMenuDivider from './ContextMenuDivider.svelte';
import ContextMenuItem from './ContextMenuItem.svelte';
@@ -36,7 +36,9 @@
function handleOpenInJellyfin() {
window.open(
JELLYFIN_BASE_URL + '/web/index.html#!/details?id=' + $itemStore.item?.jellyfinItem?.Id
$settings.jellyfin.baseUrl +
'/web/index.html#!/details?id=' +
$itemStore.item?.jellyfinItem?.Id
);
}
</script>
@@ -59,7 +61,7 @@
<ContextMenuItem
disabled={!$itemStore.item.radarrMovie}
on:click={() =>
window.open(RADARR_BASE_URL + '/movie/' + $itemStore.item?.radarrMovie?.tmdbId)}
window.open($settings.radarr.baseUrl + '/movie/' + $itemStore.item?.radarrMovie?.tmdbId)}
>
Open in Radarr
</ContextMenuItem>
@@ -67,7 +69,9 @@
<ContextMenuItem
disabled={!$itemStore.item.sonarrSeries}
on:click={() =>
window.open(SONARR_BASE_URL + '/series/' + $itemStore.item?.sonarrSeries?.titleSlug)}
window.open(
$settings.sonarr.baseUrl + '/series/' + $itemStore.item?.sonarrSeries?.titleSlug
)}
>
Open in Sonarr
</ContextMenuItem>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { getDiskSpace } from '$lib/apis/radarr/radarrApi';
import { RADARR_BASE_URL } from '$lib/constants';
import { library } from '$lib/stores/library.store';
import { settings } from '$lib/stores/settings.store';
import { formatSize } from '$lib/utils.js';
import RadarrIcon from '../svgs/RadarrIcon.svelte';
import StatsContainer from './StatsContainer.svelte';
@@ -46,7 +46,7 @@
{large}
title="Radarr"
subtitle="Movies Provider"
href={RADARR_BASE_URL}
href={$settings.radarr.baseUrl || '#'}
stats={[
{ title: 'Movies', value: String(moviesCount) },
{ title: 'Space Taken', value: formatSize(spaceOccupied) },

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { getDiskSpace } from '$lib/apis/sonarr/sonarrApi';
import { SONARR_BASE_URL } from '$lib/constants';
import { library } from '$lib/stores/library.store';
import { settings } from '$lib/stores/settings.store';
import { formatSize } from '$lib/utils.js';
import SonarrIcon from '../svgs/SonarrIcon.svelte';
import StatsContainer from './StatsContainer.svelte';
@@ -47,7 +47,7 @@
{large}
title="Sonarr"
subtitle="Shows Provider"
href={SONARR_BASE_URL}
href={$settings.sonarr.baseUrl || '#'}
stats={[
{ title: 'Episodes', value: String(episodesCount) },
{ title: 'Space Taken', value: formatSize(spaceOccupied) },

View File

@@ -14,7 +14,7 @@
const TRAILER_TIMEOUT = 3000;
const TRAILER_LOAD_TIME = 1000;
const ANIMATION_DURATION = 150;
const ANIMATION_DURATION = $settings.animationDuration;
export let tmdbId: number;
export let type: TitleType;
@@ -52,13 +52,15 @@
trailerVisible = false;
UIVisible = true;
timeout = setTimeout(() => {
trailerMounted = true;
if ($settings.autoplayTrailers) {
timeout = setTimeout(() => {
trailerVisible = true;
}, TRAILER_LOAD_TIME);
}, TRAILER_TIMEOUT - TRAILER_LOAD_TIME);
trailerMounted = true; // Mount the trailer
timeout = setTimeout(() => {
trailerVisible = true;
}, TRAILER_LOAD_TIME);
}, TRAILER_TIMEOUT - TRAILER_LOAD_TIME);
}
}
onMount(() => {

View File

@@ -1,38 +1,38 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import {
delteActiveEncoding as deleteActiveEncoding,
getJellyfinItem,
getJellyfinPlaybackInfo,
reportJellyfinPlaybackProgress,
reportJellyfinPlaybackStarted,
reportJellyfinPlaybackStopped,
delteActiveEncoding as deleteActiveEncoding
reportJellyfinPlaybackStopped
} from '$lib/apis/jellyfin/jellyfinApi';
import { getQualities } from '$lib/apis/jellyfin/qualities';
import getDeviceProfile from '$lib/apis/jellyfin/playback-profiles';
import { getQualities } from '$lib/apis/jellyfin/qualities';
import { settings } from '$lib/stores/settings.store';
import classNames from 'classnames';
import Hls from 'hls.js';
import {
Cross2,
Play,
Pause,
EnterFullScreen,
ExitFullScreen,
Gear,
Pause,
Play,
SpeakerLoud,
SpeakerModerate,
SpeakerQuiet,
SpeakerOff,
Gear
SpeakerQuiet
} from 'radix-icons-svelte';
import { onDestroy, onMount } from 'svelte';
import IconButton from '../IconButton.svelte';
import { playerState } from './VideoPlayer';
import { modalStack } from '../Modal/Modal';
import { JELLYFIN_BASE_URL } from '$lib/constants';
import { fade } from 'svelte/transition';
import { contextMenu } from '../ContextMenu/ContextMenu';
import Slider from './Slider.svelte';
import ContextMenu from '../ContextMenu/ContextMenu.svelte';
import SelectableContextMenuItem from '../ContextMenu/SelectableContextMenuItem.svelte';
import IconButton from '../IconButton.svelte';
import { modalStack } from '../Modal/Modal';
import Slider from './Slider.svelte';
import { playerState } from './VideoPlayer';
export let modalId: symbol;
@@ -138,7 +138,7 @@
}
video.poster = item?.BackdropImageTags?.length
? `${JELLYFIN_BASE_URL}/Items/${item?.Id}/Images/Backdrop?quality=100&tag=${item?.BackdropImageTags?.[0]}`
? `${$settings.jellyfin.baseUrl}/Items/${item?.Id}/Images/Backdrop?quality=100&tag=${item?.BackdropImageTags?.[0]}`
: '';
videoLoaded = false;
@@ -146,7 +146,7 @@
if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource(JELLYFIN_BASE_URL + playbackUri);
hls.loadSource($settings.jellyfin.baseUrl + playbackUri);
hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
/*
@@ -154,12 +154,12 @@
* This is not a problem, since HLS is natively supported on iOS. But any other browser
* that does not support MSE will not be able to play the video.
*/
video.src = JELLYFIN_BASE_URL + playbackUri;
video.src = $settings.jellyfin.baseUrl + playbackUri;
} else {
throw new Error('HLS is not supported');
}
} else {
video.src = JELLYFIN_BASE_URL + playbackUri;
video.src = $settings.jellyfin.baseUrl + playbackUri;
}
resolution = item?.Height || 1080;

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import classNames from 'classnames';
import { Update } from 'radix-icons-svelte';
export let type: 'base' | 'success' | 'error' = 'base';
export let loading = false;
export let disabled = false;
</script>
<button
on:click
class={classNames(
'p-1.5 px-4 text-sm text-zinc-200 rounded-lg border',
'hover:bg-opacity-30 transition-colors',
'flex items-center gap-2',
{
'bg-green-500 bg-opacity-20 text-green-200 border-green-900': type === 'success',
'bg-red-500 bg-opacity-20 text-red-200 border-red-900': type === 'error',
'bg-zinc-600 border-zinc-800 bg-opacity-20': type === 'base',
'cursor-not-allowed opacity-75 pointer-events-none': disabled || loading
},
$$restProps.class
)}
>
{#if loading}
<Update class="animate-spin" size={14} />
{/if}
<slot />
</button>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import classNames from 'classnames';
export let type: 'text' | 'number' = 'text';
export let value: any = type === 'text' ? '' : 0;
export let placeholder = '';
const baseStyles =
'appearance-none p-1 px-3 selectable border border-zinc-800 rounded-lg bg-zinc-600 bg-opacity-20 text-zinc-200 placeholder:text-zinc-700';
</script>
<div class="relative">
{#if type === 'text'}
<input type="text" {placeholder} bind:value class={classNames(baseStyles, $$restProps.class)} />
{:else if type === 'number'}
<input
type="number"
{placeholder}
bind:value
class={classNames(baseStyles, 'w-28', $$restProps.class)}
/>
{/if}
</div>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import classNames from 'classnames';
import { CaretDown } from 'radix-icons-svelte';
export let value: any = '';
export let disabled = false;
export let loading = false;
</script>
<div
class={classNames(
'relative w-max min-w-[8rem] h-min bg-zinc-600 bg-opacity-20 rounded-lg overflow-hidden',
{
'opacity-50': disabled,
'animate-pulse pointer-events-none': loading
}
)}
>
<select
on:change
bind:value
class={classNames(
'relative appearance-none p-1 pl-3 pr-8 selectable border border-zinc-800 bg-transparent rounded-lg w-full z-[1]',
{
'cursor-not-allowed pointer-events-none': disabled
}
)}
>
<slot />
</select>
<div class="absolute inset-y-0 right-2 flex items-center justify-center">
<CaretDown size={20} />
</div>
</div>

View File

@@ -0,0 +1,11 @@
<script>
export let checked = false;
</script>
<label class="relative inline-flex items-center cursor-pointer w-min h-min">
<input type="checkbox" bind:checked class="sr-only peer" />
<div
class="w-11 h-6 rounded-full peer bg-zinc-600 bg-opacity-20 peer-checked:bg-amber-200 peer-checked:bg-opacity-30 peer-selectable
after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px]"
/>
</label>

View File

@@ -1,18 +1,6 @@
import { env } from '$env/dynamic/public';
export const TMDB_API_KEY =
'eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0YTZiMDIxZTE5Y2YxOTljMTM1NGFhMGRiMDZiOTkzMiIsInN1YiI6IjY0ODYzYWRmMDI4ZjE0MDExZTU1MDkwMiIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.yyMkZlhGOGBHtw1yvpBVUUHhu7IKVYho49MvNNKt_wY';
export const TMDB_IMAGES_ORIGINAL = 'https://www.themoviedb.org/t/p/original';
export const TMDB_BACKDROP_SMALL = 'https://www.themoviedb.org/t/p/w780';
export const TMDB_POSTER_SMALL = 'https://www.themoviedb.org/t/p/w342';
export const TMDB_PROFILE_SMALL = 'https://www.themoviedb.org/t/p/w185';
export const RADARR_API_KEY = env.PUBLIC_RADARR_API_KEY;
export const RADARR_BASE_URL = env.PUBLIC_RADARR_BASE_URL;
export const SONARR_API_KEY = env.PUBLIC_SONARR_API_KEY;
export const SONARR_BASE_URL = env.PUBLIC_SONARR_BASE_URL;
export const JELLYFIN_API_KEY = env.PUBLIC_JELLYFIN_API_KEY;
export const JELLYFIN_BASE_URL = env.PUBLIC_JELLYFIN_URL || env.PUBLIC_JELLYFIN_BASE_URL; // Backwards compatibility
export const JELLYFIN_USER_ID = env.PUBLIC_JELLYFIN_USER_ID;

35
src/lib/db.ts Normal file
View File

@@ -0,0 +1,35 @@
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import { Settings } from './entities/Settings';
class TypeOrm {
private static instance: Promise<DataSource | null> | null = null;
private constructor() {
// Private constructor to prevent external instantiation
}
public static getDb(): Promise<DataSource | null> {
if (!TypeOrm.instance) {
TypeOrm.instance = new DataSource({
type: 'sqlite',
database: 'config/reiverr.sqlite',
synchronize: true,
entities: [Settings],
logging: true
})
.initialize()
.then((fulfilled) => {
console.info('Data Source has been initialized!');
return fulfilled;
})
.catch((err) => {
console.error('Error during Data Source initialization', err);
return null;
});
}
return TypeOrm.instance;
}
}
export default TypeOrm;

View File

@@ -0,0 +1,159 @@
import { defaultSettings, type SettingsValues } from '$lib/stores/settings.store';
import { BaseEntity, Column, Entity, PrimaryColumn } from 'typeorm';
@Entity({ name: 'settings' })
export class Settings extends BaseEntity {
@PrimaryColumn('text')
name: string;
@Column('boolean', { default: false })
isSetupDone: boolean;
// General
@Column('boolean', { default: defaultSettings.autoplayTrailers })
autoplayTrailers: boolean;
@Column('text', { default: defaultSettings.language })
language: string;
@Column('integer', { default: defaultSettings.animationDuration })
animationDuration: number;
// Discover
@Column('text', { default: defaultSettings.discover.region })
discoverRegion: string;
@Column('boolean', { default: defaultSettings.discover.excludeLibraryItems })
discoverExcludeLibraryItems: boolean;
@Column('text', { default: defaultSettings.discover.includedLanguages })
discoverIncludedLanguages: string;
// Sonarr
@Column('text', { nullable: true, default: defaultSettings.sonarr.baseUrl })
sonarrBaseUrl: string | null;
@Column('text', { nullable: true, default: defaultSettings.sonarr.apiKey })
sonarrApiKey: string | null;
@Column('text', { default: defaultSettings.sonarr.rootFolderPath })
sonarrRootFolderPath: string;
@Column('integer', { default: defaultSettings.sonarr.qualityProfileId })
sonarrQualityProfileId: number;
@Column('integer', { default: defaultSettings.sonarr.languageProfileId })
sonarrLanguageProfileId: number;
// Radarr
@Column('text', { nullable: true, default: defaultSettings.radarr.baseUrl })
radarrBaseUrl: string | null;
@Column('text', { nullable: true, default: defaultSettings.radarr.apiKey })
radarrApiKey: string | null;
@Column('text', { default: defaultSettings.radarr.rootFolderPath })
radarrRootFolderPath: string;
@Column('integer', { default: defaultSettings.radarr.qualityProfileId })
radarrQualityProfileId: number;
// Jellyfin
@Column('text', { nullable: true, default: defaultSettings.jellyfin.baseUrl })
jellyfinBaseUrl: string | null;
@Column('text', { nullable: true, default: defaultSettings.jellyfin.apiKey })
jellyfinApiKey: string | null;
@Column('text', { nullable: true, default: defaultSettings.jellyfin.userId })
jellyfinUserId: string | null;
public static async get(name = 'default'): Promise<SettingsValues> {
const settings = await this.findOne({ where: { name } });
if (!settings) {
const defaultSettings = new Settings();
defaultSettings.name = 'default';
await defaultSettings.save();
return this.getSettingsValues(defaultSettings);
}
return this.getSettingsValues(settings);
}
static getSettingsValues(settings: Settings): SettingsValues {
return {
...defaultSettings,
language: settings.language,
autoplayTrailers: settings.autoplayTrailers,
animationDuration: settings.animationDuration,
discover: {
...defaultSettings.discover,
region: settings.discoverRegion,
excludeLibraryItems: settings.discoverExcludeLibraryItems,
includedLanguages: settings.discoverIncludedLanguages
},
sonarr: {
...defaultSettings.sonarr,
apiKey: settings.sonarrApiKey,
baseUrl: settings.sonarrBaseUrl,
languageProfileId: settings.sonarrLanguageProfileId,
qualityProfileId: settings.sonarrQualityProfileId,
rootFolderPath: settings.sonarrRootFolderPath
},
radarr: {
...defaultSettings.radarr,
apiKey: settings.radarrApiKey,
baseUrl: settings.radarrBaseUrl,
qualityProfileId: settings.radarrQualityProfileId,
rootFolderPath: settings.radarrRootFolderPath
},
jellyfin: {
...defaultSettings.jellyfin,
apiKey: settings.jellyfinApiKey,
baseUrl: settings.jellyfinBaseUrl,
userId: settings.jellyfinUserId
},
initialised: true
};
}
public static async set(name: string, values: SettingsValues): Promise<Settings | null> {
const settings = await this.findOne({ where: { name } });
if (!settings) return null;
settings.language = values.language;
settings.autoplayTrailers = values.autoplayTrailers;
settings.animationDuration = values.animationDuration;
settings.discoverRegion = values.discover.region;
settings.discoverExcludeLibraryItems = values.discover.excludeLibraryItems;
settings.discoverIncludedLanguages = values.discover.includedLanguages;
settings.sonarrApiKey = values.sonarr.apiKey;
settings.sonarrBaseUrl = values.sonarr.baseUrl;
settings.sonarrLanguageProfileId = values.sonarr.languageProfileId;
settings.sonarrQualityProfileId = values.sonarr.qualityProfileId;
settings.sonarrRootFolderPath = values.sonarr.rootFolderPath;
settings.radarrApiKey = values.radarr.apiKey;
settings.radarrBaseUrl = values.radarr.baseUrl;
settings.radarrQualityProfileId = values.radarr.qualityProfileId;
settings.radarrRootFolderPath = values.radarr.rootFolderPath;
settings.jellyfinApiKey = values.jellyfin.apiKey;
settings.jellyfinBaseUrl = values.jellyfin.baseUrl;
settings.jellyfinUserId = values.jellyfin.userId;
await settings.save();
return settings;
}
}

View File

@@ -26,6 +26,7 @@ import {
import { TMDB_BACKDROP_SMALL, TMDB_POSTER_SMALL } from '$lib/constants';
import type { TitleType } from '$lib/types';
import { get, writable } from 'svelte/store';
import { settings } from './settings.store';
export interface PlayableItem {
tmdbRating: number;
@@ -222,8 +223,6 @@ async function getLibrary(): Promise<Library> {
? `http://jellyfin.home/Items/${jellyfinItem?.Id}/Images/Backdrop?quality=100&tag=${jellyfinItem?.ImageTags?.Primary}`
: '';
console.log(jellyfinItem);
const type: TitleType = jellyfinItem.Type === 'Movie' ? 'movie' : 'series';
const playableItem: PlayableItem = {
@@ -258,9 +257,23 @@ async function getLibrary(): Promise<Library> {
};
}
async function waitForSettings() {
return new Promise((resolve) => {
let resolved = false;
settings.subscribe((settings) => {
if (settings?.initialised && !resolved) {
resolved = true;
resolve(settings);
}
});
});
}
let delayedRefreshTimeout: NodeJS.Timeout;
function createLibraryStore() {
const { update, set, ...library } = writable<Promise<Library>>(getLibrary()); //TODO promise to undefined
const { update, set, ...library } = writable<Promise<Library>>(
waitForSettings().then(() => getLibrary())
); //TODO promise to undefined
async function filterNotInLibrary<T>(toFilter: T[], getTmdbId: (item: T) => number) {
const libraryData = await get(library);

View File

@@ -1,68 +1,64 @@
import { get, writable } from 'svelte/store';
import { writable } from 'svelte/store';
interface Settings {
export interface SettingsValues {
initialised: boolean;
autoplayTrailers: boolean;
excludeLibraryItemsFromDiscovery: boolean;
language: string;
region: string;
discover: {
includedLanguages: string[];
filterBasedOnLanguage: boolean;
};
animationDuration: number;
discover: {
region: string;
excludeLibraryItems: boolean;
includedLanguages: string;
};
sonarr: {
qualityProfileId: number;
baseUrl: string | null;
apiKey: string | null;
rootFolderPath: string;
qualityProfileId: number;
languageProfileId: number;
};
radarr: {
qualityProfileId: number;
profileId: number;
baseUrl: string | null;
apiKey: string | null;
rootFolderPath: string;
qualityProfileId: number;
};
jellyfin: {
userId: string;
};
playback: {
preferredPlaybackSource: 'reiverr' | 'jellyfin';
baseUrl: string | null;
apiKey: string | null;
userId: string | null;
};
}
const defaultSettings: Settings = {
export const defaultSettings: SettingsValues = {
initialised: false,
autoplayTrailers: true,
excludeLibraryItemsFromDiscovery: true,
language: 'en',
region: 'US',
discover: {
filterBasedOnLanguage: true,
includedLanguages: ['en']
},
animationDuration: 150,
discover: {
region: '',
excludeLibraryItems: true,
includedLanguages: 'en'
},
sonarr: {
qualityProfileId: 4,
rootFolderPath: '/tv',
languageProfileId: 1
apiKey: null,
baseUrl: null,
qualityProfileId: 0,
rootFolderPath: '',
languageProfileId: 0
},
radarr: {
qualityProfileId: 4,
profileId: 4,
rootFolderPath: '/movies'
apiKey: null,
baseUrl: null,
qualityProfileId: 0,
rootFolderPath: ''
},
jellyfin: {
userId: ''
},
playback: {
preferredPlaybackSource: 'reiverr'
apiKey: null,
baseUrl: null,
userId: null
}
};
export const settings = writable<Settings>(defaultSettings);
export const getIncludedLanguagesQuery = () => {
const settingsValue = get(settings);
if (settingsValue.discover.filterBasedOnLanguage) {
return { with_original_language: settingsValue.language };
}
return {};
};
export const settings = writable<SettingsValues>(defaultSettings);

View File

@@ -0,0 +1,735 @@
// https://github.com/meikidd/iso-639-1/blob/master/src/data.js
export const ISO_LANGUAGES = {
en: {
name: 'English',
nativeName: 'English'
},
aa: {
name: 'Afar',
nativeName: 'Afaraf'
},
ab: {
name: 'Abkhaz',
nativeName: 'аҧсуа бызшәа'
},
ae: {
name: 'Avestan',
nativeName: 'avesta'
},
af: {
name: 'Afrikaans',
nativeName: 'Afrikaans'
},
ak: {
name: 'Akan',
nativeName: 'Akan'
},
am: {
name: 'Amharic',
nativeName: 'አማርኛ'
},
an: {
name: 'Aragonese',
nativeName: 'aragonés'
},
ar: {
name: 'Arabic',
nativeName: 'اَلْعَرَبِيَّةُ'
},
as: {
name: 'Assamese',
nativeName: 'অসমীয়া'
},
av: {
name: 'Avaric',
nativeName: 'авар мацӀ'
},
ay: {
name: 'Aymara',
nativeName: 'aymar aru'
},
az: {
name: 'Azerbaijani',
nativeName: 'azərbaycan dili'
},
ba: {
name: 'Bashkir',
nativeName: 'башҡорт теле'
},
be: {
name: 'Belarusian',
nativeName: 'беларуская мова'
},
bg: {
name: 'Bulgarian',
nativeName: 'български език'
},
bi: {
name: 'Bislama',
nativeName: 'Bislama'
},
bm: {
name: 'Bambara',
nativeName: 'bamanankan'
},
bn: {
name: 'Bengali',
nativeName: 'বাংলা'
},
bo: {
name: 'Tibetan',
nativeName: 'བོད་ཡིག'
},
br: {
name: 'Breton',
nativeName: 'brezhoneg'
},
bs: {
name: 'Bosnian',
nativeName: 'bosanski jezik'
},
ca: {
name: 'Catalan',
nativeName: 'Català'
},
ce: {
name: 'Chechen',
nativeName: 'нохчийн мотт'
},
ch: {
name: 'Chamorro',
nativeName: 'Chamoru'
},
co: {
name: 'Corsican',
nativeName: 'corsu'
},
cr: {
name: 'Cree',
nativeName: 'ᓀᐦᐃᔭᐍᐏᐣ'
},
cs: {
name: 'Czech',
nativeName: 'čeština'
},
cu: {
name: 'Old Church Slavonic',
nativeName: 'ѩзыкъ словѣньскъ'
},
cv: {
name: 'Chuvash',
nativeName: 'чӑваш чӗлхи'
},
cy: {
name: 'Welsh',
nativeName: 'Cymraeg'
},
da: {
name: 'Danish',
nativeName: 'dansk'
},
de: {
name: 'German',
nativeName: 'Deutsch'
},
dv: {
name: 'Divehi',
nativeName: 'ދިވެހި'
},
dz: {
name: 'Dzongkha',
nativeName: 'རྫོང་ཁ'
},
ee: {
name: 'Ewe',
nativeName: 'Eʋegbe'
},
el: {
name: 'Greek',
nativeName: 'Ελληνικά'
},
eo: {
name: 'Esperanto',
nativeName: 'Esperanto'
},
es: {
name: 'Spanish',
nativeName: 'Español'
},
et: {
name: 'Estonian',
nativeName: 'eesti'
},
eu: {
name: 'Basque',
nativeName: 'euskara'
},
fa: {
name: 'Persian',
nativeName: 'فارسی'
},
ff: {
name: 'Fula',
nativeName: 'Fulfulde'
},
fi: {
name: 'Finnish',
nativeName: 'suomi'
},
fj: {
name: 'Fijian',
nativeName: 'vosa Vakaviti'
},
fo: {
name: 'Faroese',
nativeName: 'føroyskt'
},
fr: {
name: 'French',
nativeName: 'Français'
},
fy: {
name: 'Western Frisian',
nativeName: 'Frysk'
},
ga: {
name: 'Irish',
nativeName: 'Gaeilge'
},
gd: {
name: 'Scottish Gaelic',
nativeName: 'Gàidhlig'
},
gl: {
name: 'Galician',
nativeName: 'galego'
},
gn: {
name: 'Guaraní',
nativeName: "Avañe'ẽ"
},
gu: {
name: 'Gujarati',
nativeName: 'ગુજરાતી'
},
gv: {
name: 'Manx',
nativeName: 'Gaelg'
},
ha: {
name: 'Hausa',
nativeName: 'هَوُسَ'
},
he: {
name: 'Hebrew',
nativeName: 'עברית'
},
hi: {
name: 'Hindi',
nativeName: 'हिन्दी'
},
ho: {
name: 'Hiri Motu',
nativeName: 'Hiri Motu'
},
hr: {
name: 'Croatian',
nativeName: 'Hrvatski'
},
ht: {
name: 'Haitian',
nativeName: 'Kreyòl ayisyen'
},
hu: {
name: 'Hungarian',
nativeName: 'magyar'
},
hy: {
name: 'Armenian',
nativeName: 'Հայերեն'
},
hz: {
name: 'Herero',
nativeName: 'Otjiherero'
},
ia: {
name: 'Interlingua',
nativeName: 'Interlingua'
},
id: {
name: 'Indonesian',
nativeName: 'Bahasa Indonesia'
},
ie: {
name: 'Interlingue',
nativeName: 'Interlingue'
},
ig: {
name: 'Igbo',
nativeName: 'Asụsụ Igbo'
},
ii: {
name: 'Nuosu',
nativeName: 'ꆈꌠ꒿ Nuosuhxop'
},
ik: {
name: 'Inupiaq',
nativeName: 'Iñupiaq'
},
io: {
name: 'Ido',
nativeName: 'Ido'
},
is: {
name: 'Icelandic',
nativeName: 'Íslenska'
},
it: {
name: 'Italian',
nativeName: 'Italiano'
},
iu: {
name: 'Inuktitut',
nativeName: 'ᐃᓄᒃᑎᑐᑦ'
},
ja: {
name: 'Japanese',
nativeName: '日本語'
},
jv: {
name: 'Javanese',
nativeName: 'basa Jawa'
},
ka: {
name: 'Georgian',
nativeName: 'ქართული'
},
kg: {
name: 'Kongo',
nativeName: 'Kikongo'
},
ki: {
name: 'Kikuyu',
nativeName: 'Gĩkũyũ'
},
kj: {
name: 'Kwanyama',
nativeName: 'Kuanyama'
},
kk: {
name: 'Kazakh',
nativeName: 'қазақ тілі'
},
kl: {
name: 'Kalaallisut',
nativeName: 'kalaallisut'
},
km: {
name: 'Khmer',
nativeName: 'ខេមរភាសា'
},
kn: {
name: 'Kannada',
nativeName: 'ಕನ್ನಡ'
},
ko: {
name: 'Korean',
nativeName: '한국어'
},
kr: {
name: 'Kanuri',
nativeName: 'Kanuri'
},
ks: {
name: 'Kashmiri',
nativeName: 'कश्मीरी'
},
ku: {
name: 'Kurdish',
nativeName: 'Kurdî'
},
kv: {
name: 'Komi',
nativeName: 'коми кыв'
},
kw: {
name: 'Cornish',
nativeName: 'Kernewek'
},
ky: {
name: 'Kyrgyz',
nativeName: 'Кыргызча'
},
la: {
name: 'Latin',
nativeName: 'latine'
},
lb: {
name: 'Luxembourgish',
nativeName: 'Lëtzebuergesch'
},
lg: {
name: 'Ganda',
nativeName: 'Luganda'
},
li: {
name: 'Limburgish',
nativeName: 'Limburgs'
},
ln: {
name: 'Lingala',
nativeName: 'Lingála'
},
lo: {
name: 'Lao',
nativeName: 'ພາສາລາວ'
},
lt: {
name: 'Lithuanian',
nativeName: 'lietuvių kalba'
},
lu: {
name: 'Luba-Katanga',
nativeName: 'Kiluba'
},
lv: {
name: 'Latvian',
nativeName: 'latviešu valoda'
},
mg: {
name: 'Malagasy',
nativeName: 'fiteny malagasy'
},
mh: {
name: 'Marshallese',
nativeName: 'Kajin M̧ajeļ'
},
mi: {
name: 'Māori',
nativeName: 'te reo Māori'
},
mk: {
name: 'Macedonian',
nativeName: 'македонски јазик'
},
ml: {
name: 'Malayalam',
nativeName: 'മലയാളം'
},
mn: {
name: 'Mongolian',
nativeName: 'Монгол хэл'
},
mr: {
name: 'Marathi',
nativeName: 'मराठी'
},
ms: {
name: 'Malay',
nativeName: 'Bahasa Melayu'
},
mt: {
name: 'Maltese',
nativeName: 'Malti'
},
my: {
name: 'Burmese',
nativeName: 'ဗမာစာ'
},
na: {
name: 'Nauru',
nativeName: 'Dorerin Naoero'
},
nb: {
name: 'Norwegian Bokmål',
nativeName: 'Norsk bokmål'
},
nd: {
name: 'Northern Ndebele',
nativeName: 'isiNdebele'
},
ne: {
name: 'Nepali',
nativeName: 'नेपाली'
},
ng: {
name: 'Ndonga',
nativeName: 'Owambo'
},
nl: {
name: 'Dutch',
nativeName: 'Nederlands'
},
nn: {
name: 'Norwegian Nynorsk',
nativeName: 'Norsk nynorsk'
},
no: {
name: 'Norwegian',
nativeName: 'Norsk'
},
nr: {
name: 'Southern Ndebele',
nativeName: 'isiNdebele'
},
nv: {
name: 'Navajo',
nativeName: 'Diné bizaad'
},
ny: {
name: 'Chichewa',
nativeName: 'chiCheŵa'
},
oc: {
name: 'Occitan',
nativeName: 'occitan'
},
oj: {
name: 'Ojibwe',
nativeName: 'ᐊᓂᔑᓈᐯᒧᐎᓐ'
},
om: {
name: 'Oromo',
nativeName: 'Afaan Oromoo'
},
or: {
name: 'Oriya',
nativeName: 'ଓଡ଼ିଆ'
},
os: {
name: 'Ossetian',
nativeName: 'ирон æвзаг'
},
pa: {
name: 'Panjabi',
nativeName: 'ਪੰਜਾਬੀ'
},
pi: {
name: 'Pāli',
nativeName: 'पाऴि'
},
pl: {
name: 'Polish',
nativeName: 'Polski'
},
ps: {
name: 'Pashto',
nativeName: 'پښتو'
},
pt: {
name: 'Portuguese',
nativeName: 'Português'
},
qu: {
name: 'Quechua',
nativeName: 'Runa Simi'
},
rm: {
name: 'Romansh',
nativeName: 'rumantsch grischun'
},
rn: {
name: 'Kirundi',
nativeName: 'Ikirundi'
},
ro: {
name: 'Romanian',
nativeName: 'Română'
},
ru: {
name: 'Russian',
nativeName: 'Русский'
},
rw: {
name: 'Kinyarwanda',
nativeName: 'Ikinyarwanda'
},
sa: {
name: 'Sanskrit',
nativeName: 'संस्कृतम्'
},
sc: {
name: 'Sardinian',
nativeName: 'sardu'
},
sd: {
name: 'Sindhi',
nativeName: 'सिन्धी'
},
se: {
name: 'Northern Sami',
nativeName: 'Davvisámegiella'
},
sg: {
name: 'Sango',
nativeName: 'yângâ tî sängö'
},
si: {
name: 'Sinhala',
nativeName: 'සිංහල'
},
sk: {
name: 'Slovak',
nativeName: 'slovenčina'
},
sl: {
name: 'Slovenian',
nativeName: 'slovenščina'
},
sm: {
name: 'Samoan',
nativeName: "gagana fa'a Samoa"
},
sn: {
name: 'Shona',
nativeName: 'chiShona'
},
so: {
name: 'Somali',
nativeName: 'Soomaaliga'
},
sq: {
name: 'Albanian',
nativeName: 'Shqip'
},
sr: {
name: 'Serbian',
nativeName: 'српски језик'
},
ss: {
name: 'Swati',
nativeName: 'SiSwati'
},
st: {
name: 'Southern Sotho',
nativeName: 'Sesotho'
},
su: {
name: 'Sundanese',
nativeName: 'Basa Sunda'
},
sv: {
name: 'Swedish',
nativeName: 'Svenska'
},
sw: {
name: 'Swahili',
nativeName: 'Kiswahili'
},
ta: {
name: 'Tamil',
nativeName: 'தமிழ்'
},
te: {
name: 'Telugu',
nativeName: 'తెలుగు'
},
tg: {
name: 'Tajik',
nativeName: 'тоҷикӣ'
},
th: {
name: 'Thai',
nativeName: 'ไทย'
},
ti: {
name: 'Tigrinya',
nativeName: 'ትግርኛ'
},
tk: {
name: 'Turkmen',
nativeName: 'Türkmençe'
},
tl: {
name: 'Tagalog',
nativeName: 'Wikang Tagalog'
},
tn: {
name: 'Tswana',
nativeName: 'Setswana'
},
to: {
name: 'Tonga',
nativeName: 'faka Tonga'
},
tr: {
name: 'Turkish',
nativeName: 'Türkçe'
},
ts: {
name: 'Tsonga',
nativeName: 'Xitsonga'
},
tt: {
name: 'Tatar',
nativeName: 'татар теле'
},
tw: {
name: 'Twi',
nativeName: 'Twi'
},
ty: {
name: 'Tahitian',
nativeName: 'Reo Tahiti'
},
ug: {
name: 'Uyghur',
nativeName: 'ئۇيغۇرچە‎'
},
uk: {
name: 'Ukrainian',
nativeName: 'Українська'
},
ur: {
name: 'Urdu',
nativeName: 'اردو'
},
uz: {
name: 'Uzbek',
nativeName: 'Ўзбек'
},
ve: {
name: 'Venda',
nativeName: 'Tshivenḓa'
},
vi: {
name: 'Vietnamese',
nativeName: 'Tiếng Việt'
},
vo: {
name: 'Volapük',
nativeName: 'Volapük'
},
wa: {
name: 'Walloon',
nativeName: 'walon'
},
wo: {
name: 'Wolof',
nativeName: 'Wollof'
},
xh: {
name: 'Xhosa',
nativeName: 'isiXhosa'
},
yi: {
name: 'Yiddish',
nativeName: 'ייִדיש'
},
yo: {
name: 'Yoruba',
nativeName: 'Yorùbá'
},
za: {
name: 'Zhuang',
nativeName: 'Saɯ cueŋƅ'
},
zh: {
name: 'Chinese',
nativeName: '中文'
},
zu: {
name: 'Zulu',
nativeName: 'isiZulu'
}
} as const;

View File

@@ -0,0 +1,247 @@
export const ISO_REGIONS = {
AF: 'Afghanistan',
AX: 'Aland Islands',
AL: 'Albania',
DZ: 'Algeria',
AS: 'American Samoa',
AD: 'Andorra',
AO: 'Angola',
AI: 'Anguilla',
AQ: 'Antarctica',
AG: 'Antigua And Barbuda',
AR: 'Argentina',
AM: 'Armenia',
AW: 'Aruba',
AU: 'Australia',
AT: 'Austria',
AZ: 'Azerbaijan',
BS: 'Bahamas',
BH: 'Bahrain',
BD: 'Bangladesh',
BB: 'Barbados',
BY: 'Belarus',
BE: 'Belgium',
BZ: 'Belize',
BJ: 'Benin',
BM: 'Bermuda',
BT: 'Bhutan',
BO: 'Bolivia',
BA: 'Bosnia And Herzegovina',
BW: 'Botswana',
BV: 'Bouvet Island',
BR: 'Brazil',
IO: 'British Indian Ocean Territory',
BN: 'Brunei Darussalam',
BG: 'Bulgaria',
BF: 'Burkina Faso',
BI: 'Burundi',
KH: 'Cambodia',
CM: 'Cameroon',
CA: 'Canada',
CV: 'Cape Verde',
KY: 'Cayman Islands',
CF: 'Central African Republic',
TD: 'Chad',
CL: 'Chile',
CN: 'China',
CX: 'Christmas Island',
CC: 'Cocos (Keeling) Islands',
CO: 'Colombia',
KM: 'Comoros',
CG: 'Congo',
CD: 'Congo, Democratic Republic',
CK: 'Cook Islands',
CR: 'Costa Rica',
CI: "Cote D'Ivoire",
HR: 'Croatia',
CU: 'Cuba',
CY: 'Cyprus',
CZ: 'Czech Republic',
DK: 'Denmark',
DJ: 'Djibouti',
DM: 'Dominica',
DO: 'Dominican Republic',
EC: 'Ecuador',
EG: 'Egypt',
SV: 'El Salvador',
GQ: 'Equatorial Guinea',
ER: 'Eritrea',
EE: 'Estonia',
ET: 'Ethiopia',
FK: 'Falkland Islands (Malvinas)',
FO: 'Faroe Islands',
FJ: 'Fiji',
FI: 'Finland',
FR: 'France',
GF: 'French Guiana',
PF: 'French Polynesia',
TF: 'French Southern Territories',
GA: 'Gabon',
GM: 'Gambia',
GE: 'Georgia',
DE: 'Germany',
GH: 'Ghana',
GI: 'Gibraltar',
GR: 'Greece',
GL: 'Greenland',
GD: 'Grenada',
GP: 'Guadeloupe',
GU: 'Guam',
GT: 'Guatemala',
GG: 'Guernsey',
GN: 'Guinea',
GW: 'Guinea-Bissau',
GY: 'Guyana',
HT: 'Haiti',
HM: 'Heard Island & Mcdonald Islands',
VA: 'Holy See (Vatican City State)',
HN: 'Honduras',
HK: 'Hong Kong',
HU: 'Hungary',
IS: 'Iceland',
IN: 'India',
ID: 'Indonesia',
IR: 'Iran, Islamic Republic Of',
IQ: 'Iraq',
IE: 'Ireland',
IM: 'Isle Of Man',
IL: 'Israel',
IT: 'Italy',
JM: 'Jamaica',
JP: 'Japan',
JE: 'Jersey',
JO: 'Jordan',
KZ: 'Kazakhstan',
KE: 'Kenya',
KI: 'Kiribati',
KR: 'Korea',
KW: 'Kuwait',
KG: 'Kyrgyzstan',
LA: "Lao People's Democratic Republic",
LV: 'Latvia',
LB: 'Lebanon',
LS: 'Lesotho',
LR: 'Liberia',
LY: 'Libyan Arab Jamahiriya',
LI: 'Liechtenstein',
LT: 'Lithuania',
LU: 'Luxembourg',
MO: 'Macao',
MK: 'Macedonia',
MG: 'Madagascar',
MW: 'Malawi',
MY: 'Malaysia',
MV: 'Maldives',
ML: 'Mali',
MT: 'Malta',
MH: 'Marshall Islands',
MQ: 'Martinique',
MR: 'Mauritania',
MU: 'Mauritius',
YT: 'Mayotte',
MX: 'Mexico',
FM: 'Micronesia, Federated States Of',
MD: 'Moldova',
MC: 'Monaco',
MN: 'Mongolia',
ME: 'Montenegro',
MS: 'Montserrat',
MA: 'Morocco',
MZ: 'Mozambique',
MM: 'Myanmar',
NA: 'Namibia',
NR: 'Nauru',
NP: 'Nepal',
NL: 'Netherlands',
AN: 'Netherlands Antilles',
NC: 'New Caledonia',
NZ: 'New Zealand',
NI: 'Nicaragua',
NE: 'Niger',
NG: 'Nigeria',
NU: 'Niue',
NF: 'Norfolk Island',
MP: 'Northern Mariana Islands',
NO: 'Norway',
OM: 'Oman',
PK: 'Pakistan',
PW: 'Palau',
PS: 'Palestinian Territory, Occupied',
PA: 'Panama',
PG: 'Papua New Guinea',
PY: 'Paraguay',
PE: 'Peru',
PH: 'Philippines',
PN: 'Pitcairn',
PL: 'Poland',
PT: 'Portugal',
PR: 'Puerto Rico',
QA: 'Qatar',
RE: 'Reunion',
RO: 'Romania',
RU: 'Russian Federation',
RW: 'Rwanda',
BL: 'Saint Barthelemy',
SH: 'Saint Helena',
KN: 'Saint Kitts And Nevis',
LC: 'Saint Lucia',
MF: 'Saint Martin',
PM: 'Saint Pierre And Miquelon',
VC: 'Saint Vincent And Grenadines',
WS: 'Samoa',
SM: 'San Marino',
ST: 'Sao Tome And Principe',
SA: 'Saudi Arabia',
SN: 'Senegal',
RS: 'Serbia',
SC: 'Seychelles',
SL: 'Sierra Leone',
SG: 'Singapore',
SK: 'Slovakia',
SI: 'Slovenia',
SB: 'Solomon Islands',
SO: 'Somalia',
ZA: 'South Africa',
GS: 'South Georgia And Sandwich Isl.',
ES: 'Spain',
LK: 'Sri Lanka',
SD: 'Sudan',
SR: 'Suriname',
SJ: 'Svalbard And Jan Mayen',
SZ: 'Swaziland',
SE: 'Sweden',
CH: 'Switzerland',
SY: 'Syrian Arab Republic',
TW: 'Taiwan',
TJ: 'Tajikistan',
TZ: 'Tanzania',
TH: 'Thailand',
TL: 'Timor-Leste',
TG: 'Togo',
TK: 'Tokelau',
TO: 'Tonga',
TT: 'Trinidad And Tobago',
TN: 'Tunisia',
TR: 'Turkey',
TM: 'Turkmenistan',
TC: 'Turks And Caicos Islands',
TV: 'Tuvalu',
UG: 'Uganda',
UA: 'Ukraine',
AE: 'United Arab Emirates',
GB: 'United Kingdom',
US: 'United States',
UM: 'United States Outlying Islands',
UY: 'Uruguay',
UZ: 'Uzbekistan',
VU: 'Vanuatu',
VE: 'Venezuela',
VN: 'Viet Nam',
VG: 'Virgin Islands, British',
VI: 'Virgin Islands, U.S.',
WF: 'Wallis And Futuna',
EH: 'Western Sahara',
YE: 'Yemen',
ZM: 'Zambia',
ZW: 'Zimbabwe'
} as const;

View File

@@ -0,0 +1,10 @@
import { Settings } from '$lib/entities/Settings';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async () => {
const settings = await Settings.get();
return {
settings
};
};

View File

@@ -3,10 +3,13 @@
import DynamicModal from '$lib/components/Modal/DynamicModal.svelte';
import Navbar from '$lib/components/Navbar/Navbar.svelte';
import UpdateChecker from '$lib/components/UpdateChecker.svelte';
import { type SettingsValues, defaultSettings, settings } from '$lib/stores/settings.store';
import { writable } from 'svelte/store';
import '../app.css';
import type { LayoutData } from './$types';
import type { LayoutServerData } from './$types';
export let data: LayoutData;
export let data: LayoutServerData;
settings.set(data.settings);
</script>
<!-- {#if data.isApplicationSetUp} -->

View File

@@ -1,12 +1,3 @@
import {
JELLYFIN_API_KEY,
JELLYFIN_BASE_URL,
RADARR_API_KEY,
RADARR_BASE_URL,
SONARR_API_KEY,
SONARR_BASE_URL
} from '$lib/constants';
import type { LayoutLoad } from './$types';
// import { dev } from '$app/environment';
// Disable SSR when running the dev server
@@ -14,34 +5,3 @@ import type { LayoutLoad } from './$types';
// https://github.com/vitejs/vite/issues/11468
// export const ssr = !dev;
export const ssr = false;
export type MissingEnvironmentVariables = {
PUBLIC_RADARR_API_KEY: boolean;
PUBLIC_RADARR_BASE_URL: boolean;
PUBLIC_SONARR_API_KEY: boolean;
PUBLIC_SONARR_BASE_URL: boolean;
PUBLIC_JELLYFIN_API_KEY: boolean;
PUBLIC_JELLYFIN_URL: boolean;
};
export const load = (async () => {
const isApplicationSetUp =
!!RADARR_API_KEY &&
!!RADARR_BASE_URL &&
!!SONARR_API_KEY &&
!!SONARR_BASE_URL &&
!!JELLYFIN_API_KEY &&
!!JELLYFIN_BASE_URL;
return {
isApplicationSetUp,
missingEnvironmentVariables: {
PUBLIC_RADARR_API_KEY: !RADARR_API_KEY,
PUBLIC_RADARR_BASE_URL: !RADARR_BASE_URL,
PUBLIC_SONARR_API_KEY: !SONARR_API_KEY,
PUBLIC_SONARR_BASE_URL: !SONARR_BASE_URL,
PUBLIC_JELLYFIN_API_KEY: !JELLYFIN_API_KEY,
PUBLIC_JELLYFIN_URL: !JELLYFIN_BASE_URL
}
};
}) satisfies LayoutLoad;

View File

@@ -0,0 +1,11 @@
import { Settings } from '$lib/entities/Settings';
import { json, type RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ url }) => {
return json(await Settings.get());
};
export const POST: RequestHandler = async ({ request }) => {
const values = await request.json();
return json(await Settings.set('default', values));
};

View File

@@ -1,13 +1,5 @@
<script lang="ts">
import {
TmdbApiOpen,
getTmdbDigitalReleases,
getTmdbTrendingAll,
getTmdbUpcomingMovies,
getTrendingActors,
type TmdbMovie2,
type TmdbSeries2
} from '$lib/apis/tmdb/tmdbApi';
import { TmdbApiOpen, type TmdbMovie2, type TmdbSeries2 } from '$lib/apis/tmdb/tmdbApi';
import Card from '$lib/components/Card/Card.svelte';
import { fetchCardTmdbProps } from '$lib/components/Card/card';
import Carousel from '$lib/components/Carousel/Carousel.svelte';
@@ -17,42 +9,32 @@
import PeopleCard from '$lib/components/PeopleCard/PeopleCard.svelte';
import { genres, networks } from '$lib/discover';
import { library } from '$lib/stores/library.store';
import { getIncludedLanguagesQuery, settings } from '$lib/stores/settings.store';
import { settings } from '$lib/stores/settings.store';
import { formatDateToYearMonthDay } from '$lib/utils';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
function parseIncludedLanguages(includedLanguages: string) {
return includedLanguages.replace(' ', '').split(',').join('|');
}
const fetchCardProps = async (items: TmdbMovie2[] | TmdbSeries2[]) =>
Promise.all(
(
await ($settings.excludeLibraryItemsFromDiscovery
await ($settings.discover.excludeLibraryItems
? library.filterNotInLibrary(items, (t) => t.id || 0)
: items)
).map(fetchCardTmdbProps)
).then((props) => props.filter((p) => p.backdropUrl));
const fetchTrendingProps = () => getTmdbTrendingAll().then(fetchCardProps);
const fetchDigitalReleases = () => getTmdbDigitalReleases().then(fetchCardProps);
const fetchNowStreaming = () =>
TmdbApiOpen.get('/3/discover/tv', {
const fetchTrendingProps = () =>
TmdbApiOpen.get('/3/trending/all/{time_window}', {
params: {
path: {
time_window: 'day'
},
query: {
'air_date.gte': formatDateToYearMonthDay(new Date()),
'first_air_date.lte': formatDateToYearMonthDay(new Date()),
sort_by: 'popularity.desc',
...getIncludedLanguagesQuery()
}
}
})
.then((res) => res.data?.results || [])
.then(fetchCardProps);
const fetchUpcomingMovies = () => getTmdbUpcomingMovies().then(fetchCardProps);
const fetchUpcomingSeries = () =>
TmdbApiOpen.get('/3/discover/tv', {
params: {
query: {
'first_air_date.gte': formatDateToYearMonthDay(new Date()),
sort_by: 'popularity.desc',
...getIncludedLanguagesQuery()
language: $settings.language
}
}
})
@@ -60,19 +42,94 @@
.then(fetchCardProps);
const fetchTrendingActorProps = () =>
getTrendingActors().then((actors) =>
actors
.filter((a) => a.profile_path)
.map((actor) => ({
tmdbId: actor.id || 0,
backdropUri: actor.profile_path || '',
name: actor.name || '',
subtitle: actor.known_for_department || ''
}))
);
TmdbApiOpen.get('/3/trending/person/{time_window}', {
params: {
path: {
time_window: 'week'
}
}
})
.then((res) => res.data?.results || [])
.then((actors) =>
actors
.filter((a) => a.profile_path)
.map((actor) => ({
tmdbId: actor.id || 0,
backdropUri: actor.profile_path || '',
name: actor.name || '',
subtitle: actor.known_for_department || ''
}))
);
const fetchUpcomingMovies = () =>
TmdbApiOpen.get('/3/discover/movie', {
params: {
query: {
'primary_release_date.gte': formatDateToYearMonthDay(new Date()),
sort_by: 'popularity.desc',
language: $settings.language,
region: $settings.discover.region,
with_original_language: parseIncludedLanguages($settings.discover.includedLanguages)
}
}
})
.then((res) => res.data?.results || [])
.then(fetchCardProps);
const fetchUpcomingSeries = () =>
TmdbApiOpen.get('/3/discover/tv', {
params: {
query: {
'first_air_date.gte': formatDateToYearMonthDay(new Date()),
sort_by: 'popularity.desc',
language: $settings.language,
with_original_language: parseIncludedLanguages($settings.discover.includedLanguages)
}
}
})
.then((res) => res.data?.results || [])
.then(fetchCardProps);
const fetchDigitalReleases = () =>
TmdbApiOpen.get('/3/discover/movie', {
params: {
query: {
with_release_type: 4,
sort_by: 'popularity.desc',
'release_date.lte': formatDateToYearMonthDay(new Date()),
language: $settings.language,
with_original_language: parseIncludedLanguages($settings.discover.includedLanguages)
// region: $settings.discover.region
}
}
})
.then((res) => res.data?.results || [])
.then(fetchCardProps);
const fetchNowStreaming = () =>
TmdbApiOpen.get('/3/discover/tv', {
params: {
query: {
'air_date.gte': formatDateToYearMonthDay(new Date()),
'first_air_date.lte': formatDateToYearMonthDay(new Date()),
sort_by: 'popularity.desc',
language: $settings.language,
with_original_language: parseIncludedLanguages($settings.discover.includedLanguages)
}
}
})
.then((res) => res.data?.results || [])
.then(fetchCardProps);
</script>
<div class="pt-24 bg-stone-950 pb-8">
<div
class="pt-24 bg-stone-950 pb-8"
in:fade|global={{
duration: $settings.animationDuration,
delay: $settings.animationDuration
}}
out:fade|global={{ duration: $settings.animationDuration }}
>
<div class="max-w-screen-2xl mx-auto">
<Carousel gradientFromColor="from-stone-950" heading="Trending" class="mx-2 sm:mx-8 2xl:mx-0">
{#await fetchTrendingProps()}

View File

@@ -185,23 +185,33 @@
<svelte:window on:keydown={handleShortcuts} />
{#if noItems}
<div class="h-screen flex items-center justify-center text-zinc-500 p-8">
<h1>Configure Radarr, Sonarr and Jellyfin to view and manage your library.</h1>
</div>
{:else}
<div
style={"background-image: url('" +
TMDB_IMAGES_ORIGINAL +
(downloadingProps[0]?.backdropUrl || nextUpProps[0]?.backdropUrl) +
"');"}
class="absolute inset-0 h-[50vh] bg-center bg-cover"
class="h-screen flex items-center justify-center text-zinc-500 p-8"
in:fade|global={{
duration: $settings.animationDuration,
delay: $settings.animationDuration
}}
out:fade|global={{ duration: $settings.animationDuration }}
>
<div class="absolute inset-0 bg-gradient-to-t from-stone-950 to-80% to-darken" />
<h1>Configure Radarr, Sonarr and Jellyfin to view and manage your library.</h1>
</div>
{:else}
<div
in:fade|global={{
duration: $settings.animationDuration,
delay: $settings.animationDuration
}}
out:fade|global={{ duration: $settings.animationDuration }}
>
<div
style={"background-image: url('" +
TMDB_IMAGES_ORIGINAL +
(downloadingProps[0]?.backdropUrl || nextUpProps[0]?.backdropUrl) +
"');"}
class="absolute inset-0 h-[50vh] bg-center bg-cover"
>
<div class="absolute inset-0 bg-gradient-to-t from-stone-950 to-80% to-darken" />
</div>
</div>
<div

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { addMovieToRadarr, radarrAvailable } from '$lib/apis/radarr/radarrApi';
import { addMovieToRadarr } from '$lib/apis/radarr/radarrApi';
import {
getTmdbMovie,
getTmdbMovieRecommendations,
@@ -9,7 +9,6 @@
import Card from '$lib/components/Card/Card.svelte';
import { fetchCardTmdbProps } from '$lib/components/Card/card';
import CarouselPlaceholderItems from '$lib/components/Carousel/CarouselPlaceholderItems.svelte';
import ContextMenuButton from '$lib/components/ContextMenu/ContextMenuButton.svelte';
import { modalStack } from '$lib/components/Modal/Modal';
import PeopleCard from '$lib/components/PeopleCard/PeopleCard.svelte';
import ProgressBar from '$lib/components/ProgressBar.svelte';
@@ -18,6 +17,7 @@
import TitlePageLayout from '$lib/components/TitlePageLayout/TitlePageLayout.svelte';
import { playerState } from '$lib/components/VideoPlayer/VideoPlayer';
import { createLibraryItemStore, library } from '$lib/stores/library.store';
import { settings } from '$lib/stores/settings.store';
import { formatMinutesToTime, formatSize } from '$lib/utils';
import classNames from 'classnames';
import { Archive, ChevronRight, Plus } from 'radix-icons-svelte';
@@ -125,7 +125,7 @@
<Button type="primary" on:click={play}>
<span>Watch</span><ChevronRight size={20} />
</Button>
{:else if !$itemStore.item?.radarrMovie && radarrAvailable}
{:else if !$itemStore.item?.radarrMovie && $settings.radarr.baseUrl && $settings.radarr.apiKey}
<Button type="primary" disabled={addToRadarrLoading} on:click={addToRadarr}>
<span>Add to Radarr</span><Plus size={20} />
</Button>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
import { addSeriesToSonarr, sonarrAvailable } from '$lib/apis/sonarr/sonarrApi';
import { addSeriesToSonarr } from '$lib/apis/sonarr/sonarrApi';
import {
getTmdbSeries,
getTmdbSeriesRecommendations,
@@ -21,6 +21,7 @@
import TitlePageLayout from '$lib/components/TitlePageLayout/TitlePageLayout.svelte';
import { playerState } from '$lib/components/VideoPlayer/VideoPlayer';
import { createLibraryItemStore, library } from '$lib/stores/library.store';
import { settings } from '$lib/stores/settings.store';
import { capitalize, formatMinutesToTime, formatSize } from '$lib/utils';
import classNames from 'classnames';
import { Archive, ChevronLeft, ChevronRight, Plus } from 'radix-icons-svelte';
@@ -187,7 +188,7 @@
</span>
<ChevronRight size={20} />
</Button>
{:else if !$itemStore.item?.sonarrSeries && sonarrAvailable}
{:else if !$itemStore.item?.sonarrSeries && $settings.sonarr.apiKey && $settings.sonarr.baseUrl}
<Button type="primary" disabled={addToSonarrLoading} on:click={addToSonarr}>
<span>Add to Sonarr</span><Plus size={20} />
</Button>

View File

@@ -1,16 +1,234 @@
<script>
<script lang="ts">
import { version } from '$app/environment';
import { beforeNavigate } from '$app/navigation';
import { getJellyfinHealth, getJellyfinUsers } from '$lib/apis/jellyfin/jellyfinApi';
import { getRadarrHealth } from '$lib/apis/radarr/radarrApi';
import { getSonarrHealth } from '$lib/apis/sonarr/sonarrApi';
import FormButton from '$lib/components/forms/FormButton.svelte';
import Select from '$lib/components/forms/Select.svelte';
import { settings, type SettingsValues } from '$lib/stores/settings.store';
import axios from 'axios';
import classNames from 'classnames';
import { ChevronLeft } from 'radix-icons-svelte';
import GeneralSettingsPage from './GeneralSettingsPage.svelte';
import IntegrationSettingsPage from './IntegrationSettingsPage.svelte';
import { fade } from 'svelte/transition';
type Section = 'general' | 'integrations';
let openTab: Section = 'general';
let sonarrConnected = false;
let radarrConnected = false;
let jellyfinConnected = false;
let values: SettingsValues;
let initialValues: SettingsValues;
settings.subscribe((v) => {
values = structuredClone(v);
initialValues = structuredClone(v);
if (values.sonarr.baseUrl && values.sonarr.apiKey) checkSonarrHealth();
if (values.radarr.baseUrl && values.radarr.apiKey) checkRadarrHealth();
if (values.jellyfin.baseUrl && values.jellyfin.apiKey) checkJellyfinHealth();
});
let valuesChanged = false;
$: valuesChanged = JSON.stringify(initialValues) !== JSON.stringify(values);
let submitLoading = false;
function handleSubmit() {
if (submitLoading || !valuesChanged) return;
submitLoading = true;
submit().finally(() => (submitLoading = false));
}
async function submit() {
if (
values.sonarr.apiKey &&
values.sonarr.baseUrl &&
!(await getSonarrHealth(values.sonarr.baseUrl, values.sonarr.apiKey))
) {
throw new Error('Could not connect to Sonarr');
}
if (
values.radarr.apiKey &&
values.radarr.baseUrl &&
!(await getRadarrHealth(values.radarr.baseUrl, values.radarr.apiKey))
) {
throw new Error('Could not connect to Radarr');
}
if (values.jellyfin.apiKey && values.jellyfin.baseUrl) {
if (!(await getJellyfinHealth(values.jellyfin.baseUrl, values.jellyfin.apiKey)))
throw new Error('Could not connect to Jellyfin');
const users = await getJellyfinUsers(values.jellyfin.baseUrl, values.jellyfin.apiKey);
if (!users.find((u) => u.Id === values.jellyfin.userId)) values.jellyfin.userId = null;
}
checkSonarrHealth();
checkRadarrHealth();
checkJellyfinHealth();
axios.post('/api/settings', values).then(() => {
settings.set(values);
});
}
async function checkSonarrHealth(): Promise<boolean> {
if (!values.sonarr.baseUrl || !values.sonarr.apiKey) {
sonarrConnected = false;
return false;
}
return getSonarrHealth(
values.sonarr.baseUrl || undefined,
values.sonarr.apiKey || undefined
).then((ok) => {
sonarrConnected = ok;
return ok;
});
}
async function checkRadarrHealth(): Promise<boolean> {
if (!values.radarr.baseUrl || !values.radarr.apiKey) {
radarrConnected = false;
return false;
}
return getRadarrHealth(
values.radarr.baseUrl || undefined,
values.radarr.apiKey || undefined
).then((ok) => {
radarrConnected = ok;
return ok;
});
}
async function checkJellyfinHealth(): Promise<boolean> {
if (!values.jellyfin.baseUrl || !values.jellyfin.apiKey) {
jellyfinConnected = false;
return false;
}
return getJellyfinHealth(
values.jellyfin.baseUrl || undefined,
values.jellyfin.apiKey || undefined
).then((ok) => {
jellyfinConnected = ok;
return ok;
});
}
const getNavButtonStyle = (section: Section) =>
classNames('rounded-xl p-2 px-6 font-medium text-left', {
'text-zinc-200 bg-lighten': openTab === section,
'text-zinc-300 hover:text-zinc-200': openTab !== section
});
beforeNavigate(({ cancel }) => {
if (valuesChanged) {
if (!confirm('You have unsaved changes. Are you sure you want to leave?')) cancel();
}
});
function handleKeybinds(event: KeyboardEvent) {
if (event.key === 's' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
handleSubmit();
}
}
</script>
<div class="h-screen flex flex-col items-center justify-center text-zinc-500">
<div class="flex-1 flex items-center justify-center max-w-xl text-center m-8">
This is the settings page. It's quite empty here. If you'd like to help populate it, or any
other part of the project, head over to the project GitHub page.
<svelte:window on:keydown={handleKeybinds} />
<div
class="min-h-screen sm:h-screen flex-1 flex flex-col sm:flex-row w-full sm:pt-24"
in:fade|global={{
duration: $settings.animationDuration,
delay: $settings.animationDuration
}}
out:fade|global={{ duration: $settings.animationDuration }}
>
<div
class="hidden sm:flex flex-col gap-2 border-r border-zinc-800 justify-between w-64 p-8 border-t"
>
<div class="flex flex-col gap-2">
<button
class="mb-6 text-lg font-medium flex items-center text-zinc-300 hover:text-zinc-200"
on:click={() => history.back()}
>
<ChevronLeft size={22} /> Settings
</button>
<button
on:click={() => (openTab = 'general')}
class={openTab && getNavButtonStyle('general')}
>
General
</button>
<button
on:click={() => (openTab = 'integrations')}
class={openTab && getNavButtonStyle('integrations')}
>
Integrations
</button>
</div>
<div class="flex flex-col gap-2">
<FormButton
disabled={!valuesChanged}
loading={submitLoading}
on:click={handleSubmit}
type={valuesChanged ? 'success' : 'base'}
>
Save Changes
</FormButton>
<FormButton
disabled={!valuesChanged}
type="error"
on:click={() => {
settings.set(initialValues);
}}
>
Reset to Defaults
</FormButton>
</div>
</div>
<div class="flex items-center p-8 gap-8">
<div>v{version}</div>
<a target="_blank" href="https://github.com/aleksilassila/reiverr/releases">Changelog</a>
<a target="_blank" href="https://github.com/aleksilassila/reiverr">GitHub</a>
<div class="sm:hidden px-8 pt-20 pb-4 flex items-center justify-between">
<button
class="text-lg font-medium flex items-center text-zinc-300 hover:text-zinc-200"
on:click={() => history.back()}
>
<ChevronLeft size={22} /> Settings
</button>
<Select bind:value={openTab}>
<option value="general">General</option>
<option value="integrations">Integrations</option>
</Select>
</div>
<div class="flex-1 flex flex-col border-t border-zinc-800 justify-between">
<div class="overflow-y-scroll overflow-x-hidden px-8">
<div class="max-w-screen-md mx-auto mb-auto w-full">
{#if openTab === 'general'}
<GeneralSettingsPage bind:values />
{/if}
{#if openTab === 'integrations'}
<IntegrationSettingsPage
bind:values
{sonarrConnected}
{radarrConnected}
{jellyfinConnected}
{checkSonarrHealth}
{checkRadarrHealth}
{checkJellyfinHealth}
/>
{/if}
</div>
</div>
<div class="flex items-center p-4 gap-8 justify-center text-zinc-500 bg-stone-950">
<div>v{version}</div>
<a target="_blank" href="https://github.com/aleksilassila/reiverr/releases">Changelog</a>
<a target="_blank" href="https://github.com/aleksilassila/reiverr">GitHub</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import Input from '$lib/components/forms/Input.svelte';
import Select from '$lib/components/forms/Select.svelte';
import Toggle from '$lib/components/forms/Toggle.svelte';
import type { SettingsValues } from '$lib/stores/settings.store';
import { ISO_LANGUAGES } from '$lib/utils/iso-languages';
import { ISO_REGIONS } from '$lib/utils/iso-regions';
export let values: SettingsValues;
</script>
<div
class="grid grid-cols-[1fr_min-content] justify-items-start place-items-center gap-4 text-zinc-400"
>
<h1
class="font-medium text-xl text-zinc-200 tracking-wide col-span-2 border-b border-zinc-800 justify-self-stretch pb-2 mt-8"
>
User Interface
</h1>
<h2>Language</h2>
<Select bind:value={values.language}>
{#each Object.entries(ISO_LANGUAGES) as [code, lang]}
<option value={code}>{lang.name}</option>
{/each}
</Select>
<h2>Autoplay Trailers</h2>
<Toggle bind:checked={values.autoplayTrailers} />
<h2>Animation Duration</h2>
<Input type="number" bind:value={values.animationDuration} />
<h1
class="font-medium text-xl text-zinc-200 tracking-wide col-span-2 border-b border-zinc-800 justify-self-stretch pb-2 mt-8"
>
Discovery
</h1>
<h2>Region</h2>
<Select bind:value={values.discover.region}>
<option value="">None</option>
{#each Object.entries(ISO_REGIONS) as [code, region]}
<option value={code}>{region}</option>
{/each}
</Select>
<h2>Exclude library items from Discovery</h2>
<Toggle bind:checked={values.discover.excludeLibraryItems} />
<div>
<h2>Included languages</h2>
<p class="text-sm text-zinc-500 mt-1">
Filter results based on spoken language. Takes ISO 639-1 language codes separated with commas.
Leave empty to disable.
</p>
</div>
<Input bind:value={values.discover.includedLanguages} placeholder={'en,fr,de'} />
</div>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import classNames from 'classnames';
export let title: string;
export let href = '#';
export let status: 'connected' | 'disconnected' | 'error' = 'disconnected';
</script>
<div
class={classNames('border border-zinc-800 rounded-xl p-4 flex flex-col gap-4', {
// 'border-zinc-800': status === 'connected'
// 'border-zinc-800': status === 'disconnected'
})}
>
<div class="flex items-baseline justify-between">
<a class="text-zinc-200 text-xl font-medium" target="_blank" {href}>{title}</a>
<div
class={classNames('w-3 h-3 rounded-full', {
'bg-green-600': status === 'connected',
'bg-zinc-600': status === 'disconnected',
'bg-amber-500': status === 'error'
})}
/>
</div>
<slot />
</div>

View File

@@ -0,0 +1,295 @@
<script lang="ts">
import { getJellyfinUsers } from '$lib/apis/jellyfin/jellyfinApi';
import {
getSonarrLanguageProfiles,
getSonarrQualityProfiles,
getSonarrRootFolders
} from '$lib/apis/sonarr/sonarrApi';
import FormButton from '$lib/components/forms/FormButton.svelte';
import Input from '$lib/components/forms/Input.svelte';
import Select from '$lib/components/forms/Select.svelte';
import { settings, type SettingsValues } from '$lib/stores/settings.store';
import classNames from 'classnames';
import { Trash } from 'radix-icons-svelte';
import IntegrationCard from './IntegrationCard.svelte';
import TestConnectionButton from './TestConnectionButton.svelte';
import { getRadarrQualityProfiles, getRadarrRootFolders } from '$lib/apis/radarr/radarrApi';
export let values: SettingsValues;
export let sonarrConnected: boolean;
export let radarrConnected: boolean;
export let jellyfinConnected: boolean;
export let checkSonarrHealth: () => Promise<boolean>;
export let checkRadarrHealth: () => Promise<boolean>;
export let checkJellyfinHealth: () => Promise<boolean>;
let sonarrRootFolders: undefined | { id: number; path: string }[] = undefined;
let sonarrQualityProfiles: undefined | { id: number; name: string }[] = undefined;
let sonarrLanguageProfiles: undefined | { id: number; name: string }[] = undefined;
let radarrRootFolders: undefined | { id: number; path: string }[] = undefined;
let radarrQualityProfiles: undefined | { id: number; name: string }[] = undefined;
let jellyfinUsers: undefined | { id: string; name: string }[] = undefined;
function handleRemoveIntegration(service: 'sonarr' | 'radarr' | 'jellyfin') {
if (service === 'sonarr') {
values.sonarr.baseUrl = '';
values.sonarr.apiKey = '';
checkSonarrHealth();
} else if (service === 'radarr') {
values.radarr.baseUrl = '';
values.radarr.apiKey = '';
checkRadarrHealth();
} else if (service === 'jellyfin') {
values.jellyfin.baseUrl = '';
values.jellyfin.apiKey = '';
values.jellyfin.userId = '';
checkJellyfinHealth();
}
}
$: {
if (sonarrConnected) {
getSonarrRootFolders(
values.sonarr.baseUrl || undefined,
values.sonarr.apiKey || undefined
).then((folders) => {
sonarrRootFolders = folders.map((f) => ({ id: f.id || 0, path: f.path || '' }));
});
getSonarrQualityProfiles(
values.sonarr.baseUrl || undefined,
values.sonarr.apiKey || undefined
).then((profiles) => {
sonarrQualityProfiles = profiles.map((p) => ({ id: p.id || 0, name: p.name || '' }));
});
getSonarrLanguageProfiles(
values.sonarr.baseUrl || undefined,
values.sonarr.apiKey || undefined
).then((profiles) => {
sonarrLanguageProfiles = profiles.map((p) => ({ id: p.id || 0, name: p.name || '' }));
});
}
}
$: {
if (radarrConnected) {
getRadarrRootFolders(
values.radarr.baseUrl || undefined,
values.radarr.apiKey || undefined
).then((folders) => {
radarrRootFolders = folders.map((f) => ({ id: f.id || 0, path: f.path || '' }));
});
getRadarrQualityProfiles(
values.radarr.baseUrl || undefined,
values.radarr.apiKey || undefined
).then((profiles) => {
radarrQualityProfiles = profiles.map((p) => ({ id: p.id || 0, name: p.name || '' }));
});
}
}
$: {
if (jellyfinConnected) {
getJellyfinUsers(
values.jellyfin.baseUrl || undefined,
values.jellyfin.apiKey || undefined
).then((users) => {
jellyfinUsers = users.map((u) => ({ id: u.Id || '', name: u.Name || '' }));
});
}
}
</script>
<div class="grid grid-cols-2 gap-4">
<div
class="border-b border-zinc-800 pb-4 mt-8 col-span-2 justify-self-stretch flex flex-col gap-2"
>
<h1 class="font-medium text-2xl text-zinc-200 tracking-wide">Integrations</h1>
<p class="text-sm text-zinc-400">
Note: Base urls must be accessible from the browser, meaning that internal docker addresses
won't work, for example. API Keys <span class="font-medium underline">will be exposed</span> to
the browser.
</p>
</div>
<div class="justify-self-stretch col-span-2">
<IntegrationCard
title="Sonarr"
href={$settings.sonarr.baseUrl || '#'}
status={sonarrConnected ? 'connected' : 'disconnected'}
>
<div class="flex flex-col gap-1">
<h2 class="text-sm text-zinc-500">Base URL</h2>
<Input
placeholder={'http://127.0.0.1:8989'}
class="w-full"
bind:value={values.sonarr.baseUrl}
/>
</div>
<div class="flex flex-col gap-1">
<h2 class="text-sm text-zinc-500">API Key</h2>
<Input class="w-full" bind:value={values.sonarr.apiKey} />
</div>
<div class="grid grid-cols-[1fr_min-content] gap-2">
<TestConnectionButton handleHealthCheck={checkSonarrHealth} />
<FormButton on:click={() => handleRemoveIntegration('sonarr')} type="error">
<Trash size={20} />
</FormButton>
</div>
<h1 class="border-b border-zinc-800 py-2">Options</h1>
<div
class={classNames(
'grid grid-cols-[1fr_min-content] justify-items-start gap-4 text-zinc-400',
{
'opacity-50 pointer-events-none': !sonarrConnected
}
)}
>
<h2>Root Folder</h2>
{#if !sonarrRootFolders}
<Select loading />
{:else}
<Select bind:value={values.sonarr.rootFolderPath}>
{#each sonarrRootFolders as folder}
<option value={folder.path}>{folder.path}</option>
{/each}
</Select>
{/if}
<h2>Quality Profile</h2>
{#if !sonarrQualityProfiles}
<Select loading />
{:else}
<Select bind:value={values.sonarr.qualityProfileId}>
{#each sonarrQualityProfiles as profile}
<option value={profile.id}>{profile.name}</option>
{/each}
</Select>
{/if}
<h2>Language Profile</h2>
{#if !sonarrLanguageProfiles}
<Select loading />
{:else}
<Select bind:value={values.sonarr.languageProfileId}>
{#each sonarrLanguageProfiles as profile}
<option value={profile.id}>{profile.name}</option>
{/each}
</Select>
{/if}
</div>
</IntegrationCard>
</div>
<div class="justify-self-stretch col-span-2">
<IntegrationCard
title="Radarr"
href={$settings.radarr.baseUrl || '#'}
status={radarrConnected ? 'connected' : 'disconnected'}
>
<div class="flex flex-col gap-1">
<h2 class="text-sm text-zinc-500">Base URL</h2>
<Input
placeholder={'http://127.0.0.1:7878'}
class="w-full"
bind:value={values.radarr.baseUrl}
/>
</div>
<div class="flex flex-col gap-1">
<h2 class="text-sm text-zinc-500">API Key</h2>
<Input class="w-full" bind:value={values.radarr.apiKey} />
</div>
<div class="grid grid-cols-[1fr_min-content] gap-2">
<TestConnectionButton handleHealthCheck={checkRadarrHealth} />
<FormButton on:click={() => handleRemoveIntegration('radarr')} type="error">
<Trash size={20} />
</FormButton>
</div>
<h1 class="border-b border-zinc-800 py-2">Options</h1>
<div
class={classNames(
'grid grid-cols-[1fr_min-content] justify-items-start gap-4 text-zinc-400',
{
'opacity-50 pointer-events-none': !radarrConnected
}
)}
>
<h2>Root Folder</h2>
{#if !radarrRootFolders}
<Select loading />
{:else}
<Select bind:value={values.radarr.rootFolderPath}>
{#each radarrRootFolders as folder}
<option value={folder.path}>{folder.path}</option>
{/each}
</Select>
{/if}
<h2>Quality Profile</h2>
{#if !radarrQualityProfiles}
<Select loading />
{:else}
<Select bind:value={values.radarr.qualityProfileId}>
{#each radarrQualityProfiles as profile}
<option value={profile.id}>{profile.name}</option>
{/each}
</Select>
{/if}
</div>
</IntegrationCard>
</div>
<div class="justify-self-stretch col-span-2">
<IntegrationCard
title="Jellyfin"
href={$settings.jellyfin.baseUrl || '#'}
status={jellyfinConnected ? 'connected' : 'disconnected'}
>
<div class="flex flex-col gap-1">
<h2 class="text-sm text-zinc-500">Base URL</h2>
<Input
placeholder={'http://127.0.0.1:8096'}
class="w-full"
bind:value={values.jellyfin.baseUrl}
/>
</div>
<div class="flex flex-col gap-1">
<h2 class="text-sm text-zinc-500">API Key</h2>
<Input class="w-full" bind:value={values.jellyfin.apiKey} />
</div>
<div class="grid grid-cols-[1fr_min-content] gap-2">
<TestConnectionButton handleHealthCheck={checkJellyfinHealth} />
<FormButton on:click={() => handleRemoveIntegration('jellyfin')} type="error">
<Trash size={20} />
</FormButton>
</div>
<h1 class="border-b border-zinc-800 py-2">Options</h1>
<div
class={classNames(
'grid grid-cols-[1fr_min-content] justify-items-start gap-4 text-zinc-400',
{
'opacity-50 pointer-events-none': !jellyfinConnected
}
)}
>
<h2>Jellyfin User</h2>
{#if !jellyfinUsers}
<Select loading />
{:else}
<Select bind:value={values.jellyfin.userId}>
{#each jellyfinUsers as user}
<option value={user.id}>{user.name}</option>
{/each}
</Select>
{/if}
</div>
</IntegrationCard>
</div>
</div>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import FormButton from '$lib/components/forms/FormButton.svelte';
import { onDestroy, type ComponentProps } from 'svelte';
export let handleHealthCheck: () => Promise<boolean>;
let type: ComponentProps<FormButton>['type'] = 'base';
let loading = false;
let healthTimeout: NodeJS.Timeout;
$: {
if (type !== 'base') {
clearTimeout(healthTimeout);
healthTimeout = setTimeout(() => {
type = 'base';
}, 2000);
}
}
function handleClick() {
loading = true;
handleHealthCheck().then((ok) => {
if (ok) {
type = 'success';
} else {
type = 'error';
}
loading = false;
});
}
onDestroy(() => {
clearTimeout(healthTimeout);
});
</script>
<FormButton {type} {loading} on:click={handleClick}>Test Connection</FormButton>

View File

@@ -1,9 +1,18 @@
<script lang="ts">
import RadarrStats from '$lib/components/SourceStats/RadarrStats.svelte';
import SonarrStats from '$lib/components/SourceStats/SonarrStats.svelte';
import { settings } from '$lib/stores/settings.store';
import { fade } from 'svelte/transition';
</script>
<div class="pt-24 px-8 min-h-screen flex justify-center">
<div
class="pt-24 px-8 min-h-screen flex justify-center"
in:fade|global={{
duration: $settings.animationDuration,
delay: $settings.animationDuration
}}
out:fade|global={{ duration: $settings.animationDuration }}
>
<div class="flex flex-col gap-4 max-w-3xl flex-1">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<RadarrStats large />

View File

@@ -11,6 +11,10 @@
"strict": true,
"target": "ES2022",
"module": "ES2022",
"lib": ["es6"],
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
//

View File

@@ -9,5 +9,8 @@ export default defineConfig({
// },
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
},
ssr: {
external: ['reflect-metadata']
}
});