Merge branch 'dev-ars' into dev
All checks were successful
Build and Deploy Go App / build (push) Successful in 5m21s
Build and Deploy Go App / deploy (push) Successful in 28s

This commit is contained in:
Iron_Felix 2025-11-24 05:15:57 +03:00
commit 02f7685eaa
9 changed files with 809 additions and 381 deletions

View file

@ -1,6 +1,10 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
oapi "nyanimedb/api"
sqlc "nyanimedb/sql"
"strconv"
)
@ -13,70 +17,83 @@ func NewServer(db *sqlc.Queries) Server {
return Server{db: db}
}
// type Cursor interface {
// ParseCursor(sortBy oapi.TitleSort, data oapi.Cursor) (Cursor, error)
func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi.Title, error) {
// Values() map[string]interface{}
// // for logs only
// Type() string
// }
oapi_title := oapi.Title{
EpisodesAired: title.EpisodesAired,
EpisodesAll: title.EpisodesAired,
// EpisodesLen: &episodes_lens,
Id: title.ID,
// Poster: &oapi_image,
Rating: title.Rating,
RatingCount: title.RatingCount,
// ReleaseSeason: &release_season,
ReleaseYear: title.ReleaseYear,
// Studio: &oapi_studio,
// Tags: oapi_tag_names,
// TitleNames: title_names,
// TitleStatus: oapi_status,
// AdditionalProperties:
}
// type CursorByID struct {
// ID int64
// }
title_names := make(map[string][]string, 0)
err := json.Unmarshal(title.TitleNames, &title_names)
if err != nil {
return oapi.Title{}, fmt.Errorf("unmarshal TitleNames: %v", err)
}
// func (c CursorByID) ParseCursor(sortBy oapi.TitleSort, data oapi.Cursor) (Cursor, error) {
// var cur CursorByID
// if err := json.Unmarshal(data, &cur); err != nil {
// return nil, fmt.Errorf("invalid cursor (id): %w", err)
// }
// if cur.ID == 0 {
// return nil, errors.New("cursor id must be non-zero")
// }
// return cur, nil
// }
if title.EpisodesLen != nil && len(title.EpisodesLen) > 0 {
episodes_lens := make(map[string]float64, 0)
err = json.Unmarshal(title.EpisodesLen, &episodes_lens)
if err != nil {
return oapi.Title{}, fmt.Errorf("unmarshal EpisodesLen: %v", err)
}
oapi_title.EpisodesLen = &episodes_lens
}
// func (c CursorByID) Values() map[string]interface{} {
// return map[string]interface{}{
// "cursor_id": c.ID,
// "cursor_year": nil,
// "cursor_rating": nil,
// }
// }
oapi_tag_names := make(oapi.Tags, 0)
err = json.Unmarshal(title.TagNames, &oapi_tag_names)
if err != nil {
return oapi.Title{}, fmt.Errorf("unmarshalling title_tag: %v", err)
}
// func (c CursorByID) Type() string { return "id" }
var oapi_studio oapi.Studio
if title.StudioName != nil {
oapi_studio.Name = *title.StudioName
}
if title.StudioID != 0 {
oapi_studio.Id = title.StudioID
oapi_studio.Description = title.StudioDesc
if title.StudioIllustID != nil {
oapi_studio.Poster.Id = title.StudioIllustID
oapi_studio.Poster.ImagePath = title.StudioImagePath
oapi_studio.Poster.StorageType = &title.StudioStorageType
}
}
oapi_title.Studio = &oapi_studio
// func NewCursor(sortBy string) (Cursor, error) {
// switch Type(sortBy) {
// case TypeID:
// return CursorByID{}, nil
// case TypeYear:
// return CursorByYear{}, nil
// case TypeRating:
// return CursorByRating{}, nil
// default:
// return nil, fmt.Errorf("unsupported sort_by: %q", sortBy)
// }
// }
var oapi_image oapi.Image
// decodes a base64-encoded JSON string into a CursorObj
// Returns the parsed CursorObj and an error
// func parseCursor(encoded oapi.Cursor) (*oapi.CursorObj, error) {
if title.PosterID != nil {
oapi_image.Id = title.PosterID
oapi_image.ImagePath = title.TitleImagePath
oapi_image.StorageType = &title.TitleStorageType
}
// // Decode base64
// decoded, err := base64.StdEncoding.DecodeString(encoded)
// if err != nil {
// return nil, fmt.Errorf("parseCursor: %v", err)
// }
var release_season oapi.ReleaseSeason
if title.ReleaseSeason != nil {
release_season = oapi.ReleaseSeason(*title.ReleaseSeason)
}
oapi_title.ReleaseSeason = &release_season
// // Parse JSON
// var cursor oapi.CursorObj
// if err := json.Unmarshal(decoded, &cursor); err != nil {
// return nil, fmt.Errorf("parseCursor: %v", err)
// }
oapi_status, err := TitleStatus2oapi(&title.TitleStatus)
if err != nil {
return oapi.Title{}, fmt.Errorf("TitleStatus2oapi: %v", err)
}
oapi_title.TitleStatus = oapi_status
// return &cursor, nil
// }
return oapi_title, nil
}
func parseInt64(s string) (int32, error) {
i, err := strconv.ParseInt(s, 10, 64)

View file

@ -26,25 +26,25 @@ type SqlcStatus struct {
planned string
}
func TitleStatus2Sqlc(s *[]oapi.TitleStatus) (*SqlcStatus, error) {
var sqlc_status SqlcStatus
if s == nil {
return &sqlc_status, nil
}
for _, t := range *s {
switch t {
case oapi.TitleStatusFinished:
sqlc_status.finished = "finished"
case oapi.TitleStatusOngoing:
sqlc_status.ongoing = "ongoing"
case oapi.TitleStatusPlanned:
sqlc_status.planned = "planned"
default:
return nil, fmt.Errorf("unexpected tittle status: %s", t)
}
}
return &sqlc_status, nil
}
// func TitleStatus2Sqlc(s *[]oapi.TitleStatus) (*SqlcStatus, error) {
// var sqlc_status SqlcStatus
// if s == nil {
// return &sqlc_status, nil
// }
// for _, t := range *s {
// switch t {
// case oapi.TitleStatusFinished:
// sqlc_status.finished = "finished"
// case oapi.TitleStatusOngoing:
// sqlc_status.ongoing = "ongoing"
// case oapi.TitleStatusPlanned:
// sqlc_status.planned = "planned"
// default:
// return nil, fmt.Errorf("unexpected tittle status: %s", t)
// }
// }
// return &sqlc_status, nil
// }
func TitleStatus2oapi(s *sqlc.TitleStatusT) (*oapi.TitleStatus, error) {
if s == nil {
@ -158,78 +158,6 @@ func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) {
return &oapi_studio, nil
}
func (s Server) mapTitle(ctx context.Context, title sqlc.SearchTitlesRow) (oapi.Title, error) {
// var oapi_title oapi.Title
title_names := make(map[string][]string, 0)
err := json.Unmarshal(title.TitleNames, &title_names)
if err != nil {
return oapi.Title{}, fmt.Errorf("unmarshal TitleNames: %v", err)
}
episodes_lens := make(map[string]float64, 0)
err = json.Unmarshal(title.EpisodesLen, &episodes_lens)
if err != nil {
return oapi.Title{}, fmt.Errorf("unmarshal EpisodesLen: %v", err)
}
oapi_tag_names := make(oapi.Tags, 0)
err = json.Unmarshal(title.TagNames, &oapi_tag_names)
if err != nil {
return oapi.Title{}, fmt.Errorf("unmarshalling title_tag: %v", err)
}
var oapi_studio oapi.Studio
oapi_studio.Id = title.StudioID
if title.StudioName != nil {
oapi_studio.Name = *title.StudioName
}
oapi_studio.Description = title.StudioDesc
if title.StudioIllustID != nil {
oapi_studio.Poster.Id = title.StudioIllustID
oapi_studio.Poster.ImagePath = title.StudioImagePath
oapi_studio.Poster.StorageType = &title.StudioStorageType
}
var oapi_image oapi.Image
if title.PosterID != nil {
oapi_image.Id = title.PosterID
oapi_image.ImagePath = title.TitleImagePath
oapi_image.StorageType = &title.TitleStorageType
}
var release_season oapi.ReleaseSeason
if title.ReleaseSeason != nil {
release_season = oapi.ReleaseSeason(*title.ReleaseSeason)
}
oapi_status, err := TitleStatus2oapi(&title.TitleStatus)
if err != nil {
return oapi.Title{}, fmt.Errorf("TitleStatus2oapi: %v", err)
}
oapi_title := oapi.Title{
EpisodesAired: title.EpisodesAired,
EpisodesAll: title.EpisodesAired,
EpisodesLen: &episodes_lens,
Id: title.ID,
Poster: &oapi_image,
Rating: title.Rating,
RatingCount: title.RatingCount,
ReleaseSeason: &release_season,
ReleaseYear: title.ReleaseYear,
Studio: &oapi_studio,
Tags: oapi_tag_names,
TitleNames: title_names,
TitleStatus: oapi_status,
// AdditionalProperties:
}
return oapi_title, nil
}
func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitleIdRequestObject) (oapi.GetTitlesTitleIdResponseObject, error) {
var oapi_title oapi.Title
@ -241,29 +169,8 @@ func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitl
log.Errorf("%v", err)
return oapi.GetTitlesTitleId500Response{}, nil
}
_sqlc_title := sqlc.SearchTitlesRow{
ID: sqlc_title.ID,
StudioID: sqlc_title.StudioID,
PosterID: sqlc_title.PosterID,
TitleStatus: sqlc_title.TitleStatus,
Rating: sqlc_title.Rating,
RatingCount: sqlc_title.RatingCount,
ReleaseYear: sqlc_title.ReleaseYear,
ReleaseSeason: sqlc_title.ReleaseSeason,
Season: sqlc_title.Season,
EpisodesAired: sqlc_title.EpisodesAired,
EpisodesAll: sqlc_title.EpisodesAll,
EpisodesLen: sqlc_title.EpisodesLen,
TitleStorageType: sqlc_title.TitleStorageType,
TitleImagePath: sqlc_title.TitleImagePath,
TagNames: sqlc_title.TitleNames,
StudioName: sqlc_title.StudioName,
StudioIllustID: sqlc_title.StudioIllustID,
StudioDesc: sqlc_title.StudioDesc,
StudioStorageType: sqlc_title.StudioStorageType,
StudioImagePath: sqlc_title.StudioImagePath,
}
oapi_title, err = s.mapTitle(ctx, _sqlc_title)
oapi_title, err = s.mapTitle(ctx, sqlc_title)
if err != nil {
log.Errorf("%v", err)
return oapi.GetTitlesTitleId500Response{}, nil
@ -276,11 +183,6 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje
opai_titles := make([]oapi.Title, 0)
word := Word2Sqlc(request.Params.Word)
status, err := TitleStatus2Sqlc(request.Params.Status)
if err != nil {
log.Errorf("%v", err)
return oapi.GetTitles400Response{}, err
}
season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason)
if err != nil {
@ -288,16 +190,22 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje
return oapi.GetTitles400Response{}, err
}
var statuses_sort []string
if request.Params.Status != nil {
for _, s := range *request.Params.Status {
ss := string(s) // s type is alias for string
statuses_sort = append(statuses_sort, ss)
}
}
params := sqlc.SearchTitlesParams{
Word: word,
Ongoing: status.ongoing,
Finished: status.finished,
Planned: status.planned,
TitleStatuses: statuses_sort,
Rating: request.Params.Rating,
ReleaseYear: request.Params.ReleaseYear,
ReleaseSeason: season,
Forward: true,
SortBy: "id",
Forward: true, // default
SortBy: "id", // default
Limit: request.Params.Limit,
}
@ -307,6 +215,7 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje
if request.Params.Sort != nil {
params.SortBy = string(*request.Params.Sort)
if request.Params.Cursor != nil {
// here we set CursorYear CursorID CursorRating fields
err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), &params)
if err != nil {
log.Errorf("%v", err)
@ -328,7 +237,30 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje
for _, title := range titles {
t, err := s.mapTitle(ctx, title)
_title := sqlc.GetTitleByIDRow{
ID: title.ID,
// StudioID: title.StudioID,
PosterID: title.PosterID,
TitleStatus: title.TitleStatus,
Rating: title.Rating,
RatingCount: title.RatingCount,
ReleaseYear: title.ReleaseYear,
ReleaseSeason: title.ReleaseSeason,
Season: title.Season,
EpisodesAired: title.EpisodesAired,
EpisodesAll: title.EpisodesAll,
// EpisodesLen: title.EpisodesLen,
TitleStorageType: title.TitleStorageType,
TitleImagePath: title.TitleImagePath,
TagNames: title.TitleNames,
StudioName: title.StudioName,
// StudioIllustID: title.StudioIllustID,
// StudioDesc: title.StudioDesc,
// StudioStorageType: title.StudioStorageType,
// StudioImagePath: title.StudioImagePath,
}
t, err := s.mapTitle(ctx, _title)
if err != nil {
log.Errorf("%v", err)
return oapi.GetTitles500Response{}, nil

View file

@ -2,12 +2,16 @@ package handlers
import (
"context"
"fmt"
oapi "nyanimedb/api"
sqlc "nyanimedb/sql"
"strconv"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/oapi-codegen/runtime/types"
log "github.com/sirupsen/logrus"
)
// type Server struct {
@ -50,34 +54,199 @@ func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdReque
return oapi.GetUsersUserId200JSONResponse(mapUser(user)), nil
}
func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersUserIdTitlesRequestObject) (oapi.GetUsersUserIdTitlesResponseObject, error) {
func sqlDate2oapi(p_date pgtype.Timestamptz) *time.Time {
if p_date.Valid {
t := p_date.Time
return &t
}
return nil
}
var rate int32 = 9
var review_id int64 = 3
time := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
type SqlcUserStatus struct {
dropped string
finished string
planned string
in_progress string
}
var userTitles = []oapi.UserTitle{
{
UserId: 101,
TitleId: 2001,
Status: oapi.UserTitleStatusFinished,
Rate: &rate,
Ctime: &time,
},
{
UserId: 102,
TitleId: 2002,
Status: oapi.UserTitleStatusInProgress,
ReviewId: &review_id,
Ctime: &time,
},
{
UserId: 103,
TitleId: 2003,
Status: oapi.UserTitleStatusDropped,
Ctime: &time,
},
// func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) (*SqlcUserStatus, error) {
// var sqlc_status SqlcUserStatus
// if s == nil {
// return &sqlc_status, nil
// }
// for _, t := range *s {
// switch t {
// case oapi.UserTitleStatusFinished:
// sqlc_status.finished = "finished"
// case oapi.UserTitleStatusDropped:
// sqlc_status.dropped = "dropped"
// case oapi.UserTitleStatusPlanned:
// sqlc_status.planned = "planned"
// case oapi.UserTitleStatusInProgress:
// sqlc_status.in_progress = "in-progress"
// default:
// return nil, fmt.Errorf("unexpected tittle status: %s", t)
// }
// }
// return &sqlc_status, nil
// }
func sql2usertitlestatus(s sqlc.UsertitleStatusT) (oapi.UserTitleStatus, error) {
var status oapi.UserTitleStatus
switch status {
case "finished":
status = oapi.UserTitleStatusFinished
case "dropped":
status = oapi.UserTitleStatusDropped
case "planned":
status = oapi.UserTitleStatusPlanned
case "in-progress":
status = oapi.UserTitleStatusInProgress
default:
return status, fmt.Errorf("unexpected tittle status: %s", s)
}
return oapi.GetUsersUserIdTitles200JSONResponse(userTitles), nil
return status, nil
}
func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (oapi.UserTitle, error) {
oapi_usertitle := oapi.UserTitle{
Ctime: sqlDate2oapi(t.UserCtime),
Rate: t.UserRate,
ReviewId: t.ReviewID,
// Status: ,
// Title: ,
UserId: t.UserID,
}
status, err := sql2usertitlestatus(t.UsertitleStatus)
if err != nil {
return oapi_usertitle, fmt.Errorf("mapUsertitle: %v", err)
}
oapi_usertitle.Status = status
_title := sqlc.GetTitleByIDRow{
ID: t.ID,
// StudioID: title.StudioID,
PosterID: t.PosterID,
TitleStatus: t.TitleStatus,
Rating: t.Rating,
RatingCount: t.RatingCount,
ReleaseYear: t.ReleaseYear,
ReleaseSeason: t.ReleaseSeason,
Season: t.Season,
EpisodesAired: t.EpisodesAired,
EpisodesAll: t.EpisodesAll,
// EpisodesLen: title.EpisodesLen,
TitleStorageType: t.TitleStorageType,
TitleImagePath: t.TitleImagePath,
TagNames: t.TitleNames,
StudioName: t.StudioName,
// StudioIllustID: title.StudioIllustID,
// StudioDesc: title.StudioDesc,
// StudioStorageType: title.StudioStorageType,
// StudioImagePath: title.StudioImagePath,
}
oapi_title, err := s.mapTitle(ctx, _title)
if err != nil {
return oapi_usertitle, fmt.Errorf("mapUsertitle: %v", err)
}
oapi_usertitle.Title = &oapi_title
return oapi_usertitle, nil
}
func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersUserIdTitlesRequestObject) (oapi.GetUsersUserIdTitlesResponseObject, error) {
oapi_usertitles := make([]oapi.UserTitle, 0)
word := Word2Sqlc(request.Params.Word)
season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason)
if err != nil {
log.Errorf("%v", err)
return oapi.GetUsersUserIdTitles400Response{}, err
}
var statuses_sort []string
if request.Params.Status != nil {
for _, s := range *request.Params.Status {
ss := string(s) // s type is alias for string
statuses_sort = append(statuses_sort, ss)
}
}
var watch_status []string
if request.Params.WatchStatus != nil {
for _, s := range *request.Params.WatchStatus {
ss := string(s) // s type is alias for string
watch_status = append(statuses_sort, ss)
}
}
params := sqlc.SearchUserTitlesParams{
Word: word,
TitleStatuses: statuses_sort,
UsertitleStatuses: watch_status,
Rating: request.Params.Rating,
Rate: request.Params.MyRate,
ReleaseYear: request.Params.ReleaseYear,
ReleaseSeason: season,
Forward: true, // default
SortBy: "id", // default
Limit: request.Params.Limit,
}
if request.Params.SortForward != nil {
params.Forward = *request.Params.SortForward
}
if request.Params.Sort != nil {
params.SortBy = string(*request.Params.Sort)
if request.Params.Cursor != nil {
// here we set CursorYear CursorID CursorRating fields
err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), &params)
if err != nil {
log.Errorf("%v", err)
return oapi.GetUsersUserIdTitles400Response{}, nil
}
}
}
// param = nil means it will not be used
titles, err := s.db.SearchUserTitles(ctx, params)
if err != nil {
log.Errorf("%v", err)
return oapi.GetUsersUserIdTitles500Response{}, nil
}
if len(titles) == 0 {
return oapi.GetUsersUserIdTitles204Response{}, nil
}
var new_cursor oapi.CursorObj
for _, title := range titles {
t, err := s.mapUsertitle(ctx, title)
if err != nil {
log.Errorf("%v", err)
return oapi.GetUsersUserIdTitles500Response{}, nil
}
oapi_usertitles = append(oapi_usertitles, t)
new_cursor.Id = t.Title.Id
if request.Params.Sort != nil {
switch string(*request.Params.Sort) {
case "year":
tmp := fmt.Sprint(*t.Title.ReleaseYear)
new_cursor.Param = &tmp
case "rating":
tmp := strconv.FormatFloat(*t.Title.Rating, 'f', -1, 64)
new_cursor.Param = &tmp
}
}
}
return oapi.GetUsersUserIdTitles200JSONResponse{Cursor: new_cursor, Data: oapi_usertitles}, nil
}

