commit ccad63eb7fabf64c2aaa841fdec233abfc55db6a Author: mkelvers Date: Mon Apr 6 07:03:22 2026 +0200 chore: base project skeleton and db schema diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb1a07e --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..d45f05c --- /dev/null +++ b/cmd/server/main.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1f88e34 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f41994c --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..85d4b8c --- /dev/null +++ b/internal/database/db.go @@ -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, + } +} diff --git a/internal/database/models.go b/internal/database/models.go new file mode 100644 index 0000000..4134ac1 --- /dev/null +++ b/internal/database/models.go @@ -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"` +} diff --git a/internal/database/querier.go b/internal/database/querier.go new file mode 100644 index 0000000..866194e --- /dev/null +++ b/internal/database/querier.go @@ -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) diff --git a/internal/database/queries.sql b/internal/database/queries.sql new file mode 100644 index 0000000..c5a7150 --- /dev/null +++ b/internal/database/queries.sql @@ -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 = ?; diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go new file mode 100644 index 0000000..a423844 --- /dev/null +++ b/internal/database/queries.sql.go @@ -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 +} diff --git a/internal/jikan/client.go b/internal/jikan/client.go new file mode 100644 index 0000000..c046d47 --- /dev/null +++ b/internal/jikan/client.go @@ -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 +} diff --git a/internal/jikan/types.go b/internal/jikan/types.go new file mode 100644 index 0000000..9eacbcb --- /dev/null +++ b/internal/jikan/types.go @@ -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 +} diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..5d505ce --- /dev/null +++ b/migrations/001_init.sql @@ -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) +); diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..24f7eac --- /dev/null +++ b/static/css/style.css @@ -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; + } +} \ No newline at end of file