feat: Add Kitsu Tracker (Closes #416)
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
@@ -56,6 +56,7 @@ export const useTrackedNovel = (novelId: number | 'NO_ID') => {
|
||||
'AniList',
|
||||
'MyAnimeList',
|
||||
'MangaUpdates',
|
||||
'Kitsu',
|
||||
];
|
||||
|
||||
trackerNames.forEach(trackerName => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
+11
-7
@@ -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,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.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user