Remove sveltekit dependency, create simple test project

This commit is contained in:
Aleksi Lassila
2023-12-27 00:16:40 +02:00
parent 973f8831ee
commit d255fce52d
65 changed files with 3467 additions and 5943 deletions

View File

@@ -1,6 +1,7 @@
node_modules node_modules
.svelte-kit .svelte-kit
build build
dist
.idea .idea
.env .env
.DS_Store .DS_Store

View File

@@ -1,8 +0,0 @@
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

View File

@@ -6,8 +6,32 @@ node_modules
.env .env
.env.* .env.*
!.env.example !.env.example
.vercel
.output
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
/config/*.sqlite
/dist
# Ignore files for PNPM, NPM and YARN # Logs
pnpm-lock.yaml logs
package-lock.json *.log
yarn.lock npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

23
.gitignore vendored
View File

@@ -12,3 +12,26 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
/config/*.sqlite /config/*.sqlite
/dist /dist
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,6 +1,7 @@
.DS_Store .DS_Store
node_modules node_modules
/build /build
/dist
/.svelte-kit /.svelte-kit
/package /package
.env .env

View File

@@ -1,22 +0,0 @@
{
"appId": "me.aleksilassila.reiverr",
"productName": "Reiverr",
"directories": {
"output": "dist"
},
"mac": {
"asar": false
},
"win": {
"asar": false,
"target": "msi"
},
"asar": false,
"files": [
"src/electron.cjs",
{
"from": "build",
"to": "build"
}
]
}

33
index.html Normal file
View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="./public/favicon.png" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"
/>
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<title>Vite + Svelte + TS</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Nunito+Sans:wght@200;300;400;500;600;700;800;900;1000&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Montserrat:wght@100;200;300;400;500;600;700;800;900&display=swap"
rel="stylesheet"
/>
</head>
<body class="bg-stone-950 min-h-screen text-white touch-manipulation relative -z-10">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

7664
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,67 +1,58 @@
{ {
"name": "reiverr", "name": "reiverr",
"version": "0.8.0", "version": "0.9.0",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/aleksilassila/reiverr" "url": "https://github.com/aleksilassila/reiverr"
}, },
"main": "src/electron.cjs", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev --host", "dev": "vite --open",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"deploy": "PORT=9494 NODE_ENV=production node build/", "deploy": "PORT=9494 NODE_ENV=production node build/",
"deploy:electron": "vite build && electron-builder -mw --x64 --config build.config.json; electron-builder -m --arm64 --config build.config.json", "deploy:electron": "vite build && electron-builder -mw --x64 --config build.config.json; electron-builder -m --arm64 --config build.config.json",
"test": "playwright test", "test": "playwright test",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"test:unit": "vitest", "test:unit": "vitest",
"lint": "prettier --plugin-search-dir . --check . && eslint .", "lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write ." "format": "prettier --plugin-search-dir . --write ."
}, },
"devDependencies": { "devDependencies": {
"@fontsource/fira-mono": "^4.5.10",
"@neoconfetti/svelte": "^1.0.0",
"@playwright/test": "^1.28.1", "@playwright/test": "^1.28.1",
"@sveltejs/adapter-auto": "^2.0.0", "vitest": "^0.25.3",
"@sveltejs/adapter-node": "^1.3.1",
"@sveltejs/adapter-static": "2.0.3",
"@sveltejs/kit": "^1.5.0",
"@types/axios": "^0.14.0",
"@types/cookie": "^0.5.1",
"@types/node": "^20.3.3",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"autoprefixer": "^10.4.14",
"classnames": "^2.3.2",
"electron": "^26.1.0",
"electron-builder": "^24.6.3",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.26.0",
"openapi-typescript": "^6.2.7",
"postcss": "^8.4.24",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.8.1",
"svelte": "^4.1.0",
"svelte-check": "^3.0.1",
"tailwindcss": "^3.3.2",
"tslib": "^2.4.1",
"typescript": "^5.1.3",
"vite": "^4.3.0",
"vitest": "^0.25.3"
},
"type": "module",
"dependencies": {
"@jellyfin/sdk": "^0.7.0",
"axios": "^1.4.0",
"hls.js": "^1.4.6",
"openapi-fetch": "^0.2.1",
"radix-icons-svelte": "^1.2.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"sqlite3": "^5.1.6", "@jellyfin/sdk": "^0.8.2",
"svelte-i18n": "^3.7.0", "@sveltejs/vite-plugin-svelte": "^2.4.2",
"@tsconfig/svelte": "^5.0.2",
"@types/axios": "^0.14.0",
"@typescript-eslint/eslint-plugin": "^6.16.0",
"@typescript-eslint/parser": "^6.16.0",
"@vitejs/plugin-legacy": "^4.1.1",
"autoprefixer": "^10.4.16",
"axios": "^1.6.2",
"classnames": "^2.4.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^8.10.0",
"eslint-plugin-svelte": "^2.35.1",
"hls.js": "^1.4.14",
"openapi-fetch": "^0.8.2",
"openapi-typescript": "^6.7.3",
"postcss": "^8.4.32",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1",
"radix-icons-svelte": "^1.2.1",
"svelte": "^3.59.1",
"svelte-check": "^3.6.2",
"svelte-i18n": "^4.0.0",
"svelte-navigator": "^3.2.2",
"tailwind-scrollbar-hide": "^1.1.7", "tailwind-scrollbar-hide": "^1.1.7",
"typeorm": "^0.3.17" "tailwindcss": "^3.4.0",
"terser": "^5.26.0",
"tslib": "^2.6.2",
"typescript": "^5.2.2",
"vite": "^4.5.1",
"vite-plugin-singlefile": "^0.13.5"
} }
} }

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 169 KiB

View File

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

View File

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

Before

Width:  |  Height:  |  Size: 846 B

After

Width:  |  Height:  |  Size: 846 B

View File

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 684 B

After

Width:  |  Height:  |  Size: 684 B

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

56
src/App.svelte Normal file
View File

@@ -0,0 +1,56 @@
<script lang="ts">
import Carousel from "./lib/components/Carousel/Carousel.svelte";
import I18n from "./lib/components/Lang/I18n.svelte";
import { _ } from "svelte-i18n";
import CarouselPlaceholderItems from "./lib/components/Carousel/CarouselPlaceholderItems.svelte";
import { Link, navigate, Route, Router } from "svelte-navigator";
import { fade } from "svelte/transition";
import { networks } from "./lib/discover";
import NetworkCard from "./lib/components/NetworkCard.svelte";
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "ArrowRight") {
console.log("right");
navigate("/about");
}
if (event.key === "ArrowLeft") {
console.log("left");
navigate("/");
}
}
</script>
<I18n />
<main class="bg-stone-950 text-white">
<Router>
<nav>
<Link to="/">Home</Link>
<Link to="about">About</Link>
</nav>
<Carousel>
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_("discover.upcomingSeries")}
</div>
<CarouselPlaceholderItems />
</Carousel>
<Carousel>
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_("discover.TVNetworks")}
</div>
{#each Object.values(networks) as network (network.tmdbNetworkId)}
<NetworkCard {network} />
{/each}
</Carousel>
<Route path="/">
<div transition:fade|global>Home path</div>
</Route>
<Route path="about">
<div transition:fade|global>about path</div>
</Route>
</Router>
</main>
<svelte:window on:keydown={handleKeyDown} />

View File

@@ -1,31 +0,0 @@
<!DOCTYPE html>
<html lang="en" style="background-color: black">
<head>
<title>Reiverr</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Nunito+Sans:wght@200;300;400;500;600;700;800;900;1000&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Montserrat:wght@100;200;300;400;500;600;700;800;900&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
</head>
<body
data-sveltekit-preload-data="hover"
class="bg-stone-950 min-h-screen text-white touch-manipulation relative -z-10"
>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -1,51 +0,0 @@
process.env.PORT = '9494';
const { app, BrowserWindow } = require('electron');
(async () => {
await import('../build/index.js');
// const serveURL = serve({ directory: '.' });
const port = process.env.PORT || 5173;
let mainWindow;
const createWindow = () => {
if (!mainWindow) {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: true
}
});
}
mainWindow.once('close', () => {
mainWindow = null;
});
loadSite(port);
return mainWindow;
};
function loadSite(port) {
mainWindow.loadURL(`http://localhost:${port}`).catch((e) => {
console.log('Error loading URL, retrying', e);
setTimeout(() => {
loadSite(port);
}, 500);
});
}
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (!mainWindow) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
})();

View File

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

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { settings } from '$lib/stores/settings.store';
import { addMessages, init, locale } from 'svelte-i18n'; import { addMessages, init, locale } from 'svelte-i18n';
import de from '../../lang/de.json'; import de from '../../lang/de.json';
@@ -7,6 +6,7 @@
import es from '../../lang/es.json'; import es from '../../lang/es.json';
import fr from '../../lang/fr.json'; import fr from '../../lang/fr.json';
import it from '../../lang/it.json'; import it from '../../lang/it.json';
import { settings } from '../../stores/settings.store';
addMessages('de', de); addMessages('de', de);
addMessages('en', en); addMessages('en', en);

View File

@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import type { TitleId } from '$lib/types'; import type { TitleId } from '$lib/types';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import MoviePage from '../../../routes/movie/[id]/MoviePage.svelte'; import MoviePage from '../MoviePage.svelte';
import SeriesPage from '../../../routes/series/[id]/SeriesPage.svelte'; import SeriesPage from '../SeriesPage.svelte';
import { modalStack } from '../../stores/modal.store'; import { modalStack } from '../../stores/modal.store';
import PersonPage from '../../../routes/person/[id]/PersonPage.svelte'; import PersonPage from '../PersonPage.svelte';
export let titleId: TitleId; export let titleId: TitleId;
export let modalId: symbol; export let modalId: symbol;

8
src/main.ts Normal file
View File

@@ -0,0 +1,8 @@
import './app.css'
import App from './App.svelte'
const app = new App({
target: document.getElementById('app'),
})
export default app

View File

@@ -1,31 +0,0 @@
<script lang="ts">
import { page } from '$app/stores';
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 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 Notifications from '$lib/components/Notification/Notifications.svelte';
// 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}
<Notifications />
<UpdateChecker />
</div>
<!-- {:else} -->
<!-- <SetupRequired missingEnvironmentVariables={data.missingEnvironmentVariables} /> -->
<!-- {/if} -->

View File

@@ -1,7 +0,0 @@
// import { dev } from '$app/environment';
// Disable SSR when running the dev server
// This is a fix to vite dev server freezing on mac :(
// https://github.com/vitejs/vite/issues/11468
// export const ssr = !dev;
export const ssr = false;

View File

@@ -1,310 +0,0 @@
<script lang="ts">
import {
getJellyfinBackdrop,
getJellyfinContinueWatching,
getJellyfinNextUp,
type JellyfinItem
} from '$lib/apis/jellyfin/jellyfinApi';
import {
getPosterProps,
getTmdbMovie,
getTmdbPopularMovies,
TmdbApiOpen
} from '$lib/apis/tmdb/tmdbApi';
import Carousel from '$lib/components/Carousel/Carousel.svelte';
import CarouselPlaceholderItems from '$lib/components/Carousel/CarouselPlaceholderItems.svelte';
import GenreCard from '$lib/components/GenreCard.svelte';
import NetworkCard from '$lib/components/NetworkCard.svelte';
import PersonCard from '$lib/components/PersonCard/PersonCard.svelte';
import Poster from '$lib/components/Poster/Poster.svelte';
import TitleShowcases from '$lib/components/TitleShowcase/TitleShowcasesContainer.svelte';
import { genres, networks } from '$lib/discover';
import { jellyfinItemsStore } from '$lib/stores/data.store';
import { settings } from '$lib/stores/settings.store';
import type { TitleType } from '$lib/types';
import { formatDateToYearMonthDay } from '$lib/utils';
import type { ComponentProps } from 'svelte';
import { _ } from 'svelte-i18n';
import { fade } from 'svelte/transition';
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));
let nextUpP = getJellyfinNextUp();
let continueWatchingP = getJellyfinContinueWatching();
let nextUpProps = Promise.all([nextUpP, continueWatchingP])
.then(([nextUp, continueWatching]) => [
...(continueWatching || []),
...(nextUp?.filter((i) => !continueWatching?.find((c) => c.SeriesId === i.SeriesId)) || [])
])
.then((items) =>
Promise.all(
items?.map(async (item) => {
const parentSeries = await jellyfinItemsStore.promise.then((items) =>
items.find((i) => i.Id === item.SeriesId)
);
return {
tmdbId: Number(item.ProviderIds?.Tmdb) || Number(parentSeries?.ProviderIds?.Tmdb) || 0,
jellyfinId: item.Id,
backdropUrl: getJellyfinBackdrop(item),
title: item.Name || '',
progress: item.UserData?.PlayedPercentage || undefined,
runtime: item.RunTimeTicks ? item.RunTimeTicks / 10_000_000 / 60 : 0,
...(item.Type === 'Movie'
? {
type: 'movie',
subtitle: item.Genres?.join(', ') || ''
}
: {
type: 'series',
subtitle:
(item?.IndexNumber && 'Episode ' + item.IndexNumber) ||
item.Genres?.join(', ') ||
''
})
} as const;
})
)
);
nextUpProps.then((props) => {
if (props.length === 0) {
continueWatchingVisible = false;
}
});
let showcaseIndex = 0;
async function onNext() {
showcaseIndex = (showcaseIndex + 1) % (await tmdbPopularMoviesPromise).length;
}
async function onPrevious() {
showcaseIndex =
(showcaseIndex - 1 + (await tmdbPopularMoviesPromise).length) %
(await tmdbPopularMoviesPromise).length;
}
const jellyfinItemsPromise = new Promise<JellyfinItem[]>((resolve) => {
jellyfinItemsStore.subscribe((data) => {
if (data.loading) return;
resolve(data.data || []);
});
});
const fetchCardProps = async (
items: {
name?: string;
title?: string;
id?: number;
vote_average?: number;
number_of_seasons?: number;
first_air_date?: string;
poster_path?: string;
}[],
type: TitleType | undefined = undefined
): Promise<ComponentProps<Poster>[]> => {
const filtered = $settings.discover.excludeLibraryItems
? items.filter(
async (item) =>
!(await jellyfinItemsPromise).find((i) => i.ProviderIds?.Tmdb === String(item.id))
)
: items;
return Promise.all(filtered.map(async (item) => getPosterProps(item, type))).then((props) =>
props.filter((p) => p.backdropUrl)
);
};
const trendingItemsPromise = TmdbApiOpen.get('/3/trending/all/{time_window}', {
params: {
path: {
time_window: 'day'
},
query: {
language: $settings.language
}
}
}).then((res) => res.data?.results || []);
const fetchTrendingProps = () => trendingItemsPromise.then(fetchCardProps);
const fetchTrendingActorProps = () =>
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((i) => fetchCardProps(i, 'series'));
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((i) => fetchCardProps(i, 'series'));
function parseIncludedLanguages(includedLanguages: string) {
return includedLanguages.replace(' ', '').split(',').join('|');
}
const PADDING = 'px-4 lg:px-8 2xl:px-16';
</script>
<TitleShowcases />
<div
class="flex flex-col gap-12 py-6 bg-stone-950"
in:fade|global={{
duration: $settings.animationDuration,
delay: $settings.animationDuration
}}
out:fade|global={{ duration: $settings.animationDuration }}
>
<Carousel scrollClass={PADDING}>
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_('discover.popularPeople')}
</div>
{#await fetchTrendingActorProps()}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<PersonCard {...prop} />
{/each}
{/await}
</Carousel>
<Carousel scrollClass={PADDING}>
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_('discover.upcomingMovies')}
</div>
{#await fetchUpcomingMovies()}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<Poster {...prop} />
{/each}
{/await}
</Carousel>
<Carousel scrollClass={PADDING}>
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_('discover.upcomingSeries')}
</div>
{#await fetchUpcomingSeries()}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<Poster {...prop} />
{/each}
{/await}
</Carousel>
<Carousel scrollClass={PADDING}>
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_('discover.genres')}
</div>
{#each Object.values(genres) as genre (genre.tmdbGenreId)}
<GenreCard {genre} />
{/each}
</Carousel>
<Carousel scrollClass={PADDING}>
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_('discover.newDigitalReleases')}
</div>
{#await fetchDigitalReleases()}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<Poster {...prop} />
{/each}
{/await}
</Carousel>
<Carousel scrollClass={PADDING}>
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_('discover.streamingNow')}
</div>
{#await fetchNowStreaming()}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<Poster {...prop} />
{/each}
{/await}
</Carousel>
<Carousel scrollClass={PADDING}>
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_('discover.TVNetworks')}
</div>
{#each Object.values(networks) as network (network.tmdbNetworkId)}
<NetworkCard {network} />
{/each}
</Carousel>
</div>

View File

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

View File

@@ -1,244 +0,0 @@
<script lang="ts">
import type { JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
import { TmdbApiOpen, getPosterProps } from '$lib/apis/tmdb/tmdbApi';
import Carousel from '$lib/components/Carousel/Carousel.svelte';
import CarouselPlaceholderItems from '$lib/components/Carousel/CarouselPlaceholderItems.svelte';
import GenreCard from '$lib/components/GenreCard.svelte';
import NetworkCard from '$lib/components/NetworkCard.svelte';
import PersonCard from '$lib/components/PersonCard/PersonCard.svelte';
import Poster from '$lib/components/Poster/Poster.svelte';
import { genres, networks } from '$lib/discover';
import { jellyfinItemsStore } from '$lib/stores/data.store';
import { settings } from '$lib/stores/settings.store';
import type { TitleType } from '$lib/types';
import { formatDateToYearMonthDay } from '$lib/utils';
import type { ComponentProps } from 'svelte';
import { _ } from 'svelte-i18n';
import { fade } from 'svelte/transition';
const jellyfinItemsPromise = new Promise<JellyfinItem[]>((resolve) => {
jellyfinItemsStore.subscribe((data) => {
if (data.loading) return;
resolve(data.data || []);
});
});
const fetchCardProps = async (
items: {
name?: string;
title?: string;
id?: number;
vote_average?: number;
number_of_seasons?: number;
first_air_date?: string;
poster_path?: string;
}[],
type: TitleType | undefined = undefined
): Promise<ComponentProps<Poster>[]> => {
const filtered = $settings.discover.excludeLibraryItems
? items.filter(
async (item) =>
!(await jellyfinItemsPromise).find((i) => i.ProviderIds?.Tmdb === String(item.id))
)
: items;
return Promise.all(filtered.map(async (item) => getPosterProps(item, type))).then((props) =>
props.filter((p) => p.backdropUrl)
);
};
const trendingItemsPromise = TmdbApiOpen.get('/3/trending/all/{time_window}', {
params: {
path: {
time_window: 'day'
},
query: {
language: $settings.language
}
}
}).then((res) => res.data?.results || []);
const fetchTrendingProps = () => trendingItemsPromise.then(fetchCardProps);
const fetchTrendingActorProps = () =>
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((i) => fetchCardProps(i, 'series'));
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((i) => fetchCardProps(i, 'series'));
function parseIncludedLanguages(includedLanguages: string) {
return includedLanguages.replace(' ', '').split(',').join('|');
}
</script>
<!-- {#await trendingItemsPromise then items}
{#if items.length}
<div class="absolute inset-0 blur-3xl brightness-[0.2] z-[-1] scale-125">
<LazyImg src={TMDB_IMAGES_ORIGINAL + items?.[4].backdrop_path} class="h-full" />
</div>
{/if}
{/await} -->
<div
class="pt-24 pb-8"
in:fade|global={{
duration: $settings.animationDuration,
delay: $settings.animationDuration
}}
out:fade|global={{ duration: $settings.animationDuration }}
>
<div class="max-w-screen-2xl mx-auto">
<Carousel
gradientFromColor="from-stone-950"
heading={$_('discover.trending')}
class="mx-2 sm:mx-8 2xl:mx-0"
>
{#await fetchTrendingProps()}
<CarouselPlaceholderItems size="lg" />
{:then props}
{#each props as prop (prop.tmdbId)}
<Poster {...prop} size="lg" />
{/each}
{/await}
</Carousel>
</div>
</div>
<div
class="flex flex-col gap-12 max-w-screen-2xl mx-auto py-4"
in:fade|global={{
duration: $settings.animationDuration,
delay: $settings.animationDuration
}}
out:fade|global={{ duration: $settings.animationDuration }}
>
<Carousel class="mx-2 sm:mx-8 2xl:mx-0" heading={$_('discover.popularPeople')}>
{#await fetchTrendingActorProps()}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<PersonCard {...prop} />
{/each}
{/await}
</Carousel>
<Carousel class="mx-2 sm:mx-8 2xl:mx-0" heading={$_('discover.upcomingMovies')}>
{#await fetchUpcomingMovies()}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<Poster {...prop} />
{/each}
{/await}
</Carousel>
<Carousel class="mx-2 sm:mx-8 2xl:mx-0" heading={$_('discover.upcomingSeries')}>
{#await fetchUpcomingSeries()}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<Poster {...prop} />
{/each}
{/await}
</Carousel>
<Carousel class="mx-2 sm:mx-8 2xl:mx-0" heading={$_('discover.genres')}>
{#each Object.values(genres) as genre (genre.tmdbGenreId)}
<GenreCard {genre} />
{/each}
</Carousel>
<Carousel class="mx-2 sm:mx-8 2xl:mx-0" heading={$_('discover.newDigitalReleases')}>
{#await fetchDigitalReleases()}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<Poster {...prop} />
{/each}
{/await}
</Carousel>
<Carousel class="mx-2 sm:mx-8 2xl:mx-0" heading={$_('discover.streamingNow')}>
{#await fetchNowStreaming()}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<Poster {...prop} />
{/each}
{/await}
</Carousel>
<Carousel class="mx-2 sm:mx-8 2xl:mx-0" heading={$_('discover.TVNetworks')}>
{#each Object.values(networks) as network (network.tmdbNetworkId)}
<NetworkCard {network} />
{/each}
</Carousel>
</div>

View File

@@ -1,69 +0,0 @@
<script lang="ts">
import { getTmdbGenreMovies } from '$lib/apis/tmdb/tmdbApi';
import Card from '$lib/components/Card/Card.svelte';
import CardPlaceholder from '$lib/components/Card/CardPlaceholder.svelte';
import { fetchCardTmdbProps } from '$lib/components/Card/card';
import GridPage from '$lib/components/GridPage/GridPage.svelte';
import { genres, type Genre } from '$lib/discover';
import type { PageData } from './$types';
export let data: PageData;
const genre = genres[data.genre];
async function fetchGenreItems(genre: Genre) {
const movies = await getTmdbGenreMovies(genre.tmdbGenreId);
const itemProps = await Promise.all(movies.map(fetchCardTmdbProps));
return {
movies,
itemProps
};
}
</script>
<GridPage title={data.genre}>
{#if genre}
{#await fetchGenreItems(genre)}
{#each [...Array(20).keys()] as index (index)}
<CardPlaceholder size="dynamic" {index} />
{/each}
{:then { itemProps }}
{#each itemProps as itemProps}
<Card {...itemProps} size="dynamic" />
{/each}
{:catch error}
{error.message}
{/await}
{:else}
404
{/if}
</GridPage>
<!-- <div class="pt-24 p-8 bg-black">
<button on:click={() => window?.history?.back()}>Back</button>
{data.genre}
</div>
<div class="p-8">
{#if genre}
{#await fetchGenreItems(genre)}
<CardGrid>
{#each [...Array(20).keys()] as index (index)}
<CardPlaceholder size="dynamic" {index} />
{/each}
</CardGrid>
{:then { itemProps }}
<CardGrid>
{#each itemProps as itemProps}
<Card {...itemProps} size="dynamic" />
{/each}
</CardGrid>
{:catch error}
{error.message}
{/await}
{:else}
404
{/if}
</div> -->

View File

@@ -1,7 +0,0 @@
import type { PageLoad } from './$types';
export const load = (async ({ params }) => {
return {
genre: params.genre
};
}) satisfies PageLoad;

View File

@@ -1,46 +0,0 @@
<script lang="ts">
import { getTmdbNetworkSeries } from '$lib/apis/tmdb/tmdbApi';
import Card from '$lib/components/Card/Card.svelte';
import CardGrid from '$lib/components/Card/CardGrid.svelte';
import CardPlaceholder from '$lib/components/Card/CardPlaceholder.svelte';
import { fetchCardTmdbSeriesProps } from '$lib/components/Card/card';
import type { ComponentProps } from 'svelte';
import type { PageData } from './$types';
import GridPage from '$lib/components/GridPage/GridPage.svelte';
import { networks, type Network } from '../../../../lib/discover';
export let data: PageData;
const network = networks[data.network] || undefined;
async function fetchNetworkSeries(network: Network) {
const shows = await getTmdbNetworkSeries(network.tmdbNetworkId);
const showProps: ComponentProps<Card>[] = await Promise.all(
shows.map(fetchCardTmdbSeriesProps)
);
return {
shows,
showProps
};
}
</script>
<GridPage title={data.network}>
{#if network}
{#await fetchNetworkSeries(network)}
{#each [...Array(20).keys()] as index (index)}
<CardPlaceholder size="dynamic" {index} />
{/each}
{:then { showProps }}
{#each showProps as showProps}
<Card {...showProps} size="dynamic" />
{/each}
{:catch error}
{error.message}
{/await}
{:else}
404
{/if}
</GridPage>

View File

@@ -1,7 +0,0 @@
import type { PageLoad } from './$types';
export const load = (async ({ params }) => {
return {
network: params.network
};
}) satisfies PageLoad;

View File

@@ -1,173 +0,0 @@
<script lang="ts">
import {
getJellyfinBackdrop,
getJellyfinPosterUrl,
type JellyfinItem
} from '$lib/apis/jellyfin/jellyfinApi';
import Button from '$lib/components/Button.svelte';
import Carousel from '$lib/components/Carousel/Carousel.svelte';
import Poster from '$lib/components/Poster/Poster.svelte';
import { playerState } from '$lib/components/VideoPlayer/VideoPlayer';
import { PLACEHOLDER_BACKDROP } from '$lib/constants';
import { jellyfinItemsStore, servarrDownloadsStore } from '$lib/stores/data.store';
import { settings } from '$lib/stores/settings.store';
import { ChevronRight } from 'radix-icons-svelte';
import type { ComponentProps } from 'svelte';
import { _ } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import LibraryItems from './LibraryItems.svelte';
import { capitalize } from '$lib/utils';
import LazyImg from '$lib/components/LazyImg.svelte';
let openNextUpTab: 'downloading' | 'nextUp' = 'downloading';
let noItems = false;
let showcasePromise: Promise<JellyfinItem | undefined> = jellyfinItemsStore.promise.then(
(items) =>
items
?.slice()
?.sort((a, b) =>
(a.DateCreated || a.DateLastMediaAdded || '') <
(b.DateCreated || b.DateLastMediaAdded || '')
? 1
: -1
)?.[3]
);
let downloadProps: ComponentProps<Poster>[] = [];
$: {
const sonarrProps: ComponentProps<Poster>[] =
$servarrDownloadsStore.sonarrDownloads?.map((item) => ({
tvdbId: item.series.tvdbId,
title: item.series.title || '',
subtitle:
`S${item.episode?.seasonNumber}E${item.episode?.episodeNumber} • ` +
capitalize(item.status || ''),
type: 'series',
progress: 100 * (((item.size || 0) - (item.sizeleft || 0)) / (item.size || 1)),
backdropUrl: item.series.images?.find((i) => i.coverType === 'poster')?.url || '',
orientation: 'portrait'
})) || [];
const radarrProps: ComponentProps<Poster>[] =
$servarrDownloadsStore.radarrDownloads?.map((item) => ({
tmdbId: item.movie.tmdbId,
title: item.movie.title || '',
subtitle: capitalize(item.status || ''),
type: 'movie',
backdropUrl: item.movie.images?.find((i) => i.coverType === 'poster')?.url || '',
progress: 100 * (((item.size || 0) - (item.sizeleft || 0)) / (item.size || 1)),
orientation: 'portrait'
})) || [];
downloadProps = [...(sonarrProps || []), ...(radarrProps || [])];
}
</script>
{#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>
{$_('library.missingConfiguration')}
</h1>
</div>
{:else}
<div
in:fade|global={{
duration: $settings.animationDuration,
delay: $settings.animationDuration
}}
out:fade|global={{ duration: $settings.animationDuration }}
>
<div class="relative pt-24">
{#await showcasePromise then showcase}
<LazyImg
src={(showcase && getJellyfinBackdrop(showcase)) || PLACEHOLDER_BACKDROP}
class="absolute inset-0"
/>
{/await}
<div class="absolute inset-0 bg-gradient-to-t from-stone-950 to-80% to-darken" />
<div
class="max-w-screen-2xl mx-auto relative z-[1] px-2 md:px-8 pt-32 xl:pt-56 pb-12 overflow-hidden"
>
<h1
class="absolute font-bold uppercase text-amber-200 opacity-10 bottom-12 right-8 text-9xl hidden xl:block z-[-1]"
>
Library
</h1>
<div class="flex gap-4 items-end">
{#await showcasePromise}
<div class="w-32 aspect-[2/3] placeholder rounded-lg shadow-lg" />
<div class="flex flex-col gap-2">
<div class="placeholder-text w-20">Placeholder</div>
<div class="placeholder-text w-[50vw] text-3xl sm:text-4xl md:text-5xl">
Placeholder
</div>
<div class="flex gap-2 mt-2">
<div class="placeholder-text w-28 h-10" />
<div class="placeholder-text w-28 h-10" />
</div>
</div>
{:then showcase}
<div
style={"background-image: url('" +
(showcase ? getJellyfinPosterUrl(showcase) : '') +
"');"}
class="w-32 aspect-[2/3] rounded-lg bg-center bg-cover flex-shrink-0 shadow-lg"
/>
<div>
<p class="text-zinc-400 font-medium">Latest Addition</p>
<h1 class="text-3xl sm:text-4xl md:text-5xl font-semibold">
{showcase?.Name}
</h1>
<div class="flex gap-2 mt-4">
<Button
type="primary"
on:click={() => showcase?.Id && playerState.streamJellyfinId(showcase?.Id)}
>
Play<ChevronRight size={20} />
</Button>
<Button
href={`/${showcase?.Type === 'Movie' ? 'movie' : 'series'}/${
showcase?.ProviderIds?.Tmdb || showcase?.ProviderIds?.Tvdb
}`}
>
<span>{$_('titleShowcase.details')}</span><ChevronRight size={20} />
</Button>
</div>
</div>
{/await}
</div>
</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-12">
{#if downloadProps?.length}
<div>
<Carousel heading="Downloading">
{#each downloadProps as props}
<Poster {...props} />
{/each}
</Carousel>
</div>
{/if}
<LibraryItems />
</div>
</div>
{/if}

View File

@@ -1,13 +0,0 @@
<script lang="ts">
import type { PageData } from './$types';
import MoviePage from './MoviePage.svelte';
export let data: PageData;
let tmdbId: number;
$: tmdbId = Number(data.tmdbId);
</script>
{#key tmdbId}
<MoviePage {tmdbId} />
{/key}

View File

@@ -1,7 +0,0 @@
import type { PageLoad } from './$types';
export const load = (async ({ params }) => {
return {
tmdbId: params.id
};
}) satisfies PageLoad;

View File

@@ -1,13 +0,0 @@
<script lang="ts">
import type { PageData } from './$types';
import PersonPage from './PersonPage.svelte';
export let data: PageData;
let personId: number;
$: personId = Number(data.personId);
</script>
{#key personId}
<PersonPage tmdbId={personId} />
{/key}

View File

@@ -1,7 +0,0 @@
import type { PageLoad } from './$types';
export const load = (async ({ params }) => {
return {
personId: Number(params.id)
};
}) satisfies PageLoad;

View File

@@ -1,14 +0,0 @@
<script lang="ts">
import type { TitleId } from '$lib/types';
import type { PageData } from './$types';
import SeriesPage from './SeriesPage.svelte';
export let data: PageData;
let titleId: TitleId;
$: titleId = { provider: 'tmdb', id: data.tmdbId, type: 'series' };
</script>
{#key titleId}
<SeriesPage {titleId} />
{/key}

View File

@@ -1,7 +0,0 @@
import type { PageLoad } from './$types';
export const load = (async ({ params }) => {
return {
tmdbId: Number(params.id)
};
}) satisfies PageLoad;

View File

@@ -1,283 +0,0 @@
<script lang="ts">
import { version } from '$app/environment';
import { beforeNavigate } from '$app/navigation';
import { getJellyfinHealth, getJellyfinUsers } from '$lib/apis/jellyfin/jellyfinApi';
import { getRadarrHealth } from '$lib/apis/radarr/radarrApi';
import { getSonarrHealth } from '$lib/apis/sonarr/sonarrApi';
import FormButton from '$lib/components/forms/FormButton.svelte';
import Select from '$lib/components/forms/Select.svelte';
import { settings, type SettingsValues } from '$lib/stores/settings.store';
import axios from 'axios';
import classNames from 'classnames';
import { ChevronLeft } from 'radix-icons-svelte';
import GeneralSettingsPage from './GeneralSettingsPage.svelte';
import IntegrationSettingsPage from './IntegrationSettingsPage.svelte';
import { fade } from 'svelte/transition';
import { _ } from 'svelte-i18n';
import { createErrorNotification } from '$lib/stores/notification.store';
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(async (v) => {
values = structuredClone(v);
initialValues = structuredClone(v);
const s = updateSonarrHealth();
const r = updateRadarrHealth();
const j = updateJellyfinHealth();
await Promise.all([s, r, j]);
checkForPartialConfiguration(v);
});
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))
) {
createErrorNotification(
'Invalid Configuration',
'Could not connect to Sonarr. Check Sonarr credentials.'
);
return;
}
if (
values.radarr.apiKey &&
values.radarr.baseUrl &&
!(await getRadarrHealth(values.radarr.baseUrl, values.radarr.apiKey))
) {
createErrorNotification(
'Invalid Configuration',
'Could not connect to Radarr. Check Radarr credentials.'
);
return;
}
if (values.jellyfin.apiKey && values.jellyfin.baseUrl) {
if (!(await getJellyfinHealth(values.jellyfin.baseUrl, values.jellyfin.apiKey))) {
createErrorNotification(
'Invalid Configuration',
'Could not connect to Jellyfin. Check Jellyfin credentials.'
);
return;
}
const users = await getJellyfinUsers(values.jellyfin.baseUrl, values.jellyfin.apiKey);
if (!users.find((u) => u.Id === values.jellyfin.userId)) values.jellyfin.userId = null;
}
updateSonarrHealth();
updateRadarrHealth();
updateJellyfinHealth();
axios.post('/api/settings', values).then(() => {
settings.set(values);
});
}
function checkForPartialConfiguration(v: SettingsValues) {
let error = '';
if (sonarrConnected && !v.sonarr.rootFolderPath) {
error = 'Sonarr disabled: Root folder path is required';
} else if (sonarrConnected && !v.sonarr.qualityProfileId) {
error = 'Sonarr disabled: Quality profile is required';
} else if (sonarrConnected && !v.sonarr.languageProfileId) {
error = 'Sonarr disabled: Language profile is required';
}
if (radarrConnected && !v.radarr.rootFolderPath) {
error = 'Radarr disabled: Root folder path is required';
} else if (radarrConnected && !v.radarr.qualityProfileId) {
error = 'Radarr disabled: Quality profile is required';
}
if (jellyfinConnected && !v.jellyfin.userId) {
error = 'Jellyfin disabled: User is required';
}
if (error) createErrorNotification('Incomplete Configuration', error, 'warning');
}
async function updateSonarrHealth(reset = false): Promise<boolean> {
if (!values.sonarr.baseUrl || !values.sonarr.apiKey || reset) {
sonarrConnected = false;
return false;
}
return getSonarrHealth(
values.sonarr.baseUrl || undefined,
values.sonarr.apiKey || undefined
).then((ok) => {
sonarrConnected = ok;
return ok;
});
}
async function updateRadarrHealth(reset = false): Promise<boolean> {
if (!values.radarr.baseUrl || !values.radarr.apiKey || reset) {
radarrConnected = false;
return false;
}
return getRadarrHealth(
values.radarr.baseUrl || undefined,
values.radarr.apiKey || undefined
).then((ok) => {
radarrConnected = ok;
return ok;
});
}
async function updateJellyfinHealth(reset = false): Promise<boolean> {
if (!values.jellyfin.baseUrl || !values.jellyfin.apiKey || reset) {
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>
<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.navbar.settings')}
</button>
<button
on:click={() => (openTab = 'general')}
class={openTab && getNavButtonStyle('general')}
>
{$_('settings.navbar.general')}
</button>
<button
on:click={() => (openTab = 'integrations')}
class={openTab && getNavButtonStyle('integrations')}
>
{$_('settings.navbar.integrations')}
</button>
</div>
<div class="flex flex-col gap-2">
<FormButton
disabled={!valuesChanged}
loading={submitLoading}
on:click={handleSubmit}
type={valuesChanged ? 'success' : 'base'}
>
{$_('settings.misc.saveChanges')}
</FormButton>
<FormButton
disabled={!valuesChanged}
type="error"
on:click={() => {
settings.set(initialValues);
}}
>
{$_('settings.misc.resetToDefaults')}
</FormButton>
</div>
</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.navbar.settings')}
</button>
<Select bind:value={openTab}>
<option value="general"> {$_('settings.navbar.general')} </option>
<option value="integrations">
{$_('settings.navbar.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}
{updateSonarrHealth}
{updateRadarrHealth}
{updateJellyfinHealth}
/>
{/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">
{$_('settings.misc.changelog')}
</a>
<a target="_blank" href="https://github.com/aleksilassila/reiverr">GitHub</a>
</div>
</div>
</div>
<!-- Language settings -->

View File

@@ -1,39 +0,0 @@
<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"
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 />
<SonarrStats large />
<!-- <div
class="border-zinc-800 border-2 border-dashed relative w-full p-3 px-4 rounded-xl overflow-hidden text-zinc-500 text-center flex flex-col gap-1"
>
<h2 class="font-medium">Sonarr is not set up</h2>
<p class="text-sm">
To set up Sonarr, define the <code>PUBLIC_SONARR_API_KEY</code> and <code>SONARR_URL</code> environment
variables.
</p>
</div> -->
</div>
<!-- <div>Sources</div>-->
<!-- <div>Streaming services</div>-->
<!-- <div>(P2P network?)</div>-->
<!-- <div>Configure Sonarr</div>-->
<!-- <div>Configure Radarr</div>-->
<!-- <div>(Configure Plex)</div>-->
<!-- <div>(Configure Jellyfinn)</div>-->
<!-- <div>(Configure IMDB)</div>-->
<!-- <div>(Configure TMDB)</div>-->
</div>

2
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />

27
svelte.config.js Executable file → Normal file
View File

@@ -1,5 +1,4 @@
import adapter from '@sveltejs/adapter-static'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import { vitePreprocess } from '@sveltejs/kit/vite';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
@@ -7,28 +6,10 @@ const file = fileURLToPath(new URL('package.json', import.meta.url));
const json = readFileSync(file, 'utf8'); const json = readFileSync(file, 'utf8');
const pkg = JSON.parse(json); const pkg = JSON.parse(json);
/** @type {import('@sveltejs/kit').Config} */ export default {
const config = { // Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors // for more information about preprocessors
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter({
fallback: 'index.html'
}),
paths: {
assets: 'https://replaceme.com'
},
version: {
name: pkg.version
}
},
vitePlugin: { vitePlugin: {
inspector: { inspector: {
// toggleKeyCombo: 'meta', // toggleKeyCombo: 'meta',
@@ -36,5 +17,3 @@ const config = {
} }
} }
}; };
export default config;

View File

@@ -1,23 +1,20 @@
{ {
"extends": "./.svelte-kit/tsconfig.json", "extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true, "allowJs": true,
"checkJs": true, "checkJs": true,
"esModuleInterop": true, "isolatedModules": true
"forceConsistentCasingInFileNames": true, },
"resolveJsonModule": true, "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
"skipLibCheck": true, "references": [{ "path": "./tsconfig.node.json" }]
"sourceMap": true,
"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
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
} }

9
tsconfig.node.json Normal file
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler"
},
"include": ["vite.config.ts"]
}

View File

@@ -1,16 +1,16 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite';
import { defineConfig } from 'vitest/config'; import { svelte } from '@sveltejs/vite-plugin-svelte';
// import * as pkg from './package.json'; import { viteSingleFile } from 'vite-plugin-singlefile';
import viteLegacyPlugin from '@vitejs/plugin-legacy';
// https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()], plugins: [viteLegacyPlugin(), svelte(), viteSingleFile()]
// define: {
// PKG: pkg // base: '/dist',
// experimental: {
// renderBuiltUrl() {
// return { relative: true }
// }
// }, // },
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
},
ssr: {
external: ['reflect-metadata']
}
}); });