refactor: User and session management

This commit is contained in:
Aleksi Lassila
2024-06-12 18:32:39 +03:00
parent a73f9d6cca
commit 5c1a4d4206
28 changed files with 364 additions and 388 deletions

View File

@@ -7,13 +7,15 @@ import {
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { SignInDto } from '../user/user.dto'; import { SignInDto, UserDto } from '../user/user.dto';
import { ApiOkResponse, ApiProperty } from '@nestjs/swagger'; import { ApiOkResponse, ApiProperty } from '@nestjs/swagger';
import { ApiException } from '@nanogiants/nestjs-swagger-api-exception-decorator'; import { ApiException } from '@nanogiants/nestjs-swagger-api-exception-decorator';
export class SignInResponse { export class SignInResponse {
@ApiProperty() @ApiProperty()
accessToken: string; accessToken: string;
@ApiProperty()
user: UserDto;
} }
@Controller('auth') @Controller('auth')
@@ -24,12 +26,14 @@ export class AuthController {
@ApiOkResponse({ description: 'User found', type: SignInResponse }) @ApiOkResponse({ description: 'User found', type: SignInResponse })
@ApiException(() => UnauthorizedException) @ApiException(() => UnauthorizedException)
async signIn(@Body() signInDto: SignInDto) { async signIn(@Body() signInDto: SignInDto) {
const { token } = await this.authService.signIn( const { token, user } = await this.authService.signIn(
signInDto.name, signInDto.name,
signInDto.password, signInDto.password,
); );
return { return {
accessToken: token, accessToken: token,
user: UserDto.fromEntity(user),
}; };
} }
} }

View File

@@ -19,6 +19,7 @@ export class AuthService {
password: string, password: string,
): Promise<{ ): Promise<{
token: string; token: string;
user: User;
}> { }> {
let user = await this.userService.findOneByName(name); let user = await this.userService.findOneByName(name);
if (!user && (await this.userService.noPreviousAdmins())) if (!user && (await this.userService.noPreviousAdmins()))
@@ -34,6 +35,7 @@ export class AuthService {
return { return {
token: await this.jwtService.signAsync(payload), token: await this.jwtService.signAsync(payload),
user,
}; };
} }
} }

View File

