feat: Profiles

This commit is contained in:
RockinChaos 2024-08-15 22:24:29 -07:00
parent d576ccded4
commit 76e79e6eaa
14 changed files with 481 additions and 262 deletions

View file

@ -24,8 +24,7 @@
import TorrentModal from './views/TorrentSearch/TorrentModal.svelte'
import Menubar from './components/Menubar.svelte'
import { Toaster } from 'svelte-sonner'
import Login from './components/Login.svelte'
import Logout from './components/Logout.svelte'
import Profiles from './components/Profiles.svelte'
import Navbar from './components/Navbar.svelte'
setContext('view', view)
@ -33,8 +32,7 @@
<div class='page-wrapper with-transitions bg-dark position-relative' data-sidebar-type='overlayed-all'>
<Menubar bind:page={$page} />
<Login />
<Logout />
<Profiles />
<Sidebar bind:page={$page} />
<div class='overflow-hidden content-wrapper h-full'>
<Toaster visibleToasts={6} position='top-right' theme='dark' richColors duration={10000} closeButton />

View file

@ -1,103 +0,0 @@
<script context='module'>
import { click } from '@/modules/click.js'
import { writable } from 'simple-store-svelte'
import { platformMap } from '@/views/Settings/Settings.svelte'
import { clientID } from '@/modules/myanimelist.js'
import { toast } from 'svelte-sonner'
import IPC from '@/modules/ipc.js'
export const login = writable(false)
function confirmAnilist () {
IPC.emit('open', 'https://anilist.co/api/v2/oauth/authorize?client_id=4254&response_type=token') // Change redirect_url to miru://auth
supportNotify()
}
function confirmMAL () {
const state = generateRandomString(10)
const challenge = generateRandomString(50)
sessionStorage.setItem(state, challenge)
IPC.emit('open', `https://myanimelist.net/v1/oauth2/authorize?response_type=code&client_id=${clientID}&state=${state}&code_challenge=${challenge}&code_challenge_method=plain`) // Change redirect_url to miru://malauth
supportNotify()
}
function supportNotify() {
if (platformMap[window.version.platform] === 'Linux') {
toast('Support Notification', {
description: "If your linux distribution doesn't support custom protocol handlers, you can simply paste the full URL into the app.",
duration: 300000
})
}
}
function generateRandomString(length) {
let text = ''
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
for (let i = 0; i < length; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length))
}
return text
}
</script>
<script>
let modal
function close () {
$login = false
}
function checkClose ({ keyCode }) {
if (keyCode === 27) close()
}
$: $login && modal?.focus()
</script>
<div class='modal z-101' class:show={$login}>
{#if $login}
<div class='modal-dialog' on:pointerup|self={close} on:keydown={checkClose} tabindex='-1' role='button' bind:this={modal}>
<div class='modal-content d-flex justify-content-center flex-column'>
<button class='close pointer z-30 top-20 right-0 position-absolute' type='button' use:click={close}> &times; </button>
<h5 class='modal-title'>Log In</h5>
<div class='modal-buttons d-flex flex-column align-items-center'>
<div class='modal-button mb-10 d-flex justify-content-center flex-row'>
<img src='./anilist_logo.png' class='al-logo position-absolute rounded pointer-events-none' alt='logo' />
<button class='btn anilist w-150' type='button' on:click={confirmAnilist}></button>
</div>
<div class='modal-button d-flex justify-content-center flex-row'>
<img src='./myanimelist_logo.png' class='mal-logo position-absolute rounded pointer-events-none' alt='logo' />
<button class='btn myanimelist w-150' type='button' on:click={confirmMAL}></button>
</div>
</div>
</div>
</div>
{/if}
</div>
<style>
.close {
top: 4rem !important;
left: unset !important;
right: 2.5rem !important;
}
.mal-logo {
height: 2rem;
margin-top: 0.8rem;
}
.al-logo {
height: 1.6rem;
margin-top: 0.82rem;
}
.anilist {
background-color: #283342 !important;
}
.myanimelist {
background-color: #2C51A2 !important;
}
.anilist:hover {
background-color: #46536c !important;
}
.myanimelist:hover {
background-color: #2861d6 !important;
}
</style>

View file

@ -1,51 +0,0 @@
<script context='module'>
import { click } from '@/modules/click.js'
import { writable } from 'simple-store-svelte'
import Helper from '@/modules/helper.js'
export const logout = writable(false)
function confirm () {
localStorage.removeItem('ALviewer')
localStorage.removeItem('MALviewer')
location.hash = ''
location.reload()
}
</script>
<script>
let modal
function close () {
$logout = false
}
function checkClose ({ keyCode }) {
if (keyCode === 27) close()
}
$: $logout && modal?.focus()
</script>
<div class='modal z-101' class:show={$logout}>
{#if $logout}
<div class='modal-dialog' on:pointerup|self={close} on:keydown={checkClose} tabindex='-1' role='button' bind:this={modal}>
<div class='modal-content d-flex justify-content-center flex-column'>
<button class='close pointer z-30 top-20 right-0 position-absolute' type='button' use:click={close}> &times; </button>
<h5 class='modal-title'>Log Out</h5>
<p>
Are You Sure You Want To Sign Out? Your Progress Will No Longer Be Tracked On <u>{Helper.isAniAuth() ? 'AniList' : 'MyAnimeList'}</u>.
</p>
<div class='text-right mt-20'>
<button class='btn mr-5' type='button' on:click={close}>Cancel</button>
<button class='btn btn-danger' type='button' on:click={confirm}>Sign Out</button>
</div>
</div>
</div>
{/if}
</div>
<style>
.close {
top: 4rem !important;
left: unset !important;
right: 2.5rem !important;
}
</style>

View file

@ -0,0 +1,234 @@
<script context='module'>
import { generateRandomString } from "@/modules/util.js"
import { get, writable } from 'simple-store-svelte'
import { swapProfiles, alToken, malToken } from '@/modules/settings.js'
import { platformMap } from '@/views/Settings/Settings.svelte'
import { clientID } from "@/modules/myanimelist.js"
import { click } from '@/modules/click.js'
import { toast } from 'svelte-sonner'
import IPC from "@/modules/ipc"
export const profileView = writable(false)
const profileAdd = writable(false)
const currentProfile = writable(alToken || malToken)
const profiles = writable(JSON.parse(localStorage.getItem('profiles')) || [])
function isAniProfile (profile) {
return profile.viewer?.data?.Viewer?.avatar
}
function currentLogout () {
swapProfiles(null)
location.reload()
}
function dropProfile (profile) {
profiles.update(profiles => {
const updatedProfiles = profiles.filter(p => p.viewer.data.Viewer.id !== profile.viewer?.data?.Viewer.id)
localStorage.setItem('profiles', JSON.stringify(updatedProfiles))
return updatedProfiles
})
}
function switchProfile (profile) {
swapProfiles(profile)
location.reload()
}
function toggleSync(profile) {
const mainProfile = get(currentProfile)
if (profile.viewer.data.Viewer.id === mainProfile.viewer.data.Viewer.id) {
mainProfile.viewer.data.Viewer.sync = !mainProfile.viewer.data.Viewer.sync
localStorage.setItem(isAniProfile(mainProfile) ? 'ALviewer' : 'MALviewer', JSON.stringify(mainProfile))
} else {
profiles.update(profiles => {
const updatedProfiles = profiles.map(p => {
if (p.viewer.data.Viewer.id === profile.viewer.data.Viewer.id) {
p.viewer.data.Viewer.sync = !p.viewer.data.Viewer.sync
}
return p
})
localStorage.setItem('profiles', JSON.stringify(updatedProfiles))
return updatedProfiles
})
}
}
function confirmAnilist () {
IPC.emit('open', 'https://anilist.co/api/v2/oauth/authorize?client_id=4254&response_type=token') // Change redirect_url to miru://auth
supportNotify()
}
function confirmMAL () {
const state = generateRandomString(10)
const challenge = generateRandomString(50)
sessionStorage.setItem(state, challenge)
IPC.emit('open', `https://myanimelist.net/v1/oauth2/authorize?response_type=code&client_id=${clientID}&state=${state}&code_challenge=${challenge}&code_challenge_method=plain`) // Change redirect_url to miru://malauth
supportNotify()
}
function supportNotify() {
if (platformMap[window.version.platform] === 'Linux') {
toast('Support Notification', {
description: "If your linux distribution doesn't support custom protocol handlers, you can simply paste the full URL into the app.",
duration: 300000
})
}
}
</script>
<script>
let modal
function close () {
$profileView = false
$profileAdd = false
}
function checkClose ({ keyCode }) {
if (keyCode === 27) close()
}
$: $profileView && modal?.focus()
</script>
<div class='modal z-101' class:show={$profileView}>
{#if $profileView}
<div class='modal-dialog' on:pointerup|self={close} on:keydown={checkClose} tabindex='-1' role='button' bind:this={modal}>
<div class='modal-content w-auto mw-400 d-flex justify-content-center flex-column'>
<button class='close pointer z-30 top-20 right-0 position-absolute' type='button' use:click={close}> &times; </button>
<div class='d-flex flex-column align-items-center'>
{#if $currentProfile}
<img class='h-150 rounded-circle' src={$currentProfile.viewer.data.Viewer.avatar?.medium || $currentProfile.viewer.data.Viewer.picture} alt='Current Profile' title='Current Profile'>
<img class='h-3 auth-icon rounded-circle' src={isAniProfile($currentProfile) ? './anilist_icon.png' : './myanimelist_icon.png'} alt={isAniProfile($currentProfile) ? 'Logged in with AniList' : 'Logged in with MyAnimeList'} title={isAniProfile($currentProfile) ? 'Logged in with AniList' : 'Logged in with MyAnimeList'}>
<p class='font-size-18 font-weight-bold'>{$currentProfile.viewer.data.Viewer.name}</p>
{/if}
</div>
{#if $profiles.length > 0}
<div class='info box pointer border-0 rounded-top-10 pt-10 pb-10 d-flex align-items-center justify-content-center text-center font-weight-bold'>
Other Profiles
</div>
{/if}
<div class='d-flex flex-column align-items-start'>
{#each $profiles as profile}
<button type='button' class='profile-item box text-left pointer border-0 d-flex align-items-center justify-content-between position-relative flex-wrap' on:click={() => switchProfile(profile)}>
<div class='d-flex align-items-center flex-wrap'>
<img class='h-50 ml-10 mt-5 mb-5 mr-10 rounded-circle bg-transparent' src={profile.viewer.data.Viewer.avatar?.medium || profile.viewer.data.Viewer.picture} alt={profile.viewer.data.Viewer.name}>
<img class='ml-5 auth-icon rounded-circle' src={isAniProfile(profile) ? './anilist_icon.png' : './myanimelist_icon.png'} alt={isAniProfile(profile) ? 'Logged in with AniList' : 'Logged in with MyAnimeList'} title={isAniProfile(profile) ? 'Logged in with AniList' : 'Logged in with MyAnimeList'}>
<p class='text-wrap'>{profile.viewer.data.Viewer.name}</p>
</div>
<div class='controls d-flex align-items-center flex-wrap ml-10'>
<button type='button' class='custom-switch bg-transparent border-0' title='Sync List Entries' on:click|stopPropagation>
<input type='checkbox' id='sync-{profile.viewer.data.Viewer.id}' bind:checked={profile.viewer.data.Viewer.sync} on:click={() => toggleSync(profile)} />
<label for='sync-{profile.viewer.data.Viewer.id}'><br/></label>
</button>
<button type='button' class='button logout pt-5 pb-5 pl-5 pr-5 material-symbols-outlined rounded bg-transparent border-0' title='Logout' on:click|stopPropagation={() => dropProfile(profile)}>
logout
</button>
</div>
</button>
{/each}
{#if ($profileAdd || (!$currentProfile && $profiles.length <= 0)) && $profiles.length < 5}
<div class='modal-buttons box pointer border-0 info d-flex flex-column {$profiles.length > 0 ? "" : "rounded-top-10"} {$currentProfile || $profiles.length > 0 ? "align-items-center" : "bg-transparent"}'>
{#if !$currentProfile && $profiles.length <= 0}
<h5 class='modal-title'>Log In</h5>
{/if}
<div class='modal-button mb-10 d-flex justify-content-center flex-row {$currentProfile || $profiles.length > 0 ? "mt-10" : ""}'>
<img class='al-logo position-absolute rounded pointer-events-none' src='./anilist_logo.png' alt='logo' />
<button class='btn anilist w-150' type='button' on:click={confirmAnilist}></button>
</div>
<div class='modal-button mb-10 d-flex justify-content-center flex-row'>
<img class='mal-logo position-absolute rounded pointer-events-none' src='./myanimelist_logo.png' alt='logo' />
<button class='btn myanimelist w-150' type='button' on:click={confirmMAL}></button>
</div>
</div>
{:else if $profiles.length < 5}
<button type='button' class='box pointer border-0 pt-10 pb-10 d-flex align-items-center justify-content-center text-center {$profiles.length > 0 && $currentProfile ? "" : !$currentProfile ? "rounded-bottom-10" : "rounded-top-10"}' on:click={() => { $profileAdd = true }}>
<div class='material-symbols-outlined rounded mr-10'>
add
</div>
<div class='mt-2'>
Add Profile
</div>
</button>
{/if}
{#if $currentProfile}
{#if $profiles.length > 0}
<div class='box pointer border-0 pt-10 pb-10 d-flex align-items-center justify-content-center text-center'>
<div class='custom-switch' title='Must be enabled to sync list entries with other sync enabled profiles.'>
<input type='checkbox' id='sync-{$currentProfile.viewer.data.Viewer.id}' bind:checked={$currentProfile.viewer.data.Viewer.sync} on:click={() => toggleSync($currentProfile)} />
<label for='sync-{$currentProfile.viewer.data.Viewer.id}'>Sync Entries</label>
</div>
</div>
{/if}
<button type='button' class='box pointer border-0 rounded-bottom-10 pt-10 pb-10 d-flex align-items-center justify-content-center text-center' on:click={currentLogout}>
<div class='material-symbols-outlined bg-transparent mr-10'>
logout
</div>
<div class='mt-2'>
Sign Out
</div>
</button>
{/if}
</div>
</div>
</div>
{/if}
</div>
<style>
.close {
top: 4rem !important;
left: unset !important;
right: 2.5rem !important;
}
.logout:hover {
background: #393838 !important;
}
.h-3 {
height: 3rem !important;
}
.mt-2 {
margin-top: .4rem;
}
.mw-400 {
min-width: 35rem;
}
.rounded-top-10 {
border-radius: 3rem 3rem 0 0;
}
.rounded-bottom-10 {
border-radius: 0 0 3rem 3rem;
}
.auth-icon {
position: absolute;
height: 2rem;
margin-right: 15rem;
margin-bottom: 3rem;
}
.box:hover:not(.info) {
background: #272727;
}
.box {
background: #0e0e0e;
width: 100%;
margin-bottom: .3rem;
}
.mal-logo {
height: 2rem;
margin-top: 0.8rem;
}
.al-logo {
height: 1.6rem;
margin-top: 0.82rem;
}
.anilist {
background-color: #283342 !important;
}
.myanimelist {
background-color: #2C51A2 !important;
}
.anilist:hover {
background-color: #46536c !important;
}
.myanimelist:hover {
background-color: #2861d6 !important;
}
</style>

View file

@ -4,8 +4,7 @@
import { settings } from '@/modules/settings.js'
import { toast } from 'svelte-sonner'
import { click } from '@/modules/click.js'
import { login } from './Login.svelte'
import { logout } from './Logout.svelte'
import { profileView } from './Profiles.svelte'
import Helper from '@/modules/helper.js'
import IPC from '@/modules/ipc.js'
@ -44,11 +43,7 @@
let links = [
{
click: () => {
if (Helper.getUser()) {
$logout = true
} else {
$login = true
}
$profileView = true
},
icon: 'login',
text: 'Login',
@ -114,7 +109,7 @@
]
if (Helper.getUser()) {
links[0].image = Helper.getUserAvatar()
links[0].text = 'Logout'
links[0].text = 'Profiles'
}
</script>

View file

@ -247,8 +247,8 @@ class AnilistClient {
}
})
}
// @ts-ignore
if (alToken?.token) options.headers.Authorization = alToken.token
if (variables?.token) options.headers.Authorization = variables.token
else if (alToken?.token) options.headers.Authorization = alToken.token
return this.handleRequest(options)
}
@ -486,9 +486,7 @@ class AnilistClient {
}`
const res = await this.alRequest(query, variables)
await this.updateCache(res.data.MediaListCollection.lists.flatMap(list => list.entries.map(entry => entry.media)))
if (!variables.token) await this.updateCache(res.data.MediaListCollection.lists.flatMap(list => list.entries.map(entry => entry.media)))
return res
}
@ -627,7 +625,7 @@ class AnilistClient {
return this.alRequest(query, variables)
}
entry (variables) {
async entry (variables) {
const query = /* js */`
mutation($lists: [String], $id: Int, $status: MediaListStatus, $episode: Int, $repeat: Int, $score: Int, $startedAt: FuzzyDateInput, $completedAt: FuzzyDateInput) {
SaveMediaListEntry(mediaId: $id, status: $status, progress: $episode, repeat: $repeat, scoreRaw: $score, customLists: $lists, startedAt: $startedAt, completedAt: $completedAt) {
@ -649,18 +647,21 @@ class AnilistClient {
}
}`
return this.alRequest(query, variables)
const res = await this.alRequest(query, variables)
if (!variables.token) this.userLists.value = this.getUserLists({sort: 'UPDATED_TIME_DESC'})
return res
}
delete (variables) {
async delete (variables) {
const query = /* js */`
mutation($id: Int) {
DeleteMediaListEntry(id: $id) {
deleted
}
}`
return this.alRequest(query, variables)
const res = await this.alRequest(query, variables)
if (!variables.token) this.userLists.value = this.getUserLists({sort: 'UPDATED_TIME_DESC'})
return res
}
favourite (variables) {

View file

@ -132,13 +132,30 @@ export default class Helper {
}
static async entry(media, variables) {
let res = await this.getClient().entry(variables)
media.mediaListEntry = res.data.SaveMediaListEntry
let res
if (!variables.token) {
res = await this.getClient().entry(variables)
media.mediaListEntry = res?.data?.SaveMediaListEntry
} else {
if (variables.anilist) {
res = await anilistClient.entry(variables)
} else {
res = await malClient.entry(variables)
}
}
return res
}
static async delete(media) {
return await this.getClient().delete((this.isAniAuth() ? {id: media.mediaListEntry.id} : {idMal: media.idMal}))
static async delete(variables) {
if (!variables.token) {
return await this.getClient().delete(variables)
} else {
if (variables.anilist) {
return await anilistClient.delete(variables)
} else {
return await malClient.delete(variables)
}
}
}
static matchTitle(media, phrase, keys) {
@ -230,27 +247,50 @@ export default class Helper {
Object.assign(variables, this.getFuzzyDate(media, status))
let res
const description = `Title: ${anilistClient.title(media)}\nStatus: ${this.statusName[variables.status]}\nEpisode: ${videoEpisode} / ${media.episodes ? media.episodes : '?'}`
if (this.isAniAuth()) {
res = await anilistClient.alEntry(lists, variables)
} else if (this.isMalAuth()) {
res = await malClient.malEntry(media, variables)
}
this.listToast(res, description, false)
const description = `Title: ${anilistClient.title(media)}\nStatus: ${this.statusName[media.mediaListEntry.status]}\nEpisode: ${videoEpisode} / ${media.episodes ? media.episodes : '?'}`
if (res?.data?.mediaListEntry || res?.data?.SaveMediaListEntry) {
console.log('List Updated: ' + description)
if (this.getUser().sync) { // handle profile syncing
const mediaId = media.id
const profiles = JSON.parse(localStorage.getItem('profiles')) || []
for (const profile of profiles) {
if (profile.viewer?.data?.Viewer.sync) {
let res
if (profile.viewer?.data?.Viewer?.avatar) {
const currentLists = (await anilistClient.getUserLists({userID: profile.viewer.data.Viewer.id, token: profile.token}))?.data?.MediaListCollection?.lists?.flatMap(list => list.entries).find(({ media }) => media.id === mediaId)?.media?.mediaListEntry?.customLists?.filter(list => list.enabled).map(list => list.name) || []
res = await anilistClient.alEntry(currentLists, {...variables, token: profile.token})
} else {
res = await malClient.malEntry(media, {...variables, token: profile.token})
}
this.listToast(res, description, profile)
}
}
}
}
}
static listToast(res, description, profile){
const who = (profile ? ' for ' + profile.viewer.data.Viewer.name + (profile.viewer?.data?.Viewer?.avatar ? ' (AniList)' : ' (MyAnimeList)') : '')
if (res?.data?.mediaListEntry || res?.data?.SaveMediaListEntry) {
console.log('List Updated' + who + ':\n' + description)
if (!profile) {
toast.success('List Updated', {
description,
duration: 6000
})
} else {
const error = `\n${429} - ${codes[429]}`
console.error('Failed to update user list with: ' + description + error)
toast.error('Failed to Update List', {
description: description + error,
duration: 9000
})
}
} else {
const error = `\n${429} - ${codes[429]}`
console.error('Failed to update user list' + who + ' with:\n' + description + error)
toast.error('Failed to Update List' + who, {
description: description + error,
duration: 9000
})
}
}

View file

@ -1,7 +1,7 @@
import { writable } from 'simple-store-svelte'
import { malToken, refreshMalToken } from '@/modules/settings.js'
import { codes } from "@/modules/anilist";
import { codes } from "@/modules/anilist"
import { toast } from 'svelte-sonner'
import Helper from '@/modules/helper.js'
@ -90,14 +90,14 @@ class MALClient {
switch (res.error) {
case 'forbidden':
case 'invalid_token':
if (await refreshMalToken()) { // refresh authorization token as it typically expires every 31 days.
return this.handleRequest(query, options);
if (await refreshMalToken(query.token ? query.token : this.userID.token)) { // refresh authorization token as it typically expires every 31 days.
return this.handleRequest(query, options)
}
throw new Error("NotAuthenticatedError: " + res.message ?? res.error);
throw new Error("NotAuthenticatedError: " + res.message ?? res.error)
case 'invalid_content':
throw new Error(`This Entry is currently pending approval. It can't be saved to mal for now`);
throw new Error(`This Entry is currently pending approval. It can't be saved to mal for now`)
default:
throw new Error(res.message ?? res.error);
throw new Error(res.message ?? res.error)
}
} else if (!res || res.status !== 404) {
throw e
@ -127,7 +127,7 @@ class MALClient {
async malEntry (media, variables) {
variables.idMal = media.idMal
const res = await malClient.entry(variables)
media.mediaListEntry = res.data.SaveMediaListEntry
if (!variables.token) media.mediaListEntry = res.data.SaveMediaListEntry
return res
}
@ -158,19 +158,19 @@ class MALClient {
hasNextPage = false
} else {
offset += limit
await new Promise(resolve => setTimeout(resolve, 1000)); // 1-second delay to prevent too many consecutive requests ip block.
await new Promise(resolve => setTimeout(resolve, 1000)) // 1-second delay to prevent too many consecutive requests ip block.
}
}
// Custom sorting based on original variables.sort value
if (variables.originalSort === 'list_start_date_nan') {
allMediaList.sort((a, b) => {
return new Date(b.node.my_list_status.start_date) - new Date(a.node.my_list_status.start_date);
});
return new Date(b.node.my_list_status.start_date) - new Date(a.node.my_list_status.start_date)
})
} else if (variables.originalSort === 'list_finish_date_nan') {
allMediaList.sort((a, b) => {
return new Date(b.node.my_list_status.finish_date) - new Date(a.node.my_list_status.finish_date);
});
return new Date(b.node.my_list_status.finish_date) - new Date(a.node.my_list_status.finish_date)
})
} else if (variables.originalSort === 'list_progress_nan') {
allMediaList.sort((a, b) => {
return b.node.my_list_status.num_episodes_watched - a.node.my_list_status.num_episodes_watched
@ -201,9 +201,10 @@ class MALClient {
async entry (variables) {
const query = {
type: 'PUT',
path: `anime/${variables.idMal}/my_list_status`
path: `anime/${variables.idMal}/my_list_status`,
token: variables.token
}
const padNumber = (num) => num !== undefined && num !== null ? String(num).padStart(2, '0') : null;
const padNumber = (num) => num !== undefined && num !== null ? String(num).padStart(2, '0') : null
const start_date = variables.startedAt?.year && variables.startedAt.month && variables.startedAt.day ? `${variables.startedAt.year}-${padNumber(variables.startedAt.month)}-${padNumber(variables.startedAt.day)}` : null
const finish_date = variables.completedAt?.year && variables.completedAt.month && variables.completedAt.day ? `${variables.completedAt.year}-${padNumber(variables.completedAt.month)}-${padNumber(variables.completedAt.day)}` : null
const updateData = {
@ -219,9 +220,9 @@ class MALClient {
if (finish_date) {
updateData.finish_date = finish_date
}
const res = await this.malRequest(query, updateData)
this.userLists.value = this.getUserLists({ sort: 'list_updated_at' })
const res = await this.malRequest(query, updateData)
if (!variables.token) this.userLists.value = this.getUserLists({ sort: 'list_updated_at' })
return res ? {
data: {
SaveMediaListEntry: {
@ -241,10 +242,11 @@ class MALClient {
async delete (variables) {
const query = {
type: 'DELETE',
path: `anime/${variables.idMal}/my_list_status`
path: `anime/${variables.idMal}/my_list_status`,
token: variables.token
}
const res = await this.malRequest(query)
this.userLists.value = this.getUserLists({ sort: 'list_updated_at' })
if (!variables.token) this.userLists.value = this.getUserLists({ sort: 'list_updated_at' })
return res
}
}

View file

@ -66,19 +66,18 @@ window.addEventListener('paste', ({ clipboardData }) => {
})
IPC.on('altoken', handleToken)
async function handleToken (token) {
alToken = { token, viewer: null }
const { anilistClient } = await import('./anilist.js')
const viewer = await anilistClient.viewer({ token })
const { anilistClient} = await import('./anilist.js')
const viewer = await anilistClient.viewer({token})
if (!viewer.data?.Viewer) {
toast.error('Failed to sign in with AniList. Please try again.', { description: JSON.stringify(viewer) })
toast.error('Failed to sign in with AniList. Please try again.', {description: JSON.stringify(viewer)})
console.error(viewer)
return
}
const lists = viewer?.data?.Viewer?.mediaListOptions?.animeList?.customLists || []
if (!lists.includes('Watched using Miru')) {
await anilistClient.customList({ lists })
await anilistClient.customList({lists})
}
localStorage.setItem('ALviewer', JSON.stringify({ token, viewer }))
swapProfiles({token, viewer})
location.reload()
}
@ -108,7 +107,6 @@ async function handleMalToken (code, state) {
return
}
const oauth = await response.json()
malToken = { token: oauth.access_token, refresh:oauth.refresh_token, viewer: null }
const viewer = await malClient.viewer(oauth.access_token)
if (!viewer?.data?.Viewer?.id) {
toast.error('Failed to sign in with MyAnimeList. Please try again.', { description: JSON.stringify(viewer) })
@ -117,11 +115,11 @@ async function handleMalToken (code, state) {
} else if (!viewer?.data?.Viewer?.picture) {
viewer.data.Viewer.picture = 'https://cdn.myanimelist.net/images/kaomoji_mal_white.png' // set default image if user doesn't have an image.
}
localStorage.setItem('MALviewer', JSON.stringify({ token: oauth.access_token, refresh: oauth.refresh_token, viewer }))
swapProfiles({ token: oauth.access_token, refresh: oauth.refresh_token, viewer })
location.reload()
}
export async function refreshMalToken () {
export async function refreshMalToken (token) {
const { clientID } = await import('./myanimelist.js')
const response = await fetch('https://myanimelist.net/v1/oauth2/token', {
method: 'POST',
@ -137,12 +135,77 @@ export async function refreshMalToken () {
if (!response.ok) {
toast.error('Failed to re-authenticate with MyAnimeList. You will need to log in again.', { description: JSON.stringify(response.status) })
console.error('Failed to refresh MyAnimeList User Token.', response)
malToken = null
localStorage.removeItem('MALviewer')
if (malToken.token === token) {
swapProfiles(null)
} else {
const profiles = JSON.parse(localStorage.getItem('profiles')) || []
localStorage.setItem('profiles', JSON.stringify(profiles.filter(profile => profile.token !== token)))
}
return
}
const oauth = await response.json()
const viewer = malToken.viewer
malToken = { token: oauth.access_token, refresh:oauth.refresh_token, viewer: viewer }
localStorage.setItem('MALviewer', JSON.stringify({ token: oauth.access_token, refresh: oauth.refresh_token, viewer }))
if (malToken.token === token) {
const viewer = malToken.viewer
malToken = { token: oauth.access_token, refresh:oauth.refresh_token, viewer: viewer }
localStorage.setItem('MALviewer', JSON.stringify({ token: oauth.access_token, refresh: oauth.refresh_token, viewer }))
} else {
let profiles = JSON.parse(localStorage.getItem('profiles')) || []
profiles = profiles.map(profile => {
if (profile.token === token) {
return {...profile, token: oauth.access_token, refresh: oauth.refresh_token}
}
return profile
})
localStorage.setItem('profiles', JSON.stringify(profiles))
}
}
export function swapProfiles(profile) {
let profiles = JSON.parse(localStorage.getItem('profiles')) || []
const currentProfile = isAuthorized()
const newProfile = profile !== null && !profiles.some(p => p.viewer?.data?.Viewer?.id === currentProfile?.viewer?.data?.Viewer?.id)
if (currentProfile) {
const torrent = localStorage.getItem('torrent')
const lastFinished = localStorage.getItem('lastFinished')
const settings = localStorage.getItem('settings')
if (torrent) currentProfile.viewer.data.Viewer.torrent = torrent
if (lastFinished) currentProfile.viewer.data.Viewer.lastFinished = lastFinished
if (settings) currentProfile.viewer.data.Viewer.settings = settings
if (newProfile) profiles.unshift(currentProfile)
}
localStorage.removeItem(alToken ? 'ALviewer' : 'MALviewer')
if (profile === null && profiles.length > 0) {
const firstProfile = profiles.shift()
setViewer(firstProfile)
} else if (profile !== null) {
profiles = profiles.filter(p => p.viewer?.data?.Viewer?.id !== profile.viewer?.data?.Viewer?.id)
setViewer(profile)
} else {
alToken = null
malToken = null
}
localStorage.setItem('profiles', JSON.stringify(profiles))
}
function setViewer (profile) {
const { torrent, lastFinished, settings } = profile.viewer?.data?.Viewer
if (torrent) {
localStorage.setItem('torrent', torrent)
} else if (isAuthorized()) {
localStorage.removeItem('torrent')
}
if (lastFinished) {
localStorage.setItem('lastFinished', lastFinished)
} else if (isAuthorized()) {
localStorage.removeItem('lastFinished')
}
if (settings) {
localStorage.setItem('settings', settings)
} else if (isAuthorized()) {
localStorage.setItem('settings', writable({ ...defaults, ...scopedDefaults}))
}
localStorage.setItem(profile.viewer?.data?.Viewer?.avatar ? 'ALviewer' : 'MALviewer', JSON.stringify(profile))
}

View file

@ -94,6 +94,15 @@ export function generateRandomHexCode (len) {
return hexCode
}
export function generateRandomString(length) {
let string = ''
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
for (let i = 0; i < length; i++) {
string += possible.charAt(Math.floor(Math.random() * possible.length))
}
return string
}
export function matchPhrase(search, phrase, threshold) {
if (!search) return false
const normalizedSearch = search.toLowerCase().replace(/[^\w\s]/g, '')

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View file

@ -34,8 +34,7 @@
import TorrentSettings from './TorrentSettings.svelte'
import InterfaceSettings from './InterfaceSettings.svelte'
import AppSettings from './AppSettings.svelte'
import { login } from '@/components/Login.svelte'
import { logout } from '@/components/Logout.svelte'
import { profileView } from '@/components/Profiles.svelte'
import smoothScroll from '@/modules/scroll.js'
import Helper from '@/modules/helper.js'
@ -70,11 +69,7 @@
}
function loginButton () {
if (Helper.getUser()) {
$logout = true
} else {
$login = true
}
$profileView = true
}
onDestroy(() => {
IPC.off('path', pathListener)
@ -109,7 +104,7 @@
<span class='material-symbols-outlined rounded mr-10'>
<img src={Helper.getUserAvatar()} class='h-30 rounded' alt='logo' />
</span>
<div class='font-size-16 login-image-text'>Logout</div>
<div class='font-size-16 login-image-text'>Profiles</div>
{:else}
<span class='material-symbols-outlined font-size-24 pr-10 d-inline-flex justify-content-center align-items-center'>login</span>
<div class='font-size-16'>Login</div>

View file

@ -38,7 +38,7 @@
if (state.save || state.delete) {
await new Promise(resolve => setTimeout(resolve, 300)) // allows time for animation to play
if (state.save) {
await saveChanges()
await saveEntry()
} else if (state.delete) {
await deleteEntry()
}
@ -55,29 +55,36 @@
episode = 0
status = 'NOT IN LIST'
if (media.mediaListEntry) {
const res = await Helper.delete(media)
const res = await Helper.delete(Helper.isAniAuth() ? {id: media.mediaListEntry.id} : {idMal: media.idMal})
const description = `${anilistClient.title(media)} has been deleted from your list.`
if (res) {
console.log('List Updated: ' + description)
toast.warning('List Updated', {
description,
duration: 6000
})
media.mediaListEntry = undefined
} else {
const error = `\n${429} - ${codes[429]}`
console.error('Failed to delete title from user list with: ' + description + error)
toast.error('Failed to Delete Title', {
description: description + error,
duration: 9000
})
printToast(res, description, false, false)
if (Helper.getUser().sync) { // handle profile syncing
const mediaId = media.id
const profiles = JSON.parse(localStorage.getItem('profiles')) || []
for (const profile of profiles) {
if (profile.viewer?.data?.Viewer.sync) {
const anilist = profile.viewer?.data?.Viewer?.avatar
const listId = (anilist ? {id: (await anilistClient.getUserLists({userID: profile.viewer.data.Viewer.id, token: profile.token}))?.data?.MediaListCollection?.lists?.flatMap(list => list.entries).find(({ media }) => media.id === mediaId)?.media?.mediaListEntry?.id} : {idMal: media.idMal})
if (listId?.id || listId?.idMal) {
const res = await Helper.delete({...listId, token: profile.token, anilist})
printToast(res, description, false, profile)
}
}
}
}
if (res) media.mediaListEntry = undefined
}
}
async function saveChanges() {
async function saveEntry() {
if (!status.includes('NOT IN LIST')) {
const fuzzyDate = Helper.getFuzzyDate(media, status)
const lists = media.mediaListEntry?.customLists.filter(list => list.enabled).map(list => list.name) || []
if (!lists.includes('Watched using Miru')) {
lists.push('Watched using Miru')
}
const variables = {
id: media.id,
idMal: media.idMal,
@ -85,30 +92,59 @@
episode,
score: Helper.isAniAuth() ? (score * 10) : score, // AniList score scale is out of 100, others use a scale of 10.
repeat: media.mediaListEntry?.repeat || 0,
lists: media.mediaListEntry?.customLists?.filter(list => list.enabled).map(list => list.name) || [],
lists,
...fuzzyDate
}
let res = await Helper.entry(media, variables)
const description = `Title: ${anilistClient.title(media)}\nStatus: ${Helper.statusName[media.mediaListEntry.status]}\nEpisode: ${episode} / ${totalEpisodes}${score !== 0 ? `\nYour Score: ${score}` : ''}`
if (res?.data?.SaveMediaListEntry) {
console.log('List Updated: ' + description)
toast.success('List Updated', {
description,
duration: 6000
})
} else {
const error = `\n${429} - ${codes[429]}`
console.error('Failed to update user list with: ' + description + error)
toast.error('Failed to Update List', {
description: description + error,
duration: 9000
})
const description = `Title: ${anilistClient.title(media)}\nStatus: ${Helper.statusName[status]}\nEpisode: ${episode} / ${totalEpisodes}${score !== 0 ? `\nYour Score: ${score}` : ''}`
printToast(res, description, true, false)
if (Helper.getUser().sync) { // handle profile syncing
const mediaId = media.id
const profiles = JSON.parse(localStorage.getItem('profiles')) || []
for (const profile of profiles) {
if (profile.viewer?.data?.Viewer.sync) {
const anilist = profile.viewer?.data?.Viewer?.avatar
const currentLists = (anilist ? (await anilistClient.getUserLists({userID: profile.viewer.data.Viewer.id, token: profile.token}))?.data?.MediaListCollection?.lists?.flatMap(list => list.entries).find(({ media }) => media.id === mediaId)?.media?.mediaListEntry?.customLists?.filter(list => list.enabled).map(list => list.name) || [] : lists)
if (!currentLists.includes('Watched using Miru')) {
currentLists.push('Watched using Miru')
}
const res = await Helper.entry(media, { ...variables, lists: currentLists, score: (anilist ? (score * 10) : score), token: profile.token, anilist })
printToast(res, description, true, profile)
}
}
}
} else {
await deleteEntry()
}
}
function printToast(res, description, save, profile) {
const who = (profile ? ' for ' + profile.viewer.data.Viewer.name + (profile.viewer?.data?.Viewer?.avatar ? ' (AniList)' : ' (MyAnimeList)') : '')
if ((save && res?.data?.SaveMediaListEntry) || (!save && res)) {
console.log('List Updated' + who + ':\n' + description)
if (!profile) {
if (save) {
toast.success('List Updated', {
description,
duration: 6000
})
} else {
toast.warning('List Updated', {
description,
duration: 9000
})
}
}
} else {
const error = `\n${429} - ${codes[429]}`
console.error('Failed to ' + (save ? 'update' : 'delete title from') + ' user list' + who + ' with:\n' + description + error)
toast.error('Failed to ' + (save ? 'Update' : 'Delete') + ' List' + who, {
description: description + error,
duration: 9000
})
}
}
/**
* @param {Event & { currentTarget: HTMLInputElement }} event
*/