mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-23 17:25:14 +02:00
Merge pull request #73 from aleksilassila/feat/settings-page
Feat/settings page
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@ node_modules
|
||||
.output
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
/config/*.sqlite
|
||||
0
config/.gitkeep
Normal file
0
config/.gitkeep
Normal file
1411
package-lock.json
generated
1411
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
4
src/hooks.server.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import TypeOrm from '$lib/db';
|
||||
import 'reflect-metadata';
|
||||
|
||||
await TypeOrm.getDb();
|
||||
@@ -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(() => []);
|
||||
|
||||
@@ -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 || []);
|
||||
|
||||
@@ -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 || []);
|
||||
|
||||
@@ -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 || []);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
28
src/lib/components/forms/FormButton.svelte
Normal file
28
src/lib/components/forms/FormButton.svelte
Normal 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>
|
||||
23
src/lib/components/forms/Input.svelte
Normal file
23
src/lib/components/forms/Input.svelte
Normal 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>
|
||||
34
src/lib/components/forms/Select.svelte
Normal file
34
src/lib/components/forms/Select.svelte
Normal 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>
|
||||
11
src/lib/components/forms/Toggle.svelte
Normal file
11
src/lib/components/forms/Toggle.svelte
Normal 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>
|
||||
@@ -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
35
src/lib/db.ts
Normal 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;
|
||||
159
src/lib/entities/Settings.ts
Normal file
159
src/lib/entities/Settings.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
735
src/lib/utils/iso-languages.ts
Normal file
735
src/lib/utils/iso-languages.ts
Normal 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;
|
||||
247
src/lib/utils/iso-regions.ts
Normal file
247
src/lib/utils/iso-regions.ts
Normal 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;
|
||||
10
src/routes/+layout.server.ts
Normal file
10
src/routes/+layout.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -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} -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
11
src/routes/api/settings/+server.ts
Normal file
11
src/routes/api/settings/+server.ts
Normal 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));
|
||||
};
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
55
src/routes/settings/GeneralSettingsPage.svelte
Normal file
55
src/routes/settings/GeneralSettingsPage.svelte
Normal 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>
|
||||
26
src/routes/settings/IntegrationCard.svelte
Normal file
26
src/routes/settings/IntegrationCard.svelte
Normal 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>
|
||||
295
src/routes/settings/IntegrationSettingsPage.svelte
Normal file
295
src/routes/settings/IntegrationSettingsPage.svelte
Normal 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>
|
||||
37
src/routes/settings/TestConnectionButton.svelte
Normal file
37
src/routes/settings/TestConnectionButton.svelte
Normal 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>
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
//
|
||||
|
||||
@@ -9,5 +9,8 @@ export default defineConfig({
|
||||
// },
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{js,ts}']
|
||||
},
|
||||
ssr: {
|
||||
external: ['reflect-metadata']
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user