Files
WaifuBoard/desktop/src/api/list/list.service.ts
itsskaiya 28ff6ccc68 Organized the differences between server and docker versions.
We are launching a docker version (server version) today so we want to just organize the repo
so its easier to navigate.
2025-12-16 21:50:22 -05:00

584 lines
18 KiB
TypeScript

import {queryAll, run, queryOne} from '../../shared/database';
import {getExtension} from '../../shared/extensions';
import * as animeService from '../anime/anime.service';
import * as booksService from '../books/books.service';
import * as aniListService from '../anilist/anilist.service';
interface ListEntryData {
entry_type: any;
user_id: number;
entry_id: number;
source: string;
status: string;
progress: number;
score: number | null;
}
const USER_DB = 'userdata';
export async function upsertListEntry(entry: any) {
const {
user_id,
entry_id,
source,
entry_type,
status,
progress,
score,
start_date,
end_date,
repeat_count,
notes,
is_private
} = entry;
let prev: any = null;
try {
prev = await getSingleListEntry(user_id, entry_id, source, entry_type);
} catch {
prev = null;
}
const isNew = !prev;
if (!isNew && prev?.progress != null && progress < prev.progress) {
return { changes: 0, ignored: true };
}
const today = new Date().toISOString().slice(0, 10);
if (prev?.start_date && !entry.start_date) {
entry.start_date = prev.start_date;
}
if (!prev?.start_date && progress === 1) {
entry.start_date = today;
}
const total =
prev?.total_episodes ??
prev?.total_chapters ??
null;
if (total && progress >= total) {
entry.status = 'COMPLETED';
entry.end_date = today;
}
if (source === 'anilist') {
const token = await getActiveAccessToken(user_id);
if (token) {
try {
const result = await aniListService.updateAniListEntry(token, {
mediaId: entry.entry_id,
status: entry.status,
progress: entry.progress,
score: entry.score,
start_date: entry.start_date,
end_date: entry.end_date,
repeat_count: entry.repeat_count,
notes: entry.notes,
is_private: entry.is_private
});
return { changes: 0, external: true, anilistResult: result };
} catch (err) {
console.error("Error actualizando AniList:", err);
}
}
}
const sql = `
INSERT INTO ListEntry
(
user_id, entry_id, source, entry_type, status,
progress, score,
start_date, end_date, repeat_count, notes, is_private,
updated_at
)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, entry_id) DO UPDATE SET
source = EXCLUDED.source,
entry_type = EXCLUDED.entry_type,
status = EXCLUDED.status,
progress = EXCLUDED.progress,
score = EXCLUDED.score,
start_date = EXCLUDED.start_date,
end_date = EXCLUDED.end_date,
repeat_count = EXCLUDED.repeat_count,
notes = EXCLUDED.notes,
is_private = EXCLUDED.is_private,
updated_at = CURRENT_TIMESTAMP;
`;
const params = [
entry.user_id,
entry.entry_id,
entry.source,
entry.entry_type,
entry.status,
entry.progress,
entry.score ?? null,
entry.start_date || null,
entry.end_date || null,
entry.repeat_count ?? 0,
entry.notes || null,
entry.is_private ?? 0
];
try {
const result = await run(sql, params, USER_DB);
return { changes: result.changes, lastID: result.lastID, external: false };
} catch (error) {
console.error("Error al guardar la entrada de lista:", error);
throw new Error("Error en la base de datos al guardar la entrada.");
}
}
export async function getUserList(userId: number): Promise<any> {
const sql = `
SELECT * FROM ListEntry
WHERE user_id = ?
ORDER BY updated_at DESC;
`;
try {
const dbList = await queryAll(sql, [userId], USER_DB) as ListEntryData[];
const connected = await isConnected(userId);
let finalList: any[] = [...dbList];
if (connected) {
const anilistEntries = await aniListService.getUserAniList(userId);
const localWithoutAnilist = dbList.filter(
entry => entry.source !== 'anilist'
);
finalList = [...anilistEntries, ...localWithoutAnilist];
}
const enrichedListPromises = finalList.map(async (entry) => {
if (entry.source === 'anilist' && connected) {
let finalTitle = entry.title;
if (typeof finalTitle === 'object' && finalTitle !== null) {
finalTitle =
finalTitle.userPreferred ||
finalTitle.english ||
finalTitle.romaji ||
'Unknown Title';
}
return {
...entry,
title: finalTitle,
poster: entry.poster || 'https://placehold.co/400x600?text=No+Cover',
};
}
let contentDetails: any | null = null;
const id = entry.entry_id;
const type = entry.entry_type;
const ext = getExtension(entry.source);
try {
if (type === 'ANIME') {
if(entry.source === 'anilist') {
const anime: any = await animeService.getAnimeById(id);
contentDetails = {
title: anime?.title.english || 'Unknown Anime Title',
poster: anime?.coverImage?.extraLarge || '',
total_episodes: anime?.episodes || 0,
};
}
else{
const anime: any = await animeService.getAnimeInfoExtension(ext, id.toString());
contentDetails = {
title: anime?.title || 'Unknown Anime Title',
poster: anime?.image || 'https://placehold.co/400x600?text=No+Cover',
total_episodes: anime?.episodes || 0,
};
}
} else if (type === 'MANGA' || type === 'NOVEL') {
if(entry.source === 'anilist') {
const book: any = await booksService.getBookById(id);
contentDetails = {
title: book?.title.english || 'Unknown Book Title',
poster: book?.coverImage?.extraLarge || 'https://placehold.co/400x600?text=No+Cover',
total_chapters: book?.chapters || book?.volumes * 10 || 0,
};
}
else{
const book: any = await booksService.getBookInfoExtension(ext, id.toString());
contentDetails = {
title: book?.title || 'Unknown Book Title',
poster: book?.image || '',
total_chapters: book?.chapters || book?.volumes * 10 || 0,
};
}
}
} catch {
contentDetails = {
title: 'Error Loading Details',
poster: 'https://placehold.co/400x600?text=No+Cover',
};
}
let finalTitle = contentDetails?.title || 'Unknown Title';
let finalPoster = contentDetails?.poster || 'https://placehold.co/400x600?text=No+Cover';
if (typeof finalTitle === 'object' && finalTitle !== null) {
finalTitle =
finalTitle.userPreferred ||
finalTitle.english ||
finalTitle.romaji ||
'Unknown Title';
}
return {
...entry,
title: finalTitle,
poster: finalPoster,
total_episodes: contentDetails?.total_episodes,
total_chapters: contentDetails?.total_chapters,
};
});
return await Promise.all(enrichedListPromises);
} catch (error) {
console.error("Error al obtener la lista del usuario:", error);
throw new Error("Error getting list.");
}
}
export async function deleteListEntry(
userId: number,
entryId: string | number,
source: string
) {
if (source === 'anilist') {
const token = await getActiveAccessToken(userId);
if (token) {
try {
await aniListService.deleteAniListEntry(
token,
Number(entryId),
);
return { success: true, external: true };
} catch (err) {
console.error("Error borrando en AniList:", err);
}
}
}
const sql = `
DELETE FROM ListEntry
WHERE user_id = ? AND entry_id = ?;
`;
const result = await run(sql, [userId, entryId], USER_DB);
return { success: result.changes > 0, changes: result.changes, external: false };
}
export async function getSingleListEntry(
userId: number,
entryId: string | number,
source: string,
entryType: string
): Promise<any> {
const localSql = `
SELECT * FROM ListEntry
WHERE user_id = ? AND entry_id = ? AND source = ? AND entry_type = ?;
`;
const localResult = await queryAll(
localSql,
[userId, entryId, source, entryType],
USER_DB
) as any[];
if (localResult.length > 0) {
const entry = localResult[0];
const contentDetails: any =
entryType === 'ANIME'
? await animeService.getAnimeById(entryId).catch(() => null)
: await booksService.getBookById(entryId).catch(() => null);
let finalTitle = contentDetails?.title || 'Unknown';
let finalPoster = contentDetails?.coverImage?.extraLarge ||
contentDetails?.image ||
'https://placehold.co/400x600?text=No+Cover';
if (typeof finalTitle === 'object') {
finalTitle =
finalTitle.userPreferred ||
finalTitle.english ||
finalTitle.romaji ||
'Unknown';
}
return {
...entry,
title: finalTitle,
poster: finalPoster,
total_episodes: contentDetails?.episodes,
total_chapters: contentDetails?.chapters,
};
}
if (source === 'anilist') {
const connected = await isConnected(userId);
if (!connected) return null;
const sql = `
SELECT access_token
FROM UserIntegration
WHERE user_id = ? AND platform = 'AniList';
`;
const integration = await queryOne(sql, [userId], USER_DB) as any;
if (!integration?.access_token) return null;
if (entryType === 'NOVEL') {entryType = 'MANGA'}
const aniEntry = await aniListService.getSingleAniListEntry(
integration.access_token,
Number(entryId),
entryType as any
);
if (!aniEntry) return null;
const contentDetails: any =
entryType === 'ANIME'
? await animeService.getAnimeById(entryId).catch(() => null)
: await booksService.getBookById(entryId).catch(() => null);
let finalTitle = contentDetails?.title || 'Unknown';
let finalPoster = contentDetails?.coverImage?.extraLarge ||
contentDetails?.image ||
'https://placehold.co/400x600?text=No+Cover';
if (typeof finalTitle === 'object') {
finalTitle =
finalTitle.userPreferred ||
finalTitle.english ||
finalTitle.romaji ||
'Unknown';
}
return {
user_id: userId,
...aniEntry,
title: finalTitle,
poster: finalPoster,
total_episodes: contentDetails?.episodes,
total_chapters: contentDetails?.chapters,
};
}
return null;
}
export async function getActiveAccessToken(userId: number): Promise<string | null> {
const sql = `
SELECT access_token, expires_at
FROM UserIntegration
WHERE user_id = ? AND platform = 'AniList';
`;
try {
const integration = await queryOne(sql, [userId], USER_DB) as any | null;
if (!integration) {
return null;
}
const expiryDate = new Date(integration.expires_at);
const now = new Date();
const fiveMinutes = 5 * 60 * 1000;
if (expiryDate.getTime() < (now.getTime() + fiveMinutes)) {
console.log(`AniList token for user ${userId} expired or near expiry.`);
return null;
}
return integration.access_token;
} catch (error) {
console.error("Error al verificar la integración de AniList:", error);
return null;
}
}
export async function isConnected(userId: number): Promise<boolean> {
const token = await getActiveAccessToken(userId);
return !!token;
}
export async function getUserListByFilter(
userId: number,
status?: string,
entryType?: string
): Promise<any> {
let sql = `
SELECT * FROM ListEntry
WHERE user_id = ?
ORDER BY updated_at DESC;
`;
const params: any[] = [userId];
try {
const dbList = await queryAll(sql, params, USER_DB) as ListEntryData[];
const connected = await isConnected(userId);
const statusMap: any = {
watching: 'CURRENT',
reading: 'CURRENT',
completed: 'COMPLETED',
paused: 'PAUSED',
dropped: 'DROPPED',
planning: 'PLANNING'
};
const mappedStatus = status ? statusMap[status.toLowerCase()] : null;
let finalList: any[] = [];
const filteredLocal = dbList.filter((entry) => {
if (mappedStatus && entry.status !== mappedStatus) return false;
if (entryType) {
if (entryType === 'MANGA') {
if (!['MANGA', 'NOVEL'].includes(entry.entry_type)) return false;
} else {
if (entry.entry_type !== entryType) return false;
}
}
return true;
});
let filteredAniList: any[] = [];
if (connected) {
const anilistEntries = await aniListService.getUserAniList(userId);
filteredAniList = anilistEntries.filter((entry: any) => {
if (mappedStatus && entry.status !== mappedStatus) return false;
if (entryType) {
if (entryType === 'MANGA') {
if (!['MANGA', 'NOVEL'].includes(entry.entry_type)) return false;
} else {
if (entry.entry_type !== entryType) return false;
}
}
return true;
});
}
finalList = [...filteredAniList, ...filteredLocal];
const enrichedListPromises = finalList.map(async (entry) => {
if (entry.source === 'anilist') {
let finalTitle = entry.title;
if (typeof finalTitle === 'object' && finalTitle !== null) {
finalTitle =
finalTitle.userPreferred ||
finalTitle.english ||
finalTitle.romaji ||
'Unknown Title';
}
return {
...entry,
title: finalTitle,
poster: entry.poster || 'https://placehold.co/400x600?text=No+Cover',
};
}
let contentDetails: any | null = null;
const id = entry.entry_id;
const type = entry.entry_type;
const ext = getExtension(entry.source);
try {
if (type === 'ANIME') {
const anime: any = await animeService.getAnimeInfoExtension(ext, id.toString());
contentDetails = {
title: anime?.title || 'Unknown Anime Title',
poster: anime?.image || '',
total_episodes: anime?.episodes || 0,
};
} else if (type === 'MANGA' || type === 'NOVEL') {
const book: any = await booksService.getBookInfoExtension(ext, id.toString());
contentDetails = {
title: book?.title || 'Unknown Book Title',
poster: book?.image || '',
total_chapters: book?.chapters || book?.volumes * 10 || 0,
};
}
} catch {
contentDetails = {
title: 'Error Loading Details',
poster: 'https://placehold.co/400x600?text=No+Cover',
};
}
let finalTitle = contentDetails?.title || 'Unknown Title';
let finalPoster = contentDetails?.poster || 'https://placehold.co/400x600?text=No+Cover';
if (typeof finalTitle === 'object' && finalTitle !== null) {
finalTitle =
finalTitle.userPreferred ||
finalTitle.english ||
finalTitle.romaji ||
'Unknown Title';
}
return {
...entry,
title: finalTitle,
poster: finalPoster,
total_episodes: contentDetails?.total_episodes,
total_chapters: contentDetails?.total_chapters,
};
});
return await Promise.all(enrichedListPromises);
} catch (error) {
console.error("Error al filtrar la lista del usuario:", error);
throw new Error("Error en la base de datos al obtener la lista filtrada.");
}
}