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

42
.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
# Go
main_server
create_user
*.db
*.db-shm
*.db-wal
tmp/

185
cmd/server/main.go Normal file
View File

@@ -0,0 +1,185 @@
package main
import (
"database/sql"
"log"
"net/http"
"os"
"strconv"
_ "github.com/mattn/go-sqlite3"
"malago/internal/auth"
"malago/internal/database"
"malago/internal/handlers"
"malago/internal/jikan"
"malago/internal/middleware"
"malago/internal/templates"
)
func main() {
dbFile := os.Getenv("DATABASE_FILE")
if dbFile == "" {
dbFile = "malago.db"
}
db, err := sql.Open("sqlite3", dbFile)
if err != nil {
log.Fatalf("failed to open db: %v", err)
}
defer db.Close()
// Run migrations (assuming local dev setup, simplistic execution)
migrationSQL, err := os.ReadFile("migrations/001_init.sql")
if err != nil {
log.Fatalf("failed to read migrations: %v", err)
}
if _, err := db.Exec(string(migrationSQL)); err != nil {
log.Fatalf("failed to run migrations: %v", err)
}
queries := database.New(db)
authService := auth.NewService(queries)
authHandler := handlers.NewAuthHandler(authService)
watchlistHandler := handlers.NewWatchlistHandler(queries)
jikanClient := jikan.NewClient()
mux := http.NewServeMux()
// Serve static files
fs := http.FileServer(http.Dir("./static"))
mux.Handle("/static/", http.StripPrefix("/static/", fs))
// Index page (Search)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
q := r.URL.Query().Get("q")
templates.Index(q).Render(r.Context(), w)
})
// Search endpoint initial query
mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
return
}
res, err := jikanClient.Search(query, 1)
if err != nil {
log.Printf("search error: %v", err)
http.Error(w, "Failed to search anime", http.StatusInternalServerError)
return
}
templates.SearchResultsWrapper(query, res.Animes, 2, res.HasNextPage).Render(r.Context(), w)
})
// Search endpoint (HTMX Infinite Scroll)
mux.HandleFunc("/api/search", func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
pageStr := r.URL.Query().Get("page")
page, _ := strconv.Atoi(pageStr)
if page < 1 {
page = 1
}
res, err := jikanClient.Search(query, page)
if err != nil {
log.Printf("search pagination error: %v", err)
http.Error(w, "Failed to fetch search page", http.StatusInternalServerError)
return
}
templates.SearchItems(query, res.Animes, page+1, res.HasNextPage).Render(r.Context(), w)
})
// Catalog page
mux.HandleFunc("/catalog", func(w http.ResponseWriter, r *http.Request) {
templates.Catalog().Render(r.Context(), w)
})
// Catalog endpoint (HTMX Infinite Scroll)
mux.HandleFunc("/api/catalog", func(w http.ResponseWriter, r *http.Request) {
pageStr := r.URL.Query().Get("page")
page, _ := strconv.Atoi(pageStr)
if page < 1 {
page = 1
}
res, err := jikanClient.GetTopAnime(page)
if err != nil {
log.Printf("top anime error: %v", err)
http.Error(w, "Failed to fetch top anime", http.StatusInternalServerError)
return
}
templates.CatalogItems(res.Animes, page+1, res.HasNextPage).Render(r.Context(), w)
})
// Anime Details page
mux.HandleFunc("/anime/", func(w http.ResponseWriter, r *http.Request) {
idStr := r.URL.Path[len("/anime/"):]
id, err := strconv.Atoi(idStr)
if err != nil || id <= 0 {
http.NotFound(w, r)
return
}
anime, err := jikanClient.GetAnimeByID(id)
if err != nil {
log.Printf("anime fetch error for %d: %v", id, err)
http.Error(w, "Failed to fetch anime details", http.StatusInternalServerError)
return
}
templates.AnimeDetails(anime).Render(r.Context(), w)
})
// Anime Relations API endpoint (HTMX "Suspense")
mux.HandleFunc("/api/anime/", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path[len("/api/anime/"):]
idStr := ""
for i, c := range path {
if c == '/' {
idStr = path[:i]
break
}
}
id, _ := strconv.Atoi(idStr)
if id <= 0 {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
relations := jikanClient.GetFullRelations(id)
templates.AnimeRelationsList(relations).Render(r.Context(), w)
})
// Auth Endpoints
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
authHandler.HandleLoginPage(w, r)
} else {
authHandler.HandleLogin(w, r)
}
})
mux.HandleFunc("/logout", authHandler.HandleLogout)
// Watchlist POST endpoint (Protected)
mux.Handle("/api/watchlist", middleware.RequireAuth(http.HandlerFunc(watchlistHandler.HandleUpdateWatchlist)))
mux.Handle("/api/watchlist/", middleware.RequireAuth(http.HandlerFunc(watchlistHandler.HandleDeleteWatchlist)))
mux.Handle("/watchlist", middleware.RequireAuth(http.HandlerFunc(watchlistHandler.HandleGetWatchlist)))
// Wrap mux with global auth checking, THEN auth context parsing
protectedHandler := middleware.RequireGlobalAuth(mux)
handler := middleware.Auth(authService)(protectedHandler)
log.Println("Server starting on http://localhost:3000")
if err := http.ListenAndServe(":3000", handler); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}

