mirror of
https://github.com/Dvorinka/SEEN.git
synced 2026-06-05 04:53:01 +00:00
small fix, don't worry about it
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/tdvorak/seen/backend/internal/domain"
|
||||
)
|
||||
|
||||
var ErrUserAlreadyExists = errors.New("user already exists")
|
||||
|
||||
type AuthRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewAuthRepository(pool *pgxpool.Pool) *AuthRepository {
|
||||
return &AuthRepository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *AuthRepository) CreateUser(ctx context.Context, user domain.User) error {
|
||||
_, err := r.pool.Exec(
|
||||
ctx,
|
||||
`INSERT INTO users (id, email, display_name, role, password_hash, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
user.ID,
|
||||
strings.ToLower(strings.TrimSpace(user.Email)),
|
||||
user.DisplayName,
|
||||
user.Role,
|
||||
user.PasswordHash,
|
||||
user.CreatedAt,
|
||||
user.UpdatedAt,
|
||||
)
|
||||
if err != nil && strings.Contains(err.Error(), "duplicate key") {
|
||||
return ErrUserAlreadyExists
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *AuthRepository) FindUserByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
row := r.pool.QueryRow(
|
||||
ctx,
|
||||
`SELECT id, email, display_name, role, password_hash, created_at, updated_at
|
||||
FROM users
|
||||
WHERE email = $1`,
|
||||
strings.ToLower(strings.TrimSpace(email)),
|
||||
)
|
||||
|
||||
var user domain.User
|
||||
if err := row.Scan(
|
||||
&user.ID,
|
||||
&user.Email,
|
||||
&user.DisplayName,
|
||||
&user.Role,
|
||||
&user.PasswordHash,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *AuthRepository) FindUserByID(ctx context.Context, userID uuid.UUID) (*domain.User, error) {
|
||||
row := r.pool.QueryRow(
|
||||
ctx,
|
||||
`SELECT id, email, display_name, role, password_hash, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = $1`,
|
||||
userID,
|
||||
)
|
||||
|
||||
var user domain.User
|
||||
if err := row.Scan(
|
||||
&user.ID,
|
||||
&user.Email,
|
||||
&user.DisplayName,
|
||||
&user.Role,
|
||||
&user.PasswordHash,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *AuthRepository) CreateSession(ctx context.Context, session domain.Session) error {
|
||||
_, err := r.pool.Exec(
|
||||
ctx,
|
||||
`INSERT INTO sessions (id, user_id, refresh_token, user_agent, ip, expires_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
session.ID,
|
||||
session.UserID,
|
||||
session.RefreshToken,
|
||||
session.UserAgent,
|
||||
session.IP,
|
||||
session.ExpiresAt,
|
||||
session.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *AuthRepository) FindSessionByRefreshToken(ctx context.Context, refreshToken string) (*domain.Session, error) {
|
||||
row := r.pool.QueryRow(
|
||||
ctx,
|
||||
`SELECT id, user_id, refresh_token, user_agent, ip, expires_at, revoked_at, created_at
|
||||
FROM sessions
|
||||
WHERE refresh_token = $1`,
|
||||
refreshToken,
|
||||
)
|
||||
|
||||
var session domain.Session
|
||||
if err := row.Scan(
|
||||
&session.ID,
|
||||
&session.UserID,
|
||||
&session.RefreshToken,
|
||||
&session.UserAgent,
|
||||
&session.IP,
|
||||
&session.ExpiresAt,
|
||||
&session.RevokedAt,
|
||||
&session.CreatedAt,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (r *AuthRepository) RevokeSession(ctx context.Context, sessionID uuid.UUID) error {
|
||||
_, err := r.pool.Exec(
|
||||
ctx,
|
||||
`UPDATE sessions
|
||||
SET revoked_at = now()
|
||||
WHERE id = $1`,
|
||||
sessionID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/tdvorak/seen/backend/internal/services/catalog"
|
||||
)
|
||||
|
||||
const listDiscoverRowsSQL = `
|
||||
WITH matched AS (
|
||||
SELECT
|
||||
ds.kind,
|
||||
ds.title AS section_title,
|
||||
ds.subtitle AS section_subtitle,
|
||||
ds.display_order,
|
||||
dsi.position AS section_position,
|
||||
m.id,
|
||||
m.provider,
|
||||
m.provider_id,
|
||||
m.title,
|
||||
m.overview,
|
||||
m.media_type,
|
||||
m.release_date::text AS release_date,
|
||||
m.rating,
|
||||
m.runtime_minutes,
|
||||
m.artwork_key,
|
||||
m.platforms,
|
||||
COALESCE(array_agg(g.name ORDER BY mg.position) FILTER (WHERE g.name IS NOT NULL), '{}'::text[]) AS genres
|
||||
FROM discover_sections ds
|
||||
JOIN discover_section_items dsi ON dsi.section_kind = ds.kind
|
||||
JOIN media_items m ON m.id = dsi.media_id
|
||||
LEFT JOIN media_genres mg ON mg.media_id = m.id
|
||||
LEFT JOIN genres g ON g.id = mg.genre_id
|
||||
WHERE
|
||||
(
|
||||
$1::text = ''
|
||||
OR LOWER(m.title) LIKE '%' || LOWER($1::text) || '%'
|
||||
OR LOWER(m.overview) LIKE '%' || LOWER($1::text) || '%'
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM media_genres qmg
|
||||
JOIN genres qg ON qg.id = qmg.genre_id
|
||||
WHERE qmg.media_id = m.id
|
||||
AND LOWER(qg.name) LIKE '%' || LOWER($1::text) || '%'
|
||||
)
|
||||
)
|
||||
AND (
|
||||
$2::text = ''
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM media_genres gmg
|
||||
JOIN genres gg ON gg.id = gmg.genre_id
|
||||
WHERE gmg.media_id = m.id
|
||||
AND LOWER(gg.name) = LOWER($2::text)
|
||||
)
|
||||
)
|
||||
AND (
|
||||
$3::text = ''
|
||||
OR LOWER($3::text) = 'all'
|
||||
OR m.media_type = LOWER($3::text)
|
||||
)
|
||||
GROUP BY
|
||||
ds.kind,
|
||||
ds.title,
|
||||
ds.subtitle,
|
||||
ds.display_order,
|
||||
dsi.position,
|
||||
m.id,
|
||||
m.provider,
|
||||
m.provider_id,
|
||||
m.title,
|
||||
m.overview,
|
||||
m.media_type,
|
||||
m.release_date,
|
||||
m.rating,
|
||||
m.runtime_minutes,
|
||||
m.artwork_key
|
||||
,
|
||||
m.platforms
|
||||
), ranked AS (
|
||||
SELECT
|
||||
*,
|
||||
row_number() OVER (PARTITION BY kind ORDER BY section_position ASC) AS row_num
|
||||
FROM matched
|
||||
)
|
||||
SELECT
|
||||
kind,
|
||||
section_title,
|
||||
section_subtitle,
|
||||
display_order,
|
||||
section_position,
|
||||
id,
|
||||
provider,
|
||||
provider_id,
|
||||
title,
|
||||
overview,
|
||||
media_type,
|
||||
release_date,
|
||||
rating,
|
||||
runtime_minutes,
|
||||
artwork_key,
|
||||
platforms,
|
||||
genres
|
||||
FROM ranked
|
||||
WHERE row_num > $4::int
|
||||
AND row_num <= ($4::int + $5::int)
|
||||
ORDER BY display_order ASC, section_position ASC;
|
||||
`
|
||||
|
||||
const listSectionItemsByKindSQL = `
|
||||
SELECT
|
||||
m.id,
|
||||
m.provider,
|
||||
m.provider_id,
|
||||
m.title,
|
||||
m.overview,
|
||||
m.media_type,
|
||||
m.release_date::text AS release_date,
|
||||
m.rating,
|
||||
m.runtime_minutes,
|
||||
m.artwork_key,
|
||||
m.platforms,
|
||||
COALESCE(array_agg(g.name ORDER BY mg.position) FILTER (WHERE g.name IS NOT NULL), '{}'::text[]) AS genres
|
||||
FROM discover_section_items dsi
|
||||
JOIN media_items m ON m.id = dsi.media_id
|
||||
LEFT JOIN media_genres mg ON mg.media_id = m.id
|
||||
LEFT JOIN genres g ON g.id = mg.genre_id
|
||||
WHERE dsi.section_kind = $1::text
|
||||
GROUP BY
|
||||
dsi.position,
|
||||
m.id,
|
||||
m.provider,
|
||||
m.provider_id,
|
||||
m.title,
|
||||
m.overview,
|
||||
m.media_type,
|
||||
m.release_date,
|
||||
m.rating,
|
||||
m.runtime_minutes,
|
||||
m.artwork_key,
|
||||
m.platforms
|
||||
ORDER BY dsi.position ASC
|
||||
LIMIT $2::int;
|
||||
`
|
||||
|
||||
const searchMediaItemsSQL = `
|
||||
SELECT
|
||||
m.id,
|
||||
m.provider,
|
||||
m.provider_id,
|
||||
m.title,
|
||||
m.overview,
|
||||
m.media_type,
|
||||
m.release_date::text AS release_date,
|
||||
m.rating,
|
||||
m.runtime_minutes,
|
||||
m.artwork_key,
|
||||
m.platforms,
|
||||
COALESCE(array_agg(g.name ORDER BY mg.position) FILTER (WHERE g.name IS NOT NULL), '{}'::text[]) AS genres
|
||||
FROM media_items m
|
||||
LEFT JOIN media_genres mg ON mg.media_id = m.id
|
||||
LEFT JOIN genres g ON g.id = mg.genre_id
|
||||
WHERE
|
||||
(
|
||||
$1::text = ''
|
||||
OR LOWER(m.title) LIKE '%' || LOWER($1::text) || '%'
|
||||
OR LOWER(m.overview) LIKE '%' || LOWER($1::text) || '%'
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM media_genres qmg
|
||||
JOIN genres qg ON qg.id = qmg.genre_id
|
||||
WHERE qmg.media_id = m.id
|
||||
AND LOWER(qg.name) LIKE '%' || LOWER($1::text) || '%'
|
||||
)
|
||||
)
|
||||
AND (
|
||||
$2::text = ''
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM media_genres gmg
|
||||
JOIN genres gg ON gg.id = gmg.genre_id
|
||||
WHERE gmg.media_id = m.id
|
||||
AND LOWER(gg.name) = LOWER($2::text)
|
||||
)
|
||||
)
|
||||
AND (
|
||||
$3::text = ''
|
||||
OR LOWER($3::text) = 'all'
|
||||
OR m.media_type = LOWER($3::text)
|
||||
)
|
||||
GROUP BY
|
||||
m.id,
|
||||
m.provider,
|
||||
m.provider_id,
|
||||
m.title,
|
||||
m.overview,
|
||||
m.media_type,
|
||||
m.release_date,
|
||||
m.rating,
|
||||
m.runtime_minutes,
|
||||
m.artwork_key,
|
||||
m.platforms
|
||||
ORDER BY m.rating DESC, m.release_date DESC, m.title ASC
|
||||
LIMIT 50;
|
||||
`
|
||||
|
||||
const listUserWatchLaterSQL = `
|
||||
SELECT
|
||||
m.id,
|
||||
m.provider,
|
||||
m.provider_id,
|
||||
m.title,
|
||||
m.overview,
|
||||
m.media_type,
|
||||
m.release_date::text AS release_date,
|
||||
m.rating,
|
||||
m.runtime_minutes,
|
||||
m.artwork_key,
|
||||
m.platforms,
|
||||
COALESCE(array_agg(g.name ORDER BY mg.position) FILTER (WHERE g.name IS NOT NULL), '{}'::text[]) AS genres,
|
||||
uwl.created_at
|
||||
FROM user_watch_later uwl
|
||||
JOIN media_items m ON m.id = uwl.media_id
|
||||
LEFT JOIN media_genres mg ON mg.media_id = m.id
|
||||
LEFT JOIN genres g ON g.id = mg.genre_id
|
||||
WHERE uwl.user_id = $1::uuid
|
||||
GROUP BY
|
||||
uwl.created_at,
|
||||
m.id,
|
||||
m.provider,
|
||||
m.provider_id,
|
||||
m.title,
|
||||
m.overview,
|
||||
m.media_type,
|
||||
m.release_date,
|
||||
m.rating,
|
||||
m.runtime_minutes,
|
||||
m.artwork_key,
|
||||
m.platforms
|
||||
ORDER BY uwl.created_at DESC;
|
||||
`
|
||||
|
||||
const addUserWatchLaterSQL = `
|
||||
INSERT INTO user_watch_later (user_id, media_id)
|
||||
VALUES ($1::uuid, $2::bigint)
|
||||
ON CONFLICT (user_id, media_id) DO NOTHING;
|
||||
`
|
||||
|
||||
const removeUserWatchLaterSQL = `
|
||||
DELETE FROM user_watch_later
|
||||
WHERE user_id = $1::uuid
|
||||
AND media_id = $2::bigint;
|
||||
`
|
||||
|
||||
const mediaExistsSQL = `
|
||||
SELECT EXISTS(SELECT 1 FROM media_items WHERE id = $1::bigint);
|
||||
`
|
||||
|
||||
const listContinueWatchingSQL = `
|
||||
SELECT
|
||||
m.id,
|
||||
m.provider,
|
||||
m.provider_id,
|
||||
m.title,
|
||||
m.overview,
|
||||
m.media_type,
|
||||
m.release_date::text AS release_date,
|
||||
m.rating,
|
||||
m.runtime_minutes,
|
||||
m.artwork_key,
|
||||
m.platforms,
|
||||
COALESCE(array_agg(g.name ORDER BY mg.position) FILTER (WHERE g.name IS NOT NULL), '{}'::text[]) AS genres,
|
||||
up.season_number,
|
||||
up.episode_number,
|
||||
up.progress_percent,
|
||||
up.last_watched_at
|
||||
FROM user_progress up
|
||||
JOIN media_items m ON m.id = up.media_id
|
||||
LEFT JOIN media_genres mg ON mg.media_id = m.id
|
||||
LEFT JOIN genres g ON g.id = mg.genre_id
|
||||
WHERE up.user_id = $1::uuid
|
||||
AND up.progress_percent > 0
|
||||
AND up.progress_percent < 100
|
||||
GROUP BY
|
||||
up.last_watched_at,
|
||||
up.season_number,
|
||||
up.episode_number,
|
||||
up.progress_percent,
|
||||
m.id,
|
||||
m.provider,
|
||||
m.provider_id,
|
||||
m.title,
|
||||
m.overview,
|
||||
m.media_type,
|
||||
m.release_date,
|
||||
m.rating,
|
||||
m.runtime_minutes,
|
||||
m.artwork_key,
|
||||
m.platforms
|
||||
ORDER BY up.last_watched_at DESC
|
||||
LIMIT $2::int;
|
||||
`
|
||||
|
||||
const upsertUserProgressSQL = `
|
||||
INSERT INTO user_progress (
|
||||
user_id,
|
||||
media_id,
|
||||
season_number,
|
||||
episode_number,
|
||||
progress_percent,
|
||||
last_watched_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES ($1::uuid, $2::bigint, $3::int, $4::int, $5::int, NOW(), NOW(), NOW())
|
||||
ON CONFLICT (user_id, media_id, season_number, episode_number)
|
||||
DO UPDATE SET
|
||||
progress_percent = EXCLUDED.progress_percent,
|
||||
last_watched_at = NOW(),
|
||||
updated_at = NOW();
|
||||
`
|
||||
|
||||
type CatalogRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewCatalogRepository(pool *pgxpool.Pool) *CatalogRepository {
|
||||
return &CatalogRepository{pool: pool}
|
||||
}
|
||||
|
||||
type scannedMediaItem struct {
|
||||
id int64
|
||||
provider string
|
||||
providerID int64
|
||||
title string
|
||||
overview string
|
||||
mediaType string
|
||||
releaseDate string
|
||||
rating float64
|
||||
runtimeMinutes int32
|
||||
artworkKey string
|
||||
platforms []string
|
||||
genres []string
|
||||
}
|
||||
|
||||
func (item scannedMediaItem) toCatalog() catalog.MediaItem {
|
||||
return catalog.MediaItem{
|
||||
ID: int(item.id),
|
||||
Provider: catalog.MediaProvider(item.provider),
|
||||
ProviderID: int(item.providerID),
|
||||
Title: item.title,
|
||||
Overview: item.overview,
|
||||
Type: catalog.MediaType(item.mediaType),
|
||||
ReleaseDate: item.releaseDate,
|
||||
Genres: cloneStrings(item.genres),
|
||||
Platforms: cloneStrings(item.platforms),
|
||||
Rating: item.rating,
|
||||
RuntimeMinutes: int(item.runtimeMinutes),
|
||||
ArtworkKey: item.artworkKey,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *CatalogRepository) Discover(
|
||||
ctx context.Context,
|
||||
params catalog.DiscoverParams,
|
||||
) ([]catalog.DiscoverSection, error) {
|
||||
offset := (params.Page - 1) * params.PageSize
|
||||
|
||||
rows, err := r.pool.Query(
|
||||
ctx,
|
||||
listDiscoverRowsSQL,
|
||||
strings.TrimSpace(params.Query),
|
||||
strings.TrimSpace(params.Genre),
|
||||
strings.TrimSpace(params.MediaType),
|
||||
offset,
|
||||
params.PageSize,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
sections := make([]catalog.DiscoverSection, 0)
|
||||
sectionIndex := make(map[string]int)
|
||||
|
||||
for rows.Next() {
|
||||
var kind string
|
||||
var sectionTitle string
|
||||
var sectionSubtitle string
|
||||
var displayOrder int16
|
||||
var sectionPosition int16
|
||||
var item scannedMediaItem
|
||||
|
||||
if err := rows.Scan(
|
||||
&kind,
|
||||
§ionTitle,
|
||||
§ionSubtitle,
|
||||
&displayOrder,
|
||||
§ionPosition,
|
||||
&item.id,
|
||||
&item.provider,
|
||||
&item.providerID,
|
||||
&item.title,
|
||||
&item.overview,
|
||||
&item.mediaType,
|
||||
&item.releaseDate,
|
||||
&item.rating,
|
||||
&item.runtimeMinutes,
|
||||
&item.artworkKey,
|
||||
&item.platforms,
|
||||
&item.genres,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = displayOrder
|
||||
_ = sectionPosition
|
||||
|
||||
idx, exists := sectionIndex[kind]
|
||||
if !exists {
|
||||
idx = len(sections)
|
||||
sectionIndex[kind] = idx
|
||||
sections = append(sections, catalog.DiscoverSection{
|
||||
Kind: kind,
|
||||
Title: sectionTitle,
|
||||
Subtitle: sectionSubtitle,
|
||||
Items: []catalog.MediaItem{},
|
||||
})
|
||||
}
|
||||
|
||||
sections[idx].Items = append(sections[idx].Items, item.toCatalog())
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sections, nil
|
||||
}
|
||||
|
||||
func (r *CatalogRepository) SectionItems(
|
||||
ctx context.Context,
|
||||
kind string,
|
||||
limit int,
|
||||
) ([]catalog.MediaItem, error) {
|
||||
rows, err := r.pool.Query(ctx, listSectionItemsByKindSQL, strings.TrimSpace(kind), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]catalog.MediaItem, 0, limit)
|
||||
for rows.Next() {
|
||||
var item scannedMediaItem
|
||||
if err := rows.Scan(
|
||||
&item.id,
|
||||
&item.provider,
|
||||
&item.providerID,
|
||||
&item.title,
|
||||
&item.overview,
|
||||
&item.mediaType,
|
||||
&item.releaseDate,
|
||||
&item.rating,
|
||||
&item.runtimeMinutes,
|
||||
&item.artworkKey,
|
||||
&item.platforms,
|
||||
&item.genres,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items = append(items, item.toCatalog())
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *CatalogRepository) SearchMedia(
|
||||
ctx context.Context,
|
||||
params catalog.SearchParams,
|
||||
) ([]catalog.MediaItem, error) {
|
||||
query := strings.TrimSpace(params.Query)
|
||||
if query == "" {
|
||||
return []catalog.MediaItem{}, nil
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(
|
||||
ctx,
|
||||
searchMediaItemsSQL,
|
||||
query,
|
||||
strings.TrimSpace(params.Genre),
|
||||
strings.TrimSpace(params.MediaType),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]catalog.MediaItem, 0, 32)
|
||||
for rows.Next() {
|
||||
var item scannedMediaItem
|
||||
if err := rows.Scan(
|
||||
&item.id,
|
||||
&item.provider,
|
||||
&item.providerID,
|
||||
&item.title,
|
||||
&item.overview,
|
||||
&item.mediaType,
|
||||
&item.releaseDate,
|
||||
&item.rating,
|
||||
&item.runtimeMinutes,
|
||||
&item.artworkKey,
|
||||
&item.platforms,
|
||||
&item.genres,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items = append(items, item.toCatalog())
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *CatalogRepository) ListWatchLater(
|
||||
ctx context.Context,
|
||||
userID uuid.UUID,
|
||||
) ([]catalog.MediaItem, error) {
|
||||
rows, err := r.pool.Query(ctx, listUserWatchLaterSQL, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]catalog.MediaItem, 0, 8)
|
||||
for rows.Next() {
|
||||
var item scannedMediaItem
|
||||
var createdAt time.Time
|
||||
if err := rows.Scan(
|
||||
&item.id,
|
||||
&item.provider,
|
||||
&item.providerID,
|
||||
&item.title,
|
||||
&item.overview,
|
||||
&item.mediaType,
|
||||
&item.releaseDate,
|
||||
&item.rating,
|
||||
&item.runtimeMinutes,
|
||||
&item.artworkKey,
|
||||
&item.platforms,
|
||||
&item.genres,
|
||||
&createdAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = createdAt
|
||||
|
||||
items = append(items, item.toCatalog())
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *CatalogRepository) AddWatchLater(
|
||||
ctx context.Context,
|
||||
userID uuid.UUID,
|
||||
mediaID int,
|
||||
) error {
|
||||
if err := r.ensureMediaExists(ctx, mediaID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := r.pool.Exec(ctx, addUserWatchLaterSQL, userID, mediaID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *CatalogRepository) RemoveWatchLater(
|
||||
ctx context.Context,
|
||||
userID uuid.UUID,
|
||||
mediaID int,
|
||||
) error {
|
||||
_, err := r.pool.Exec(ctx, removeUserWatchLaterSQL, userID, mediaID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *CatalogRepository) ListContinueWatching(
|
||||
ctx context.Context,
|
||||
userID uuid.UUID,
|
||||
limit int,
|
||||
) ([]catalog.ContinueWatchingItem, error) {
|
||||
rows, err := r.pool.Query(ctx, listContinueWatchingSQL, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]catalog.ContinueWatchingItem, 0, limit)
|
||||
for rows.Next() {
|
||||
var item scannedMediaItem
|
||||
var seasonNumber int32
|
||||
var episodeNumber int32
|
||||
var progressPercent int32
|
||||
var lastWatchedAt time.Time
|
||||
|
||||
if err := rows.Scan(
|
||||
&item.id,
|
||||
&item.provider,
|
||||
&item.providerID,
|
||||
&item.title,
|
||||
&item.overview,
|
||||
&item.mediaType,
|
||||
&item.releaseDate,
|
||||
&item.rating,
|
||||
&item.runtimeMinutes,
|
||||
&item.artworkKey,
|
||||
&item.platforms,
|
||||
&item.genres,
|
||||
&seasonNumber,
|
||||
&episodeNumber,
|
||||
&progressPercent,
|
||||
&lastWatchedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items = append(items, catalog.ContinueWatchingItem{
|
||||
Item: item.toCatalog(),
|
||||
Progress: catalog.EpisodeProgress{
|
||||
ItemID: int(item.id),
|
||||
SeasonNumber: int(seasonNumber),
|
||||
EpisodeNumber: int(episodeNumber),
|
||||
ProgressPercent: int(progressPercent),
|
||||
LastWatchedAt: lastWatchedAt.UTC().Format(time.RFC3339),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *CatalogRepository) UpsertProgress(
|
||||
ctx context.Context,
|
||||
userID uuid.UUID,
|
||||
input catalog.ProgressUpdateInput,
|
||||
) error {
|
||||
if err := r.ensureMediaExists(ctx, input.MediaID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := r.pool.Exec(
|
||||
ctx,
|
||||
upsertUserProgressSQL,
|
||||
userID,
|
||||
input.MediaID,
|
||||
input.SeasonNumber,
|
||||
input.EpisodeNumber,
|
||||
input.ProgressPercent,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *CatalogRepository) ensureMediaExists(ctx context.Context, mediaID int) error {
|
||||
var exists bool
|
||||
if err := r.pool.QueryRow(ctx, mediaExistsSQL, mediaID).Scan(&exists); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return catalog.ErrMediaNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneStrings(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
cloned := make([]string, len(values))
|
||||
copy(cloned, values)
|
||||
return cloned
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/tdvorak/seen/backend/internal/services/download"
|
||||
)
|
||||
|
||||
const createDownloadJobSQL = `
|
||||
INSERT INTO download_jobs (
|
||||
user_id,
|
||||
source_type,
|
||||
source,
|
||||
title,
|
||||
status,
|
||||
queue_position,
|
||||
progress_percent,
|
||||
bytes_total,
|
||||
bytes_downloaded,
|
||||
download_speed_mbps,
|
||||
eta_seconds,
|
||||
error_message,
|
||||
retry_count,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
$1::uuid,
|
||||
$2::text,
|
||||
$3::text,
|
||||
COALESCE(NULLIF($4::text, ''), $3::text),
|
||||
'queued',
|
||||
NULL,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
NULL,
|
||||
'',
|
||||
0,
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
RETURNING
|
||||
id::text,
|
||||
user_id::text,
|
||||
source_type,
|
||||
source,
|
||||
title,
|
||||
status,
|
||||
COALESCE(queue_position, 0),
|
||||
progress_percent,
|
||||
bytes_total,
|
||||
bytes_downloaded,
|
||||
download_speed_mbps,
|
||||
COALESCE(eta_seconds, 0),
|
||||
error_message,
|
||||
retry_count,
|
||||
created_at,
|
||||
updated_at;
|
||||
`
|
||||
|
||||
const listDownloadJobsSQL = `
|
||||
SELECT
|
||||
id::text,
|
||||
user_id::text,
|
||||
source_type,
|
||||
source,
|
||||
title,
|
||||
status,
|
||||
COALESCE(queue_position, 0),
|
||||
progress_percent,
|
||||
bytes_total,
|
||||
bytes_downloaded,
|
||||
download_speed_mbps,
|
||||
COALESCE(eta_seconds, 0),
|
||||
error_message,
|
||||
retry_count,
|
||||
created_at,
|
||||
updated_at,
|
||||
COALESCE(started_at, NOW()),
|
||||
COALESCE(completed_at, NOW()),
|
||||
COALESCE(cancelled_at, NOW())
|
||||
FROM download_jobs
|
||||
WHERE user_id = $1::uuid
|
||||
AND ($2::text = '' OR status = $2::text)
|
||||
ORDER BY updated_at DESC, created_at DESC
|
||||
LIMIT $3::int
|
||||
OFFSET $4::int;
|
||||
`
|
||||
|
||||
const getDownloadJobByIDSQL = `
|
||||
SELECT
|
||||
id::text,
|
||||
user_id::text,
|
||||
source_type,
|
||||
source,
|
||||
title,
|
||||
status,
|
||||
COALESCE(queue_position, 0),
|
||||
progress_percent,
|
||||
bytes_total,
|
||||
bytes_downloaded,
|
||||
download_speed_mbps,
|
||||
COALESCE(eta_seconds, 0),
|
||||
error_message,
|
||||
retry_count,
|
||||
created_at,
|
||||
updated_at,
|
||||
COALESCE(started_at, NOW()),
|
||||
COALESCE(completed_at, NOW()),
|
||||
COALESCE(cancelled_at, NOW())
|
||||
FROM download_jobs
|
||||
WHERE user_id = $1::uuid
|
||||
AND id = $2::uuid
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
const cancelDownloadJobSQL = `
|
||||
UPDATE download_jobs
|
||||
SET
|
||||
status = 'cancelled',
|
||||
cancelled_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE user_id = $1::uuid
|
||||
AND id = $2::uuid
|
||||
RETURNING
|
||||
id::text,
|
||||
user_id::text,
|
||||
source_type,
|
||||
source,
|
||||
title,
|
||||
status,
|
||||
COALESCE(queue_position, 0),
|
||||
progress_percent,
|
||||
bytes_total,
|
||||
bytes_downloaded,
|
||||
download_speed_mbps,
|
||||
COALESCE(eta_seconds, 0),
|
||||
error_message,
|
||||
retry_count,
|
||||
created_at,
|
||||
updated_at,
|
||||
COALESCE(started_at, NOW()),
|
||||
COALESCE(completed_at, NOW()),
|
||||
COALESCE(cancelled_at, NOW());
|
||||
`
|
||||
|
||||
const appendDownloadEventSQL = `
|
||||
INSERT INTO download_events (
|
||||
job_id,
|
||||
status,
|
||||
message,
|
||||
progress_percent,
|
||||
payload,
|
||||
created_at
|
||||
) VALUES (
|
||||
$1::uuid,
|
||||
$2::text,
|
||||
$3::text,
|
||||
$4::int,
|
||||
$5::jsonb,
|
||||
NOW()
|
||||
);
|
||||
`
|
||||
|
||||
const updateDownloadJobSQL = `
|
||||
UPDATE download_jobs
|
||||
SET
|
||||
status = $3::text,
|
||||
progress_percent = $4::int,
|
||||
bytes_total = $5::bigint,
|
||||
bytes_downloaded = $6::bigint,
|
||||
download_speed_mbps = $7::double precision,
|
||||
eta_seconds = CASE WHEN $8::int <= 0 THEN NULL ELSE $8::int END,
|
||||
error_message = $9::text,
|
||||
retry_count = $10::smallint,
|
||||
started_at = CASE
|
||||
WHEN $3::text IN ('preparing', 'downloading') AND started_at IS NULL THEN NOW()
|
||||
ELSE started_at
|
||||
END,
|
||||
completed_at = CASE
|
||||
WHEN $3::text = 'completed' THEN NOW()
|
||||
ELSE completed_at
|
||||
END,
|
||||
cancelled_at = CASE
|
||||
WHEN $3::text = 'cancelled' THEN NOW()
|
||||
ELSE cancelled_at
|
||||
END,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = $1::uuid
|
||||
AND id = $2::uuid
|
||||
RETURNING
|
||||
id::text,
|
||||
user_id::text,
|
||||
source_type,
|
||||
source,
|
||||
title,
|
||||
status,
|
||||
COALESCE(queue_position, 0),
|
||||
progress_percent,
|
||||
bytes_total,
|
||||
bytes_downloaded,
|
||||
download_speed_mbps,
|
||||
COALESCE(eta_seconds, 0),
|
||||
error_message,
|
||||
retry_count,
|
||||
created_at,
|
||||
updated_at,
|
||||
COALESCE(started_at, NOW()),
|
||||
COALESCE(completed_at, NOW()),
|
||||
COALESCE(cancelled_at, NOW());
|
||||
`
|
||||
|
||||
const listDownloadEventsSQL = `
|
||||
SELECT
|
||||
id,
|
||||
job_id::text,
|
||||
status,
|
||||
message,
|
||||
progress_percent,
|
||||
payload::text,
|
||||
created_at
|
||||
FROM download_events
|
||||
WHERE job_id = $1::uuid
|
||||
AND ($2::timestamptz IS NULL OR created_at > $2::timestamptz)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3::int;
|
||||
`
|
||||
|
||||
type DownloadRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewDownloadRepository(pool *pgxpool.Pool) *DownloadRepository {
|
||||
return &DownloadRepository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *DownloadRepository) CreateJob(
|
||||
ctx context.Context,
|
||||
userID uuid.UUID,
|
||||
input download.CreateInput,
|
||||
) (download.Job, error) {
|
||||
var job download.Job
|
||||
err := r.pool.QueryRow(
|
||||
ctx,
|
||||
createDownloadJobSQL,
|
||||
userID,
|
||||
input.SourceType,
|
||||
input.Source,
|
||||
input.Title,
|
||||
).Scan(
|
||||
&job.ID,
|
||||
&job.UserID,
|
||||
&job.SourceType,
|
||||
&job.Source,
|
||||
&job.Title,
|
||||
&job.Status,
|
||||
&job.QueuePosition,
|
||||
&job.ProgressPercent,
|
||||
&job.BytesTotal,
|
||||
&job.BytesDownloaded,
|
||||
&job.DownloadSpeedMbps,
|
||||
&job.EtaSeconds,
|
||||
&job.ErrorMessage,
|
||||
&job.RetryCount,
|
||||
&job.CreatedAt,
|
||||
&job.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return download.Job{}, err
|
||||
}
|
||||
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (r *DownloadRepository) ListJobs(
|
||||
ctx context.Context,
|
||||
userID uuid.UUID,
|
||||
params download.ListParams,
|
||||
) ([]download.Job, error) {
|
||||
rows, err := r.pool.Query(
|
||||
ctx,
|
||||
listDownloadJobsSQL,
|
||||
userID,
|
||||
params.Status,
|
||||
params.Limit,
|
||||
params.Offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
jobs := make([]download.Job, 0, params.Limit)
|
||||
for rows.Next() {
|
||||
var job download.Job
|
||||
if err := rows.Scan(
|
||||
&job.ID,
|
||||
&job.UserID,
|
||||
&job.SourceType,
|
||||
&job.Source,
|
||||
&job.Title,
|
||||
&job.Status,
|
||||
&job.QueuePosition,
|
||||
&job.ProgressPercent,
|
||||
&job.BytesTotal,
|
||||
&job.BytesDownloaded,
|
||||
&job.DownloadSpeedMbps,
|
||||
&job.EtaSeconds,
|
||||
&job.ErrorMessage,
|
||||
&job.RetryCount,
|
||||
&job.CreatedAt,
|
||||
&job.UpdatedAt,
|
||||
&job.StartedAt,
|
||||
&job.CompletedAt,
|
||||
&job.CancelledAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
|
||||
return jobs, rows.Err()
|
||||
}
|
||||
|
||||
func (r *DownloadRepository) GetJobByID(
|
||||
ctx context.Context,
|
||||
userID uuid.UUID,
|
||||
jobID string,
|
||||
) (download.Job, error) {
|
||||
var job download.Job
|
||||
err := r.pool.QueryRow(ctx, getDownloadJobByIDSQL, userID, jobID).Scan(
|
||||
&job.ID,
|
||||
&job.UserID,
|
||||
&job.SourceType,
|
||||
&job.Source,
|
||||
&job.Title,
|
||||
&job.Status,
|
||||
&job.QueuePosition,
|
||||
&job.ProgressPercent,
|
||||
&job.BytesTotal,
|
||||
&job.BytesDownloaded,
|
||||
&job.DownloadSpeedMbps,
|
||||
&job.EtaSeconds,
|
||||
&job.ErrorMessage,
|
||||
&job.RetryCount,
|
||||
&job.CreatedAt,
|
||||
&job.UpdatedAt,
|
||||
&job.StartedAt,
|
||||
&job.CompletedAt,
|
||||
&job.CancelledAt,
|
||||
)
|
||||
if err != nil {
|
||||
return download.Job{}, download.ErrNotFound
|
||||
}
|
||||
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (r *DownloadRepository) CancelJob(
|
||||
ctx context.Context,
|
||||
userID uuid.UUID,
|
||||
jobID string,
|
||||
) (download.Job, error) {
|
||||
var job download.Job
|
||||
err := r.pool.QueryRow(ctx, cancelDownloadJobSQL, userID, jobID).Scan(
|
||||
&job.ID,
|
||||
&job.UserID,
|
||||
&job.SourceType,
|
||||
&job.Source,
|
||||
&job.Title,
|
||||
&job.Status,
|
||||
&job.QueuePosition,
|
||||
&job.ProgressPercent,
|
||||
&job.BytesTotal,
|
||||
&job.BytesDownloaded,
|
||||
&job.DownloadSpeedMbps,
|
||||
&job.EtaSeconds,
|
||||
&job.ErrorMessage,
|
||||
&job.RetryCount,
|
||||
&job.CreatedAt,
|
||||
&job.UpdatedAt,
|
||||
&job.StartedAt,
|
||||
&job.CompletedAt,
|
||||
&job.CancelledAt,
|
||||
)
|
||||
if err != nil {
|
||||
return download.Job{}, download.ErrNotFound
|
||||
}
|
||||
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (r *DownloadRepository) ListEvents(
|
||||
ctx context.Context,
|
||||
userID uuid.UUID,
|
||||
jobID string,
|
||||
params download.EventParams,
|
||||
) ([]download.Event, error) {
|
||||
var after pgtype.Timestamptz
|
||||
if params.After != "" {
|
||||
t, err := time.Parse(time.RFC3339, params.After)
|
||||
if err == nil {
|
||||
after = pgtype.Timestamptz{
|
||||
Time: t,
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx, listDownloadEventsSQL, jobID, after, params.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
events := make([]download.Event, 0, params.Limit)
|
||||
for rows.Next() {
|
||||
var event download.Event
|
||||
if err := rows.Scan(
|
||||
&event.ID,
|
||||
&event.JobID,
|
||||
&event.Status,
|
||||
&event.Message,
|
||||
&event.ProgressPercent,
|
||||
&event.Payload,
|
||||
&event.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
events = append(events, event)
|
||||
}
|
||||
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func (r *DownloadRepository) AppendEvent(
|
||||
ctx context.Context,
|
||||
jobID string,
|
||||
event download.Event,
|
||||
) error {
|
||||
_, err := r.pool.Exec(
|
||||
ctx,
|
||||
appendDownloadEventSQL,
|
||||
jobID,
|
||||
event.Status,
|
||||
event.Message,
|
||||
event.ProgressPercent,
|
||||
event.Payload,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *DownloadRepository) UpdateJob(
|
||||
ctx context.Context,
|
||||
userID uuid.UUID,
|
||||
jobID string,
|
||||
input download.UpdateInput,
|
||||
) (download.Job, error) {
|
||||
var job download.Job
|
||||
err := r.pool.QueryRow(
|
||||
ctx,
|
||||
updateDownloadJobSQL,
|
||||
userID,
|
||||
jobID,
|
||||
input.Status,
|
||||
input.ProgressPercent,
|
||||
input.BytesTotal,
|
||||
input.BytesDownloaded,
|
||||
input.DownloadSpeedMbps,
|
||||
input.EtaSeconds,
|
||||
input.ErrorMessage,
|
||||
input.RetryCount,
|
||||
).Scan(
|
||||
&job.ID,
|
||||
&job.UserID,
|
||||
&job.SourceType,
|
||||
&job.Source,
|
||||
&job.Title,
|
||||
&job.Status,
|
||||
&job.QueuePosition,
|
||||
&job.ProgressPercent,
|
||||
&job.BytesTotal,
|
||||
&job.BytesDownloaded,
|
||||
&job.DownloadSpeedMbps,
|
||||
&job.EtaSeconds,
|
||||
&job.ErrorMessage,
|
||||
&job.RetryCount,
|
||||
&job.CreatedAt,
|
||||
&job.UpdatedAt,
|
||||
&job.StartedAt,
|
||||
&job.CompletedAt,
|
||||
&job.CancelledAt,
|
||||
)
|
||||
if err != nil {
|
||||
return download.Job{}, download.ErrNotFound
|
||||
}
|
||||
|
||||
return job, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/tdvorak/seen/backend/internal/config"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func NewPool(ctx context.Context, cfg config.PostgresConfig, log *zap.Logger) (*pgxpool.Pool, error) {
|
||||
poolConfig, err := pgxpool.ParseConfig(cfg.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse postgres url: %w", err)
|
||||
}
|
||||
|
||||
poolConfig.MaxConns = cfg.MaxConns
|
||||
poolConfig.MinConns = cfg.MinConns
|
||||
poolConfig.MaxConnIdleTime = 5 * time.Minute
|
||||
poolConfig.HealthCheckPeriod = 30 * time.Second
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect postgres: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping postgres: %w", err)
|
||||
}
|
||||
|
||||
log.Info("postgres connected", zap.Int32("max_conns", cfg.MaxConns), zap.Int32("min_conns", cfg.MinConns))
|
||||
|
||||
return pool, nil
|
||||
}
|
||||
Reference in New Issue
Block a user