init: scaffold complet Sakuin — backend NestJS + frontend SvelteKit + CI/CD + deploy VPS
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 29s
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 29s
Backend: 5 modules (auth, user, work, list, health), AniList GraphQL proxy, SuperOAuth PKCE introspection, XP system, migrations TypeORM. Frontend: SvelteKit adapter-node, PWA manifest, dark theme, pages home/search/list/profile/callback. Infra: CI/CD Gitea vps-runner, Apache vhost SSL, pm2 sakuin-backend + sakuin-frontend, port 4002. License: BSL 1.1 (Apache 2.0 en 2028).
This commit is contained in:
115
backend/src/work/anilist.service.ts
Normal file
115
backend/src/work/anilist.service.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface AniListMedia {
|
||||
id: number;
|
||||
type: 'ANIME' | 'MANGA';
|
||||
title: { romaji: string; english: string | null; native: string | null };
|
||||
coverImage: { large: string } | null;
|
||||
description: string | null;
|
||||
episodes: number | null;
|
||||
chapters: number | null;
|
||||
status: string | null;
|
||||
genres: string[];
|
||||
}
|
||||
|
||||
interface AniListSearchResult {
|
||||
data: {
|
||||
Page: {
|
||||
media: AniListMedia[];
|
||||
pageInfo: { total: number; hasNextPage: boolean };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const SEARCH_QUERY = `
|
||||
query ($search: String, $type: MediaType, $page: Int, $perPage: Int) {
|
||||
Page(page: $page, perPage: $perPage) {
|
||||
pageInfo { total hasNextPage }
|
||||
media(search: $search, type: $type, sort: POPULARITY_DESC) {
|
||||
id type
|
||||
title { romaji english native }
|
||||
coverImage { large }
|
||||
description
|
||||
episodes chapters
|
||||
status
|
||||
genres
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const GET_BY_ID_QUERY = `
|
||||
query ($id: Int) {
|
||||
Media(id: $id) {
|
||||
id type
|
||||
title { romaji english native }
|
||||
coverImage { large }
|
||||
description
|
||||
episodes chapters
|
||||
status
|
||||
genres
|
||||
}
|
||||
}`;
|
||||
|
||||
@Injectable()
|
||||
export class AniListService {
|
||||
private readonly logger = new Logger(AniListService.name);
|
||||
private readonly apiUrl: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.apiUrl = this.configService.get(
|
||||
'ANILIST_API_URL',
|
||||
'https://graphql.anilist.co',
|
||||
);
|
||||
}
|
||||
|
||||
async search(
|
||||
query: string,
|
||||
type?: 'ANIME' | 'MANGA',
|
||||
page = 1,
|
||||
perPage = 20,
|
||||
): Promise<{ media: AniListMedia[]; total: number; hasNextPage: boolean }> {
|
||||
const variables: Record<string, any> = { search: query, page, perPage };
|
||||
if (type) variables.type = type;
|
||||
|
||||
const result = await this.request<AniListSearchResult>(
|
||||
SEARCH_QUERY,
|
||||
variables,
|
||||
);
|
||||
const pageData = result.data.Page;
|
||||
|
||||
return {
|
||||
media: pageData.media,
|
||||
total: pageData.pageInfo.total,
|
||||
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||
};
|
||||
}
|
||||
|
||||
async getById(id: number): Promise<AniListMedia | null> {
|
||||
try {
|
||||
const result = await this.request<{ data: { Media: AniListMedia } }>(
|
||||
GET_BY_ID_QUERY,
|
||||
{ id },
|
||||
);
|
||||
return result.data.Media;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(query: string, variables: Record<string, any>): Promise<T> {
|
||||
const res = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
this.logger.error(`AniList API error ${res.status}: ${body}`);
|
||||
throw new Error(`AniList API error: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
}
|
||||
19
backend/src/work/work.controller.ts
Normal file
19
backend/src/work/work.controller.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { WorkService } from './work.service';
|
||||
|
||||
@Controller('api/works')
|
||||
export class WorkController {
|
||||
constructor(private readonly workService: WorkService) {}
|
||||
|
||||
@Get('search')
|
||||
async search(
|
||||
@Query('q') query: string,
|
||||
@Query('type') type?: 'ANIME' | 'MANGA',
|
||||
@Query('page') page?: string,
|
||||
) {
|
||||
if (!query || query.trim().length < 2) {
|
||||
return { media: [], total: 0, hasNextPage: false };
|
||||
}
|
||||
return this.workService.search(query.trim(), type, page ? parseInt(page, 10) : 1);
|
||||
}
|
||||
}
|
||||
66
backend/src/work/work.entity.ts
Normal file
66
backend/src/work/work.entity.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { UserWork } from '../list/user-work.entity';
|
||||
|
||||
export enum WorkType {
|
||||
ANIME = 'anime',
|
||||
MANGA = 'manga',
|
||||
}
|
||||
|
||||
export enum WorkStatus {
|
||||
RELEASING = 'releasing',
|
||||
FINISHED = 'finished',
|
||||
NOT_YET_RELEASED = 'not_yet_released',
|
||||
CANCELLED = 'cancelled',
|
||||
HIATUS = 'hiatus',
|
||||
}
|
||||
|
||||
@Entity('works')
|
||||
export class Work {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ unique: true })
|
||||
anilistId: number;
|
||||
|
||||
@Column({ type: 'enum', enum: WorkType })
|
||||
type: WorkType;
|
||||
|
||||
@Column()
|
||||
titleRomaji: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
titleEnglish: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
titleNative: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
posterUrl: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
synopsis: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
totalEpisodes: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
totalChapters: number;
|
||||
|
||||
@Column({ type: 'enum', enum: WorkStatus, nullable: true })
|
||||
status: WorkStatus;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
genres: string[];
|
||||
|
||||
@OneToMany(() => UserWork, (uw) => uw.work)
|
||||
userWorks: UserWork[];
|
||||
|
||||
@CreateDateColumn()
|
||||
cachedAt: Date;
|
||||
}
|
||||
14
backend/src/work/work.module.ts
Normal file
14
backend/src/work/work.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Work } from './work.entity';
|
||||
import { WorkController } from './work.controller';
|
||||
import { WorkService } from './work.service';
|
||||
import { AniListService } from './anilist.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Work])],
|
||||
controllers: [WorkController],
|
||||
providers: [WorkService, AniListService],
|
||||
exports: [WorkService],
|
||||
})
|
||||
export class WorkModule {}
|
||||
53
backend/src/work/work.service.ts
Normal file
53
backend/src/work/work.service.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Work, WorkType, WorkStatus } from './work.entity';
|
||||
import { AniListService } from './anilist.service';
|
||||
|
||||
@Injectable()
|
||||
export class WorkService {
|
||||
constructor(
|
||||
@InjectRepository(Work)
|
||||
private readonly workRepo: Repository<Work>,
|
||||
private readonly anilist: AniListService,
|
||||
) {}
|
||||
|
||||
async search(query: string, type?: 'ANIME' | 'MANGA', page = 1) {
|
||||
return this.anilist.search(query, type, page);
|
||||
}
|
||||
|
||||
async findOrCreateFromAniList(anilistId: number): Promise<Work> {
|
||||
const existing = await this.workRepo.findOne({ where: { anilistId } });
|
||||
if (existing) return existing;
|
||||
|
||||
const media = await this.anilist.getById(anilistId);
|
||||
if (!media) throw new Error(`AniList media ${anilistId} not found`);
|
||||
|
||||
const work = this.workRepo.create({
|
||||
anilistId: media.id,
|
||||
type: media.type === 'ANIME' ? WorkType.ANIME : WorkType.MANGA,
|
||||
titleRomaji: media.title.romaji,
|
||||
titleEnglish: media.title.english ?? undefined,
|
||||
titleNative: media.title.native ?? undefined,
|
||||
posterUrl: media.coverImage?.large ?? undefined,
|
||||
synopsis: media.description ?? undefined,
|
||||
totalEpisodes: media.episodes ?? undefined,
|
||||
totalChapters: media.chapters ?? undefined,
|
||||
status: this.mapStatus(media.status),
|
||||
genres: media.genres,
|
||||
} as Partial<Work>);
|
||||
|
||||
return this.workRepo.save(work as Work);
|
||||
}
|
||||
|
||||
private mapStatus(status: string | null): WorkStatus | undefined {
|
||||
const map: Record<string, WorkStatus> = {
|
||||
RELEASING: WorkStatus.RELEASING,
|
||||
FINISHED: WorkStatus.FINISHED,
|
||||
NOT_YET_RELEASED: WorkStatus.NOT_YET_RELEASED,
|
||||
CANCELLED: WorkStatus.CANCELLED,
|
||||
HIATUS: WorkStatus.HIATUS,
|
||||
};
|
||||
return status ? map[status] : undefined;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user