11
go.mod Normal file
View File

@@ -0,0 +1,11 @@
module malago
go 1.26.1
require (
github.com/a-h/templ v0.3.1001
github.com/google/uuid v1.6.0
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/mattn/go-sqlite3 v1.14.40
golang.org/x/crypto v0.49.0
)

10
go.sum Normal file
View File

@@ -0,0 +1,10 @@
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-sqlite3 v1.14.40 h1:f7+saIsbq4EF86mUqe0uiecQOJYMOdfi5uATADmUG94=
github.com/mattn/go-sqlite3 v1.14.40/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=

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
}

39
migrations/001_init.sql Normal file
View File

@@ -0,0 +1,39 @@
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS session (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS account (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
provider_account_id TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(provider, provider_account_id)
);
CREATE TABLE IF NOT EXISTS anime (
id INTEGER PRIMARY KEY, -- Jikan ID
title TEXT NOT NULL,
image_url TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS watch_list_entry (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
anime_id INTEGER NOT NULL REFERENCES anime(id) ON DELETE CASCADE,
status TEXT NOT NULL CHECK(status IN ('watching', 'completed', 'on_hold', 'dropped', 'plan_to_watch')),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, anime_id)
);

320
static/css/style.css Normal file
View File

@@ -0,0 +1,320 @@
:root {
/* Dark Monochromatic Palette (4chan catalog style but dark) */
--bg: #111111; /* very dark grey / almost black */
--surface: #1a1a1a; /* slightly lighter dark for inputs/boxes */
--border: #333333; /* dark grey border */
--text: #e0e0e0; /* light grey text */
--text-muted: #888888; /* medium grey for meta/loading */
--link: #cccccc; /* light grey links */
--link-hover: #ffffff; /* pure white on hover */
--greentext: #98fb98; /* pale green for success/quotes */
}
body {
background-color: var(--bg);
color: var(--text);
font-family: arial, helvetica, sans-serif;
font-size: 15px;
margin: 0;
padding: 16px;
min-height: 100vh;
}
header {
text-align: center;
margin-bottom: 24px;
}
h1 {
font-size: 24px;
font-weight: bold;
margin: 0 0 8px 0;
color: var(--text);
}
.nav {
font-size: 14px;
font-weight: bold;
}
a {
color: var(--link);
text-decoration: none;
}
a:hover {
color: var(--link-hover);
text-decoration: underline;
}
hr {
border: 0;
border-bottom: 1px solid var(--border);
margin: 16px 0;
}
/* Search Area */
.search-box {
text-align: center;
margin-bottom: 24px;
}
.search-form {
display: inline-flex;
gap: 4px;
}
.search-input {
border: 1px solid var(--border);
background: var(--surface);
padding: 6px;
color: var(--text);
font-size: 15px;
width: 250px;
}
.search-input:focus {
outline: none;
border-color: var(--text-muted);
}
.search-button {
background-color: var(--surface);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 16px;
cursor: pointer;
font-size: 15px;
}
.search-button:hover {
background-color: var(--border);
}
.status-text {
color: var(--text-muted);
font-weight: bold;
font-size: 12px;
text-align: center;
margin-bottom: 16px;
}
/* 4chan Catalog Grid */
.catalog-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 24px;
padding: 0 16px;
justify-items: center;
}
.catalog-item {
text-align: center;
width: 180px;
}
.catalog-thumb {
max-width: 180px;
max-height: 240px;
border: 1px solid var(--border);
transition: opacity 0.1s;
}
.catalog-thumb:hover {
opacity: 0.8;
}
.no-image {
width: 180px;
height: 240px;
background-color: var(--surface);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 13px;
}
.catalog-title {
font-weight: bold;
font-size: 13px;
margin-top: 6px;
word-wrap: break-word;
line-height: 1.4;
color: var(--text);
}
footer {
text-align: center;
font-size: 13px;
color: var(--text-muted);
margin-top: 48px;
}
/* HTMX states */
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: block; }
.htmx-request.htmx-indicator { display: block; }
/* Anime Details Page */
.details-container {
padding: 16px;
}
.details-header {
border-bottom: 1px solid var(--border);
padding-bottom: 16px;
margin-bottom: 24px;
}
.details-header h2 {
font-size: 28px;
margin: 0 0 8px 0;
color: var(--text);
}
.details-subtitle {
font-size: 16px;
color: var(--text-muted);
}
.details-body {
display: flex;
gap: 32px;
align-items: flex-start;
}
.details-sidebar {
width: 250px;
flex-shrink: 0;
}
.details-image {
width: 100%;
height: auto;
border: 1px solid var(--border);
margin-bottom: 16px;
}
.stats-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
background-color: var(--surface);
border: 1px solid var(--border);
}
.stats-table td {
border: 1px solid var(--border);
padding: 6px 8px;
text-align: left;
vertical-align: top;
}
.stats-table .stat-label {
font-weight: bold;
color: var(--text-muted);
background-color: var(--bg);
width: 80px;
}
.stat-sub {
color: var(--text-muted);
font-size: 12px;
}
.details-main {
flex: 1;
}
.details-main h3 {
font-size: 20px;
margin: 0 0 16px 0;
color: var(--text);
border-bottom: 1px solid var(--border);
padding-bottom: 4px;
display: inline-block;
}
.details-synopsis {
font-size: 17px;
line-height: 1.7;
white-space: pre-wrap;
}
.relations-container {
margin-top: 32px;
}
.relations-list {
margin-top: 32px;
padding-top: 16px;
}
.relations-list ul {
list-style-type: none;
padding: 0;
margin: 0;
}
.relations-list li {
margin-bottom: 8px;
font-size: 15px;
}
/* Responsive adjustments */
@media (max-width: 600px) {
body {
padding: 8px;
}
.catalog-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
padding: 0 4px;
}
.catalog-item, .catalog-thumb, .no-image {
width: 120px;
}
.catalog-thumb, .no-image {
max-height: 170px;
}
.search-form {
flex-direction: column;
width: 100%;
}
.search-input {
width: 100%;
box-sizing: border-box;
}
.search-button {
width: 100%;
}
.details-body {
flex-direction: column;
gap: 16px;
}
.details-sidebar {
width: 100%;
margin: 0 auto;
}
.details-main {
width: 100%;
}
.nav {
font-size: 14px;
gap: 8px;
display: flex;
justify-content: center;
}
}