View file

@ -95,31 +95,36 @@ LEFT JOIN tags as g ON (tt.tag_id = g.id)
LEFT JOIN studios as s ON (t.studio_id = s.id)
LEFT JOIN images as si ON (s.illust_id = si.id)
WHERE id = sqlc.arg('title_id')::bigint
WHERE t.id = sqlc.arg('title_id')::bigint
GROUP BY
t.id, i.id, s.id, si.id;
-- name: SearchTitles :many
SELECT
t.*,
t.id as id,
t.title_names as title_names,
t.poster_id as poster_id,
t.title_status as title_status,
t.rating as rating,
t.rating_count as rating_count,
t.release_year as release_year,
t.release_season as release_season,
t.season as season,
t.episodes_aired as episodes_aired,
t.episodes_all as episodes_all,
i.storage_type::text as title_storage_type,
i.image_path as title_image_path,
COALESCE(
jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL),
'[]'::jsonb
)::jsonb as tag_names,
s.studio_name as studio_name,
s.illust_id as studio_illust_id,
s.studio_desc as studio_desc,
si.storage_type::text as studio_storage_type,
si.image_path as studio_image_path
s.studio_name as studio_name
FROM titles as t
LEFT JOIN images as i ON (t.poster_id = i.id)
LEFT JOIN title_tags as tt ON (t.id = tt.title_id)
LEFT JOIN tags as g ON (tt.tag_id = g.id)
LEFT JOIN studios as s ON (t.studio_id = s.id)
LEFT JOIN images as si ON (s.illust_id = si.id)
WHERE
CASE
@ -187,13 +192,19 @@ WHERE
END
)
AND (t.title_status::text IN (sqlc.arg('ongoing')::text, sqlc.arg('finished')::text, sqlc.arg('planned')::text))
AND (
-- Если массив пуст (NULL или []) — не фильтруем
cardinality(sqlc.arg('title_statuses')::text[]) = 0
OR
-- Иначе: статус есть в списке
t.title_status = ANY(sqlc.arg('title_statuses')::text[])
)
AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float)
AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int)
AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t)
GROUP BY
t.id, i.id, s.id, si.id
t.id, i.id, s.id
ORDER BY
CASE WHEN sqlc.arg('forward')::boolean THEN
@ -216,37 +227,142 @@ ORDER BY
LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit
-- name: SearchUserTitles :many
SELECT
*
FROM usertitles as u
JOIN titles as t ON (u.title_id = t.id)
WHERE
CASE
WHEN sqlc.narg('word')::text IS NOT NULL THEN
(
SELECT bool_and(
EXISTS (
SELECT 1
FROM jsonb_each_text(t.title_names) AS t(key, val)
WHERE val ILIKE pattern
)
)
FROM unnest(
ARRAY(
SELECT '%' || trim(w) || '%'
FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w
WHERE trim(w) <> ''
)
) AS pattern
)
ELSE true
SELECT
t.id as id,
t.title_names as title_names,
t.poster_id as poster_id,
t.title_status as title_status,
t.rating as rating,
t.rating_count as rating_count,
t.release_year as release_year,
t.release_season as release_season,
t.season as season,
t.episodes_aired as episodes_aired,
t.episodes_all as episodes_all,
u.user_id as user_id,
u.status as usertitle_status,
u.rate as user_rate,
u.review_id as review_id,
u.ctime as user_ctime,
i.storage_type::text as title_storage_type,
i.image_path as title_image_path,
COALESCE(
jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL),
'[]'::jsonb
)::jsonb as tag_names,
s.studio_name as studio_name
FROM usertitles as u
JOIN titles as t ON (u.title_id = t.id)
LEFT JOIN images as i ON (t.poster_id = i.id)
LEFT JOIN title_tags as tt ON (t.id = tt.title_id)
LEFT JOIN tags as g ON (tt.tag_id = g.id)
LEFT JOIN studios as s ON (t.studio_id = s.id)
WHERE
CASE
WHEN sqlc.arg('forward')::boolean THEN
-- forward: greater than cursor (next page)
CASE sqlc.arg('sort_by')::text
WHEN 'year' THEN
(sqlc.narg('cursor_year')::int IS NULL) OR
(t.release_year > sqlc.narg('cursor_year')::int) OR
(t.release_year = sqlc.narg('cursor_year')::int AND t.id > sqlc.narg('cursor_id')::bigint)
WHEN 'rating' THEN
(sqlc.narg('cursor_rating')::float IS NULL) OR
(t.rating > sqlc.narg('cursor_rating')::float) OR
(t.rating = sqlc.narg('cursor_rating')::float AND t.id > sqlc.narg('cursor_id')::bigint)
WHEN 'id' THEN
(sqlc.narg('cursor_id')::bigint IS NULL) OR
(t.id > sqlc.narg('cursor_id')::bigint)
ELSE true -- fallback
END
ELSE
-- backward: less than cursor (prev page)
CASE sqlc.arg('sort_by')::text
WHEN 'year' THEN
(sqlc.narg('cursor_year')::int IS NULL) OR
(t.release_year < sqlc.narg('cursor_year')::int) OR
(t.release_year = sqlc.narg('cursor_year')::int AND t.id < sqlc.narg('cursor_id')::bigint)
WHEN 'rating' THEN
(sqlc.narg('cursor_rating')::float IS NULL) OR
(t.rating < sqlc.narg('cursor_rating')::float) OR
(t.rating = sqlc.narg('cursor_rating')::float AND t.id < sqlc.narg('cursor_id')::bigint)
WHEN 'id' THEN
(sqlc.narg('cursor_id')::bigint IS NULL) OR
(t.id < sqlc.narg('cursor_id')::bigint)
ELSE true
END
END
AND (sqlc.narg('status')::title_status_t IS NULL OR t.title_status = sqlc.narg('status')::title_status_t)
AND (
CASE
WHEN sqlc.narg('word')::text IS NOT NULL THEN
(
SELECT bool_and(
EXISTS (
SELECT 1
FROM jsonb_each_text(t.title_names) AS t(key, val)
WHERE val ILIKE pattern
)
)
FROM unnest(
ARRAY(
SELECT '%' || trim(w) || '%'
FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w
WHERE trim(w) <> ''
)
) AS pattern
)
ELSE true
END
)
AND (
-- Если массив пуст (NULL или []) — не фильтруем
cardinality(sqlc.arg('title_statuses')::text[]) = 0
OR
t.title_status = ANY(sqlc.arg('title_statuses')::text[])
)
AND (
cardinality(sqlc.arg('usertitle_statuses')::text[]) = 0
OR
u.status = ANY(sqlc.arg('usertitle_statuses')::text[])
)
AND (sqlc.narg('rate')::int IS NULL OR u.rate >= sqlc.narg('rate')::int)
AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float)
AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int)
AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t)
AND (sqlc.narg('usertitle_status')::usertitle_status_t IS NULL OR u.usertitle_status = sqlc.narg('usertitle_status')::usertitle_status_t)
GROUP BY
t.id, i.id, s.id
ORDER BY
CASE WHEN sqlc.arg('forward')::boolean THEN
CASE
WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id
WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year
WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating
WHEN sqlc.arg('sort_by')::text = 'rate' THEN u.rate
END
END ASC,
CASE WHEN NOT sqlc.arg('forward')::boolean THEN
CASE
WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id
WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year
WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating
WHEN sqlc.arg('sort_by')::text = 'rate' THEN u.rate
END
END DESC,
CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN t.id END ASC
LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit