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

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:
2026-03-25 01:43:32 +01:00
commit f1cff74d83
56 changed files with 9891 additions and 0 deletions

20
backend/.env.example Normal file
View File

@@ -0,0 +1,20 @@
# Sakuin Backend
PORT=4002
# Database
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=
DB_PASSWORD=
DB_DATABASE=sakuin
# SuperOAuth
SUPEROAUTH_URL=https://superoauth.tetardtek.com
SUPEROAUTH_CLIENT_ID=sakuin
SUPEROAUTH_CLIENT_SECRET=
# AniList (public API — no auth required for search)
ANILIST_API_URL=https://graphql.anilist.co
# Frontend URL (CORS)
FRONTEND_URL=http://localhost:5173

4
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
dist/
node_modules/
.env
*.js.map

8
backend/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

5709
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
backend/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "sakuin-backend",
"version": "0.1.0",
"description": "Sakuin — manga/anime tracker API",
"license": "BUSL-1.1",
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main",
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
"migration:generate": "npm run typeorm -- migration:generate src/migrations/$npm_config_name -d src/config/data-source.ts",
"migration:run": "npm run typeorm -- migration:run -d src/config/data-source.ts",
"migration:revert": "npm run typeorm -- migration:revert -d src/config/data-source.ts"
},
"dependencies": {
"@nestjs/common": "^11.1.17",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.17",
"@nestjs/platform-express": "^11.1.17",
"@nestjs/typeorm": "^11.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"mysql2": "^3.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"typeorm": "^0.3.28"
},
"devDependencies": {
"@nestjs/cli": "^11.0.16",
"@nestjs/schematics": "^11.0.9",
"@types/express": "^5.0.6",
"@types/node": "^25.5.0",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^6.0.2"
}
}

25
backend/src/app.module.ts Normal file
View File

