fix: And refactor onboarding integration components

This commit is contained in:
Aleksi Lassila
2024-06-18 00:58:03 +03:00
parent 1c2fbf74eb
commit 355c93e9e8
10 changed files with 372 additions and 599 deletions

View File

@@ -506,9 +506,9 @@ export class JellyfinApi implements Api<paths> {
apiKey: string | undefined = undefined apiKey: string | undefined = undefined
): Promise<JellyfinUser[]> => ): Promise<JellyfinUser[]> =>
axios axios
.get((baseUrl || this.getBaseUrl()) + '/Users', { .get((baseUrl ?? this.getBaseUrl()) + '/Users', {
headers: { headers: {
'X-Emby-Token': apiKey || this.getApiKey() 'X-Emby-Token': apiKey ?? this.getApiKey()
} }
}) })
.then((res) => res.data || []) .then((res) => res.data || [])

View File

@@ -249,9 +249,9 @@ export class RadarrApi implements Api<paths> {
apiKey: string | undefined = undefined apiKey: string | undefined = undefined
) => ) =>
axios axios
.get((baseUrl || this.getBaseUrl()) + '/api/v3/health', { .get((baseUrl ?? this.getBaseUrl()) + '/api/v3/health', {
headers: { headers: {
'X-Api-Key': apiKey || this.getSettings()?.apiKey 'X-Api-Key': apiKey ?? this.getSettings()?.apiKey
} }
}) })
.catch((e) => e.response); .catch((e) => e.response);

View File

@@ -397,9 +397,9 @@ export class SonarrApi implements ApiAsync<paths> {
apiKey: string | undefined = undefined apiKey: string | undefined = undefined
) => ) =>
axios axios
.get((baseUrl || this.getBaseUrl()) + '/api/v3/health', { .get((baseUrl ?? this.getBaseUrl()) + '/api/v3/health', {
headers: { headers: {
'X-Api-Key': apiKey || this.getApiKey() 'X-Api-Key': apiKey ?? this.getApiKey()
} }
}) })
.catch((e) => e.response); .catch((e) => e.response);

View File

