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

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