feat: MyAnimeList login

This commit is contained in:
RockinChaos 2024-08-01 15:47:21 -07:00
parent 16e6a68bb3
commit b970ed6282
29 changed files with 1054 additions and 220 deletions

View file

@ -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'

View 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}> &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,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}> &times; </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>

View file

@ -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>

View file

@ -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>

View file

@ -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}

View file

@ -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 {

View file

@ -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;

View file

@ -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
}
}
}

View file

@ -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
View 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
View 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
}

View 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()

View file

@ -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'] } },

View file

@ -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 }))
}

View file

@ -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) => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

View file

@ -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

View file

@ -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} />

View file

@ -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)
}
}
}

View file

@ -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} />

View file

@ -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>

View file

@ -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>

View file

@ -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()
}
}

View file

@ -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',

View file

@ -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
*/

View file

@ -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",

View file

@ -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==}