UNPKG

wakitsu

Version:

Hobby project for managing anime watch list on Kitsu through CLI

438 lines 16.8 kB
import { getColoredTimeWatchedStr, getTimeUnits, parseWithZod } from '../utils.js'; import { HTTP } from '../http.js'; import { KitsuAnimeEntriesSchema, LibraryEntriesSchema, LibraryInfoSchema, LibraryPatchRespSchema, UserDataRespSchema, } from './kitsu-schemas.js'; import { Config } from '../config.js'; import { z } from 'zod'; import { KitsuURLs } from './kitsu-urls.js'; import { Printer } from '../printer/printer.js'; const _tokenURL = 'https://kitsu.app/api/oauth/token'; const _gK = Config.getKitsuProp; const _sK = Config.setKitsuProp; export class Kitsu { static get animeCache() { return _gK('cache').slice(0); } static get tokenInfo() { return { accessToken: _gK('access_token'), refreshToken: _gK('refresh_token'), expiresSec: _gK('token_expiration'), }; } static async init() { await tryGetSetupConsent(); const user = await promptUser(); if (!areStatsDefined(user)) { Printer.printError(`;bc;Stats Undefined: ;by;stats.time ;x;|| ;by;stats.completed`, 'Serialization Failed'); process.exit(1); } const password = await promptPassword(); const tokenData = await grantTokenData(user.attributes.name, password); Config.setKitsuData(serializeKitsuData(user, tokenData)); const animeCache = await buildAnimeCache(); Config.setKitsuProp('cache', animeCache); } static getFileBinding(libID) { return _gK('fileBindings').find((f) => f.id == libID)?.name; } static setFileBinding(libID, name) { _gK('fileBindings').push({ id: libID, name, }); } static removeFileBinding(libID) { _sK('fileBindings', _gK('fileBindings').filter((fb) => fb.id != libID)); } static async trackAnime(animeID) { const resp = await HTTP.postAPI('https://kitsu.app/api/edge/library-entries', JSON.stringify({ data: { type: 'library-entries', attributes: { status: 'current' }, relationships: { anime: { data: { id: animeID, type: 'anime', }, }, user: { data: { id: _gK('id'), type: 'users', }, }, }, }, }), Config.getKitsuProp('access_token')); const [error, jsonResp] = parseWithZod(z.object({ data: z.object({ id: z.string(), }), }), await resp.json(), 'AddAnimeScheme'); if (error) { const header = error.shift(); Printer.printError([...error], header); process.exit(1); } return jsonResp; } static async dropAnime(libID) { const resp = await HTTP.patch(new URL(`https://kitsu.app/api/edge/library-entries/${libID}`), JSON.stringify({ data: { id: libID, type: 'library-entries', attributes: { status: 'dropped' }, }, }), Config.getKitsuProp('access_token')); const json = await resp.json(); if (json.errors) { throw Error(json); } const [error, jsonResp] = parseWithZod(z.object({ data: z.object({ id: z.string(), attributes: z.object({ status: z.string(), }), }), }), json, 'DropAnimeScheme'); if (error) { const header = error.shift(); Printer.printError([...error], header); process.exit(1); } return jsonResp; } static async updateAnime(url, data) { const urlObj = new URL(url); urlObj.searchParams.append('include', 'anime'); urlObj.searchParams.append('fields[anime]', 'episodeCount'); const tokenExpiresIn = Math.floor(getTimeUnits(Kitsu.tokenInfo.expiresSec - Date.now() / 1000).days); const resp = await HTTP.patch(urlObj, JSON.stringify(data), _gK('access_token')); const resolvedData = await resp.json(); if (!resp.ok) { const errData = resolvedData; if (errData.errors[0].title == 'Invalid token') { Printer.printError('Authentication Token Expired', 'API Error'); process.exit(1); } const errMessage = errData.errors[0].detail ? errData.errors[0].detail : errData.errors[0].title; Printer.printError(errMessage, 'API Error'); process.exit(1); } const [error, libPatchResp] = parseWithZod(LibraryPatchRespSchema, resolvedData, 'LibraryPatchResponse'); if (error) { const header = error.shift(); Printer.printError([...error], header); process.exit(1); } return [ libPatchResp.data.attributes.progress, libPatchResp.included[0].attributes.episodeCount, tokenExpiresIn, ]; } static removeAnimeFromCache(cachedItem, opt = { saveConfig: true }) { _sK('cache', _gK('cache').filter((anime) => anime.libID != cachedItem.libID)); this.removeFileBinding(cachedItem.libID); if (opt.saveConfig) { Config.save(); } return true; } static async rebuildProfile() { const user = await getUserData(_gK('username')); if (!user) { return false; } const { time, completed } = user.stats; const { secondsSpentWatching, completedSeries } = _gK('stats'); const stats = { secondsSpentWatching: time ?? secondsSpentWatching, completedSeries: completed ?? completedSeries, }; _sK('about', user.attributes.about); _sK('stats', stats); Config.save(); return true; } static async findAnime(name, status) { const resp = await HTTP.get(KitsuURLs.getAnimeInfoURL(name, status)); const jsonResp = await resp.json(); const [error, entries] = parseWithZod(KitsuAnimeEntriesSchema, jsonResp, 'AnimeEntries'); if (error) { const header = error.shift(); Printer.printError([...error], header); process.exit(1); } return serializeAnimeInfo(entries.data); } static async findLibraryAnime(name) { const filteredCache = _gK('cache').filter((anime) => { const hasCanonTitle = anime.jpTitle.toLowerCase().includes(name.toLowerCase()); const hasEnglishTitle = anime.enTitle ? anime.enTitle.toLowerCase().includes(name.toLowerCase()) : false; const hasAltTitle = anime.synonyms.some((s) => s.toLowerCase().includes(name)); return hasCanonTitle || hasEnglishTitle || hasAltTitle; }); if (!filteredCache.length) return []; const libraryAnimeURL = KitsuURLs.getLibraryAnimeInfoURL(filteredCache.map((a) => a.libID)); const resp = await HTTP.get(libraryAnimeURL); const [error, entries] = parseWithZod(LibraryEntriesSchema, await resp.json(), 'LibraryEntries'); if (error) { const header = error.shift(); Printer.printError([...error], header); process.exit(1); } return serializeLibraryAnimeInfo(filteredCache, entries); } static findCachedAnime(title) { const lowerTitle = title.toLowerCase(); const cachedAnime = []; for (let i = 0; i < Kitsu.animeCache.length; i++) { const a = Kitsu.animeCache[i]; const isCached = a.jpTitle.toLowerCase().includes(lowerTitle) || a.enTitle?.toLowerCase().includes(lowerTitle) || a.synonyms.some((s) => s.toLowerCase().includes(lowerTitle)); if (isCached) { cachedAnime.push([a, i]); } } return cachedAnime; } static async rebuildCache() { const cachedAnime = await buildAnimeCache(); _sK('cache', cachedAnime); Config.save(); return { cachedAnimeCount: cachedAnime.length }; } static async refreshToken() { const credentials = JSON.stringify({ grant_type: 'refresh_token', refresh_token: _gK('refresh_token'), }); const resp = await HTTP.post(_tokenURL, credentials); const tokenResp = await tryGetDataFromResp(resp); saveTokenData(serializeTokenData(tokenResp)); } static displayUserProfile() { const stats = _gK('stats'); const { allTimeStr, hoursAndMinutesLeft } = getColoredTimeWatchedStr(stats.secondsSpentWatching); Printer.print([ null, ['h3', ['User Profile']], null, ['py', ['Name', `${_gK('username')}`], 6], ['py', ['About', `${_gK('about')}`], 5], ['', `;c;Link: ;g;${_gK('urls').profile}`, 9], ['py', ['Watch Time', `${allTimeStr} ;m;or ${hoursAndMinutesLeft}`]], ['py', ['Watching', `;y;${_gK('cache').length} ;g;Series`], 2], ['py', ['Time Left', `;y;${toWatchTimeLeft(_gK('cache'))}`], 1], ['py', ['Completed', `;y;${stats.completedSeries} ;g;Series`], 1], null, ]); } static async resetToken() { Printer.print([ null, null, [ 'p', 'You will need to provide your ;x;kitsu.app ;bk;password so that we can ' + 'reset your ;x;Access Token;bk;. You only need to do this if your ' + ';x;Access Token ;bk;is about to ;m;expire;bk;. You can check this\n' + 'by typing the following command:', ], null, ['p', ';by;wak ;bc;-t ;y;info'], null, ]); const password = await promptPassword(); const tokenData = await grantTokenData(_gK('username'), password); saveTokenData(tokenData); } } function saveTokenData(data) { _sK('token_expiration', data.token_expiration); _sK('access_token', data.access_token); _sK('refresh_token', data.refresh_token); Config.save(); } async function tryGetSetupConsent() { const hasSetupConsent = await Printer.promptYesNo(`Proceed with setup`); if (!hasSetupConsent) { Printer.printWarning('You have decided not to consent', 'Setup Aborted'); process.exit(0); } } async function promptUser() { const username = await Printer.prompt(`Enter Kitsu username:`); const user = await getUserData(username); if (!user) { Printer.printError(`;c;${username};y; not found`); return await promptUser(); } Printer.print([ null, ['py', ['Name', `${user.attributes.name}`], 3], ['py', ['Profile', `;g;https://kitsu.app/users/${user.attributes.name}`]], ['py', ['About', `${user.attributes.about}`], 2], null, ]); const isVerifiedUser = await Printer.promptYesNo(`Is the above info correct`); if (!isVerifiedUser) { return await promptUser(); } return user; } async function getUserData(userName) { const url = buildUserDataURL(userName); const resp = await HTTP.get(url); const resolvedResp = await resp.json(); if (!resolvedResp.data.length) { return null; } const [error, user] = parseWithZod(UserDataRespSchema, resolvedResp, 'UserData'); if (error) { const header = error.shift(); Printer.printError([...error], header); process.exit(1); } return { ...user.data[0], stats: { ...user.included[0].attributes.statsData, }, }; } function buildUserDataURL(userName) { const url = new URL('https://kitsu.app/api/edge/users'); url.searchParams.append('filter[name]', userName); url.searchParams.append('include', 'stats'); return url; } async function promptPassword() { return await Printer.prompt(`Enter password:`); } async function grantTokenData(username, password) { const credentials = JSON.stringify({ grant_type: 'password', username: username, password: password, }); const resp = await HTTP.post(_tokenURL, credentials); const tokenResp = await tryGetDataFromResp(resp); return serializeTokenData(tokenResp); } function areStatsDefined(data) { return typeof data.stats.time == 'number' && typeof data.stats.completed == 'number'; } function serializeKitsuData(user, serializedTokenData) { return { id: user.id, urls: { profile: `https://kitsu.app/users/${user.attributes.name}`, library: `https://kitsu.app/api/edge/users/${user.id}/library-entries`, }, stats: { secondsSpentWatching: user.stats.time, completedSeries: user.stats.completed, }, about: user.attributes.about, username: user.attributes.name, ...serializedTokenData, fileBindings: [], cache: [], }; } async function tryGetDataFromResp(resp) { const data = await resp.json(); if (!resp.ok) { const header = data['error']; Printer.printError([ data['error_description'], '', ';bc;... ;y;Possible Issues ;bc;...', '(;bc;1;y;) ;c;Make sure you entered the correct password.', ], header); process.exit(1); } return data; } async function buildAnimeCache() { const resp = await HTTP.get(KitsuURLs.getWatchListURL()); const [error, library] = parseWithZod(LibraryInfoSchema, await resp.json(), 'Library'); if (error) { const header = error.shift(); Printer.printError([...error], header); process.exit(1); } const cache = []; library.included.forEach((anime, i) => { cache.push({ libID: library.data[i].id, jpTitle: anime.attributes.canonicalTitle.trim(), enTitle: anime.attributes.titles.en ? anime.attributes.titles.en.trim() : '', epCount: anime.attributes.episodeCount ?? 0, epProgress: library.data[i].attributes.progress || 0, synonyms: anime.attributes.abbreviatedTitles, slug: anime.attributes.slug.replaceAll(' ', '%20'), }); }); return cache; } function serializeAnimeInfo(entries) { return entries.map((entry) => ({ id: entry.id, jpTitle: entry.attributes.titles.en_jp, enTitle: entry.attributes.titles.en, usTitle: entry.attributes.titles.en_us, synonyms: entry.attributes.abbreviatedTitles, epCount: entry.attributes.episodeCount ?? 0, slug: `${entry.attributes.slug.replaceAll(' ', '%20')}`, synopsis: entry.attributes.synopsis ?? '', avgRating: entry.attributes.averageRating ? `${(Number(entry.attributes.averageRating) / 10).toFixed(2)}` : 'Not Calculated Yet', })); } function serializeLibraryAnimeInfo(cacheList, entries) { return cacheList.map((cache, i) => { const rating = entries.data[i].attributes.ratingTwenty; const avgRating = entries.included[i].attributes.averageRating; const anime = { title_jp: cache.jpTitle, title_en: cache.enTitle ? cache.enTitle : '', synonyms: cache.synonyms, epProgress: entries.data[i].attributes.progress, rating: rating ? `${(rating / 20) * 10}` : rating, epCount: entries.included[i].attributes.episodeCount, synopsis: entries.included[i].attributes.synopsis, link: `https://kitsu.app/anime/${cache.slug}`, avgRating: avgRating ? `${(Number(avgRating) / 10).toFixed(2)}` : 'Not Calculated Yet', }; return anime; }); } function serializeTokenData(tokenData) { return { access_token: tokenData.access_token, refresh_token: tokenData.refresh_token, token_expiration: Math.floor(tokenData.expires_in + Date.now() / 1000), }; } function toWatchTimeLeft(cache) { const episodesLeft = cache.reduce((pv, cv) => (cv.epCount > 0 ? pv + (cv.epCount - cv.epProgress) : pv), 0); const timeLeft = getTimeUnits(episodesLeft * 24 * 60); return timeLeft.hours > 2 ? `${timeLeft.hours.toFixed(0)} ;g;hours, ;y;${Math.ceil((timeLeft.hours % 1) * 60)} ;g;Minutes` : `${timeLeft.minutes} Minutes`; } //# sourceMappingURL=kitsu.js.map