Merge pull request #1 from Axelazo/localization

Merge changes from upstream
This commit is contained in:
Axel Aguilar
2023-08-19 08:54:50 -06:00
committed by GitHub
71 changed files with 5197 additions and 1291 deletions

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
PUBLIC_RADARR_API_KEY=yourapikeyhere
PUBLIC_RADARR_BASE_URL=http://127.0.0.1:7878
PUBLIC_SONARR_API_KEY=yourapikeyhere
PUBLIC_SONARR_BASE_URL=http://127.0.0.1:8989
PUBLIC_JELLYFIN_API_KEY=yourapikeyhere
PUBLIC_JELLYFIN_BASE_URL=http://127.0.0.1:8096

1
.gitignore vendored
View File

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

View File

@@ -26,6 +26,10 @@ COPY package-lock.json .
RUN npm ci --omit dev
RUN mkdir -p ./config
RUN ln -s /usr/src/app/config /config
CMD [ "npm", "run", "deploy" ]
FROM node:18 as development
@@ -40,4 +44,8 @@ COPY package-lock.json .
RUN npm i
RUN mkdir -p ./config
RUN ln -s /usr/src/app/config /config
CMD [ "npm", "run", "dev" ]

View File

@@ -26,10 +26,23 @@ For a list of planned features & known bugs, see [Reiverr Taskboard](https://git
# Installation
The easiest and the recommended way to insstall Reiverr is via docker-compose. Make sure to update the api keys and base URLs to match your setup.
The easiest and the recommended way to install Reiverr is via Docker. Make sure to update the api keys and base URLs to match your setup.
Radarr & Sonarr API keys can be found under Settings > General in their respective web UIs. Jellyfin API key is located under Administration > Dashboard > Advanced > API Keys in the Jellyfin Web UI.
### Docker CLI
```sh
docker run -it --init \
--name reiverr \
--restart unless-stopped \
-p 9494:9494 \
-v /path/to/appdata/config:/config \
ghcr.io/aleksilassila/reiverr:latest
```
### Docker compose
```yaml
version: '3.8'
@@ -41,25 +54,35 @@ services:
container_name: reiverr
ports:
- 9494:9494
environment:
PUBLIC_RADARR_API_KEY: yourapikeyhere
PUBLIC_RADARR_BASE_URL: http://127.0.0.1:7878
PUBLIC_SONARR_API_KEY: yourapikeyhere
PUBLIC_SONARR_BASE_URL: http://127.0.0.1:8989
PUBLIC_JELLYFIN_API_KEY: yourapikeyhere
PUBLIC_JELLYFIN_URL: http://127.0.0.1:8096
volumes:
- /path/to/appdata/config:/config
restart: unless-stopped
```
### Manual Instructions
1. Requirements:
- Node v18.14.0 or high
- NPM v9.3.1 or high
1. Clone from **master** or download the [latest source](https://github.com/aleksilassila/reiverr/releases)
1. Build the app:
```sh
npm ci --ignore-scripts
npm run build
npm ci --ignore-scripts --omit=dev # optional
```
1. Start the app:
```sh
npm run deploy
```
### Reiverr will be accessible via port 9494 by default.
If you have any questions or run into issues, you can start a [discussion](https://github.com/aleksilassila/reiverr/discussions) or open an [issue](https://github.com/aleksilassila/reiverr/issues).
If you have any questions or run into issues or bugs, you can start a [discussion](https://github.com/aleksilassila/reiverr/discussions) or open an [issue](https://github.com/aleksilassila/reiverr/issues).
## Early Adopters: jellyfin login is currently broken and will be fixed later today
## Other Platforms
### Other Upcoming Platforms
Platforms that are planned to be supported in the future:
The roadmap includes plans to support the following platforms in the future:
- Windows Desktop App
- MacOS Desktop App
@@ -83,30 +106,22 @@ I'm not a designer, so if you have any ideas for improving the UI, I'd love to l
To get started with development:
1. Clone the repo
2. Add and populate `.env` file
3. Run `npm install`
4. Run `npm run dev`
1. Clone the repository
2. Run `npm install`
3. Run `npm run dev`
Alternatively, you can run `docker-compose up`.
Example .env file:
For Webstorm users: I'd recommend using VS Code as it has way better Svelte Typescript support.
```env
# The PUBLIC_ prefix is required for SvelteKit to expose the variable to the web browser.
# If you are exposing the server to the internet (not recommended), you should use HTTPS.
Useful resources:
# Fill in the blanks and change the base URLs to match your setup.
PUBLIC_RADARR_API_KEY=yourapikeyhere
PUBLIC_RADARR_BASE_URL=http://127.0.0.1:7878
PUBLIC_SONARR_API_KEY=yourapikeyhere
PUBLIC_SONARR_BASE_URL=http://127.0.0.1:8989
PUBLIC_JELLYFIN_API_KEY=yourapikeyhere
PUBLIC_JELLYFIN_URL=http://127.0.0.1:8096
```
- https://developer.themoviedb.org/reference
- https://api.jellyfin.org/
- https://sonarr.tv/docs/api/
- https://radarr.video/docs/api/
- https://github.com/jellyfin/jellyfin-web
- Network tab in the browser in Jellyfin, Radarr & Sonarr web UIs
# Additional Screenshots

0
config/.gitkeep Normal file
View File

View File

@@ -9,4 +9,4 @@ services:
context: .
target: production
ports:
- 9494:3000
- 9494:9494

View File

@@ -7,3 +7,5 @@ services:
container_name: reiverr-dev
image: ghcr.io/aleksilassila/reiverr:latest
restart: unless-stopped
volumes:
- ./config:/config

1762
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "reiverr",
"version": "0.3.0",
"version": "0.5.1",
"repository": {
"type": "git",
"url": "https://github.com/aleksilassila/reiverr"
@@ -9,7 +9,7 @@
"dev": "vite dev --host",
"build": "vite build",
"preview": "vite preview",
"deploy": "PORT=9494 node build/",
"deploy": "PORT=9494 NODE_ENV=production node build/",
"test": "playwright test",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@@ -55,6 +55,9 @@
"openapi-fetch": "^0.2.1",
"radix-icons-svelte": "^1.2.1",
"svelte-i18n": "^3.7.0",
"tailwind-scrollbar-hide": "^1.1.7"
"reflect-metadata": "^0.1.13",
"sqlite3": "^5.1.6",
"tailwind-scrollbar-hide": "^1.1.7",
"typeorm": "^0.3.17"
}
}

View File

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

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

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

View File

@@ -1,80 +1,78 @@
import createClient from 'openapi-fetch';
import type { components, paths } from '$lib/apis/jellyfin/jellyfin.generated';
import { env } from '$env/dynamic/public';
import { request } from '$lib/utils';
import type { DeviceProfile } from '$lib/apis/jellyfin/playback-profiles';
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 JELLYFIN_DEVICE_ID = 'Reiverr Client';
export const JellyfinApi = createClient<paths>({
baseUrl: env.PUBLIC_JELLYFIN_URL,
headers: {
Authorization: `MediaBrowser DeviceId="${JELLYFIN_DEVICE_ID}", Token="${env.PUBLIC_JELLYFIN_API_KEY}"`
}
});
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[]> =>
JellyfinApi.get('/Users/{userId}/Items/Resume', {
params: {
path: {
userId: await getUserId()
},
query: {
mediaTypes: ['Video'],
fields: ['ProviderIds']
export const getJellyfinContinueWatching = async (): Promise<JellyfinItem[] | undefined> =>
getJellyfinApi()
?.get('/Users/{userId}/Items/Resume', {
params: {
path: {
userId: get(settings)?.jellyfin.userId || ''
},
query: {
mediaTypes: ['Video'],
fields: ['ProviderIds']
}
}
}
}).then((r) => r.data?.Items || []);
})
.then((r) => r.data?.Items || []);
export const getJellyfinNextUp = async () =>
JellyfinApi.get('/Shows/NextUp', {
params: {
query: {
userId: await getUserId(),
fields: ['ProviderIds']
getJellyfinApi()
?.get('/Shows/NextUp', {
params: {
query: {
userId: get(settings)?.jellyfin.userId || '',
fields: ['ProviderIds']
}
}
}
}).then((r) => r.data?.Items || []);
})
.then((r) => r.data?.Items || []);
export const getJellyfinItems = async () =>
JellyfinApi.get('/Users/{userId}/Items', {
params: {
path: {
userId: await getUserId()
},
query: {
hasTmdbId: true,
recursive: true,
includeItemTypes: ['Movie', 'Series'],
fields: ['ProviderIds']
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 || []);
})
.then((r) => r.data?.Items || []);
// export const getJellyfinSeries = () =>
// JellyfinApi.get('/Users/{userId}/Items', {
// params: {
// path: {
// userId: env.PUBLIC_JELLYFIN_USER_ID || ""
// userId: PUBLIC_JELLYFIN_USER_ID || ""
// },
// query: {
// hasTmdbId: true,
@@ -85,70 +83,81 @@ export const getJellyfinItems = async () =>
// }).then((r) => r.data?.Items || []);
export const getJellyfinEpisodes = async () =>
JellyfinApi.get('/Users/{userId}/Items', {
params: {
path: {
userId: await getUserId()
getJellyfinApi()
?.get('/Users/{userId}/Items', {
params: {
path: {
userId: get(settings)?.jellyfin.userId || ''
},
query: {
recursive: true,
includeItemTypes: ['Episode']
}
},
query: {
recursive: true,
includeItemTypes: ['Episode']
headers: {
'cache-control': 'max-age=10'
}
},
headers: {
'cache-control': 'max-age=10'
}
}).then((r) => r.data?.Items || []);
})
.then((r) => r.data?.Items || []);
export const getJellyfinEpisodesBySeries = (seriesId: string) =>
getJellyfinEpisodes().then((items) => items.filter((i) => i.SeriesId === seriesId) || []);
// export const getJellyfinEpisodesBySeries = (seriesId: string) =>
// getJellyfinEpisodes().then((items) => items?.filter((i) => i.SeriesId === seriesId) || []);
export const getJellyfinItemByTmdbId = (tmdbId: string) =>
getJellyfinItems().then((items) => items.find((i) => i.ProviderIds?.Tmdb == tmdbId));
// export const getJellyfinItemByTmdbId = (tmdbId: string) =>
// getJellyfinItems().then((items) => items?.find((i) => i.ProviderIds?.Tmdb == tmdbId));
export const getJellyfinItem = async (itemId: string) =>
JellyfinApi.get('/Users/{userId}/Items/{itemId}', {
params: {
path: {
itemId,
userId: await getUserId()
getJellyfinApi()
?.get('/Users/{userId}/Items/{itemId}', {
params: {
path: {
itemId,
userId: get(settings)?.jellyfin.userId || ''
}
}
}
}).then((r) => r.data);
})
.then((r) => r.data);
export const requestJellyfinItemByTmdbId = () =>
request((tmdbId: string) => getJellyfinItemByTmdbId(tmdbId));
// export const requestJellyfinItemByTmdbId = () =>
// request((tmdbId: string) => getJellyfinItemByTmdbId(tmdbId));
export const getJellyfinPlaybackInfo = async (
itemId: string,
playbackProfile: DeviceProfile,
startTimeTicks = 0
startTimeTicks = 0,
maxStreamingBitrate = 140000000
) =>
JellyfinApi.post('/Items/{itemId}/PlaybackInfo', {
params: {
path: {
itemId: itemId
getJellyfinApi()
?.post('/Items/{itemId}/PlaybackInfo', {
params: {
path: {
itemId: itemId
},
query: {
userId: get(settings)?.jellyfin.userId || '',
startTimeTicks,
autoOpenLiveStream: true,
maxStreamingBitrate
}
},
query: {
userId: await getUserId(),
startTimeTicks,
autoOpenLiveStream: true,
maxStreamingBitrate: 140000000
body: {
DeviceProfile: playbackProfile
}
},
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=${PUBLIC_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
}));
})
.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,
@@ -157,7 +166,7 @@ export const reportJellyfinPlaybackStarted = (
audioStreamIndex?: number,
subtitleStreamIndex?: number
) =>
JellyfinApi.post('/Sessions/Playing', {
getJellyfinApi()?.post('/Sessions/Playing', {
body: {
CanSeek: true,
ItemId: itemId,
@@ -174,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,
@@ -190,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,
@@ -199,11 +208,21 @@ export const reportJellyfinPlaybackStopped = (
}
});
export const delteActiveEncoding = (playSessionId: string) =>
getJellyfinApi()?.del('/Videos/ActiveEncodings', {
params: {
query: {
deviceId: JELLYFIN_DEVICE_ID,
playSessionId: playSessionId
}
}
});
export const setJellyfinItemWatched = async (jellyfinId: string) =>
JellyfinApi.post('/Users/{userId}/PlayedItems/{itemId}', {
getJellyfinApi()?.post('/Users/{userId}/PlayedItems/{itemId}', {
params: {
path: {
userId: await getUserId(),
userId: get(settings)?.jellyfin.userId || '',
itemId: jellyfinId
},
query: {
@@ -213,11 +232,37 @@ export const setJellyfinItemWatched = async (jellyfinId: string) =>
});
export const setJellyfinItemUnwatched = async (jellyfinId: string) =>
JellyfinApi.del('/Users/{userId}/PlayedItems/{itemId}', {
getJellyfinApi()?.del('/Users/{userId}/PlayedItems/{itemId}', {
params: {
path: {
userId: await getUserId(),
userId: get(settings)?.jellyfin.userId || '',
itemId: jellyfinId
}
}
});
export const getJellyfinHealth = async (
baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined
) =>
axios
.get((baseUrl || get(settings)?.jellyfin.baseUrl) + '/System/Info', {
headers: {
'X-Emby-Token': apiKey || get(settings)?.jellyfin.apiKey
}
})
.then((res) => res.status === 200)
.catch(() => false);
export const getJellyfinUsers = async (
baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined
): Promise<components['schemas']['UserDto'][]> =>
axios
.get((baseUrl || get(settings)?.jellyfin.baseUrl) + '/Users', {
headers: {
'X-Emby-Token': apiKey || get(settings)?.jellyfin.apiKey
}
})
.then((res) => res.data || [])
.catch(() => []);

View File

@@ -0,0 +1,64 @@
/**
* Returns an array containing all the available
* qualities the user can select when playing a video
*
* @param resolution The resolution of the video
* @returns An array containing all the available qualities
*/
export function getQualities(resolution: number) {
// We add one to the minimum resolution since some movies
// have a resolution of 1080p, but the format isn't 16:9,
// so the height is less than 1080, so we detect as 1080p
// anything higher than 720p, and so on for the other.
const data = [
{
name: '4K - 120 Mbps',
maxBitrate: 120000000,
minResolution: 1080 + 1
},
{
name: '4K - 80 Mbps',
maxBitrate: 80000000,
minResolution: 1080 + 1
},
{
name: '1080p - 40 Mbps',
maxBitrate: 40000000,
minResolution: 720 + 1
},
{
name: '1080p - 10 Mbps',
maxBitrate: 10000000,
minResolution: 720 + 1
},
{
name: '720p - 8 Mbps',
maxBitrate: 8000000,
minResolution: 480 + 1
},
{
name: '720p - 4 Mbps',
maxBitrate: 4000000,
minResolution: 480 + 1
},
{
name: '480p - 3 Mbps',
maxBitrate: 3000000,
minResolution: 360 + 1
},
{
name: '480p - 720 Kbps',
maxBitrate: 720000,
minResolution: 360 + 1
},
{
name: '360p - 420 Kbps',
maxBitrate: 420000,
minResolution: 0
}
];
return data.filter((quality) => {
return quality.minResolution <= resolution;
});
}

View File

@@ -1,10 +1,9 @@
import createClient from 'openapi-fetch';
import { log, request } from '$lib/utils';
import type { paths } from '$lib/apis/radarr/radarr.generated';
import type { components } from '$lib/apis/radarr/radarr.generated';
import type { components, paths } from '$lib/apis/radarr/radarr.generated';
import { getTmdbMovie } from '$lib/apis/tmdb/tmdbApi';
import { env } from '$env/dynamic/public';
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';
export type RadarrMovie = components['schemas']['MovieResource'];
@@ -26,28 +25,41 @@ export interface RadarrMovieOptions {
searchNow?: boolean;
}
export const RadarrApi = createClient<paths>({
baseUrl: env.PUBLIC_RADARR_BASE_URL,
headers: {
'X-Api-Key': env.PUBLIC_RADARR_API_KEY
}
});
function getRadarrApi() {
const baseUrl = get(settings)?.radarr.baseUrl;
const apiKey = get(settings)?.radarr.apiKey;
const rootFolder = get(settings)?.radarr.rootFolderPath;
const qualityProfileId = get(settings)?.radarr.qualityProfileId;
if (!baseUrl || !apiKey || !rootFolder || !qualityProfileId) 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 || []);
export const requestRadarrMovie = () => request(getRadarrMovieByTmdbId);
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));
})
.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);
@@ -58,9 +70,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,
@@ -70,59 +82,72 @@ export const addMovieToRadarr = async (tmdbId: number) => {
searchNow: false
};
return RadarrApi.post('/api/v3/movie', {
params: {},
body: options
}).then((r) => r.data);
return (
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;
return !!deleteResponse?.response.ok;
};
export const fetchRadarrReleases = (movieId: number) =>
RadarrApi.get('/api/v3/release', { params: { query: { movieId: movieId } } }).then(
(r) => r.data || []
);
getRadarrApi()
?.get('/api/v3/release', { params: { query: { movieId: movieId } } })
.then((r) => r.data || []) || Promise.resolve([]);
export const downloadRadarrMovie = (guid: string) =>
RadarrApi.post('/api/v3/release', {
params: {},
body: {
indexerId: 2,
guid
}
});
export const downloadRadarrMovie = (guid: string, indexerId: number) =>
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);
})
.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) =>
getRadarrDownloads().then((downloads) => downloads.filter((d) => d.movie.id === radarrId));
@@ -131,22 +156,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);
})
.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 || []);
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);
})
.then((res) => res.response.ok) || Promise.resolve(false);
export const getRadarrHealth = async (
baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined
) =>
axios
.get((baseUrl || get(settings)?.radarr.baseUrl) + '/api/v3/health', {
headers: {
'X-Api-Key': apiKey || get(settings)?.radarr.apiKey
}
})
.then((res) => res.status === 200)
.catch(() => false);
export const getRadarrRootFolders = async (
baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined
) =>
axios
.get<components['schemas']['RootFolderResource'][]>(
(baseUrl || get(settings)?.sonarr.baseUrl) + '/api/v3/rootFolder',
{
headers: {
'X-Api-Key': apiKey || get(settings)?.sonarr.apiKey
}
}
)
.then((res) => res.data || []);
export const getRadarrQualityProfiles = async (
baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined
) =>
axios
.get<components['schemas']['QualityProfileResource'][]>(
(baseUrl || get(settings)?.sonarr.baseUrl) + '/api/v3/qualityprofile',
{
headers: {
'X-Api-Key': apiKey || get(settings)?.sonarr.apiKey
}
}
)
.then((res) => res.data || []);

View File

@@ -1,13 +1,12 @@
import { env } from '$env/dynamic/public';
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 { 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 MovieFileResource = components['schemas']['MovieFileResource'];
export type SonarrReleaseResource = components['schemas']['ReleaseResource'];
export type SonarrDownload = components['schemas']['QueueResource'] & { series: SonarrSeries };
export type DiskSpaceInfo = components['schemas']['DiskSpaceResource'];
@@ -39,32 +38,46 @@ export interface SonarrSeriesOptions {
};
}
export const SonarrApi = createClient<paths>({
baseUrl: env.PUBLIC_SONARR_BASE_URL,
headers: {
'X-Api-Key': env.PUBLIC_SONARR_API_KEY
}
});
function getSonarrApi() {
const baseUrl = get(settings)?.sonarr.baseUrl;
const apiKey = get(settings)?.sonarr.apiKey;
const rootFolder = get(settings)?.sonarr.rootFolderPath;
const qualityProfileId = get(settings)?.sonarr.qualityProfileId;
const languageProfileId = get(settings)?.sonarr.languageProfileId;
if (!baseUrl || !apiKey || !rootFolder || !qualityProfileId || !languageProfileId)
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 || []);
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));
export const getRadarrDownloadById = (sonarrId: number) =>
getSonarrDownloads().then((downloads) => downloads.find((d) => d.series.id === sonarrId));
})
.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 || []);
getSonarrApi()
?.get('/api/v3/diskspace', {})
.then((d) => d.data || []) || Promise.resolve([]);
export const addSeriesToSonarr = async (tmdbId: number) => {
const tmdbSeries = await getTmdbSeries(tmdbId);
@@ -75,102 +88,123 @@ 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;
return !!deleteResponse?.response.ok;
};
export const downloadSonarrEpisode = (guid: string) =>
SonarrApi.post('/api/v3/release', {
params: {},
body: {
indexerId: 2,
guid
}
});
export const downloadSonarrEpisode = (guid: string, indexerId: number) =>
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);
})
.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[]) ||
[]
);
})
.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));
getSonarrDownloads().then((downloads) => downloads.filter((d) => d.seriesId === sonarrId)) ||
Promise.resolve([]);
export const removeFromSonarr = (id: number) =>
SonarrApi.del('/api/v3/series/{id}', {
params: {
path: {
id
export const removeFromSonarr = (id: number): Promise<boolean> =>
getSonarrApi()
?.del('/api/v3/series/{id}', {
params: {
path: {
id
}
}
}
}).then((res) => res.response.ok);
})
.then((res) => res.response.ok) || Promise.resolve(false);
export const getSonarrEpisodes = async (seriesId: number) => {
const episodesPromise = SonarrApi.get('/api/v3/episode', {
params: {
query: {
seriesId
}
}
}).then((r) => r.data || []);
const episodesPromise =
getSonarrApi()
?.get('/api/v3/episode', {
params: {
query: {
seriesId
}
}
})
.then((r) => r.data || []) || Promise.resolve([]);
const episodeFilesPromise = SonarrApi.get('/api/v3/episodefile', {
params: {
query: {
seriesId
}
}
}).then((r) => r.data || []);
const episodeFilesPromise =
getSonarrApi()
?.get('/api/v3/episodefile', {
params: {
query: {
seriesId
}
}
})
.then((r) => r.data || []) || Promise.resolve([]);
const [episodes, episodeFiles] = await Promise.all([episodesPromise, episodeFilesPromise]);
const episodes = await episodesPromise;
const episodeFiles = await episodeFilesPromise;
return episodes.map((episode) => ({
episode,
@@ -179,30 +213,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 || []);
})
.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 || []);
})
.then((r) => r.data || []) || Promise.resolve([]);
export const fetchSonarrEpisodes = async (seriesId: number): Promise<SonarrEpisode[]> => {
return SonarrApi.get('/api/v3/episode', {
params: {
query: {
seriesId
}
}
}).then((r) => r.data || []);
return (
getSonarrApi()
?.get('/api/v3/episode', {
params: {
query: {
seriesId
}
}
})
.then((r) => r.data || []) || Promise.resolve([])
);
};
export const getSonarrHealth = async (
baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined
) =>
axios
.get((baseUrl || get(settings)?.sonarr.baseUrl) + '/api/v3/health', {
headers: {
'X-Api-Key': apiKey || get(settings)?.sonarr.apiKey
}
})
.then((res) => res.status === 200)
.catch(() => false);
export const getSonarrRootFolders = async (
baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined
) =>
axios
.get<components['schemas']['RootFolderResource'][]>(
(baseUrl || get(settings)?.sonarr.baseUrl) + '/api/v3/rootFolder',
{
headers: {
'X-Api-Key': apiKey || get(settings)?.sonarr.apiKey
}
}
)
.then((res) => res.data || []);
export const getSonarrQualityProfiles = async (
baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined
) =>
axios
.get<components['schemas']['QualityProfileResource'][]>(
(baseUrl || get(settings)?.sonarr.baseUrl) + '/api/v3/qualityprofile',
{
headers: {
'X-Api-Key': apiKey || get(settings)?.sonarr.apiKey
}
}
)
.then((res) => res.data || []);
export const getSonarrLanguageProfiles = async (
baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined
) =>
axios
.get<components['schemas']['LanguageProfileResource'][]>(
(baseUrl || get(settings)?.sonarr.baseUrl) + '/api/v3/languageprofile',
{
headers: {
'X-Api-Key': apiKey || get(settings)?.sonarr.apiKey
}
}
)
.then((res) => res.data || []);