@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { getDatabaseConfig } from './config/database.config';
import { HealthModule } from './health/health.module';
import { AuthModule } from './auth/auth.module';
import { UserModule } from './user/user.module';
import { WorkModule } from './work/work.module';
import { ListModule } from './list/list.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: getDatabaseConfig,
}),
HealthModule,
AuthModule,
UserModule,
WorkModule,
ListModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,50 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly configService: ConfigService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException('No token provided');
}
const userInfo = await this.introspect(token);
if (!userInfo) {
throw new UnauthorizedException('Invalid token');
}
request.user = userInfo;
return true;
}
private extractToken(request: any): string | null {
const authHeader = request.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
return authHeader.slice(7);
}
return request.cookies?.access_token || null;
}
private async introspect(token: string): Promise<any> {
const url = this.configService.get('SUPEROAUTH_URL');
try {
const res = await fetch(`${url}/api/v1/user/profile`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { AuthGuard } from './auth.guard';
@Module({
providers: [AuthGuard],
exports: [AuthGuard],
})
export class AuthModule {}

View File

@@ -0,0 +1,16 @@
import { DataSource } from 'typeorm';
import { config } from 'dotenv';
config();
export default new DataSource({
type: 'mysql',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306', 10),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE || 'sakuin',
entities: ['src/**/*.entity.ts'],
migrations: ['src/migrations/*.ts'],
synchronize: false,
});

View File

@@ -0,0 +1,17 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';
export const getDatabaseConfig = (
configService: ConfigService,
): TypeOrmModuleOptions => ({
type: 'mysql',
host: configService.get('DB_HOST', 'localhost'),
port: configService.get<number>('DB_PORT', 3306),
username: configService.get('DB_USERNAME'),
password: configService.get('DB_PASSWORD'),
database: configService.get('DB_DATABASE', 'sakuin'),
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
migrations: [__dirname + '/../migrations/*{.ts,.js}'],
synchronize: false,
migrationsRun: true,
});

View File

@@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller('api/health')
export class HealthController {
@Get()
check() {
return { status: 'ok', service: 'sakuin-backend' };
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -0,0 +1,105 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
Req,
UseGuards,
ParseIntPipe,
} from '@nestjs/common';
import { ListService } from './list.service';
import { ListStatus } from './user-work.entity';
import { AuthGuard } from '../auth/auth.guard';
import { UserService } from '../user/user.service';
@Controller('api/list')
@UseGuards(AuthGuard)
export class ListController {
constructor(
private readonly listService: ListService,
private readonly userService: UserService,
) {}
@Get()
async getList(@Req() req: any, @Query('status') status?: ListStatus) {
const user = await this.userService.findOrCreate({
id: req.user.id,
nickname: req.user.nickname,
avatar: req.user.avatar,
});
return this.listService.getUserList(user.id, status);
}
@Post()
async addToList(
@Req() req: any,
@Body() body: { anilistId: number; status: ListStatus },
) {
const user = await this.userService.findOrCreate({
id: req.user.id,
nickname: req.user.nickname,
avatar: req.user.avatar,
});
return this.listService.addToList(user.id, body.anilistId, body.status);
}
@Put(':id/progress')
async updateProgress(
@Req() req: any,
@Param('id', ParseIntPipe) id: number,
@Body() body: { progress: number },
) {
const user = await this.userService.findOrCreate({
id: req.user.id,
nickname: req.user.nickname,
avatar: req.user.avatar,
});
return this.listService.updateProgress(user.id, id, body.progress);
}
@Put(':id/status')
async updateStatus(
@Req() req: any,
@Param('id', ParseIntPipe) id: number,
@Body() body: { status: ListStatus },
) {
const user = await this.userService.findOrCreate({
id: req.user.id,
nickname: req.user.nickname,
avatar: req.user.avatar,
});
return this.listService.updateStatus(user.id, id, body.status);
}
@Put(':id/score')
async setScore(
@Req() req: any,
@Param('id', ParseIntPipe) id: number,
@Body() body: { score: number },
) {
const user = await this.userService.findOrCreate({
id: req.user.id,
nickname: req.user.nickname,
avatar: req.user.avatar,
});
return this.listService.setScore(user.id, id, body.score);
}
@Delete(':id')
async remove(
@Req() req: any,
@Param('id', ParseIntPipe) id: number,
) {
const user = await this.userService.findOrCreate({
id: req.user.id,
nickname: req.user.nickname,
avatar: req.user.avatar,
});
await this.listService.removeFromList(user.id, id);
return { deleted: true };
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserWork } from './user-work.entity';
import { ListController } from './list.controller';
import { ListService } from './list.service';
import { WorkModule } from '../work/work.module';
import { UserModule } from '../user/user.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([UserWork]),
WorkModule,
UserModule,
AuthModule,
],
controllers: [ListController],
providers: [ListService],
})
export class ListModule {}

View File

@@ -0,0 +1,137 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserWork, ListStatus } from './user-work.entity';
import { WorkService } from '../work/work.service';
import { UserService } from '../user/user.service';
const XP_ADD_WORK = 10;
const XP_PROGRESS = 5;
const XP_COMPLETE = 100;
const XP_SCORE = 15;
@Injectable()
export class ListService {
constructor(
@InjectRepository(UserWork)
private readonly userWorkRepo: Repository<UserWork>,
private readonly workService: WorkService,
private readonly userService: UserService,
) {}
async getUserList(userId: number, status?: ListStatus) {
const where: any = { userId };
if (status) where.status = status;
return this.userWorkRepo.find({
where,
relations: ['work'],
order: { updatedAt: 'DESC' },
});
}
async addToList(
userId: number,
anilistId: number,
status: ListStatus,
): Promise<UserWork> {
const work = await this.workService.findOrCreateFromAniList(anilistId);
const existing = await this.userWorkRepo.findOne({
where: { userId, workId: work.id },
});
if (existing) {
existing.status = status;
return this.userWorkRepo.save(existing);
}
const userWork = this.userWorkRepo.create({
userId,
workId: work.id,
status,
progress: 0,
});
const saved = await this.userWorkRepo.save(userWork);
await this.userService.addXp(userId, XP_ADD_WORK);
return saved;
}
async updateProgress(
userId: number,
userWorkId: number,
progress: number,
): Promise<UserWork> {
const uw = await this.userWorkRepo.findOne({
where: { id: userWorkId, userId },
relations: ['work'],
});
if (!uw) throw new NotFoundException('Entry not found');
const oldProgress = uw.progress;
uw.progress = progress;
const total = uw.work.totalEpisodes || uw.work.totalChapters;
if (total && progress >= total && uw.status !== ListStatus.COMPLETED) {
uw.status = ListStatus.COMPLETED;
uw.completedAt = new Date();
await this.userService.addXp(userId, XP_COMPLETE);
}
const saved = await this.userWorkRepo.save(uw);
if (progress > oldProgress) {
const delta = progress - oldProgress;
await this.userService.addXp(userId, delta * XP_PROGRESS);
}
return saved;
}
async updateStatus(
userId: number,
userWorkId: number,
status: ListStatus,
): Promise<UserWork> {
const uw = await this.userWorkRepo.findOne({
where: { id: userWorkId, userId },
});
if (!uw) throw new NotFoundException('Entry not found');
uw.status = status;
if (status === ListStatus.COMPLETED) {
uw.completedAt = new Date();
await this.userService.addXp(userId, XP_COMPLETE);
}
return this.userWorkRepo.save(uw);
}
async setScore(
userId: number,
userWorkId: number,
score: number,
): Promise<UserWork> {
const uw = await this.userWorkRepo.findOne({
where: { id: userWorkId, userId },
});
if (!uw) throw new NotFoundException('Entry not found');
const hadScore = uw.score !== null;
uw.score = score;
const saved = await this.userWorkRepo.save(uw);
if (!hadScore) {
await this.userService.addXp(userId, XP_SCORE);
}
return saved;
}
async removeFromList(userId: number, userWorkId: number): Promise<void> {
const uw = await this.userWorkRepo.findOne({
where: { id: userWorkId, userId },
});
if (!uw) throw new NotFoundException('Entry not found');
await this.userWorkRepo.remove(uw);
}
}

View File

@@ -0,0 +1,60 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
CreateDateColumn,
UpdateDateColumn,
Unique,
} from 'typeorm';
import { User } from '../user/user.entity';
import { Work } from '../work/work.entity';
export enum ListStatus {
WATCHING = 'watching',
READING = 'reading',
COMPLETED = 'completed',
DROPPED = 'dropped',
PAUSED = 'paused',
PLAN_TO = 'plan_to',
}
@Entity('user_works')
@Unique(['user', 'work'])
export class UserWork {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne(() => User, (u) => u.userWorks, { onDelete: 'CASCADE' })
user: User;
@Column()
userId: number;
@ManyToOne(() => Work, (w) => w.userWorks, { onDelete: 'CASCADE' })
work: Work;
@Column()
workId: number;
@Column({ type: 'enum', enum: ListStatus })
status: ListStatus;
@Column({ default: 0 })
progress: number;
@Column({ type: 'decimal', precision: 3, scale: 1, nullable: true })
score: number;
@Column({ type: 'datetime', nullable: true })
startedAt: Date;
@Column({ type: 'datetime', nullable: true })
completedAt: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

22
backend/src/main.ts Normal file
View File

@@ -0,0 +1,22 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
credentials: true,
});
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, transform: true }),
);
const port = process.env.PORT || 4002;
await app.listen(port);
console.log(`Sakuin backend listening on :${port}`);
}
bootstrap();

