Merge pull request #1 from Axelazo/localization
Merge changes from upstream
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal 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
1
.gitignore
vendored
@@ -10,3 +10,4 @@ node_modules
|
||||
.output
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
/config/*.sqlite
|
||||
@@ -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" ]
|
||||
79
README.md
79
README.md
@@ -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
0
config/.gitkeep
Normal file
@@ -9,4 +9,4 @@ services:
|
||||
context: .
|
||||
target: production
|
||||
ports:
|
||||
- 9494:3000
|
||||
- 9494:9494
|
||||
|
||||
@@ -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
1762
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ a {
|
||||
@apply focus-visible:outline outline-2 outline-[#f0cd6dc2] outline-offset-2;
|
||||
}
|
||||
|
||||
.peer-selectable {
|
||||
@apply peer-focus-visible:outline outline-2 outline-[#f0cd6dc2] outline-offset-2;
|
||||
}
|
||||
|
||||
.selectable-explicit {
|
||||
@apply focus-within:outline outline-2 outline-[#f0cd6dc2] outline-offset-2;
|
||||
}
|
||||
|
||||
4
src/hooks.server.ts
Normal file
4
src/hooks.server.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import TypeOrm from '$lib/db';
|
||||
import 'reflect-metadata';
|
||||
|
||||
await TypeOrm.getDb();
|
||||
@@ -1,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(() => []);
|
||||
|
||||
64
src/lib/apis/jellyfin/qualities.ts
Normal file
64
src/lib/apis/jellyfin/qualities.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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 || []);
|
||||
|
||||
@@ -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 || []);
|
||||
|
||||
@@ -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 || []);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
25
src/lib/components/ContextMenu/ContextMenuButton.svelte
Normal file
25
src/lib/components/ContextMenu/ContextMenuButton.svelte
Normal 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>
|
||||
1
src/lib/components/ContextMenu/ContextMenuDivider.svelte
Normal file
1
src/lib/components/ContextMenu/ContextMenuDivider.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<div class="bg-zinc-200 bg-opacity-20 h-[1.5px] mx-3 my-1 rounded-full" />
|
||||
@@ -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
|
||||
}
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
|
||||
export let disabled = false;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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) },
|
||||
|
||||
17
src/lib/components/TitlePageLayout/OpenInButton.svelte
Normal file
17
src/lib/components/TitlePageLayout/OpenInButton.svelte
Normal 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>
|
||||
@@ -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
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
const TRAILER_TIMEOUT = 3000;
|
||||
const TRAILER_LOAD_TIME = 1000;
|
||||
const ANIMATION_DURATION = 150;
|
||||
const ANIMATION_DURATION = $settings.animationDuration;
|
||||
|
||||
export let tmdbId: number;
|
||||
export let type: TitleType;
|
||||
@@ -52,13 +52,15 @@
|
||||
trailerVisible = false;
|
||||
UIVisible = true;
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
trailerMounted = true;
|
||||
|
||||
if ($settings.autoplayTrailers) {
|
||||
timeout = setTimeout(() => {
|
||||
trailerVisible = true;
|
||||
}, TRAILER_LOAD_TIME);
|
||||
}, TRAILER_TIMEOUT - TRAILER_LOAD_TIME);
|
||||
trailerMounted = true; // Mount the trailer
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
trailerVisible = true;
|
||||
}, TRAILER_LOAD_TIME);
|
||||
}, TRAILER_TIMEOUT - TRAILER_LOAD_TIME);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
|
||||
@@ -1,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}
|
||||
|
||||
47
src/lib/components/VideoPlayer/Slider.svelte
Normal file
47
src/lib/components/VideoPlayer/Slider.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
28
src/lib/components/forms/FormButton.svelte
Normal file
28
src/lib/components/forms/FormButton.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
import { Update } from 'radix-icons-svelte';
|
||||
export let type: 'base' | 'success' | 'error' = 'base';
|
||||
export let loading = false;
|
||||
export let disabled = false;
|
||||
</script>
|
||||
|
||||
<button
|
||||
on:click
|
||||
class={classNames(
|
||||
'p-1.5 px-4 text-sm text-zinc-200 rounded-lg border',
|
||||
'hover:bg-opacity-30 transition-colors',
|
||||
'flex items-center gap-2',
|
||||
{
|
||||
'bg-green-500 bg-opacity-20 text-green-200 border-green-900': type === 'success',
|
||||
'bg-red-500 bg-opacity-20 text-red-200 border-red-900': type === 'error',
|
||||
'bg-zinc-600 border-zinc-800 bg-opacity-20': type === 'base',
|
||||
'cursor-not-allowed opacity-75 pointer-events-none': disabled || loading
|
||||
},
|
||||
$$restProps.class
|
||||
)}
|
||||
>
|
||||
{#if loading}
|
||||
<Update class="animate-spin" size={14} />
|
||||
{/if}
|
||||
<slot />
|
||||
</button>
|
||||
23
src/lib/components/forms/Input.svelte
Normal file
23
src/lib/components/forms/Input.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
|
||||
export let type: 'text' | 'number' = 'text';
|
||||
export let value: any = type === 'text' ? '' : 0;
|
||||
export let placeholder = '';
|
||||
|
||||
const baseStyles =
|
||||
'appearance-none p-1 px-3 selectable border border-zinc-800 rounded-lg bg-zinc-600 bg-opacity-20 text-zinc-200 placeholder:text-zinc-700';
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
{#if type === 'text'}
|
||||
<input type="text" {placeholder} bind:value class={classNames(baseStyles, $$restProps.class)} />
|
||||
{:else if type === 'number'}
|
||||
<input
|
||||
type="number"
|
||||
{placeholder}
|
||||
bind:value
|
||||
class={classNames(baseStyles, 'w-28', $$restProps.class)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
34
src/lib/components/forms/Select.svelte
Normal file
34
src/lib/components/forms/Select.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
import { CaretDown } from 'radix-icons-svelte';
|
||||
|
||||
export let value: any = '';
|
||||
export let disabled = false;
|
||||
export let loading = false;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={classNames(
|
||||
'relative w-max min-w-[8rem] h-min bg-zinc-600 bg-opacity-20 rounded-lg overflow-hidden',
|
||||
{
|
||||
'opacity-50': disabled,
|
||||
'animate-pulse pointer-events-none': loading
|
||||
}
|
||||
)}
|
||||
>
|
||||
<select
|
||||
on:change
|
||||
bind:value
|
||||
class={classNames(
|
||||
'relative appearance-none p-1 pl-3 pr-8 selectable border border-zinc-800 bg-transparent rounded-lg w-full z-[1]',
|
||||
{
|
||||
'cursor-not-allowed pointer-events-none': disabled
|
||||
}
|
||||
)}
|
||||
>
|
||||
<slot />
|
||||
</select>
|
||||
<div class="absolute inset-y-0 right-2 flex items-center justify-center">
|
||||
<CaretDown size={20} />
|
||||
</div>
|
||||
</div>
|
||||
11
src/lib/components/forms/Toggle.svelte
Normal file
11
src/lib/components/forms/Toggle.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script>
|
||||
export let checked = false;
|
||||
</script>
|
||||
|
||||
<label class="relative inline-flex items-center cursor-pointer w-min h-min">
|
||||
<input type="checkbox" bind:checked class="sr-only peer" />
|
||||
<div
|
||||
class="w-11 h-6 rounded-full peer bg-zinc-600 bg-opacity-20 peer-checked:bg-amber-200 peer-checked:bg-opacity-30 peer-selectable
|
||||
after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px]"
|
||||
/>
|
||||
</label>
|
||||
35
src/lib/db.ts
Normal file
35
src/lib/db.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'reflect-metadata';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Settings } from './entities/Settings';
|
||||
|
||||
class TypeOrm {
|
||||
private static instance: Promise<DataSource | null> | null = null;
|
||||
|
||||
private constructor() {
|
||||
// Private constructor to prevent external instantiation
|
||||
}
|
||||
|
||||
public static getDb(): Promise<DataSource | null> {
|
||||
if (!TypeOrm.instance) {
|
||||
TypeOrm.instance = new DataSource({
|
||||
type: 'sqlite',
|
||||
database: 'config/reiverr.sqlite',
|
||||
synchronize: true,
|
||||
entities: [Settings],
|
||||
logging: true
|
||||
})
|
||||
.initialize()
|
||||
.then((fulfilled) => {
|
||||
console.info('Data Source has been initialized!');
|
||||
return fulfilled;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error during Data Source initialization', err);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
return TypeOrm.instance;
|
||||
}
|
||||
}
|
||||
|
||||
export default TypeOrm;
|
||||
159
src/lib/entities/Settings.ts
Normal file
159
src/lib/entities/Settings.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { defaultSettings, type SettingsValues } from '$lib/stores/settings.store';
|
||||
import { BaseEntity, Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity({ name: 'settings' })
|
||||
export class Settings extends BaseEntity {
|
||||
@PrimaryColumn('text')
|
||||
name: string;
|
||||
|
||||
@Column('boolean', { default: false })
|
||||
isSetupDone: boolean;
|
||||
|
||||
// General
|
||||
|
||||
@Column('boolean', { default: defaultSettings.autoplayTrailers })
|
||||
autoplayTrailers: boolean;
|
||||
|
||||
@Column('text', { default: defaultSettings.language })
|
||||
language: string;
|
||||
|
||||
@Column('integer', { default: defaultSettings.animationDuration })
|
||||
animationDuration: number;
|
||||
|
||||
// Discover
|
||||
@Column('text', { default: defaultSettings.discover.region })
|
||||
discoverRegion: string;
|
||||
|
||||
@Column('boolean', { default: defaultSettings.discover.excludeLibraryItems })
|
||||
discoverExcludeLibraryItems: boolean;
|
||||
|
||||
@Column('text', { default: defaultSettings.discover.includedLanguages })
|
||||
discoverIncludedLanguages: string;
|
||||
|
||||
// Sonarr
|
||||
|
||||
@Column('text', { nullable: true, default: defaultSettings.sonarr.baseUrl })
|
||||
sonarrBaseUrl: string | null;
|
||||
|
||||
@Column('text', { nullable: true, default: defaultSettings.sonarr.apiKey })
|
||||
sonarrApiKey: string | null;
|
||||
|
||||
@Column('text', { default: defaultSettings.sonarr.rootFolderPath })
|
||||
sonarrRootFolderPath: string;
|
||||
|
||||
@Column('integer', { default: defaultSettings.sonarr.qualityProfileId })
|
||||
sonarrQualityProfileId: number;
|
||||
|
||||
@Column('integer', { default: defaultSettings.sonarr.languageProfileId })
|
||||
sonarrLanguageProfileId: number;
|
||||
|
||||
// Radarr
|
||||
|
||||
@Column('text', { nullable: true, default: defaultSettings.radarr.baseUrl })
|
||||
radarrBaseUrl: string | null;
|
||||
|
||||
@Column('text', { nullable: true, default: defaultSettings.radarr.apiKey })
|
||||
radarrApiKey: string | null;
|
||||
|
||||
@Column('text', { default: defaultSettings.radarr.rootFolderPath })
|
||||
radarrRootFolderPath: string;
|
||||
|
||||
@Column('integer', { default: defaultSettings.radarr.qualityProfileId })
|
||||
radarrQualityProfileId: number;
|
||||
|
||||
// Jellyfin
|
||||
|
||||
@Column('text', { nullable: true, default: defaultSettings.jellyfin.baseUrl })
|
||||
jellyfinBaseUrl: string | null;
|
||||
|
||||
@Column('text', { nullable: true, default: defaultSettings.jellyfin.apiKey })
|
||||
jellyfinApiKey: string | null;
|
||||
|
||||
@Column('text', { nullable: true, default: defaultSettings.jellyfin.userId })
|
||||
jellyfinUserId: string | null;
|
||||
|
||||
public static async get(name = 'default'): Promise<SettingsValues> {
|
||||
const settings = await this.findOne({ where: { name } });
|
||||
|
||||
if (!settings) {
|
||||
const defaultSettings = new Settings();
|
||||
defaultSettings.name = 'default';
|
||||
await defaultSettings.save();
|
||||
return this.getSettingsValues(defaultSettings);
|
||||
}
|
||||
|
||||
return this.getSettingsValues(settings);
|
||||
}
|
||||
|
||||
static getSettingsValues(settings: Settings): SettingsValues {
|
||||
return {
|
||||
...defaultSettings,
|
||||
language: settings.language,
|
||||
autoplayTrailers: settings.autoplayTrailers,
|
||||
animationDuration: settings.animationDuration,
|
||||
|
||||
discover: {
|
||||
...defaultSettings.discover,
|
||||
region: settings.discoverRegion,
|
||||
excludeLibraryItems: settings.discoverExcludeLibraryItems,
|
||||
includedLanguages: settings.discoverIncludedLanguages
|
||||
},
|
||||
|
||||
sonarr: {
|
||||
...defaultSettings.sonarr,
|
||||
apiKey: settings.sonarrApiKey,
|
||||
baseUrl: settings.sonarrBaseUrl,
|
||||
languageProfileId: settings.sonarrLanguageProfileId,
|
||||
qualityProfileId: settings.sonarrQualityProfileId,
|
||||
rootFolderPath: settings.sonarrRootFolderPath
|
||||
},
|
||||
radarr: {
|
||||
...defaultSettings.radarr,
|
||||
apiKey: settings.radarrApiKey,
|
||||
baseUrl: settings.radarrBaseUrl,
|
||||
qualityProfileId: settings.radarrQualityProfileId,
|
||||
rootFolderPath: settings.radarrRootFolderPath
|
||||
},
|
||||
jellyfin: {
|
||||
...defaultSettings.jellyfin,
|
||||
apiKey: settings.jellyfinApiKey,
|
||||
baseUrl: settings.jellyfinBaseUrl,
|
||||
userId: settings.jellyfinUserId
|
||||
},
|
||||
initialised: true
|
||||
};
|
||||
}
|
||||
|
||||
public static async set(name: string, values: SettingsValues): Promise<Settings | null> {
|
||||
const settings = await this.findOne({ where: { name } });
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
settings.language = values.language;
|
||||
settings.autoplayTrailers = values.autoplayTrailers;
|
||||
settings.animationDuration = values.animationDuration;
|
||||
|
||||
settings.discoverRegion = values.discover.region;
|
||||
settings.discoverExcludeLibraryItems = values.discover.excludeLibraryItems;
|
||||
settings.discoverIncludedLanguages = values.discover.includedLanguages;
|
||||
|
||||
settings.sonarrApiKey = values.sonarr.apiKey;
|
||||
settings.sonarrBaseUrl = values.sonarr.baseUrl;
|
||||
settings.sonarrLanguageProfileId = values.sonarr.languageProfileId;
|
||||
settings.sonarrQualityProfileId = values.sonarr.qualityProfileId;
|
||||
settings.sonarrRootFolderPath = values.sonarr.rootFolderPath;
|
||||
|
||||
settings.radarrApiKey = values.radarr.apiKey;
|
||||
settings.radarrBaseUrl = values.radarr.baseUrl;
|
||||
settings.radarrQualityProfileId = values.radarr.qualityProfileId;
|
||||
settings.radarrRootFolderPath = values.radarr.rootFolderPath;
|
||||
|
||||
settings.jellyfinApiKey = values.jellyfin.apiKey;
|
||||
settings.jellyfinBaseUrl = values.jellyfin.baseUrl;
|
||||
settings.jellyfinUserId = values.jellyfin.userId;
|
||||
|
||||
await settings.save();
|
||||
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
735
src/lib/utils/iso-languages.ts
Normal file
735
src/lib/utils/iso-languages.ts
Normal file
@@ -0,0 +1,735 @@
|
||||
// https://github.com/meikidd/iso-639-1/blob/master/src/data.js
|
||||
export const ISO_LANGUAGES = {
|
||||
en: {
|
||||
name: 'English',
|
||||
nativeName: 'English'
|
||||
},
|
||||
aa: {
|
||||
name: 'Afar',
|
||||
nativeName: 'Afaraf'
|
||||
},
|
||||
ab: {
|
||||
name: 'Abkhaz',
|
||||
nativeName: 'аҧсуа бызшәа'
|
||||
},
|
||||
ae: {
|
||||
name: 'Avestan',
|
||||
nativeName: 'avesta'
|
||||
},
|
||||
af: {
|
||||
name: 'Afrikaans',
|
||||
nativeName: 'Afrikaans'
|
||||
},
|
||||
ak: {
|
||||
name: 'Akan',
|
||||
nativeName: 'Akan'
|
||||
},
|
||||
am: {
|
||||
name: 'Amharic',
|
||||
nativeName: 'አማርኛ'
|
||||
},
|
||||
an: {
|
||||
name: 'Aragonese',
|
||||
nativeName: 'aragonés'
|
||||
},
|
||||
ar: {
|
||||
name: 'Arabic',
|
||||
nativeName: 'اَلْعَرَبِيَّةُ'
|
||||
},
|
||||
as: {
|
||||
name: 'Assamese',
|
||||
nativeName: 'অসমীয়া'
|
||||
},
|
||||
av: {
|
||||
name: 'Avaric',
|
||||
nativeName: 'авар мацӀ'
|
||||
},
|
||||
ay: {
|
||||
name: 'Aymara',
|
||||
nativeName: 'aymar aru'
|
||||
},
|
||||
az: {
|
||||
name: 'Azerbaijani',
|
||||
nativeName: 'azərbaycan dili'
|
||||
},
|
||||
ba: {
|
||||
name: 'Bashkir',
|
||||
nativeName: 'башҡорт теле'
|
||||
},
|
||||
be: {
|
||||
name: 'Belarusian',
|
||||
nativeName: 'беларуская мова'
|
||||
},
|
||||
bg: {
|
||||
name: 'Bulgarian',
|
||||
nativeName: 'български език'
|
||||
},
|
||||
bi: {
|
||||
name: 'Bislama',
|
||||
nativeName: 'Bislama'
|
||||
},
|
||||
bm: {
|
||||
name: 'Bambara',
|
||||
nativeName: 'bamanankan'
|
||||
},
|
||||
bn: {
|
||||
name: 'Bengali',
|
||||
nativeName: 'বাংলা'
|
||||
},
|
||||
bo: {
|
||||
name: 'Tibetan',
|
||||
nativeName: 'བོད་ཡིག'
|
||||
},
|
||||
br: {
|
||||
name: 'Breton',
|
||||
nativeName: 'brezhoneg'
|
||||
},
|
||||
bs: {
|
||||
name: 'Bosnian',
|
||||
nativeName: 'bosanski jezik'
|
||||
},
|
||||
ca: {
|
||||
name: 'Catalan',
|
||||
nativeName: 'Català'
|
||||
},
|
||||
ce: {
|
||||
name: 'Chechen',
|
||||
nativeName: 'нохчийн мотт'
|
||||
},
|
||||
ch: {
|
||||
name: 'Chamorro',
|
||||
nativeName: 'Chamoru'
|
||||
},
|
||||
co: {
|
||||
name: 'Corsican',
|
||||
nativeName: 'corsu'
|
||||
},
|
||||
cr: {
|
||||
name: 'Cree',
|
||||
nativeName: 'ᓀᐦᐃᔭᐍᐏᐣ'
|
||||
},
|
||||
cs: {
|
||||
name: 'Czech',
|
||||
nativeName: 'čeština'
|
||||
},
|
||||
cu: {
|
||||
name: 'Old Church Slavonic',
|
||||
nativeName: 'ѩзыкъ словѣньскъ'
|
||||
},
|
||||
cv: {
|
||||
name: 'Chuvash',
|
||||
nativeName: 'чӑваш чӗлхи'
|
||||
},
|
||||
cy: {
|
||||
name: 'Welsh',
|
||||
nativeName: 'Cymraeg'
|
||||
},
|
||||
da: {
|
||||
name: 'Danish',
|
||||
nativeName: 'dansk'
|
||||
},
|
||||
de: {
|
||||
name: 'German',
|
||||
nativeName: 'Deutsch'
|
||||
},
|
||||
dv: {
|
||||
name: 'Divehi',
|
||||
nativeName: 'ދިވެހި'
|
||||
},
|
||||
dz: {
|
||||
name: 'Dzongkha',
|
||||
nativeName: 'རྫོང་ཁ'
|
||||
},
|
||||
ee: {
|
||||
name: 'Ewe',
|
||||
nativeName: 'Eʋegbe'
|
||||
},
|
||||
el: {
|
||||
name: 'Greek',
|
||||
nativeName: 'Ελληνικά'
|
||||
},
|
||||
eo: {
|
||||
name: 'Esperanto',
|
||||
nativeName: 'Esperanto'
|
||||
},
|
||||
es: {
|
||||
name: 'Spanish',
|
||||
nativeName: 'Español'
|
||||
},
|
||||
et: {
|
||||
name: 'Estonian',
|
||||
nativeName: 'eesti'
|
||||
},
|
||||
eu: {
|
||||
name: 'Basque',
|
||||
nativeName: 'euskara'
|
||||
},
|
||||
fa: {
|
||||
name: 'Persian',
|
||||
nativeName: 'فارسی'
|
||||
},
|
||||
ff: {
|
||||
name: 'Fula',
|
||||
nativeName: 'Fulfulde'
|
||||
},
|
||||
fi: {
|
||||
name: 'Finnish',
|
||||
nativeName: 'suomi'
|
||||
},
|
||||
fj: {
|
||||
name: 'Fijian',
|
||||
nativeName: 'vosa Vakaviti'
|
||||
},
|
||||
fo: {
|
||||
name: 'Faroese',
|
||||
nativeName: 'føroyskt'
|
||||
},
|
||||
fr: {
|
||||
name: 'French',
|
||||
nativeName: 'Français'
|
||||
},
|
||||
fy: {
|
||||
name: 'Western Frisian',
|
||||
nativeName: 'Frysk'
|
||||
},
|
||||
ga: {
|
||||
name: 'Irish',
|
||||
nativeName: 'Gaeilge'
|
||||
},
|
||||
gd: {
|
||||
name: 'Scottish Gaelic',
|
||||
nativeName: 'Gàidhlig'
|
||||
},
|
||||
gl: {
|
||||
name: 'Galician',
|
||||
nativeName: 'galego'
|
||||
},
|
||||
gn: {
|
||||
name: 'Guaraní',
|
||||
nativeName: "Avañe'ẽ"
|
||||
},
|
||||
gu: {
|
||||
name: 'Gujarati',
|
||||
nativeName: 'ગુજરાતી'
|
||||
},
|
||||
gv: {
|
||||
name: 'Manx',
|
||||
nativeName: 'Gaelg'
|
||||
},
|
||||
ha: {
|
||||
name: 'Hausa',
|
||||
nativeName: 'هَوُسَ'
|
||||
},
|
||||
he: {
|
||||
name: 'Hebrew',
|
||||
nativeName: 'עברית'
|
||||
},
|
||||
hi: {
|
||||
name: 'Hindi',
|
||||
nativeName: 'हिन्दी'
|
||||
},
|
||||
ho: {
|
||||
name: 'Hiri Motu',
|
||||
nativeName: 'Hiri Motu'
|
||||
},
|
||||
hr: {
|
||||
name: 'Croatian',
|
||||
nativeName: 'Hrvatski'
|
||||
},
|
||||
ht: {
|
||||
name: 'Haitian',
|
||||
nativeName: 'Kreyòl ayisyen'
|
||||
},
|
||||
hu: {
|
||||
name: 'Hungarian',
|
||||
nativeName: 'magyar'
|
||||
},
|
||||
hy: {
|
||||
name: 'Armenian',
|
||||
nativeName: 'Հայերեն'
|
||||
},
|
||||
hz: {
|
||||
name: 'Herero',
|
||||
nativeName: 'Otjiherero'
|
||||
},
|
||||
ia: {
|
||||
name: 'Interlingua',
|
||||
nativeName: 'Interlingua'
|
||||
},
|
||||
id: {
|
||||
name: 'Indonesian',
|
||||
nativeName: 'Bahasa Indonesia'
|
||||
},
|
||||
ie: {
|
||||
name: 'Interlingue',
|
||||
nativeName: 'Interlingue'
|
||||
},
|
||||
ig: {
|
||||
name: 'Igbo',
|
||||
nativeName: 'Asụsụ Igbo'
|
||||
},
|
||||
ii: {
|
||||
name: 'Nuosu',
|
||||
nativeName: 'ꆈꌠ꒿ Nuosuhxop'
|
||||
},
|
||||
ik: {
|
||||
name: 'Inupiaq',
|
||||
nativeName: 'Iñupiaq'
|
||||
},
|
||||
io: {
|
||||
name: 'Ido',
|
||||
nativeName: 'Ido'
|
||||
},
|
||||
is: {
|
||||
name: 'Icelandic',
|
||||
nativeName: 'Íslenska'
|
||||
},
|
||||
it: {
|
||||
name: 'Italian',
|
||||
nativeName: 'Italiano'
|
||||
},
|
||||
iu: {
|
||||
name: 'Inuktitut',
|
||||
nativeName: 'ᐃᓄᒃᑎᑐᑦ'
|
||||
},
|
||||
ja: {
|
||||
name: 'Japanese',
|
||||
nativeName: '日本語'
|
||||
},
|
||||
jv: {
|
||||
name: 'Javanese',
|
||||
nativeName: 'basa Jawa'
|
||||
},
|
||||
ka: {
|
||||
name: 'Georgian',
|
||||
nativeName: 'ქართული'
|
||||
},
|
||||
kg: {
|
||||
name: 'Kongo',
|
||||
nativeName: 'Kikongo'
|
||||
},
|
||||
ki: {
|
||||
name: 'Kikuyu',
|
||||
nativeName: 'Gĩkũyũ'
|
||||
},
|
||||
kj: {
|
||||
name: 'Kwanyama',
|
||||
nativeName: 'Kuanyama'
|
||||
},
|
||||
kk: {
|
||||
name: 'Kazakh',
|
||||
nativeName: 'қазақ тілі'
|
||||
},
|
||||
kl: {
|
||||
name: 'Kalaallisut',
|
||||
nativeName: 'kalaallisut'
|
||||
},
|
||||
km: {
|
||||
name: 'Khmer',
|
||||
nativeName: 'ខេមរភាសា'
|
||||
},
|
||||
kn: {
|
||||
name: 'Kannada',
|
||||
nativeName: 'ಕನ್ನಡ'
|
||||
},
|
||||
ko: {
|
||||
name: 'Korean',
|
||||
nativeName: '한국어'
|
||||
},
|
||||
kr: {
|
||||
name: 'Kanuri',
|
||||
nativeName: 'Kanuri'
|
||||
},
|
||||
ks: {
|
||||
name: 'Kashmiri',
|
||||
nativeName: 'कश्मीरी'
|
||||
},
|
||||
ku: {
|
||||
name: 'Kurdish',
|
||||
nativeName: 'Kurdî'
|
||||
},
|
||||
kv: {
|
||||
name: 'Komi',
|
||||
nativeName: 'коми кыв'
|
||||
},
|
||||
kw: {
|
||||
name: 'Cornish',
|
||||
nativeName: 'Kernewek'
|
||||
},
|
||||
ky: {
|
||||
name: 'Kyrgyz',
|
||||
nativeName: 'Кыргызча'
|
||||
},
|
||||
la: {
|
||||
name: 'Latin',
|
||||
nativeName: 'latine'
|
||||
},
|
||||
lb: {
|
||||
name: 'Luxembourgish',
|
||||
nativeName: 'Lëtzebuergesch'
|
||||
},
|
||||
lg: {
|
||||
name: 'Ganda',
|
||||
nativeName: 'Luganda'
|
||||
},
|
||||
li: {
|
||||
name: 'Limburgish',
|
||||
nativeName: 'Limburgs'
|
||||
},
|
||||
ln: {
|
||||
name: 'Lingala',
|
||||
nativeName: 'Lingála'
|
||||
},
|
||||
lo: {
|
||||
name: 'Lao',
|
||||
nativeName: 'ພາສາລາວ'
|
||||
},
|
||||
lt: {
|
||||
name: 'Lithuanian',
|
||||
nativeName: 'lietuvių kalba'
|
||||
},
|
||||
lu: {
|
||||
name: 'Luba-Katanga',
|
||||
nativeName: 'Kiluba'
|
||||
},
|
||||
lv: {
|
||||
name: 'Latvian',
|
||||
nativeName: 'latviešu valoda'
|
||||
},
|
||||
mg: {
|
||||
name: 'Malagasy',
|
||||
nativeName: 'fiteny malagasy'
|
||||
},
|
||||
mh: {
|
||||
name: 'Marshallese',
|
||||
nativeName: 'Kajin M̧ajeļ'
|
||||
},
|
||||
mi: {
|
||||
name: 'Māori',
|
||||
nativeName: 'te reo Māori'
|
||||
},
|
||||
mk: {
|
||||
name: 'Macedonian',
|
||||
nativeName: 'македонски јазик'
|
||||
},
|
||||
ml: {
|
||||
name: 'Malayalam',
|
||||
nativeName: 'മലയാളം'
|
||||
},
|
||||
mn: {
|
||||
name: 'Mongolian',
|
||||
nativeName: 'Монгол хэл'
|
||||
},
|
||||
mr: {
|
||||
name: 'Marathi',
|
||||
nativeName: 'मराठी'
|
||||
},
|
||||
ms: {
|
||||
name: 'Malay',
|
||||
nativeName: 'Bahasa Melayu'
|
||||
},
|
||||
mt: {
|
||||
name: 'Maltese',
|
||||
nativeName: 'Malti'
|
||||
},
|
||||
my: {
|
||||
name: 'Burmese',
|
||||
nativeName: 'ဗမာစာ'
|
||||
},
|
||||
na: {
|
||||
name: 'Nauru',
|
||||
nativeName: 'Dorerin Naoero'
|
||||
},
|
||||
nb: {
|
||||
name: 'Norwegian Bokmål',
|
||||
nativeName: 'Norsk bokmål'
|
||||
},
|
||||
nd: {
|
||||
name: 'Northern Ndebele',
|
||||
nativeName: 'isiNdebele'
|
||||
},
|
||||
ne: {
|
||||
name: 'Nepali',
|
||||
nativeName: 'नेपाली'
|
||||
},
|
||||
ng: {
|
||||
name: 'Ndonga',
|
||||
nativeName: 'Owambo'
|
||||
},
|
||||
nl: {
|
||||
name: 'Dutch',
|
||||
nativeName: 'Nederlands'
|
||||
},
|
||||
nn: {
|
||||
name: 'Norwegian Nynorsk',
|
||||
nativeName: 'Norsk nynorsk'
|
||||
},
|
||||
no: {
|
||||
name: 'Norwegian',
|
||||
nativeName: 'Norsk'
|
||||
},
|
||||
nr: {
|
||||
name: 'Southern Ndebele',
|
||||
nativeName: 'isiNdebele'
|
||||
},
|
||||
nv: {
|
||||
name: 'Navajo',
|
||||
nativeName: 'Diné bizaad'
|
||||
},
|
||||
ny: {
|
||||
name: 'Chichewa',
|
||||
nativeName: 'chiCheŵa'
|
||||
},
|
||||
oc: {
|
||||
name: 'Occitan',
|
||||
nativeName: 'occitan'
|
||||
},
|
||||
oj: {
|
||||
name: 'Ojibwe',
|
||||
nativeName: 'ᐊᓂᔑᓈᐯᒧᐎᓐ'
|
||||
},
|
||||
om: {
|
||||
name: 'Oromo',
|
||||
nativeName: 'Afaan Oromoo'
|
||||
},
|
||||
or: {
|
||||
name: 'Oriya',
|
||||
nativeName: 'ଓଡ଼ିଆ'
|
||||
},
|
||||
os: {
|
||||
name: 'Ossetian',
|
||||
nativeName: 'ирон æвзаг'
|
||||
},
|
||||
pa: {
|
||||
name: 'Panjabi',
|
||||
nativeName: 'ਪੰਜਾਬੀ'
|
||||
},
|
||||
pi: {
|
||||
name: 'Pāli',
|
||||
nativeName: 'पाऴि'
|
||||
},
|
||||
pl: {
|
||||
name: 'Polish',
|
||||
nativeName: 'Polski'
|
||||
},
|
||||
ps: {
|
||||
name: 'Pashto',
|
||||
nativeName: 'پښتو'
|
||||
},
|
||||
pt: {
|
||||
name: 'Portuguese',
|
||||
nativeName: 'Português'
|
||||
},
|
||||
qu: {
|
||||
name: 'Quechua',
|
||||
nativeName: 'Runa Simi'
|
||||
},
|
||||
rm: {
|
||||
name: 'Romansh',
|
||||
nativeName: 'rumantsch grischun'
|
||||
},
|
||||
rn: {
|
||||
name: 'Kirundi',
|
||||
nativeName: 'Ikirundi'
|
||||
},
|
||||
ro: {
|
||||
name: 'Romanian',
|
||||
nativeName: 'Română'
|
||||
},
|
||||
ru: {
|
||||
name: 'Russian',
|
||||
nativeName: 'Русский'
|
||||
},
|
||||
rw: {
|
||||
name: 'Kinyarwanda',
|
||||
nativeName: 'Ikinyarwanda'
|
||||
},
|
||||
sa: {
|
||||
name: 'Sanskrit',
|
||||
nativeName: 'संस्कृतम्'
|
||||
},
|
||||
sc: {
|
||||
name: 'Sardinian',
|
||||
nativeName: 'sardu'
|
||||
},
|
||||
sd: {
|
||||
name: 'Sindhi',
|
||||
nativeName: 'सिन्धी'
|
||||
},
|
||||
se: {
|
||||
name: 'Northern Sami',
|
||||
nativeName: 'Davvisámegiella'
|
||||
},
|
||||
sg: {
|
||||
name: 'Sango',
|
||||
nativeName: 'yângâ tî sängö'
|
||||
},
|
||||
si: {
|
||||
name: 'Sinhala',
|
||||
nativeName: 'සිංහල'
|
||||
},
|
||||
sk: {
|
||||
name: 'Slovak',
|
||||
nativeName: 'slovenčina'
|
||||
},
|
||||
sl: {
|
||||
name: 'Slovenian',
|
||||
nativeName: 'slovenščina'
|
||||
},
|
||||
sm: {
|
||||
name: 'Samoan',
|
||||
nativeName: "gagana fa'a Samoa"
|
||||
},
|
||||
sn: {
|
||||
name: 'Shona',
|
||||
nativeName: 'chiShona'
|
||||
},
|
||||
so: {
|
||||
name: 'Somali',
|
||||
nativeName: 'Soomaaliga'
|
||||
},
|
||||
sq: {
|
||||
name: 'Albanian',
|
||||
nativeName: 'Shqip'
|
||||
},
|
||||
sr: {
|
||||
name: 'Serbian',
|
||||
nativeName: 'српски језик'
|
||||
},
|
||||
ss: {
|
||||
name: 'Swati',
|
||||
nativeName: 'SiSwati'
|
||||
},
|
||||
st: {
|
||||
name: 'Southern Sotho',
|
||||
nativeName: 'Sesotho'
|
||||
},
|
||||
su: {
|
||||
name: 'Sundanese',
|
||||
nativeName: 'Basa Sunda'
|
||||
},
|
||||
sv: {
|
||||
name: 'Swedish',
|
||||
nativeName: 'Svenska'
|
||||
},
|
||||
sw: {
|
||||
name: 'Swahili',
|
||||
nativeName: 'Kiswahili'
|
||||
},
|
||||
ta: {
|
||||
name: 'Tamil',
|
||||
nativeName: 'தமிழ்'
|
||||
},
|
||||
te: {
|
||||
name: 'Telugu',
|
||||
nativeName: 'తెలుగు'
|
||||
},
|
||||
tg: {
|
||||
name: 'Tajik',
|
||||
nativeName: 'тоҷикӣ'
|
||||
},
|
||||
th: {
|
||||
name: 'Thai',
|
||||
nativeName: 'ไทย'
|
||||
},
|
||||
ti: {
|
||||
name: 'Tigrinya',
|
||||
nativeName: 'ትግርኛ'
|
||||
},
|
||||
tk: {
|
||||
name: 'Turkmen',
|
||||
nativeName: 'Türkmençe'
|
||||
},
|
||||
tl: {
|
||||
name: 'Tagalog',
|
||||
nativeName: 'Wikang Tagalog'
|
||||
},
|
||||
tn: {
|
||||
name: 'Tswana',
|
||||
nativeName: 'Setswana'
|
||||
},
|
||||
to: {
|
||||
name: 'Tonga',
|
||||
nativeName: 'faka Tonga'
|
||||
},
|
||||
tr: {
|
||||
name: 'Turkish',
|
||||
nativeName: 'Türkçe'
|
||||
},
|
||||
ts: {
|
||||
name: 'Tsonga',
|
||||
nativeName: 'Xitsonga'
|
||||
},
|
||||
tt: {
|
||||
name: 'Tatar',
|
||||
nativeName: 'татар теле'
|
||||
},
|
||||
tw: {
|
||||
name: 'Twi',
|
||||
nativeName: 'Twi'
|
||||
},
|
||||
ty: {
|
||||
name: 'Tahitian',
|
||||
nativeName: 'Reo Tahiti'
|
||||
},
|
||||
ug: {
|
||||
name: 'Uyghur',
|
||||
nativeName: 'ئۇيغۇرچە'
|
||||
},
|
||||
uk: {
|
||||
name: 'Ukrainian',
|
||||
nativeName: 'Українська'
|
||||
},
|
||||
ur: {
|
||||
name: 'Urdu',
|
||||
nativeName: 'اردو'
|
||||
},
|
||||
uz: {
|
||||
name: 'Uzbek',
|
||||
nativeName: 'Ўзбек'
|
||||
},
|
||||
ve: {
|
||||
name: 'Venda',
|
||||
nativeName: 'Tshivenḓa'
|
||||
},
|
||||
vi: {
|
||||
name: 'Vietnamese',
|
||||
nativeName: 'Tiếng Việt'
|
||||
},
|
||||
vo: {
|
||||
name: 'Volapük',
|
||||
nativeName: 'Volapük'
|
||||
},
|
||||
wa: {
|
||||
name: 'Walloon',
|
||||
nativeName: 'walon'
|
||||
},
|
||||
wo: {
|
||||
name: 'Wolof',
|
||||
nativeName: 'Wollof'
|
||||
},
|
||||
xh: {
|
||||
name: 'Xhosa',
|
||||
nativeName: 'isiXhosa'
|
||||
},
|
||||
yi: {
|
||||
name: 'Yiddish',
|
||||
nativeName: 'ייִדיש'
|
||||
},
|
||||
yo: {
|
||||
name: 'Yoruba',
|
||||
nativeName: 'Yorùbá'
|
||||
},
|
||||
za: {
|
||||
name: 'Zhuang',
|
||||
nativeName: 'Saɯ cueŋƅ'
|
||||
},
|
||||
zh: {
|
||||
name: 'Chinese',
|
||||
nativeName: '中文'
|
||||
},
|
||||
zu: {
|
||||
name: 'Zulu',
|
||||
nativeName: 'isiZulu'
|
||||
}
|
||||
} as const;
|
||||
247
src/lib/utils/iso-regions.ts
Normal file
247
src/lib/utils/iso-regions.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
export const ISO_REGIONS = {
|
||||
AF: 'Afghanistan',
|
||||
AX: 'Aland Islands',
|
||||
AL: 'Albania',
|
||||
DZ: 'Algeria',
|
||||
AS: 'American Samoa',
|
||||
AD: 'Andorra',
|
||||
AO: 'Angola',
|
||||
AI: 'Anguilla',
|
||||
AQ: 'Antarctica',
|
||||
AG: 'Antigua And Barbuda',
|
||||
AR: 'Argentina',
|
||||
AM: 'Armenia',
|
||||
AW: 'Aruba',
|
||||
AU: 'Australia',
|
||||
AT: 'Austria',
|
||||
AZ: 'Azerbaijan',
|
||||
BS: 'Bahamas',
|
||||
BH: 'Bahrain',
|
||||
BD: 'Bangladesh',
|
||||
BB: 'Barbados',
|
||||
BY: 'Belarus',
|
||||
BE: 'Belgium',
|
||||
BZ: 'Belize',
|
||||
BJ: 'Benin',
|
||||
BM: 'Bermuda',
|
||||
BT: 'Bhutan',
|
||||
BO: 'Bolivia',
|
||||
BA: 'Bosnia And Herzegovina',
|
||||
BW: 'Botswana',
|
||||
BV: 'Bouvet Island',
|
||||
BR: 'Brazil',
|
||||
IO: 'British Indian Ocean Territory',
|
||||
BN: 'Brunei Darussalam',
|
||||
BG: 'Bulgaria',
|
||||
BF: 'Burkina Faso',
|
||||
BI: 'Burundi',
|
||||
KH: 'Cambodia',
|
||||
CM: 'Cameroon',
|
||||
CA: 'Canada',
|
||||
CV: 'Cape Verde',
|
||||
KY: 'Cayman Islands',
|
||||
CF: 'Central African Republic',
|
||||
TD: 'Chad',
|
||||
CL: 'Chile',
|
||||
CN: 'China',
|
||||
CX: 'Christmas Island',
|
||||
CC: 'Cocos (Keeling) Islands',
|
||||
CO: 'Colombia',
|
||||
KM: 'Comoros',
|
||||
CG: 'Congo',
|
||||
CD: 'Congo, Democratic Republic',
|
||||
CK: 'Cook Islands',
|
||||
CR: 'Costa Rica',
|
||||
CI: "Cote D'Ivoire",
|
||||
HR: 'Croatia',
|
||||
CU: 'Cuba',
|
||||
CY: 'Cyprus',
|
||||
CZ: 'Czech Republic',
|
||||
DK: 'Denmark',
|
||||
DJ: 'Djibouti',
|
||||
DM: 'Dominica',
|
||||
DO: 'Dominican Republic',
|
||||
EC: 'Ecuador',
|
||||
EG: 'Egypt',
|
||||
SV: 'El Salvador',
|
||||
GQ: 'Equatorial Guinea',
|
||||
ER: 'Eritrea',
|
||||
EE: 'Estonia',
|
||||
ET: 'Ethiopia',
|
||||
FK: 'Falkland Islands (Malvinas)',
|
||||
FO: 'Faroe Islands',
|
||||
FJ: 'Fiji',
|
||||
FI: 'Finland',
|
||||
FR: 'France',
|
||||
GF: 'French Guiana',
|
||||
PF: 'French Polynesia',
|
||||
TF: 'French Southern Territories',
|
||||
GA: 'Gabon',
|
||||
GM: 'Gambia',
|
||||
GE: 'Georgia',
|
||||
DE: 'Germany',
|
||||
GH: 'Ghana',
|
||||
GI: 'Gibraltar',
|
||||
GR: 'Greece',
|
||||
GL: 'Greenland',
|
||||
GD: 'Grenada',
|
||||
GP: 'Guadeloupe',
|
||||
GU: 'Guam',
|
||||
GT: 'Guatemala',
|
||||
GG: 'Guernsey',
|
||||
GN: 'Guinea',
|
||||
GW: 'Guinea-Bissau',
|
||||
GY: 'Guyana',
|
||||
HT: 'Haiti',
|
||||
HM: 'Heard Island & Mcdonald Islands',
|
||||
VA: 'Holy See (Vatican City State)',
|
||||
HN: 'Honduras',
|
||||
HK: 'Hong Kong',
|
||||
HU: 'Hungary',
|
||||
IS: 'Iceland',
|
||||
IN: 'India',
|
||||
ID: 'Indonesia',
|
||||
IR: 'Iran, Islamic Republic Of',
|
||||
IQ: 'Iraq',
|
||||
IE: 'Ireland',
|
||||
IM: 'Isle Of Man',
|
||||
IL: 'Israel',
|
||||
IT: 'Italy',
|
||||
JM: 'Jamaica',
|
||||
JP: 'Japan',
|
||||
JE: 'Jersey',
|
||||
JO: 'Jordan',
|
||||
KZ: 'Kazakhstan',
|
||||
KE: 'Kenya',
|
||||
KI: 'Kiribati',
|
||||
KR: 'Korea',
|
||||
KW: 'Kuwait',
|
||||
KG: 'Kyrgyzstan',
|
||||
LA: "Lao People's Democratic Republic",
|
||||
LV: 'Latvia',
|
||||
LB: 'Lebanon',
|
||||
LS: 'Lesotho',
|
||||
LR: 'Liberia',
|
||||
LY: 'Libyan Arab Jamahiriya',
|
||||
LI: 'Liechtenstein',
|
||||
LT: 'Lithuania',
|
||||
LU: 'Luxembourg',
|
||||
MO: 'Macao',
|
||||
MK: 'Macedonia',
|
||||
MG: 'Madagascar',
|
||||
MW: 'Malawi',
|
||||
MY: 'Malaysia',
|
||||
MV: 'Maldives',
|
||||
ML: 'Mali',
|
||||
MT: 'Malta',
|
||||
MH: 'Marshall Islands',
|
||||
MQ: 'Martinique',
|
||||
MR: 'Mauritania',
|
||||
MU: 'Mauritius',
|
||||
YT: 'Mayotte',
|
||||
MX: 'Mexico',
|
||||
FM: 'Micronesia, Federated States Of',
|
||||
MD: 'Moldova',
|
||||
MC: 'Monaco',
|
||||
MN: 'Mongolia',
|
||||
ME: 'Montenegro',
|
||||
MS: 'Montserrat',
|
||||
MA: 'Morocco',
|
||||
MZ: 'Mozambique',
|
||||
MM: 'Myanmar',
|
||||
NA: 'Namibia',
|
||||
NR: 'Nauru',
|
||||
NP: 'Nepal',
|
||||
NL: 'Netherlands',
|
||||
AN: 'Netherlands Antilles',
|
||||
NC: 'New Caledonia',
|
||||
NZ: 'New Zealand',
|
||||
NI: 'Nicaragua',
|
||||
NE: 'Niger',
|
||||
NG: 'Nigeria',
|
||||
NU: 'Niue',
|
||||
NF: 'Norfolk Island',
|
||||
MP: 'Northern Mariana Islands',
|
||||
NO: 'Norway',
|
||||
OM: 'Oman',
|
||||
PK: 'Pakistan',
|
||||
PW: 'Palau',
|
||||
PS: 'Palestinian Territory, Occupied',
|
||||
PA: 'Panama',
|
||||
PG: 'Papua New Guinea',
|
||||
PY: 'Paraguay',
|
||||
PE: 'Peru',
|
||||
PH: 'Philippines',
|
||||
PN: 'Pitcairn',
|
||||
PL: 'Poland',
|
||||
PT: 'Portugal',
|
||||
PR: 'Puerto Rico',
|
||||
QA: 'Qatar',
|
||||
RE: 'Reunion',
|
||||
RO: 'Romania',
|
||||
RU: 'Russian Federation',
|
||||
RW: 'Rwanda',
|
||||
BL: 'Saint Barthelemy',
|
||||
SH: 'Saint Helena',
|
||||
KN: 'Saint Kitts And Nevis',
|
||||
LC: 'Saint Lucia',
|
||||
MF: 'Saint Martin',
|
||||
PM: 'Saint Pierre And Miquelon',
|
||||
VC: 'Saint Vincent And Grenadines',
|
||||
WS: 'Samoa',
|
||||
SM: 'San Marino',
|
||||
ST: 'Sao Tome And Principe',
|
||||
SA: 'Saudi Arabia',
|
||||
SN: 'Senegal',
|
||||
RS: 'Serbia',
|
||||
SC: 'Seychelles',
|
||||
SL: 'Sierra Leone',
|
||||
SG: 'Singapore',
|
||||
SK: 'Slovakia',
|
||||
SI: 'Slovenia',
|
||||
SB: 'Solomon Islands',
|
||||
SO: 'Somalia',
|
||||
ZA: 'South Africa',
|
||||
GS: 'South Georgia And Sandwich Isl.',
|
||||
ES: 'Spain',
|
||||
LK: 'Sri Lanka',
|
||||
SD: 'Sudan',
|
||||
SR: 'Suriname',
|
||||
SJ: 'Svalbard And Jan Mayen',
|
||||
SZ: 'Swaziland',
|
||||
SE: 'Sweden',
|
||||
CH: 'Switzerland',
|
||||
SY: 'Syrian Arab Republic',
|
||||
TW: 'Taiwan',
|
||||
TJ: 'Tajikistan',
|
||||
TZ: 'Tanzania',
|
||||
TH: 'Thailand',
|
||||
TL: 'Timor-Leste',
|
||||
TG: 'Togo',
|
||||
TK: 'Tokelau',
|
||||
TO: 'Tonga',
|
||||
TT: 'Trinidad And Tobago',
|
||||
TN: 'Tunisia',
|
||||
TR: 'Turkey',
|
||||
TM: 'Turkmenistan',
|
||||
TC: 'Turks And Caicos Islands',
|
||||
TV: 'Tuvalu',
|
||||
UG: 'Uganda',
|
||||
UA: 'Ukraine',
|
||||
AE: 'United Arab Emirates',
|
||||
GB: 'United Kingdom',
|
||||
US: 'United States',
|
||||
UM: 'United States Outlying Islands',
|
||||
UY: 'Uruguay',
|
||||
UZ: 'Uzbekistan',
|
||||
VU: 'Vanuatu',
|
||||
VE: 'Venezuela',
|
||||
VN: 'Viet Nam',
|
||||
VG: 'Virgin Islands, British',
|
||||
VI: 'Virgin Islands, U.S.',
|
||||
WF: 'Wallis And Futuna',
|
||||
EH: 'Western Sahara',
|
||||
YE: 'Yemen',
|
||||
ZM: 'Zambia',
|
||||
ZW: 'Zimbabwe'
|
||||
} as const;
|
||||
10
src/routes/+layout.server.ts
Normal file
10
src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Settings } from '$lib/entities/Settings';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async () => {
|
||||
const settings = await Settings.get();
|
||||
|
||||
return {
|
||||
settings
|
||||
};
|
||||
};
|
||||
@@ -3,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} -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
11
src/routes/api/settings/+server.ts
Normal file
11
src/routes/api/settings/+server.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Settings } from '$lib/entities/Settings';
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
return json(await Settings.get());
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const values = await request.json();
|
||||
return json(await Settings.set('default', values));
|
||||
};
|
||||
@@ -1,13 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
TmdbApiOpen,
|
||||
getTmdbDigitalReleases,
|
||||
getTmdbTrendingAll,
|
||||
getTmdbUpcomingMovies,
|
||||
getTrendingActors,
|
||||
type TmdbMovie2,
|
||||
type TmdbSeries2
|
||||
} from '$lib/apis/tmdb/tmdbApi';
|
||||
import { TmdbApiOpen, type TmdbMovie2, type TmdbSeries2 } from '$lib/apis/tmdb/tmdbApi';
|
||||
import Card from '$lib/components/Card/Card.svelte';
|
||||
import { fetchCardTmdbProps } from '$lib/components/Card/card';
|
||||
import Carousel from '$lib/components/Carousel/Carousel.svelte';
|
||||
@@ -17,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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
55
src/routes/settings/GeneralSettingsPage.svelte
Normal file
55
src/routes/settings/GeneralSettingsPage.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/forms/Input.svelte';
|
||||
import Select from '$lib/components/forms/Select.svelte';
|
||||
import Toggle from '$lib/components/forms/Toggle.svelte';
|
||||
import type { SettingsValues } from '$lib/stores/settings.store';
|
||||
import { ISO_LANGUAGES } from '$lib/utils/iso-languages';
|
||||
import { ISO_REGIONS } from '$lib/utils/iso-regions';
|
||||
|
||||
export let values: SettingsValues;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-[1fr_min-content] justify-items-start place-items-center gap-4 text-zinc-400"
|
||||
>
|
||||
<h1
|
||||
class="font-medium text-xl text-zinc-200 tracking-wide col-span-2 border-b border-zinc-800 justify-self-stretch pb-2 mt-8"
|
||||
>
|
||||
User Interface
|
||||
</h1>
|
||||
<h2>Language</h2>
|
||||
<Select bind:value={values.language}>
|
||||
{#each Object.entries(ISO_LANGUAGES) as [code, lang]}
|
||||
<option value={code}>{lang.name}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
<h2>Autoplay Trailers</h2>
|
||||
<Toggle bind:checked={values.autoplayTrailers} />
|
||||
|
||||
<h2>Animation Duration</h2>
|
||||
<Input type="number" bind:value={values.animationDuration} />
|
||||
|
||||
<h1
|
||||
class="font-medium text-xl text-zinc-200 tracking-wide col-span-2 border-b border-zinc-800 justify-self-stretch pb-2 mt-8"
|
||||
>
|
||||
Discovery
|
||||
</h1>
|
||||
<h2>Region</h2>
|
||||
<Select bind:value={values.discover.region}>
|
||||
<option value="">None</option>
|
||||
{#each Object.entries(ISO_REGIONS) as [code, region]}
|
||||
<option value={code}>{region}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
<h2>Exclude library items from Discovery</h2>
|
||||
<Toggle bind:checked={values.discover.excludeLibraryItems} />
|
||||
|
||||
<div>
|
||||
<h2>Included languages</h2>
|
||||
<p class="text-sm text-zinc-500 mt-1">
|
||||
Filter results based on spoken language. Takes ISO 639-1 language codes separated with commas.
|
||||
Leave empty to disable.
|
||||
</p>
|
||||
</div>
|
||||
<Input bind:value={values.discover.includedLanguages} placeholder={'en,fr,de'} />
|
||||
</div>
|
||||
26
src/routes/settings/IntegrationCard.svelte
Normal file
26
src/routes/settings/IntegrationCard.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
|
||||
export let title: string;
|
||||
export let href = '#';
|
||||
export let status: 'connected' | 'disconnected' | 'error' = 'disconnected';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={classNames('border border-zinc-800 rounded-xl p-4 flex flex-col gap-4', {
|
||||
// 'border-zinc-800': status === 'connected'
|
||||
// 'border-zinc-800': status === 'disconnected'
|
||||
})}
|
||||
>
|
||||
<div class="flex items-baseline justify-between">
|
||||
<a class="text-zinc-200 text-xl font-medium" target="_blank" {href}>{title}</a>
|
||||
<div
|
||||
class={classNames('w-3 h-3 rounded-full', {
|
||||
'bg-green-600': status === 'connected',
|
||||
'bg-zinc-600': status === 'disconnected',
|
||||
'bg-amber-500': status === 'error'
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
295
src/routes/settings/IntegrationSettingsPage.svelte
Normal file
295
src/routes/settings/IntegrationSettingsPage.svelte
Normal file
@@ -0,0 +1,295 @@
|
||||
<script lang="ts">
|
||||
import { getJellyfinUsers } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import {
|
||||
getSonarrLanguageProfiles,
|
||||
getSonarrQualityProfiles,
|
||||
getSonarrRootFolders
|
||||
} from '$lib/apis/sonarr/sonarrApi';
|
||||
import FormButton from '$lib/components/forms/FormButton.svelte';
|
||||
import Input from '$lib/components/forms/Input.svelte';
|
||||
import Select from '$lib/components/forms/Select.svelte';
|
||||
import { settings, type SettingsValues } from '$lib/stores/settings.store';
|
||||
import classNames from 'classnames';
|
||||
import { Trash } from 'radix-icons-svelte';
|
||||
import IntegrationCard from './IntegrationCard.svelte';
|
||||
import TestConnectionButton from './TestConnectionButton.svelte';
|
||||
import { getRadarrQualityProfiles, getRadarrRootFolders } from '$lib/apis/radarr/radarrApi';
|
||||
|
||||
export let values: SettingsValues;
|
||||
|
||||
export let sonarrConnected: boolean;
|
||||
export let radarrConnected: boolean;
|
||||
export let jellyfinConnected: boolean;
|
||||
|
||||
export let checkSonarrHealth: () => Promise<boolean>;
|
||||
export let checkRadarrHealth: () => Promise<boolean>;
|
||||
export let checkJellyfinHealth: () => Promise<boolean>;
|
||||
|
||||
let sonarrRootFolders: undefined | { id: number; path: string }[] = undefined;
|
||||
let sonarrQualityProfiles: undefined | { id: number; name: string }[] = undefined;
|
||||
let sonarrLanguageProfiles: undefined | { id: number; name: string }[] = undefined;
|
||||
|
||||
let radarrRootFolders: undefined | { id: number; path: string }[] = undefined;
|
||||
let radarrQualityProfiles: undefined | { id: number; name: string }[] = undefined;
|
||||
|
||||
let jellyfinUsers: undefined | { id: string; name: string }[] = undefined;
|
||||
|
||||
function handleRemoveIntegration(service: 'sonarr' | 'radarr' | 'jellyfin') {
|
||||
if (service === 'sonarr') {
|
||||
values.sonarr.baseUrl = '';
|
||||
values.sonarr.apiKey = '';
|
||||
|
||||
checkSonarrHealth();
|
||||
} else if (service === 'radarr') {
|
||||
values.radarr.baseUrl = '';
|
||||
values.radarr.apiKey = '';
|
||||
checkRadarrHealth();
|
||||
} else if (service === 'jellyfin') {
|
||||
values.jellyfin.baseUrl = '';
|
||||
values.jellyfin.apiKey = '';
|
||||
values.jellyfin.userId = '';
|
||||
checkJellyfinHealth();
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
if (sonarrConnected) {
|
||||
getSonarrRootFolders(
|
||||
values.sonarr.baseUrl || undefined,
|
||||
values.sonarr.apiKey || undefined
|
||||
).then((folders) => {
|
||||
sonarrRootFolders = folders.map((f) => ({ id: f.id || 0, path: f.path || '' }));
|
||||
});
|
||||
|
||||
getSonarrQualityProfiles(
|
||||
values.sonarr.baseUrl || undefined,
|
||||
values.sonarr.apiKey || undefined
|
||||
).then((profiles) => {
|
||||
sonarrQualityProfiles = profiles.map((p) => ({ id: p.id || 0, name: p.name || '' }));
|
||||
});
|
||||
|
||||
getSonarrLanguageProfiles(
|
||||
values.sonarr.baseUrl || undefined,
|
||||
values.sonarr.apiKey || undefined
|
||||
).then((profiles) => {
|
||||
sonarrLanguageProfiles = profiles.map((p) => ({ id: p.id || 0, name: p.name || '' }));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
if (radarrConnected) {
|
||||
getRadarrRootFolders(
|
||||
values.radarr.baseUrl || undefined,
|
||||
values.radarr.apiKey || undefined
|
||||
).then((folders) => {
|
||||
radarrRootFolders = folders.map((f) => ({ id: f.id || 0, path: f.path || '' }));
|
||||
});
|
||||
|
||||
getRadarrQualityProfiles(
|
||||
values.radarr.baseUrl || undefined,
|
||||
values.radarr.apiKey || undefined
|
||||
).then((profiles) => {
|
||||
radarrQualityProfiles = profiles.map((p) => ({ id: p.id || 0, name: p.name || '' }));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
if (jellyfinConnected) {
|
||||
getJellyfinUsers(
|
||||
values.jellyfin.baseUrl || undefined,
|
||||
values.jellyfin.apiKey || undefined
|
||||
).then((users) => {
|
||||
jellyfinUsers = users.map((u) => ({ id: u.Id || '', name: u.Name || '' }));
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
class="border-b border-zinc-800 pb-4 mt-8 col-span-2 justify-self-stretch flex flex-col gap-2"
|
||||
>
|
||||
<h1 class="font-medium text-2xl text-zinc-200 tracking-wide">Integrations</h1>
|
||||
<p class="text-sm text-zinc-400">
|
||||
Note: Base urls must be accessible from the browser, meaning that internal docker addresses
|
||||
won't work, for example. API Keys <span class="font-medium underline">will be exposed</span> to
|
||||
the browser.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="justify-self-stretch col-span-2">
|
||||
<IntegrationCard
|
||||
title="Sonarr"
|
||||
href={$settings.sonarr.baseUrl || '#'}
|
||||
status={sonarrConnected ? 'connected' : 'disconnected'}
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-sm text-zinc-500">Base URL</h2>
|
||||
<Input
|
||||
placeholder={'http://127.0.0.1:8989'}
|
||||
class="w-full"
|
||||
bind:value={values.sonarr.baseUrl}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-sm text-zinc-500">API Key</h2>
|
||||
<Input class="w-full" bind:value={values.sonarr.apiKey} />
|
||||
</div>
|
||||
<div class="grid grid-cols-[1fr_min-content] gap-2">
|
||||
<TestConnectionButton handleHealthCheck={checkSonarrHealth} />
|
||||
<FormButton on:click={() => handleRemoveIntegration('sonarr')} type="error">
|
||||
<Trash size={20} />
|
||||
</FormButton>
|
||||
</div>
|
||||
<h1 class="border-b border-zinc-800 py-2">Options</h1>
|
||||
<div
|
||||
class={classNames(
|
||||
'grid grid-cols-[1fr_min-content] justify-items-start gap-4 text-zinc-400',
|
||||
{
|
||||
'opacity-50 pointer-events-none': !sonarrConnected
|
||||
}
|
||||
)}
|
||||
>
|
||||
<h2>Root Folder</h2>
|
||||
{#if !sonarrRootFolders}
|
||||
<Select loading />
|
||||
{:else}
|
||||
<Select bind:value={values.sonarr.rootFolderPath}>
|
||||
{#each sonarrRootFolders as folder}
|
||||
<option value={folder.path}>{folder.path}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
{/if}
|
||||
|
||||
<h2>Quality Profile</h2>
|
||||
{#if !sonarrQualityProfiles}
|
||||
<Select loading />
|
||||
{:else}
|
||||
<Select bind:value={values.sonarr.qualityProfileId}>
|
||||
{#each sonarrQualityProfiles as profile}
|
||||
<option value={profile.id}>{profile.name}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
{/if}
|
||||
|
||||
<h2>Language Profile</h2>
|
||||
{#if !sonarrLanguageProfiles}
|
||||
<Select loading />
|
||||
{:else}
|
||||
<Select bind:value={values.sonarr.languageProfileId}>
|
||||
{#each sonarrLanguageProfiles as profile}
|
||||
<option value={profile.id}>{profile.name}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
{/if}
|
||||
</div>
|
||||
</IntegrationCard>
|
||||
</div>
|
||||
|
||||
<div class="justify-self-stretch col-span-2">
|
||||
<IntegrationCard
|
||||
title="Radarr"
|
||||
href={$settings.radarr.baseUrl || '#'}
|
||||
status={radarrConnected ? 'connected' : 'disconnected'}
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-sm text-zinc-500">Base URL</h2>
|
||||
<Input
|
||||
placeholder={'http://127.0.0.1:7878'}
|
||||
class="w-full"
|
||||
bind:value={values.radarr.baseUrl}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-sm text-zinc-500">API Key</h2>
|
||||
<Input class="w-full" bind:value={values.radarr.apiKey} />
|
||||
</div>
|
||||
<div class="grid grid-cols-[1fr_min-content] gap-2">
|
||||
<TestConnectionButton handleHealthCheck={checkRadarrHealth} />
|
||||
<FormButton on:click={() => handleRemoveIntegration('radarr')} type="error">
|
||||
<Trash size={20} />
|
||||
</FormButton>
|
||||
</div>
|
||||
<h1 class="border-b border-zinc-800 py-2">Options</h1>
|
||||
<div
|
||||
class={classNames(
|
||||
'grid grid-cols-[1fr_min-content] justify-items-start gap-4 text-zinc-400',
|
||||
{
|
||||
'opacity-50 pointer-events-none': !radarrConnected
|
||||
}
|
||||
)}
|
||||
>
|
||||
<h2>Root Folder</h2>
|
||||
{#if !radarrRootFolders}
|
||||
<Select loading />
|
||||
{:else}
|
||||
<Select bind:value={values.radarr.rootFolderPath}>
|
||||
{#each radarrRootFolders as folder}
|
||||
<option value={folder.path}>{folder.path}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
{/if}
|
||||
|
||||
<h2>Quality Profile</h2>
|
||||
{#if !radarrQualityProfiles}
|
||||
<Select loading />
|
||||
{:else}
|
||||
<Select bind:value={values.radarr.qualityProfileId}>
|
||||
{#each radarrQualityProfiles as profile}
|
||||
<option value={profile.id}>{profile.name}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
{/if}
|
||||
</div>
|
||||
</IntegrationCard>
|
||||
</div>
|
||||
|
||||
<div class="justify-self-stretch col-span-2">
|
||||
<IntegrationCard
|
||||
title="Jellyfin"
|
||||
href={$settings.jellyfin.baseUrl || '#'}
|
||||
status={jellyfinConnected ? 'connected' : 'disconnected'}
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-sm text-zinc-500">Base URL</h2>
|
||||
<Input
|
||||
placeholder={'http://127.0.0.1:8096'}
|
||||
class="w-full"
|
||||
bind:value={values.jellyfin.baseUrl}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-sm text-zinc-500">API Key</h2>
|
||||
<Input class="w-full" bind:value={values.jellyfin.apiKey} />
|
||||
</div>
|
||||
<div class="grid grid-cols-[1fr_min-content] gap-2">
|
||||
<TestConnectionButton handleHealthCheck={checkJellyfinHealth} />
|
||||
<FormButton on:click={() => handleRemoveIntegration('jellyfin')} type="error">
|
||||
<Trash size={20} />
|
||||
</FormButton>
|
||||
</div>
|
||||
<h1 class="border-b border-zinc-800 py-2">Options</h1>
|
||||
<div
|
||||
class={classNames(
|
||||
'grid grid-cols-[1fr_min-content] justify-items-start gap-4 text-zinc-400',
|
||||
{
|
||||
'opacity-50 pointer-events-none': !jellyfinConnected
|
||||
}
|
||||
)}
|
||||
>
|
||||
<h2>Jellyfin User</h2>
|
||||
{#if !jellyfinUsers}
|
||||
<Select loading />
|
||||
{:else}
|
||||
<Select bind:value={values.jellyfin.userId}>
|
||||
{#each jellyfinUsers as user}
|
||||
<option value={user.id}>{user.name}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
{/if}
|
||||
</div>
|
||||
</IntegrationCard>
|
||||
</div>
|
||||
</div>
|
||||
37
src/routes/settings/TestConnectionButton.svelte
Normal file
37
src/routes/settings/TestConnectionButton.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import FormButton from '$lib/components/forms/FormButton.svelte';
|
||||
import { onDestroy, type ComponentProps } from 'svelte';
|
||||
|
||||
export let handleHealthCheck: () => Promise<boolean>;
|
||||
|
||||
let type: ComponentProps<FormButton>['type'] = 'base';
|
||||
let loading = false;
|
||||
|
||||
let healthTimeout: NodeJS.Timeout;
|
||||
$: {
|
||||
if (type !== 'base') {
|
||||
clearTimeout(healthTimeout);
|
||||
healthTimeout = setTimeout(() => {
|
||||
type = 'base';
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
loading = true;
|
||||
handleHealthCheck().then((ok) => {
|
||||
if (ok) {
|
||||
type = 'success';
|
||||
} else {
|
||||
type = 'error';
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(healthTimeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
<FormButton {type} {loading} on:click={handleClick}>Test Connection</FormButton>
|
||||
@@ -1,9 +1,18 @@
|
||||
<script lang="ts">
|
||||
import RadarrStats from '$lib/components/SourceStats/RadarrStats.svelte';
|
||||
import SonarrStats from '$lib/components/SourceStats/SonarrStats.svelte';
|
||||
import { settings } from '$lib/stores/settings.store';
|
||||
import { fade } from 'svelte/transition';
|
||||
</script>
|
||||
|
||||
<div class="pt-24 px-8 min-h-screen flex justify-center">
|
||||
<div
|
||||
class="pt-24 px-8 min-h-screen flex justify-center"
|
||||
in:fade|global={{
|
||||
duration: $settings.animationDuration,
|
||||
delay: $settings.animationDuration
|
||||
}}
|
||||
out:fade|global={{ duration: $settings.animationDuration }}
|
||||
>
|
||||
<div class="flex flex-col gap-4 max-w-3xl flex-1">
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<RadarrStats large />
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"strict": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"lib": ["es6"],
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"strictPropertyInitialization": false
|
||||
}
|
||||
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
|
||||
//
|
||||
|
||||
@@ -9,5 +9,8 @@ export default defineConfig({
|
||||
// },
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{js,ts}']
|
||||
},
|
||||
ssr: {
|
||||
external: ['reflect-metadata']
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user