View File

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

View File

@@ -4,8 +4,9 @@
const dispatch = createEventDispatcher();
export let size: 'md' | 'sm' | 'lg' = 'md';
export let size: 'md' | 'sm' | 'lg' | 'xs' = 'md';
export let type: 'primary' | 'secondary' | 'tertiary' = 'secondary';
export let slim = false;
export let disabled = false;
export let href: string | undefined = undefined;
@@ -13,18 +14,26 @@
let buttonStyle: string;
$: buttonStyle = classNames(
'flex items-center gap-1 rounded-xl font-medium select-none cursor-pointer selectable transition-all flex-shrink-0',
'flex items-center gap-1 font-medium select-none cursor-pointer selectable transition-all flex-shrink-0',
{
'bg-white text-zinc-900 font-extrabold backdrop-blur-lg': type === 'primary',
'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': 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',
'py-2 px-6 sm:py-3 sm:px-6': size === 'lg',
'py-2 px-6': size === 'md',
'py-1 px-3': size === 'sm',
'py-2 px-6 sm:py-3 sm:px-6': size === 'lg' && !slim,
'py-2 px-6': size === 'md' && !slim,
'py-1 px-4': size === 'sm' && !slim,
'py-1 px-4 text-sm': size === 'xs' && !slim,
'p-2 sm:p-3': size === 'lg' && slim,
'p-2': size === 'md' && slim,
'p-1': size === 'sm' && slim,
'p-1 text-sm': size === 'xs' && slim,
'opacity-50': disabled,
'cursor-pointer': !disabled
}

View File

@@ -1,25 +1,23 @@
<script lang="ts">
import { setJellyfinItemUnwatched, setJellyfinItemWatched } from '$lib/apis/jellyfin/jellyfinApi';
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
import { library } from '$lib/stores/library.store';
import { createLibraryItemStore } from '$lib/stores/library.store';
import type { TitleType } from '$lib/types';
import { formatMinutesToTime } from '$lib/utils';
import classNames from 'classnames';
import { Clock, Star } from 'radix-icons-svelte';
import ContextMenu from '../ContextMenu/ContextMenu.svelte';
import ContextMenuItem from '../ContextMenu/ContextMenuItem.svelte';
import type { TitleType } from '$lib/types';
import LibraryItemContextItems from '../ContextMenu/LibraryItemContextItems.svelte';
import { openTitleModal } from '../Modal/Modal';
import ProgressBar from '../ProgressBar.svelte';
export let tmdbId: number;
export let jellyfinId: string | undefined = undefined;
export let type: TitleType = 'movie';
export let title: string;
export let genres: string[] = [];
export let runtimeMinutes = 0;
export let seasons = 0;
export let completionTime = '';
export let backdropUri: string;
export let backdropUrl: string;
export let rating: number;
export let available = true;
@@ -27,30 +25,12 @@
export let size: 'dynamic' | 'md' | 'lg' = 'md';
export let openInModal = true;
let watched = false;
$: watched = !available && !!jellyfinId;
function handleSetWatched() {
if (jellyfinId) {
setJellyfinItemWatched(jellyfinId).finally(() => library.refreshIn(3000));
}
}
function handleSetUnwatched() {
if (jellyfinId) {
setJellyfinItemUnwatched(jellyfinId).finally(() => library.refreshIn(3000));
}
}
let itemStore = createLibraryItemStore(tmdbId);
</script>
<ContextMenu heading={title} disabled={!jellyfinId}>
<ContextMenu heading={title}>
<svelte:fragment slot="menu">
<ContextMenuItem on:click={handleSetWatched} disabled={!jellyfinId || watched}>
Mark as watched
</ContextMenuItem>
<ContextMenuItem on:click={handleSetUnwatched} disabled={!jellyfinId || !watched}>
Mark as unwatched
</ContextMenuItem>
<LibraryItemContextItems {itemStore} {type} {tmdbId} />
</svelte:fragment>
<button
class={classNames(
@@ -71,7 +51,7 @@
}}
>
<div
style={"background-image: url('" + TMDB_BACKDROP_SMALL + backdropUri + "')"}
style={"background-image: url('" + backdropUrl + "')"}
class="absolute inset-0 bg-center bg-cover group-hover:scale-105 group-focus-visible:scale-105 transition-transform"
/>
<div

View File

@@ -7,9 +7,10 @@ import {
} from '$lib/apis/tmdb/tmdbApi';
import type { ComponentProps } from 'svelte';
import type Card from './Card.svelte';
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
export const fetchCardTmdbMovieProps = async (movie: TmdbMovie2): Promise<ComponentProps<Card>> => {
const backdropUri = getTmdbMovieBackdrop(movie.id || 0);
const backdropUri = await getTmdbMovieBackdrop(movie.id || 0);
const movieAny = movie as any;
const genres =
@@ -24,7 +25,7 @@ export const fetchCardTmdbMovieProps = async (movie: TmdbMovie2): Promise<Compon
title: movie.title || '',
genres,
runtimeMinutes: movie.runtime,
backdropUri: (await backdropUri) || '',
backdropUrl: backdropUri ? TMDB_BACKDROP_SMALL + backdropUri : '',
rating: movie.vote_average || 0
};
};
@@ -32,7 +33,7 @@ export const fetchCardTmdbMovieProps = async (movie: TmdbMovie2): Promise<Compon
export const fetchCardTmdbSeriesProps = async (
series: TmdbSeries2
): Promise<ComponentProps<Card>> => {
const backdropUri = getTmdbSeriesBackdrop(series.id || 0);
const backdropUri = await getTmdbSeriesBackdrop(series.id || 0);
const seriesAny = series as any;
const genres =
@@ -47,7 +48,7 @@ export const fetchCardTmdbSeriesProps = async (
title: series.name || '',
genres,
runtimeMinutes: series.episode_run_time?.[0],
backdropUri: (await backdropUri) || '',
backdropUrl: backdropUri ? TMDB_BACKDROP_SMALL + backdropUri : '',
rating: series.vote_average || 0,
type: 'series'
};

View File

@@ -4,22 +4,29 @@
export let heading = '';
export let disabled = false;
export let position: 'absolute' | 'fixed' = 'fixed';
let anchored = position === 'absolute';
export let bottom = false;
const id = Symbol();
export let id = Symbol();
let menu: HTMLDivElement;
let windowWidth: number;
let windowHeight: number;
let position = { x: 0, y: 0 };
let fixedPosition = { x: 0, y: 0 };
function close() {
contextMenu.hide();
}
function handleOpen(event: MouseEvent) {
if (disabled) return;
export function handleOpen(event: MouseEvent) {
if (disabled || (anchored && $contextMenu === id)) return; // Clicking button will close menu
position = { x: event.clientX, y: event.clientY };
fixedPosition = { x: event.clientX, y: event.clientY };
contextMenu.show(id);
event.preventDefault();
event.stopPropagation();
}
function handleClickOutside(event: MouseEvent) {
@@ -37,7 +44,12 @@
}
</script>
<svelte:window on:keydown={handleShortcuts} on:click={handleClickOutside} />
<svelte:window
on:keydown={handleShortcuts}
on:click={handleClickOutside}
bind:innerWidth={windowWidth}
bind:innerHeight={windowHeight}
/>
<svelte:head>
{#if $contextMenu === id}
<style>
@@ -49,24 +61,34 @@
</svelte:head>
<!-- <svelte:body bind:this={body} /> -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:contextmenu|preventDefault={handleOpen}>
<div on:contextmenu|preventDefault={handleOpen} on:click={(e) => anchored && e.stopPropagation()}>
<slot />
</div>
{#if $contextMenu === id}
{#key position}
{#key fixedPosition}
<div
class="fixed z-50 px-1 py-1 bg-zinc-800 bg-opacity-50 rounded-lg backdrop-blur-xl flex flex-col"
style="left: {position.x}px; top: {position.y}px;"
class={`${position} z-50 my-2 px-1 py-1 bg-zinc-800 bg-opacity-50 rounded-lg backdrop-blur-xl flex flex-col w-max`}
style={position === 'fixed'
? `left: ${
fixedPosition.x - (fixedPosition.x > windowWidth / 2 ? menu?.clientWidth : 0)
}px; top: ${
fixedPosition.y -
(bottom ? (fixedPosition.y > windowHeight / 2 ? menu?.clientHeight : 0) : 0)
}px;`
: menu?.getBoundingClientRect()?.left > windowWidth / 2
? `right: 0;${bottom ? 'bottom: 40px;' : ''}`
: `left: 0;${bottom ? 'bottom: 40px;' : ''}`}
bind:this={menu}
in:fly|global={{ y: 5, duration: 100, delay: 100 }}
in:fly|global={{ y: 5, duration: 100, delay: anchored ? 0 : 100 }}
out:fly|global={{ y: 5, duration: 100 }}
>
<slot name="title">
{#if heading}
<h2
class="text-xs text-zinc-200 opacity-60 tracking-wide font-semibold px-3 py-1 line-clamp-1"
class="text-xs text-zinc-200 opacity-60 tracking-wide font-semibold px-3 py-1 line-clamp-1 text-left"
>
{heading}
</h2>

View File

@@ -1,11 +1,11 @@
import { writable } from 'svelte/store';
function createContextMenu() {
const visibleItem = writable<Symbol | null>(null);
const visibleItem = writable<symbol | null>(null);
return {
subscribe: visibleItem.subscribe,
show: (item: Symbol) => {
show: (item: symbol) => {
visibleItem.set(item);
},
hide: () => {

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import ContextMenu from './ContextMenu.svelte';
import { contextMenu } from '../ContextMenu/ContextMenu';
import Button from '../Button.svelte';
import { DotsVertical } from 'radix-icons-svelte';
export let heading = '';
export let contextMenuId = Symbol();
function handleToggleVisibility() {
if ($contextMenu === contextMenuId) contextMenu.hide();
else contextMenu.show(contextMenuId);
}
</script>
<div class="relative">
<ContextMenu position="absolute" {heading} id={contextMenuId}>
<slot name="menu" slot="menu" />
<Button slim on:click={handleToggleVisibility}>
<DotsVertical size={24} />
</Button>
</ContextMenu>
</div>

View File

@@ -0,0 +1 @@
<div class="bg-zinc-200 bg-opacity-20 h-[1.5px] mx-3 my-1 rounded-full" />

View File

@@ -7,9 +7,9 @@
<button
on:click
class={classNames(
'text-sm font-medium px-3 py-1 hover:bg-zinc-200 hover:bg-opacity-10 rounded-md text-left cursor-default',
'text-sm font-medium tracking-wide px-3 py-1 hover:bg-zinc-200 hover:bg-opacity-10 rounded-md text-left cursor-default',
{
'opacity-80 pointer-events-none': disabled
'opacity-75 pointer-events-none': disabled
}
)}
>

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import { setJellyfinItemUnwatched, setJellyfinItemWatched } from '$lib/apis/jellyfin/jellyfinApi';
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';
export let itemStore: LibraryItemStore;
export let type: TitleType;
export let tmdbId: number;
let watched = false;
itemStore.subscribe((i) => {
if (i.item?.jellyfinItem) {
watched =
i.item.jellyfinItem.UserData?.Played !== undefined
? i.item.jellyfinItem.UserData?.Played
: watched;
}
});
function handleSetWatched() {
if ($itemStore.item?.jellyfinId) {
watched = true;
setJellyfinItemWatched($itemStore.item?.jellyfinId).finally(() => library.refreshIn(3000));
}
}
function handleSetUnwatched() {
if ($itemStore.item?.jellyfinId) {
watched = false;
setJellyfinItemUnwatched($itemStore.item?.jellyfinId).finally(() => library.refreshIn(3000));
}
}
function handleOpenInJellyfin() {
window.open(
$settings.jellyfin.baseUrl +
'/web/index.html#!/details?id=' +
$itemStore.item?.jellyfinItem?.Id
);
}
</script>
{#if $itemStore.item}
<ContextMenuItem on:click={handleSetWatched} disabled={!$itemStore.item?.jellyfinId || watched}>
Mark as watched
</ContextMenuItem>
<ContextMenuItem
on:click={handleSetUnwatched}
disabled={!$itemStore.item?.jellyfinId || !watched}
>
Mark as unwatched
</ContextMenuItem>
<ContextMenuDivider />
<ContextMenuItem disabled={!$itemStore.item.jellyfinItem} on:click={handleOpenInJellyfin}>
Open in Jellyfin
</ContextMenuItem>
{#if $itemStore.item.type === 'movie'}
<ContextMenuItem
disabled={!$itemStore.item.radarrMovie}
on:click={() =>
window.open($settings.radarr.baseUrl + '/movie/' + $itemStore.item?.radarrMovie?.tmdbId)}
>
Open in Radarr
</ContextMenuItem>
{:else}
<ContextMenuItem
disabled={!$itemStore.item.sonarrSeries}
on:click={() =>
window.open(
$settings.sonarr.baseUrl + '/series/' + $itemStore.item?.sonarrSeries?.titleSlug
)}
>
Open in Sonarr
</ContextMenuItem>
{/if}
{/if}
<ContextMenuItem on:click={() => window.open(`https://www.themoviedb.org/${type}/${tmdbId}`)}>
Open in TMDB
</ContextMenuItem>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import classNames from 'classnames';
import { Check } from 'radix-icons-svelte';
import ContextMenuItem from './ContextMenuItem.svelte';
export let selected = false;
</script>
<ContextMenuItem on:click>
<div class="flex items-center gap-2 justify-between cursor-pointer">
<Check
size={20}
class={classNames({
'opacity-0': !selected,
'opacity-100': selected
})}
/>
<div class="flex items-center text-left w-32">
<slot />
</div>
</div>
</ContextMenuItem>

View File

@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import classNames from 'classnames';
export let disabled = false;

View File

@@ -3,8 +3,8 @@ import { writable } from 'svelte/store';
import TitlePageModal from '../TitlePageLayout/TitlePageModal.svelte';
type ModalItem = {
id: Symbol;
group: Symbol;
id: symbol;
group: symbol;
component: ConstructorOfATypedSvelteComponent;
props: Record<string, any>;
};
@@ -14,7 +14,7 @@ function createDynamicModalStack() {
top: undefined
});
function close(symbol: Symbol) {
function close(symbol: symbol) {
store.update((s) => {
s.stack = s.stack.filter((i) => i.id !== symbol);
s.top = s.stack[s.stack.length - 1];
@@ -22,7 +22,7 @@ function createDynamicModalStack() {
});
}
function closeGroup(group: Symbol) {
function closeGroup(group: symbol) {
store.update((s) => {
s.stack = s.stack.filter((i) => i.group !== group);
s.top = s.stack[s.stack.length - 1];
@@ -33,7 +33,7 @@ function createDynamicModalStack() {
function create(
component: ConstructorOfATypedSvelteComponent,
props: Record<string, any>,
group: Symbol | undefined = undefined
group: symbol | undefined = undefined
) {
const id = Symbol();
const item = { id, component, props, group: group || id };
@@ -60,7 +60,7 @@ function createDynamicModalStack() {
export const modalStack = createDynamicModalStack();
let lastTitleModal: Symbol | undefined = undefined;
let lastTitleModal: symbol | undefined = undefined;
export function openTitleModal(tmdbId: number, type: TitleType) {
if (lastTitleModal) {
modalStack.close(lastTitleModal);

View File

@@ -10,7 +10,7 @@
import type { TitleType } from '$lib/types';
import { _ } from 'svelte-i18n';
export let modalId: Symbol;
export let modalId: symbol;
let inputValue = '';
let inputElement: HTMLInputElement;

View File

@@ -9,7 +9,7 @@
export let tmdbId: number;
export let jellyfinId: string = '';
export let type: TitleType = 'movie';
export let backdropUri: string;
export let backdropUrl: string;
export let title = '';
export let subtitle = '';
@@ -19,7 +19,7 @@
<a
href={`/${type}/${tmdbId}`}
style={"background-image: url('" + TMDB_POSTER_SMALL + backdropUri + "');"}
style={"background-image: url('" + backdropUrl + "');"}
class="relative flex shadow-lg rounded-lg aspect-[2/3] bg-center bg-cover w-44 selectable group hover:text-inherit flex-shrink-0"
>
<div

View File

@@ -6,8 +6,8 @@
import ModalHeader from '../Modal/ModalHeader.svelte';
import RequestModal from './RequestModal.svelte';
export let modalId: Symbol;
export let groupId: Symbol;
export let modalId: symbol;
export let groupId: symbol;
export let sonarrId: number;
export let seasonNumber: number;
@@ -22,8 +22,8 @@
modalStack.create(
RequestModal,
{
episode,
sonarrId,
sonarrEpisodeId: episode.id,
// sonarrId,
groupId
},
groupId

View File

@@ -16,10 +16,11 @@
const dispatch = createEventDispatcher();
export let modalId: Symbol;
export let groupId: Symbol | undefined = undefined;
export let modalId: symbol;
export let groupId: symbol | undefined = undefined;
export let title = 'Releases';
export let radarrId: number | undefined = undefined;
export let sonarrEpisodeId: number | undefined = undefined;
export let seasonPack: { sonarrId: number; seasonNumber: number } | undefined = undefined;
@@ -66,21 +67,21 @@
};
}
function handleDownload(guid: string) {
function handleDownload(guid: string, indexerId: number) {
downloadFetchingGuid = guid;
if (radarrId) {
downloadRadarrMovie(guid).then((res) => {
downloadRadarrMovie(guid, indexerId).then((ok) => {
dispatch('download');
downloadFetchingGuid = undefined;
if (res.response?.ok) {
if (ok) {
downloadingGuid = guid;
}
});
} else {
downloadSonarrEpisode(guid).then((res) => {
downloadSonarrEpisode(guid, indexerId).then((ok) => {
dispatch('download');
downloadFetchingGuid = undefined;
if (res.response?.ok) {
if (ok) {
downloadingGuid = guid;
}
});
@@ -130,7 +131,10 @@
<div class="text-zinc-400">{formatSize(release?.size || 0)}</div>
{#if release.guid !== downloadingGuid}
<IconButton
on:click={() => release.guid && handleDownload(release.guid)}
on:click={() =>
release.guid &&
release.indexerId &&
handleDownload(release.guid, release.indexerId)}
disabled={downloadFetchingGuid === release.guid}
>
<Plus size={20} />

View File

@@ -8,7 +8,7 @@
import EpisodeSelectModal from './EpisodeSelectModal.svelte';
import RequestModal from './RequestModal.svelte';
export let modalId: Symbol;
export let modalId: symbol;
export let sonarrId: number;
export let seasons: number;
export let heading = 'Seasons';

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { env } from '$env/dynamic/public';
import { getDiskSpace } from '$lib/apis/radarr/radarrApi';
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';
@@ -21,7 +21,9 @@
);
const diskSpaceInfo =
(await discSpacePromise).find((disk) => disk.path === '/') || (await discSpacePromise)[0];
(await discSpacePromise).find((disk) => disk.path === '/') ||
(await discSpacePromise)[0] ||
undefined;
const spaceOccupied = availableMovies.reduce(
(acc, movie) => acc + (movie.radarrMovie?.sizeOnDisk || 0),
@@ -30,9 +32,9 @@
return {
moviesCount: availableMovies.length,
spaceLeft: diskSpaceInfo.freeSpace || 0,
spaceLeft: diskSpaceInfo?.freeSpace || 0,
spaceOccupied,
spaceTotal: diskSpaceInfo.totalSpace || 0
spaceTotal: diskSpaceInfo?.totalSpace || 0
};
}
</script>
@@ -44,7 +46,7 @@
{large}
title="Radarr"
subtitle="Movies Provider"
href={env.PUBLIC_RADARR_BASE_URL}
href={$settings.radarr.baseUrl || '#'}
stats={[
{ title: 'Movies', value: String(moviesCount) },
{ title: 'Space Taken', value: formatSize(spaceOccupied) },

View File

@@ -1,12 +1,11 @@
<script lang="ts">
import { formatSize } from '$lib/utils.js';
import { onMount } from 'svelte';
import StatsPlaceholder from './StatsPlaceholder.svelte';
import StatsContainer from './StatsContainer.svelte';
import SonarrIcon from '../svgs/SonarrIcon.svelte';
import { env } from '$env/dynamic/public';
import { getDiskSpace } from '$lib/apis/sonarr/sonarrApi';
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';
import StatsPlaceholder from './StatsPlaceholder.svelte';
export let large = false;
@@ -18,7 +17,9 @@
);
const diskSpaceInfo =
(await discSpacePromise).find((disk) => disk.path === '/') || (await discSpacePromise)[0];
(await discSpacePromise).find((disk) => disk.path === '/') ||
(await discSpacePromise)[0] ||
undefined;
const spaceOccupied = availableSeries.reduce(
(acc, series) => acc + (series.sonarrSeries?.statistics?.sizeOnDisk || 0),
@@ -32,9 +33,9 @@
return {
episodesCount,
spaceLeft: diskSpaceInfo.freeSpace || 0,
spaceLeft: diskSpaceInfo?.freeSpace || 0,
spaceOccupied,
spaceTotal: diskSpaceInfo.totalSpace || 0
spaceTotal: diskSpaceInfo?.totalSpace || 0
};
}
</script>
@@ -46,7 +47,7 @@
{large}
title="Sonarr"
subtitle="Shows Provider"
href={env.PUBLIC_SONARR_BASE_URL}
href={$settings.sonarr.baseUrl || '#'}
stats={[
{ title: 'Episodes', value: String(episodesCount) },
{ title: 'Space Taken', value: formatSize(spaceOccupied) },

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import type { LibraryItemStore } from '$lib/stores/library.store';
import type { TitleType } from '$lib/types';
import ContextMenuButton from '../ContextMenu/ContextMenuButton.svelte';
import LibraryItemContextItems from '../ContextMenu/LibraryItemContextItems.svelte';
export let title = '';
export let itemStore: LibraryItemStore;
export let type: TitleType;
export let tmdbId: number;
</script>
<ContextMenuButton heading={$itemStore.loading ? 'Loading...' : title}>
<svelte:fragment slot="menu">
<LibraryItemContextItems {itemStore} {type} {tmdbId} />
</svelte:fragment>
</ContextMenuButton>

View File

@@ -67,7 +67,7 @@
})}
>
<div
class={classNames('flex-1 relative flex pt-24 px-4 sm:px-8 pb-6', {
class={classNames('flex-1 relative flex pt-24 px-2 sm:px-4 lg:px-8 pb-6', {
'min-h-[60vh]': isModal
})}
bind:clientHeight={topHeight}
@@ -79,7 +79,7 @@
</IconButton>
</a>
<div class="absolute top-8 left-4 sm:left-8 z-10">
<button class="flex items-center sm:hidden" on:click={handleCloseModal}>
<button class="flex items-center sm:hidden font-medium" on:click={handleCloseModal}>
<ChevronLeft size={20} />
Back
</button>
@@ -127,7 +127,7 @@
</div>
<div
class={classNames('flex flex-col gap-6 bg-stone-950 px-2 sm:px-4 lg:px-8 pb-6 relative z-[1]', {
class={classNames('flex flex-col gap-6 bg-stone-950 px-2 sm:px-4 lg:px-8 pb-6 relative', {
'2xl:px-0': !isModal
})}
>

View File

@@ -7,7 +7,7 @@
export let tmdbId: number;
export let type: TitleType;
export let modalId: Symbol;
export let modalId: symbol;
function handleCloseModal() {
modalStack.close(modalId);

View File

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

View File

@@ -1,28 +1,36 @@
<script lang="ts">
import { skippedVersion } from '$lib/localstorage';
import axios from 'axios';
import IconButton from './IconButton.svelte';
import { Cross2 } from 'radix-icons-svelte';
import { log } from '$lib/utils';
import { version } from '$app/environment';
import { createLocalStorageStore } from '$lib/stores/localstorage.store';
import { Cross2 } from 'radix-icons-svelte';
import IconButton from './IconButton.svelte';
import axios from 'axios';
import Button from './Button.svelte';
let visible = true;
function fetchLatestVersion() {
const skippedVersion = createLocalStorageStore<string>('skipped-version');
async function fetchLatestVersion() {
return axios
.get('https://api.github.com/repos/aleksilassila/reiverr/tags')
.then((res) => res.data?.[0]?.name)
.then(log);
.then((res) => res.data?.[0]?.name);
}
</script>
{#await fetchLatestVersion() then latestVersion}
{#if latestVersion !== `v${version}` && latestVersion !== $skippedVersion && visible}
<div class="fixed inset-x-0 bottom-0 p-2 flex items-center justify-center z-20 bg-stone-800">
<a href="https://github.com/aleksilassila/reiverr">New version is available!</a>
<IconButton on:click={() => (visible = false)} class="absolute right-8 inset-y-0">
<Cross2 size={20} />
</IconButton>
<div
class="fixed inset-x-0 bottom-0 p-3 flex items-center justify-center z-20 bg-stone-800 text-sm"
>
<a href="https://github.com/aleksilassila/reiverr">{latestVersion} is now available!</a>
<div class="absolute right-4 inset-y-0 flex items-center gap-2">
<Button type="tertiary" size="xs" on:click={() => skippedVersion.set(latestVersion)}>
Skip this version
</Button>
<IconButton on:click={() => (visible = false)}>
<Cross2 size={20} />
</IconButton>
</div>
</div>
{/if}
{/await}

View File

@@ -0,0 +1,47 @@
<script lang="ts">
export let min = 0;
export let max = 100;
export let step = 0.01;
export let primaryValue = 0;
export let secondaryValue = 0;
let progressBarOffset = 0;
</script>
<div class="h-1 relative group">
<div class="h-full relative px-[0.5rem]">
<div class="h-full bg-zinc-200 bg-opacity-20 rounded-full overflow-hidden relative">
<!-- Secondary progress -->
<div
class="h-full bg-zinc-200 bg-opacity-20 absolute top-0"
style="width: {(secondaryValue / max) * 100}%;"
/>
<!-- Primary progress -->
<div
class="h-full bg-amber-300 absolute top-0"
style="width: {(primaryValue / max) * 100}%;"
bind:offsetWidth={progressBarOffset}
/>
</div>
<div
class="absolute w-3 h-3 bg-amber-200 rounded-full transform mx-2 -translate-x-1/2 -translate-y-1/2 top-1/2 cursor-pointer
drop-shadow-md opacity-0 group-hover:opacity-100 transition-opacity duration-100"
style="left: {progressBarOffset}px;"
/>
</div>
<input
type="range"
class="w-full absolute -top-0.5 cursor-pointer h-2 opacity-0"
{min}
{max}
{step}
bind:value={primaryValue}
on:mouseup
on:mousedown
on:touchstart
on:touchend
/>
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { env } from '$env/dynamic/public';
import {
delteActiveEncoding as deleteActiveEncoding,
getJellyfinItem,
getJellyfinPlaybackInfo,
reportJellyfinPlaybackProgress,
@@ -8,73 +8,233 @@
reportJellyfinPlaybackStopped
} from '$lib/apis/jellyfin/jellyfinApi';
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 } from 'radix-icons-svelte';
import { onDestroy } from 'svelte';
import {
Cross2,
EnterFullScreen,
ExitFullScreen,
Gear,
Pause,
Play,
SpeakerLoud,
SpeakerModerate,
SpeakerOff,
SpeakerQuiet
} from 'radix-icons-svelte';
import { onDestroy, onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { contextMenu } from '../ContextMenu/ContextMenu';
import ContextMenu from '../ContextMenu/ContextMenu.svelte';
import SelectableContextMenuItem from '../ContextMenu/SelectableContextMenuItem.svelte';
import IconButton from '../IconButton.svelte';
import { playerState } from './VideoPlayer';
import { modalStack } from '../Modal/Modal';
import Slider from './Slider.svelte';
import { playerState } from './VideoPlayer';
export let modalId: Symbol;
export let modalId: symbol;
let uiVisible = true;
let qualityContextMenuId = Symbol();
let video: HTMLVideoElement;
let videoWrapper: HTMLDivElement;
let mouseMovementTimeout: NodeJS.Timeout;
let stopCallback: () => void;
let deleteEncoding: () => void;
let reportProgress: () => void;
let progressInterval: NodeJS.Timeout;
const fetchPlaybackInfo = (itemId: string) =>
// These functions are different in every browser
let reqFullscreenFunc: ((elem: HTMLElement) => void) | undefined = undefined;
let exitFullscreen: (() => void) | undefined = undefined;
let fullscreenChangeEvent: string | undefined = undefined;
let getFullscreenElement: (() => HTMLElement) | undefined = undefined;
// Find the correct functions
let elem = document.createElement('div');
// @ts-ignore
if (elem.requestFullscreen) {
reqFullscreenFunc = (elem) => {
elem.requestFullscreen();
};
fullscreenChangeEvent = 'fullscreenchange';
getFullscreenElement = () => <HTMLElement>document.fullscreenElement;
if (document.exitFullscreen) exitFullscreen = () => document.exitFullscreen();
// @ts-ignore
} else if (elem.webkitRequestFullscreen) {
reqFullscreenFunc = (elem) => {
// @ts-ignore
elem.webkitRequestFullscreen();
};
fullscreenChangeEvent = 'webkitfullscreenchange';
// @ts-ignore
getFullscreenElement = () => <HTMLElement>document.webkitFullscreenElement;
// @ts-ignore
if (document.webkitExitFullscreen) exitFullscreen = () => document.webkitExitFullscreen();
// @ts-ignore
} else if (elem.msRequestFullscreen) {
reqFullscreenFunc = (elem) => {
// @ts-ignore
elem.msRequestFullscreen();
};
fullscreenChangeEvent = 'MSFullscreenChange';
// @ts-ignore
getFullscreenElement = () => <HTMLElement>document.msFullscreenElement;
// @ts-ignore
if (document.msExitFullscreen) exitFullscreen = () => document.msExitFullscreen();
// @ts-ignore
} else if (elem.mozRequestFullScreen) {
reqFullscreenFunc = (elem) => {
// @ts-ignore
elem.mozRequestFullScreen();
};
fullscreenChangeEvent = 'mozfullscreenchange';
// @ts-ignore
getFullscreenElement = () => <HTMLElement>document.mozFullScreenElement;
// @ts-ignore
if (document.mozCancelFullScreen) exitFullscreen = () => document.mozCancelFullScreen();
}
let paused: boolean;
let duration: number = 0;
let displayedTime: number = 0;
let bufferedTime: number = 0;
let videoLoaded: boolean = false;
let seeking: boolean = false;
let playerStateBeforeSeek: boolean;
let fullscreen: boolean = false;
let volume: number = 1;
let mute: boolean = false;
let resolution: number = 1080;
let currentBitrate: number = 0;
let shouldCloseUi = false;
let uiVisible = true;
$: uiVisible = !shouldCloseUi || seeking || paused || $contextMenu === qualityContextMenuId;
const fetchPlaybackInfo = (
itemId: string,
maxBitrate: number | undefined = undefined,
starting: boolean = true
) =>
getJellyfinItem(itemId).then((item) =>
getJellyfinPlaybackInfo(
itemId,
getDeviceProfile(),
item?.UserData?.PlaybackPositionTicks || 0
).then(async ({ playbackUri, playSessionId: sessionId, mediaSourceId, directPlay }) => {
item?.UserData?.PlaybackPositionTicks || Math.floor(displayedTime * 10_000_000),
maxBitrate || getQualities(item?.Height || 1080)[0].maxBitrate
).then(async (playbackInfo) => {
if (!playbackInfo) return;
const { playbackUri, playSessionId: sessionId, mediaSourceId, directPlay } = playbackInfo;
if (!playbackUri || !sessionId) {
console.log('No playback URL or session ID', playbackUri, sessionId);
return;
}
video.poster = item?.BackdropImageTags?.length
? `http://jellyfin.home/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;
if (!directPlay) {
if (!Hls.isSupported()) {
if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource($settings.jellyfin.baseUrl + playbackUri);
hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
/*
* HLS.js does NOT work on iOS on iPhone because Safari on iPhone does not support MSE.
* 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 = $settings.jellyfin.baseUrl + playbackUri;
} else {
throw new Error('HLS is not supported');
}
const hls = new Hls();
hls.loadSource(env.PUBLIC_JELLYFIN_URL + playbackUri);
hls.attachMedia(video);
} else {
video.src = env.PUBLIC_JELLYFIN_URL + playbackUri;
video.src = $settings.jellyfin.baseUrl + playbackUri;
}
resolution = item?.Height || 1080;
currentBitrate = maxBitrate || getQualities(resolution)[0].maxBitrate;
if (item?.UserData?.PlaybackPositionTicks) {
video.currentTime = item?.UserData?.PlaybackPositionTicks / 10_000_000;
displayedTime = item?.UserData?.PlaybackPositionTicks / 10_000_000;
}
video.play().then(() => video.requestFullscreen());
if (mediaSourceId) await reportJellyfinPlaybackStarted(itemId, sessionId, mediaSourceId);
progressInterval = setInterval(() => {
video && video.readyState === 4 && video?.currentTime > 0 && sessionId && itemId;
reportJellyfinPlaybackProgress(
// We should not requestFullscreen automatically, as it's not what
// the user expects. Moreover, most browsers will deny the request
// if the video takes a while to load.
// video.play().then(() => videoWrapper.requestFullscreen());
// A start report should only be sent when the video starts playing,
// not every time a playback info request is made
if (mediaSourceId && starting)
await reportJellyfinPlaybackStarted(itemId, sessionId, mediaSourceId);
reportProgress = async () => {
await reportJellyfinPlaybackProgress(
itemId,
sessionId,
video?.paused == true,
video?.currentTime * 10_000_000
);
};
if (progressInterval) clearInterval(progressInterval);
progressInterval = setInterval(() => {
video && video.readyState === 4 && video?.currentTime > 0 && sessionId && itemId;
reportProgress();
}, 5000);
deleteEncoding = () => {
deleteActiveEncoding(sessionId);
};
stopCallback = () => {
reportJellyfinPlaybackStopped(itemId, sessionId, video?.currentTime * 10_000_000);
deleteEncoding();
};
})
);
function onSeekStart() {
if (seeking) return;
playerStateBeforeSeek = paused;
seeking = true;
paused = true;
}
function onSeekEnd() {
if (!seeking) return;
paused = playerStateBeforeSeek;
seeking = false;
video.currentTime = displayedTime;
}
function handleBuffer() {
let timeRanges = video.buffered;
// Find the first one whose end time is after the current time
// (the time ranges given by the browser are normalized, which means
// that they are sorted and non-overlapping)
for (let i = 0; i < timeRanges.length; i++) {
if (timeRanges.end(i) > video.currentTime) {
bufferedTime = timeRanges.end(i);
break;
}
}
}
function handleClose() {
playerState.close();
video?.pause();
@@ -83,38 +243,235 @@
modalStack.close(modalId);
}
function handleMouseMove() {
// uiVisible = true;
// clearTimeout(mouseMovementTimeout);
// mouseMovementTimeout = setTimeout(() => {
// uiVisible = false;
// }, 2000);
function handleUserInteraction(touch: boolean = false) {
if (touch) shouldCloseUi = !shouldCloseUi;
else shouldCloseUi = false;
if (!shouldCloseUi) {
if (mouseMovementTimeout) clearTimeout(mouseMovementTimeout);
mouseMovementTimeout = setTimeout(() => {
shouldCloseUi = true;
}, 3000);
} else {
if (mouseMovementTimeout) clearTimeout(mouseMovementTimeout);
}
}
onDestroy(() => clearInterval(progressInterval));
function handleQualityToggleVisibility() {
if ($contextMenu === qualityContextMenuId) contextMenu.hide();
else contextMenu.show(qualityContextMenuId);
}
async function handleSelectQuality(bitrate: number) {
if (!$playerState.jellyfinId || !video || seeking) return;
if (bitrate === currentBitrate) return;
currentBitrate = bitrate;
video.pause();
let timeBeforeLoad = video.currentTime;
let stateBeforeLoad = paused;
await reportProgress?.();
await deleteEncoding?.();
await fetchPlaybackInfo?.($playerState.jellyfinId, bitrate, false);
displayedTime = timeBeforeLoad;
paused = stateBeforeLoad;
}
function secondsToTime(seconds: number, forceHours = false) {
if (isNaN(seconds)) return '00:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds - hours * 3600) / 60);
const secondsLeft = Math.floor(seconds - hours * 3600 - minutes * 60);
let str = '';
if (hours > 0 || forceHours) str += `${hours}:`;
if (minutes >= 10) str += `${minutes}:`;
else str += `0${minutes}:`;
if (secondsLeft >= 10) str += `${secondsLeft}`;
else str += `0${secondsLeft}`;
return str;
}
onMount(() => {
// Workaround because the paused state does not sync
// with the video element until a change is made
paused = false;
$: {
if (video && $playerState.jellyfinId) {
if (video.src === '') fetchPlaybackInfo($playerState.jellyfinId);
}
});
onDestroy(() => {
clearInterval(progressInterval);
if (fullscreen) exitFullscreen?.();
});
$: {
if (fullscreen && !getFullscreenElement?.()) {
if (reqFullscreenFunc) reqFullscreenFunc(videoWrapper);
} else if (getFullscreenElement?.()) {
if (exitFullscreen) exitFullscreen();
}
}
// We add a listener to the fullscreen change event to update the fullscreen variable
// since it can be changed by the user by other means than the button
if (fullscreenChangeEvent) {
document.addEventListener(fullscreenChangeEvent, () => {
fullscreen = !!getFullscreenElement?.();
});
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="bg-black w-screen h-screen relative flex items-center justify-center"
on:mousemove={handleMouseMove}
class={classNames(
'bg-black w-screen h-[100dvh] sm:h-screen relative flex items-center justify-center',
{
'cursor-none': !uiVisible
}
)}
>
<!-- svelte-ignore a11y-media-has-caption -->
<video controls bind:this={video} class="sm:w-full sm:h-full" />
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class={classNames('absolute top-4 right-8 transition-opacity z-50', {
'opacity-0': !uiVisible,
'opacity-100': uiVisible
})}
class="bg-black w-screen h-screen flex items-center justify-center"
bind:this={videoWrapper}
on:mousemove={() => handleUserInteraction(false)}
on:touchend|preventDefault={() => handleUserInteraction(true)}
on:dblclick|preventDefault={() => (fullscreen = !fullscreen)}
on:click={() => (paused = !paused)}
>
<IconButton on:click={handleClose}>
<Cross2 size={25} />
</IconButton>
<!-- svelte-ignore a11y-media-has-caption -->
<video
bind:this={video}
bind:paused
bind:duration
on:timeupdate={() =>
(displayedTime = !seeking && videoLoaded ? video.currentTime : displayedTime)}
on:progress={() => handleBuffer()}
on:play={() => {
if (seeking) video?.pause();
}}
on:loadeddata={() => {
video.currentTime = displayedTime;
videoLoaded = true;
}}
bind:volume
bind:muted={mute}
class="sm:w-full sm:h-full"
playsinline={true}
/>
{#if uiVisible}
<!-- Video controls -->
<div
class="absolute bottom-0 w-screen bg-gradient-to-t from-black/[.8] via-60% via-black-opacity-80 to-transparent"
on:touchend|stopPropagation
transition:fade={{ duration: 100 }}
>
<div class="flex flex-col items-center p-4 gap-2 w-full">
<div class="flex items-center text-sm w-full">
<span class="whitespace-nowrap tabular-nums"
>{secondsToTime(displayedTime, duration > 3600)}</span
>
<div class="flex-grow">
<Slider
bind:primaryValue={displayedTime}
secondaryValue={bufferedTime}
max={duration}
on:mousedown={onSeekStart}
on:mouseup={onSeekEnd}
on:touchstart={onSeekStart}
on:touchend={onSeekEnd}
/>
</div>
<span class="whitespace-nowrap tabular-nums">{secondsToTime(duration)}</span>
</div>
<div class="flex items-center justify-between mb-2 w-full">
<IconButton on:click={() => (paused = !paused)}>
{#if (!seeking && paused) || (seeking && playerStateBeforeSeek)}
<Play size={20} />
{:else}
<Pause size={20} />
{/if}
</IconButton>
<div class="flex items-center space-x-3">
<div class="relative">
<ContextMenu
heading="Quality"
position="absolute"
bottom={true}
id={qualityContextMenuId}
>
<svelte:fragment slot="menu">
{#each getQualities(resolution) as quality}
<SelectableContextMenuItem
selected={quality.maxBitrate === currentBitrate}
on:click={() => handleSelectQuality(quality.maxBitrate)}
>
{quality.name}
</SelectableContextMenuItem>
{/each}
</svelte:fragment>
<IconButton on:click={handleQualityToggleVisibility}>
<Gear size={20} />
</IconButton>
</ContextMenu>
</div>
<IconButton
on:click={() => {
mute = !mute;
}}
>
{#if volume == 0 || mute}
<SpeakerOff size={20} />
{:else if volume < 0.25}
<SpeakerQuiet size={20} />
{:else if volume < 0.9}
<SpeakerModerate size={20} />
{:else}
<SpeakerLoud size={20} />
{/if}
</IconButton>
<div class="w-32">
<Slider bind:primaryValue={volume} secondaryValue={0} max={1} />
</div>
{#if reqFullscreenFunc}
<IconButton on:click={() => (fullscreen = !fullscreen)}>
{#if fullscreen}
<ExitFullScreen size={20} />
{:else if !fullscreen && exitFullscreen}
<EnterFullScreen size={20} />
{/if}
</IconButton>
<!-- Edge case to allow fullscreen on iPhone -->
{:else if video?.webkitEnterFullScreen}
<IconButton on:click={() => video.webkitEnterFullScreen()}>
<EnterFullScreen size={20} />
</IconButton>
{/if}
</div>
</div>
</div>
</div>
{/if}
</div>
{#if uiVisible}
<div class="absolute top-4 right-8 z-50" transition:fade={{ duration: 100 }}>
<IconButton on:click={handleClose}>
<Cross2 size={25} />
</IconButton>
</div>
{/if}
</div>

View File

@@ -7,7 +7,7 @@
<div class="overflow-hidden w-full h-full">
<div class="youtube-container scale-[150%] h-full w-full">
<iframe
src={'https://www.youtube.com/embed/' +
src={'https://www.youtube-nocookie.com/embed/' +
videoId +
'?autoplay=1&mute=1&loop=1&controls=0&modestbranding=1&playsinline=1&rel=0&enablejsapi=1'}
title="YouTube video player"

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -23,13 +23,15 @@ import {
getTmdbSeriesBackdrop,
getTmdbSeriesFromTvdbId
} from '$lib/apis/tmdb/tmdbApi';
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;
cardBackdropUrl: string;
posterUri: string;
backdropUrl: string;
posterUrl: string;
download?: {
progress: number;
completionTime: string | undefined;
@@ -76,11 +78,12 @@ async function getLibrary(): Promise<Library> {
const sonarrSeries = await sonarrSeriesPromise;
const sonarrDownloads = await sonarrDownloadsPromise;
const jellyfinContinueWatching = await jellyfinContinueWatchingPromise;
const jellyfinLibraryItems = await jellyfinLibraryItemsPromise;
const jellyfinEpisodes = await jellyfinEpisodesPromise.then((episodes) =>
episodes?.sort((a, b) => (a.IndexNumber || 99) - (b.IndexNumber || 99))
);
const jellyfinContinueWatching = (await jellyfinContinueWatchingPromise) || [];
const jellyfinLibraryItems = (await jellyfinLibraryItemsPromise) || [];
const jellyfinEpisodes =
(await jellyfinEpisodesPromise.then((episodes) =>
episodes?.sort((a, b) => (a.IndexNumber || 99) - (b.IndexNumber || 99))
)) || [];
const jellyfinNextUp = await jellyfinNextUpPromise;
const items: Record<string, PlayableItem> = {};
@@ -112,15 +115,15 @@ async function getLibrary(): Promise<Library> {
? { length, progress: watchingProgress }
: undefined;
const backdropUrl = await getTmdbMovieBackdrop(radarrMovie.tmdbId || 0);
const backdropUri = await getTmdbMovieBackdrop(radarrMovie.tmdbId || 0);
const posterUri = await getTmdbMoviePoster(radarrMovie.tmdbId || 0);
return {
const playableItem: PlayableItem = {
type: 'movie' as const,
tmdbId: radarrMovie.tmdbId || 0,
tmdbRating: radarrMovie.ratings?.tmdb?.value || 0,
cardBackdropUrl: backdropUrl || '',
posterUri: posterUri || '',
backdropUrl: backdropUri ? TMDB_BACKDROP_SMALL + backdropUri : '',
posterUrl: posterUri ? TMDB_POSTER_SMALL + posterUri : '',
download,
continueWatching,
isPlayed: jellyfinItem?.UserData?.Played || false,
@@ -129,6 +132,8 @@ async function getLibrary(): Promise<Library> {
radarrMovie,
radarrDownloads: itemRadarrDownloads
};
return playableItem;
});
const seriesPromise: Promise<PlayableItem>[] = sonarrSeries.map(async (sonarrSeries) => {
@@ -149,7 +154,7 @@ async function getLibrary(): Promise<Library> {
const nextJellyfinEpisode = jellyfinItem
? jellyfinContinueWatching.find((i) => i.SeriesId === jellyfinItem?.Id) ||
jellyfinNextUp.find((i) => i.SeriesId === jellyfinItem?.Id)
jellyfinNextUp?.find((i) => i.SeriesId === jellyfinItem?.Id)
: undefined;
const length = nextJellyfinEpisode?.RunTimeTicks
@@ -169,12 +174,12 @@ async function getLibrary(): Promise<Library> {
const backdropUrl = await getTmdbSeriesBackdrop(tmdbId || 0);
const posterUri = tmdbItem?.poster_path || '';
return {
const playableItem: PlayableItem = {
type: 'series' as const,
tmdbId: tmdbId || 0,
tmdbRating: tmdbItem?.vote_average || 0,
cardBackdropUrl: backdropUrl || '',
posterUri,
backdropUrl: backdropUrl ? TMDB_BACKDROP_SMALL + backdropUrl : '',
posterUrl: posterUri ? TMDB_POSTER_SMALL + posterUri : '',
download,
continueWatching,
isPlayed: jellyfinItem?.UserData?.Played || false,
@@ -185,9 +190,59 @@ async function getLibrary(): Promise<Library> {
jellyfinEpisodes: jellyfinEpisodes.filter((i) => i.SeriesId === jellyfinItem?.Id),
nextJellyfinEpisode
};
return playableItem;
});
await Promise.all([...moviesPromise, ...seriesPromise]).then((r) =>
const jellyfinSourceItems = jellyfinLibraryItems
.filter(
(i) =>
!radarrMovies.find((m) => m.tmdbId === Number(i.ProviderIds?.Tmdb)) &&
!sonarrSeries.find((s) => s.tvdbId === Number(i.ProviderIds?.Tvdb))
)
.map((jellyfinItem) => {
const itemJellyfinEpisodes = jellyfinEpisodes.filter((e) => e.SeriesId === jellyfinItem.Id);
const jellyfinNextUpEpisode =
jellyfinNextUp?.find((e) => e.SeriesId === jellyfinItem.Id) ||
jellyfinContinueWatching.find((e) => e.SeriesId === jellyfinItem.Id);
const length = jellyfinNextUpEpisode?.RunTimeTicks
? jellyfinNextUpEpisode.RunTimeTicks / 10_000_000 / 60
: undefined;
const watchingProgress = jellyfinNextUpEpisode?.UserData?.PlayedPercentage;
const continueWatching =
length && watchingProgress && !!jellyfinNextUpEpisode
? { length, progress: watchingProgress }
: undefined;
const tmdbId = Number(jellyfinItem.ProviderIds?.Tmdb);
const backdropUrl = jellyfinItem.BackdropImageTags?.length
? `http://jellyfin.home/Items/${jellyfinItem?.Id}/Images/Backdrop?quality=100&tag=${jellyfinItem?.BackdropImageTags?.[0]}`
: '';
const posterUri = jellyfinItem.ImageTags?.Primary
? `http://jellyfin.home/Items/${jellyfinItem?.Id}/Images/Backdrop?quality=100&tag=${jellyfinItem?.ImageTags?.Primary}`
: '';
const type: TitleType = jellyfinItem.Type === 'Movie' ? 'movie' : 'series';
const playableItem: PlayableItem = {
type,
tmdbId,
tmdbRating: jellyfinItem.CommunityRating || 0,
backdropUrl: backdropUrl,
posterUrl: posterUri,
continueWatching,
isPlayed: jellyfinItem.UserData?.Played || false,
jellyfinId: jellyfinItem.Id,
jellyfinItem,
jellyfinEpisodes: itemJellyfinEpisodes,
nextJellyfinEpisode: jellyfinNextUpEpisode
};
return playableItem;
});
await Promise.all([...moviesPromise, ...seriesPromise, ...jellyfinSourceItems]).then((r) =>
r.forEach((item) => {
items[item.tmdbId] = item;
})
@@ -202,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);
@@ -245,6 +314,7 @@ function _createLibraryItemStore(tmdbId: number) {
const itemStores: Record<string, ReturnType<typeof _createLibraryItemStore>> = {};
export type LibraryItemStore = ReturnType<typeof _createLibraryItemStore>;
export function createLibraryItemStore(tmdbId: number) {
if (!itemStores[tmdbId]) {
itemStores[tmdbId] = _createLibraryItemStore(tmdbId);

View File

@@ -1,11 +1,11 @@
import { writable } from 'svelte/store';
function createLocalStorageStore(key: string) {
const store = writable(JSON.parse(localStorage.getItem(key) || 'null') || null);
export function createLocalStorageStore<T>(key: string) {
const store = writable<T | null>(JSON.parse(localStorage.getItem(key) || 'null') || null);
return {
subscribe: store.subscribe,
set: (value: any) => {
set: (value: T | null) => {
localStorage.setItem(key, JSON.stringify(value));
store.set(value);
}

View File

@@ -1,59 +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;
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: {
apiKey: null,
baseUrl: null,
userId: null
}
};
export const settings = writable<Settings>(defaultSettings);
export const getIncludedLanguagesQuery = () => {
const settingsValue = get(settings);
if (settingsValue.discover.filterBasedOnLanguage) {
return { with_original_language: settingsValue.language };
}
return {};
};
export const settings = writable<SettingsValues>(defaultSettings);

View File

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

View File

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

View File

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

View File

@@ -3,26 +3,28 @@
import I18n from '$lib/components/Lang/I18n.svelte';
import DynamicModal from '$lib/components/Modal/DynamicModal.svelte';
import Navbar from '$lib/components/Navbar/Navbar.svelte';
import SetupRequired from '$lib/components/SetupRequired/SetupRequired.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}
<I18n />
<div class="app">
<Navbar />
<main>
<slot />
</main>
{#key $page.url.pathname}
<DynamicModal />
{/key}
<UpdateChecker />
</div>
{:else}
<SetupRequired missingEnvironmentVariables={data.missingEnvironmentVariables} />
{/if}
<!-- {#if data.isApplicationSetUp} -->
<I18n />
<div class="app">
<Navbar />
<main>
<slot />
</main>
{#key $page.url.pathname}
<DynamicModal />
{/key}
<UpdateChecker />
</div>
<!-- {:else} -->
<!-- <SetupRequired missingEnvironmentVariables={data.missingEnvironmentVariables} /> -->
<!-- {/if} -->

View File

@@ -1,5 +1,3 @@
import type { LayoutLoad } from './$types';
import { env } from '$env/dynamic/public';
// import { dev } from '$app/environment';
// Disable SSR when running the dev server
@@ -7,34 +5,3 @@ import { env } from '$env/dynamic/public';
// 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 =
!!env.PUBLIC_RADARR_API_KEY &&
!!env.PUBLIC_RADARR_BASE_URL &&
!!env.PUBLIC_SONARR_API_KEY &&
!!env.PUBLIC_SONARR_BASE_URL &&
!!env.PUBLIC_JELLYFIN_API_KEY &&
!!env.PUBLIC_JELLYFIN_URL;
return {
isApplicationSetUp,
missingEnvironmentVariables: {
PUBLIC_RADARR_API_KEY: !env.PUBLIC_RADARR_API_KEY,
PUBLIC_RADARR_BASE_URL: !env.PUBLIC_RADARR_BASE_URL,
PUBLIC_SONARR_API_KEY: !env.PUBLIC_SONARR_API_KEY,
PUBLIC_SONARR_BASE_URL: !env.PUBLIC_SONARR_BASE_URL,
PUBLIC_JELLYFIN_API_KEY: !env.PUBLIC_JELLYFIN_API_KEY,
PUBLIC_JELLYFIN_URL: !env.PUBLIC_JELLYFIN_URL
}
};
}) satisfies LayoutLoad;

View File

@@ -8,6 +8,8 @@
import type { ComponentProps } from 'svelte';
import { _ } from 'svelte-i18n';
let continueWatchingVisible = true;
const tmdbPopularMoviesPromise = getTmdbPopularMovies()
.then((movies) => Promise.all(movies.map((movie) => getTmdbMovie(movie.id || 0))))
.then((movies) => movies.filter((m) => !!m).slice(0, 10));
@@ -16,21 +18,22 @@
.then((libraryData) => libraryData.continueWatching)
.then((items) =>
items.map((item) =>
item.radarrMovie
item.type === 'movie'
? {
type: 'movie',
tmdbId: item.tmdbId || 0,
jellyfinId: item.jellyfinId,
backdropUri: item.posterUri || '',
title: item.radarrMovie.title || '',
subtitle: item.radarrMovie.genres?.join(', ') || '',
backdropUrl: item.posterUrl || '',
title: item.radarrMovie?.title || item.jellyfinItem?.Name || '',
subtitle: item.radarrMovie?.genres?.join(', ') || '',
progress: item.continueWatching?.progress,
runtime: item.radarrMovie.runtime || 0
runtime: item.radarrMovie?.runtime || 0
}
: {
tmdbId: item.tmdbId || 0,
jellyfinId: item.nextJellyfinEpisode?.Id,
type: 'series',
backdropUri: item.posterUri || '',
backdropUrl: item.posterUrl || '',
title: item.nextJellyfinEpisode?.Name || item.sonarrSeries?.title || '',
subtitle:
(item.nextJellyfinEpisode?.IndexNumber &&
@@ -45,6 +48,12 @@
)
);
continueWatchingProps.then((props) => {
if (props.length === 0) {
continueWatchingVisible = false;
}
});
let showcaseIndex = 0;
async function onNext() {
@@ -84,7 +93,7 @@
{/await}
</div>
<div class="py-8">
<div class="py-8" hidden={!continueWatchingVisible}>
<Carousel gradientFromColor="from-stone-950" class="px-4 lg:px-16 2xl:px-32">
<div slot="title" class="text-xl font-medium text-zinc-200">Continue Watching</div>
{#await continueWatchingProps}

View File

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

View File

@@ -1,13 +1,5 @@
<script lang="ts">
import {
TmdbApiOpen,
getTmdbDigitalReleases,
getTmdbTrendingAll,
getTmdbUpcomingMovies,
getTrendingActors,
type TmdbMovie2,
type TmdbSeries2
} from '$lib/apis/tmdb/tmdbApi';
import { TmdbApiOpen, type TmdbMovie2, type TmdbSeries2 } from '$lib/apis/tmdb/tmdbApi';
import Card from '$lib/components/Card/Card.svelte';
import { fetchCardTmdbProps } from '$lib/components/Card/card';
import Carousel from '$lib/components/Carousel/Carousel.svelte';
@@ -17,43 +9,33 @@
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';
import { _ } from 'svelte-i18n';
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.backdropUri));
).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
}
}
})
@@ -61,19 +43,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"

View File

@@ -17,6 +17,7 @@
let sortBy: 'added' | 'rating' | 'release' | 'size' | 'name' = 'name';
let loading = true;
let noItems = false;
let searchInput: HTMLInputElement | undefined;
let searchInputValue = '';
@@ -32,14 +33,14 @@
let nextUpProps: ComponentProps<Card>[] = [];
$: {
if (items.length) updateComponentProps(searchInputValue);
}
$: if (items.length) updateComponentProps(searchInputValue);
$: if (!downloadingProps.length && nextUpProps.length) openNextUpTab = 'nextUp';
library.subscribe(async (libraryPromise) => {
const libraryData = await libraryPromise;
items = libraryData.itemsArray;
loading = false;
noItems = !items.length;
});
function getComponentProps(item: PlayableItem) {
@@ -48,30 +49,28 @@
const series = item.sonarrSeries;
const movie = item.radarrMovie;
if (series) {
if (item.type === 'series') {
props = {
size: 'dynamic',
type: 'series',
tmdbId: item.tmdbId,
title: series.title || '',
genres: series.genres || [],
backdropUri: item.cardBackdropUrl,
rating: series.ratings?.value || series.ratings?.value || item.tmdbRating || 0,
seasons: series.seasons?.length || 0,
jellyfinId: item.sonarrSeries?.statistics?.sizeOnDisk ? item.jellyfinId : undefined,
title: series?.title || item.jellyfinItem?.Name || '',
genres: series?.genres || [],
backdropUrl: item.backdropUrl,
rating: series?.ratings?.value || series?.ratings?.value || item.tmdbRating || 0,
seasons: series?.seasons?.length || 0,
progress: item.nextJellyfinEpisode?.UserData?.PlayedPercentage || undefined
};
} else if (movie) {
} else if (item.type === 'movie') {
props = {
size: 'dynamic',
type: 'movie',
tmdbId: item.tmdbId,
title: movie.title || '',
genres: movie.genres || [],
backdropUri: item.cardBackdropUrl,
rating: movie.ratings?.tmdb?.value || movie.ratings?.imdb?.value || 0,
runtimeMinutes: movie.runtime || 0,
jellyfinId: item.radarrMovie?.movieFile ? item.jellyfinId : undefined,
title: movie?.title || item.jellyfinItem?.Name || '',
genres: movie?.genres || [],
backdropUrl: item.backdropUrl,
rating: movie?.ratings?.tmdb?.value || movie?.ratings?.imdb?.value || 0,
runtimeMinutes: movie?.runtime || 0,
progress: item.jellyfinItem?.UserData?.PlayedPercentage || undefined
};
} else props = undefined;
@@ -143,9 +142,6 @@
if (!props) continue;
const series = item.sonarrSeries;
const movie = item.radarrMovie;
if (item.download) {
downloadingProps.push({
...props,
@@ -155,8 +151,8 @@
} else if (item.isPlayed) {
watchedProps.push({ ...props, available: false });
} else if (
(movie?.isAvailable && movie?.movieFile) ||
series?.seasons?.find((season) => !!season?.statistics?.episodeFileCount)
(item.type === 'movie' && item.jellyfinItem?.RunTimeTicks) ||
item.jellyfinEpisodes?.length
) {
availableProps.push(props);
} else {
@@ -189,143 +185,155 @@
<svelte:window on:keydown={handleShortcuts} />
<div
style={"background-image: url('" +
TMDB_IMAGES_ORIGINAL +
(downloadingProps[0]?.backdropUri || nextUpProps[0]?.backdropUri) +
"');"}
class="absolute inset-0 h-[50vh] bg-center bg-cover"
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" />
</div>
{#if noItems}
<div
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 }}
>
<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
class="pt-24 pb-4 px-2 md:px-8 relative bg-center bg-cover"
in:fade|global={{
duration: $settings.animationDuration,
delay: $settings.animationDuration
}}
out:fade|global={{ duration: $settings.animationDuration }}
>
<div class="relative grid grid-cols-3 grid-rows-3 z-[1] max-w-screen-2xl mx-auto">
<div class="col-start-1 row-start-2 row-span-2 col-span-3 flex justify-end flex-col">
{#if downloadingProps.length || nextUpProps.length}
<Carousel>
<div slot="title" class="flex items-center gap-6 font-medium text-xl text-zinc-400">
<div
class="pt-24 pb-4 px-2 md:px-8 relative bg-center bg-cover"
in:fade|global={{
duration: $settings.animationDuration,
delay: $settings.animationDuration
}}
out:fade|global={{ duration: $settings.animationDuration }}
>
<div class="relative grid grid-cols-3 grid-rows-3 z-[1] max-w-screen-2xl mx-auto">
<div class="col-start-1 row-start-2 row-span-2 col-span-3 flex justify-end flex-col">
{#if downloadingProps.length || nextUpProps.length}
<Carousel>
<div slot="title" class="flex items-center gap-6 font-medium text-xl text-zinc-400">
{#if downloadingProps.length}
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openNextUpTab === 'downloading'
})}
on:click={() => (openNextUpTab = 'downloading')}
>
Download Queue
</button>
{/if}
{#if nextUpProps.length}
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openNextUpTab === 'nextUp'
})}
on:click={() => (openNextUpTab = 'nextUp')}
>
Next Up
</button>
{/if}
</div>
{#if downloadingProps.length && openNextUpTab === 'downloading'}
{#each downloadingProps as props (props.tmdbId)}
<Card {...props} size="md" />
{/each}
{:else if openNextUpTab === 'nextUp'}
{#each nextUpProps as props (props.tmdbId)}
<Card {...props} size="md" />
{/each}
{/if}
</Carousel>
{/if}
</div>
</div>
</div>
<div
class="py-4 px-2 md:px-8"
in:fade|global={{
duration: $settings.animationDuration,
delay: $settings.animationDuration
}}
out:fade|global={{ duration: $settings.animationDuration }}
>
<div class="max-w-screen-2xl m-auto flex flex-col gap-4">
<div class="flex items-center justify-between gap-2">
<UiCarousel>
<div class="flex gap-6 text-lg font-medium text-zinc-400">
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openNextUpTab === 'downloading'
'text-zinc-200': openTab === 'available'
})}
on:click={() => (openNextUpTab = 'downloading')}
on:click={() => (openTab = 'available')}
>
Download Queue
{$_('libraryAvailable')}
</button>
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openNextUpTab === 'nextUp'
'text-zinc-200': openTab === 'watched'
})}
on:click={() => (openNextUpTab = 'nextUp')}
on:click={() => (openTab = 'watched')}
>
Next Up
{$_('libraryWatched')}
</button>
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openTab === 'unavailable'
})}
on:click={() => (openTab = 'unavailable')}
>
{$_('libraryUnavailable')}
</button>
</div>
{#if downloadingProps.length && openNextUpTab === 'downloading'}
{#each downloadingProps as props (props.tmdbId)}
<Card {...props} size="md" />
{/each}
{:else if openNextUpTab === 'nextUp'}
{#each nextUpProps as props (props.tmdbId)}
<Card {...props} size="md" />
{/each}
{/if}
</Carousel>
</UiCarousel>
<div class="flex items-center gap-3 justify-end flex-shrink-0 flex-initial relative">
<IconButton>
<div class="flex gap-0.5 items-center text-sm">
<span>By Title</span>
<CaretDown size={20} />
</div>
</IconButton>
<IconButton>
<Gear size={20} />
</IconButton>
</div>
</div>
{#if loading}
<div
class="grid gap-x-2 sm:gap-x-4 gap-y-4 sm:gap-y-8 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
>
{#each [...Array(20).keys()] as index (index)}
<CardPlaceholder size="dynamic" {index} />
{/each}
</div>
{:else}
<div
class="grid gap-x-2 sm:gap-x-4 gap-y-4 sm:gap-y-8 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
>
{#each { available: availableProps, watched: watchedProps, unavailable: unavailableProps }[openTab] as props (props.tmdbId)}
<Card {...props} />
{:else}
<div class="flex-1 flex items-center text-zinc-500">No items.</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
<div
class="py-4 px-2 md:px-8"
in:fade|global={{
duration: $settings.animationDuration,
delay: $settings.animationDuration
}}
out:fade|global={{ duration: $settings.animationDuration }}
>
<div class="max-w-screen-2xl m-auto flex flex-col gap-4">
<div class="flex items-center justify-between gap-2">
<UiCarousel>
<div class="flex gap-6 text-lg font-medium text-zinc-400">
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openTab === 'available'
})}
on:click={() => (openTab = 'available')}
>
{$_('libraryAvailable')}
</button>
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openTab === 'watched'
})}
on:click={() => (openTab = 'watched')}
>
{$_('libraryWatched')}
</button>
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openTab === 'unavailable'
})}
on:click={() => (openTab = 'unavailable')}
>
{$_('libraryUnavailable')}
</button>
</div>
</UiCarousel>
<div class="flex items-center gap-3 justify-end flex-shrink-0 flex-initial relative">
<IconButton>
<div class="flex gap-0.5 items-center text-sm">
<span>By Title</span>
<CaretDown size={20} />
</div>
</IconButton>
<IconButton>
<Gear size={20} />
</IconButton>
</div>
</div>
{#if loading}
<div
class="grid gap-x-2 sm:gap-x-4 gap-y-4 sm:gap-y-8 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
>
{#each [...Array(20).keys()] as index (index)}
<CardPlaceholder size="dynamic" {index} />
{/each}
</div>
{:else}
<div
class="grid gap-x-2 sm:gap-x-4 gap-y-4 sm:gap-y-8 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
>
{#if openTab === 'available'}
{#each availableProps as props (props.tmdbId)}
<Card {...props} />
{/each}
{:else if openTab === 'watched'}
{#each watchedProps as props (props.tmdbId)}
<Card {...props} />
{/each}
{:else if openTab === 'unavailable'}
{#each unavailableProps as props (props.tmdbId)}
<Card {...props} />
{/each}
{/if}
</div>
{/if}
</div>
</div>
{/if}

View File

@@ -13,9 +13,11 @@
import PeopleCard from '$lib/components/PeopleCard/PeopleCard.svelte';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import RequestModal from '$lib/components/RequestModal/RequestModal.svelte';
import OpenInButton from '$lib/components/TitlePageLayout/OpenInButton.svelte';
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';
@@ -31,10 +33,10 @@
const tmdbMoviePromise = getTmdbMovie(tmdbId);
const tmdbRecommendationProps = getTmdbMovieRecommendations(tmdbId)
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
.then((r) => r.filter((p) => p.backdropUri));
.then((r) => r.filter((p) => p.backdropUrl));
const tmdbSimilarProps = getTmdbMovieSimilar(tmdbId)
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
.then((r) => r.filter((p) => p.backdropUri));
.then((r) => r.filter((p) => p.backdropUrl));
const castProps: Promise<ComponentProps<PeopleCard>[]> = tmdbMoviePromise.then((m) =>
Promise.all(
m?.credits?.cast?.slice(0, 20).map((m) => ({
@@ -46,7 +48,7 @@
)
);
function stream() {
function play() {
if ($itemStore.item?.jellyfinItem?.Id)
playerState.streamJellyfinId($itemStore.item?.jellyfinItem?.Id);
}
@@ -112,21 +114,28 @@
</svelte:fragment>
<svelte:fragment slot="title-right">
{#if $itemStore.loading}
<div class="placeholder h-10 w-48 rounded-xl" />
{:else if $itemStore.item?.jellyfinItem}
<Button type="primary" on:click={stream}>
<span>Stream</span><ChevronRight size={20} />
</Button>
{:else if !$itemStore.item?.radarrMovie}
<Button type="primary" disabled={addToRadarrLoading} on:click={addToRadarr}>
<span>Add to Radarr</span><Plus size={20} />
</Button>
{:else}
<Button type="primary" on:click={openRequestModal}>
<span class="mr-2">Request Movie</span><Plus size={20} />
</Button>
{/if}
<div
class="flex gap-2 items-center flex-row-reverse justify-end lg:flex-row lg:justify-start"
>
{#if $itemStore.loading}
<div class="placeholder h-10 w-48 rounded-xl" />
{:else}
<OpenInButton title={movie?.title} {itemStore} type="movie" {tmdbId} />
{#if $itemStore.item?.jellyfinItem}
<Button type="primary" on:click={play}>
<span>Watch</span><ChevronRight size={20} />
</Button>
{: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>
{:else if $itemStore.item?.radarrMovie}
<Button type="primary" on:click={openRequestModal}>
<span class="mr-2">Request Movie</span><Plus size={20} />
</Button>
{/if}
{/if}
</div>
</svelte:fragment>
<svelte:fragment slot="info-components">

View File

@@ -17,9 +17,11 @@
import { modalStack } from '$lib/components/Modal/Modal';
import PeopleCard from '$lib/components/PeopleCard/PeopleCard.svelte';
import SeriesRequestModal from '$lib/components/RequestModal/SeriesRequestModal.svelte';
import OpenInButton from '$lib/components/TitlePageLayout/OpenInButton.svelte';
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';
@@ -64,7 +66,7 @@
);
const tmdbSimilarProps = getTmdbSeriesSimilar(tmdbId)
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
.then((r) => r.filter((p) => p.backdropUri));
.then((r) => r.filter((p) => p.backdropUrl));
const castProps: Promise<ComponentProps<PeopleCard>[]> = tmdbSeriesPromise.then((s) =>
Promise.all(
s?.aggregate_credits?.cast?.slice(0, 20)?.map((m) => ({
@@ -105,6 +107,7 @@
episodeProps[season?.season_number || 0] = episodes;
});
if (!nextJellyfinEpisode) nextJellyfinEpisode = libraryItem.item?.jellyfinEpisodes?.[0];
visibleSeasonNumber = nextJellyfinEpisode?.ParentIndexNumber || visibleSeasonNumber || 1;
});
@@ -171,21 +174,31 @@
</svelte:fragment>
<svelte:fragment slot="title-right">
{#if $itemStore.loading}
<div class="placeholder h-10 w-48 rounded-xl" />
{:else if $itemStore.item?.sonarrSeries?.statistics?.sizeOnDisk}
<Button type="primary" on:click={playNextEpisode}>
<span>Next Episode</span><ChevronRight size={20} />
</Button>
{:else if !$itemStore.item?.sonarrSeries}
<Button type="primary" disabled={addToSonarrLoading} on:click={addToSonarr}>
<span>Add to Sonarr</span><Plus size={20} />
</Button>
{:else}
<Button type="primary" on:click={openRequestModal}>
<span class="mr-2">Request Series</span><Plus size={20} />
</Button>
{/if}
<div
class="flex gap-2 items-center flex-row-reverse justify-end lg:flex-row lg:justify-start"
>
{#if $itemStore.loading}
<div class="placeholder h-10 w-48 rounded-xl" />
{:else}
<OpenInButton title={series?.name} {itemStore} type="series" {tmdbId} />
{#if $itemStore.item?.jellyfinEpisodes?.length && !!nextJellyfinEpisode}
<Button type="primary" on:click={playNextEpisode}>
<span>
Watch {`S${nextJellyfinEpisode?.ParentIndexNumber}E${nextJellyfinEpisode?.IndexNumber}`}
</span>
<ChevronRight size={20} />
</Button>
{: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>
{:else if $itemStore.item?.sonarrSeries}
<Button type="primary" on:click={openRequestModal}>
<span class="mr-2">Request Series</span><Plus size={20} />
</Button>
{/if}
{/if}
</div>
</svelte:fragment>
<div slot="episodes-carousel">

View File

@@ -1,15 +1,234 @@
<script>
<script lang="ts">
import { version } from '$app/environment';
import { _ } from 'svelte-i18n';
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">
{$_('settingsEmpty')}
<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">
<a href="https://github.com/aleksilassila/reiverr">GitHub</a>
<div>v{version}</div>
<div class="sm:hidden px-8 pt-20 pb-4 flex items-center justify-between">
<button
class="text-lg font-medium flex items-center text-zinc-300 hover:text-zinc-200"
on:click={() => history.back()}
>
<ChevronLeft size={22} /> Settings
</button>
<Select bind:value={openTab}>
<option value="general">General</option>
<option value="integrations">Integrations</option>
</Select>
</div>
<div class="flex-1 flex flex-col border-t border-zinc-800 justify-between">
<div class="overflow-y-scroll overflow-x-hidden px-8">
<div class="max-w-screen-md mx-auto mb-auto w-full">
{#if openTab === 'general'}
<GeneralSettingsPage bind:values />
{/if}
{#if openTab === 'integrations'}
<IntegrationSettingsPage
bind:values
{sonarrConnected}
{radarrConnected}
{jellyfinConnected}
{checkSonarrHealth}
{checkRadarrHealth}
{checkJellyfinHealth}
/>
{/if}
</div>
</div>
<div class="flex items-center p-4 gap-8 justify-center text-zinc-500 bg-stone-950">
<div>v{version}</div>
<a target="_blank" href="https://github.com/aleksilassila/reiverr/releases">Changelog</a>
<a target="_blank" href="https://github.com/aleksilassila/reiverr">GitHub</a>
</div>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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