diff --git a/assets/kitsu.png b/assets/kitsu.png new file mode 100644 index 00000000..4d487aaa Binary files /dev/null and b/assets/kitsu.png differ diff --git a/src/hooks/persisted/useTrackedNovel.ts b/src/hooks/persisted/useTrackedNovel.ts index e60a31e1..e02cad3f 100644 --- a/src/hooks/persisted/useTrackedNovel.ts +++ b/src/hooks/persisted/useTrackedNovel.ts @@ -56,6 +56,7 @@ export const useTrackedNovel = (novelId: number | 'NO_ID') => { 'AniList', 'MyAnimeList', 'MangaUpdates', + 'Kitsu', ]; trackerNames.forEach(trackerName => { diff --git a/src/hooks/persisted/useTracker.ts b/src/hooks/persisted/useTracker.ts index f854840b..9232928d 100644 --- a/src/hooks/persisted/useTracker.ts +++ b/src/hooks/persisted/useTracker.ts @@ -2,6 +2,7 @@ import { AuthenticationResult, Tracker, TrackerName } from '@services/Trackers'; import { aniListTracker } from '@services/Trackers/aniList'; import { myAnimeListTracker } from '@services/Trackers/myAnimeList'; import { mangaUpdatesTracker } from '@services/Trackers/mangaUpdates'; +import { kitsuTracker } from '@services/Trackers/kitsu'; import { useMMKVObject, useMMKVString } from 'react-native-mmkv'; import { useEffect } from 'react'; import { @@ -23,6 +24,7 @@ const trackers: Record = { AniList: aniListTracker, MyAnimeList: myAnimeListTracker, MangaUpdates: mangaUpdatesTracker, + Kitsu: kitsuTracker, }; export const getTracker = (name: TrackerName) => { diff --git a/src/screens/novel/components/Tracker/ScoreSelectors.tsx b/src/screens/novel/components/Tracker/ScoreSelectors.tsx index 8648dae5..1931da9b 100644 --- a/src/screens/novel/components/Tracker/ScoreSelectors.tsx +++ b/src/screens/novel/components/Tracker/ScoreSelectors.tsx @@ -6,6 +6,7 @@ import { RadioButton, RadioButtonGroup } from '@components/RadioButton'; import { useTheme } from '@hooks/persisted'; import { getAniListScoreFormatting, + getKitsuScoreFormatting, getMyAnimeListScoreLabel, } from './constants'; import { @@ -141,6 +142,41 @@ export const AniListScoreSelector: React.FC = ({ ); }; +/** + * Kitsu uses a 0.5-10 scale (displayed as half-increments). + * Score values: 0 (no score), 1 (0.5), 2 (1.0), 3 (1.5), ... 20 (10.0) + */ +export const KitsuScoreSelector: React.FC = ({ + trackItem, + onUpdateScore, +}) => { + const theme = useTheme(); + const formatting = getKitsuScoreFormatting(); + const scores = Array.from({ length: formatting.count }, (_, i) => i); + + const handleValueChange = (value: string) => { + onUpdateScore(Number(value)); + }; + + return ( + + + {scores.map(score => ( + + ))} + + + ); +}; + const styles = StyleSheet.create({ errorText: { fontSize: 12, diff --git a/src/screens/novel/components/Tracker/SetTrackScoreDialog.tsx b/src/screens/novel/components/Tracker/SetTrackScoreDialog.tsx index 70806793..92bb2b16 100644 --- a/src/screens/novel/components/Tracker/SetTrackScoreDialog.tsx +++ b/src/screens/novel/components/Tracker/SetTrackScoreDialog.tsx @@ -5,6 +5,7 @@ import { Button, DialogTitle, Modal } from '@components'; import { getString } from '@strings/translations'; import { AniListScoreSelector, + KitsuScoreSelector, MangaUpdatesScoreSelector, MyAnimeListScoreSelector, } from './ScoreSelectors'; @@ -46,6 +47,13 @@ const SetTrackScoreDialog: React.FC = ({ onUpdateScore={setSelectedScore} /> ); + case 'Kitsu': + return ( + + ); case 'AniList': default: return ( diff --git a/src/screens/novel/components/Tracker/TrackerCards.tsx b/src/screens/novel/components/Tracker/TrackerCards.tsx index a5e902c8..5944167d 100644 --- a/src/screens/novel/components/Tracker/TrackerCards.tsx +++ b/src/screens/novel/components/Tracker/TrackerCards.tsx @@ -3,7 +3,10 @@ import { Image, Pressable, StyleSheet, Text, View } from 'react-native'; import { IconButton } from 'react-native-paper'; import { useTheme } from '@hooks/persisted'; -import { getAniListScoreFormatting } from './constants'; +import { + getAniListScoreFormatting, + getKitsuScoreFormatting, +} from './constants'; import { AddTrackingCardProps, TrackedItemCardProps } from './types'; export const AddTrackingCard: React.FC = ({ @@ -60,12 +63,16 @@ export const TrackedItemCard: React.FC = ({ } if (tracker.name === 'MangaUpdates') { - // Show decimal for MangaUpdates if it has decimal places + /* Show decimal for MangaUpdates if it has decimal places */ return Number.isInteger(trackItem.score) ? trackItem.score.toString() : trackItem.score.toFixed(1); } + if (tracker.name === 'Kitsu') { + return getKitsuScoreFormatting().label(trackItem.score); + } + return trackItem.score; }, [tracker, trackItem.score]); diff --git a/src/screens/novel/components/Tracker/constants.ts b/src/screens/novel/components/Tracker/constants.ts index 9d0c0992..61dff640 100644 --- a/src/screens/novel/components/Tracker/constants.ts +++ b/src/screens/novel/components/Tracker/constants.ts @@ -7,6 +7,7 @@ export const TRACKER_ICONS: Record = { MyAnimeList: require('../../../../../assets/mal.png'), MangaUpdates: require('../../../../../assets/mangaupdates.png'), AniList: require('../../../../../assets/anilist.png'), + Kitsu: require('../../../../../assets/kitsu.png'), }; export const STATUS_LABELS: Record = { @@ -91,3 +92,25 @@ export const getAniListScoreFormatting = ( }; } }; + +/** + * Kitsu uses a 0.5-10 scale (displayed as half-increments). + * Internally stored as ratingTwenty (2-20), displayed as ratingTwenty/2. + * This provides 21 options: 0 (no score), 0.5, 1.0, 1.5, ... 10.0 + */ +export const getKitsuScoreFormatting = (): ScoreFormatting => { + return { + count: 21, + label: score => { + if (score === 0) { + return '-'; + } + /* Convert index to Kitsu's half-point scale (0.5-10) */ + const displayScore = score / 2; + return displayScore.toLocaleString(undefined, { + minimumFractionDigits: displayScore % 1 === 0 ? 0 : 1, + maximumFractionDigits: 1, + }); + }, + }; +}; diff --git a/src/screens/settings/SettingsTrackerScreen.tsx b/src/screens/settings/SettingsTrackerScreen.tsx index 80460cb8..f083d312 100644 --- a/src/screens/settings/SettingsTrackerScreen.tsx +++ b/src/screens/settings/SettingsTrackerScreen.tsx @@ -12,8 +12,9 @@ import { getTracker, useTheme, useTracker } from '@hooks/persisted'; import { Appbar, List, Modal, SafeAreaView } from '@components'; import { TrackerSettingsScreenProps } from '@navigators/types'; import { getString } from '@strings/translations'; -import MangaUpdatesLoginDialog from './components/MangaUpdatesLoginDialog'; -import { authenticateWithCredentials } from '@services/Trackers/mangaUpdates'; +import TrackerLoginDialog from './components/TrackerLoginDialog'; +import { authenticateWithCredentials as mangaUpdatesAuth } from '@services/Trackers/mangaUpdates'; +import { authenticateWithCredentials as kitsuAuth } from '@services/Trackers/kitsu'; import { showToast } from '@utils/showToast'; interface TrackerCheckIconProps { @@ -62,6 +63,15 @@ const MangaUpdatesLogo = () => ( ); +const KitsuLogo = () => ( + + + +); + const TrackerScreen = ({ navigation }: TrackerSettingsScreenProps) => { const theme = useTheme(); const { isTrackerAuthenticated, setTracker, removeTracker, getTrackerAuth } = @@ -79,26 +89,37 @@ const TrackerScreen = ({ navigation }: TrackerSettingsScreenProps) => { setLogoutTrackerName(''); }; - // MangaUpdates Login Dialog - const [mangaUpdatesLoginVisible, setMangaUpdatesLoginVisible] = - useState(false); - const showMangaUpdatesLogin = () => setMangaUpdatesLoginVisible(true); - const hideMangaUpdatesLogin = () => setMangaUpdatesLoginVisible(false); + // Credential-based Login Dialog (MangaUpdates, Kitsu) + const [credentialLoginTracker, setCredentialLoginTracker] = useState< + 'MangaUpdates' | 'Kitsu' | null + >(null); + const showCredentialLogin = (tracker: 'MangaUpdates' | 'Kitsu') => + setCredentialLoginTracker(tracker); + const hideCredentialLogin = () => setCredentialLoginTracker(null); + + const handleCredentialLogin = async (username: string, password: string) => { + if (!credentialLoginTracker) { + return; + } - const handleMangaUpdatesLogin = async ( - username: string, - password: string, - ) => { try { - const auth = await authenticateWithCredentials(username, password); - setTracker('MangaUpdates', auth); - hideMangaUpdatesLogin(); - showToast('Successfully logged in to MangaUpdates'); + let auth; + if (credentialLoginTracker === 'MangaUpdates') { + auth = await mangaUpdatesAuth(username, password); + } else if (credentialLoginTracker === 'Kitsu') { + auth = await kitsuAuth(username, password); + } else { + throw new Error('Unknown tracker'); + } + + setTracker(credentialLoginTracker, auth); + hideCredentialLogin(); + showToast(`Successfully logged in to ${credentialLoginTracker}`); } catch (error) { if (error instanceof Error) { - throw error; // Let the dialog handle the error display + throw error; /* Let the dialog handle the error display */ } - throw new Error('Failed to authenticate with MangaUpdates'); + throw new Error(`Failed to authenticate with ${credentialLoginTracker}`); } }; @@ -135,6 +156,17 @@ const TrackerScreen = ({ navigation }: TrackerSettingsScreenProps) => { [theme, isTrackerAuthenticated], ); + const renderKitsuRight = useCallback( + (props: any) => ( + + ), + [theme, isTrackerAuthenticated], + ); + return ( @@ -199,35 +231,81 @@ const TrackerScreen = ({ navigation }: TrackerSettingsScreenProps) => { if (isTrackerAuthenticated('MangaUpdates')) { showModal('MangaUpdates'); } else { - showMangaUpdatesLogin(); + showCredentialLogin('MangaUpdates'); } }} rippleColor={theme.rippleColor} style={styles.listItem} /> - {isTrackerAuthenticated('MyAnimeList') && + { + if (isTrackerAuthenticated('Kitsu')) { + showModal('Kitsu'); + } else { + showCredentialLogin('Kitsu'); + } + }} + rippleColor={theme.rippleColor} + style={styles.listItem} + /> + {(isTrackerAuthenticated('MyAnimeList') && getTrackerAuth('MyAnimeList')?.auth?.expiresAt && getTrackerAuth('MyAnimeList')!.auth.expiresAt < - new Date(Date.now()) ? ( + new Date(Date.now())) || + (isTrackerAuthenticated('Kitsu') && + getTrackerAuth('Kitsu')?.auth?.expiresAt && + getTrackerAuth('Kitsu')!.auth.expiresAt < new Date(Date.now())) ? ( <> {getString('common.settings')} - { - const trackerAuth = getTrackerAuth('MyAnimeList'); - const revalidate = getTracker('MyAnimeList')?.revalidate; - if (revalidate && trackerAuth) { - const auth = await revalidate(trackerAuth.auth); - setTracker('MyAnimeList', auth); - } - }} - theme={theme} - /> + {isTrackerAuthenticated('MyAnimeList') && + getTrackerAuth('MyAnimeList')?.auth?.expiresAt && + getTrackerAuth('MyAnimeList')!.auth.expiresAt < + new Date(Date.now()) && ( + { + const trackerAuth = getTrackerAuth('MyAnimeList'); + const revalidate = getTracker('MyAnimeList')?.revalidate; + if (revalidate && trackerAuth) { + const auth = await revalidate(trackerAuth.auth); + setTracker('MyAnimeList', auth); + } + }} + theme={theme} + /> + )} + {isTrackerAuthenticated('Kitsu') && + getTrackerAuth('Kitsu')?.auth?.expiresAt && + getTrackerAuth('Kitsu')!.auth.expiresAt < + new Date(Date.now()) && ( + { + const trackerAuth = getTrackerAuth('Kitsu'); + const revalidate = getTracker('Kitsu')?.revalidate; + if (revalidate && trackerAuth) { + try { + const auth = await revalidate(trackerAuth.auth); + setTracker('Kitsu', auth); + showToast('Successfully refreshed Kitsu session'); + } catch (error) { + showToast('Failed to refresh Kitsu session. Please log in again.'); + removeTracker('Kitsu'); + } + } + }} + theme={theme} + /> + )} ) : null} @@ -265,10 +343,14 @@ const TrackerScreen = ({ navigation }: TrackerSettingsScreenProps) => { - diff --git a/src/screens/settings/components/MangaUpdatesLoginDialog.tsx b/src/screens/settings/components/TrackerLoginDialog.tsx similarity index 90% rename from src/screens/settings/components/MangaUpdatesLoginDialog.tsx rename to src/screens/settings/components/TrackerLoginDialog.tsx index b55724c8..c24716a9 100644 --- a/src/screens/settings/components/MangaUpdatesLoginDialog.tsx +++ b/src/screens/settings/components/TrackerLoginDialog.tsx @@ -4,16 +4,20 @@ import { Text, Button } from 'react-native-paper'; import { Modal } from '@components'; import { useTheme } from '@hooks/persisted'; -interface MangaUpdatesLoginDialogProps { +interface TrackerLoginDialogProps { visible: boolean; + trackerName: string; onDismiss: () => void; onSubmit: (username: string, password: string) => Promise; + usernameLabel?: string; } -const MangaUpdatesLoginDialog: React.FC = ({ +const TrackerLoginDialog: React.FC = ({ visible, + trackerName, onDismiss, onSubmit, + usernameLabel = 'Username', }) => { const theme = useTheme(); @@ -24,7 +28,7 @@ const MangaUpdatesLoginDialog: React.FC = ({ const handleSubmit = async () => { if (!username.trim() || !password.trim()) { - setError('Username and password are required'); + setError(`${usernameLabel} and password are required`); return; } @@ -33,7 +37,7 @@ const MangaUpdatesLoginDialog: React.FC = ({ try { await onSubmit(username.trim(), password); - // Clear form on success + /* Clear form on success */ setUsername(''); setPassword(''); } catch (err) { @@ -54,7 +58,7 @@ const MangaUpdatesLoginDialog: React.FC = ({ - Login to MangaUpdates + Login to {trackerName} = ({ borderColor: theme.outline, }, ]} - placeholder="Username" + placeholder={usernameLabel} placeholderTextColor={theme.onSurfaceVariant} value={username} onChangeText={setUsername} @@ -124,7 +128,7 @@ const MangaUpdatesLoginDialog: React.FC = ({ ); }; -export default MangaUpdatesLoginDialog; +export default TrackerLoginDialog; const styles = StyleSheet.create({ container: { diff --git a/src/services/Trackers/index.ts b/src/services/Trackers/index.ts index 736a614e..db5e00ec 100644 --- a/src/services/Trackers/index.ts +++ b/src/services/Trackers/index.ts @@ -1,4 +1,4 @@ -export type TrackerName = 'AniList' | 'MyAnimeList' | 'MangaUpdates'; +export type TrackerName = 'AniList' | 'MyAnimeList' | 'MangaUpdates' | 'Kitsu'; /** * Normalized list status values. * Not all trackers will use all of these. diff --git a/src/services/Trackers/kitsu.ts b/src/services/Trackers/kitsu.ts new file mode 100644 index 00000000..780fc999 --- /dev/null +++ b/src/services/Trackers/kitsu.ts @@ -0,0 +1,489 @@ +import { AuthenticationResult, Tracker, UserListStatus } from './index'; + +/** + * Kitsu Tracker Implementation + * + * Kitsu uses username/password authentication with OAuth2 client credentials. + * It supports refresh tokens for session renewal. + */ + +const BASE_URL = 'https://kitsu.app/api/edge/'; +const LOGIN_URL = 'https://kitsu.app/api/oauth/token'; +const ALGOLIA_KEY_URL = 'https://kitsu.app/api/edge/algolia-keys/media/'; + +const ALGOLIA_APP_ID = 'AWQO5J657S'; +const ALGOLIA_URL = `https://${ALGOLIA_APP_ID}-dsn.algolia.net/1/indexes/production_media/query/`; +const ALGOLIA_FILTER = + '&facetFilters=%5B%22kind%3Amanga%22%5D&attributesToRetrieve=' + + '%5B%22synopsis%22%2C%22averageRating%22%2C%22canonicalTitle%22%2C%22chapterCount%22%2C%22' + + 'posterImage%22%2C%22startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D'; + +/* Kitsu public OAuth credentials */ +const CLIENT_ID = + 'dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd'; +const CLIENT_SECRET = + '54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151'; + +const VND_API_JSON = 'application/vnd.api+json'; + +/* Kitsu status mapping */ +const KITSU_STATUS = { + CURRENT: 'current', + COMPLETED: 'completed', + ON_HOLD: 'on_hold', + DROPPED: 'dropped', + PLAN_TO_READ: 'planned', +} as const; + +export const kitsuToNormalized: Record = { + current: 'CURRENT', + completed: 'COMPLETED', + on_hold: 'PAUSED', + dropped: 'DROPPED', + planned: 'PLANNING', +}; + +const normalizedToKitsu: Record = { + CURRENT: 'current', + COMPLETED: 'completed', + PAUSED: 'on_hold', + DROPPED: 'dropped', + PLANNING: 'planned', + REPEATING: 'current', +}; + +type KitsuAuthMeta = { + userId: string; + refreshToken: string; + createdAt: number; +}; + +type KitsuOAuth = { + access_token: string; + token_type: string; + created_at: number; + expires_in: number; + refresh_token: string; +}; + +type KitsuAlgoliaSearchItem = { + id: number; + canonicalTitle: string; + chapterCount: number | null; + subtype: string | null; + posterImage: { + original: string; + large?: string; + medium?: string; + small?: string; + tiny?: string; + } | null; + synopsis: string | null; +}; + +type KitsuLibraryEntry = { + id: string; + attributes: { + status: string; + progress: number; + ratingTwenty: number | null; + startedAt: string | null; + finishedAt: string | null; + }; +}; + +/** + * Authenticates with Kitsu using username and password. + * This function is called from the login dialog in settings. + */ +export async function authenticateWithCredentials( + username: string, + password: string, +): Promise> { + const formBody = new URLSearchParams({ + username, + password, + grant_type: 'password', + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + }).toString(); + + const response = await fetch(LOGIN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formBody, + }); + + if (response.status !== 200) { + const error = await response.json().catch(() => ({})); + throw new Error(error.error_description || 'Invalid username or password'); + } + + const oauth: KitsuOAuth = await response.json(); + + /* Get current user ID */ + const userId = await getCurrentUser(oauth.access_token); + + return { + accessToken: oauth.access_token, + refreshToken: oauth.refresh_token, + expiresAt: new Date((oauth.created_at + oauth.expires_in) * 1000), + meta: { + userId, + refreshToken: oauth.refresh_token, + createdAt: oauth.created_at, + }, + }; +} + +async function getCurrentUser(accessToken: string): Promise { + const url = `${BASE_URL}users?filter[self]=true`; + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: VND_API_JSON, + }, + }); + + if (response.status !== 200) { + throw new Error('Failed to get current user'); + } + + const data = await response.json(); + return data.data[0].id; +} + +async function getAlgoliaKey(accessToken: string): Promise { + const response = await fetch(ALGOLIA_KEY_URL, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: VND_API_JSON, + }, + }); + + if (response.status !== 200) { + throw new Error('Failed to get Algolia key'); + } + + const data = await response.json(); + return data.media.key; +} + +async function algoliaSearch( + key: string, + query: string, +): Promise { + const jsonBody = JSON.stringify({ + params: `query=${encodeURIComponent(query)}${ALGOLIA_FILTER}`, + }); + + const response = await fetch(ALGOLIA_URL, { + method: 'POST', + headers: { + 'X-Algolia-Application-Id': ALGOLIA_APP_ID, + 'X-Algolia-API-Key': key, + 'Content-Type': 'application/json', + }, + body: jsonBody, + }); + + if (response.status !== 200) { + return []; + } + + const data = await response.json(); + return data.hits || []; +} + +async function findLibraryEntry( + mediaId: number | string, + userId: string, + accessToken: string, +): Promise<{ libraryId: string; entry: KitsuLibraryEntry } | null> { + const url = `${BASE_URL}library-entries?filter[manga_id]=${mediaId}&filter[user_id]=${userId}&include=manga`; + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: VND_API_JSON, + }, + }); + + if (response.status !== 200) { + return null; + } + + const data = await response.json(); + + if (data.data && data.data.length > 0) { + return { + libraryId: data.data[0].id, + entry: data.data[0], + }; + } + + return null; +} + +async function addToLibrary( + mediaId: number | string, + userId: string, + status: string, + progress: number, + accessToken: string, +): Promise { + const body = JSON.stringify({ + data: { + type: 'libraryEntries', + attributes: { + status, + progress, + }, + relationships: { + user: { + data: { + id: userId, + type: 'users', + }, + }, + media: { + data: { + id: String(mediaId), + type: 'manga', + }, + }, + }, + }, + }); + + const response = await fetch(`${BASE_URL}library-entries`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': VND_API_JSON, + 'Accept': VND_API_JSON, + }, + body, + }); + + if (response.status !== 201 && response.status !== 200) { + throw new Error('Failed to add to library'); + } + + const data = await response.json(); + return data.data.id; +} + +async function updateLibraryEntry( + libraryId: string, + status: string, + progress: number, + ratingTwenty: number | null, + accessToken: string, +): Promise { + const body = JSON.stringify({ + data: { + type: 'libraryEntries', + id: libraryId, + attributes: { + status, + progress, + ratingTwenty, + }, + }, + }); + + const response = await fetch(`${BASE_URL}library-entries/${libraryId}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': VND_API_JSON, + 'Accept': VND_API_JSON, + }, + body, + }); + + if (response.status !== 200) { + throw new Error('Failed to update library entry'); + } +} + +/** + * Converts a 0-10 score to Kitsu's ratingTwenty (0-20 scale). + * Kitsu uses a 20-point scale internally (2-20), displayed as 0.5-10.0. + */ +function scoreToRatingTwenty(score: number): number | null { + if (score <= 0) { + return null; + } + /* Convert 0-10 to 0-20, ensuring minimum of 2 if score > 0 */ + return Math.max(2, Math.round(score * 2)); +} + +/** + * Converts Kitsu's ratingTwenty (0-20 scale) to a 0-10 score. + */ +function ratingTwentyToScore(ratingTwenty: number | null): number { + if (ratingTwenty === null || ratingTwenty <= 0) { + return 0; + } + return ratingTwenty / 2; +} + +export const kitsuTracker: Tracker = { + authenticate: async () => { + /** + * Authentication is handled by authenticateWithCredentials + * which is called from the login dialog in settings. + */ + throw new Error('Please use the login dialog to authenticate with Kitsu'); + }, + + revalidate: async auth => { + if (!auth.meta?.refreshToken) { + throw new Error('No refresh token available'); + } + + const formBody = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: auth.meta.refreshToken, + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + }).toString(); + + const response = await fetch(LOGIN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formBody, + }); + + if (response.status !== 200) { + throw new Error('Session expired, please re-authenticate'); + } + + const oauth: KitsuOAuth = await response.json(); + + return { + accessToken: oauth.access_token, + refreshToken: oauth.refresh_token, + expiresAt: new Date((oauth.created_at + oauth.expires_in) * 1000), + meta: { + userId: auth.meta.userId, + refreshToken: oauth.refresh_token, + createdAt: oauth.created_at, + }, + }; + }, + + handleSearch: async (search, auth) => { + try { + const algoliaKey = await getAlgoliaKey(auth.accessToken); + const hits = await algoliaSearch(algoliaKey, search); + + /* Filter to only include novels (subtype === 'novel') */ + return hits + .filter(hit => hit.subtype === 'novel') + .map(hit => ({ + id: hit.id, + title: hit.canonicalTitle, + coverImage: hit.posterImage?.original || '', + totalChapters: hit.chapterCount || undefined, + })); + } catch (error) { + return []; + } + }, + + getUserListEntry: async (id, auth) => { + try { + const entry = await findLibraryEntry( + id, + auth.meta!.userId, + auth.accessToken, + ); + + if (entry) { + return { + status: kitsuToNormalized[entry.entry.attributes.status] || 'CURRENT', + progress: entry.entry.attributes.progress || 0, + score: ratingTwentyToScore(entry.entry.attributes.ratingTwenty), + }; + } + + /* Entry not found, return defaults */ + return { + status: 'CURRENT', + progress: 0, + score: 0, + }; + } catch (error) { + return { + status: 'CURRENT', + progress: 0, + score: 0, + }; + } + }, + + updateUserListEntry: async (id, payload, auth) => { + const status = payload.status + ? normalizedToKitsu[payload.status] + : KITSU_STATUS.CURRENT; + const progress = Math.round(payload.progress || 0); + const ratingTwenty = scoreToRatingTwenty(payload.score || 0); + + try { + /* Check if entry exists */ + const existingEntry = await findLibraryEntry( + id, + auth.meta!.userId, + auth.accessToken, + ); + + let libraryId: string; + + if (existingEntry) { + libraryId = existingEntry.libraryId; + await updateLibraryEntry( + libraryId, + status, + progress, + ratingTwenty, + auth.accessToken, + ); + } else { + /* Create new entry */ + libraryId = await addToLibrary( + id, + auth.meta!.userId, + status, + progress, + auth.accessToken, + ); + + /* Update with score if provided */ + if (ratingTwenty !== null) { + await updateLibraryEntry( + libraryId, + status, + progress, + ratingTwenty, + auth.accessToken, + ); + } + } + + return { + status: payload.status || 'CURRENT', + progress, + score: payload.score || 0, + }; + } catch (error) { + throw new Error('Failed to update Kitsu entry'); + } + }, +};