View File

@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class Init1711360000000 implements MigrationInterface {
name = 'Init1711360000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE users (
id INT NOT NULL AUTO_INCREMENT,
superOauthId VARCHAR(255) NOT NULL,
username VARCHAR(255) NOT NULL,
avatarUrl VARCHAR(512) NULL,
xp INT NOT NULL DEFAULT 0,
level INT NOT NULL DEFAULT 1,
createdAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updatedAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
UNIQUE INDEX IDX_users_superOauthId (superOauthId),
PRIMARY KEY (id)
) ENGINE=InnoDB
`);
await queryRunner.query(`
CREATE TABLE works (
id INT NOT NULL AUTO_INCREMENT,
anilistId INT NOT NULL,
type ENUM('anime', 'manga') NOT NULL,
titleRomaji VARCHAR(512) NOT NULL,
titleEnglish VARCHAR(512) NULL,
titleNative VARCHAR(512) NULL,
posterUrl VARCHAR(1024) NULL,
synopsis TEXT NULL,
totalEpisodes INT NULL,
totalChapters INT NULL,
status ENUM('releasing', 'finished', 'not_yet_released', 'cancelled', 'hiatus') NULL,
genres JSON NULL,
cachedAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
UNIQUE INDEX IDX_works_anilistId (anilistId),
PRIMARY KEY (id)
) ENGINE=InnoDB
`);
await queryRunner.query(`
CREATE TABLE user_works (
id INT NOT NULL AUTO_INCREMENT,
userId INT NOT NULL,
workId INT NOT NULL,
status ENUM('watching', 'reading', 'completed', 'dropped', 'paused', 'plan_to') NOT NULL,
progress INT NOT NULL DEFAULT 0,
score DECIMAL(3,1) NULL,
startedAt DATETIME NULL,
completedAt DATETIME NULL,
createdAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updatedAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
UNIQUE INDEX IDX_user_works_userId_workId (userId, workId),
INDEX IDX_user_works_userId (userId),
INDEX IDX_user_works_workId (workId),
PRIMARY KEY (id),
CONSTRAINT FK_user_works_userId FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT FK_user_works_workId FOREIGN KEY (workId) REFERENCES works(id) ON DELETE CASCADE
) ENGINE=InnoDB
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE IF EXISTS user_works');
await queryRunner.query('DROP TABLE IF EXISTS works');
await queryRunner.query('DROP TABLE IF EXISTS users');
}
}

