feat: Add Kitsu Tracker (Closes #416)

This commit is contained in:
Rajarshee Chatterjee
2026-01-25 14:50:17 +05:30
parent bdd42caaaf
commit c40edd5b2c
11 changed files with 700 additions and 48 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

+1
View File
@@ -56,6 +56,7 @@ export const useTrackedNovel = (novelId: number | 'NO_ID') => {
'AniList',
'MyAnimeList',
'MangaUpdates',
'Kitsu',
];
trackerNames.forEach(trackerName => {
+2
View File
@@ -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<TrackerName, Tracker> = {
AniList: aniListTracker,
MyAnimeList: myAnimeListTracker,
MangaUpdates: mangaUpdatesTracker,
Kitsu: kitsuTracker,
};
export const getTracker = (name: TrackerName) => {
@@ -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<AniListScoreSelectorProps> = ({
);
};
/**
* 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<ScoreSelectorProps> = ({
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 (
<ScrollView>
<RadioButtonGroup
onValueChange={handleValueChange}
value={trackItem.score}
>
{scores.map(score => (
<RadioButton
key={score}
value={score}
label={formatting.label(score)}
theme={theme}
/>
))}
</RadioButtonGroup>
</ScrollView>
);
};
const styles = StyleSheet.create({
errorText: {
fontSize: 12,
@@ -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<TrackScoreDialogProps> = ({
onUpdateScore={setSelectedScore}
/>
);
case 'Kitsu':
return (
<KitsuScoreSelector
trackItem={{ ...trackItem, score: selectedScore }}
onUpdateScore={setSelectedScore}
/>
);
case 'AniList':
default:
return (
@@ -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<AddTrackingCardProps> = ({
@@ -60,12 +63,16 @@ export const TrackedItemCard: React.FC<TrackedItemCardProps> = ({
}
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]);
@@ -7,6 +7,7 @@ export const TRACKER_ICONS: Record<TrackerName, ImageSourcePropType> = {
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<UserListStatus, string> = {
@@ -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,
});
},
};
};
+120 -38
View File
@@ -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 = () => (
</View>
);
const KitsuLogo = () => (
<View style={styles.logoContainer}>
<Image
source={require('../../../assets/kitsu.png')}
style={styles.trackerLogo}
/>
</View>
);
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) => (
<TrackerCheckIcon
{...props}
theme={theme}
checked={isTrackerAuthenticated('Kitsu')}
/>
),
[theme, isTrackerAuthenticated],
);
return (
<SafeAreaView excludeTop>
<Provider>
@@ -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') &&
<PaperList.Item
title="Kitsu"
titleStyle={{ color: theme.onSurface }}
left={KitsuLogo}
right={renderKitsuRight}
onPress={() => {
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())) ? (
<>
<List.Divider theme={theme} />
<List.SubHeader theme={theme}>
{getString('common.settings')}
</List.SubHeader>
<List.Item
title={
getString('trackingScreen.revalidate') + ' Myanimelist'
}
onPress={async () => {
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()) && (
<List.Item
title={
getString('trackingScreen.revalidate') + ' MyAnimeList'
}
onPress={async () => {
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()) && (
<List.Item
title={getString('trackingScreen.revalidate') + ' Kitsu'}
onPress={async () => {
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}
</List.Section>
@@ -265,10 +343,14 @@ const TrackerScreen = ({ navigation }: TrackerSettingsScreenProps) => {
</Button>
</View>
</Modal>
<MangaUpdatesLoginDialog
visible={mangaUpdatesLoginVisible}
onDismiss={hideMangaUpdatesLogin}
onSubmit={handleMangaUpdatesLogin}
<TrackerLoginDialog
visible={credentialLoginTracker !== null}
trackerName={credentialLoginTracker || ''}
onDismiss={hideCredentialLogin}
onSubmit={handleCredentialLogin}
usernameLabel={
credentialLoginTracker === 'Kitsu' ? 'Email' : 'Username'
}
/>
</Portal>
</View>
@@ -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<void>;
usernameLabel?: string;
}
const MangaUpdatesLoginDialog: React.FC<MangaUpdatesLoginDialogProps> = ({
const TrackerLoginDialog: React.FC<TrackerLoginDialogProps> = ({
visible,
trackerName,
onDismiss,
onSubmit,
usernameLabel = 'Username',
}) => {
const theme = useTheme();
@@ -24,7 +28,7 @@ const MangaUpdatesLoginDialog: React.FC<MangaUpdatesLoginDialogProps> = ({
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<MangaUpdatesLoginDialogProps> = ({
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<MangaUpdatesLoginDialogProps> = ({
<Modal visible={visible} onDismiss={handleCancel}>
<View style={styles.container}>
<Text style={[styles.title, { color: theme.onSurface }]}>
Login to MangaUpdates
Login to {trackerName}
</Text>
<TextInput
@@ -66,7 +70,7 @@ const MangaUpdatesLoginDialog: React.FC<MangaUpdatesLoginDialogProps> = ({
borderColor: theme.outline,
},
]}
placeholder="Username"
placeholder={usernameLabel}
placeholderTextColor={theme.onSurfaceVariant}
value={username}
onChangeText={setUsername}
@@ -124,7 +128,7 @@ const MangaUpdatesLoginDialog: React.FC<MangaUpdatesLoginDialogProps> = ({
);
};
export default MangaUpdatesLoginDialog;
export default TrackerLoginDialog;
const styles = StyleSheet.create({
container: {
+1 -1
View File
@@ -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.
+489
View File
@@ -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<string, UserListStatus> = {
current: 'CURRENT',
completed: 'COMPLETED',
on_hold: 'PAUSED',
dropped: 'DROPPED',
planned: 'PLANNING',
};
const normalizedToKitsu: Record<UserListStatus, string> = {
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<AuthenticationResult<KitsuAuthMeta>> {
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<string> {
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<string> {
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<KitsuAlgoliaSearchItem[]> {
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<string> {
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<void> {
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<KitsuAuthMeta> = {
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');
}
},
};