mirror of
https://github.com/NoCrypt/migu.git
synced 2026-04-17 23:01:23 +00:00
feat: MyAnimeList login
This commit is contained in:
parent
16e6a68bb3
commit
b970ed6282
29 changed files with 1054 additions and 220 deletions
|
|
@ -43,6 +43,7 @@ IPC.on('notification', noti => {
|
|||
// schema: miru://key/value
|
||||
const protocolMap = {
|
||||
auth: token => sendToken(token),
|
||||
malauth: token => sendMalToken(token),
|
||||
anime: id => IPC.emit('open-anime', id),
|
||||
w2g: link => IPC.emit('w2glink', link),
|
||||
schedule: () => IPC.emit('schedule'),
|
||||
|
|
@ -64,6 +65,17 @@ function sendToken (line) {
|
|||
}
|
||||
}
|
||||
|
||||
function sendMalToken (line) {
|
||||
let code = line.split('code=')[1].split('&state')[0]
|
||||
let state = line.split('&state=')[1]
|
||||
if (code && state) {
|
||||
if (code.endsWith('/')) code = code.slice(0, -1)
|
||||
if (state.endsWith('/')) state = state.slice(0, -1)
|
||||
if (state.includes('%')) state = decodeURIComponent(state)
|
||||
IPC.emit('maltoken', code, state)
|
||||
}
|
||||
}
|
||||
|
||||
App.getLaunchUrl().then(res => {
|
||||
if (location.hash !== '#skipAlLogin') {
|
||||
location.hash = '#skipAlLogin'
|
||||
|
|
|
|||
103
common/components/Login.svelte
Normal file
103
common/components/Login.svelte
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<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}> × </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>
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
<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()
|
||||
}
|
||||
|
|
@ -29,7 +31,7 @@
|
|||
<button class='close pointer z-30 top-20 right-0 position-absolute' type='button' use:click={close}> × </button>
|
||||
<h5 class='modal-title'>Log Out</h5>
|
||||
<p>
|
||||
Are You Sure You Want To Sign Out?
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
import { click } from '@/modules/click.js'
|
||||
import { page } from '@/App.svelte'
|
||||
import { toast } from 'svelte-sonner'
|
||||
import Helper from '@/modules/helper.js'
|
||||
|
||||
export let search
|
||||
let searchTextInput = {
|
||||
|
|
@ -148,6 +149,10 @@
|
|||
if (list.includes(selectedValue) && (!search[searchKey] || !search[searchKey].includes(selectedValue))) {
|
||||
search[searchKey] = search[searchKey] ? [...search[searchKey], selectedValue] : [selectedValue]
|
||||
searchTextInput[searchKey] = null
|
||||
function clearTags() { // cannot specify genre and tag filtering with user specific sorting options when using alternative authentication.
|
||||
if (!Helper.isAniAuth() && Helper.isUserSort(search)) {
|
||||
search.genre = ''
|
||||
search.tag = ''
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -206,7 +211,7 @@
|
|||
bind:value={searchTextInput.genre}
|
||||
on:change={(event) => filterList(event, 'genre')}
|
||||
data-option='search'
|
||||
disabled={search.disableSearch}
|
||||
disabled={search.disableSearch || (!Helper.isAniAuth() && Helper.isUserSort(search))}
|
||||
placeholder='Any'
|
||||
list='search-genre'/>
|
||||
</div>
|
||||
|
|
@ -229,7 +234,7 @@
|
|||
bind:value={searchTextInput.tag}
|
||||
on:change={(event) => filterList(event, 'tag')}
|
||||
data-option='search'
|
||||
disabled={search.disableSearch}
|
||||
disabled={search.disableSearch || (!Helper.isAniAuth() && Helper.isUserSort(search))}
|
||||
placeholder='Any'
|
||||
list='search-tag'/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<script>
|
||||
import { getContext } from 'svelte'
|
||||
import { anilistClient } from '@/modules/anilist.js'
|
||||
import { media } from '../views/Player/MediaHandler.svelte'
|
||||
import { platformMap } from '@/views/Settings/Settings.svelte'
|
||||
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 Helper from '@/modules/helper.js'
|
||||
import IPC from '@/modules/ipc.js'
|
||||
|
||||
let wasUpdated = false
|
||||
|
|
@ -44,20 +44,14 @@
|
|||
let links = [
|
||||
{
|
||||
click: () => {
|
||||
if (anilistClient.userID?.viewer?.data?.Viewer) {
|
||||
if (Helper.getUser()) {
|
||||
$logout = true
|
||||
} else {
|
||||
IPC.emit('open', 'https://anilist.co/api/v2/oauth/authorize?client_id=4254&response_type=token') // Change redirect_url to miru://auth
|
||||
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
|
||||
})
|
||||
}
|
||||
$login = true
|
||||
}
|
||||
},
|
||||
icon: 'login',
|
||||
text: 'Login With AniList',
|
||||
text: 'Login',
|
||||
css: 'mt-auto'
|
||||
},
|
||||
{
|
||||
|
|
@ -116,8 +110,8 @@
|
|||
text: 'Settings'
|
||||
}
|
||||
]
|
||||
if (anilistClient.userID?.viewer?.data?.Viewer) {
|
||||
links[0].image = anilistClient.userID.viewer.data.Viewer.avatar.medium
|
||||
if (Helper.getUser()) {
|
||||
links[0].image = Helper.getUserAvatar()
|
||||
links[0].text = 'Logout'
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
export let card
|
||||
|
||||
export let variables = null
|
||||
const type = card.type || $settings.cards
|
||||
</script>
|
||||
|
||||
|
|
@ -29,7 +30,7 @@
|
|||
<FullSkeletonCard />
|
||||
{:then media}
|
||||
{#if media}
|
||||
<FullCard media={anilistClient.mediaCache[media.id]} />
|
||||
<FullCard media={anilistClient.mediaCache[media.id]} {variables} />
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
|
|
@ -39,7 +40,7 @@
|
|||
<SkeletonCard />
|
||||
{:then media}
|
||||
{#if media}
|
||||
<SmallCard media={anilistClient.mediaCache[media.id]} />
|
||||
<SmallCard media={anilistClient.mediaCache[media.id]} {variables} />
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import AudioLabel from '@/views/ViewAnime/AudioLabel.svelte'
|
||||
/** @type {import('@/modules/al.d.ts').Media} */
|
||||
export let media
|
||||
export let variables = null
|
||||
|
||||
const view = getContext('view')
|
||||
function viewMedia () {
|
||||
|
|
@ -15,7 +16,7 @@
|
|||
</script>
|
||||
|
||||
<div class='d-flex px-20 py-10 position-relative justify-content-center' use:click={viewMedia}>
|
||||
<div class='card m-0 p-0 overflow-hidden pointer content-visibility-auto full-card'
|
||||
<div class='card m-0 p-0 overflow-hidden pointer content-visibility-auto full-card' class:opacity-half={variables?.continueWatching && Helper.isMalAuth() && media?.status !== 'FINISHED' && media?.mediaListEntry?.progress >= media?.nextAiringEpisode?.episode - 1}
|
||||
style:--color={media.coverImage.color || '#1890ff'}>
|
||||
<div class='row h-full'>
|
||||
<div class='col-4 img-col d-inline-block position-relative'>
|
||||
|
|
@ -95,6 +96,9 @@
|
|||
}
|
||||
.details span + span::before {
|
||||
content: ' • ';
|
||||
.opacity-half {
|
||||
opacity: 30%;
|
||||
}
|
||||
white-space: normal;
|
||||
}
|
||||
.card {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
import { page } from '@/App.svelte'
|
||||
/** @type {import('@/modules/al.d.ts').Media} */
|
||||
export let media
|
||||
export let variables = null
|
||||
let preview = false
|
||||
|
||||
const view = getContext('view')
|
||||
|
|
@ -24,7 +25,7 @@
|
|||
{#if preview}
|
||||
<PreviewCard {media} />
|
||||
{/if}
|
||||
<div class='item small-card d-flex flex-column h-full pointer content-visibility-auto'>
|
||||
<div class='item small-card d-flex flex-column h-full pointer content-visibility-auto' class:opacity-half={variables?.continueWatching && Helper.isMalAuth() && media?.status !== 'FINISHED' && media?.mediaListEntry?.progress >= media?.nextAiringEpisode?.episode - 1}>
|
||||
{#if $page === 'schedule'}
|
||||
<div class='w-full text-center pb-10'>
|
||||
{#if media.airingSchedule?.nodes?.[0]?.airingAt}
|
||||
|
|
@ -65,7 +66,9 @@
|
|||
z-index: 30;
|
||||
/* fixes transform scaling on click causing z-index issues */
|
||||
}
|
||||
|
||||
.opacity-half {
|
||||
opacity: 30%;
|
||||
}
|
||||
.title {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import lavenshtein from 'js-levenshtein'
|
|||
import { writable } from 'simple-store-svelte'
|
||||
import Bottleneck from 'bottleneck'
|
||||
|
||||
import { alToken } from '@/modules/settings.js'
|
||||
import { alToken, settings } from '@/modules/settings.js'
|
||||
import { toast } from 'svelte-sonner'
|
||||
import { sleep } from './util.js'
|
||||
import Helper from '@/modules/helper.js'
|
||||
import IPC from '@/modules/ipc.js'
|
||||
|
||||
export const codes = {
|
||||
|
|
@ -136,6 +137,7 @@ relations {
|
|||
id,
|
||||
title {userPreferred},
|
||||
coverImage {medium},
|
||||
idMal,
|
||||
type,
|
||||
status,
|
||||
format,
|
||||
|
|
@ -361,50 +363,13 @@ class AnilistClient {
|
|||
return Object.entries(searchResults).map(([filename, id]) => [filename, search.data.Page.media.find(media => media.id === id)])
|
||||
}
|
||||
|
||||
async alEntry (filemedia) {
|
||||
// check if values exist
|
||||
if (filemedia.media && alToken) {
|
||||
const { media, failed } = filemedia
|
||||
|
||||
if (failed) return
|
||||
if (media.status !== 'FINISHED' && media.status !== 'RELEASING') return
|
||||
|
||||
// check if media can even be watched, ex: it was resolved incorrectly
|
||||
// some anime/OVA's can have a single episode, or some movies can have multiple episodes
|
||||
const singleEpisode = ((!media.episodes && (Number(filemedia.episode) === 1 || isNaN(Number(filemedia.episode)))) || (media.format === 'MOVIE' && media.episodes === 1)) && 1 // movie check
|
||||
const videoEpisode = Number(filemedia.episode) || singleEpisode
|
||||
const mediaEpisode = media.episodes || media.nextAiringEpisode?.episode || singleEpisode
|
||||
|
||||
if (!videoEpisode || !mediaEpisode) return
|
||||
// check episode range, safety check if `failed` didn't catch this
|
||||
if (videoEpisode > mediaEpisode) return
|
||||
|
||||
const lists = media.mediaListEntry?.customLists.filter(list => list.enabled).map(list => list.name) || []
|
||||
|
||||
const status = media.mediaListEntry?.status === 'REPEATING' ? 'REPEATING' : 'CURRENT'
|
||||
const progress = media.mediaListEntry?.progress
|
||||
|
||||
// check user's own watch progress
|
||||
if (progress > videoEpisode) return
|
||||
if (progress === videoEpisode && videoEpisode !== mediaEpisode && !singleEpisode) return
|
||||
|
||||
const variables = {
|
||||
repeat: media.mediaListEntry?.repeat || 0,
|
||||
id: media.id,
|
||||
status,
|
||||
episode: videoEpisode,
|
||||
lists
|
||||
}
|
||||
if (videoEpisode === mediaEpisode) {
|
||||
variables.status = 'COMPLETED'
|
||||
if (media.mediaListEntry?.status === 'REPEATING') variables.repeat = media.mediaListEntry.repeat + 1
|
||||
}
|
||||
if (!lists.includes('Watched using Miru')) {
|
||||
variables.lists.push('Watched using Miru')
|
||||
}
|
||||
await this.entry(variables)
|
||||
this.userLists.value = this.getUserLists()
|
||||
async alEntry (lists, variables) {
|
||||
if (!lists.includes('Watched using Miru')) {
|
||||
variables.lists.push('Watched using Miru')
|
||||
}
|
||||
const res = await this.entry(variables)
|
||||
this.userLists.value = this.getUserLists({ sort: 'UPDATED_TIME_DESC' })
|
||||
return res
|
||||
}
|
||||
|
||||
async searchName (variables = {}) {
|
||||
|
|
@ -424,8 +389,7 @@ class AnilistClient {
|
|||
|
||||
/** @type {import('./al.d.ts').PagedQuery<{media: import('./al.d.ts').Media[]}>} */
|
||||
const res = await this.alRequest(query, variables)
|
||||
|
||||
this.updateCache(res.data.Page.media)
|
||||
await this.updateCache(res.data.Page.media)
|
||||
|
||||
return res
|
||||
}
|
||||
|
|
@ -441,19 +405,19 @@ class AnilistClient {
|
|||
/** @type {import('./al.d.ts').Query<{Media: import('./al.d.ts').Media}>} */
|
||||
const res = await this.alRequest(query, variables)
|
||||
|
||||
this.updateCache([res.data.Media])
|
||||
await this.updateCache([res.data.Media])
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
async searchIDS (variables) {
|
||||
const query = /* js */`
|
||||
query($id: [Int], $page: Int, $perPage: Int, $status: [MediaStatus], $onList: Boolean, $sort: [MediaSort], $search: String, $season: MediaSeason, $year: Int, $genre: [String], $tag: [String], $format: MediaFormat) {
|
||||
query($id: [Int], $idMal: [Int], $page: Int, $perPage: Int, $status: [MediaStatus], $onList: Boolean, $sort: [MediaSort], $search: String, $season: MediaSeason, $year: Int, $genre: [String], $tag: [String], $format: MediaFormat) {
|
||||
Page(page: $page, perPage: $perPage) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
},
|
||||
media(id_in: $id, type: ANIME, status_in: $status, onList: $onList, search: $search, sort: $sort, season: $season, seasonYear: $year, genre_in: $genre, tag_in: $tag, format: $format) {
|
||||
media(id_in: $id, idMal_in: $idMal, type: ANIME, status_in: $status, onList: $onList, search: $search, sort: $sort, season: $season, seasonYear: $year, genre_in: $genre, tag_in: $tag, format: $format) {
|
||||
${queryObjects}
|
||||
}
|
||||
}
|
||||
|
|
@ -462,7 +426,7 @@ class AnilistClient {
|
|||
/** @type {import('./al.d.ts').PagedQuery<{media: import('./al.d.ts').Media[]}>} */
|
||||
const res = await this.alRequest(query, variables)
|
||||
|
||||
this.updateCache(res.data.Page.media)
|
||||
await this.updateCache(res.data.Page.media)
|
||||
|
||||
return res
|
||||
}
|
||||
|
|
@ -532,9 +496,8 @@ class AnilistClient {
|
|||
|
||||
/** @returns {Promise<import('./al.d.ts').Query<{ MediaListCollection: import('./al.d.ts').MediaListCollection }>>} */
|
||||
async getUserLists (variables) {
|
||||
const userId = this.userID?.viewer?.data?.Viewer.id
|
||||
variables.id = userId
|
||||
variables.sort = variables.sort?.replace('USER_SCORE_DESC', 'SCORE_DESC');
|
||||
variables.id = this.userID?.viewer?.data?.Viewer.id
|
||||
variables.sort = variables.sort?.replace('USER_SCORE_DESC', 'SCORE_DESC')
|
||||
const query = /* js */`
|
||||
query($id: Int, $sort: [MediaListSort]) {
|
||||
MediaListCollection(userId: $id, type: ANIME, sort: $sort, forceSingleCompletedList: true) {
|
||||
|
|
@ -551,7 +514,7 @@ class AnilistClient {
|
|||
|
||||
const res = await this.alRequest(query, variables)
|
||||
|
||||
this.updateCache(res.data.MediaListCollection.lists.flatMap(list => list.entries.map(entry => entry.media)));
|
||||
await this.updateCache(res.data.MediaListCollection.lists.flatMap(list => list.entries.map(entry => entry.media)))
|
||||
|
||||
return res
|
||||
}
|
||||
|
|
@ -594,7 +557,7 @@ class AnilistClient {
|
|||
/** @type {import('./al.d.ts').PagedQuery<{ airingSchedules: { timeUntilAiring: number, airingAt: number, episode: number, media: import('./al.d.ts').Media}[]}>} */
|
||||
const res = await this.alRequest(query, variables)
|
||||
|
||||
this.updateCache(res.data.Page.airingSchedules?.map(({ media }) => media))
|
||||
await this.updateCache(res.data.Page.airingSchedules?.map(({media}) => media))
|
||||
|
||||
return res
|
||||
}
|
||||
|
|
@ -617,12 +580,12 @@ class AnilistClient {
|
|||
async search (variables = {}) {
|
||||
variables.sort ||= 'SEARCH_MATCH'
|
||||
const query = /* js */`
|
||||
query($page: Int, $perPage: Int, $sort: [MediaSort], $search: String, $onList: Boolean, $status: MediaStatus, $status_not: MediaStatus, $season: MediaSeason, $year: Int, $genre: [String], $tag: [String], $format: MediaFormat, $id_not: [Int]) {
|
||||
query($page: Int, $perPage: Int, $sort: [MediaSort], $search: String, $onList: Boolean, $status: MediaStatus, $status_not: MediaStatus, $season: MediaSeason, $year: Int, $genre: [String], $tag: [String], $format: MediaFormat, $id_not: [Int], $idMal_not: [Int], $idMal: [Int]) {
|
||||
Page(page: $page, perPage: $perPage) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
},
|
||||
media(id_not_in: $id_not, type: ANIME, search: $search, sort: $sort, onList: $onList, status: $status, status_not: $status_not, season: $season, seasonYear: $year, genre_in: $genre, tag_in: $tag, format: $format, format_not: MUSIC) {
|
||||
media(id_not_in: $id_not, idMal_not_in: $idMal_not, idMal_in: $idMal, type: ANIME, search: $search, sort: $sort, onList: $onList, status: $status, status_not: $status_not, season: $season, seasonYear: $year, genre_in: $genre, tag_in: $tag, format: $format, format_not: MUSIC) {
|
||||
${queryObjects}
|
||||
}
|
||||
}
|
||||
|
|
@ -631,7 +594,7 @@ class AnilistClient {
|
|||
/** @type {import('./al.d.ts').PagedQuery<{media: import('./al.d.ts').Media[]}>} */
|
||||
const res = await this.alRequest(query, variables)
|
||||
|
||||
this.updateCache(res.data.Page.media)
|
||||
await this.updateCache(res.data.Page.media)
|
||||
|
||||
return res
|
||||
}
|
||||
|
|
@ -672,13 +635,23 @@ class AnilistClient {
|
|||
|
||||
entry (variables) {
|
||||
const query = /* js */`
|
||||
mutation($lists: [String], $id: Int, $status: MediaListStatus, $episode: Int, $repeat: Int, $score: Int) {
|
||||
SaveMediaListEntry(mediaId: $id, status: $status, progress: $episode, repeat: $repeat, scoreRaw: $score, customLists: $lists) {
|
||||
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) {
|
||||
id,
|
||||
status,
|
||||
progress,
|
||||
score,
|
||||
repeat
|
||||
repeat,
|
||||
startedAt {
|
||||
year,
|
||||
month,
|
||||
day
|
||||
},
|
||||
completedAt {
|
||||
year,
|
||||
month,
|
||||
day
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
|
|
@ -718,8 +691,15 @@ class AnilistClient {
|
|||
}
|
||||
|
||||
/** @param {import('./al.d.ts').Media[]} medias */
|
||||
updateCache (medias) {
|
||||
this.mediaCache = { ...this.mediaCache, ...Object.fromEntries(medias.map(media => [media.id, media])) }
|
||||
async updateCache (medias) {
|
||||
for (const media of medias) {
|
||||
if (!alToken) {
|
||||
// attach any alternative authorization userList information
|
||||
await Helper.fillEntry(media)
|
||||
}
|
||||
// Update the cache
|
||||
this.mediaCache[media.id] = media
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import clipboard from './clipboard.js'
|
|||
import { search, key } from '@/views/Search.svelte'
|
||||
|
||||
import { playAnime } from '@/views/TorrentSearch/TorrentModal.svelte'
|
||||
import Helper from "@/modules/helper.js"
|
||||
|
||||
const imageRx = /\.(jpeg|jpg|gif|png|webp)/i
|
||||
|
||||
|
|
@ -227,12 +228,17 @@ export async function playMedia (media) {
|
|||
}
|
||||
|
||||
export function setStatus (status, other = {}, media) {
|
||||
const fuzzyDate = Helper.getFuzzyDate(media, status)
|
||||
const variables = {
|
||||
id: media.id,
|
||||
idMal: media.idMal,
|
||||
status,
|
||||
score: media.mediaListEntry?.score || 0,
|
||||
repeat: media.mediaListEntry?.repeat || 0,
|
||||
...fuzzyDate,
|
||||
...other
|
||||
}
|
||||
return anilistClient.entry(variables)
|
||||
return Helper.entry(media, variables)
|
||||
}
|
||||
|
||||
const episodeMetadataMap = {}
|
||||
|
|
|
|||
310
common/modules/helper.js
Normal file
310
common/modules/helper.js
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import { alToken, malToken, isAuthorized } from '@/modules/settings.js'
|
||||
import { anilistClient, codes } from '@/modules/anilist.js'
|
||||
import { malClient } from '@/modules/myanimelist.js'
|
||||
import { malDubs } from "@/modules/animedubs.js"
|
||||
import { toast } from 'svelte-sonner'
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
export default class Helper {
|
||||
|
||||
static statusName = {
|
||||
CURRENT: 'Watching',
|
||||
PLANNING: 'Planning',
|
||||
COMPLETED: 'Completed',
|
||||
PAUSED: 'Paused',
|
||||
DROPPED: 'Dropped',
|
||||
REPEATING: 'Rewatching'
|
||||
}
|
||||
|
||||
static sortMap(sort) {
|
||||
switch(sort) {
|
||||
case 'UPDATED_TIME_DESC':
|
||||
return 'list_updated_at'
|
||||
case 'STARTED_ON_DESC':
|
||||
return 'list_start_date_nan' // doesn't exist, therefore we use custom logic.
|
||||
case 'FINISHED_ON_DESC':
|
||||
return 'list_finish_date_nan' // doesn't exist, therefore we use custom logic.
|
||||
case 'PROGRESS_DESC':
|
||||
return 'list_progress_nan' // doesn't exist, therefore we use custom logic.
|
||||
case 'USER_SCORE_DESC':
|
||||
return 'list_score'
|
||||
}
|
||||
}
|
||||
|
||||
static statusMap(status) {
|
||||
switch(status) {
|
||||
// MyAnimeList to AniList
|
||||
case 'watching':
|
||||
return 'CURRENT'
|
||||
case 'rewatching':
|
||||
return 'REPEATING' // rewatching is determined by is_rewatching boolean (no individual list)
|
||||
case 'plan_to_watch':
|
||||
return 'PLANNING'
|
||||
case 'completed':
|
||||
return 'COMPLETED'
|
||||
case 'dropped':
|
||||
return 'DROPPED'
|
||||
case 'on_hold':
|
||||
return 'PAUSED'
|
||||
// AniList to MyAnimeList
|
||||
case 'CURRENT':
|
||||
return 'watching'
|
||||
case 'PLANNING':
|
||||
return 'plan_to_watch'
|
||||
case 'COMPLETED':
|
||||
return 'completed'
|
||||
case 'DROPPED':
|
||||
return 'dropped'
|
||||
case 'PAUSED':
|
||||
return 'on_hold'
|
||||
case 'REPEATING':
|
||||
return 'watching' // repeating is determined by is_rewatching boolean (no individual list)
|
||||
}
|
||||
}
|
||||
|
||||
static airingMap(status) {
|
||||
switch(status) {
|
||||
case 'finished_airing':
|
||||
return 'FINISHED'
|
||||
case 'currently_airing':
|
||||
return 'RELEASING'
|
||||
case 'not_yet_aired':
|
||||
return 'NOT_YET_RELEASED'
|
||||
}
|
||||
}
|
||||
|
||||
static getFuzzyDate(media, status) {
|
||||
const updatedDate = new Date()
|
||||
const fuzzyDate = {
|
||||
year: updatedDate.getFullYear(),
|
||||
month: updatedDate.getMonth() + 1,
|
||||
day: updatedDate.getDate()
|
||||
}
|
||||
const startedAt = media.mediaListEntry?.startedAt?.year && media.mediaListEntry?.startedAt?.month && media.mediaListEntry?.startedAt?.day ? media.mediaListEntry.startedAt : (['CURRENT', 'REPEATING'].includes(status) ? fuzzyDate : undefined)
|
||||
const completedAt = media.mediaListEntry?.completedAt?.year && media.mediaListEntry?.completedAt?.month && media.mediaListEntry?.completedAt?.day ? media.mediaListEntry.completedAt : (status === 'COMPLETED' ? fuzzyDate : undefined)
|
||||
return {startedAt, completedAt}
|
||||
}
|
||||
|
||||
static sanitiseObject (object = {}) {
|
||||
const safe = {}
|
||||
for (const [key, value] of Object.entries(object)) {
|
||||
if (value) safe[key] = value
|
||||
}
|
||||
return safe
|
||||
}
|
||||
|
||||
static isAniAuth() {
|
||||
return alToken
|
||||
}
|
||||
|
||||
static isMalAuth() {
|
||||
return malToken
|
||||
}
|
||||
|
||||
static isAuthorized() {
|
||||
return isAuthorized()
|
||||
}
|
||||
|
||||
static getClient() {
|
||||
return this.isAniAuth() ? anilistClient : malClient
|
||||
}
|
||||
|
||||
static getUser() {
|
||||
return this.getClient().userID?.viewer?.data?.Viewer
|
||||
}
|
||||
|
||||
static getUserAvatar() {
|
||||
if (anilistClient.userID?.viewer?.data?.Viewer) {
|
||||
return anilistClient.userID.viewer.data.Viewer.avatar.medium
|
||||
} else if (malClient.userID?.viewer?.data?.Viewer) {
|
||||
return malClient.userID.viewer.data.Viewer.picture
|
||||
}
|
||||
}
|
||||
|
||||
static isUserSort(variables) {
|
||||
return ['UPDATED_TIME_DESC', 'STARTED_ON_DESC', 'FINISHED_ON_DESC', 'PROGRESS_DESC', 'USER_SCORE_DESC'].includes(variables?.sort)
|
||||
}
|
||||
|
||||
static userLists(variables) {
|
||||
return (!this.isUserSort(variables) || variables.sort === 'UPDATED_TIME_DESC')
|
||||
? this.getClient().userLists.value
|
||||
: this.getClient().getUserLists({sort: (this.isAniAuth() ? variables.sort : this.sortMap(variables.sort))})
|
||||
}
|
||||
|
||||
static async entry(media, variables) {
|
||||
let res = await this.getClient().entry(variables)
|
||||
media.mediaListEntry = res.data.SaveMediaListEntry
|
||||
return res
|
||||
}
|
||||
|
||||
static async delete(media) {
|
||||
return await this.getClient().delete(...(this.isAniAuth() ? {id: media.mediaListEntry.id} : {idMal: media.idMal}))
|
||||
}
|
||||
|
||||
static matchTitle(media, phrase, keys) {
|
||||
if (!phrase) {
|
||||
return true
|
||||
}
|
||||
const options = {
|
||||
includeScore: true,
|
||||
threshold: 0.4,
|
||||
keys: keys
|
||||
}
|
||||
return new Fuse([media], options).search(phrase).length > 0
|
||||
}
|
||||
|
||||
/*
|
||||
* This exists to fill in any queried AniList media with the user list media data from alternate authorizations.
|
||||
*/
|
||||
static async fillEntry(media) {
|
||||
if (this.isMalAuth()) {
|
||||
const userLists = await malClient.userLists.value
|
||||
const malEntry = userLists.data.MediaList.find(({ node }) => node.id === media.idMal)
|
||||
if (malEntry) {
|
||||
const start_date = malEntry.node.my_list_status.start_date ? new Date(malEntry.node.my_list_status.start_date) : undefined
|
||||
const finish_date = malEntry.node.my_list_status.finish_date ? new Date(malEntry.node.my_list_status.finish_date) : undefined
|
||||
const startedAt = start_date ? {
|
||||
year: start_date.getFullYear(),
|
||||
month: start_date.getMonth() + 1,
|
||||
day: start_date.getDate()
|
||||
} : undefined
|
||||
const completedAt = finish_date ? {
|
||||
year: finish_date.getFullYear(),
|
||||
month: finish_date.getMonth() + 1,
|
||||
day: finish_date.getDate()
|
||||
} : undefined
|
||||
media.mediaListEntry = {
|
||||
id: media.id,
|
||||
progress: malEntry.node.my_list_status.num_episodes_watched,
|
||||
repeat: malEntry.node.my_list_status.number_times_rewatched,
|
||||
status: this.statusMap(malEntry.node.my_list_status?.is_rewatching ? 'rewatching' : malEntry.node.my_list_status?.status),
|
||||
customLists: [],
|
||||
score: malEntry.node.my_list_status.score,
|
||||
startedAt,
|
||||
completedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async updateEntry(filemedia) {
|
||||
// check if values exist
|
||||
if (filemedia.media && this.isAuthorized()) {
|
||||
const { media, failed } = filemedia
|
||||
|
||||
if (failed) return
|
||||
if (media.status !== 'FINISHED' && media.status !== 'RELEASING') return
|
||||
|
||||
// check if media can even be watched, ex: it was resolved incorrectly
|
||||
// some anime/OVA's can have a single episode, or some movies can have multiple episodes
|
||||
const singleEpisode = ((!media.episodes && (Number(filemedia.episode) === 1 || isNaN(Number(filemedia.episode)))) || (media.format === 'MOVIE' && media.episodes === 1)) && 1 // movie check
|
||||
const videoEpisode = Number(filemedia.episode) || singleEpisode
|
||||
const mediaEpisode = media.episodes || media.nextAiringEpisode?.episode || singleEpisode
|
||||
|
||||
if (!videoEpisode || !mediaEpisode) return
|
||||
// check episode range, safety check if `failed` didn't catch this
|
||||
if (videoEpisode > mediaEpisode) return
|
||||
|
||||
const lists = media.mediaListEntry?.customLists.filter(list => list.enabled).map(list => list.name) || []
|
||||
|
||||
const status = media.mediaListEntry?.status === 'REPEATING' ? 'REPEATING' : 'CURRENT'
|
||||
const progress = media.mediaListEntry?.progress
|
||||
|
||||
// check user's own watch progress
|
||||
if (progress > videoEpisode) return
|
||||
if (progress === videoEpisode && videoEpisode !== mediaEpisode && !singleEpisode) return
|
||||
|
||||
const variables = {
|
||||
repeat: media.mediaListEntry?.repeat || 0,
|
||||
id: media.id,
|
||||
status,
|
||||
score: (media.mediaListEntry?.score ? media.mediaListEntry?.score : 0),
|
||||
episode: videoEpisode,
|
||||
lists
|
||||
}
|
||||
if (videoEpisode === mediaEpisode) {
|
||||
variables.status = 'COMPLETED'
|
||||
if (media.mediaListEntry?.status === 'REPEATING') variables.repeat = media.mediaListEntry.repeat + 1
|
||||
}
|
||||
|
||||
Object.assign(variables, this.getFuzzyDate(media, status))
|
||||
|
||||
let res
|
||||
if (this.isAniAuth()) {
|
||||
res = await anilistClient.alEntry(lists, variables)
|
||||
} else if (this.isMalAuth()) {
|
||||
res = await malClient.malEntry(media, variables)
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static getPaginatedMediaList(page, perPage, variables, mediaList) {
|
||||
const ids = this.isAniAuth() ? mediaList.filter(({ media }) => {
|
||||
if ((!variables.hideSubs || malDubs.dubLists.value.dubbed.includes(media.idMal)) &&
|
||||
this.matchTitle(media, variables.search, ['title.userPreferred', 'title.english', 'title.romaji', 'title.native']) &&
|
||||
(!variables.genre || variables.genre.map(genre => genre.trim().toLowerCase()).every(genre => media.genres.map(genre => genre.trim().toLowerCase()).includes(genre))) &&
|
||||
(!variables.tag || variables.tag.map(tag => tag.trim().toLowerCase()).every(tag => media.tags.map(tag => tag.name.trim().toLowerCase()).includes(tag))) &&
|
||||
(!variables.season || variables.season === media.season) &&
|
||||
(!variables.year || variables.year === media.seasonYear) &&
|
||||
(!variables.format || variables.format === media.format) &&
|
||||
(!variables.status || variables.status === media.status) &&
|
||||
(!variables.continueWatching || (media.status === 'FINISHED' || media.mediaListEntry?.progress < media.nextAiringEpisode?.episode - 1))) {
|
||||
return true
|
||||
}
|
||||
}).map(({ media }) => (this.isUserSort(variables) ? media : media.id)) : mediaList.filter(({ node }) => {
|
||||
if ((!variables.hideSubs || malDubs.dubLists.value.dubbed.includes(node.id)) &&
|
||||
this.matchTitle(node, variables.search, ['title', 'alternative_titles.en', 'alternative_titles.ja']) &&
|
||||
(!variables.season || variables.season.toLowerCase() === node.start_season?.season.toLowerCase()) &&
|
||||
(!variables.year || variables.year === node.start_season?.year) &&
|
||||
(!variables.format || (variables.format !== 'TV_SHORT' && variables.format === node.media_type.toUpperCase()) || (variables.format === 'TV_SHORT' && node.average_episode_duration < 1200)) &&
|
||||
(!variables.status || variables.status === 'CANCELLED' || variables.status === this.airingMap(node.status))) {
|
||||
// api does provide airing episode or tags, additionally genres are inaccurate and tags do not exist.
|
||||
return true
|
||||
}
|
||||
}).map(({ node }) => node.id)
|
||||
if (!ids.length) return {}
|
||||
if (this.isUserSort(variables)) {
|
||||
const updatedVariables = { ...variables }
|
||||
delete updatedVariables.sort // delete user sort as you can't sort by user specific sorting on AniList when logged into MyAnimeList.
|
||||
const startIndex = (perPage * (page - 1))
|
||||
const endIndex = startIndex + perPage
|
||||
const paginatedIds = ids.slice(startIndex, endIndex)
|
||||
const hasNextPage = ids.length > endIndex
|
||||
const idIndexMap = paginatedIds.reduce((map, id, index) => { map[id] = index; return map }, {})
|
||||
return this.isAniAuth() ? {
|
||||
data: {
|
||||
Page: {
|
||||
pageInfo: {
|
||||
hasNextPage: hasNextPage
|
||||
},
|
||||
media: paginatedIds
|
||||
}
|
||||
}
|
||||
} : anilistClient.searchIDS({ page: 1, perPage, idMal: paginatedIds, ...this.sanitiseObject(updatedVariables) }).then(res => {
|
||||
res.data.Page.pageInfo.hasNextPage = hasNextPage
|
||||
res.data.Page.media = res.data.Page.media.sort((a, b) => { return idIndexMap[a.idMal] - idIndexMap[b.idMal] })
|
||||
return res
|
||||
})
|
||||
} else {
|
||||
return anilistClient.searchIDS({ page, perPage, ...({[this.isAniAuth() ? 'id' : 'idMal']: ids}), ...this.sanitiseObject(variables) }).then(res => {
|
||||
return res
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
51
common/modules/mal.d.ts
vendored
Normal file
51
common/modules/mal.d.ts
vendored
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
export type Media = {
|
||||
id: number
|
||||
title: string
|
||||
alternative_titles: {
|
||||
synonyms: string[]
|
||||
en: string
|
||||
ja: string
|
||||
}
|
||||
rank?: number
|
||||
nsfw?: string
|
||||
media_type: string
|
||||
status: string
|
||||
my_list_status?: AnimeListStatus
|
||||
start_season?: {
|
||||
year: number
|
||||
season: string
|
||||
}
|
||||
average_episode_duration?: number
|
||||
related_anime?: {
|
||||
node: Media
|
||||
relation_type: string
|
||||
relation_type_formatted: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export type AnimeListStatus = {
|
||||
status: string
|
||||
score: number
|
||||
num_episodes_watched: number
|
||||
is_rewatching: boolean
|
||||
start_date?: string
|
||||
finish_date?: string
|
||||
priority: number
|
||||
number_times_rewatched: number
|
||||
rewatch_value: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export type Viewer = {
|
||||
id: number
|
||||
name: string
|
||||
picture: string
|
||||
}
|
||||
|
||||
export type MediaList = {
|
||||
node: Media
|
||||
}[];
|
||||
|
||||
export type Query<T> = {
|
||||
data: T
|
||||
}
|
||||
252
common/modules/myanimelist.js
Normal file
252
common/modules/myanimelist.js
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import { writable } from 'simple-store-svelte'
|
||||
|
||||
import { malToken, refreshMalToken } from '@/modules/settings.js'
|
||||
import { codes } from "@/modules/anilist";
|
||||
import { toast } from 'svelte-sonner'
|
||||
import Helper from '@/modules/helper.js'
|
||||
|
||||
export const clientID = '4c2c172971b9164f924fd5925b443ac3' // app type MUST be set to other, do not generate a seed.
|
||||
|
||||
function printError (error) {
|
||||
console.warn(error)
|
||||
toast.error('Search Failed', {
|
||||
description: `Failed making request to MyAnimeList!\nTry again in a minute.\n${error.status || 429} - ${error.message || codes[error.status || 429]}`,
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
|
||||
const queryFields = [
|
||||
'synopsis',
|
||||
'alternative_titles',
|
||||
'mean',
|
||||
'rank',
|
||||
'popularity',
|
||||
'num_list_users',
|
||||
'num_scoring_users',
|
||||
'related_anime',
|
||||
'media_type',
|
||||
'num_episodes',
|
||||
'status',
|
||||
'my_list_status',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'start_season',
|
||||
'broadcast',
|
||||
'studios',
|
||||
'authors{first_name,last_name}',
|
||||
'source',
|
||||
'genres',
|
||||
'average_episode_duration',
|
||||
'rating'
|
||||
]
|
||||
|
||||
class MALClient {
|
||||
/** @type {import('simple-store-svelte').Writable<ReturnType<MALClient['getUserLists']>>} */
|
||||
userLists = writable()
|
||||
|
||||
userID = malToken
|
||||
|
||||
constructor () {
|
||||
if (this.userID?.viewer?.data?.Viewer) {
|
||||
this.userLists.value = this.getUserLists({ sort: 'list_updated_at' })
|
||||
// update userLists every 15 mins
|
||||
setInterval(() => {
|
||||
this.userLists.value = this.getUserLists({ sort: 'list_updated_at' })
|
||||
}, 1000 * 60 * 15)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, any>} query
|
||||
* @param {Record<string, any>} body
|
||||
* @returns {Promise<import('./mal').Query<any>>}
|
||||
*/
|
||||
malRequest (query, body = {}) {
|
||||
/** @type {RequestInit} */
|
||||
const options = {
|
||||
method: `${query.type}`,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${query.token ? query.token : this.userID.token}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
}
|
||||
if (Object.keys(body).length > 0) {
|
||||
options.body = new URLSearchParams(body)
|
||||
}
|
||||
return this.handleRequest(query, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, any>} query
|
||||
* @param {Record<string, any>} options
|
||||
* @returns {Promise<import('./mal').Query<any>>}
|
||||
*/
|
||||
async handleRequest (query, options) {
|
||||
let res = {}
|
||||
try {
|
||||
res = await fetch(`https://api.myanimelist.net/v2/${query.path}`, options)
|
||||
} catch (e) {
|
||||
if (res && res && res.status !== 404) {
|
||||
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);
|
||||
}
|
||||
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`);
|
||||
default:
|
||||
throw new Error(res.message ?? res.error);
|
||||
}
|
||||
} else if (!res || res.status !== 404) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
if (!res.ok && (res.status === 429 || res.status === 500)) {
|
||||
throw res
|
||||
}
|
||||
let json = null
|
||||
try {
|
||||
json = await res.json()
|
||||
} catch (error) {
|
||||
if (res.ok) printError(error)
|
||||
}
|
||||
if (!res.ok && res.status !== 404) {
|
||||
if (json) {
|
||||
for (const error of json?.errors || []) {
|
||||
printError(error)
|
||||
}
|
||||
} else {
|
||||
printError(res)
|
||||
}
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
async malEntry (media, variables) {
|
||||
variables.idMal = media.idMal
|
||||
const res = await malClient.entry(variables)
|
||||
media.mediaListEntry = res.data.SaveMediaListEntry
|
||||
return res
|
||||
}
|
||||
|
||||
/** @returns {Promise<import('./mal').Query<{ MediaList: import('./mal').MediaList }>>} */
|
||||
async getUserLists (variables) {
|
||||
const limit = 1000 // max possible you can fetch
|
||||
let offset = 0
|
||||
let allMediaList = []
|
||||
let hasNextPage = true
|
||||
|
||||
// Check and replace specific sort values
|
||||
const customSorts = ['list_start_date_nan', 'list_finish_date_nan', 'list_progress_nan']
|
||||
if (customSorts.includes(variables.sort)) {
|
||||
variables.originalSort = variables.sort
|
||||
variables.sort = 'list_updated_at'
|
||||
}
|
||||
|
||||
while (hasNextPage) {
|
||||
const query = {
|
||||
type: 'GET',
|
||||
path: `users/@me/animelist?fields=${queryFields}&nsfw=true&limit=${limit}&offset=${offset}&sort=${variables.sort}`
|
||||
}
|
||||
|
||||
const res = await this.malRequest(query)
|
||||
allMediaList = allMediaList.concat(res.data)
|
||||
|
||||
if (res.data.length < limit) {
|
||||
hasNextPage = false
|
||||
} else {
|
||||
offset += limit
|
||||
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);
|
||||
});
|
||||
} 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);
|
||||
});
|
||||
} 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
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
MediaList: allMediaList
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<import('./mal').Query<{ Viewer: import('./mal').Viewer }>>} */
|
||||
async viewer (token) {
|
||||
const query = {
|
||||
type: 'GET',
|
||||
path: 'users/@me',
|
||||
token
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
Viewer: await this.malRequest(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async entry (variables) {
|
||||
const query = {
|
||||
type: 'PUT',
|
||||
path: `anime/${variables.idMal}/my_list_status`
|
||||
}
|
||||
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 = {
|
||||
status: Helper.statusMap(variables.status),
|
||||
is_rewatching: variables.status?.includes('REPEATING'),
|
||||
num_watched_episodes: variables.episode || 0,
|
||||
num_times_rewatched: variables.repeat || 0,
|
||||
score: variables.score || 0
|
||||
}
|
||||
if (start_date) {
|
||||
updateData.start_date = start_date
|
||||
}
|
||||
if (finish_date) {
|
||||
updateData.finish_date = finish_date
|
||||
}
|
||||
const res = await this.malRequest(query, updateData)
|
||||
this.userLists.value = this.getUserLists({ sort: 'list_updated_at' })
|
||||
|
||||
return res ? {
|
||||
data: {
|
||||
SaveMediaListEntry: {
|
||||
id: variables.id,
|
||||
status: variables.status,
|
||||
progress: variables.episode,
|
||||
score: variables.score,
|
||||
repeat: variables.repeat,
|
||||
startedAt: variables.startedAt,
|
||||
completedAt: variables.completedAt,
|
||||
customLists: []
|
||||
}
|
||||
}
|
||||
} : res
|
||||
}
|
||||
|
||||
async delete (variables) {
|
||||
const query = {
|
||||
type: 'DELETE',
|
||||
path: `anime/${variables.idMal}/my_list_status`
|
||||
}
|
||||
const res = await this.malRequest(query)
|
||||
this.userLists.value = this.getUserLists({ sort: 'list_updated_at' })
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
export const malClient = new MALClient()
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import { anilistClient, currentSeason, currentYear } from '@/modules/anilist.js'
|
||||
import { malDubs } from "@/modules/animedubs.js"
|
||||
import { writable } from 'simple-store-svelte'
|
||||
import { settings, alToken } from '@/modules/settings.js'
|
||||
import { settings } from '@/modules/settings.js'
|
||||
import { RSSManager } from '@/modules/rss.js'
|
||||
import Helper from '@/modules/helper.js'
|
||||
|
||||
export const hasNextPage = writable(true)
|
||||
|
||||
|
|
@ -24,16 +26,13 @@ export default class SectionsManager {
|
|||
|
||||
static createFallbackLoad (variables, type) {
|
||||
return (page = 1, perPage = 50, search = variables) => {
|
||||
if (search.hideMyAnime) {
|
||||
const res = anilistClient.userLists.value.then(res => {
|
||||
const ids = Array.from(new Set(res.data.MediaListCollection.lists.filter(({ status }) => search.hideStatus.includes(status)).flatMap(list => list.entries.map(({ media }) => media.id))));
|
||||
return anilistClient.search({ page, perPage, id_not: ids, ...SectionsManager.sanitiseObject(search) })
|
||||
})
|
||||
return SectionsManager.wrapResponse(res, perPage, type)
|
||||
} else {
|
||||
const res = anilistClient.search({ page, perPage, ...SectionsManager.sanitiseObject(search) })
|
||||
return SectionsManager.wrapResponse(res, perPage, type)
|
||||
}
|
||||
const hideSubs = search.hideSubs ? { idMal: malDubs.dubLists.value.dubbed } : {}
|
||||
const res = (search.hideMyAnime && Helper.isAuthorized()) ? Helper.userLists(search).then(res => {
|
||||
const hideMyAnime = Helper.isAniAuth() ? { id_not: Array.from(new Set(res.data.MediaListCollection.lists.filter(({ status }) => search.hideStatus.includes(status)).flatMap(list => list.entries.map(({ media }) => media.id)))) }
|
||||
: {idMal_not: res.data.MediaList.filter(({ node }) => search.hideStatus.includes(Helper.statusMap(node.my_list_status.status))).map(({ node }) => node.id)}
|
||||
return anilistClient.search({ page, perPage, ...hideSubs, ...hideMyAnime, ...SectionsManager.sanitiseObject(search) })
|
||||
}) : anilistClient.search({ page, perPage, ...hideSubs, ...SectionsManager.sanitiseObject(search) })
|
||||
return SectionsManager.wrapResponse(res, perPage, type)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -44,56 +43,12 @@ export default class SectionsManager {
|
|||
return Array.from({ length }, (_, i) => ({ type, data: SectionsManager.fromPending(res, i) }))
|
||||
}
|
||||
|
||||
static sanitiseObject (object = {}) {
|
||||
const safe = {}
|
||||
for (const [key, value] of Object.entries(object)) {
|
||||
if (value) safe[key] = value
|
||||
}
|
||||
return safe
|
||||
}
|
||||
|
||||
static async fromPending (arr, i) {
|
||||
const { data } = await arr
|
||||
return data?.Page.media[i]
|
||||
}
|
||||
|
||||
static isUserSort(variables) {
|
||||
return ['UPDATED_TIME_DESC', 'STARTED_ON_DESC', 'FINISHED_ON_DESC', 'PROGRESS_DESC', 'USER_SCORE_DESC'].includes(variables.sort);
|
||||
}
|
||||
|
||||
static async getPaginatedMediaList(_page, perPage, variables, mediaList) {
|
||||
const ids = mediaList.filter(({ media }) => {
|
||||
if ((!variables.search || (media.title.userPreferred && media.title.userPreferred.toLowerCase().includes(variables.search.toLowerCase())) || (media.title.english && media.title.english.toLowerCase().includes(variables.search.toLowerCase())) || (media.title.romaji && media.title.romaji.toLowerCase().includes(variables.search.toLowerCase())) || (media.title.native && media.title.native.toLowerCase().includes(variables.search.toLowerCase()))) &&
|
||||
(!variables.genre || variables.genre.map(genre => genre.trim().toLowerCase()).every(genre => media.genres.map(genre => genre.trim().toLowerCase()).includes(genre))) &&
|
||||
(!variables.tag || variables.tag.map(tag => tag.trim().toLowerCase()).every(tag => media.tags.map(tag => tag.name.trim().toLowerCase()).includes(tag))) &&
|
||||
(!variables.season || variables.season === media.season) &&
|
||||
(!variables.year || variables.year === media.seasonYear) &&
|
||||
(!variables.format || variables.format === media.format) &&
|
||||
(!variables.status || variables.status === media.status) &&
|
||||
(!variables.continueWatching || (media.status === 'FINISHED' || media.mediaListEntry?.progress < media.nextAiringEpisode?.episode - 1))) {
|
||||
return true;
|
||||
}
|
||||
}).map(({ media }) => (this.isUserSort(variables) ? media : media.id));
|
||||
if (!ids.length) return {}
|
||||
if (this.isUserSort(variables)) {
|
||||
const startIndex = (perPage * (_page - 1));
|
||||
const endIndex = startIndex + perPage;
|
||||
const paginatedIds = ids.slice(startIndex, endIndex);
|
||||
const hasNextPage = ids.length > endIndex;
|
||||
return {
|
||||
data: {
|
||||
Page: {
|
||||
pageInfo: {
|
||||
hasNextPage: hasNextPage
|
||||
},
|
||||
media: paginatedIds
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return anilistClient.searchIDS({ _page, perPage, id: ids, ...SectionsManager.sanitiseObject(variables) })
|
||||
}
|
||||
}
|
||||
static sanitiseObject = Helper.sanitiseObject
|
||||
}
|
||||
|
||||
// list of all possible home screen sections
|
||||
|
|
@ -127,9 +82,10 @@ function createSections () {
|
|||
}),
|
||||
// user specific sections
|
||||
{
|
||||
title: 'Sequels You Missed', variables : { disableHide: true },
|
||||
title: 'Sequels You Missed', variables : { sort: 'POPULARITY_DESC', userList: true, disableHide: true },
|
||||
load: (page = 1, perPage = 50, variables = {}) => {
|
||||
const res = anilistClient.userLists.value.then(res => {
|
||||
if (Helper.isMalAuth()) return {} // not going to bother handling this, see below.
|
||||
const res = Helper.userLists(variables).then(res => {
|
||||
const mediaList = res.data.MediaListCollection.lists.find(({ status }) => status === 'COMPLETED')?.entries
|
||||
if (!mediaList) return {}
|
||||
const ids = mediaList.flatMap(({ media }) => {
|
||||
|
|
@ -140,86 +96,91 @@ function createSections () {
|
|||
})
|
||||
return SectionsManager.wrapResponse(res, perPage)
|
||||
},
|
||||
hide: !alToken
|
||||
hide: !Helper.isAuthorized() || Helper.isMalAuth() // disable this section when authenticated with MyAnimeList. API for userLists fail to return relations and likely will never be fixed on their end.
|
||||
},
|
||||
{
|
||||
title: 'Continue Watching', variables: { sort: 'UPDATED_TIME_DESC', userList: true, continueWatching: true, disableHide: true },
|
||||
load: (_page = 1, perPage = 50, variables = {}) => {
|
||||
const userLists = (!SectionsManager.isUserSort(variables) || variables.sort === 'UPDATED_TIME_DESC') ? anilistClient.userLists.value : anilistClient.getUserLists({ sort: variables.sort })
|
||||
const res = userLists.then(res => {
|
||||
const mediaList = res.data.MediaListCollection.lists.reduce((filtered, { status, entries }) => {
|
||||
load: (page = 1, perPage = 50, variables = {}) => {
|
||||
const res = Helper.userLists(variables).then(res => {
|
||||
const mediaList = Helper.isAniAuth() ? res.data.MediaListCollection.lists.reduce((filtered, { status, entries }) => {
|
||||
return (status === 'CURRENT' || status === 'REPEATING') ? filtered.concat(entries) : filtered
|
||||
}, []);
|
||||
}, []) : res.data.MediaList.filter(({ node }) => (node.my_list_status.status === Helper.statusMap('CURRENT') || node.my_list_status.is_rewatching))
|
||||
if (!mediaList) return {}
|
||||
return SectionsManager.getPaginatedMediaList(_page, perPage, variables, mediaList)
|
||||
});
|
||||
return SectionsManager.wrapResponse(res, perPage);
|
||||
return Helper.getPaginatedMediaList(page, perPage, variables, mediaList)
|
||||
})
|
||||
return SectionsManager.wrapResponse(res, perPage)
|
||||
},
|
||||
hide: !alToken
|
||||
hide: !Helper.isAuthorized()
|
||||
},
|
||||
{
|
||||
title: 'Watching List', variables : { sort: 'UPDATED_TIME_DESC', userList: true, disableHide: true },
|
||||
load: (_page = 1, perPage = 50, variables = {}) => {
|
||||
const userLists = (!SectionsManager.isUserSort(variables) || variables.sort === 'UPDATED_TIME_DESC') ? anilistClient.userLists.value : anilistClient.getUserLists({ sort: variables.sort })
|
||||
const res = userLists.then(res => {
|
||||
const mediaList = res.data.MediaListCollection.lists.find(({ status }) => status === 'CURRENT')?.entries
|
||||
load: (page = 1, perPage = 50, variables = {}) => {
|
||||
const res = Helper.userLists(variables).then(res => {
|
||||
const mediaList = Helper.isAniAuth()
|
||||
? res.data.MediaListCollection.lists.find(({ status }) => status === 'CURRENT')?.entries
|
||||
: res.data.MediaList.filter(({ node }) => node.my_list_status.status === Helper.statusMap('CURRENT'))
|
||||
if (!mediaList) return {}
|
||||
return SectionsManager.getPaginatedMediaList(_page, perPage, variables, mediaList)
|
||||
return Helper.getPaginatedMediaList(page, perPage, variables, mediaList)
|
||||
})
|
||||
return SectionsManager.wrapResponse(res, perPage)
|
||||
},
|
||||
hide: !alToken
|
||||
hide: !Helper.isAuthorized()
|
||||
},
|
||||
{
|
||||
title: 'Completed List', variables : { sort: 'UPDATED_TIME_DESC', userList: true, completedList: true, disableHide: true },
|
||||
load: (_page = 1, perPage = 50, variables = {}) => {
|
||||
const userLists = (!SectionsManager.isUserSort(variables) || variables.sort === 'UPDATED_TIME_DESC') ? anilistClient.userLists.value : anilistClient.getUserLists({ sort: variables.sort })
|
||||
const res = userLists.then(res => {
|
||||
const mediaList = res.data.MediaListCollection.lists.find(({ status }) => status === 'COMPLETED')?.entries
|
||||
load: (page = 1, perPage = 50, variables = {}) => {
|
||||
const res = Helper.userLists(variables).then(res => {
|
||||
const mediaList = Helper.isAniAuth()
|
||||
? res.data.MediaListCollection.lists.find(({ status }) => status === 'COMPLETED')?.entries
|
||||
: res.data.MediaList.filter(({ node }) => node.my_list_status.status === Helper.statusMap('COMPLETED'))
|
||||
if (!mediaList) return {}
|
||||
return SectionsManager.getPaginatedMediaList(_page, perPage, variables, mediaList)
|
||||
return Helper.getPaginatedMediaList(page, perPage, variables, mediaList)
|
||||
})
|
||||
return SectionsManager.wrapResponse(res, perPage)
|
||||
},
|
||||
hide: !alToken
|
||||
hide: !Helper.isAuthorized()
|
||||
},
|
||||
{
|
||||
title: 'Planning List', variables : { sort: 'POPULARITY_DESC', userList: true, disableHide: true },
|
||||
load: (_page = 1, perPage = 50, variables = {}) => {
|
||||
const res = anilistClient.userLists.value.then(res => {
|
||||
const mediaList = res.data.MediaListCollection.lists.find(({ status }) => status === 'PLANNING')?.entries
|
||||
title: 'Planning List', variables : { test: 'Planning', sort: 'POPULARITY_DESC', userList: true, disableHide: true },
|
||||
load: (page = 1, perPage = 50, variables = {}) => {
|
||||
const res = Helper.userLists(variables).then(res => {
|
||||
const mediaList = Helper.isAniAuth()
|
||||
? res.data.MediaListCollection.lists.find(({ status }) => status === 'PLANNING')?.entries
|
||||
: res.data.MediaList.filter(({ node }) => node.my_list_status.status === Helper.statusMap('PLANNING'))
|
||||
if (!mediaList) return {}
|
||||
return SectionsManager.getPaginatedMediaList(_page, perPage, variables, mediaList)
|
||||
return Helper.getPaginatedMediaList(page, perPage, variables, mediaList)
|
||||
})
|
||||
return SectionsManager.wrapResponse(res, perPage)
|
||||
},
|
||||
hide: !alToken
|
||||
hide: !Helper.isAuthorized()
|
||||
},
|
||||
{
|
||||
title: 'Paused List', variables : { sort: 'UPDATED_TIME_DESC', userList: true, disableHide: true },
|
||||
load: (_page = 1, perPage = 50, variables = {}) => {
|
||||
const userLists = (!SectionsManager.isUserSort(variables) || variables.sort === 'UPDATED_TIME_DESC') ? anilistClient.userLists.value : anilistClient.getUserLists({ sort: variables.sort })
|
||||
const res = userLists.then(res => {
|
||||
const mediaList = res.data.MediaListCollection.lists.find(({ status }) => status === 'PAUSED')?.entries
|
||||
load: (page = 1, perPage = 50, variables = {}) => {
|
||||
const res = Helper.userLists(variables).then(res => {
|
||||
const mediaList = Helper.isAniAuth()
|
||||
? res.data.MediaListCollection.lists.find(({ status }) => status === 'PAUSED')?.entries
|
||||
: res.data.MediaList.filter(({ node }) => node.my_list_status.status === Helper.statusMap('PAUSED'))
|
||||
if (!mediaList) return {}
|
||||
return SectionsManager.getPaginatedMediaList(_page, perPage, variables, mediaList)
|
||||
return Helper.getPaginatedMediaList(page, perPage, variables, mediaList)
|
||||
})
|
||||
return SectionsManager.wrapResponse(res, perPage)
|
||||
},
|
||||
hide: !alToken
|
||||
hide: !Helper.isAuthorized()
|
||||
},
|
||||
{
|
||||
title: 'Dropped List', variables : { sort: 'UPDATED_TIME_DESC', userList: true, disableHide: true },
|
||||
load: (_page = 1, perPage = 50, variables = {}) => {
|
||||
const userLists = (!SectionsManager.isUserSort(variables) || variables.sort === 'UPDATED_TIME_DESC') ? anilistClient.userLists.value : anilistClient.getUserLists({ sort: variables.sort })
|
||||
const res = userLists.then(res => {
|
||||
const mediaList = res.data.MediaListCollection.lists.find(({ status }) => status === 'DROPPED')?.entries
|
||||
load: (page = 1, perPage = 50, variables = {}) => {
|
||||
const res = Helper.userLists(variables).then(res => {
|
||||
const mediaList = Helper.isAniAuth()
|
||||
? res.data.MediaListCollection.lists.find(({ status }) => status === 'DROPPED')?.entries
|
||||
: res.data.MediaList.filter(({ node }) => node.my_list_status.status === Helper.statusMap('DROPPED'))
|
||||
if (!mediaList) return {}
|
||||
return SectionsManager.getPaginatedMediaList(_page, perPage, variables, mediaList)
|
||||
return Helper.getPaginatedMediaList(page, perPage, variables, mediaList)
|
||||
})
|
||||
return SectionsManager.wrapResponse(res, perPage)
|
||||
},
|
||||
hide: !alToken
|
||||
hide: !Helper.isAuthorized()
|
||||
},
|
||||
// common, non-user specific sections
|
||||
{ title: 'Popular This Season', variables: { sort: 'POPULARITY_DESC', season: currentSeason, year: currentYear, hideMyAnime: settings.value.hideMyAnime, hideStatus: ['COMPLETED', 'DROPPED'] } },
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { writable } from 'simple-store-svelte'
|
||||
import { defaults } from './util.js'
|
||||
import IPC from '@/modules/ipc.js'
|
||||
import { anilistClient } from './anilist.js'
|
||||
import { toast } from 'svelte-sonner'
|
||||
/** @type {{viewer: import('./al').Query<{Viewer: import('./al').Viewer}>, token: string} | null} */
|
||||
export let alToken = JSON.parse(localStorage.getItem('ALviewer')) || null
|
||||
/** @type {{viewer: import('./mal').Query<{Viewer: import('./mal').Viewer}>, token: string} | null} */
|
||||
export let malToken = JSON.parse(localStorage.getItem('MALviewer')) || null
|
||||
|
||||
let storedSettings = { ...defaults }
|
||||
|
||||
|
|
@ -35,14 +36,29 @@ export function resetSettings () {
|
|||
settings.value = { ...defaults, ...scopedDefaults }
|
||||
}
|
||||
|
||||
export function isAuthorized() {
|
||||
return alToken || malToken
|
||||
}
|
||||
|
||||
window.addEventListener('paste', ({ clipboardData }) => {
|
||||
if (clipboardData.items?.[0]) {
|
||||
if (clipboardData.items[0].type === 'text/plain' && clipboardData.items[0].kind === 'string') {
|
||||
clipboardData.items[0].getAsString(text => {
|
||||
let token = text.split('access_token=')?.[1]?.split('&token_type')?.[0]
|
||||
if (token) {
|
||||
if (token.endsWith('/')) token = token.slice(0, -1)
|
||||
handleToken(token)
|
||||
if (text.includes("access_token=")) { // is an AniList token
|
||||
let token = text.split('access_token=')?.[1]?.split('&token_type')?.[0]
|
||||
if (token) {
|
||||
if (token.endsWith('/')) token = token.slice(0, -1)
|
||||
handleToken(token)
|
||||
}
|
||||
} else if (text.includes("code=") && text.includes("&state")) { // is a MyAnimeList authorization
|
||||
let code = line.split('code=')[1].split('&state')[0]
|
||||
let state = line.split('&state=')[1]
|
||||
if (code && state) {
|
||||
if (code.endsWith('/')) code = code.slice(0, -1)
|
||||
if (state.endsWith('/')) state = state.slice(0, -1)
|
||||
if (state.includes('%')) state = decodeURIComponent(state)
|
||||
handleMalToken(code, state)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -51,6 +67,7 @@ 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 })
|
||||
if (!viewer.data?.Viewer) {
|
||||
toast.error('Failed to sign in with AniList. Please try again.', { description: JSON.stringify(viewer) })
|
||||
|
|
@ -64,3 +81,68 @@ async function handleToken (token) {
|
|||
localStorage.setItem('ALviewer', JSON.stringify({ token, viewer }))
|
||||
location.reload()
|
||||
}
|
||||
|
||||
IPC.on('maltoken', handleMalToken)
|
||||
async function handleMalToken (code, state) {
|
||||
const { clientID, malClient } = await import('./myanimelist.js')
|
||||
if (!state || !code) {
|
||||
toast.error('Failed to sign in with MyAnimeList. Please try again.')
|
||||
console.error('Failed to get the state and code from MyAnimeList.')
|
||||
return
|
||||
}
|
||||
const response = await fetch('https://myanimelist.net/v1/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: clientID,
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
code_verifier: sessionStorage.getItem(state)
|
||||
})
|
||||
})
|
||||
if (!response.ok) {
|
||||
toast.error('Failed to sign in with MyAnimeList. Please try again.', { description: JSON.stringify(response.status) })
|
||||
console.error('Failed to get MyAnimeList User Token.', response)
|
||||
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) })
|
||||
console.error(viewer)
|
||||
return
|
||||
} 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 }))
|
||||
location.reload()
|
||||
}
|
||||
|
||||
export async function refreshMalToken () {
|
||||
const { clientID } = await import('./myanimelist.js')
|
||||
const response = await fetch('https://myanimelist.net/v1/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: clientID,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: malToken.refresh
|
||||
})
|
||||
})
|
||||
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')
|
||||
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 }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { SUPPORTS } from '@/modules/support.js'
|
||||
import levenshtein from 'js-levenshtein'
|
||||
|
||||
export function countdown (s) {
|
||||
const d = Math.floor(s / (3600 * 24))
|
||||
|
|
@ -93,6 +94,23 @@ export function generateRandomHexCode (len) {
|
|||
return hexCode
|
||||
}
|
||||
|
||||
export function matchPhrase(search, phrase, threshold) {
|
||||
if (!search) return false
|
||||
const normalizedSearch = search.toLowerCase().replace(/[^\w\s]/g, '')
|
||||
phrase = Array.isArray(phrase) ? phrase : [phrase]
|
||||
|
||||
for (let p of phrase) {
|
||||
const normalizedPhrase = p.toLowerCase().replace(/[^\w\s]/g, '')
|
||||
if (normalizedSearch.includes(normalizedPhrase)) return true
|
||||
|
||||
const wordsInFileName = normalizedSearch.split(/\s+/)
|
||||
for (let word of wordsInFileName) {
|
||||
if (levenshtein(word, normalizedPhrase) <= threshold) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function throttle (fn, time) {
|
||||
let wait = false
|
||||
return (...args) => {
|
||||
|
|
|
|||
BIN
common/public/anilist_logo.png
Normal file
BIN
common/public/anilist_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
BIN
common/public/myanimelist_logo.png
Normal file
BIN
common/public/myanimelist_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
|
|
@ -2,6 +2,7 @@
|
|||
import SectionsManager, { sections } from '@/modules/sections.js'
|
||||
import { settings } from '@/modules/settings.js'
|
||||
import { anilistClient, currentSeason, currentYear } from '@/modules/anilist.js'
|
||||
import Helper from '@/modules/helper.js'
|
||||
|
||||
const bannerData = anilistClient.search({ method: 'Search', sort: 'POPULARITY_DESC', perPage: 15, onList: false, season: currentSeason, year: currentYear, status_not: 'NOT_YET_RELEASED' })
|
||||
|
||||
|
|
@ -15,9 +16,9 @@
|
|||
|
||||
for (const sectionTitle of settings.value.homeSections) manager.add(mappedSections[sectionTitle])
|
||||
|
||||
if (anilistClient.userID?.viewer?.data?.Viewer) {
|
||||
if (Helper.getUser()) {
|
||||
const userSections = ['Continue Watching', 'Sequels You Missed', 'Planning List', 'Completed List', 'Paused List', 'Dropped List', 'Watching List']
|
||||
anilistClient.userLists.subscribe(value => {
|
||||
Helper.getClient().userLists.subscribe(value => {
|
||||
if (!value) return
|
||||
for (const section of manager.sections) {
|
||||
// remove preview value, to force UI to re-request data, which updates it once in viewport
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
function deferredLoad (element) {
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
if (!opts.preview.value) opts.preview.value = opts.load(1, 15)
|
||||
if (!opts.preview.value) opts.preview.value = opts.load(1, 15, { ...opts.variables })
|
||||
observer.unobserve(element)
|
||||
}
|
||||
}, { threshold: 0 })
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
<div class='position-relative'>
|
||||
<div class='pb-10 w-full d-flex flex-row justify-content-start gallery' class:isRSS={opts.isRSS}>
|
||||
{#each $preview || fakecards as card}
|
||||
<Card {card} />
|
||||
<Card {card} variables={{...opts.variables}} />
|
||||
{/each}
|
||||
{#if $preview?.length}
|
||||
<ErrorCard promise={$preview[0].data} />
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
import { playAnime } from '@/views/TorrentSearch/TorrentModal.svelte'
|
||||
import { client } from '@/modules/torrent.js'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { anilistClient } from '@/modules/anilist.js'
|
||||
import Subtitles from '@/modules/subtitles.js'
|
||||
import { toTS, fastPrettyBytes, videoRx } from '@/modules/util.js'
|
||||
import { toast } from 'svelte-sonner'
|
||||
|
|
@ -12,6 +11,7 @@
|
|||
import Seekbar from 'perfect-seekbar'
|
||||
import { click } from '@/modules/click.js'
|
||||
import VideoDeband from 'video-deband'
|
||||
import Helper from '@/modules/helper.js'
|
||||
|
||||
import { w2gEmitter, state } from '../WatchTogether/WatchTogether.svelte'
|
||||
import Keybinds, { loadWithDefaults, condition } from 'svelte-keybinds'
|
||||
|
|
@ -939,7 +939,7 @@
|
|||
if (media?.media?.episodes || media?.media?.nextAiringEpisode?.episode) {
|
||||
if (media.media.episodes || media.media.nextAiringEpisode?.episode > media.episode) {
|
||||
completed = true
|
||||
anilistClient.alEntry(media)
|
||||
Helper.updateEntry(media)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@
|
|||
<div class='w-full d-grid d-md-flex flex-wrap flex-row px-md-50 px-20 justify-content-center align-content-start'>
|
||||
{#key $key}
|
||||
{#each $items as card}
|
||||
<Card {card} />
|
||||
<Card {card} variables={{...$search}} />
|
||||
{/each}
|
||||
{#if $items?.length}
|
||||
<ErrorCard promise={$items[0].data} />
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@
|
|||
<label for='player-pause'>{settings.playerPause ? 'On' : 'Off'}</label>
|
||||
</div>
|
||||
</SettingCard>
|
||||
<SettingCard title='Auto-Complete Episodes' description='Automatically marks episodes as complete on AniList when you finish watching them. Requires AniList login.'>
|
||||
<SettingCard title='Auto-Complete Episodes' description='Automatically marks episodes as complete on AniList or MyAnimeList when you finish watching them. You must be logged in.'>
|
||||
<div class='custom-switch'>
|
||||
<input type='checkbox' id='player-autocomplete' bind:checked={settings.playerAutocomplete} />
|
||||
<label for='player-autocomplete'>{settings.playerAutocomplete ? 'On' : 'Off'}</label>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script context='module'>
|
||||
import { toast } from 'svelte-sonner'
|
||||
import { click } from '@/modules/click.js'
|
||||
import { settings } from '@/modules/settings.js'
|
||||
import IPC from '@/modules/ipc.js'
|
||||
|
|
@ -35,9 +34,10 @@
|
|||
import TorrentSettings from './TorrentSettings.svelte'
|
||||
import InterfaceSettings from './InterfaceSettings.svelte'
|
||||
import AppSettings from './AppSettings.svelte'
|
||||
import { anilistClient } from '@/modules/anilist.js'
|
||||
import { login } from '@/components/Login.svelte'
|
||||
import { logout } from '@/components/Logout.svelte'
|
||||
import smoothScroll from '@/modules/scroll.js'
|
||||
import Helper from '@/modules/helper.js'
|
||||
|
||||
const groups = {
|
||||
player: {
|
||||
|
|
@ -70,16 +70,10 @@
|
|||
}
|
||||
|
||||
function loginButton () {
|
||||
if (anilistClient.userID?.viewer?.data?.Viewer) {
|
||||
if (Helper.getUser()) {
|
||||
$logout = true
|
||||
} else {
|
||||
IPC.emit('open', 'https://anilist.co/api/v2/oauth/authorize?client_id=4254&response_type=token') // Change redirect_url to miru://auth
|
||||
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
|
||||
})
|
||||
}
|
||||
$login = true
|
||||
}
|
||||
}
|
||||
onDestroy(() => {
|
||||
|
|
@ -111,14 +105,14 @@
|
|||
</div>
|
||||
<div class='pointer my-5 rounded' use:click={loginButton}>
|
||||
<div class='px-20 py-10 d-flex'>
|
||||
{#if anilistClient.userID?.viewer?.data?.Viewer}
|
||||
{#if Helper.getUser()}
|
||||
<span class='material-symbols-outlined rounded mr-10'>
|
||||
<img src={anilistClient.userID.viewer.data.Viewer.avatar.medium} class='h-30 rounded' alt='logo' />
|
||||
<img src={Helper.getUserAvatar()} class='h-30 rounded' alt='logo' />
|
||||
</span>
|
||||
<div class='font-size-16 login-image-text'>Logout</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 With AniList</div>
|
||||
<div class='font-size-16'>Login</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,30 +28,62 @@
|
|||
}
|
||||
}
|
||||
|
||||
function deleteEntry() {
|
||||
async function deleteEntry() {
|
||||
score = 0
|
||||
episode = 0
|
||||
status = 'NOT IN LIST'
|
||||
if (media.mediaListEntry) {
|
||||
anilistClient.delete({ id: media.mediaListEntry.id })
|
||||
media.mediaListEntry = undefined
|
||||
const res = await Helper.delete(media)
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (!status.includes('NOT IN LIST')) {
|
||||
const fuzzyDate = Helper.getFuzzyDate(media, status)
|
||||
const variables = {
|
||||
repeat: media.mediaListEntry?.repeat || 0,
|
||||
id: media.id,
|
||||
idMal: media.idMal,
|
||||
status,
|
||||
episode,
|
||||
score: score * 10,
|
||||
lists: media.mediaListEntry?.customLists?.filter(list => list.enabled).map(list => list.name) || []
|
||||
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) || [],
|
||||
...fuzzyDate
|
||||
}
|
||||
const res = await anilistClient.entry(variables)
|
||||
media.mediaListEntry = res.data.SaveMediaListEntry
|
||||
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
|
||||
})
|
||||
}
|
||||
} else {
|
||||
deleteEntry()
|
||||
await deleteEntry()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<script context='module'>
|
||||
import { writable } from 'simple-store-svelte'
|
||||
import { anilistClient } from '@/modules/anilist.js'
|
||||
import { add, client } from '@/modules/torrent.js'
|
||||
import { generateRandomHexCode } from '@/modules/util.js'
|
||||
import { toast } from 'svelte-sonner'
|
||||
import { page } from '@/App.svelte'
|
||||
import P2PT from 'p2pt'
|
||||
import { click } from '@/modules/click.js'
|
||||
import Helper from '@/modules/helper.js'
|
||||
import IPC from '@/modules/ipc.js'
|
||||
import 'browser-event-target-emitter'
|
||||
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
p2pt.on('peerconnect', peer => {
|
||||
console.log(peer.id)
|
||||
console.log('connect')
|
||||
const user = anilistClient.userID?.viewer?.data?.Viewer || {}
|
||||
const user = Helper.getUser() || {}
|
||||
p2pt.send(peer,
|
||||
JSON.stringify({
|
||||
type: 'init',
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export default class Protocol {
|
|||
// schema: miru://key/value
|
||||
protocolMap = {
|
||||
auth: token => this.sendToken(token),
|
||||
malauth: token => this.sendMalToken(token),
|
||||
anime: id => this.window.webContents.send('open-anime', id),
|
||||
w2g: link => this.window.webContents.send('w2glink', link),
|
||||
schedule: () => this.window.webContents.send('schedule'),
|
||||
|
|
@ -71,6 +72,20 @@ export default class Protocol {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} line
|
||||
*/
|
||||
sendMalToken (line) {
|
||||
let code = line.split('code=')[1].split('&state')[0]
|
||||
let state = line.split('&state=')[1]
|
||||
if (code && state) {
|
||||
if (code.endsWith('/')) code = code.slice(0, -1)
|
||||
if (state.endsWith('/')) state = state.slice(0, -1)
|
||||
if (state.includes('%')) state = decodeURIComponent(state)
|
||||
this.window.webContents.send('maltoken', code, state)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
"eslint": "^8.57.0",
|
||||
"eslint-config-standard": "^17.1.0",
|
||||
"eslint-plugin-svelte": "^2.35.1",
|
||||
"fuse.js": "^7.0.0",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"matroska-metadata": "^1.0.6",
|
||||
"mini-css-extract-plugin": "^2.8.1",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ importers:
|
|||
eslint-plugin-svelte:
|
||||
specifier: ^2.35.1
|
||||
version: 2.35.1(eslint@8.57.0)
|
||||
fuse.js:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
html-webpack-plugin:
|
||||
specifier: ^5.6.0
|
||||
version: 5.6.0(webpack@5.91.0)
|
||||
|
|
@ -4883,6 +4886,10 @@ packages:
|
|||
|
||||
/functions-have-names@1.2.3:
|
||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||
|
||||
/fuse.js@7.0.0:
|
||||
resolution: {integrity: sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
/gauge@2.7.4:
|
||||
resolution: {integrity: sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==}
|
||||
|
|
|
|||
Loading…
Reference in a new issue