@@ -7,55 +7,59 @@
import { derived, get } from 'svelte/store'; import { derived, get } from 'svelte/store';
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
change: { baseUrl: string; apiKey: string; stale: boolean }; 'click-user': {
'click-user': { user: JellyfinUser | undefined; users: JellyfinUser[] }; user: JellyfinUser | undefined;
users: JellyfinUser[];
setJellyfinUser: typeof setJellyfinUser;
};
}>(); }>();
export let baseUrl = get(user)?.settings.jellyfin.baseUrl || ''; let baseUrl = get(user)?.settings.jellyfin.baseUrl || '';
export let apiKey = get(user)?.settings.jellyfin.apiKey || ''; let apiKey = get(user)?.settings.jellyfin.apiKey || '';
export let jellyfinUser: JellyfinUser | undefined = undefined;
const originalBaseUrl = derived(user, (user) => user?.settings.jellyfin.baseUrl || ''); const originalBaseUrl = derived(user, (user) => user?.settings.jellyfin.baseUrl || '');
const originalApiKey = derived(user, (user) => user?.settings.jellyfin.apiKey || ''); const originalApiKey = derived(user, (user) => user?.settings.jellyfin.apiKey || '');
const originalUserId = derived(user, (user) => user?.settings.jellyfin.userId || undefined);
let timeout: ReturnType<typeof setTimeout>; let timeout: ReturnType<typeof setTimeout>;
export let jellyfinUsers: Promise<JellyfinUser[]> | undefined = undefined;
let stale = false;
let error = ''; let error = '';
let jellyfinUsers: Promise<JellyfinUser[]> | undefined = undefined;
export let jellyfinUser: JellyfinUser | undefined;
$: { $: {
if ($originalBaseUrl !== baseUrl && $originalApiKey !== apiKey) handleChange(); jellyfinUser;
else dispatch('change', { baseUrl, apiKey, stale: false }); $originalBaseUrl;
$originalApiKey;
$originalUserId;
stale = getIsStale();
} }
$: if (jellyfinUser)
dispatch('change', {
baseUrl,
apiKey,
stale:
baseUrl && apiKey ? jellyfinUser?.Id !== get(user)?.settings.jellyfin.userId : !jellyfinUser
});
handleChange(); handleChange();
function getIsStale() {
return (
(!!jellyfinUser?.Id || (!baseUrl && !apiKey && !jellyfinUser)) &&
($originalBaseUrl !== baseUrl ||
$originalApiKey !== apiKey ||
$originalUserId !== jellyfinUser?.Id)
);
}
function handleChange() { function handleChange() {
clearTimeout(timeout); clearTimeout(timeout);
stale = false;
error = ''; error = '';
jellyfinUsers = undefined; jellyfinUsers = undefined;
jellyfinUser = undefined; jellyfinUser = undefined;
if (baseUrl === '' || apiKey === '') {
stale = getIsStale();
return;
}
const baseUrlCopy = baseUrl; const baseUrlCopy = baseUrl;
const apiKeyCopy = apiKey; const apiKeyCopy = apiKey;
dispatch('change', {
baseUrl: '',
apiKey: '',
stale:
baseUrl === '' &&
apiKey === '' &&
jellyfinUser === undefined &&
(baseUrl !== $originalBaseUrl || apiKey !== $originalApiKey)
});
if (baseUrlCopy === '' || apiKeyCopy === '') return;
timeout = setTimeout(async () => { timeout = setTimeout(async () => {
jellyfinUsers = jellyfinApi.getJellyfinUsers(baseUrl, apiKey); jellyfinUsers = jellyfinApi.getJellyfinUsers(baseUrl, apiKey);
@@ -64,28 +68,38 @@
if (users.length) { if (users.length) {
jellyfinUser = users.find((u) => u.Id === get(user)?.settings.jellyfin.userId); jellyfinUser = users.find((u) => u.Id === get(user)?.settings.jellyfin.userId);
const stale = // stale = !!jellyfinUser?.Id && getIsStale();
(baseUrlCopy !== $originalBaseUrl || apiKeyCopy !== $originalApiKey) &&
jellyfinUser !== undefined;
dispatch('change', { baseUrl: baseUrlCopy, apiKey: apiKeyCopy, stale });
} else { } else {
error = 'Could not connect'; error = 'Could not connect';
stale = false;
} }
// if (res.status !== 200) {
// error =
// res.status === 404
// ? 'Server not found'
// : res.status === 401
// ? 'Invalid api key'
// : 'Could not connect';
//
// return; // TODO add notification
// } else {
// dispatch('change', { baseUrl: baseUrlCopy, apiKey: apiKeyCopy, stale });
// }
}, 1000); }, 1000);
} }
const setJellyfinUser = (u: JellyfinUser) => (jellyfinUser = u);
async function handleSave() {
if (!stale) return;
return user.updateUser((prev) => ({
...prev,
settings: {
...prev.settings,
jellyfin: {
...prev.settings.jellyfin,
baseUrl,
apiKey,
userId: jellyfinUser?.Id || ''
}
}
}));
}
$: empty = !baseUrl && !apiKey && !jellyfinUser;
$: unchanged =
$originalBaseUrl === baseUrl &&
$originalApiKey === apiKey &&
$originalUserId === jellyfinUser?.Id;
</script> </script>
<div class="space-y-4 mb-4"> <div class="space-y-4 mb-4">
@@ -109,7 +123,8 @@
{#if users?.length} {#if users?.length}
<SelectField <SelectField
value={jellyfinUser?.Name || 'Select User'} value={jellyfinUser?.Name || 'Select User'}
on:clickOrSelect={() => dispatch('click-user', { user: jellyfinUser, users })} on:clickOrSelect={() =>
dispatch('click-user', { user: jellyfinUser, users, setJellyfinUser })}
class="mb-4" class="mb-4"
> >
User User
@@ -120,3 +135,5 @@
{#if error} {#if error}
<div class="text-red-500 mb-4">{error}</div> <div class="text-red-500 mb-4">{error}</div>
{/if} {/if}
<slot {handleSave} {stale} {empty} {unchanged} />

View File

@@ -5,48 +5,51 @@
import { user } from '../../stores/user.store'; import { user } from '../../stores/user.store';
import { derived, get } from 'svelte/store'; import { derived, get } from 'svelte/store';
const dispatch = createEventDispatcher<{ let baseUrl = get(user)?.settings.radarr.baseUrl || '';
change: { baseUrl: string; apiKey: string; stale: boolean }; let apiKey = get(user)?.settings.radarr.apiKey || '';
}>();
export let baseUrl = get(user)?.settings.radarr.baseUrl || '';
export let apiKey = get(user)?.settings.radarr.apiKey || '';
const originalBaseUrl = derived(user, (user) => user?.settings.radarr.baseUrl || ''); const originalBaseUrl = derived(user, (user) => user?.settings.radarr.baseUrl || '');
const originalApiKey = derived(user, (user) => user?.settings.radarr.apiKey || ''); const originalApiKey = derived(user, (user) => user?.settings.radarr.apiKey || '');
let timeout: ReturnType<typeof setTimeout>;
let stale = false;
let error = ''; let error = '';
let timeout: ReturnType<typeof setTimeout>;
let healthCheck: Promise<boolean> | undefined; let healthCheck: Promise<boolean> | undefined;
$: { $: {
if ($originalBaseUrl !== baseUrl && $originalApiKey !== apiKey) handleChange(); $originalBaseUrl;
else dispatch('change', { baseUrl, apiKey, stale: false }); $originalApiKey;
stale = getIsStale();
} }
handleChange(); handleChange();
function getIsStale() {
return (
(!!healthCheck || (!baseUrl && !apiKey)) &&
($originalBaseUrl !== baseUrl || $originalApiKey !== apiKey)
);
}
function handleChange() { function handleChange() {
clearTimeout(timeout); clearTimeout(timeout);
stale = false;
error = ''; error = '';
healthCheck = undefined; healthCheck = undefined;
if (baseUrl === '' || apiKey === '') {
stale = getIsStale();
return;
}
const baseUrlCopy = baseUrl; const baseUrlCopy = baseUrl;
const apiKeyCopy = apiKey; const apiKeyCopy = apiKey;
const stale = baseUrlCopy !== $originalBaseUrl || apiKeyCopy !== $originalApiKey;
dispatch('change', {
baseUrl: '',
apiKey: '',
stale: baseUrl === '' && apiKey === '' && stale
});
if (baseUrlCopy === '' || apiKeyCopy === '') return;
timeout = setTimeout(async () => { timeout = setTimeout(async () => {
const p = radarrApi.getHealth(baseUrlCopy, apiKeyCopy); const p = radarrApi.getHealth(baseUrlCopy, apiKeyCopy);
healthCheck = p.then((res) => res.status === 200); healthCheck = p.then((res) => res.status === 200);
const res = await p; const res = await p;
if (baseUrlCopy !== baseUrl || apiKeyCopy !== apiKey) return; if (baseUrlCopy !== baseUrl || apiKeyCopy !== apiKey) return;
if (res.status !== 200) { if (res.status !== 200) {
error = error =
res.status === 404 res.status === 404
@@ -55,12 +58,29 @@
? 'Invalid api key' ? 'Invalid api key'
: 'Could not connect'; : 'Could not connect';
return; // TODO add notification stale = false; // TODO add notification
} else { } else {
dispatch('change', { baseUrl: baseUrlCopy, apiKey: apiKeyCopy, stale }); stale = getIsStale();
} }
}, 1000); }, 1000);
} }
async function handleSave() {
return user.updateUser((prev) => ({
...prev,
settings: {
...prev.settings,
radarr: {
...prev.settings.radarr,
baseUrl,
apiKey
}
}
}));
}
$: empty = !baseUrl && !apiKey;
$: unchanged = baseUrl === $originalBaseUrl && apiKey === $originalApiKey;
</script> </script>
<div class="space-y-4 mb-4"> <div class="space-y-4 mb-4">
@@ -73,3 +93,5 @@
{#if error} {#if error}
<div class="text-red-500 mb-4">{error}</div> <div class="text-red-500 mb-4">{error}</div>
{/if} {/if}
<slot {handleSave} {stale} {empty} {unchanged} />

View File

@@ -5,50 +5,51 @@
import { user } from '../../stores/user.store'; import { user } from '../../stores/user.store';
import { derived, get } from 'svelte/store'; import { derived, get } from 'svelte/store';
const dispatch = createEventDispatcher<{ let baseUrl = get(user)?.settings.sonarr.baseUrl || '';
change: { baseUrl: string; apiKey: string; stale: boolean }; let apiKey = get(user)?.settings.sonarr.apiKey || '';
}>();
export let baseUrl = get(user)?.settings.sonarr.baseUrl || '';
export let apiKey = get(user)?.settings.sonarr.apiKey || '';
const originalBaseUrl = derived(user, (u) => u?.settings.sonarr.baseUrl || ''); const originalBaseUrl = derived(user, (u) => u?.settings.sonarr.baseUrl || '');
const originalApiKey = derived(user, (u) => u?.settings.sonarr.apiKey || ''); const originalApiKey = derived(user, (u) => u?.settings.sonarr.apiKey || '');
let timeout: ReturnType<typeof setTimeout>;
let stale = false;
let error = ''; let error = '';
let timeout: ReturnType<typeof setTimeout>;
let healthCheck: Promise<boolean> | undefined; let healthCheck: Promise<boolean> | undefined;
$: { $: {
if ($originalBaseUrl !== baseUrl && $originalApiKey !== apiKey) handleChange(); $originalBaseUrl;
else dispatch('change', { baseUrl, apiKey, stale: false }); $originalApiKey;
stale = getIsStale();
} }
handleChange(); handleChange();
function handleChange() { function getIsStale() {
console.log('handleChange', $originalBaseUrl, baseUrl, $originalApiKey, apiKey); return (
(!!healthCheck || (!baseUrl && !apiKey)) &&
($originalBaseUrl !== baseUrl || $originalApiKey !== apiKey)
);
}
function handleChange() {
clearTimeout(timeout); clearTimeout(timeout);
stale = false;
error = ''; error = '';
healthCheck = undefined; healthCheck = undefined;
if (baseUrl === '' || apiKey === '') {
stale = getIsStale();
return;
}
const baseUrlCopy = baseUrl; const baseUrlCopy = baseUrl;
const apiKeyCopy = apiKey; const apiKeyCopy = apiKey;
const stale = baseUrlCopy !== $originalBaseUrl || apiKeyCopy !== $originalApiKey;
dispatch('change', {
baseUrl: '',
apiKey: '',
stale: baseUrl === '' && apiKey === '' && stale
});
if (baseUrlCopy === '' || apiKeyCopy === '') return;
timeout = setTimeout(async () => { timeout = setTimeout(async () => {
const p = sonarrApi.getHealth(baseUrlCopy, apiKeyCopy); const p = sonarrApi.getHealth(baseUrlCopy, apiKeyCopy);
healthCheck = p.then((res) => res.status === 200); healthCheck = p.then((res) => res.status === 200);
const res = await p; const res = await p;
if (baseUrlCopy !== baseUrl || apiKeyCopy !== apiKey) return; if (baseUrlCopy !== baseUrl || apiKeyCopy !== apiKey) return;
if (res.status !== 200) { if (res.status !== 200) {
error = error =
res.status === 404 res.status === 404
@@ -57,12 +58,29 @@
? 'Invalid api key' ? 'Invalid api key'
: 'Could not connect'; : 'Could not connect';
return; // TODO add notification stale = false; // TODO add notification
} else { } else {
dispatch('change', { baseUrl: baseUrlCopy, apiKey: apiKeyCopy, stale }); stale = getIsStale();
} }
}, 1000); }, 1000);
} }
async function handleSave() {
return user.updateUser((prev) => ({
...prev,
settings: {
...prev.settings,
sonarr: {
...prev.settings.sonarr,
baseUrl,
apiKey
}
}
}));
}
$: empty = !baseUrl && !apiKey;
$: unchanged = baseUrl === $originalBaseUrl && apiKey === $originalApiKey;
</script> </script>
<div class="space-y-4 mb-4"> <div class="space-y-4 mb-4">
@@ -75,3 +93,5 @@
{#if error} {#if error}
<div class="text-red-500 mb-4">{error}</div> <div class="text-red-500 mb-4">{error}</div>
{/if} {/if}
<slot {handleSave} {stale} {empty} {unchanged} />

View File

@@ -1,76 +1,48 @@
<script lang="ts"> <script lang="ts">
import TextField from '../TextField.svelte'; import { tmdbApi } from '../../apis/tmdb/tmdb-api';
import { sonarrApi } from '../../apis/sonarr/sonarr-api';
import { createEventDispatcher } from 'svelte';
import { user } from '../../stores/user.store'; import { user } from '../../stores/user.store';
import SelectField from '../SelectField.svelte';
import { createEventDispatcher } from 'svelte';
import Button from '../Button.svelte';
import { ArrowRight, Trash } from 'radix-icons-svelte';
import { derived } from 'svelte/store';
import classNames from 'classnames';
const dispatch = createEventDispatcher<{ export let handleConnectTmdb: () => void;
change: { baseUrl: string; apiKey: string; stale: boolean };
}>();
export let baseUrl = ''; const dispatch = createEventDispatcher<{ 'click-user': null }>();
export let apiKey = ''; const userId = derived(user, (user) => user?.settings.tmdb.userId);
let originalBaseUrl: string | undefined;
let originalApiKey: string | undefined;
let timeout: ReturnType<typeof setTimeout>;
let error = '';
let healthCheck: Promise<boolean> | undefined;
user.subscribe((user) => { $: connectedTmdbAccount = !!$userId && tmdbApi.getAccountDetails();
baseUrl = baseUrl || user?.settings.sonarr.baseUrl || '';
apiKey = apiKey || user?.settings.sonarr.apiKey || '';
originalBaseUrl = baseUrl; async function handleDisconnectTmdb() {
originalApiKey = apiKey; return user.updateUser((prev) => ({
...prev,
handleChange(); settings: {
}); ...prev.settings,
tmdb: {
function handleChange() { ...prev.settings.tmdb,
clearTimeout(timeout); userId: '',
error = ''; sessionId: ''
healthCheck = undefined; }
const baseUrlCopy = baseUrl;
const apiKeyCopy = apiKey;
const stale = baseUrlCopy !== originalBaseUrl || apiKeyCopy !== originalApiKey;
dispatch('change', {
baseUrl: '',
apiKey: '',
stale: baseUrl === '' && apiKey === ''
});
if (baseUrlCopy === '' || apiKeyCopy === '') return;
timeout = setTimeout(async () => {
const p = sonarrApi.getHealth(baseUrlCopy, apiKeyCopy);
healthCheck = p.then((res) => res.status === 200);
const res = await p;
if (baseUrlCopy !== baseUrl || apiKeyCopy !== apiKey) return;
if (res.status !== 200) {
error =
res.status === 404
? 'Server not found'
: res.status === 401
? 'Invalid api key'
: 'Could not connect';
return; // TODO add notification
} else {
dispatch('change', { baseUrl: baseUrlCopy, apiKey: apiKeyCopy, stale });
} }
}, 1000); }));
} }
</script> </script>
<div class="space-y-4 mb-4"> {#await connectedTmdbAccount then tmdbAccount}
<TextField bind:value={baseUrl} isValid={healthCheck} on:change={handleChange}>Base Url</TextField {#if tmdbAccount}
> <SelectField value={tmdbAccount.username || ''} action={handleDisconnectTmdb} class="mb-4">
<TextField bind:value={apiKey} isValid={healthCheck} on:change={handleChange}>API Key</TextField> Connected to
</div> <Trash slot="icon" let:size let:iconClass {size} class={classNames(iconClass, '')} />
</SelectField>
{#if error} {:else}
<div class="text-red-500 mb-4">{error}</div> <slot>
{/if} <div class="flex space-x-4">
<Button type="primary-dark" iconAfter={ArrowRight} on:clickOrSelect={handleConnectTmdb}>
Connect
</Button>
</div>
</slot>
{/if}
{/await}

View File

@@ -2,7 +2,7 @@
import Container from '../../../Container.svelte'; import Container from '../../../Container.svelte';
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, onMount } from 'svelte';
import { ExternalLink } from 'radix-icons-svelte'; import { ExternalLink } from 'radix-icons-svelte';
import { user } from '../../stores/user.store'; import { user } from '../../stores/user.store';
@@ -13,6 +13,8 @@
let tmdbConnectQrCode: string | undefined = undefined; let tmdbConnectQrCode: string | undefined = undefined;
let tmdbError: string = ''; let tmdbError: string = '';
handleGenerateTMDBLink();
async function handleGenerateTMDBLink() { async function handleGenerateTMDBLink() {
return tmdbApi.getConnectAccountLink().then((res) => { return tmdbApi.getConnectAccountLink().then((res) => {
if (res?.status_code !== 1) return; // TODO add notification if (res?.status_code !== 1) return; // TODO add notification
@@ -66,9 +68,9 @@
{/if} {/if}
<Container direction="horizontal" class="flex space-x-4 *:flex-1"> <Container direction="horizontal" class="flex space-x-4 *:flex-1">
{#if !tmdbConnectRequestToken} <!--{#if !tmdbConnectRequestToken}-->
<Button type="primary-dark" action={handleGenerateTMDBLink}>Generate Link</Button> <!-- <Button type="primary-dark" action={handleGenerateTMDBLink}>Generate Link</Button>-->
{:else if tmdbConnectLink} {#if tmdbConnectLink}
<Button type="primary-dark" action={completeTMDBConnect}>Complete Connection</Button> <Button type="primary-dark" action={completeTMDBConnect}>Complete Connection</Button>
<Button type="primary-dark" on:clickOrSelect={() => window.open(tmdbConnectLink)}> <Button type="primary-dark" on:clickOrSelect={() => window.open(tmdbConnectLink)}>
Open Link Open Link

View File

@@ -13,7 +13,7 @@
import JellyfinIntegrationUsersDialog from '../components/Integrations/JellyfinIntegrationUsersDialog.svelte'; import JellyfinIntegrationUsersDialog from '../components/Integrations/JellyfinIntegrationUsersDialog.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, Exit, Pencil1, Pencil2, Plus, Trash } from 'radix-icons-svelte'; import { ArrowRight, Exit, Pencil2, Plus, 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';
@@ -21,8 +21,8 @@
import { sessions } from '../stores/session.store'; import { sessions } from '../stores/session.store';
import EditProfileModal from '../components/Dialog/CreateOrEditProfileModal.svelte'; import EditProfileModal from '../components/Dialog/CreateOrEditProfileModal.svelte';
import { scrollIntoView } from '../selectable'; import { scrollIntoView } from '../selectable';
import Panel from '../components/Panel.svelte';
import { reiverrApi } from '../apis/reiverr/reiverr-api'; import { reiverrApi } from '../apis/reiverr/reiverr-api';
import TmdbIntegration from '../components/Integrations/TmdbIntegration.svelte';
enum Tabs { enum Tabs {
Interface, Interface,
@@ -32,19 +32,6 @@
const tab = useTabs(Tabs.Interface, { size: 'stretch' }); const tab = useTabs(Tabs.Interface, { size: 'stretch' });
let jellyfinBaseUrl = '';
let jellyfinApiKey = '';
let jellyfinStale = false;
let jellyfinUser: JellyfinUser | undefined = undefined;
let sonarrBaseUrl = '';
let sonarrApiKey = '';
let sonarrStale = false;
let radarrBaseUrl = '';
let radarrApiKey = '';
let radarrStale = false;
let lastKeyCode = 0; let lastKeyCode = 0;
let lastKey = ''; let lastKey = '';
let tizenMediaKey = ''; let tizenMediaKey = '';
@@ -55,63 +42,6 @@
return $user?.isAdmin ? reiverrApi.getUsers() : undefined; return $user?.isAdmin ? reiverrApi.getUsers() : undefined;
} }
async function handleDisconnectTmdb() {
return user.updateUser((prev) => ({
...prev,
settings: {
...prev.settings,
tmdb: {
...prev.settings.tmdb,
userId: '',
sessionId: ''
}
}
}));
}
async function handleSaveJellyfin() {
return user.updateUser((prev) => ({
...prev,
settings: {
...prev.settings,
jellyfin: {
...prev.settings.jellyfin,
baseUrl: jellyfinBaseUrl,
apiKey: jellyfinApiKey,
userId: jellyfinUser?.Id ?? ''
}
}
}));
}
async function handleSaveSonarr() {
return user.updateUser((prev) => ({
...prev,
settings: {
...prev.settings,
sonarr: {
...prev.settings.sonarr,
baseUrl: sonarrBaseUrl,
apiKey: sonarrApiKey
}
}
}));
}
async function handleSaveRadarr() {
return user.updateUser((prev) => ({
...prev,
settings: {
...prev.settings,
radarr: {
...prev.settings.radarr,
baseUrl: radarrBaseUrl,
apiKey: radarrApiKey
}
}
}));
}
function handleLogOut() { function handleLogOut() {
sessions.removeSession(); sessions.removeSession();
} }
@@ -294,18 +224,9 @@
on:enter={scrollIntoView({ vertical: 64 })} on:enter={scrollIntoView({ vertical: 64 })}
> >
<h1 class="mb-4 header1">Sonarr</h1> <h1 class="mb-4 header1">Sonarr</h1>
<SonarrIntegration <SonarrIntegration let:stale let:handleSave>
on:change={({ detail }) => { <Button disabled={!stale} type="primary-dark" action={handleSave}>Save</Button>
sonarrBaseUrl = detail.baseUrl; </SonarrIntegration>
sonarrApiKey = detail.apiKey;
sonarrStale = detail.stale;
}}
/>
<div class="flex">
<Button disabled={!sonarrStale} type="primary-dark" action={handleSaveSonarr}>
Save
</Button>
</div>
</Container> </Container>
<Container <Container
@@ -313,18 +234,9 @@
on:enter={scrollIntoView({ vertical: 64 })} on:enter={scrollIntoView({ vertical: 64 })}
> >
<h1 class="mb-4 header1">Radarr</h1> <h1 class="mb-4 header1">Radarr</h1>
<RadarrIntegration <RadarrIntegration let:stale let:handleSave>
on:change={({ detail }) => { <Button disabled={!stale} type="primary-dark" action={handleSave}>Save</Button>
radarrBaseUrl = detail.baseUrl; </RadarrIntegration>
radarrApiKey = detail.apiKey;
radarrStale = detail.stale;
}}
/>
<div class="flex">
<Button disabled={!radarrStale} type="primary-dark" action={handleSaveRadarr}>
Save
</Button>
</div>
</Container> </Container>
</Container> </Container>
@@ -334,33 +246,37 @@
on:enter={scrollIntoView({ vertical: 64 })} on:enter={scrollIntoView({ vertical: 64 })}
> >
<h1 class="mb-4 header1">Tmdb Account</h1> <h1 class="mb-4 header1">Tmdb Account</h1>
{#await tmdbAccount then tmdbAccount} <TmdbIntegration
{#if tmdbAccount} handleConnectTmdb={() => createModal(TmdbIntegrationConnectDialog, {})}
<SelectField />
value={tmdbAccount.username || ''}
action={handleDisconnectTmdb} <!--{#await tmdbAccount then tmdbAccount}-->
class="mb-4" <!-- {#if tmdbAccount}-->
> <!-- <SelectField-->
Connected to <!-- value={tmdbAccount.username || ''}-->
<Trash <!-- action={handleDisconnectTmdb}-->
slot="icon" <!-- class="mb-4"-->
let:size <!-- >-->
let:iconClass <!-- Connected to-->
{size} <!-- <Trash-->
class={classNames(iconClass, '')} <!-- slot="icon"-->
/> <!-- let:size-->
</SelectField> <!-- let:iconClass-->
{:else} <!-- {size}-->
<div class="flex space-x-4"> <!-- class={classNames(iconClass, '')}-->
<Button <!-- />-->
type="primary-dark" <!-- </SelectField>-->
iconAfter={ArrowRight} <!-- {:else}-->
on:clickOrSelect={() => createModal(TmdbIntegrationConnectDialog, {})} <!-- <div class="flex space-x-4">-->
>Connect</Button <!-- <Button-->
> <!-- type="primary-dark"-->
</div> <!-- iconAfter={ArrowRight}-->
{/if} <!-- on:clickOrSelect={() => createModal(TmdbIntegrationConnectDialog, {})}-->
{/await} <!-- >Connect</Button-->
<!-- >-->
<!-- </div>-->
<!-- {/if}-->
<!--{/await}-->
</Container> </Container>
<Container <Container
@@ -369,24 +285,17 @@
> >
<h1 class="mb-4 header1">Jellyfin</h1> <h1 class="mb-4 header1">Jellyfin</h1>
<JellyfinIntegration <JellyfinIntegration
bind:jellyfinUser
on:change={({ detail }) => {
jellyfinBaseUrl = detail.baseUrl;
jellyfinApiKey = detail.apiKey;
jellyfinStale = detail.stale;
}}
on:click-user={({ detail }) => on:click-user={({ detail }) =>
createModal(JellyfinIntegrationUsersDialog, { createModal(JellyfinIntegrationUsersDialog, {
selectedUser: detail.user, selectedUser: detail.user,
users: detail.users, users: detail.users,
handleSelectUser: (u) => (jellyfinUser = u) handleSelectUser: detail.setJellyfinUser
})} })}
/> let:handleSave
<div class="flex"> let:stale
<Button disabled={!jellyfinStale} type="primary-dark" action={handleSaveJellyfin}> >
Save <Button disabled={!stale} type="primary-dark" action={handleSave}>Save</Button>
</Button> </JellyfinIntegration>
</div>
</Container> </Container>
</Container> </Container>
</Container> </Container>

View File

@@ -15,6 +15,11 @@
import { user } from '../stores/user.store'; import { user } from '../stores/user.store';
import { sessions } from '../stores/session.store'; import { sessions } from '../stores/session.store';
import Panel from '../components/Panel.svelte'; import Panel from '../components/Panel.svelte';
import TmdbIntegrationConnect from '../components/Integrations/TmdbIntegrationConnect.svelte';
import JellyfinIntegration from '../components/Integrations/JellyfinIntegration.svelte';
import SonarrIntegration from '../components/Integrations/SonarrIntegration.svelte';
import RadarrIntegration from '../components/Integrations/RadarrIntegration.svelte';
import TmdbIntegration from '../components/Integrations/TmdbIntegration.svelte';
enum Tabs { enum Tabs {
Welcome, Welcome,
@@ -30,190 +35,10 @@
const tab = useTabs(Tabs.Welcome, { ['class']: 'w-max max-w-lg' }); const tab = useTabs(Tabs.Welcome, { ['class']: 'w-max max-w-lg' });
let tmdbConnectRequestToken: string | undefined = undefined;
let tmdbConnectLink: string | undefined = undefined;
let tmdbConnectQrCode: string | undefined = undefined;
$: connectedTmdbAccount = $user?.settings.tmdb.userId && tmdbApi.getAccountDetails(); $: connectedTmdbAccount = $user?.settings.tmdb.userId && tmdbApi.getAccountDetails();
let tmdbError: string = '';
let jellyfinBaseUrl: string = '';
let jellyfinApiKey: string = '';
let jellyfinUser: JellyfinUser | undefined = undefined; let jellyfinUser: JellyfinUser | undefined = undefined;
let jellyfinUsers: Promise<JellyfinUser[]> = Promise.resolve([]); let jellyfinUsers: Promise<JellyfinUser[]> = Promise.resolve([]);
let jellyfinConnectionCheckTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
let jellyfinError: string = '';
let sonarrBaseUrl: string = '';
let sonarrApiKey: string = '';
let sonarrError: string = '';
let radarrBaseUrl: string = '';
let radarrApiKey: string = '';
let radarrError: string = '';
user.subscribe((user) => {
jellyfinBaseUrl = jellyfinBaseUrl || user?.settings.jellyfin.baseUrl || '';
jellyfinApiKey = jellyfinApiKey || user?.settings.jellyfin.apiKey || '';
sonarrBaseUrl = sonarrBaseUrl || user?.settings.sonarr.baseUrl || '';
sonarrApiKey = sonarrApiKey || user?.settings.sonarr.apiKey || '';
radarrBaseUrl = radarrBaseUrl || user?.settings.radarr.baseUrl || '';
radarrApiKey = radarrApiKey || user?.settings.radarr.apiKey || '';
// if (
// !jellyfinUser &&
// appState.user?.settings.jellyfin.userId &&
// jellyfinBaseUrl &&
// jellyfinApiKey
// ) {
// jellyfinUsers = jellyfinApi.getJellyfinUsers(jellyfinBaseUrl, jellyfinApiKey);
// jellyfinUsers.then(
// (users) =>
// (jellyfinUser = users.find((u) => u.Id === appState.user?.settings.jellyfin.userId))
// );
// }
});
$: if (jellyfinBaseUrl && jellyfinApiKey) {
clearTimeout(jellyfinConnectionCheckTimeout);
const baseUrlCopy = jellyfinBaseUrl;
const apiKeyCopy = jellyfinApiKey;
jellyfinUser = undefined;
jellyfinConnectionCheckTimeout = setTimeout(async () => {
jellyfinUsers = jellyfinApi
.getJellyfinUsers(jellyfinBaseUrl, jellyfinApiKey)
.then((users) => {
if (baseUrlCopy === jellyfinBaseUrl && apiKeyCopy === jellyfinApiKey) {
jellyfinUser = users.find((u) => u.Id === $user?.settings.jellyfin.userId);
jellyfinError = users.length ? '' : 'Could not connect';
}
// console.log(users, baseUrlCopy, jellyfinBaseUrl, apiKeyCopy, jellyfinApiKey);
// jellyfinUsers = users;
// return !!users?.length;
return users;
});
}, 500);
}
async function handleGenerateTMDBLink() {
return tmdbApi.getConnectAccountLink().then((res) => {
if (res?.status_code !== 1) return; // TODO add notification
const link = `https://www.themoviedb.org/auth/access?request_token=${res?.request_token}`;
const qrCode = `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${link}`;
tmdbConnectRequestToken = res?.request_token;
tmdbConnectLink = link;
tmdbConnectQrCode = qrCode;
});
}
async function completeTMDBConnect() {
if (!tmdbConnectRequestToken) return;
tmdbApi.getAccountAccessToken(tmdbConnectRequestToken).then((res) => {
const { status_code, access_token, account_id } = res || {};
if (status_code !== 1 || !access_token || !account_id) return; // TODO add notification
user.updateUser((prev) => ({
...prev,
settings: {
...prev.settings,
tmdb: {
userId: account_id,
sessionId: access_token
}
}
}));
tab.set(Tabs.Jellyfin);
});
}
async function handleConnectJellyfin() {
const userId = jellyfinUser?.Id;
const baseUrl = jellyfinBaseUrl;
const apiKey = jellyfinApiKey;
if (!userId || !baseUrl || !apiKey) return;
await user.updateUser((prev) => ({
...prev,
settings: {
...prev.settings,
jellyfin: {
...prev.settings.jellyfin,
userId,
baseUrl,
apiKey
}
}
}));
tab.next();
}
async function handleConnectSonarr() {
const baseUrl = sonarrBaseUrl;
const apiKey = sonarrApiKey;
if (!baseUrl || !apiKey) return;
const res = await sonarrApi.getHealth(baseUrl, apiKey);
if (res.status !== 200) {
sonarrError =
res.status === 404
? 'Server not found'
: res.status === 401
? 'Invalid api key'
: 'Could not connect';
return; // TODO add notification
}
await user.updateUser((prev) => ({
...prev,
settings: {
...prev.settings,
sonarr: {
...prev.settings.sonarr,
baseUrl,
apiKey
}
}
}));
tab.next();
}
async function handleConnectRadarr() {
const baseUrl = radarrBaseUrl;
const apiKey = radarrApiKey;
if (!baseUrl || !apiKey) return;
const res = await radarrApi.getHealth(baseUrl, apiKey);
if (res.status !== 200) {
res.status === 404
? 'Server not found'
: res.status === 401
? 'Invalid api key'
: 'Could not connect';
return; // TODO add notification
}
await user.updateUser((prev) => ({
...prev,
settings: {
...prev.settings,
radarr: {
...prev.settings.radarr,
baseUrl,
apiKey
}
}
}));
await finalizeSetup();
}
async function finalizeSetup() { async function finalizeSetup() {
await user.updateUser((prev) => ({ await user.updateUser((prev) => ({
@@ -225,9 +50,6 @@
function handleBack() { function handleBack() {
tab.previous(); tab.previous();
} }
const tabContainer =
'col-start-1 col-end-1 row-start-1 row-end-1 flex flex-col bg-primary-800 rounded-2xl p-10 shadow-xl max-w-lg';
</script> </script>
<Container focusOnMount class="h-full w-full flex items-center justify-center" on:back={handleBack}> <Container focusOnMount class="h-full w-full flex items-center justify-center" on:back={handleBack}>
@@ -260,40 +82,52 @@
preferences. preferences.
</div> </div>
<div class="space-y-4 flex flex-col"> <TmdbIntegration handleConnectTmdb={() => tab.set(Tabs.TmdbConnect)}>
{#await connectedTmdbAccount then account} <Container direction="horizontal" class="flex space-x-4 *:flex-1">
{#if account} {#if !$user?.settings.tmdb.userId}
<SelectField
class="mb-4"
value={account.username || ''}
on:clickOrSelect={() => {
tab.set(Tabs.TmdbConnect);
handleGenerateTMDBLink();
}}>Logged in as</SelectField
>
{:else}
<Button <Button
type="primary-dark" type="primary-dark"
on:clickOrSelect={() => { on:clickOrSelect={() => {
tab.set(Tabs.TmdbConnect); tab.set(Tabs.TmdbConnect);
handleGenerateTMDBLink();
}} }}
> >
Connect Connect
<ArrowRight size={19} slot="icon-absolute" />
</Button> </Button>
{/if} {/if}
{/await} <Button type="primary-dark" on:clickOrSelect={() => tab.next()}>
{#if $user?.settings.tmdb.userId}
Next
{:else}
Skip
{/if}
<ArrowRight size={19} slot="icon-absolute" />
</Button>
</Container>
</TmdbIntegration>
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}> <!-- <div class="space-y-4 flex flex-col">-->
{#if $user?.settings.tmdb.userId} <!-- {#await connectedTmdbAccount then account}-->
Next <!-- {#if account}-->
{:else} <!-- <SelectField-->
Skip <!-- class="mb-4"-->
{/if} <!-- value={account.username || ''}-->
<ArrowRight size={19} slot="icon-absolute" /> <!-- on:clickOrSelect={() => {-->
</Button> <!-- tab.set(Tabs.TmdbConnect);-->
</div> <!-- }}>Logged in as</SelectField-->
<!-- >-->
<!-- {:else}-->
<!-- <Button-->
<!-- type="primary-dark"-->
<!-- on:clickOrSelect={() => {-->
<!-- tab.set(Tabs.TmdbConnect);-->
<!-- }}-->
<!-- >-->
<!-- Connect-->
<!-- <ArrowRight size={19} slot="icon-absolute" />-->
<!-- </Button>-->
<!-- {/if}-->
<!-- {/await}-->
<!-- </div>-->
</Tab> </Tab>
<Tab <Tab
@@ -304,69 +138,64 @@
detail.stopPropagation(); detail.stopPropagation();
}} }}
> >
<h1 class="header2 mb-2">Connect a TMDB Account</h1> <TmdbIntegrationConnect on:connected={() => tab.set(Tabs.Jellyfin)} />
<div class="body mb-8">
To connect your TMDB account, log in via the link below and then click "Complete
Connection".
</div>
{#if tmdbConnectQrCode}
<div
class="w-[150px] h-[150px] bg-contain bg-center mb-8 mx-auto"
style={`background-image: url(${tmdbConnectQrCode})`}
/>
{/if}
<Container direction="horizontal" class="flex space-x-4 *:flex-1">
{#if !tmdbConnectRequestToken}
<Button type="primary-dark" action={handleGenerateTMDBLink}>Generate Link</Button>
{:else if tmdbConnectLink}
<Button type="primary-dark" action={completeTMDBConnect}>Complete Connection</Button>
<Button type="primary-dark" on:clickOrSelect={() => window.open(tmdbConnectLink)}>
Open Link
<ExternalLink size={19} slot="icon-after" />
</Button>
{/if}
</Container>
</Tab> </Tab>
<Tab {...tab} tab={Tabs.Jellyfin}> <Tab {...tab} tab={Tabs.Jellyfin}>
<h1 class="header2 mb-2">Connect to Jellyfin</h1> <h1 class="header2 mb-2">Connect to Jellyfin</h1>
<div class="mb-8 body">Connect to Jellyfin to watch movies and tv shows.</div> <div class="mb-8 body">Connect to Jellyfin to watch movies and tv shows.</div>
<div class="space-y-4 mb-4"> <JellyfinIntegration
<TextField bind:value={jellyfinBaseUrl} isValid={jellyfinUsers.then((u) => !!u?.length)}> bind:jellyfinUser
Base Url bind:jellyfinUsers
</TextField> on:click-user={() => tab.set(Tabs.SelectUser)}
<TextField bind:value={jellyfinApiKey} isValid={jellyfinUsers.then((u) => !!u?.length)}> let:handleSave
API Key let:stale
</TextField> let:empty
</div> let:unchanged
>
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4">
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()}>Back</Button>
{#if empty || unchanged}
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>
{empty ? 'Skip' : 'Next'}
</Button>
{:else}
<Button
type="primary-dark"
disabled={!stale}
action={() => handleSave().then(tab.next)}
>
Connect
</Button>
{/if}
</Container>
</JellyfinIntegration>
{#await jellyfinUsers then users} <!-- <div class="space-y-4 mb-4">-->
{#if users.length} <!-- <TextField bind:value={jellyfinBaseUrl} isValid={jellyfinUsers.then((u) => !!u?.length)}>-->
<SelectField <!-- Base Url-->
value={jellyfinUser?.Name || 'Select User'} <!-- </TextField>-->
on:clickOrSelect={() => tab.set(Tabs.SelectUser)} <!-- <TextField bind:value={jellyfinApiKey} isValid={jellyfinUsers.then((u) => !!u?.length)}>-->
class="mb-4" <!-- API Key-->
> <!-- </TextField>-->
User <!-- </div>-->
</SelectField>
{/if}
{/await}
{#if jellyfinError} <!-- {#await jellyfinUsers then users}-->
<div class="text-red-500 mb-4">{jellyfinError}</div> <!-- {#if users.length}-->
{/if} <!-- <SelectField-->
<!-- value={jellyfinUser?.Name || 'Select User'}-->
<!-- on:clickOrSelect={() => tab.set(Tabs.SelectUser)}-->
<!-- class="mb-4"-->
<!-- >-->
<!-- User-->
<!-- </SelectField>-->
<!-- {/if}-->
<!-- {/await}-->
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4"> <!-- {#if jellyfinError}-->
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()}>Back</Button> <!-- <div class="text-red-500 mb-4">{jellyfinError}</div>-->
{#if jellyfinBaseUrl && jellyfinApiKey && jellyfinUser} <!-- {/if}-->
<Button type="primary-dark" action={handleConnectJellyfin}>Connect</Button>
{:else}
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>Skip</Button>
{/if}
</Container>
</Tab> </Tab>
<Tab <Tab
{...tab} {...tab}
@@ -379,7 +208,7 @@
<h1 class="header1 mb-2 w-96">Select User</h1> <h1 class="header1 mb-2 w-96">Select User</h1>
<div class="flex flex-col space-y-4" /> <div class="flex flex-col space-y-4" />
{#await jellyfinUsers then users} {#await jellyfinUsers then users}
{#each users as user} {#each users || [] as user}
<SelectItem <SelectItem
selected={user?.Id === jellyfinUser?.Id} selected={user?.Id === jellyfinUser?.Id}
on:clickOrSelect={() => { on:clickOrSelect={() => {
@@ -397,46 +226,48 @@
<h1 class="header2 mb-2">Connect to Sonarr</h1> <h1 class="header2 mb-2">Connect to Sonarr</h1>
<div class="mb-8">Connect to Sonarr for requesting and managing tv shows.</div> <div class="mb-8">Connect to Sonarr for requesting and managing tv shows.</div>
<div class="space-y-4 mb-4"> <SonarrIntegration let:stale let:handleSave let:empty let:unchanged>
<TextField bind:value={sonarrBaseUrl}>Base Url</TextField> <Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4">
<TextField bind:value={sonarrApiKey}>API Key</TextField> <Button type="primary-dark" on:clickOrSelect={() => tab.previous()}>Back</Button>
</div> {#if empty || unchanged}
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>
{#if sonarrError} {empty ? 'Skip' : 'Next'}
<div class="text-red-500 mb-4">{sonarrError}</div> </Button>
{/if} {:else}
<Button
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4"> type="primary-dark"
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()}>Back</Button> disabled={!stale}
{#if sonarrBaseUrl && sonarrApiKey} action={() => handleSave().then(tab.next)}
<Button type="primary-dark" action={handleConnectSonarr}>Connect</Button> >
{:else} Connect
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>Skip</Button> </Button>
{/if} {/if}
</Container> </Container>
</SonarrIntegration>
</Tab> </Tab>
<Tab {...tab} tab={Tabs.Radarr}> <Tab {...tab} tab={Tabs.Radarr}>
<h1 class="header2 mb-2">Connect to Radarr</h1> <h1 class="header2 mb-2">Connect to Radarr</h1>
<div class="mb-8">Connect to Radarr for requesting and managing movies.</div> <div class="mb-8">Connect to Radarr for requesting and managing movies.</div>
<div class="space-y-4 mb-4"> <RadarrIntegration let:stale let:handleSave let:empty let:unchanged>
<TextField bind:value={radarrBaseUrl}>Base Url</TextField> <Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4">
<TextField bind:value={radarrApiKey}>API Key</TextField> <Button type="primary-dark" on:clickOrSelect={() => tab.previous()}>Back</Button>
</div> {#if empty || unchanged}
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>
{#if radarrError} {empty ? 'Skip' : 'Next'}
<div class="text-red-500 mb-4">{radarrError}</div> </Button>
{/if} {:else}
<Button
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4"> type="primary-dark"
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()}>Back</Button> disabled={!stale}
{#if radarrBaseUrl && radarrApiKey} action={() => handleSave().then(tab.next)}
<Button type="primary-dark" action={handleConnectRadarr}>Connect</Button> >
{:else} Connect
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>Skip</Button> </Button>
{/if} {/if}
</Container> </Container>
</RadarrIntegration>
</Tab> </Tab>
<Tab {...tab} tab={Tabs.Complete} class={classNames('w-full')}> <Tab {...tab} tab={Tabs.Complete} class={classNames('w-full')}>