added mangaupdates integration
cleanup db related code cleanup ui ui eye candy
This commit is contained in:
33
src/data/api/AniListApi.ts
Normal file
33
src/data/api/AniListApi.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type {AniListUser} from '@/data/models/anilist/AniListUser';
|
||||
import {handleJsonResponse} from '@/data/api/ApiUtils';
|
||||
import type {AniListMangaListCollection} from '@/data/models/anilist/AniListMangaListCollection';
|
||||
|
||||
export default class AniListApi {
|
||||
async fetchUser(userName: string): Promise<AniListUser> {
|
||||
const res = fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
'query': 'query($id:Int,$name:String){User(id:$id,name:$name){id name previousNames{name updatedAt}avatar{large}bannerImage about isFollowing isFollower donatorTier donatorBadge createdAt moderatorRoles isBlocked bans options{profileColor restrictMessagesToFollowing}mediaListOptions{scoreFormat}statistics{anime{count meanScore standardDeviation minutesWatched episodesWatched genrePreview:genres(limit:10,sort:COUNT_DESC){genre count}}manga{count meanScore standardDeviation chaptersRead volumesRead genrePreview:genres(limit:10,sort:COUNT_DESC){genre count}}}stats{activityHistory{date amount level}}favourites{anime{edges{favouriteOrder node{id type status(version:2)format isAdult bannerImage title{userPreferred}coverImage{large}startDate{year}}}}manga{edges{favouriteOrder node{id type status(version:2)format isAdult bannerImage title{userPreferred}coverImage{large}startDate{year}}}}characters{edges{favouriteOrder node{id name{userPreferred}image{large}}}}staff{edges{favouriteOrder node{id name{userPreferred}image{large}}}}studios{edges{favouriteOrder node{id name}}}}}}',
|
||||
'variables': {'name': userName},
|
||||
}),
|
||||
});
|
||||
return (await handleJsonResponse(res)).data.User;
|
||||
}
|
||||
|
||||
async fetchManga(userId: number): Promise<AniListMangaListCollection> {
|
||||
const res = fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
'query': 'query($userId:Int,$userName:String,$type:MediaType){MediaListCollection(userId:$userId,userName:$userName,type:$type){lists{name isCustomList isCompletedList:isSplitCompletedList entries{...mediaListEntry}}user{id name avatar{large}mediaListOptions{scoreFormat rowOrder animeList{sectionOrder customLists splitCompletedSectionByFormat theme}mangaList{sectionOrder customLists splitCompletedSectionByFormat theme}}}}}fragment mediaListEntry on MediaList{id mediaId status score progress progressVolumes repeat priority private hiddenFromStatusLists customLists advancedScores notes updatedAt startedAt{year month day}completedAt{year month day}media{id title{userPreferred romaji english native}coverImage{extraLarge large}type format status(version:2)episodes volumes chapters averageScore popularity isAdult countryOfOrigin genres bannerImage startDate{year month day}}}',
|
||||
'variables': {'userId': userId, 'type': 'MANGA'},
|
||||
}),
|
||||
});
|
||||
return (await handleJsonResponse(res)).data.MediaListCollection;
|
||||
}
|
||||
}
|
||||
8
src/data/api/ApiUtils.ts
Normal file
8
src/data/api/ApiUtils.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function handleJsonResponse(res: Primise<T>): Promise<T> {
|
||||
return res.then(async e => {
|
||||
if (e.status === 200) {
|
||||
return await e.json();
|
||||
}
|
||||
throw new Error('Api error ' + e.status + ': ' + await e.text());
|
||||
});
|
||||
}
|
||||
30
src/data/api/MangaUpdatesApi.ts
Normal file
30
src/data/api/MangaUpdatesApi.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import {handleJsonResponse} from '@/data/api/ApiUtils';
|
||||
|
||||
import type {MangaUpdatesSearchResult} from '@/data/models/mangaupdates/MangaUpdatesSearchResult';
|
||||
import type {MangaUpdatesSeries} from '@/data/models/mangaupdates/MangaUpdatesSeries';
|
||||
import type {MangaUpdatesSeriesGroups} from '@/data/models/mangaupdates/MangaUpdatesSeriesGroups';
|
||||
|
||||
export default class MangaUpdatesApi {
|
||||
search(name: string): Promise<MangaUpdatesSearchResult> {
|
||||
const res = fetch('/mangaupdates/v1/series/search', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
search: name,
|
||||
stype: 'title',
|
||||
type: 'Manga',
|
||||
}),
|
||||
});
|
||||
return handleJsonResponse(res);
|
||||
}
|
||||
|
||||
get(id: number): Promise<MangaUpdatesSeries> {
|
||||
const res = fetch(`/mangaupdates/v1/series/${id}`);
|
||||
return handleJsonResponse(res);
|
||||
}
|
||||
|
||||
groups(id: number): Promise<MangaUpdatesSeriesGroups> {
|
||||
const res = fetch(`/mangaupdates/v1/series/${id}/groups`);
|
||||
return handleJsonResponse(res);
|
||||
}
|
||||
}
|
||||
101
src/data/db/AniListDb.ts
Normal file
101
src/data/db/AniListDb.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import {type IDBPDatabase, openDB} from 'idb';
|
||||
import type {AniListUser} from '@/data/models/anilist/AniListUser';
|
||||
import type {AniListMangaListEntry} from '@/data/models/anilist/AniListMangaListEntry';
|
||||
import type {AniListMedia} from '@/data/models/anilist/AniListMedia';
|
||||
import type {AniListMangaList} from '@/data/models/anilist/AniListMangaList';
|
||||
|
||||
export type AlDb = IDBPDatabase<AniListDBSchema>;
|
||||
|
||||
export default class AniListDb {
|
||||
private intDb: IDBPDatabase<AniListDBSchema> | null = null;
|
||||
|
||||
get db(): IDBPDatabase<AniListDBSchema> | null {
|
||||
return this.intDb;
|
||||
}
|
||||
|
||||
static async withDb<T>(fn: (db: AlDb) => Promise<T> | T): Promise<T> {
|
||||
const db = new AniListDb();
|
||||
const idb = await db.open();
|
||||
try {
|
||||
return await fn(idb);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async open(): Promise<IDBPDatabase<AniListDBSchema>> {
|
||||
this.intDb = await openDB('anilist', 1, {
|
||||
upgrade(db, oldVersion, newVersion, transaction, event) {
|
||||
switch (oldVersion) {
|
||||
case 0:
|
||||
const user = db.createObjectStore('user', {keyPath: 'id'});
|
||||
user.createIndex('id', 'id', {unique: true});
|
||||
user.createIndex('name', 'name', {unique: true});
|
||||
|
||||
const list = db.createObjectStore('list', {keyPath: '_userId_listName'});
|
||||
list.createIndex('_userId', '_userId', {multiEntry: true});
|
||||
list.createIndex('_userId_listName', '_userId_listName', {unique: true}); // TODO multiprop indexes
|
||||
|
||||
const manga = db.createObjectStore('manga', {keyPath: 'id'});
|
||||
manga.createIndex('id', 'id', {unique: true});
|
||||
manga.createIndex('_userId', '_userId', {multiEntry: true});
|
||||
manga.createIndex('_listName', '_listName', {multiEntry: true});
|
||||
manga.createIndex('_userId_listName', '_userId_listName', {multiEntry: true});
|
||||
|
||||
const media = db.createObjectStore('media', {keyPath: 'id'});
|
||||
media.createIndex('id', 'id', {unique: true});
|
||||
//fall through
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
return this.intDb;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.intDb?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export type AniListDBSchema = {
|
||||
user: {
|
||||
key: 'id',
|
||||
value: AniListUser,
|
||||
indexes: {
|
||||
id: number,
|
||||
name: string
|
||||
}
|
||||
},
|
||||
list: {
|
||||
value: AniListMangaList & {
|
||||
_userId: number,
|
||||
_userId_listName: string
|
||||
},
|
||||
indexes: {
|
||||
_userId: number,
|
||||
_userId_listName: string
|
||||
}
|
||||
},
|
||||
manga: {
|
||||
key: 'id';
|
||||
value: AniListMangaListEntry & {
|
||||
_userId: number,
|
||||
_listName: string,
|
||||
_userId_listName: string
|
||||
},
|
||||
indexes: {
|
||||
id: number,
|
||||
_userId: number,
|
||||
_listName: string,
|
||||
_userId_listName: string
|
||||
}
|
||||
},
|
||||
media: {
|
||||
key: 'id';
|
||||
value: AniListMedia;
|
||||
indexes: {
|
||||
id: number
|
||||
}
|
||||
},
|
||||
}
|
||||
4
src/data/db/DbUtil.ts
Normal file
4
src/data/db/DbUtil.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function strip_<T extends {} | object>(obj: T): T {
|
||||
Object.keys(obj).filter(e => e.startsWith('_')).forEach(k => delete (obj as any)[k]);
|
||||
return obj;
|
||||
}
|
||||
73
src/data/db/MangaUpdatesDb.ts
Normal file
73
src/data/db/MangaUpdatesDb.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {type IDBPDatabase, openDB} from 'idb';
|
||||
import type {MangaUpdatesSeries} from '@/data/models/mangaupdates/MangaUpdatesSeries';
|
||||
import type {MangaUpdatesChapter} from '@/data/models/mangaupdates/MangaUpdatesChapter';
|
||||
|
||||
export type MuDb = IDBPDatabase<MangaUpdatesDBSchema>;
|
||||
|
||||
export default class MangaUpdatesDb {
|
||||
private intDb: IDBPDatabase<MangaUpdatesDBSchema> | null = null;
|
||||
|
||||
get db(): IDBPDatabase<MangaUpdatesDBSchema> | null {
|
||||
return this.intDb;
|
||||
}
|
||||
|
||||
static async withDb<T>(fn: (db: MuDb) => Promise<T> | T): Promise<T> {
|
||||
const db = new MangaUpdatesDb();
|
||||
const idb = await db.open();
|
||||
try {
|
||||
return await fn(idb);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async open(): Promise<IDBPDatabase<MangaUpdatesDBSchema>> {
|
||||
this.intDb = await openDB('mangaupdates', 1, {
|
||||
upgrade(db, oldVersion, newVersion, transaction, event) {
|
||||
switch (oldVersion) {
|
||||
case 0:
|
||||
const relation = db.createObjectStore('relation', {autoIncrement: true});
|
||||
relation.createIndex('unique', ['aniListMediaId', 'mangaUpdatesSeriesId'], {unique: true});
|
||||
|
||||
const media = db.createObjectStore('series', {keyPath: 'series_id'});
|
||||
media.createIndex('series_id', 'series_id', {unique: true});
|
||||
|
||||
const chapter = db.createObjectStore('chapter', {autoIncrement: true});
|
||||
chapter.createIndex('series_id', 'series_id', {multiEntry: true});
|
||||
//fall through
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
return this.intDb;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.intDb?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export type MangaUpdatesDBSchema = {
|
||||
relation: {
|
||||
key: 'id',
|
||||
value: MangaUpdatesRelation,
|
||||
indexes: {
|
||||
unique: [number, number]
|
||||
}
|
||||
},
|
||||
series: {
|
||||
key: 'series_id';
|
||||
value: MangaUpdatesSeries;
|
||||
indexes: {
|
||||
series_id: number
|
||||
}
|
||||
},
|
||||
chapter: {
|
||||
key: 'id';
|
||||
value: MangaUpdatesChapter;
|
||||
indexes: {
|
||||
series_id: number
|
||||
}
|
||||
},
|
||||
}
|
||||
8
src/data/models/anilist/AniListMangaList.ts
Normal file
8
src/data/models/anilist/AniListMangaList.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import {AniListMangaListEntry} from '@/data/models/anilist/AniListMangaListEntry';
|
||||
|
||||
export type AniListMangaList = {
|
||||
name: 'Paused' | 'Completed' | 'Planning' | 'Dropped' | 'Reading',
|
||||
isCustomList: boolean,
|
||||
isCompletedList: boolean,
|
||||
entries: AniListMangaListEntry[]
|
||||
}
|
||||
6
src/data/models/anilist/AniListMangaListCollection.ts
Normal file
6
src/data/models/anilist/AniListMangaListCollection.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type {AniListMangaList} from '@/data/models/anilist/AniListMangaList';
|
||||
|
||||
export type AniListMangaListCollection = {
|
||||
lists: AniListMangaList[]
|
||||
}
|
||||
|
||||
21
src/data/models/anilist/AniListMangaListEntry.ts
Normal file
21
src/data/models/anilist/AniListMangaListEntry.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type {AniListMedia} from '@/data/models/anilist/AniListMedia';
|
||||
|
||||
export type AniListMangaListEntry = {
|
||||
id: number,
|
||||
mediaId: number,
|
||||
status: 'PAUSED' | 'COMPLETED' | 'PLANNING' | 'DROPPED' | 'READING',
|
||||
score: number,
|
||||
progress: number,
|
||||
progressVolumes: number,
|
||||
repeat: number,
|
||||
priority: number,
|
||||
private: boolean,
|
||||
hiddenFromStatusLists: boolean,
|
||||
customLists: any,
|
||||
advancedScores: any,
|
||||
notes: any,
|
||||
updatedAt: number
|
||||
startedAt: any,
|
||||
completedAt: any,
|
||||
media: AniListMedia,
|
||||
}
|
||||
26
src/data/models/anilist/AniListMedia.ts
Normal file
26
src/data/models/anilist/AniListMedia.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type AniListMedia = {
|
||||
id: number,
|
||||
title: {
|
||||
userPreferred: string,
|
||||
romaji: string,
|
||||
english: string,
|
||||
native: string,
|
||||
}
|
||||
coverImage: {
|
||||
extraLarge: string, //url
|
||||
large: string, //url
|
||||
}
|
||||
type: 'MANGA' | string,
|
||||
format: 'MANGA' | string,
|
||||
status: 'RELEASING' | string,
|
||||
episodes: number | null,
|
||||
volumes: number | null,
|
||||
chapters: number | null,
|
||||
averageScore: number,
|
||||
popularity: number,
|
||||
isAdult: boolean,
|
||||
countryOfOrigin: 'JP' | 'KR' | string,
|
||||
genres: string[],
|
||||
bannerImage: string, //url
|
||||
startDate: any,
|
||||
}
|
||||
5
src/data/models/anilist/AniListUser.ts
Normal file
5
src/data/models/anilist/AniListUser.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type AniListUser = {
|
||||
id: number,
|
||||
name: string,
|
||||
//...
|
||||
}
|
||||
91
src/data/models/mangaupdates/MangaUpdateSearchResult.ts
Normal file
91
src/data/models/mangaupdates/MangaUpdateSearchResult.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
export type SearchResultRecord = {
|
||||
'series_id': number,
|
||||
'title': string,
|
||||
'url': string,
|
||||
'description': string,
|
||||
'image': {
|
||||
'url': {
|
||||
'original': string,
|
||||
'thumb': string
|
||||
},
|
||||
'height': number,
|
||||
'width': number
|
||||
},
|
||||
'type': 'Artbook' | 'Doujinshi' | 'Drama CD' | 'Filipino' | 'Indonesian' | 'Manga' | 'Manhwa' | 'Manhua' | 'Novel'
|
||||
| 'OEL' | 'Thai' | 'Vietnamese' | 'Malaysian' | 'Nordic' | 'French' | 'Spanish',
|
||||
'year': string,
|
||||
'bayesian_rating': number,
|
||||
'rating_votes': number,
|
||||
'genres': [{
|
||||
'genre': string
|
||||
}],
|
||||
'latest_chapter': number,
|
||||
'rank': {
|
||||
'position': {
|
||||
'week': number,
|
||||
'month': number,
|
||||
'three_months': number,
|
||||
'six_months': number,
|
||||
'year': number
|
||||
},
|
||||
'old_position': {
|
||||
'week': number,
|
||||
'month': number,
|
||||
'three_months': number,
|
||||
'six_months': number,
|
||||
'year': number
|
||||
},
|
||||
'lists': {
|
||||
'reading': number,
|
||||
'wish': number,
|
||||
'complete': number,
|
||||
'unfinished': number,
|
||||
'custom': number
|
||||
}
|
||||
},
|
||||
'last_updated': {
|
||||
'timestamp': number,
|
||||
'as_rfc3339': string,
|
||||
'as_string': string
|
||||
},
|
||||
'admin': {
|
||||
'added_by': {
|
||||
'user_id': number,
|
||||
'username': string,
|
||||
'url': string,
|
||||
'avatar': {
|
||||
'id': number,
|
||||
'url': string,
|
||||
'height': number,
|
||||
'width': number
|
||||
},
|
||||
'time_joined': {
|
||||
'timestamp': number,
|
||||
'as_rfc3339': string,
|
||||
'as_string': string
|
||||
},
|
||||
'signature': string,
|
||||
'forum_title': string,
|
||||
'folding_at_home': true,
|
||||
'profile': {
|
||||
'upgrade': {
|
||||
'requested': true,
|
||||
'reason': string
|
||||
}
|
||||
},
|
||||
'stats': {
|
||||
'forum_posts': number,
|
||||
'added_authors': number,
|
||||
'added_groups': number,
|
||||
'added_publishers': number,
|
||||
'added_releases': number,
|
||||
'added_series': number
|
||||
},
|
||||
'user_group': string,
|
||||
'user_group_name': string
|
||||
},
|
||||
'approved': true
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
5
src/data/models/mangaupdates/MangaUpdatesChapter.ts
Normal file
5
src/data/models/mangaupdates/MangaUpdatesChapter.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type MangaUpdatesChapter = {
|
||||
series_id: number,
|
||||
group: string,
|
||||
chapter: number
|
||||
}
|
||||
5
src/data/models/mangaupdates/MangaUpdatesRelation.ts
Normal file
5
src/data/models/mangaupdates/MangaUpdatesRelation.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type MangaUpdatesRelation = {
|
||||
aniListMediaId: number,
|
||||
mangaUpdatesSeriesId: number
|
||||
}
|
||||
|
||||
13
src/data/models/mangaupdates/MangaUpdatesSearchResult.ts
Normal file
13
src/data/models/mangaupdates/MangaUpdatesSearchResult.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type {SearchResultRecord} from '@/data/models/mangaupdates/MangaUpdateSearchResult';
|
||||
import type {MangaUpdatesSearchResultMetaData} from '@/data/models/mangaupdates/MangaUpdatesSearchResultMetaData';
|
||||
|
||||
export type MangaUpdatesSearchResult = {
|
||||
'total_hits': number,
|
||||
'page': number,
|
||||
'per_page': number,
|
||||
'results': [{
|
||||
'record': SearchResultRecord,
|
||||
'hit_title': string,
|
||||
'metadata': MangaUpdatesSearchResultMetaData
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type MangaUpdatesSearchResultMetaData = {
|
||||
'user_list': {
|
||||
'series': {
|
||||
'id': number,
|
||||
'title': string
|
||||
},
|
||||
'list_id': number,
|
||||
'list_type': string,
|
||||
'list_icon': string,
|
||||
'status': {
|
||||
'volume': number,
|
||||
'chapter': number
|
||||
},
|
||||
'priority': number,
|
||||
'time_added': {
|
||||
'timestamp': number,
|
||||
'as_rfc3339': string,
|
||||
'as_string': string
|
||||
}
|
||||
},
|
||||
'user_genre_highlights': [{
|
||||
'genre': string,
|
||||
'color': string
|
||||
}]
|
||||
}
|
||||
67
src/data/models/mangaupdates/MangaUpdatesSeries.ts
Normal file
67
src/data/models/mangaupdates/MangaUpdatesSeries.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export type MangaUpdatesSeries = {
|
||||
'series_id': number,
|
||||
'title': string,
|
||||
'url': string,
|
||||
'associated': { 'title': string }[],
|
||||
'description': string,
|
||||
'image': {
|
||||
'url': {
|
||||
'original': string,
|
||||
'thumb': string
|
||||
},
|
||||
'height': number,
|
||||
'width': number
|
||||
},
|
||||
'type': 'Artbook' | 'Doujinshi' | 'Drama CD' | 'Filipino' | 'Indonesian' | 'Manga' | 'Manhwa' | 'Manhua' | 'Novel'
|
||||
| 'OEL' | 'Thai' | 'Vietnamese' | 'Malaysian' | 'Nordic' | 'French' | 'Spanish',
|
||||
'year': string,
|
||||
'bayesian_rating': number,
|
||||
'rating_votes': number,
|
||||
'genres': { 'genre': string }[],
|
||||
'categories': { 'series_id': number, 'category': string, 'votes': number, 'votes_plus': number, 'votes_minus': number, 'added_by': number }[],
|
||||
'latest_chapter': number,
|
||||
'forum_id': number,
|
||||
'status': string,
|
||||
'licensed': true,
|
||||
'completed': false,
|
||||
'anime': {
|
||||
'start': string,
|
||||
'end': string
|
||||
},
|
||||
'related_series': { 'relation_id': number, 'relation_type': string, 'related_series_id': number, 'related_series_name': string, 'triggered_by_relation_id': number }[],
|
||||
'publishers': { 'publisher_name': string, 'publisher_id': number, 'type': string, 'notes': string }[],
|
||||
'publications': { 'publication_name': string, 'publisher_name': string, 'publisher_id': number }[],
|
||||
'recommendations': { 'series_name': string, 'series_id': number, 'weight': number }[],
|
||||
'category_recommendations': { 'series_name': string, 'series_id': number, 'weight': number }[],
|
||||
'rank': {
|
||||
'position':
|
||||
{
|
||||
'week': number,
|
||||
'month': number,
|
||||
'three_months': number,
|
||||
'six_months': number,
|
||||
'year': number
|
||||
},
|
||||
'old_position':
|
||||
{
|
||||
'week': number,
|
||||
'month': number,
|
||||
'three_months': number,
|
||||
'six_months': number,
|
||||
'year': number
|
||||
},
|
||||
'lists':
|
||||
{
|
||||
'reading': number,
|
||||
'wish': number,
|
||||
'complete': number,
|
||||
'unfinished': number,
|
||||
'custom': number
|
||||
}
|
||||
},
|
||||
'last_updated': {
|
||||
'timestamp': number,
|
||||
'as_rfc3339': string,
|
||||
'as_string': string
|
||||
}
|
||||
}
|
||||
25
src/data/models/mangaupdates/MangaUpdatesSeriesGroups.ts
Normal file
25
src/data/models/mangaupdates/MangaUpdatesSeriesGroups.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type MangaUpdatesSeriesGroups = {
|
||||
'group_list': {
|
||||
'group_id': number,
|
||||
'name': string,
|
||||
'url': string,
|
||||
'social': {
|
||||
'site': string,
|
||||
'facebook': string,
|
||||
'twitter': string,
|
||||
'irc': { 'channel': string, 'server': string },
|
||||
'forum': string,
|
||||
'discord': string
|
||||
},
|
||||
'active': true
|
||||
}[],
|
||||
'release_list': {
|
||||
'id': number,
|
||||
'title': string,
|
||||
'volume': null,
|
||||
'chapter': string,
|
||||
'groups': { 'name': string, 'group_id': number }[],
|
||||
'release_date': string,
|
||||
'time_added': { 'timestamp': number, 'as_rfc3339': string, 'as_string': string }
|
||||
}[]
|
||||
}
|
||||
114
src/data/repository/aniList/AniListMangaRepository.ts
Normal file
114
src/data/repository/aniList/AniListMangaRepository.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import AniListDb, {type AlDb} from '@/data/db/AniListDb';
|
||||
import {strip_} from '@/data/db/DbUtil';
|
||||
import type {AniListMangaListEntry} from '@/data/models/anilist/AniListMangaListEntry';
|
||||
import type {AniListMedia} from '@/data/models/anilist/AniListMedia';
|
||||
import type {AniListMangaList} from '@/data/models/anilist/AniListMangaList';
|
||||
|
||||
export default class AniListMangaRepository {
|
||||
async getMangaLists(userId: number): Promise<AniListMangaList[]> {
|
||||
return await AniListDb.withDb(async db => {
|
||||
return (await db.getAllFromIndex('list', '_userId', userId)).map(strip_);
|
||||
});
|
||||
}
|
||||
|
||||
async getManga(userId: number): Promise<Map<string, AniListMangaListEntry[]>> {
|
||||
return await AniListDb.withDb(async db => {
|
||||
const lists = (await db.getAllFromIndex('list', '_userId', userId)) as AniListMangaList[];
|
||||
const arr = await Promise.all(lists.map(async list => {
|
||||
let entries = (await db
|
||||
.getAllFromIndex('manga', '_userId_listName', IDBKeyRange.only(userId + '_' + list.name))
|
||||
) as AniListMangaListEntry[];
|
||||
return [list.name, entries.map(strip_)] as [string, AniListMangaListEntry[]];
|
||||
}));
|
||||
return new Map<string, AniListMangaListEntry[]>(arr);
|
||||
});
|
||||
}
|
||||
|
||||
async getMedia(): Promise<AniListMedia[]> {
|
||||
return await AniListDb.withDb(async db => {
|
||||
return (await db.getAll('media')).map(strip_);
|
||||
});
|
||||
}
|
||||
|
||||
async patchLists(userId: number, lists: AniListMangaList[]): Promise<void> {
|
||||
await AniListDb.withDb(async db => {
|
||||
await this.patchMangaDropUnnecessaryDbs(db, userId, lists);
|
||||
|
||||
await Promise.allSettled(lists.map(async list => {
|
||||
await this.patchMangaList(db, userId, list);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async patchManga(userId: number, manga: Map<string, AniListMangaListEntry[]>): Promise<void> {
|
||||
await AniListDb.withDb(async db => {
|
||||
await Promise.allSettled(Array.from(manga.entries()).map(async ([listName, entries]) => {
|
||||
await this.patchMangaListEntries(db, userId, listName, entries);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async updateMedia(media: AniListMedia[]): Promise<void> {
|
||||
await AniListDb.withDb(async db => {
|
||||
await this.patchMangaMedia(db, media);
|
||||
});
|
||||
}
|
||||
|
||||
private async patchMangaDropUnnecessaryDbs(db: AlDb, userId: number, lists: AniListMangaList[]): Promise<void> {
|
||||
//drop lists if necessary
|
||||
const newListNames = new Set(lists.map(e => e.name as string));
|
||||
const listNamesPresent = (await db.getAllKeysFromIndex('list', '_userId', userId)) as string[];
|
||||
const listNamesToDrop = listNamesPresent.filter(listName => !newListNames.has(listName));
|
||||
let txList = db.transaction('list', 'readwrite');
|
||||
await Promise.allSettled([...listNamesToDrop.map(async listName => {
|
||||
await txList.store.delete(listName);
|
||||
}), txList.done]);
|
||||
|
||||
//drop manga if necessary
|
||||
const mangaIdGroupsToDrop = await Promise.all(listNamesToDrop.map(async listName => {
|
||||
const ret = await db.getAllKeysFromIndex('manga', '_userId_listName', IDBKeyRange.only(userId + '_' + listName));
|
||||
return ret as string[];
|
||||
}));
|
||||
const mangaIdsToDrop = mangaIdGroupsToDrop.flat();
|
||||
const txManga = db.transaction('manga', 'readwrite');
|
||||
await Promise.allSettled([...mangaIdsToDrop.map(async mangaId => {
|
||||
await txManga.store.delete(mangaId);
|
||||
}), txManga.done]);
|
||||
}
|
||||
|
||||
private async patchMangaList(db: AlDb, userId: number, list: AniListMangaList): Promise<void> {
|
||||
const o = {...list, _userId: userId, _userId_listName: userId + '_' + list.name} as any;
|
||||
delete o.entries;
|
||||
await db.put('list', o).catch(err => console.error(err));
|
||||
}
|
||||
|
||||
private async patchMangaListEntries(db: AlDb, userId: number, listName: string, entries: AniListMangaListEntry[]): Promise<void> {
|
||||
// console.log("patchMangaListEntries", list);
|
||||
|
||||
//drop entries if necessary
|
||||
const newEntryIds = new Set(entries.map(e => e.id));
|
||||
const entryIdsPresent = (await db
|
||||
.getAllKeysFromIndex('manga', '_userId_listName', IDBKeyRange.only(userId + '_' + listName))) as number[];
|
||||
const entryIdsToDrop = entryIdsPresent.filter(e => !newEntryIds.has(e));
|
||||
let tx = db.transaction('manga', 'readwrite');
|
||||
await Promise.allSettled([...entryIdsToDrop.map(async entryId => {
|
||||
await tx.store.delete(entryId).catch(err => console.error(err));
|
||||
}), tx.done]);
|
||||
|
||||
//create/update entries
|
||||
tx = db.transaction('manga', 'readwrite');
|
||||
await Promise.allSettled([...entries.map(async entry => {
|
||||
const o = {...entry, _userId: userId, _listName: listName, _userId_listName: userId + '_' + listName} as any;
|
||||
delete o.media;
|
||||
await tx.store.put(o).catch(err => console.error(err));
|
||||
}), tx.done]);
|
||||
}
|
||||
|
||||
private async patchMangaMedia(db: AlDb, media: AniListMedia[]): Promise<void> {
|
||||
//create/update entries
|
||||
let tx = db.transaction('media', 'readwrite');
|
||||
await Promise.allSettled([...media.map(async entry => {
|
||||
await tx!.store.put(entry).catch(err => console.error(err));
|
||||
}), tx.done]);
|
||||
}
|
||||
}
|
||||
16
src/data/repository/aniList/AniListUserRepository.ts
Normal file
16
src/data/repository/aniList/AniListUserRepository.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type {AniListUser} from '@/data/models/anilist/AniListUser';
|
||||
import AniListDb from '@/data/db/AniListDb';
|
||||
|
||||
export default class AniListUserRepository {
|
||||
async getUser(userName: string): Promise<AniListUser | null> {
|
||||
return await AniListDb.withDb(async db => {
|
||||
return await db.getFromIndex('user', 'name', IDBKeyRange.only(userName)) ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
async updateUser(user: AniListUser): Promise<void> {
|
||||
return await AniListDb.withDb(async db => {
|
||||
await db.put('user', user);
|
||||
});
|
||||
}
|
||||
}
|
||||
64
src/data/repository/mangaUpdates/MangaUpdatesRepository.ts
Normal file
64
src/data/repository/mangaUpdates/MangaUpdatesRepository.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import MangaUpdatesDb from '@/data/db/MangaUpdatesDb';
|
||||
import type {MangaUpdatesRelation} from '@/data/models/mangaupdates/MangaUpdatesRelation';
|
||||
import type {MangaUpdatesMedia} from '@/data/models/mangaupdates/MangaUpdatesMedia';
|
||||
import type {MangaUpdatesChapter} from '@/data/models/mangaupdates/MangaUpdatesChapter';
|
||||
import type {MangaUpdatesSeries} from '@/data/models/mangaupdates/MangaUpdatesSeries';
|
||||
|
||||
export default class MangaUpdatesRepository {
|
||||
async getChapters(): Promise<MangaUpdatesChapter[]> {
|
||||
return await MangaUpdatesDb.withDb(async db => {
|
||||
return await db.getAll('chapter');
|
||||
});
|
||||
}
|
||||
|
||||
async getRelations(): Promise<MangaUpdatesRelation[]> {
|
||||
return await MangaUpdatesDb.withDb(async db => {
|
||||
return await db.getAll('relation');
|
||||
});
|
||||
}
|
||||
|
||||
async getSeries(): Promise<MangaUpdatesSeries[]> {
|
||||
return await MangaUpdatesDb.withDb(async db => {
|
||||
return await db.getAll('series');
|
||||
});
|
||||
}
|
||||
|
||||
async getSeriesById(id: number) {
|
||||
return await MangaUpdatesDb.withDb(async db => {
|
||||
return await db.get('series', id);
|
||||
});
|
||||
}
|
||||
|
||||
async addRelations(newRelations: MangaUpdatesRelation[]) {
|
||||
return await MangaUpdatesDb.withDb(async db => {
|
||||
let txList = db.transaction('relation', 'readwrite');
|
||||
await Promise.allSettled([
|
||||
...newRelations.map(relation => txList.store.add(relation)),
|
||||
txList.done]);
|
||||
});
|
||||
}
|
||||
|
||||
async updateChapters(newChapters: MangaUpdatesChapter[]) {
|
||||
const seriesIds = Array.from(new Set(newChapters.map(e => e.series_id)).values());
|
||||
return await MangaUpdatesDb.withDb(async db => {
|
||||
const keysToDelete = await Promise.all(seriesIds.map(series_id => {
|
||||
return db.getAllKeysFromIndex('chapter', 'series_id', IDBKeyRange.only(series_id)) as any as Promise<number[]>;
|
||||
}));
|
||||
|
||||
let txList = db.transaction('chapter', 'readwrite');
|
||||
await Promise.allSettled([
|
||||
...keysToDelete.flat().map(key => txList.store.delete(IDBKeyRange.only(key))),
|
||||
...newChapters.map(chapter => txList.store.add(chapter)),
|
||||
txList.done]);
|
||||
});
|
||||
}
|
||||
|
||||
async updateSeries(newSeries: MangaUpdatesMedia[]) {
|
||||
return await MangaUpdatesDb.withDb(async db => {
|
||||
let txList = db.transaction('series', 'readwrite');
|
||||
await Promise.allSettled([
|
||||
...newSeries.map(series => txList.store.put(series)),
|
||||
txList.done]);
|
||||
});
|
||||
}
|
||||
}
|
||||
46
src/data/service/AniListDataService.ts
Normal file
46
src/data/service/AniListDataService.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import AniListApi from '@/data/api/AniListApi';
|
||||
import {MangaStore} from '@/stores/MangaStore';
|
||||
import type {AniListMangaListEntry} from '@/data/models/anilist/AniListMangaListEntry';
|
||||
import type {AniListMedia} from '@/data/models/anilist/AniListMedia';
|
||||
import type {AniListMangaList} from '@/data/models/anilist/AniListMangaList';
|
||||
|
||||
export default class AniListDataService {
|
||||
async updateDb(): Promise<void> {
|
||||
const store = new MangaStore();
|
||||
const userName = store.userName;
|
||||
if (!userName) {
|
||||
return Promise.reject('No username set');
|
||||
}
|
||||
|
||||
const api = new AniListApi();
|
||||
|
||||
const user = await api.fetchUser(userName);
|
||||
await store.updateAniListUser(user);
|
||||
|
||||
const manga = await api.fetchManga(user.id);
|
||||
const lists = manga.lists
|
||||
.map(e => {
|
||||
const ret = {...e} as AniListMangaList; //shallow copy
|
||||
delete (ret as any).entries;
|
||||
return ret;
|
||||
});
|
||||
const mangaByList = new Map<string, AniListMangaListEntry[]>(manga.lists
|
||||
.map(list => {
|
||||
const entries = [...list.entries.map(manga => {
|
||||
const ret = {...manga} as AniListMangaListEntry; //shallow copy
|
||||
delete (ret as any).media;
|
||||
return ret;
|
||||
})];
|
||||
return [list.name, entries];
|
||||
}));
|
||||
|
||||
const media = manga.lists
|
||||
.map(e => e.entries.map(e => e.media)).flat()
|
||||
.filter(e => e)//not null
|
||||
.map(e => ({...e} as AniListMedia)); //shallow copy
|
||||
|
||||
await store.updateAniListLists(user.id, lists);
|
||||
await store.updateAniListManga(user.id, mangaByList);
|
||||
await store.updateAniListMedia(media);
|
||||
}
|
||||
}
|
||||
122
src/data/service/MangaUpdatesDataService.ts
Normal file
122
src/data/service/MangaUpdatesDataService.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import MangaUpdatesApi from '@/data/api/MangaUpdatesApi';
|
||||
import {DbStore} from '@/stores/DbStore';
|
||||
import {MangaStore} from '@/stores/MangaStore';
|
||||
import type {MangaUpdatesChapter} from '@/data/models/mangaupdates/MangaUpdatesChapter';
|
||||
|
||||
export default class MangaUpdatesDataService {
|
||||
private readonly mangaUpdatesApi = new MangaUpdatesApi();
|
||||
|
||||
updateDb(progress: Progress): Progress {
|
||||
const mangaStore = new MangaStore();
|
||||
const dbStore = new DbStore();
|
||||
this.findMissingRelations(mangaStore, dbStore, progress)
|
||||
.then(_ => this.fetchSeriesUpdates(mangaStore, dbStore, progress))
|
||||
.then(_ => this.fetchSeriesChapterUpdates(mangaStore, dbStore, progress))
|
||||
.then(_ => progress.onFinished());
|
||||
return progress;
|
||||
}
|
||||
|
||||
private async findMissingRelations(mangaStore: MangaStore, dbStore: DbStore, progress: Progress): Promise<void> {
|
||||
const allowTypes = new Set(['Manga', 'Manhwa', 'Manhua'].map(e => e.toLowerCase()));
|
||||
|
||||
const media = await dbStore.aniListMangaRepository.getMedia();
|
||||
const relations = await dbStore.mangaUpdatesRepository.getRelations();
|
||||
const presentRelationIds = new Set(relations.map(e => e.aniListMediaId));
|
||||
|
||||
let i = 0;
|
||||
for (const m of media) {
|
||||
try {
|
||||
if (presentRelationIds.has(m.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const results = await this.mangaUpdatesApi.search(m.title.romaji);
|
||||
const matching = results.results
|
||||
.filter(e => allowTypes.has(e.record.type.toLowerCase())) //check if a manga or similar but not novel
|
||||
.filter(e => m.startDate.year - 1 <= e.record.year && e.record.year <= m.startDate.year + 1); //check year +-1
|
||||
if (matching.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await mangaStore.addMangaUpdatesRelations([{aniListMediaId: m.id, mangaUpdatesSeriesId: matching[0].record.series_id}]);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
} finally {
|
||||
await progress.onProgress('relations', ++i, media.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchSeriesUpdates(mangaStore: MangaStore, dbStore: DbStore, progress: Progress): Promise<void> {
|
||||
const relations = await dbStore.mangaUpdatesRepository.getRelations();
|
||||
|
||||
let i = 0;
|
||||
for (const relation of relations) {
|
||||
try {
|
||||
const dbSeries = await dbStore.mangaUpdatesRepository.getSeriesById(relation.mangaUpdatesSeriesId);
|
||||
if (dbSeries) { // TODO check for now - lastUpdated < 1d or sth
|
||||
continue;
|
||||
}
|
||||
|
||||
const series = await this.mangaUpdatesApi.get(relation.mangaUpdatesSeriesId);
|
||||
await mangaStore.updateMangaUpdatesSeries([series]);
|
||||
} finally {
|
||||
await progress.onProgress('series', ++i, relations.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchSeriesChapterUpdates(mangaStore: MangaStore, dbStore: DbStore, progress: Progress): Promise<void> {
|
||||
const series = await dbStore.mangaUpdatesRepository.getSeries();
|
||||
|
||||
let i = 0;
|
||||
for (const s of series) {
|
||||
try {
|
||||
// const dbSeries = await dbStore.mangaUpdatesRepository.getSeriesById(s.series_id);
|
||||
// if (dbSeries) { // TODO check for now - lastUpdated < 1d or sth
|
||||
// continue;
|
||||
// }
|
||||
|
||||
const groups = await this.mangaUpdatesApi.groups(s.series_id);
|
||||
|
||||
const updates = groups.release_list
|
||||
.map(r => {
|
||||
const match = r.chapter.match(/([0-9]+?)[^0-9]*$/);
|
||||
if (!match) {
|
||||
return [];
|
||||
}
|
||||
const chapter = parseInt(match[1]);
|
||||
return r.groups.map(g => ({series_id: s.series_id, group: g.name, chapter: chapter} as MangaUpdatesChapter));
|
||||
})
|
||||
.flat();
|
||||
|
||||
//only keep chapter with the highest chapter number per group
|
||||
const filtered = Array.from(Map.groupBy(updates, c => c.group).values())
|
||||
.map(chaptersOfGroup => chaptersOfGroup.reduce((l, r) => l.chapter > r.chapter ? l : r, chaptersOfGroup[0]));
|
||||
await mangaStore.updateMangaUpdatesChapters(filtered);
|
||||
} finally {
|
||||
await progress.onProgress('chapters', ++i, series.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ProgressCallBackFn = (type: string, progress: number, max: number) => (Promise<void> | void);
|
||||
export type FinishCallBackFn = () => (Promise<void> | void);
|
||||
|
||||
export class Progress {
|
||||
readonly progress: ProgressCallBackFn;
|
||||
readonly finish: FinishCallBackFn;
|
||||
|
||||
constructor(onProgress: ProgressCallBackFn, onFinish: FinishCallBackFn) {
|
||||
this.progress = onProgress;
|
||||
this.finish = onFinish;
|
||||
}
|
||||
|
||||
async onProgress(type: string, progress: number, max: number): Promise<void> {
|
||||
return (this.progress ? this.progress(type, progress, max) : Promise.resolve());
|
||||
}
|
||||
|
||||
async onFinished(): Promise<void> {
|
||||
return (this.finish ? this.finish() : Promise.resolve());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user