View File

@@ -0,0 +1,43 @@
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { UserService } from './user.service';
import { AuthGuard } from '../auth/auth.guard';
@Controller('api/user')
export class UserController {
constructor(private readonly userService: UserService) {}
@Get('me')
@UseGuards(AuthGuard)
async me(@Req() req: any) {
const user = await this.userService.findOrCreate({
id: req.user.id,
nickname: req.user.nickname,
avatar: req.user.avatar,
});
const profile = await this.userService.getProfile(user.id);
const stats = this.computeStats(profile);
return { ...profile, stats };
}
private computeStats(profile: any) {
if (!profile?.userWorks) return { total: 0, watching: 0, reading: 0, completed: 0 };
const works = profile.userWorks;
return {
total: works.length,
watching: works.filter((w: any) => w.status === 'watching').length,
reading: works.filter((w: any) => w.status === 'reading').length,
completed: works.filter((w: any) => w.status === 'completed').length,
dropped: works.filter((w: any) => w.status === 'dropped').length,
planTo: works.filter((w: any) => w.status === 'plan_to').length,
episodesWatched: works
.filter((w: any) => w.work?.type === 'anime')
.reduce((sum: number, w: any) => sum + w.progress, 0),
chaptersRead: works
.filter((w: any) => w.work?.type === 'manga')
.reduce((sum: number, w: any) => sum + w.progress, 0),
};
}
}

View File

@@ -0,0 +1,39 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { UserWork } from '../list/user-work.entity';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
superOauthId: string;
@Column()
username: string;
@Column({ nullable: true })
avatarUrl: string;
@Column({ default: 0 })
xp: number;
@Column({ default: 1 })
level: number;
@OneToMany(() => UserWork, (uw) => uw.user)
userWorks: UserWork[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [TypeOrmModule.forFeature([User]), AuthModule],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}

View File

@@ -0,0 +1,61 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepo: Repository<User>,
) {}
async findOrCreate(oauthProfile: {
id: string;
nickname: string;
avatar?: string;
}): Promise<User> {
let user = await this.userRepo.findOne({
where: { superOauthId: oauthProfile.id },
});
if (!user) {
user = this.userRepo.create({
superOauthId: oauthProfile.id,
username: oauthProfile.nickname,
avatarUrl: oauthProfile.avatar,
});
return this.userRepo.save(user);
}
if (
user.username !== oauthProfile.nickname ||
user.avatarUrl !== oauthProfile.avatar
) {
user.username = oauthProfile.nickname;
user.avatarUrl = oauthProfile.avatar || user.avatarUrl;
return this.userRepo.save(user);
}
return user;
}
async getProfile(userId: number) {
return this.userRepo.findOne({
where: { id: userId },
relations: ['userWorks', 'userWorks.work'],
});
}
async addXp(userId: number, amount: number): Promise<User> {
const user = await this.userRepo.findOneByOrFail({ id: userId });
user.xp += amount;
user.level = this.calculateLevel(user.xp);
return this.userRepo.save(user);
}
private calculateLevel(xp: number): number {
// Level up every 500 XP — simple for now, tuneable later
return Math.floor(xp / 500) + 1;
}
}

View 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>;
}
}

View 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);
}
}

View 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;
}

View 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 {}

View 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;
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}

27
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"strictPropertyInitialization": false,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"ignoreDeprecations": "6.0",
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
}
}