@@ -1,22 +1,23 @@
<script lang="ts"> <script lang="ts">
import I18n from './lib/components/Lang/I18n.svelte'; import I18n from './lib/components/Lang/I18n.svelte';
import { appState } from './lib/stores/app-state.store';
import { handleKeyboardNavigation } from './lib/selectable'; import { handleKeyboardNavigation } from './lib/selectable';
import LoginPage from './lib/pages/LoginPage.svelte'; import LoginPage from './lib/pages/LoginPage.svelte';
import ModalStack from './lib/components/Modal/ModalStack.svelte'; import ModalStack from './lib/components/Modal/ModalStack.svelte';
import NavigationDebugger from './lib/components/DebugElements.svelte'; import NavigationDebugger from './lib/components/DebugElements.svelte';
import StackRouter from './lib/components/StackRouter/StackRouter.svelte'; import StackRouter from './lib/components/StackRouter/StackRouter.svelte';
import { defaultStackRouter } from './lib/components/StackRouter/StackRouter'; import { stackRouter } from './lib/components/StackRouter/StackRouter';
import OnboardingPage from './lib/pages/OnboardingPage.svelte'; import OnboardingPage from './lib/pages/OnboardingPage.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { skippedVersion } from './lib/stores/localstorage.store';
import axios from 'axios'; import axios from 'axios';
import NotificationStack from './lib/components/Notifications/NotificationStack.svelte'; import NotificationStack from './lib/components/Notifications/NotificationStack.svelte';
import { createModal } from './lib/components/Modal/modal.store'; import { createModal } from './lib/components/Modal/modal.store';
import UpdateDialog from './lib/components/Dialog/UpdateDialog.svelte'; import UpdateDialog from './lib/components/Dialog/UpdateDialog.svelte';
import { localSettings } from './lib/stores/localstorage.store'; import { localSettings } from './lib/stores/localstorage.store';
import { user } from './lib/stores/user.store';
import { sessions } from './lib/stores/session.store';
appState.subscribe((s) => console.log('appState', s)); user.subscribe((s) => console.log('user', s));
sessions.subscribe((s) => console.log('sessions', s));
// onMount(() => { // onMount(() => {
// if (isTizen()) { // if (isTizen()) {
@@ -55,7 +56,7 @@
<I18n /> <I18n />
<!--<Container class="w-full h-full overflow-auto text-white scrollbar-hide">--> <!--<Container class="w-full h-full overflow-auto text-white scrollbar-hide">-->
{#if $appState.user === undefined} {#if $user === undefined}
<div class="h-full w-full flex flex-col items-center justify-center"> <div class="h-full w-full flex flex-col items-center justify-center">
<div class="flex items-center justify-center hover:text-inherit selectable rounded-sm mb-2"> <div class="flex items-center justify-center hover:text-inherit selectable rounded-sm mb-2">
<div class="rounded-full bg-amber-300 h-4 w-4 mr-2" /> <div class="rounded-full bg-amber-300 h-4 w-4 mr-2" />
@@ -63,9 +64,9 @@
</div> </div>
<div>Loading...</div> <div>Loading...</div>
</div> </div>
{:else if $appState.user === null} {:else if $user === null}
<LoginPage /> <LoginPage />
{:else if $appState.user.onboardingDone === false} {:else if $user.onboardingDone === false}
<OnboardingPage /> <OnboardingPage />
{:else} {:else}
<!-- <Router primary={false}>--> <!-- <Router primary={false}>-->
@@ -88,7 +89,7 @@
<!-- <Route path="*">--> <!-- <Route path="*">-->
<!-- <PageNotFound />--> <!-- <PageNotFound />-->
<!-- </Route>--> <!-- </Route>-->
<StackRouter stack={defaultStackRouter} /> <StackRouter stack={stackRouter} />
<!-- </Container>--> <!-- </Container>-->
<!-- </Router>--> <!-- </Router>-->

View File

@@ -2,7 +2,7 @@ import createClient from 'openapi-fetch';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import type { components, paths } from './jellyfin.generated'; import type { components, paths } from './jellyfin.generated';
import type { Api } from '../api.interface'; import type { Api } from '../api.interface';
import { appState } from '../../stores/app-state.store'; import { user } from '../../stores/user.store';
import type { DeviceProfile } from './playback-profiles'; import type { DeviceProfile } from './playback-profiles';
import axios from 'axios'; import axios from 'axios';
import { log } from '../../utils'; import { log } from '../../utils';
@@ -16,7 +16,7 @@ export const JELLYFIN_DEVICE_ID = 'Reiverr Client';
export class JellyfinApi implements Api<paths> { export class JellyfinApi implements Api<paths> {
getClient() { getClient() {
const jellyfinSettings = get(appState).user?.settings.jellyfin; const jellyfinSettings = get(user)?.settings.jellyfin;
const baseUrl = jellyfinSettings?.baseUrl; const baseUrl = jellyfinSettings?.baseUrl;
const apiKey = jellyfinSettings?.apiKey; const apiKey = jellyfinSettings?.apiKey;
@@ -29,15 +29,15 @@ export class JellyfinApi implements Api<paths> {
} }
getUserId() { getUserId() {
return get(appState).user?.settings.jellyfin.userId || ''; return get(user)?.settings.jellyfin.userId || '';
} }
getApiKey() { getApiKey() {
return get(appState).user?.settings.jellyfin.apiKey || ''; return get(user)?.settings.jellyfin.apiKey || '';
} }
getBaseUrl() { getBaseUrl() {
return get(appState).user?.settings.jellyfin.baseUrl || ''; return get(user)?.settings.jellyfin.baseUrl || '';
} }
getContinueWatching = async (type?: Type): Promise<JellyfinItem[] | undefined> => getContinueWatching = async (type?: Type): Promise<JellyfinItem[] | undefined> =>
@@ -102,7 +102,7 @@ export class JellyfinApi implements Api<paths> {
getPosterUrl(item: JellyfinItem, quality = 100, original = false) { getPosterUrl(item: JellyfinItem, quality = 100, original = false) {
return item.ImageTags?.Primary return item.ImageTags?.Primary
? `${get(appState).user?.settings.jellyfin.baseUrl}/Items/${ ? `${get(user)?.settings.jellyfin.baseUrl}/Items/${
item?.Id item?.Id
}/Images/Primary?quality=${quality}${original ? '' : '&fillWidth=432'}&tag=${ }/Images/Primary?quality=${quality}${original ? '' : '&fillWidth=432'}&tag=${
item?.ImageTags?.Primary item?.ImageTags?.Primary

View File

@@ -5,8 +5,8 @@ import { settings } from '../../stores/settings.store';
import type { components, paths } from './radarr.generated'; import type { components, paths } from './radarr.generated';
import { getTmdbMovie } from '../tmdb/tmdb-api'; import { getTmdbMovie } from '../tmdb/tmdb-api';
import { log } from '../../utils'; import { log } from '../../utils';
import { appState } from '../../stores/app-state.store';
import type { Api } from '../api.interface'; import type { Api } from '../api.interface';
import { user } from '../../stores/user.store';
export const movieAvailabilities = [ export const movieAvailabilities = [
// 'tba', // 'tba',
@@ -39,7 +39,7 @@ export interface RadarrMovieOptions {
export class RadarrApi implements Api<paths> { export class RadarrApi implements Api<paths> {
getClient() { getClient() {
const radarrSettings = get(appState).user?.settings.radarr; const radarrSettings = get(user)?.settings.radarr;
const baseUrl = radarrSettings?.baseUrl; const baseUrl = radarrSettings?.baseUrl;
const apiKey = radarrSettings?.apiKey; const apiKey = radarrSettings?.apiKey;
@@ -52,11 +52,11 @@ export class RadarrApi implements Api<paths> {
} }
getBaseUrl() { getBaseUrl() {
return get(appState)?.user?.settings?.radarr.baseUrl || ''; return get(user)?.settings?.radarr.baseUrl || '';
} }
getSettings() { getSettings() {
return get(appState).user?.settings.radarr; return get(user)?.settings.radarr;
} }
getMovieByTmdbId = (tmdbId: number): Promise<RadarrMovie | undefined> => getMovieByTmdbId = (tmdbId: number): Promise<RadarrMovie | undefined> =>
@@ -277,10 +277,10 @@ export class RadarrApi implements Api<paths> {
) => ) =>
axios axios
.get<components['schemas']['QualityProfileResource'][]>( .get<components['schemas']['QualityProfileResource'][]>(
(baseUrl || get(appState)?.user?.settings.radarr.baseUrl) + '/api/v3/qualityprofile', (baseUrl || get(user)?.settings.radarr.baseUrl) + '/api/v3/qualityprofile',
{ {
headers: { headers: {
'X-Api-Key': apiKey || get(appState)?.user?.settings.radarr.apiKey 'X-Api-Key': apiKey || get(user)?.settings.radarr.apiKey
} }
} }
) )

View File

@@ -1,18 +1,19 @@
import createClient from 'openapi-fetch'; import createClient from 'openapi-fetch';
import type { components, paths } from './reiverr.generated'; import type { components, paths } from './reiverr.generated';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { appState } from '../../stores/app-state.store';
import type { Api } from '../api.interface'; import type { Api } from '../api.interface';
import { sessions } from '../../stores/session.store';
export type ReiverrUser = components['schemas']['UserDto']; export type ReiverrUser = components['schemas']['UserDto'];
export type ReiverrSettings = ReiverrUser['settings']; export type ReiverrSettings = ReiverrUser['settings'];
export class ReiverrApi implements Api<paths> { export class ReiverrApi implements Api<paths> {
getClient(basePath?: string, _token?: string) { getClient(basePath?: string, _token?: string) {
const token = _token || get(appState).token; const session = get(sessions).activeSession;
const token = _token || session?.token;
return createClient<paths>({ return createClient<paths>({
baseUrl: (basePath || get(appState).serverBaseUrl) + '/api', baseUrl: (basePath || session?.baseUrl) + '/api',
...(token && { ...(token && {
headers: { headers: {
Authorization: 'Bearer ' + token Authorization: 'Bearer ' + token
@@ -45,7 +46,7 @@ export class ReiverrApi implements Api<paths> {
?.PUT('/user/{id}', { ?.PUT('/user/{id}', {
params: { params: {
path: { path: {
id: get(appState).user?.id as string id: user.id
} }
}, },
body: user body: user

View File

@@ -13,9 +13,6 @@ export interface paths {
get: operations["UserController_findById"]; get: operations["UserController_findById"];
put: operations["UserController_updateUser"]; put: operations["UserController_updateUser"];
}; };
"/user/isSetupDone": {
get: operations["UserController_isSetupDone"];
};
"/auth": { "/auth": {
post: operations["AuthController_signIn"]; post: operations["AuthController_signIn"];
}; };
@@ -82,6 +79,7 @@ export interface components {
}; };
SignInResponse: { SignInResponse: {
accessToken: string; accessToken: string;
user: components["schemas"]["UserDto"];
}; };
}; };
responses: never; responses: never;
@@ -178,16 +176,6 @@ export interface operations {
}; };
}; };
}; };
UserController_isSetupDone: {
responses: {
/** @description Setup done */
200: {
content: {
"application/json": boolean;
};
};
};
};
AuthController_signIn: { AuthController_signIn: {
requestBody: { requestBody: {
content: { content: {

View File

@@ -5,8 +5,8 @@ import { getTmdbSeries, tmdbApi } from '../tmdb/tmdb-api';
import type { components, paths } from './sonarr.generated'; import type { components, paths } from './sonarr.generated';
import { log } from '../../utils'; import { log } from '../../utils';
import type { Api, ApiAsync } from '../api.interface'; import type { Api, ApiAsync } from '../api.interface';
import { appState } from '../../stores/app-state.store';
import { createLocalStorageStore } from '../../stores/localstorage.store'; import { createLocalStorageStore } from '../../stores/localstorage.store';
import { user } from '../../stores/user.store';
export const sonarrMonitorOptions = [ export const sonarrMonitorOptions = [
'unknown', 'unknown',
@@ -52,7 +52,7 @@ const tmdbToTvdbCache = createLocalStorageStore<Record<number, number>>('tmdb-to
export class SonarrApi implements ApiAsync<paths> { export class SonarrApi implements ApiAsync<paths> {
async getClient() { async getClient() {
await appState.ready; // await appState.ready;
const sonarrSettings = this.getSettings(); const sonarrSettings = this.getSettings();
const baseUrl = this.getBaseUrl(); const baseUrl = this.getBaseUrl();
const apiKey = sonarrSettings?.apiKey; const apiKey = sonarrSettings?.apiKey;
@@ -66,15 +66,15 @@ export class SonarrApi implements ApiAsync<paths> {
} }
getBaseUrl() { getBaseUrl() {
return get(appState)?.user?.settings?.sonarr.baseUrl || ''; return get(user)?.settings?.sonarr.baseUrl || '';
} }
getSettings() { getSettings() {
return get(appState).user?.settings.sonarr; return get(user)?.settings.sonarr;
} }
getApiKey() { getApiKey() {
return get(appState).user?.settings.sonarr.apiKey; return get(user)?.settings.sonarr.apiKey;
} }
tmdbToTvdb = async (tmdbId: number) => { tmdbToTvdb = async (tmdbId: number) => {

View File

@@ -6,7 +6,7 @@ import { TMDB_API_KEY, TMDB_BACKDROP_SMALL } from '../../constants';
import { settings } from '../../stores/settings.store'; import { settings } from '../../stores/settings.store';
import type { TitleType } from '../../types'; import type { TitleType } from '../../types';
import type { Api } from '../api.interface'; import type { Api } from '../api.interface';
import { appState } from '../../stores/app-state.store'; import { user } from '../../stores/user.store';
const CACHE_ONE_DAY = 'max-age=86400'; const CACHE_ONE_DAY = 'max-age=86400';
const CACHE_FOUR_DAYS = 'max-age=345600'; const CACHE_FOUR_DAYS = 'max-age=345600';
@@ -73,7 +73,7 @@ export class TmdbApi implements Api<paths> {
} }
static getClient4l() { static getClient4l() {
const sessionId = get(appState)?.user?.settings.tmdb.sessionId; const sessionId = get(user)?.settings.tmdb.sessionId;
return createClient<paths4>({ return createClient<paths4>({
baseUrl: 'https://api.themoviedb.org', baseUrl: 'https://api.themoviedb.org',
@@ -96,11 +96,11 @@ export class TmdbApi implements Api<paths> {
} }
getSessionId() { getSessionId() {
return get(appState)?.user?.settings.tmdb.sessionId; return get(user)?.settings.tmdb.sessionId;
} }
getUserId() { getUserId() {
return get(appState)?.user?.settings.tmdb.userId; return get(user)?.settings.tmdb.userId;
} }
// MOVIES // MOVIES

View File

@@ -6,6 +6,7 @@
import classNames from 'classnames'; import classNames from 'classnames';
export let topmost = true; export let topmost = true;
export let sidebar = true;
// Top element, that when focused and back is pressed, will exit the modal // Top element, that when focused and back is pressed, will exit the modal
const topSelectable = useRegistrar(); const topSelectable = useRegistrar();
@@ -41,7 +42,9 @@
direction="horizontal" direction="horizontal"
on:mount on:mount
> >
<Sidebar /> {#if sidebar}
<Sidebar />
{/if}
<Container on:back={handleGoToTop} focusOnMount class={classNames($$restProps.class)}> <Container on:back={handleGoToTop} focusOnMount class={classNames($$restProps.class)}>
<slot {handleGoBack} registrar={topSelectable.registrar} /> <slot {handleGoBack} registrar={topSelectable.registrar} />
</Container> </Container>

View File

@@ -43,7 +43,7 @@
class={classNames('absolute inset-0 bg-center bg-cover', { class={classNames('absolute inset-0 bg-center bg-cover', {
'opacity-100': visibleIndex === i, 'opacity-100': visibleIndex === i,
'opacity-0': visibleIndex !== i, 'opacity-0': visibleIndex !== i,
'scale-125': !hasFocus 'scale-110': !hasFocus
})} })}
style={`background-image: url('${url}'); transition: opacity 500ms, transform 500ms;`} style={`background-image: url('${url}'); transition: opacity 500ms, transform 500ms;`}
/> />
@@ -52,7 +52,7 @@
{:else} {:else}
<div <div
class={classNames('flex overflow-hidden h-full w-full transition-transform duration-500', { class={classNames('flex overflow-hidden h-full w-full transition-transform duration-500', {
'scale-125': !hasFocus 'scale-110': !hasFocus
})} })}
style="perspective: 1px; -webkit-perspective: 1px;" style="perspective: 1px; -webkit-perspective: 1px;"
> >

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import TextField from '../TextField.svelte'; import TextField from '../TextField.svelte';
import { appState } from '../../stores/app-state.store'; import { user } from '../../stores/user.store';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import SelectField from '../SelectField.svelte'; import SelectField from '../SelectField.svelte';
import { jellyfinApi, type JellyfinUser } from '../../apis/jellyfin/jellyfin-api'; import { jellyfinApi, type JellyfinUser } from '../../apis/jellyfin/jellyfin-api';
@@ -20,9 +20,9 @@
let jellyfinUsers: Promise<JellyfinUser[]> | undefined = undefined; let jellyfinUsers: Promise<JellyfinUser[]> | undefined = undefined;
export let jellyfinUser: JellyfinUser | undefined; export let jellyfinUser: JellyfinUser | undefined;
appState.subscribe((appState) => { user.subscribe((user) => {
baseUrl = baseUrl || appState.user?.settings.jellyfin.baseUrl || ''; baseUrl = baseUrl || user?.settings.jellyfin.baseUrl || '';
apiKey = apiKey || appState.user?.settings.jellyfin.apiKey || ''; apiKey = apiKey || user?.settings.jellyfin.apiKey || '';
originalBaseUrl = baseUrl; originalBaseUrl = baseUrl;
originalApiKey = apiKey; originalApiKey = apiKey;
@@ -35,9 +35,7 @@
baseUrl, baseUrl,
apiKey, apiKey,
stale: stale:
baseUrl && apiKey baseUrl && apiKey ? jellyfinUser?.Id !== get(user)?.settings.jellyfin.userId : !jellyfinUser
? jellyfinUser?.Id !== get(appState).user?.settings.jellyfin.userId
: !jellyfinUser
}); });
function handleChange() { function handleChange() {

View File

@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import TextField from '../TextField.svelte'; import TextField from '../TextField.svelte';
import { appState } from '../../stores/app-state.store';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { radarrApi } from '../../apis/radarr/radarr-api'; import { radarrApi } from '../../apis/radarr/radarr-api';
import { user } from '../../stores/user.store';
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
change: { baseUrl: string; apiKey: string; stale: boolean }; change: { baseUrl: string; apiKey: string; stale: boolean };
@@ -16,9 +16,9 @@
let error = ''; let error = '';
let healthCheck: Promise<boolean> | undefined; let healthCheck: Promise<boolean> | undefined;
appState.subscribe((appState) => { user.subscribe((user) => {
baseUrl = baseUrl || appState.user?.settings.radarr.baseUrl || ''; baseUrl = baseUrl || user?.settings.radarr.baseUrl || '';
apiKey = apiKey || appState.user?.settings.radarr.apiKey || ''; apiKey = apiKey || user?.settings.radarr.apiKey || '';
originalBaseUrl = baseUrl; originalBaseUrl = baseUrl;
originalApiKey = apiKey; originalApiKey = apiKey;

View File

@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import TextField from '../TextField.svelte'; import TextField from '../TextField.svelte';
import { appState } from '../../stores/app-state.store';
import { sonarrApi } from '../../apis/sonarr/sonarr-api'; import { sonarrApi } from '../../apis/sonarr/sonarr-api';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { user } from '../../stores/user.store';
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
change: { baseUrl: string; apiKey: string; stale: boolean }; change: { baseUrl: string; apiKey: string; stale: boolean };
@@ -16,9 +16,9 @@
let error = ''; let error = '';
let healthCheck: Promise<boolean> | undefined; let healthCheck: Promise<boolean> | undefined;
appState.subscribe((appState) => { user.subscribe((user) => {
baseUrl = baseUrl || appState.user?.settings.sonarr.baseUrl || ''; baseUrl = baseUrl || user?.settings.sonarr.baseUrl || '';
apiKey = apiKey || appState.user?.settings.sonarr.apiKey || ''; apiKey = apiKey || user?.settings.sonarr.apiKey || '';
originalBaseUrl = baseUrl; originalBaseUrl = baseUrl;
originalApiKey = apiKey; originalApiKey = apiKey;

View File

@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import TextField from '../TextField.svelte'; import TextField from '../TextField.svelte';
import { appState } from '../../stores/app-state.store';
import { sonarrApi } from '../../apis/sonarr/sonarr-api'; import { sonarrApi } from '../../apis/sonarr/sonarr-api';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { user } from '../../stores/user.store';
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
change: { baseUrl: string; apiKey: string; stale: boolean }; change: { baseUrl: string; apiKey: string; stale: boolean };
@@ -16,9 +16,9 @@
let error = ''; let error = '';
let healthCheck: Promise<boolean> | undefined; let healthCheck: Promise<boolean> | undefined;
appState.subscribe((appState) => { user.subscribe((user) => {
baseUrl = baseUrl || appState.user?.settings.sonarr.baseUrl || ''; baseUrl = baseUrl || user?.settings.sonarr.baseUrl || '';
apiKey = apiKey || appState.user?.settings.sonarr.apiKey || ''; apiKey = apiKey || user?.settings.sonarr.apiKey || '';
originalBaseUrl = baseUrl; originalBaseUrl = baseUrl;
originalApiKey = apiKey; originalApiKey = apiKey;

View File

@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import Container from '../../../Container.svelte'; import Container from '../../../Container.svelte';
import { appState } from '../../stores/app-state.store';
import { tmdbApi } from '../../apis/tmdb/tmdb-api'; import { tmdbApi } from '../../apis/tmdb/tmdb-api';
import Button from '../Button.svelte'; import Button from '../Button.svelte';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { ExternalLink } from 'radix-icons-svelte'; import { ExternalLink } from 'radix-icons-svelte';
import { user } from '../../stores/user.store';
const dispatch = createEventDispatcher<{ connected: null }>(); const dispatch = createEventDispatcher<{ connected: null }>();
@@ -33,7 +33,7 @@
return; // TODO add notification return; // TODO add notification
} }
appState.updateUser((prev) => ({ user.updateUser((prev) => ({
...prev, ...prev,
settings: { settings: {
...prev.settings, ...prev.settings,

View File

@@ -5,14 +5,28 @@
DotFilled, DotFilled,
Gear, Gear,
Laptop, Laptop,
MagnifyingGlass MagnifyingGlass,
Person
} from 'radix-icons-svelte'; } from 'radix-icons-svelte';
import classNames from 'classnames'; import classNames from 'classnames';
import { get, type Readable, writable, type Writable } from 'svelte/store'; import { get, type Readable, writable, type Writable } from 'svelte/store';
import Container from '../../../Container.svelte'; import Container from '../../../Container.svelte';
import { registrars, Selectable } from '../../selectable'; import { registrars, Selectable } from '../../selectable';
import { defaultStackRouter, navigate } from '../StackRouter/StackRouter'; import { stackRouter, navigate } from '../StackRouter/StackRouter';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { useTabs } from '../Tab/Tab';
import { user } from '../../stores/user.store';
enum Tabs {
Users,
Series,
Movies,
Library,
Search,
Manage
}
const tab = useTabs(Tabs.Series);
let selectedIndex = 0; let selectedIndex = 0;
let activeIndex = -1; let activeIndex = -1;
@@ -40,29 +54,31 @@
selectable.focusChild(index); selectable.focusChild(index);
const path = const path =
{ {
0: '/', [Tabs.Users]: '/users',
1: '/movies', [Tabs.Series]: '/',
2: '/library', [Tabs.Movies]: '/movies',
3: '/search', [Tabs.Library]: '/library',
4: '/manage' [Tabs.Search]: '/search',
[Tabs.Manage]: '/manage'
}[index] || '/'; }[index] || '/';
navigate(path); navigate(path);
selectedIndex = index; selectedIndex = index;
}; };
onMount(() => { onMount(() => {
defaultStackRouter.subscribe((r) => { // Set active tab based on bottommost page
stackRouter.subscribe((r) => {
const bottomPage = r[0]; const bottomPage = r[0];
console.log('bottomPage', bottomPage);
if (bottomPage) { if (bottomPage) {
activeIndex = activeIndex =
{ {
'/': 0, '/users': Tabs.Users,
'/series': 0, '/': Tabs.Series,
'/movies': 1, '/series': Tabs.Series,
'/library': 2, '/movies': Tabs.Movies,
'/search': 3, '/library': Tabs.Library,
'/manage': 4 '/search': Tabs.Search,
'/manage': Tabs.Manage
}[bottomPage.route.path] ?? -1; }[bottomPage.route.path] ?? -1;
selectable.focusIndex.set(activeIndex); selectable.focusIndex.set(activeIndex);
selectedIndex = activeIndex; selectedIndex = activeIndex;
@@ -103,18 +119,61 @@
})} })}
/> />
<Container
class="w-full h-12 cursor-pointer"
on:clickOrSelect={selectIndex(Tabs.Users)}
let:hasFocus
>
<div
class={classNames(
'w-full h-full relative flex items-center justify-center transition-opacity',
{
'text-primary-500': hasFocus || (!$isNavBarOpen && selectedIndex === Tabs.Users),
'text-stone-300 hover:text-primary-500':
!hasFocus && !(!$isNavBarOpen && selectedIndex === Tabs.Users),
'opacity-0 pointer-events-none': $isNavBarOpen === false,
'group-hover:opacity-100 group-hover:pointer-events-auto': true
}
)}
>
<div class="absolute inset-y-0 left-2 flex items-center justify-center">
<DotFilled
class={classNames('text-primary-500', { 'opacity-0': activeIndex !== Tabs.Users })}
size={19}
/>
</div>
<Person class="w-8 h-8" />
<span
class={classNames(
'text-xl font-medium transition-opacity flex items-center absolute inset-y-0 left-20',
{
'opacity-0 pointer-events-none': $isNavBarOpen === false,
'group-hover:opacity-100 group-hover:pointer-events-auto': true
}
)}
>
{$user?.name}
</span>
</div>
</Container>
<div class={'flex-1 flex flex-col justify-center self-stretch'}> <div class={'flex-1 flex flex-col justify-center self-stretch'}>
<Container class="w-full h-12 cursor-pointer" on:clickOrSelect={selectIndex(0)} let:hasFocus> <Container
class="w-full h-12 cursor-pointer"
on:clickOrSelect={selectIndex(Tabs.Series)}
let:hasFocus
focusedChild
>
<div <div
class={classNames('w-full h-full relative flex items-center justify-center', { class={classNames('w-full h-full relative flex items-center justify-center', {
'text-primary-500': hasFocus || (!$isNavBarOpen && selectedIndex === 0), 'text-primary-500': hasFocus || (!$isNavBarOpen && selectedIndex === Tabs.Series),
'text-stone-300 hover:text-primary-500': 'text-stone-300 hover:text-primary-500':
!hasFocus && !(!$isNavBarOpen && selectedIndex === 0) !hasFocus && !(!$isNavBarOpen && selectedIndex === Tabs.Series)
})} })}
> >
<div class="absolute inset-y-0 left-2 flex items-center justify-center"> <div class="absolute inset-y-0 left-2 flex items-center justify-center">
<DotFilled <DotFilled
class={classNames('text-primary-500', { 'opacity-0': activeIndex !== 0 })} class={classNames('text-primary-500', { 'opacity-0': activeIndex !== Tabs.Series })}
size={19} size={19}
/> />
</div> </div>
@@ -132,17 +191,21 @@
</span> </span>
</div> </div>
</Container> </Container>
<Container class="w-full h-12 cursor-pointer" on:clickOrSelect={selectIndex(1)} let:hasFocus> <Container
class="w-full h-12 cursor-pointer"
on:clickOrSelect={selectIndex(Tabs.Movies)}
let:hasFocus
>
<div <div
class={classNames('w-full h-full relative flex items-center justify-center', { class={classNames('w-full h-full relative flex items-center justify-center', {
'text-primary-500': hasFocus || (!$isNavBarOpen && selectedIndex === 1), 'text-primary-500': hasFocus || (!$isNavBarOpen && selectedIndex === Tabs.Movies),
'text-stone-300 hover:text-primary-500': 'text-stone-300 hover:text-primary-500':
!hasFocus && !(!$isNavBarOpen && selectedIndex === 1) !hasFocus && !(!$isNavBarOpen && selectedIndex === Tabs.Movies)
})} })}
> >
<div class="absolute inset-y-0 left-2 flex items-center justify-center"> <div class="absolute inset-y-0 left-2 flex items-center justify-center">
<DotFilled <DotFilled
class={classNames('text-primary-500', { 'opacity-0': activeIndex !== 1 })} class={classNames('text-primary-500', { 'opacity-0': activeIndex !== Tabs.Movies })}
size={19} size={19}
/> />
</div> </div>
@@ -160,17 +223,21 @@
</span> </span>
</div> </div>
</Container> </Container>
<Container class="w-full h-12 cursor-pointer" on:clickOrSelect={selectIndex(2)} let:hasFocus> <Container
class="w-full h-12 cursor-pointer"
on:clickOrSelect={selectIndex(Tabs.Library)}
let:hasFocus
>
<div <div
class={classNames('w-full h-full relative flex items-center justify-center', { class={classNames('w-full h-full relative flex items-center justify-center', {
'text-primary-500': hasFocus || (!$isNavBarOpen && selectedIndex === 2), 'text-primary-500': hasFocus || (!$isNavBarOpen && selectedIndex === Tabs.Library),
'text-stone-300 hover:text-primary-500': 'text-stone-300 hover:text-primary-500':
!hasFocus && !(!$isNavBarOpen && selectedIndex === 2) !hasFocus && !(!$isNavBarOpen && selectedIndex === Tabs.Library)
})} })}
> >
<div class="absolute inset-y-0 left-2 flex items-center justify-center"> <div class="absolute inset-y-0 left-2 flex items-center justify-center">
<DotFilled <DotFilled
class={classNames('text-primary-500', { 'opacity-0': activeIndex !== 2 })} class={classNames('text-primary-500', { 'opacity-0': activeIndex !== Tabs.Library })}
size={19} size={19}
/> />
</div> </div>
@@ -188,17 +255,21 @@
</span> </span>
</div> </div>
</Container> </Container>
<Container class="w-full h-12 cursor-pointer" on:clickOrSelect={selectIndex(3)} let:hasFocus> <Container
class="w-full h-12 cursor-pointer"
on:clickOrSelect={selectIndex(Tabs.Search)}
let:hasFocus
>
<div <div
class={classNames('w-full h-full relative flex items-center justify-center', { class={classNames('w-full h-full relative flex items-center justify-center', {
'text-primary-500': hasFocus || (!$isNavBarOpen && selectedIndex === 3), 'text-primary-500': hasFocus || (!$isNavBarOpen && selectedIndex === Tabs.Search),
'text-stone-300 hover:text-primary-500': 'text-stone-300 hover:text-primary-500':
!hasFocus && !(!$isNavBarOpen && selectedIndex === 3) !hasFocus && !(!$isNavBarOpen && selectedIndex === Tabs.Search)
})} })}
> >
<div class="absolute inset-y-0 left-2 flex items-center justify-center"> <div class="absolute inset-y-0 left-2 flex items-center justify-center">
<DotFilled <DotFilled
class={classNames('text-primary-500', { 'opacity-0': activeIndex !== 3 })} class={classNames('text-primary-500', { 'opacity-0': activeIndex !== Tabs.Search })}
size={19} size={19}
/> />
</div> </div>
@@ -218,14 +289,18 @@
</Container> </Container>
</div> </div>
<Container class="w-full h-12 cursor-pointer" on:clickOrSelect={selectIndex(4)} let:hasFocus> <Container
class="w-full h-12 cursor-pointer"
on:clickOrSelect={selectIndex(Tabs.Manage)}
let:hasFocus
>
<div <div
class={classNames( class={classNames(
'w-full h-full relative flex items-center justify-center transition-opacity', 'w-full h-full relative flex items-center justify-center transition-opacity',
{ {
'text-primary-500': hasFocus || (!$isNavBarOpen && selectedIndex === 4), 'text-primary-500': hasFocus || (!$isNavBarOpen && selectedIndex === Tabs.Manage),
'text-stone-300 hover:text-primary-500': 'text-stone-300 hover:text-primary-500':
!hasFocus && !(!$isNavBarOpen && selectedIndex === 4), !hasFocus && !(!$isNavBarOpen && selectedIndex === Tabs.Manage),
'opacity-0 pointer-events-none': $isNavBarOpen === false, 'opacity-0 pointer-events-none': $isNavBarOpen === false,
'group-hover:opacity-100 group-hover:pointer-events-auto': true 'group-hover:opacity-100 group-hover:pointer-events-auto': true
} }
@@ -233,7 +308,7 @@
> >
<div class="absolute inset-y-0 left-2 flex items-center justify-center"> <div class="absolute inset-y-0 left-2 flex items-center justify-center">
<DotFilled <DotFilled
class={classNames('text-primary-500', { 'opacity-0': activeIndex !== 4 })} class={classNames('text-primary-500', { 'opacity-0': activeIndex !== Tabs.Manage })}
size={19} size={19}
/> />
</div> </div>
@@ -251,105 +326,4 @@
</span> </span>
</div> </div>
</Container> </Container>
<!-- <div class={'flex flex-col flex-1 relative z-20 items-center'}>-->
<!-- <div class={'flex flex-col flex-1 justify-center self-stretch'}>-->
<!-- <Container-->
<!-- class={classNames(itemContainer(0, $focusIndex), 'w-full flex justify-center')}-->
<!-- on:clickOrSelect={selectIndex(0)}-->
<!-- >-->
<!-- <Laptop class="w-8 h-8" />-->
<!-- </Container>-->
<!-- <Container-->
<!-- class={classNames(itemContainer(1, $focusIndex), 'w-full flex justify-center')}-->
<!-- on:clickOrSelect={selectIndex(1)}-->
<!-- >-->
<!-- <CardStack class="w-8 h-8" />-->
<!-- </Container>-->
<!-- <Container-->
<!-- class={classNames(itemContainer(2, $focusIndex), 'w-full flex justify-center')}-->
<!-- on:clickOrSelect={selectIndex(2)}-->
<!-- >-->
<!-- <Bookmark class="w-8 h-8" />-->
<!-- </Container>-->
<!-- <Container-->
<!-- class={classNames(itemContainer(3, $focusIndex), 'w-full flex justify-center')}-->
<!-- on:clickOrSelect={selectIndex(3)}-->
<!-- >-->
<!-- <MagnifyingGlass class="w-8 h-8" />-->
<!-- </Container>-->
<!-- </div>-->
<!-- <Container-->
<!-- class={classNames(itemContainer(4, $focusIndex), 'w-full flex justify-center')}-->
<!-- on:clickOrSelect={selectIndex(4)}-->
<!-- >-->
<!-- <Gear class="w-8 h-8" />-->
<!-- </Container>-->
<!-- </div>-->
<!-- <div-->
<!-- class={classNames(-->
<!-- 'absolute inset-y-0 left-0 pl-[64px] pr-96 z-10 transition-all bg-gradient-to-r from-secondary-500 to-transparent',-->
<!-- 'flex flex-col flex-1 p-4',-->
<!-- {-->
<!-- // 'translate-x-full opacity-100': $isNavBarOpen,-->
<!-- 'opacity-0 pointer-events-none': !$isNavBarOpen,-->
<!-- 'group-hover:translate-x-0 group-hover:opacity-100 group-hover:pointer-events-auto': true-->
<!-- }-->
<!-- )}-->
<!-- >-->
<!-- <div class="flex flex-col flex-1 justify-center">-->
<!-- &lt;!&ndash; svelte-ignore a11y-click-events-have-key-events &ndash;&gt;-->
<!-- <div class={itemContainer(0, $focusIndex)} on:click={selectIndex(0)}>-->
<!-- <span-->
<!-- class={classNames('text-xl transition-opacity font-medium', {-->
<!-- // 'opacity-0': $isNavBarOpen === false-->
<!-- })}-->
<!-- >-->
<!-- Series</span-->
<!-- >-->
<!-- </div>-->
<!-- &lt;!&ndash; svelte-ignore a11y-click-events-have-key-events &ndash;&gt;-->
<!-- <div class={itemContainer(1, $focusIndex)} on:click={selectIndex(1)}>-->
<!-- <span-->
<!-- class={classNames('text-xl transition-opacity font-medium', {-->
<!-- // 'opacity-0': $isNavBarOpen === false-->
<!-- })}-->
<!-- >-->
<!-- Movies</span-->
<!-- >-->
<!-- </div>-->
<!-- &lt;!&ndash; svelte-ignore a11y-click-events-have-key-events &ndash;&gt;-->
<!-- <div class={itemContainer(2, $focusIndex)} on:click={selectIndex(2)}>-->
<!-- <span-->
<!-- class={classNames('text-xl transition-opacity font-medium', {-->
<!-- // 'opacity-0': $isNavBarOpen === false-->
<!-- })}-->
<!-- >-->
<!-- Library</span-->
<!-- >-->
<!-- </div>-->
<!-- &lt;!&ndash; svelte-ignore a11y-click-events-have-key-events &ndash;&gt;-->
<!-- <div class={itemContainer(3, $focusIndex)} on:click={selectIndex(3)}>-->
<!-- <span-->
<!-- class={classNames('text-xl transition-opacity font-medium', {-->
<!-- // 'opacity-0': $isNavBarOpen === false-->
<!-- })}-->
<!-- >-->
<!-- Search</span-->
<!-- >-->
<!-- </div>-->
<!-- </div>-->
<!-- &lt;!&ndash; svelte-ignore a11y-click-events-have-key-events &ndash;&gt;-->
<!-- <div class={itemContainer(4, $focusIndex)} on:click={selectIndex(4)}>-->
<!-- <span-->
<!-- class={classNames('text-xl transition-opacity font-medium', {-->
<!-- // 'opacity-0': $isNavBarOpen === false-->
<!-- })}-->
<!-- >-->
<!-- Manage</span-->
<!-- >-->
<!-- </div>-->
<!-- </div>-->
</Container> </Container>

View File

@@ -11,6 +11,7 @@ import SearchPage from '../../pages/SearchPage.svelte';
import PageNotFound from '../../pages/PageNotFound.svelte'; import PageNotFound from '../../pages/PageNotFound.svelte';
import ManagePage from '../../pages/ManagePage.svelte'; import ManagePage from '../../pages/ManagePage.svelte';
import PersonPage from '../../pages/PersonPage.svelte'; import PersonPage from '../../pages/PersonPage.svelte';
import UsersPage from '../../pages/UsersPage.svelte';
interface Page { interface Page {
id: symbol; id: symbol;
@@ -179,6 +180,12 @@ export function useStackRouter({
}; };
} }
const usersRoute: Route = {
path: '/users',
root: true,
component: UsersPage
};
const seriesHomeRoute: Route = { const seriesHomeRoute: Route = {
path: '/series', path: '/series',
default: true, default: true,
@@ -239,8 +246,9 @@ const notFoundRoute: Route = {
root: true root: true
}; };
export const defaultStackRouter = useStackRouter({ export const stackRouter = useStackRouter({
routes: [ routes: [
usersRoute,
seriesHomeRoute, seriesHomeRoute,
seriesRoute, seriesRoute,
episodeRoute, episodeRoute,
@@ -278,6 +286,6 @@ export const defaultStackRouter = useStackRouter({
// // } // // }
// } as const); // } as const);
export const navigate = defaultStackRouter.navigate; export const navigate = stackRouter.navigate;
export const back = defaultStackRouter.back; export const back = stackRouter.back;
defaultStackRouter.subscribe(console.log); stackRouter.subscribe(console.log);

View File

@@ -6,13 +6,13 @@
import { jellyfinApi } from '../../apis/jellyfin/jellyfin-api'; import { jellyfinApi } from '../../apis/jellyfin/jellyfin-api';
import getDeviceProfile from '../../apis/jellyfin/playback-profiles'; import getDeviceProfile from '../../apis/jellyfin/playback-profiles';
import { getQualities } from '../../apis/jellyfin/qualities'; import { getQualities } from '../../apis/jellyfin/qualities';
import { appState } from '../../stores/app-state.store';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { modalStack, modalStackTop } from '../Modal/modal.store'; import { modalStack, modalStackTop } from '../Modal/modal.store';
import { createLocalStorageStore } from '../../stores/localstorage.store'; import { createLocalStorageStore } from '../../stores/localstorage.store';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { ISO_2_LANGUAGES } from '../../utils/iso-2-languages'; import { ISO_2_LANGUAGES } from '../../utils/iso-2-languages';
import Modal from '../Modal/Modal.svelte'; import Modal from '../Modal/Modal.svelte';
import { user } from '../../stores/user.store';
type MediaLanguageStore = { type MediaLanguageStore = {
subtitles?: string; subtitles?: string;
@@ -115,7 +115,7 @@
subtitles = { subtitles = {
kind: 'subtitles', kind: 'subtitles',
srclang: stream.Language || '', srclang: stream.Language || '',
url: `${$appState.user?.settings.jellyfin.baseUrl}/Videos/${id}/${mediaSource?.Id}/Subtitles/${stream.Index}/${stream.Level}/Stream.vtt`, url: `${$user?.settings.jellyfin.baseUrl}/Videos/${id}/${mediaSource?.Id}/Subtitles/${stream.Index}/${stream.Level}/Stream.vtt`,
// @ts-ignore // @ts-ignore
language: ISO_2_LANGUAGES[stream?.Language || '']?.name || 'English' language: ISO_2_LANGUAGES[stream?.Language || '']?.name || 'English'
}; };
@@ -126,7 +126,7 @@
mediaSource?.MediaStreams?.filter((s) => s.Type === 'Subtitle').map((s) => ({ mediaSource?.MediaStreams?.filter((s) => s.Type === 'Subtitle').map((s) => ({
kind: 'subtitles' as const, kind: 'subtitles' as const,
srclang: s.Language || '', srclang: s.Language || '',
url: `${$appState.user?.settings.jellyfin.baseUrl}/Videos/${id}/${mediaSource?.Id}/Subtitles/${s.Index}/${s.Level}/Stream.vtt`, url: `${$user?.settings.jellyfin.baseUrl}/Videos/${id}/${mediaSource?.Id}/Subtitles/${s.Index}/${s.Level}/Stream.vtt`,
language: 'English' language: 'English'
})) || []; })) || [];
@@ -170,9 +170,9 @@
playbackPosition: progressTime * 10_000_000 playbackPosition: progressTime * 10_000_000
}), }),
directPlay, directPlay,
playbackUrl: $appState.user?.settings.jellyfin.baseUrl + playbackUri, playbackUrl: $user?.settings.jellyfin.baseUrl + playbackUri,
backdrop: item?.BackdropImageTags?.length backdrop: item?.BackdropImageTags?.length
? `${$appState.user?.settings.jellyfin.baseUrl}/Items/${item?.Id}/Images/Backdrop?quality=100&tag=${item?.BackdropImageTags?.[0]}` ? `${$user?.settings.jellyfin.baseUrl}/Items/${item?.Id}/Images/Backdrop?quality=100&tag=${item?.BackdropImageTags?.[0]}`
: '', : '',
startTime: startTime:
(options.playbackPosition || 0) / 10_000_000 || (options.playbackPosition || 0) / 10_000_000 ||

View File

@@ -25,7 +25,7 @@
import { jellyfinApi } from '../../apis/jellyfin/jellyfin-api.js'; import { jellyfinApi } from '../../apis/jellyfin/jellyfin-api.js';
import { videoPlayerSettings } from '../../stores/localstorage.store'; import { videoPlayerSettings } from '../../stores/localstorage.store';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { appState } from '../../stores/app-state.store'; import { appState } from '../../stores/user.store';
import { getBrowserSpecificMediaFunctions } from './VideoPlayer'; import { getBrowserSpecificMediaFunctions } from './VideoPlayer';
export let jellyfinId: string; export let jellyfinId: string;

View File

@@ -1,22 +0,0 @@
<script lang="ts">
import { Selectable } from '../selectable';
import Carousel from '../components/Carousel/Carousel.svelte';
import CarouselPlaceholderItems from '../components/Carousel/CarouselPlaceholderItems.svelte';
import classNames from 'classnames';
export let container: Selectable;
const focusWithin = container.hasFocusWithin;
</script>
<div
class={classNames('flex flex-col', {
'bg-green-100': $focusWithin
})}
>
<Carousel>
<CarouselPlaceholderItems {container} />
</Carousel>
<Carousel>
<CarouselPlaceholderItems {container} />
</Carousel>
</div>

View File

@@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
import { getReiverrApiClient, reiverrApi } from '../apis/reiverr/reiverr-api';
import Container from '../../Container.svelte'; import Container from '../../Container.svelte';
import { appState } from '../stores/app-state.store';
import TextField from '../components/TextField.svelte'; import TextField from '../components/TextField.svelte';
import Button from '../components/Button.svelte'; import Button from '../components/Button.svelte';
import { createLocalStorageStore } from '../stores/localstorage.store';
let name: string = ''; import { sessions } from '../stores/session.store';
const baseUrl = createLocalStorageStore('baseUrl', window.location.origin || '');
const name = createLocalStorageStore('username', '');
let password: string = ''; let password: string = '';
let error: string | undefined = undefined; let error: string | undefined = undefined;
@@ -14,17 +16,13 @@
function handleLogin() { function handleLogin() {
loading = true; loading = true;
reiverrApi sessions
.authenticate(name, password) .addSession($baseUrl, $name, password)
.then((res) => { .then((res) => {
if (res.error?.statusCode === 401) { if (res.error?.statusCode === 401) {
error = 'Invalid credentials. Please try again.'; error = 'Invalid credentials. Please try again.';
} else if (res.error) { } else if (res.error) {
error = 'Error occurred: ' + res.error.message; error = 'Error occurred: ' + res.error.message;
} else {
const token = res.data.accessToken;
appState.setToken(token);
// window.location.reload();
} }
}) })
.catch((err: Error) => { .catch((err: Error) => {
@@ -44,15 +42,13 @@
credentials. credentials.
</div> </div>
<TextField <TextField value={$baseUrl} on:change={(e) => baseUrl.set(e.detail)} class="mb-4 w-full">
value={$appState.serverBaseUrl}
on:change={(e) => appState.setBaseUrl(e.detail)}
class="mb-4 w-full"
>
Server Server
</TextField> </TextField>
<TextField bind:value={name} class="mb-4 w-full">Name</TextField> <TextField value={$name} on:change={({ detail }) => name.set(detail)} class="mb-4 w-full">
Name
</TextField>
<TextField bind:value={password} type="password" class="mb-8 w-full">Password</TextField> <TextField bind:value={password} type="password" class="mb-8 w-full">Password</TextField>
<Button <Button

View File

@@ -1,25 +1,24 @@
<script lang="ts"> <script lang="ts">
import Container from '../../Container.svelte'; import Container from '../../Container.svelte';
import { appState } from '../stores/app-state.store';
import Button from '../components/Button.svelte'; import Button from '../components/Button.svelte';
import Toggle from '../components/Toggle.svelte'; import Toggle from '../components/Toggle.svelte';
import { localSettings } from '../stores/localstorage.store'; import { localSettings } from '../stores/localstorage.store';
import classNames from 'classnames'; import classNames from 'classnames';
import Tab from '../components/Tab/Tab.svelte'; import Tab from '../components/Tab/Tab.svelte';
import { useTabs } from '../components/Tab/Tab'; import { useTabs } from '../components/Tab/Tab';
import TextField from '../components/TextField.svelte';
import SonarrIntegration from '../components/Integrations/SonarrIntegration.svelte'; import SonarrIntegration from '../components/Integrations/SonarrIntegration.svelte';
import RadarrIntegration from '../components/Integrations/RadarrIntegration.svelte'; import RadarrIntegration from '../components/Integrations/RadarrIntegration.svelte';
import type { JellyfinUser } from '../apis/jellyfin/jellyfin-api'; import type { JellyfinUser } from '../apis/jellyfin/jellyfin-api';
import JellyfinIntegration from '../components/Integrations/JellyfinIntegration.svelte'; import JellyfinIntegration from '../components/Integrations/JellyfinIntegration.svelte';
import JellyfinIntegrationUsersDialog from '../components/Integrations/JellyfinIntegrationUsersDialog.svelte'; import JellyfinIntegrationUsersDialog from '../components/Integrations/JellyfinIntegrationUsersDialog.svelte';
import TmdbIntegration from '../components/Integrations/TmdbIntegration.svelte';
import { tmdbApi } from '../apis/tmdb/tmdb-api'; import { tmdbApi } from '../apis/tmdb/tmdb-api';
import SelectField from '../components/SelectField.svelte'; import SelectField from '../components/SelectField.svelte';
import { ArrowRight, Trash } from 'radix-icons-svelte'; import { ArrowRight, Trash } from 'radix-icons-svelte';
import TmdbIntegrationConnectDialog from '../components/Integrations/TmdbIntegrationConnectDialog.svelte'; import TmdbIntegrationConnectDialog from '../components/Integrations/TmdbIntegrationConnectDialog.svelte';
import { createModal } from '../components/Modal/modal.store'; import { createModal } from '../components/Modal/modal.store';
import DetachedPage from '../components/DetachedPage/DetachedPage.svelte'; import DetachedPage from '../components/DetachedPage/DetachedPage.svelte';
import { user } from '../stores/user.store';
import { sessions } from '../stores/session.store';
enum Tabs { enum Tabs {
Interface, Interface,
@@ -45,7 +44,7 @@
let lastKeyCode = 0; let lastKeyCode = 0;
let lastKey = ''; let lastKey = '';
let tizenMediaKey = ''; let tizenMediaKey = '';
$: tmdbAccount = $appState.user?.settings.tmdb.userId ? tmdbApi.getAccountDetails() : undefined; $: tmdbAccount = $user?.settings.tmdb.userId ? tmdbApi.getAccountDetails() : undefined;
// onMount(() => { // onMount(() => {
// if (isTizen()) { // if (isTizen()) {
@@ -63,7 +62,7 @@
// }); // });
async function handleDisconnectTmdb() { async function handleDisconnectTmdb() {
return appState.updateUser((prev) => ({ return user.updateUser((prev) => ({
...prev, ...prev,
settings: { settings: {
...prev.settings, ...prev.settings,
@@ -77,7 +76,7 @@
} }
async function handleSaveJellyfin() { async function handleSaveJellyfin() {
return appState.updateUser((prev) => ({ return user.updateUser((prev) => ({
...prev, ...prev,
settings: { settings: {
...prev.settings, ...prev.settings,
@@ -92,7 +91,7 @@
} }
async function handleSaveSonarr() { async function handleSaveSonarr() {
return appState.updateUser((prev) => ({ return user.updateUser((prev) => ({
...prev, ...prev,
settings: { settings: {
...prev.settings, ...prev.settings,
@@ -106,7 +105,7 @@
} }
async function handleSaveRadarr() { async function handleSaveRadarr() {
return appState.updateUser((prev) => ({ return user.updateUser((prev) => ({
...prev, ...prev,
settings: { settings: {
...prev.settings, ...prev.settings,
@@ -318,7 +317,9 @@
<div>Tizen media key: {tizenMediaKey}</div> <div>Tizen media key: {tizenMediaKey}</div>
{/if} {/if}
<div class="flex space-x-4 mt-4"> <div class="flex space-x-4 mt-4">
<Button on:clickOrSelect={appState.logOut} class="hover:bg-red-500">Log Out</Button> <Button on:clickOrSelect={() => sessions.removeSession()} class="hover:bg-red-500">
Log Out
</Button>
</div> </div>
</Tab> </Tab>
</Container> </Container>

View File

@@ -2,7 +2,6 @@
import Container from '../../Container.svelte'; import Container from '../../Container.svelte';
import Tab from '../components/Tab/Tab.svelte'; import Tab from '../components/Tab/Tab.svelte';
import Button from '../components/Button.svelte'; import Button from '../components/Button.svelte';
import { appState } from '../stores/app-state.store';
import { tmdbApi } from '../apis/tmdb/tmdb-api'; import { tmdbApi } from '../apis/tmdb/tmdb-api';
import { ArrowLeft, ArrowRight, CheckCircled, ExternalLink } from 'radix-icons-svelte'; import { ArrowLeft, ArrowRight, CheckCircled, ExternalLink } from 'radix-icons-svelte';
import TextField from '../components/TextField.svelte'; import TextField from '../components/TextField.svelte';
@@ -14,6 +13,8 @@
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { useTabs } from '../components/Tab/Tab'; import { useTabs } from '../components/Tab/Tab';
import classNames from 'classnames'; import classNames from 'classnames';
import { user } from '../stores/user.store';
import { sessions } from '../stores/session.store';
enum Tabs { enum Tabs {
Welcome, Welcome,
@@ -32,7 +33,7 @@
let tmdbConnectRequestToken: string | undefined = undefined; let tmdbConnectRequestToken: string | undefined = undefined;
let tmdbConnectLink: string | undefined = undefined; let tmdbConnectLink: string | undefined = undefined;
let tmdbConnectQrCode: string | undefined = undefined; let tmdbConnectQrCode: string | undefined = undefined;
$: connectedTmdbAccount = $appState.user?.settings.tmdb.userId && tmdbApi.getAccountDetails(); $: connectedTmdbAccount = $user?.settings.tmdb.userId && tmdbApi.getAccountDetails();
let tmdbError: string = ''; let tmdbError: string = '';
let jellyfinBaseUrl: string = ''; let jellyfinBaseUrl: string = '';
@@ -50,15 +51,15 @@
let radarrApiKey: string = ''; let radarrApiKey: string = '';
let radarrError: string = ''; let radarrError: string = '';
appState.subscribe((appState) => { user.subscribe((user) => {
jellyfinBaseUrl = jellyfinBaseUrl || appState.user?.settings.jellyfin.baseUrl || ''; jellyfinBaseUrl = jellyfinBaseUrl || user?.settings.jellyfin.baseUrl || '';
jellyfinApiKey = jellyfinApiKey || appState.user?.settings.jellyfin.apiKey || ''; jellyfinApiKey = jellyfinApiKey || user?.settings.jellyfin.apiKey || '';
sonarrBaseUrl = sonarrBaseUrl || appState.user?.settings.sonarr.baseUrl || ''; sonarrBaseUrl = sonarrBaseUrl || user?.settings.sonarr.baseUrl || '';
sonarrApiKey = sonarrApiKey || appState.user?.settings.sonarr.apiKey || ''; sonarrApiKey = sonarrApiKey || user?.settings.sonarr.apiKey || '';
radarrBaseUrl = radarrBaseUrl || appState.user?.settings.radarr.baseUrl || ''; radarrBaseUrl = radarrBaseUrl || user?.settings.radarr.baseUrl || '';
radarrApiKey = radarrApiKey || appState.user?.settings.radarr.apiKey || ''; radarrApiKey = radarrApiKey || user?.settings.radarr.apiKey || '';
// if ( // if (
// !jellyfinUser && // !jellyfinUser &&
@@ -86,7 +87,7 @@
.getJellyfinUsers(jellyfinBaseUrl, jellyfinApiKey) .getJellyfinUsers(jellyfinBaseUrl, jellyfinApiKey)
.then((users) => { .then((users) => {
if (baseUrlCopy === jellyfinBaseUrl && apiKeyCopy === jellyfinApiKey) { if (baseUrlCopy === jellyfinBaseUrl && apiKeyCopy === jellyfinApiKey) {
jellyfinUser = users.find((u) => u.Id === get(appState).user?.settings.jellyfin.userId); jellyfinUser = users.find((u) => u.Id === $user?.settings.jellyfin.userId);
jellyfinError = users.length ? '' : 'Could not connect'; jellyfinError = users.length ? '' : 'Could not connect';
} }
// console.log(users, baseUrlCopy, jellyfinBaseUrl, apiKeyCopy, jellyfinApiKey); // console.log(users, baseUrlCopy, jellyfinBaseUrl, apiKeyCopy, jellyfinApiKey);
@@ -114,7 +115,7 @@
const { status_code, access_token, account_id } = res || {}; const { status_code, access_token, account_id } = res || {};
if (status_code !== 1 || !access_token || !account_id) return; // TODO add notification if (status_code !== 1 || !access_token || !account_id) return; // TODO add notification
appState.updateUser((prev) => ({ user.updateUser((prev) => ({
...prev, ...prev,
settings: { settings: {
...prev.settings, ...prev.settings,
@@ -135,7 +136,7 @@
const apiKey = jellyfinApiKey; const apiKey = jellyfinApiKey;
if (!userId || !baseUrl || !apiKey) return; if (!userId || !baseUrl || !apiKey) return;
await appState.updateUser((prev) => ({ await user.updateUser((prev) => ({
...prev, ...prev,
settings: { settings: {
...prev.settings, ...prev.settings,
@@ -168,7 +169,7 @@
return; // TODO add notification return; // TODO add notification
} }
await appState.updateUser((prev) => ({ await user.updateUser((prev) => ({
...prev, ...prev,
settings: { settings: {
...prev.settings, ...prev.settings,
@@ -199,7 +200,7 @@
return; // TODO add notification return; // TODO add notification
} }
await appState.updateUser((prev) => ({ await user.updateUser((prev) => ({
...prev, ...prev,
settings: { settings: {
...prev.settings, ...prev.settings,
@@ -215,7 +216,7 @@
} }
async function finalizeSetup() { async function finalizeSetup() {
await appState.updateUser((prev) => ({ await user.updateUser((prev) => ({
...prev, ...prev,
onboardingDone: true onboardingDone: true
})); }));
@@ -237,7 +238,7 @@
services to get most out of Reiverr. services to get most out of Reiverr.
</div> </div>
<Container direction="horizontal" class="flex space-x-4"> <Container direction="horizontal" class="flex space-x-4">
<Button type="primary-dark" on:clickOrSelect={() => appState.logOut()}>Log Out</Button> <Button type="primary-dark" on:clickOrSelect={() => sessions.removeSession()}>Log Out</Button>
<div class="flex-1"> <div class="flex-1">
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}> <Button type="primary-dark" on:clickOrSelect={() => tab.next()}>
Next Next
@@ -280,7 +281,7 @@
{/await} {/await}
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}> <Button type="primary-dark" on:clickOrSelect={() => tab.next()}>
{#if $appState.user?.settings.tmdb.userId} {#if $user?.settings.tmdb.userId}
Next Next
{:else} {:else}
Skip Skip

View File

@@ -0,0 +1,5 @@
<script>
import DetachedPage from '../components/DetachedPage/DetachedPage.svelte';
</script>
<DetachedPage sidebar={false} class="px-32 py-16">Users Page</DetachedPage>

View File

@@ -1,107 +0,0 @@
import { derived, get, writable } from 'svelte/store';
import { createLocalStorageStore } from './localstorage.store';
import {
getReiverrApiClient,
reiverrApi,
type ReiverrSettings,
type ReiverrUser
} from '../apis/reiverr/reiverr-api';
interface AuthenticationStoreData {
token?: string;
serverBaseUrl?: string;
}
interface UserStoreData {
user: ReiverrUser | null;
}
export interface AppStateData extends AuthenticationStoreData {
user: ReiverrUser | null;
}
const authenticationStore = createLocalStorageStore<AuthenticationStoreData>(
'authentication-token',
{
token: undefined,
serverBaseUrl: window?.location?.origin
}
);
function createAppState() {
const userStore = writable<UserStoreData>(undefined);
const combinedStore = derived<[typeof userStore, typeof authenticationStore], AppStateData>(
[userStore, authenticationStore],
([user, auth]) => {
return {
...user,
...auth
};
}
);
function setBaseUrl(serverBaseUrl: string | undefined = undefined) {
authenticationStore.update((p) => ({ ...p, serverBaseUrl }));
}
function setToken(token: string | undefined = undefined) {
authenticationStore.update((p) => ({ ...p, token }));
}
function setUser(user: ReiverrUser | null) {
userStore.set({ user });
}
function logOut() {
setUser(null);
setToken(undefined);
}
const ready = new Promise<AppStateData>((resolve) => {
combinedStore.subscribe((state) => {
if (state.token && state.serverBaseUrl && state.user !== undefined) {
resolve(state);
}
});
});
async function updateUser(updateFn: (user: ReiverrUser) => ReiverrUser) {
const user = get(userStore).user;
if (!user) return;
const updated = updateFn(user);
const update = await reiverrApi.updateUser(updated);
if (update) {
setUser(update);
}
}
return {
subscribe: combinedStore.subscribe,
setBaseUrl,
setToken,
setUser,
updateUser,
logOut,
ready
};
}
export const appState = createAppState();
export const appStateUser = derived(appState, ($state) => $state.user);
authenticationStore.subscribe((auth) => {
if (auth.token) {
reiverrApi
.getClient(auth.serverBaseUrl, auth.token)
?.GET('/user', {})
.then((res) => res.data)
.then((user) => appState.setUser(user || null))
.catch((err) => appState.setUser(null));
} else {
appState.setUser(null);
}
});

View File

@@ -0,0 +1,70 @@
import { createLocalStorageStore } from './localstorage.store';
import type { operations } from '../apis/reiverr/reiverr.generated';
import axios from 'axios';
export interface Session {
baseUrl: string;
token: string;
}
function useSessions() {
const sessions = createLocalStorageStore<{ sessions: Session[]; activeSession?: Session }>(
'sessions',
{
sessions: []
}
);
function setActiveSession(session: Session) {
sessions.update((s) => ({ ...s, activeSession: session }));
}
async function addSession(baseUrl: string, name: string, password: string, activate = true) {
const res = await axios
.post<operations['AuthController_signIn']['responses']['200']['content']['application/json']>(
baseUrl + '/api/auth',
{
name,
password
}
)
.catch((e) => e.response);
if (res.status !== 200) return res;
const session = {
baseUrl,
token: res.data.accessToken
};
sessions.update((s) => {
const sessions = s.sessions.concat(session);
return {
sessions,
activeSession: activate ? session : s.activeSession
};
});
return res;
}
function removeSession(_session?: Session) {
sessions.update((s) => {
const session = _session || s.activeSession;
const sessions = s.sessions.filter((s) => s !== session);
return {
sessions,
activeSession: s.activeSession === session ? undefined : s.activeSession
};
});
}
return {
subscribe: sessions.subscribe,
setActiveSession,
addSession,
removeSession
};
}
export const sessions = useSessions();

View File

@@ -0,0 +1,53 @@
import { derived, get, writable } from 'svelte/store';
import { reiverrApi, type ReiverrUser } from '../apis/reiverr/reiverr-api';
import axios from 'axios';
import type { operations } from '../apis/reiverr/reiverr.generated';
import { type Session, sessions } from './session.store';
function useUser() {
const activeSession = derived(sessions, (sessions) => sessions.activeSession);
const userStore = writable<ReiverrUser | undefined | null>(undefined);
let lastActiveSession: Session | undefined;
activeSession.subscribe(async (activeSession) => {
if (!activeSession) {
userStore.set(null);
return;
}
userStore.set(undefined);
lastActiveSession = activeSession;
const user = await axios
.get<
operations['UserController_getProfile']['responses']['200']['content']['application/json']
>(activeSession.baseUrl + '/api/user', {
headers: {
Authorization: 'Bearer ' + activeSession.token
}
})
.then((r) => r.data);
if (lastActiveSession === activeSession) userStore.set(user);
});
async function updateUser(updateFn: (user: ReiverrUser) => ReiverrUser) {
const user = get(userStore);
if (!user) return;
const updated = updateFn(user);
const update = await reiverrApi.updateUser(updated);
if (update) {
userStore.set(update);
}
}
return {
subscribe: userStore.subscribe,
updateUser
};
}
export const user = useUser();