chore: base project skeleton and db schema

This commit is contained in:
2026-04-06 07:03:22 +02:00
commit ccad63eb7f
13 changed files with 1353 additions and 0 deletions

31
internal/database/db.go Normal file
View File

@@ -0,0 +1,31 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package database
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}

View File

@@ -0,0 +1,47 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package database
import (
"time"
)
type Account struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Provider string `json:"provider"`
ProviderAccountID string `json:"provider_account_id"`
CreatedAt time.Time `json:"created_at"`
}
type Anime struct {
ID int64 `json:"id"`
Title string `json:"title"`
ImageUrl string `json:"image_url"`
CreatedAt time.Time `json:"created_at"`
}
type Session struct {
ID string `json:"id"`
UserID string `json:"user_id"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
type User struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
CreatedAt time.Time `json:"created_at"`
}
type WatchListEntry struct {
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -0,0 +1,26 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package database
import (
"context"
)
type Querier interface {
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
DeleteSession(ctx context.Context, id string) error
DeleteUserSessions(ctx context.Context, userID string) error
DeleteWatchListEntry(ctx context.Context, arg DeleteWatchListEntryParams) error
GetSession(ctx context.Context, id string) (Session, error)
GetUser(ctx context.Context, id string) (User, error)
GetUserByUsername(ctx context.Context, username string) (User, error)
GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error)
GetWatchListEntry(ctx context.Context, arg GetWatchListEntryParams) (WatchListEntry, error)
UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error)
UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error)
}
var _ Querier = (*Queries)(nil)

View File

@@ -0,0 +1,55 @@
-- name: GetUser :one
SELECT * FROM user WHERE id = ? LIMIT 1;
-- name: GetUserByUsername :one
SELECT * FROM user WHERE username = ? LIMIT 1;
-- name: CreateUser :one
INSERT INTO user (id, username, password_hash)
VALUES (?, ?, ?)
RETURNING *;
-- name: CreateSession :one
INSERT INTO session (id, user_id, expires_at)
VALUES (?, ?, ?)
RETURNING *;
-- name: GetSession :one
SELECT * FROM session WHERE id = ? LIMIT 1;
-- name: DeleteSession :exec
DELETE FROM session WHERE id = ?;
-- name: DeleteUserSessions :exec
DELETE FROM session WHERE user_id = ?;
-- name: UpsertAnime :one
INSERT INTO anime (id, title, image_url)
VALUES (?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
title = excluded.title,
image_url = excluded.image_url
RETURNING *;
-- name: UpsertWatchListEntry :one
INSERT INTO watch_list_entry (id, user_id, anime_id, status, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT (user_id, anime_id) DO UPDATE SET
status = excluded.status,
updated_at = CURRENT_TIMESTAMP
RETURNING *;
-- name: GetWatchListEntry :one
SELECT * FROM watch_list_entry
WHERE user_id = ? AND anime_id = ? LIMIT 1;
-- name: GetUserWatchList :many
SELECT e.*, a.title, a.image_url
FROM watch_list_entry e
JOIN anime a ON e.anime_id = a.id
WHERE e.user_id = ?
ORDER BY e.updated_at DESC;
-- name: DeleteWatchListEntry :exec
DELETE FROM watch_list_entry
WHERE user_id = ? AND anime_id = ?;

View File

@@ -0,0 +1,277 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: queries.sql
package database
import (
"context"
"time"
)
const createSession = `-- name: CreateSession :one
INSERT INTO session (id, user_id, expires_at)
VALUES (?, ?, ?)
RETURNING id, user_id, expires_at, created_at
`
type CreateSessionParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
ExpiresAt time.Time `json:"expires_at"`
}
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
row := q.db.QueryRowContext(ctx, createSession, arg.ID, arg.UserID, arg.ExpiresAt)
var i Session
err := row.Scan(
&i.ID,
&i.UserID,
&i.ExpiresAt,
&i.CreatedAt,
)
return i, err
}
const createUser = `-- name: CreateUser :one
INSERT INTO user (id, username, password_hash)
VALUES (?, ?, ?)
RETURNING id, username, password_hash, created_at
`
type CreateUserParams struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
row := q.db.QueryRowContext(ctx, createUser, arg.ID, arg.Username, arg.PasswordHash)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
)
return i, err
}
const deleteSession = `-- name: DeleteSession :exec
DELETE FROM session WHERE id = ?
`
func (q *Queries) DeleteSession(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, deleteSession, id)
return err
}
const deleteUserSessions = `-- name: DeleteUserSessions :exec
DELETE FROM session WHERE user_id = ?
`
func (q *Queries) DeleteUserSessions(ctx context.Context, userID string) error {
_, err := q.db.ExecContext(ctx, deleteUserSessions, userID)
return err
}
const deleteWatchListEntry = `-- name: DeleteWatchListEntry :exec
DELETE FROM watch_list_entry
WHERE user_id = ? AND anime_id = ?
`
type DeleteWatchListEntryParams struct {
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
}
func (q *Queries) DeleteWatchListEntry(ctx context.Context, arg DeleteWatchListEntryParams) error {
_, err := q.db.ExecContext(ctx, deleteWatchListEntry, arg.UserID, arg.AnimeID)
return err
}
const getSession = `-- name: GetSession :one
SELECT id, user_id, expires_at, created_at FROM session WHERE id = ? LIMIT 1
`
func (q *Queries) GetSession(ctx context.Context, id string) (Session, error) {
row := q.db.QueryRowContext(ctx, getSession, id)
var i Session
err := row.Scan(
&i.ID,
&i.UserID,
&i.ExpiresAt,
&i.CreatedAt,
)
return i, err
}
const getUser = `-- name: GetUser :one
SELECT id, username, password_hash, created_at FROM user WHERE id = ? LIMIT 1
`
func (q *Queries) GetUser(ctx context.Context, id string) (User, error) {
row := q.db.QueryRowContext(ctx, getUser, id)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
)
return i, err
}
const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, username, password_hash, created_at FROM user WHERE username = ? LIMIT 1
`
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
row := q.db.QueryRowContext(ctx, getUserByUsername, username)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
)
return i, err
}
const getUserWatchList = `-- name: GetUserWatchList :many
SELECT e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, a.title, a.image_url
FROM watch_list_entry e
JOIN anime a ON e.anime_id = a.id
WHERE e.user_id = ?
ORDER BY e.updated_at DESC
`
type GetUserWatchListRow struct {
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Title string `json:"title"`
ImageUrl string `json:"image_url"`
}
func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error) {
rows, err := q.db.QueryContext(ctx, getUserWatchList, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetUserWatchListRow
for rows.Next() {
var i GetUserWatchListRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.AnimeID,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.Title,
&i.ImageUrl,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getWatchListEntry = `-- name: GetWatchListEntry :one
SELECT id, user_id, anime_id, status, created_at, updated_at FROM watch_list_entry
WHERE user_id = ? AND anime_id = ? LIMIT 1
`
type GetWatchListEntryParams struct {
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
}
func (q *Queries) GetWatchListEntry(ctx context.Context, arg GetWatchListEntryParams) (WatchListEntry, error) {
row := q.db.QueryRowContext(ctx, getWatchListEntry, arg.UserID, arg.AnimeID)
var i WatchListEntry
err := row.Scan(
&i.ID,
&i.UserID,
&i.AnimeID,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const upsertAnime = `-- name: UpsertAnime :one
INSERT INTO anime (id, title, image_url)
VALUES (?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
title = excluded.title,
image_url = excluded.image_url
RETURNING id, title, image_url, created_at
`
type UpsertAnimeParams struct {
ID int64 `json:"id"`
Title string `json:"title"`
ImageUrl string `json:"image_url"`
}
func (q *Queries) UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error) {
row := q.db.QueryRowContext(ctx, upsertAnime, arg.ID, arg.Title, arg.ImageUrl)
var i Anime
err := row.Scan(
&i.ID,
&i.Title,
&i.ImageUrl,
&i.CreatedAt,
)
return i, err
}
const upsertWatchListEntry = `-- name: UpsertWatchListEntry :one
INSERT INTO watch_list_entry (id, user_id, anime_id, status, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT (user_id, anime_id) DO UPDATE SET
status = excluded.status,
updated_at = CURRENT_TIMESTAMP
RETURNING id, user_id, anime_id, status, created_at, updated_at
`
type UpsertWatchListEntryParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
Status string `json:"status"`
}
func (q *Queries) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error) {
row := q.db.QueryRowContext(ctx, upsertWatchListEntry,
arg.ID,
arg.UserID,
arg.AnimeID,
arg.Status,
)
var i WatchListEntry
err := row.Scan(
&i.ID,
&i.UserID,
&i.AnimeID,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

235
internal/jikan/client.go Normal file
View File

@@ -0,0 +1,235 @@
package jikan
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
"github.com/hashicorp/golang-lru/v2/expirable"
)
type SearchResult struct {
Animes []Anime
HasNextPage bool
}
type TopAnimeResult struct {
Animes []Anime
HasNextPage bool
}
type Client struct {
httpClient *http.Client
baseURL string
cache *expirable.LRU[string, SearchResult]
topCache *expirable.LRU[int, TopAnimeResult]
animeCache *expirable.LRU[int, Anime]
relationsCache *expirable.LRU[int, JikanRelationsResponse]
}
func NewClient() *Client {
cache := expirable.NewLRU[string, SearchResult](500, nil, time.Hour*1)
topCache := expirable.NewLRU[int, TopAnimeResult](100, nil, time.Hour*1)
animeCache := expirable.NewLRU[int, Anime](1000, nil, time.Hour*24)
relationsCache := expirable.NewLRU[int, JikanRelationsResponse](1000, nil, time.Hour*24)
return &Client{
httpClient: &http.Client{Timeout: 10 * time.Second},
baseURL: "https://api.jikan.moe/v4",
cache: cache,
topCache: topCache,
animeCache: animeCache,
relationsCache: relationsCache,
}
}
// fetchWithRetry provides robust fetching respecting Jikan's strict 3 req/sec rate limit
func (c *Client) fetchWithRetry(urlStr string, out interface{}) error {
maxRetries := 3
for i := 0; i < maxRetries; i++ {
// Base delay for Jikan rate limiting (3 requests per second)
time.Sleep(340 * time.Millisecond)
resp, err := c.httpClient.Get(urlStr)
if err != nil {
return fmt.Errorf("jikan api error: %w", err)
}
if resp.StatusCode == 429 {
resp.Body.Close()
time.Sleep(800 * time.Millisecond) // Double delay on rate limit
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return fmt.Errorf("jikan api returned status %d", resp.StatusCode)
}
err = json.NewDecoder(resp.Body).Decode(out)
resp.Body.Close()
return err
}
return fmt.Errorf("max retries exceeded for %s", urlStr)
}
// Search returns the anime list with pagination support
func (c *Client) Search(query string, page int) (SearchResult, error) {
if query == "" {
return SearchResult{}, nil
}
if page < 1 {
page = 1
}
cacheKey := fmt.Sprintf("search:%s:%d", query, page)
if cached, ok := c.cache.Get(cacheKey); ok {
return cached, nil
}
var result SearchResponse
reqURL := fmt.Sprintf("%s/anime?q=%s&page=%d", c.baseURL, url.QueryEscape(query), page)
if err := c.fetchWithRetry(reqURL, &result); err != nil {
return SearchResult{}, err
}
res := SearchResult{
Animes: result.Data,
HasNextPage: result.Pagination.HasNextPage,
}
c.cache.Add(cacheKey, res)
return res, nil
}
// GetTopAnime fetches the top anime by popularity
func (c *Client) GetTopAnime(page int) (TopAnimeResult, error) {
if page < 1 {
page = 1
}
if cached, ok := c.topCache.Get(page); ok {
return cached, nil
}
var result TopAnimeResponse
reqURL := fmt.Sprintf("%s/top/anime?filter=bypopularity&page=%d", c.baseURL, page)
if err := c.fetchWithRetry(reqURL, &result); err != nil {
return TopAnimeResult{}, err
}
res := TopAnimeResult{
Animes: result.Data,
HasNextPage: result.Pagination.HasNextPage,
}
c.topCache.Add(page, res)
return res, nil
}
// GetAnimeByID fetches full details for a single anime
func (c *Client) GetAnimeByID(id int) (Anime, error) {
if cached, ok := c.animeCache.Get(id); ok {
return cached, nil
}
var result AnimeResponse
reqURL := fmt.Sprintf("%s/anime/%d/full", c.baseURL, id)
if err := c.fetchWithRetry(reqURL, &result); err != nil {
return Anime{}, err
}
c.animeCache.Add(id, result.Data)
return result.Data, nil
}
// GetRelationsData fetches the raw relationships for an anime
func (c *Client) GetRelationsData(id int) (JikanRelationsResponse, error) {
if cached, ok := c.relationsCache.Get(id); ok {
return cached, nil
}
var result JikanRelationsResponse
reqURL := fmt.Sprintf("%s/anime/%d/relations", c.baseURL, id)
if err := c.fetchWithRetry(reqURL, &result); err != nil {
return JikanRelationsResponse{}, err
}
c.relationsCache.Add(id, result)
return result, nil
}
// findFirstAnimeRelation extracts the first related anime ID for a specific relation type
func findFirstAnimeRelation(res JikanRelationsResponse, relType string) *int {
for _, group := range res.Data {
if group.Relation == relType {
for _, entry := range group.Entry {
if entry.Type == "anime" {
id := entry.MalID
return &id
}
}
}
}
return nil
}
// fetchChain recursively builds the relational chain (Prequels or Sequels)
func (c *Client) fetchChain(startID int, direction string, visited map[int]bool) []RelationEntry {
rels, err := c.GetRelationsData(startID)
if err != nil {
return nil
}
nextIDPtr := findFirstAnimeRelation(rels, direction)
if nextIDPtr == nil {
return nil
}
nextID := *nextIDPtr
if visited[nextID] { // prevent loops
return nil
}
visited[nextID] = true
anime, err := c.GetAnimeByID(nextID)
if err != nil {
return nil
}
entry := RelationEntry{Anime: anime, IsCurrent: false}
rest := c.fetchChain(nextID, direction, visited)
if direction == "Prequel" {
return append(rest, entry)
}
return append([]RelationEntry{entry}, rest...)
}
// GetFullRelations resolves the full Prequel/Sequel chronological chain synchronously
func (c *Client) GetFullRelations(id int) []RelationEntry {
currentAnime, err := c.GetAnimeByID(id)
if err != nil {
return nil
}
visited := map[int]bool{id: true}
prequels := c.fetchChain(id, "Prequel", visited)
// Clone visited set for sequels so we don't block valid paths if there's weird branching
visitedSeq := make(map[int]bool)
for k, v := range visited {
visitedSeq[k] = v
}
sequels := c.fetchChain(id, "Sequel", visitedSeq)
var result []RelationEntry
result = append(result, prequels...)
result = append(result, RelationEntry{Anime: currentAnime, IsCurrent: true})
result = append(result, sequels...)
return result
}

75
internal/jikan/types.go Normal file
View File

@@ -0,0 +1,75 @@
package jikan
// Anime struct matching the Jikan v4 API structure
type Anime struct {
MalID int `json:"mal_id"`
Title string `json:"title"`
TitleEnglish string `json:"title_english"`
TitleJapanese string `json:"title_japanese"`
Images struct {
Webp struct {
LargeImageURL string `json:"large_image_url"`
} `json:"webp"`
} `json:"images"`
Synopsis string `json:"synopsis"`
Score float64 `json:"score"`
ScoredBy int `json:"scored_by"`
Rank int `json:"rank"`
Popularity int `json:"popularity"`
Status string `json:"status"`
Episodes int `json:"episodes"`
Season string `json:"season"`
Year int `json:"year"`
Type string `json:"type"`
}
type AnimeResponse struct {
Data Anime `json:"data"`
}
type SearchResponse struct {
Data []Anime `json:"data"`
Pagination Pagination `json:"pagination"`
}
type Pagination struct {
HasNextPage bool `json:"has_next_page"`
}
type TopAnimeResponse struct {
Data []Anime `json:"data"`
Pagination Pagination `json:"pagination"`
}
// Relation Types
type JikanRelationEntry struct {
MalID int `json:"mal_id"`
Type string `json:"type"`
Name string `json:"name"`
URL string `json:"url"`
}
type JikanRelationGroup struct {
Relation string `json:"relation"`
Entry []JikanRelationEntry `json:"entry"`
}
type JikanRelationsResponse struct {
Data []JikanRelationGroup `json:"data"`
}
type RelationEntry struct {
Anime Anime
IsCurrent bool
}
// DisplayTitle prefers English, falls back to Japanese, then standard Title
func (a Anime) DisplayTitle() string {
if a.TitleEnglish != "" {
return a.TitleEnglish
}
if a.TitleJapanese != "" {
return a.TitleJapanese
}
return a.Title
}