From c5901d986cb62b1801db7d9635eb3c67961e6766 Mon Sep 17 00:00:00 2001 From: vincent Date: Mon, 29 Dec 2025 14:11:06 +0100 Subject: [PATCH 01/33] feat(generator): add db models --- .../20251228183248_add_generator_table.sql | 18 +++++++ db/queries/generator.sql | 4 ++ internal/database/model/generator.go | 37 ++++++++++++++ internal/generator/generate.go | 19 +++++++ internal/generator/generator.go | 27 ++++++++++ pkg/sqlc/generator.sql.go | 29 +++++++++++ pkg/sqlc/models.go | 51 +++++++++++++++++++ 7 files changed, 185 insertions(+) create mode 100644 db/migrations/20251228183248_add_generator_table.sql create mode 100644 db/queries/generator.sql create mode 100644 internal/database/model/generator.go create mode 100644 internal/generator/generate.go create mode 100644 internal/generator/generator.go create mode 100644 pkg/sqlc/generator.sql.go diff --git a/db/migrations/20251228183248_add_generator_table.sql b/db/migrations/20251228183248_add_generator_table.sql new file mode 100644 index 0000000..17cd017 --- /dev/null +++ b/db/migrations/20251228183248_add_generator_table.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TYPE generator_preset AS ENUM ('custom', 'forgotten', 'top', 'old_top'); + +CREATE TABLE generators ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + preset GENERATOR_PRESET NOT NULL, + parameters JSONB +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE generators; + +DROP TYPE generator_preset; +-- +goose StatementEnd diff --git a/db/queries/generator.sql b/db/queries/generator.sql new file mode 100644 index 0000000..751f5c6 --- /dev/null +++ b/db/queries/generator.sql @@ -0,0 +1,4 @@ +-- name: GeneratorCreate :one +INSERT INTO generators (name, preset, parameters) +VALUES ($1, $2, $3) +RETURNING id; diff --git a/internal/database/model/generator.go b/internal/database/model/generator.go new file mode 100644 index 0000000..d6ac3c7 --- /dev/null +++ b/internal/database/model/generator.go @@ -0,0 +1,37 @@ +package model + +import ( + "encoding/json" + + "github.com/topvennie/sortifyr/pkg/sqlc" +) + +type GeneratorPreset string + +const ( + PresetCustom GeneratorPreset = "custom" + PresetForgotten GeneratorPreset = "forgotten" + PresetTop GeneratorPreset = "top" + PresetOldTop GeneratorPreset = "old_top" +) + +type GeneratorParameters struct{} + +type Generator struct { + ID int + Name string + Preset GeneratorPreset + Parameters GeneratorParameters +} + +func GeneratorModel(g sqlc.Generator) *Generator { + parameters := GeneratorParameters{} + _ = json.Unmarshal(g.Parameters, ¶meters) + + return &Generator{ + ID: int(g.ID), + Name: g.Name, + Preset: GeneratorPreset(g.Preset), + Parameters: parameters, + } +} diff --git a/internal/generator/generate.go b/internal/generator/generate.go new file mode 100644 index 0000000..7bb53d4 --- /dev/null +++ b/internal/generator/generate.go @@ -0,0 +1,19 @@ +package generator + +import "github.com/topvennie/sortifyr/internal/database/model" + +func custom(params model.GeneratorParameters) ([]model.Track, error) { + return nil, nil +} + +func forgotten(params model.GeneratorParameters) ([]model.Track, error) { + return nil, nil +} + +func top(params model.GeneratorParameters) ([]model.Track, error) { + return nil, nil +} + +func oldTop(params model.GeneratorParameters) ([]model.Track, error) { + return nil, nil +} diff --git a/internal/generator/generator.go b/internal/generator/generator.go new file mode 100644 index 0000000..b9c0576 --- /dev/null +++ b/internal/generator/generator.go @@ -0,0 +1,27 @@ +// Package generator creates and maintaines playlists based on parameters and presets +package generator + +import "github.com/topvennie/sortifyr/internal/database/model" + +func Generate(preset model.GeneratorPreset, params model.GeneratorParameters) ([]model.Track, error) { + var tracks []model.Track + var err error + + switch preset { + case model.PresetForgotten: + tracks, err = forgotten(params) + case model.PresetTop: + tracks, err = top(params) + case model.PresetOldTop: + tracks, err = oldTop(params) + + default: + tracks, err = custom(params) + } + + if err != nil { + return nil, err + } + + return tracks, nil +} diff --git a/pkg/sqlc/generator.sql.go b/pkg/sqlc/generator.sql.go new file mode 100644 index 0000000..a08b4df --- /dev/null +++ b/pkg/sqlc/generator.sql.go @@ -0,0 +1,29 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: generator.sql + +package sqlc + +import ( + "context" +) + +const generatorCreate = `-- name: GeneratorCreate :one +INSERT INTO generators (name, preset, parameters) +VALUES ($1, $2, $3) +RETURNING id +` + +type GeneratorCreateParams struct { + Name string + Preset GeneratorPreset + Parameters []byte +} + +func (q *Queries) GeneratorCreate(ctx context.Context, arg GeneratorCreateParams) (int32, error) { + row := q.db.QueryRow(ctx, generatorCreate, arg.Name, arg.Preset, arg.Parameters) + var id int32 + err := row.Scan(&id) + return id, err +} diff --git a/pkg/sqlc/models.go b/pkg/sqlc/models.go index 888fac9..abc2734 100644 --- a/pkg/sqlc/models.go +++ b/pkg/sqlc/models.go @@ -11,6 +11,50 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type GeneratorPreset string + +const ( + GeneratorPresetCustom GeneratorPreset = "custom" + GeneratorPresetForgotten GeneratorPreset = "forgotten" + GeneratorPresetTop GeneratorPreset = "top" + GeneratorPresetOldTop GeneratorPreset = "old_top" +) + +func (e *GeneratorPreset) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = GeneratorPreset(s) + case string: + *e = GeneratorPreset(s) + default: + return fmt.Errorf("unsupported scan type for GeneratorPreset: %T", src) + } + return nil +} + +type NullGeneratorPreset struct { + GeneratorPreset GeneratorPreset + Valid bool // Valid is true if GeneratorPreset is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullGeneratorPreset) Scan(value interface{}) error { + if value == nil { + ns.GeneratorPreset, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.GeneratorPreset.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullGeneratorPreset) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.GeneratorPreset), nil +} + type TaskResult string const ( @@ -101,6 +145,13 @@ type DirectoryPlaylist struct { PlaylistID int32 } +type Generator struct { + ID int32 + Name string + Preset GeneratorPreset + Parameters []byte +} + type History struct { ID int32 UserID int32 From b57fcc23e21ad069a384425325d9fc665f3dcf55 Mon Sep 17 00:00:00 2001 From: vincent Date: Tue, 30 Dec 2025 17:11:12 +0100 Subject: [PATCH 02/33] chore(history): remove 20 seconds required playtime --- README.md | 6 +- ...251230104421_alter_history_add_skipped.sql | 20 ++++ ...=> 20251231183248_add_generator_table.sql} | 0 db/queries/history.sql | 23 ++-- db/queries/track.sql | 6 +- internal/database/model/history.go | 38 ++---- internal/database/model/track.go | 4 +- internal/database/repository/history.go | 39 ++++-- internal/database/repository/track.go | 2 + internal/spotify/api/model.go | 2 + internal/spotify/export.go | 2 + internal/spotify/history.go | 31 ++--- pkg/sqlc/history.sql.go | 112 ++++++++++++------ pkg/sqlc/models.go | 2 + pkg/sqlc/playlist.sql.go | 6 +- pkg/sqlc/track.sql.go | 42 +++++-- 16 files changed, 211 insertions(+), 124 deletions(-) create mode 100644 db/migrations/20251230104421_alter_history_add_skipped.sql rename db/migrations/{20251228183248_add_generator_table.sql => 20251231183248_add_generator_table.sql} (100%) diff --git a/README.md b/README.md index e78b854..a0534ba 100644 --- a/README.md +++ b/README.md @@ -45,10 +45,10 @@ A web application to help organize and automate your Spotify playlists. An overview of your history. It will show any track that you listened to or was played in a jam that you were a part of. - A track is only counted as played after you listened to it for at least 20 seconds, however - due to the internal workings of the application it can be up to 40 seconds before a track is considered played. + Sortifyr will track all the data is needs in the background however it can not access any past data. + In the settings page you can find instructions how you can upload all your historic Spotify data. - In the settings page you can upload all your historic Spotify data. + It's highly recommended to do so. ![Track history](./screenshots/track_history.png) diff --git a/db/migrations/20251230104421_alter_history_add_skipped.sql b/db/migrations/20251230104421_alter_history_add_skipped.sql new file mode 100644 index 0000000..98875bf --- /dev/null +++ b/db/migrations/20251230104421_alter_history_add_skipped.sql @@ -0,0 +1,20 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE history +ADD COLUMN skipped BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE history +ALTER COLUMN skipped DROP DEFAULT; + +ALTER TABLE tracks +ADD COLUMN duration_ms INTEGER; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE tracks +DROP COLUMN duration_ms; + +ALTER TABLE history +DROP COLUMN skipped; +-- +goose StatementEnd diff --git a/db/migrations/20251228183248_add_generator_table.sql b/db/migrations/20251231183248_add_generator_table.sql similarity index 100% rename from db/migrations/20251228183248_add_generator_table.sql rename to db/migrations/20251231183248_add_generator_table.sql diff --git a/db/queries/history.sql b/db/queries/history.sql index f431f6c..4f4b2c7 100644 --- a/db/queries/history.sql +++ b/db/queries/history.sql @@ -1,8 +1,9 @@ --- name: HistoryGetLatestByUser :one -SELECT * -FROM history -WHERE user_id = $1 -ORDER BY played_at DESC +-- name: HistoryGetPreviousPopulated :one +SELECT sqlc.embed(h), sqlc.embed(t) +FROM history h +LEFT JOIN tracks t ON t.id = h.track_id +WHERE h.played_at < $1 AND h.user_id = $2 +ORDER BY h.played_at DESC LIMIT 1; -- name: HistoryGetPopulatedFiltered :many @@ -12,21 +13,23 @@ LEFT JOIN tracks t ON t.id = h.track_id WHERE h.user_id = $1::int AND (h.played_at >= $4::timestamptz OR NOT @filter_start) AND - (h.played_at <= $5::timestamptz OR NOT @filter_end) + (h.played_at <= $5::timestamptz OR NOT @filter_end) AND + (h.skipped = $6 OR NOT @filter_skipped) ORDER BY h.played_at DESC LIMIT $2 OFFSET $3; -- name: HistoryCreate :one -INSERT INTO history (user_id, track_id, played_at, album_id, artist_id, playlist_id, show_id) -VALUES ($1, $2, $3, $4, $5, $6, $7) +INSERT INTO history (user_id, track_id, played_at, album_id, artist_id, playlist_id, show_id, skipped) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id; -- name: HistoryCreateBatch :exec -INSERT INTO history (user_id, track_id, played_at) +INSERT INTO history (user_id, track_id, played_at, skipped) VALUES ( UNNEST($1::int[]), UNNEST($2::int[]), - UNNEST($3::timestamptz[]) + UNNEST($3::timestamptz[]), + UNNEST($4::boolean[]) ); -- name: HistoryDeleteUserOlder :exec diff --git a/db/queries/track.sql b/db/queries/track.sql index 219af2d..11ed6f2 100644 --- a/db/queries/track.sql +++ b/db/queries/track.sql @@ -51,10 +51,9 @@ WHERE ORDER BY pt.deleted_at DESC LIMIT $1 OFFSET $2; - -- name: TrackCreate :one -INSERT INTO tracks (spotify_id, name, popularity) -VALUES ($1, $2, $3) +INSERT INTO tracks (spotify_id, name, popularity, duration_ms) +VALUES ($1, $2, $3, $4) RETURNING id; -- name: TrackUpdate :exec @@ -62,5 +61,6 @@ UPDATE tracks SET name = coalesce(sqlc.narg('name'), name), popularity = coalesce(sqlc.narg('popularity'), popularity), + duration_ms = coalesce(sqlc.narg('duration_ms'), duration_ms), updated_at = NOW() WHERE id = $1; diff --git a/internal/database/model/history.go b/internal/database/model/history.go index 32d8bac..812c88b 100644 --- a/internal/database/model/history.go +++ b/internal/database/model/history.go @@ -11,6 +11,7 @@ type History struct { UserID int TrackID int PlayedAt time.Time + Skipped bool AlbumID int ArtistID int PlaylistID int @@ -21,39 +22,24 @@ type History struct { } func HistoryModel(h sqlc.History) *History { - albumID := 0 - if h.AlbumID.Valid { - albumID = int(h.AlbumID.Int32) - } - artistID := 0 - if h.ArtistID.Valid { - artistID = int(h.ArtistID.Int32) - } - playlistID := 0 - if h.PlaylistID.Valid { - playlistID = int(h.PlaylistID.Int32) - } - showID := 0 - if h.ShowID.Valid { - showID = int(h.ShowID.Int32) - } - return &History{ UserID: int(h.UserID), ID: int(h.ID), TrackID: int(h.TrackID), PlayedAt: h.PlayedAt.Time, - AlbumID: albumID, - ArtistID: artistID, - PlaylistID: playlistID, - ShowID: showID, + Skipped: h.Skipped, + AlbumID: fromInt(h.AlbumID), + ArtistID: fromInt(h.ArtistID), + PlaylistID: fromInt(h.PlaylistID), + ShowID: fromInt(h.ShowID), } } type HistoryFilter struct { - UserID int - Limit int - Offset int - Start time.Time - End time.Time + UserID int + Limit int + Offset int + Start time.Time + End time.Time + Skipped *bool } diff --git a/internal/database/model/track.go b/internal/database/model/track.go index 691021e..4aea4b2 100644 --- a/internal/database/model/track.go +++ b/internal/database/model/track.go @@ -11,6 +11,7 @@ type Track struct { SpotifyID string Name string Popularity int + DurationMs int UpdatedAt time.Time // Non db fields @@ -26,6 +27,7 @@ func TrackModel(t sqlc.Track) *Track { SpotifyID: t.SpotifyID, Name: fromString(t.Name), Popularity: fromInt(t.Popularity), + DurationMs: fromInt(t.DurationMs), UpdatedAt: fromTime(t.UpdatedAt), } } @@ -35,7 +37,7 @@ func (t *Track) Equal(t2 Track) bool { } func (t *Track) EqualEntry(t2 Track) bool { - return t.Name == t2.Name && t.Popularity == t2.Popularity + return t.Name == t2.Name && t.Popularity == t2.Popularity && t.DurationMs == t2.DurationMs } type TrackArtist struct { diff --git a/internal/database/repository/history.go b/internal/database/repository/history.go index c5a40ba..5cb61f0 100644 --- a/internal/database/repository/history.go +++ b/internal/database/repository/history.go @@ -23,27 +23,40 @@ func (r *Repository) NewHistory() *History { } } -func (h *History) GetLatest(ctx context.Context, userID int) (*model.History, error) { - history, err := h.repo.queries(ctx).HistoryGetLatestByUser(ctx, int32(userID)) +func (h *History) GetPreviousPopulated(ctx context.Context, userID int, playedAt time.Time) (*model.History, error) { + previous, err := h.repo.queries(ctx).HistoryGetPreviousPopulated(ctx, sqlc.HistoryGetPreviousPopulatedParams{ + PlayedAt: toTime(playedAt), + UserID: int32(userID), + }) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } - return nil, fmt.Errorf("get latest history %w", err) + return nil, fmt.Errorf("get previous populated %s | %d | %w", playedAt, userID, err) } - return model.HistoryModel(history), nil + previousModel := model.HistoryModel(previous.History) + previousModel.Track = *model.TrackModel(previous.Track) + + return previousModel, nil } func (h *History) GetPopulatedFiltered(ctx context.Context, filter model.HistoryFilter) ([]*model.History, error) { + skipped := false + if filter.Skipped != nil { + skipped = *filter.Skipped + } + history, err := h.repo.queries(ctx).HistoryGetPopulatedFiltered(ctx, sqlc.HistoryGetPopulatedFilteredParams{ - Column1: int32(filter.UserID), - Limit: int32(filter.Limit), - Offset: int32(filter.Offset), - Column4: toTime(filter.Start), - FilterStart: !filter.Start.IsZero(), - Column5: toTime(filter.End), - FilterEnd: !filter.End.IsZero(), + Column1: int32(filter.UserID), + Limit: int32(filter.Limit), + Offset: int32(filter.Offset), + Column4: toTime(filter.Start), + FilterStart: !filter.Start.IsZero(), + Column5: toTime(filter.End), + FilterEnd: !filter.End.IsZero(), + Skipped: skipped, + FilterSkipped: filter.Skipped != nil, }) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -65,6 +78,7 @@ func (h *History) Create(ctx context.Context, history *model.History) error { UserID: int32(history.UserID), TrackID: int32(history.TrackID), PlayedAt: toTime(history.PlayedAt), + Skipped: history.Skipped, AlbumID: toInt(history.AlbumID), ArtistID: toInt(history.ArtistID), PlaylistID: toInt(history.PlaylistID), @@ -83,17 +97,20 @@ func (h *History) CreateBatch(ctx context.Context, histories []model.History) er userIDs := make([]int32, 0, len(histories)) trackIDs := make([]int32, 0, len(histories)) playedAts := make([]pgtype.Timestamptz, 0, len(histories)) + skipped := make([]bool, 0, len(histories)) for i := range histories { userIDs = append(userIDs, int32(histories[i].UserID)) trackIDs = append(trackIDs, int32(histories[i].TrackID)) playedAts = append(playedAts, toTime(histories[i].PlayedAt)) + skipped = append(skipped, histories[i].Skipped) } if err := h.repo.queries(ctx).HistoryCreateBatch(ctx, sqlc.HistoryCreateBatchParams{ Column1: userIDs, Column2: trackIDs, Column3: playedAts, + Column4: skipped, }); err != nil { return fmt.Errorf("create history batch %w", err) } diff --git a/internal/database/repository/track.go b/internal/database/repository/track.go index 49816ee..fce17c2 100644 --- a/internal/database/repository/track.go +++ b/internal/database/repository/track.go @@ -140,6 +140,7 @@ func (t *Track) Create(ctx context.Context, track *model.Track) error { SpotifyID: track.SpotifyID, Name: toString(track.Name), Popularity: toInt(track.Popularity), + DurationMs: toInt(track.DurationMs), }) if err != nil { return fmt.Errorf("create track %+v | %w", *track, err) @@ -169,6 +170,7 @@ func (t *Track) Update(ctx context.Context, track model.Track) error { ID: int32(track.ID), Name: toString(track.Name), Popularity: toInt(track.Popularity), + DurationMs: toInt(track.DurationMs), }); err != nil { return fmt.Errorf("update track %+v | %w", track, err) } diff --git a/internal/spotify/api/model.go b/internal/spotify/api/model.go index bb30970..ae4dc4f 100644 --- a/internal/spotify/api/model.go +++ b/internal/spotify/api/model.go @@ -168,6 +168,7 @@ type Track struct { Name string `json:"name"` Popularity int `json:"popularity"` Artists []Artist `json:"artists"` + DurationMs int `json:"duration_ms"` LinkedFrom struct { SpotifyID string `json:"id"` } `json:"linked_from"` @@ -183,6 +184,7 @@ func (t *Track) ToModel() model.Track { SpotifyID: spotifyID, Name: t.Name, Popularity: t.Popularity, + DurationMs: t.DurationMs, Artists: utils.SliceMap(t.Artists, func(a Artist) model.Artist { return a.ToModel() }), } } diff --git a/internal/spotify/export.go b/internal/spotify/export.go index 0608468..088c553 100644 --- a/internal/spotify/export.go +++ b/internal/spotify/export.go @@ -26,12 +26,14 @@ type exportTrack struct { Username string `json:"username"` MsPlayed int `json:"ms_played"` SpotifyTrackURI string `json:"spotify_track_uri"` + Skipped bool `json:"skipped"` } func (e exportTrack) toHistory(userID int) *model.History { return &model.History{ UserID: userID, PlayedAt: e.StoppedAt.Add(time.Duration(-1*e.MsPlayed) * time.Millisecond), + Skipped: e.Skipped, Track: model.Track{ SpotifyID: uriToID(e.SpotifyTrackURI), }, diff --git a/internal/spotify/history.go b/internal/spotify/history.go index f4ba6e1..6fb7b4e 100644 --- a/internal/spotify/history.go +++ b/internal/spotify/history.go @@ -8,14 +8,6 @@ import ( ) func (c *client) historySync(ctx context.Context, user model.User) error { - latest, err := c.history.GetLatest(ctx, user.ID) - if err != nil { - return err - } - if latest == nil { - latest = &model.History{} - } - current, err := c.api.PlayerGetCurrent(ctx, user) if err != nil { return err @@ -25,19 +17,22 @@ func (c *client) historySync(ctx context.Context, user model.User) error { } now := time.Now() + currentStart := now.Add(time.Duration(-current.ProgressMs) * time.Millisecond) - if !current.IsPlaying { - return nil + previous, err := c.history.GetPreviousPopulated(ctx, user.ID, now) + if err != nil { + return err } - - // Listen at least 20 seconds - if current.ProgressMs < 20*1000 { - return nil + if previous == nil { + previous = &model.History{} } - // Add a 5 second buffer - if latest.PlayedAt.Add(5 * time.Second).After(now.Add(time.Duration(-current.ProgressMs) * time.Millisecond)) { - return nil + if previous.Track.SpotifyID == current.Track.SpotifyID { + // Same track + // Let's give it a 5 second buffer + if previous.PlayedAt.Add(5 * time.Second).After(currentStart) { + return nil + } } track := current.Track.ToModel() @@ -47,7 +42,7 @@ func (c *client) historySync(ctx context.Context, user model.User) error { history := model.History{ UserID: user.ID, - PlayedAt: now.Add(time.Duration(-current.ProgressMs) * time.Millisecond), + PlayedAt: currentStart, TrackID: track.ID, } diff --git a/pkg/sqlc/history.sql.go b/pkg/sqlc/history.sql.go index 45c1f79..8385124 100644 --- a/pkg/sqlc/history.sql.go +++ b/pkg/sqlc/history.sql.go @@ -12,8 +12,8 @@ import ( ) const historyCreate = `-- name: HistoryCreate :one -INSERT INTO history (user_id, track_id, played_at, album_id, artist_id, playlist_id, show_id) -VALUES ($1, $2, $3, $4, $5, $6, $7) +INSERT INTO history (user_id, track_id, played_at, album_id, artist_id, playlist_id, show_id, skipped) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id ` @@ -25,6 +25,7 @@ type HistoryCreateParams struct { ArtistID pgtype.Int4 PlaylistID pgtype.Int4 ShowID pgtype.Int4 + Skipped bool } func (q *Queries) HistoryCreate(ctx context.Context, arg HistoryCreateParams) (int32, error) { @@ -36,6 +37,7 @@ func (q *Queries) HistoryCreate(ctx context.Context, arg HistoryCreateParams) (i arg.ArtistID, arg.PlaylistID, arg.ShowID, + arg.Skipped, ) var id int32 err := row.Scan(&id) @@ -43,11 +45,12 @@ func (q *Queries) HistoryCreate(ctx context.Context, arg HistoryCreateParams) (i } const historyCreateBatch = `-- name: HistoryCreateBatch :exec -INSERT INTO history (user_id, track_id, played_at) +INSERT INTO history (user_id, track_id, played_at, skipped) VALUES ( UNNEST($1::int[]), UNNEST($2::int[]), - UNNEST($3::timestamptz[]) + UNNEST($3::timestamptz[]), + UNNEST($4::boolean[]) ) ` @@ -55,10 +58,16 @@ type HistoryCreateBatchParams struct { Column1 []int32 Column2 []int32 Column3 []pgtype.Timestamptz + Column4 []bool } func (q *Queries) HistoryCreateBatch(ctx context.Context, arg HistoryCreateBatchParams) error { - _, err := q.db.Exec(ctx, historyCreateBatch, arg.Column1, arg.Column2, arg.Column3) + _, err := q.db.Exec(ctx, historyCreateBatch, + arg.Column1, + arg.Column2, + arg.Column3, + arg.Column4, + ) return err } @@ -77,50 +86,29 @@ func (q *Queries) HistoryDeleteUserOlder(ctx context.Context, arg HistoryDeleteU return err } -const historyGetLatestByUser = `-- name: HistoryGetLatestByUser :one -SELECT id, user_id, track_id, played_at, album_id, artist_id, playlist_id, show_id -FROM history -WHERE user_id = $1 -ORDER BY played_at DESC -LIMIT 1 -` - -func (q *Queries) HistoryGetLatestByUser(ctx context.Context, userID int32) (History, error) { - row := q.db.QueryRow(ctx, historyGetLatestByUser, userID) - var i History - err := row.Scan( - &i.ID, - &i.UserID, - &i.TrackID, - &i.PlayedAt, - &i.AlbumID, - &i.ArtistID, - &i.PlaylistID, - &i.ShowID, - ) - return i, err -} - const historyGetPopulatedFiltered = `-- name: HistoryGetPopulatedFiltered :many -SELECT h.id, h.user_id, h.track_id, h.played_at, h.album_id, h.artist_id, h.playlist_id, h.show_id, t.id, t.spotify_id, t.name, t.popularity, t.updated_at +SELECT h.id, h.user_id, h.track_id, h.played_at, h.album_id, h.artist_id, h.playlist_id, h.show_id, h.skipped, t.id, t.spotify_id, t.name, t.popularity, t.updated_at, t.duration_ms FROM history h LEFT JOIN tracks t ON t.id = h.track_id WHERE h.user_id = $1::int AND - (h.played_at >= $4::timestamptz OR NOT $6) AND - (h.played_at <= $5::timestamptz OR NOT $7) + (h.played_at >= $4::timestamptz OR NOT $7) AND + (h.played_at <= $5::timestamptz OR NOT $8) AND + (h.skipped = $6 OR NOT $9) ORDER BY h.played_at DESC LIMIT $2 OFFSET $3 ` type HistoryGetPopulatedFilteredParams struct { - Column1 int32 - Limit int32 - Offset int32 - Column4 pgtype.Timestamptz - Column5 pgtype.Timestamptz - FilterStart interface{} - FilterEnd interface{} + Column1 int32 + Limit int32 + Offset int32 + Column4 pgtype.Timestamptz + Column5 pgtype.Timestamptz + Skipped bool + FilterStart interface{} + FilterEnd interface{} + FilterSkipped interface{} } type HistoryGetPopulatedFilteredRow struct { @@ -135,8 +123,10 @@ func (q *Queries) HistoryGetPopulatedFiltered(ctx context.Context, arg HistoryGe arg.Offset, arg.Column4, arg.Column5, + arg.Skipped, arg.FilterStart, arg.FilterEnd, + arg.FilterSkipped, ) if err != nil { return nil, err @@ -154,11 +144,13 @@ func (q *Queries) HistoryGetPopulatedFiltered(ctx context.Context, arg HistoryGe &i.History.ArtistID, &i.History.PlaylistID, &i.History.ShowID, + &i.History.Skipped, &i.Track.ID, &i.Track.SpotifyID, &i.Track.Name, &i.Track.Popularity, &i.Track.UpdatedAt, + &i.Track.DurationMs, ); err != nil { return nil, err } @@ -169,3 +161,45 @@ func (q *Queries) HistoryGetPopulatedFiltered(ctx context.Context, arg HistoryGe } return items, nil } + +const historyGetPreviousPopulated = `-- name: HistoryGetPreviousPopulated :one +SELECT h.id, h.user_id, h.track_id, h.played_at, h.album_id, h.artist_id, h.playlist_id, h.show_id, h.skipped, t.id, t.spotify_id, t.name, t.popularity, t.updated_at, t.duration_ms +FROM history h +LEFT JOIN tracks t ON t.id = h.track_id +WHERE h.played_at < $1 AND h.user_id = $2 +ORDER BY h.played_at DESC +LIMIT 1 +` + +type HistoryGetPreviousPopulatedParams struct { + PlayedAt pgtype.Timestamptz + UserID int32 +} + +type HistoryGetPreviousPopulatedRow struct { + History History + Track Track +} + +func (q *Queries) HistoryGetPreviousPopulated(ctx context.Context, arg HistoryGetPreviousPopulatedParams) (HistoryGetPreviousPopulatedRow, error) { + row := q.db.QueryRow(ctx, historyGetPreviousPopulated, arg.PlayedAt, arg.UserID) + var i HistoryGetPreviousPopulatedRow + err := row.Scan( + &i.History.ID, + &i.History.UserID, + &i.History.TrackID, + &i.History.PlayedAt, + &i.History.AlbumID, + &i.History.ArtistID, + &i.History.PlaylistID, + &i.History.ShowID, + &i.History.Skipped, + &i.Track.ID, + &i.Track.SpotifyID, + &i.Track.Name, + &i.Track.Popularity, + &i.Track.UpdatedAt, + &i.Track.DurationMs, + ) + return i, err +} diff --git a/pkg/sqlc/models.go b/pkg/sqlc/models.go index abc2734..eaf1d31 100644 --- a/pkg/sqlc/models.go +++ b/pkg/sqlc/models.go @@ -161,6 +161,7 @@ type History struct { ArtistID pgtype.Int4 PlaylistID pgtype.Int4 ShowID pgtype.Int4 + Skipped bool } type Link struct { @@ -242,6 +243,7 @@ type Track struct { Name pgtype.Text Popularity pgtype.Int4 UpdatedAt pgtype.Timestamptz + DurationMs pgtype.Int4 } type TrackArtist struct { diff --git a/pkg/sqlc/playlist.sql.go b/pkg/sqlc/playlist.sql.go index 67a87c5..7e2b2de 100644 --- a/pkg/sqlc/playlist.sql.go +++ b/pkg/sqlc/playlist.sql.go @@ -194,7 +194,7 @@ func (q *Queries) PlaylistGetByUserWithOwner(ctx context.Context, userID int32) } const playlistGetDuplicateTracksByUser = `-- name: PlaylistGetDuplicateTracksByUser :many -SELECT p.id, p.spotify_id, p.name, p.description, p.public, p.track_amount, p.collaborative, p.cover_id, p.cover_url, p.owner_id, p.updated_at, p.snapshot_id, t.id, t.spotify_id, t.name, t.popularity, t.updated_at, u.id, u.uid, u.name, u.display_name, u.email +SELECT p.id, p.spotify_id, p.name, p.description, p.public, p.track_amount, p.collaborative, p.cover_id, p.cover_url, p.owner_id, p.updated_at, p.snapshot_id, t.id, t.spotify_id, t.name, t.popularity, t.updated_at, t.duration_ms, u.id, u.uid, u.name, u.display_name, u.email FROM playlist_tracks pt JOIN ( SELECT playlist_id, track_id @@ -246,6 +246,7 @@ func (q *Queries) PlaylistGetDuplicateTracksByUser(ctx context.Context, userID i &i.Track.Name, &i.Track.Popularity, &i.Track.UpdatedAt, + &i.Track.DurationMs, &i.User.ID, &i.User.Uid, &i.User.Name, @@ -263,7 +264,7 @@ func (q *Queries) PlaylistGetDuplicateTracksByUser(ctx context.Context, userID i } const playlistGetUnplayableTracksByUser = `-- name: PlaylistGetUnplayableTracksByUser :many -SELECT p.id, p.spotify_id, p.name, p.description, p.public, p.track_amount, p.collaborative, p.cover_id, p.cover_url, p.owner_id, p.updated_at, p.snapshot_id, t.id, t.spotify_id, t.name, t.popularity, t.updated_at, u.id, u.uid, u.name, u.display_name, u.email +SELECT p.id, p.spotify_id, p.name, p.description, p.public, p.track_amount, p.collaborative, p.cover_id, p.cover_url, p.owner_id, p.updated_at, p.snapshot_id, t.id, t.spotify_id, t.name, t.popularity, t.updated_at, t.duration_ms, u.id, u.uid, u.name, u.display_name, u.email FROM playlist_tracks pt LEFT JOIN playlists p ON p.id = pt.playlist_id LEFT JOIN tracks t ON t.id = pt.track_id @@ -306,6 +307,7 @@ func (q *Queries) PlaylistGetUnplayableTracksByUser(ctx context.Context, userID &i.Track.Name, &i.Track.Popularity, &i.Track.UpdatedAt, + &i.Track.DurationMs, &i.User.ID, &i.User.Uid, &i.User.Name, diff --git a/pkg/sqlc/track.sql.go b/pkg/sqlc/track.sql.go index d41ae05..e742eb8 100644 --- a/pkg/sqlc/track.sql.go +++ b/pkg/sqlc/track.sql.go @@ -12,8 +12,8 @@ import ( ) const trackCreate = `-- name: TrackCreate :one -INSERT INTO tracks (spotify_id, name, popularity) -VALUES ($1, $2, $3) +INSERT INTO tracks (spotify_id, name, popularity, duration_ms) +VALUES ($1, $2, $3, $4) RETURNING id ` @@ -21,17 +21,23 @@ type TrackCreateParams struct { SpotifyID string Name pgtype.Text Popularity pgtype.Int4 + DurationMs pgtype.Int4 } func (q *Queries) TrackCreate(ctx context.Context, arg TrackCreateParams) (int32, error) { - row := q.db.QueryRow(ctx, trackCreate, arg.SpotifyID, arg.Name, arg.Popularity) + row := q.db.QueryRow(ctx, trackCreate, + arg.SpotifyID, + arg.Name, + arg.Popularity, + arg.DurationMs, + ) var id int32 err := row.Scan(&id) return id, err } const trackGetAll = `-- name: TrackGetAll :many -SELECT id, spotify_id, name, popularity, updated_at +SELECT id, spotify_id, name, popularity, updated_at, duration_ms FROM tracks ` @@ -50,6 +56,7 @@ func (q *Queries) TrackGetAll(ctx context.Context) ([]Track, error) { &i.Name, &i.Popularity, &i.UpdatedAt, + &i.DurationMs, ); err != nil { return nil, err } @@ -62,7 +69,7 @@ func (q *Queries) TrackGetAll(ctx context.Context) ([]Track, error) { } const trackGetAllBySpotify = `-- name: TrackGetAllBySpotify :many -SELECT id, spotify_id, name, popularity, updated_at +SELECT id, spotify_id, name, popularity, updated_at, duration_ms FROM tracks WHERE spotify_id = ANY($1::text[]) ` @@ -82,6 +89,7 @@ func (q *Queries) TrackGetAllBySpotify(ctx context.Context, dollar_1 []string) ( &i.Name, &i.Popularity, &i.UpdatedAt, + &i.DurationMs, ); err != nil { return nil, err } @@ -94,7 +102,7 @@ func (q *Queries) TrackGetAllBySpotify(ctx context.Context, dollar_1 []string) ( } const trackGetByName = `-- name: TrackGetByName :many -SELECT id, spotify_id, name, popularity, updated_at +SELECT id, spotify_id, name, popularity, updated_at, duration_ms FROM tracks WHERE name = $1 ` @@ -114,6 +122,7 @@ func (q *Queries) TrackGetByName(ctx context.Context, name pgtype.Text) ([]Track &i.Name, &i.Popularity, &i.UpdatedAt, + &i.DurationMs, ); err != nil { return nil, err } @@ -126,7 +135,7 @@ func (q *Queries) TrackGetByName(ctx context.Context, name pgtype.Text) ([]Track } const trackGetByPlaylist = `-- name: TrackGetByPlaylist :many -SELECT t.id, t.spotify_id, t.name, t.popularity, t.updated_at +SELECT t.id, t.spotify_id, t.name, t.popularity, t.updated_at, t.duration_ms FROM tracks t LEFT JOIN playlist_tracks pt ON pt.track_id = t.id WHERE pt.playlist_id = $1 AND pt.deleted_at IS NULL @@ -147,6 +156,7 @@ func (q *Queries) TrackGetByPlaylist(ctx context.Context, playlistID int32) ([]T &i.Name, &i.Popularity, &i.UpdatedAt, + &i.DurationMs, ); err != nil { return nil, err } @@ -159,7 +169,7 @@ func (q *Queries) TrackGetByPlaylist(ctx context.Context, playlistID int32) ([]T } const trackGetBySpotify = `-- name: TrackGetBySpotify :one -SELECT id, spotify_id, name, popularity, updated_at +SELECT id, spotify_id, name, popularity, updated_at, duration_ms FROM tracks WHERE spotify_id = $1 ` @@ -173,12 +183,13 @@ func (q *Queries) TrackGetBySpotify(ctx context.Context, spotifyID string) (Trac &i.Name, &i.Popularity, &i.UpdatedAt, + &i.DurationMs, ) return i, err } const trackGetCreatedFilteredPopulated = `-- name: TrackGetCreatedFilteredPopulated :many -SELECT t.id, t.spotify_id, t.name, t.popularity, t.updated_at, pt.id, pt.playlist_id, pt.track_id, pt.deleted_at, pt.created_at, p.id, p.spotify_id, p.name, p.description, p.public, p.track_amount, p.collaborative, p.cover_id, p.cover_url, p.owner_id, p.updated_at, p.snapshot_id, u.id, u.uid, u.name, u.display_name, u.email +SELECT t.id, t.spotify_id, t.name, t.popularity, t.updated_at, t.duration_ms, pt.id, pt.playlist_id, pt.track_id, pt.deleted_at, pt.created_at, p.id, p.spotify_id, p.name, p.description, p.public, p.track_amount, p.collaborative, p.cover_id, p.cover_url, p.owner_id, p.updated_at, p.snapshot_id, u.id, u.uid, u.name, u.display_name, u.email FROM tracks t LEFT JOIN playlist_tracks pt ON pt.track_id = t.id LEFT JOIN playlist_users pu ON pu.playlist_id = pt.playlist_id @@ -228,6 +239,7 @@ func (q *Queries) TrackGetCreatedFilteredPopulated(ctx context.Context, arg Trac &i.Track.Name, &i.Track.Popularity, &i.Track.UpdatedAt, + &i.Track.DurationMs, &i.PlaylistTrack.ID, &i.PlaylistTrack.PlaylistID, &i.PlaylistTrack.TrackID, @@ -262,7 +274,7 @@ func (q *Queries) TrackGetCreatedFilteredPopulated(ctx context.Context, arg Trac } const trackGetDeletedFilteredPopulated = `-- name: TrackGetDeletedFilteredPopulated :many -SELECT t.id, t.spotify_id, t.name, t.popularity, t.updated_at, pt.id, pt.playlist_id, pt.track_id, pt.deleted_at, pt.created_at, p.id, p.spotify_id, p.name, p.description, p.public, p.track_amount, p.collaborative, p.cover_id, p.cover_url, p.owner_id, p.updated_at, p.snapshot_id, u.id, u.uid, u.name, u.display_name, u.email +SELECT t.id, t.spotify_id, t.name, t.popularity, t.updated_at, t.duration_ms, pt.id, pt.playlist_id, pt.track_id, pt.deleted_at, pt.created_at, p.id, p.spotify_id, p.name, p.description, p.public, p.track_amount, p.collaborative, p.cover_id, p.cover_url, p.owner_id, p.updated_at, p.snapshot_id, u.id, u.uid, u.name, u.display_name, u.email FROM tracks t LEFT JOIN playlist_tracks pt ON pt.track_id = t.id LEFT JOIN playlist_users pu ON pu.playlist_id = pt.playlist_id @@ -312,6 +324,7 @@ func (q *Queries) TrackGetDeletedFilteredPopulated(ctx context.Context, arg Trac &i.Track.Name, &i.Track.Popularity, &i.Track.UpdatedAt, + &i.Track.DurationMs, &i.PlaylistTrack.ID, &i.PlaylistTrack.PlaylistID, &i.PlaylistTrack.TrackID, @@ -350,6 +363,7 @@ UPDATE tracks SET name = coalesce($2, name), popularity = coalesce($3, popularity), + duration_ms = coalesce($4, duration_ms), updated_at = NOW() WHERE id = $1 ` @@ -358,9 +372,15 @@ type TrackUpdateParams struct { ID int32 Name pgtype.Text Popularity pgtype.Int4 + DurationMs pgtype.Int4 } func (q *Queries) TrackUpdate(ctx context.Context, arg TrackUpdateParams) error { - _, err := q.db.Exec(ctx, trackUpdate, arg.ID, arg.Name, arg.Popularity) + _, err := q.db.Exec(ctx, trackUpdate, + arg.ID, + arg.Name, + arg.Popularity, + arg.DurationMs, + ) return err } From 0ed6c7b675d84f2c6fc337dd087a669964510d06 Mon Sep 17 00:00:00 2001 From: vincent Date: Tue, 30 Dec 2025 19:04:13 +0100 Subject: [PATCH 03/33] feat(history): calculate skipped value --- ...251230104421_alter_history_add_skipped.sql | 5 +- db/queries/history.sql | 16 +++- internal/database/model/history.go | 4 +- internal/database/model/playlist.go | 4 +- internal/database/model/util.go | 22 +++--- internal/database/repository/history.go | 41 ++++++++-- internal/database/repository/task.go | 4 +- internal/database/repository/util.go | 9 ++- internal/server/dto/playlist.go | 4 +- internal/spotify/api/model.go | 4 +- internal/spotify/export.go | 2 +- internal/spotify/history.go | 63 ++++++++++++++- internal/spotify/task.go | 43 +++++------ internal/task/manager.go | 1 + pkg/sqlc/history.sql.go | 76 ++++++++++++++++++- pkg/sqlc/models.go | 2 +- ui/src/components/playlist/PlaylistTable.tsx | 14 ++-- ui/src/lib/types/api.d.ts | 4 +- ui/src/lib/types/playlist.ts | 4 +- ui/src/pages/Settings.tsx | 1 + 20 files changed, 247 insertions(+), 76 deletions(-) diff --git a/db/migrations/20251230104421_alter_history_add_skipped.sql b/db/migrations/20251230104421_alter_history_add_skipped.sql index 98875bf..700d079 100644 --- a/db/migrations/20251230104421_alter_history_add_skipped.sql +++ b/db/migrations/20251230104421_alter_history_add_skipped.sql @@ -1,10 +1,7 @@ -- +goose Up -- +goose StatementBegin ALTER TABLE history -ADD COLUMN skipped BOOLEAN NOT NULL DEFAULT false; - -ALTER TABLE history -ALTER COLUMN skipped DROP DEFAULT; +ADD COLUMN skipped BOOLEAN; ALTER TABLE tracks ADD COLUMN duration_ms INTEGER; diff --git a/db/queries/history.sql b/db/queries/history.sql index 4f4b2c7..f688782 100644 --- a/db/queries/history.sql +++ b/db/queries/history.sql @@ -14,9 +14,16 @@ WHERE h.user_id = $1::int AND (h.played_at >= $4::timestamptz OR NOT @filter_start) AND (h.played_at <= $5::timestamptz OR NOT @filter_end) AND - (h.skipped = $6 OR NOT @filter_skipped) + (h.skipped = $6::boolean OR NOT @filter_skipped) ORDER BY h.played_at DESC LIMIT $2 OFFSET $3; + +-- name: HistoryGetSkippedUnknownPopulated :many +SELECT sqlc.embed(h), sqlc.embed(t) +FROM history h +LEFT JOIN tracks t ON t.id = h.track_id +WHERE h.skipped IS NULL AND h.user_id = $1 +ORDER BY played_at ASC; -- name: HistoryCreate :one INSERT INTO history (user_id, track_id, played_at, album_id, artist_id, playlist_id, show_id, skipped) @@ -32,6 +39,13 @@ VALUES ( UNNEST($4::boolean[]) ); +-- name: HistoryUpdate :exec +UPDATE history +SET + played_at = coalesce(sqlc.narg('played_at'), played_at), + skipped = coalesce(sqlc.narg('skipped'), skipped) +WHERE id = $1; + -- name: HistoryDeleteUserOlder :exec DELETE FROM history WHERE user_id = $1 AND played_at < $2; diff --git a/internal/database/model/history.go b/internal/database/model/history.go index 812c88b..1072c97 100644 --- a/internal/database/model/history.go +++ b/internal/database/model/history.go @@ -11,7 +11,7 @@ type History struct { UserID int TrackID int PlayedAt time.Time - Skipped bool + Skipped *bool AlbumID int ArtistID int PlaylistID int @@ -27,7 +27,7 @@ func HistoryModel(h sqlc.History) *History { ID: int(h.ID), TrackID: int(h.TrackID), PlayedAt: h.PlayedAt.Time, - Skipped: h.Skipped, + Skipped: fromBool(h.Skipped), AlbumID: fromInt(h.AlbumID), ArtistID: fromInt(h.ArtistID), PlaylistID: fromInt(h.PlaylistID), diff --git a/internal/database/model/playlist.go b/internal/database/model/playlist.go index 77cdd68..4987ba6 100644 --- a/internal/database/model/playlist.go +++ b/internal/database/model/playlist.go @@ -12,9 +12,9 @@ type Playlist struct { OwnerID int Name string Description string - Public bool + Public *bool TrackAmount int - Collaborative bool + Collaborative *bool CoverID string CoverURL string SnapshotID string diff --git a/internal/database/model/util.go b/internal/database/model/util.go index 6a9a9eb..69b8fc8 100644 --- a/internal/database/model/util.go +++ b/internal/database/model/util.go @@ -7,37 +7,33 @@ import ( ) func fromString(s pgtype.Text) string { - result := "" if s.Valid { - result = s.String + return s.String } - return result + return "" } func fromInt(i pgtype.Int4) int { - result := 0 if i.Valid { - result = int(i.Int32) + return int(i.Int32) } - return result + return 0 } -func fromBool(b pgtype.Bool) bool { - result := false +func fromBool(b pgtype.Bool) *bool { if b.Valid { - result = b.Bool + return &b.Bool } - return result + return nil } func fromTime(t pgtype.Timestamptz) time.Time { - result := time.Time{} if t.Valid { - result = t.Time + return t.Time } - return result + return time.Time{} } diff --git a/internal/database/repository/history.go b/internal/database/repository/history.go index 5cb61f0..16badad 100644 --- a/internal/database/repository/history.go +++ b/internal/database/repository/history.go @@ -55,7 +55,7 @@ func (h *History) GetPopulatedFiltered(ctx context.Context, filter model.History FilterStart: !filter.Start.IsZero(), Column5: toTime(filter.End), FilterEnd: !filter.End.IsZero(), - Skipped: skipped, + Column6: skipped, FilterSkipped: filter.Skipped != nil, }) if err != nil { @@ -73,12 +73,29 @@ func (h *History) GetPopulatedFiltered(ctx context.Context, filter model.History }), nil } +func (h *History) GetSkippedUnknownPopulated(ctx context.Context, userID int) ([]*model.History, error) { + skippeds, err := h.repo.queries(ctx).HistoryGetSkippedUnknownPopulated(ctx, int32(userID)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get skipped unknown populated %d | %w", userID, err) + } + + return utils.SliceMap(skippeds, func(s sqlc.HistoryGetSkippedUnknownPopulatedRow) *model.History { + h := model.HistoryModel(s.History) + h.Track = *model.TrackModel(s.Track) + + return h + }), nil +} + func (h *History) Create(ctx context.Context, history *model.History) error { id, err := h.repo.queries(ctx).HistoryCreate(ctx, sqlc.HistoryCreateParams{ UserID: int32(history.UserID), TrackID: int32(history.TrackID), PlayedAt: toTime(history.PlayedAt), - Skipped: history.Skipped, + Skipped: toBool(history.Skipped), AlbumID: toInt(history.AlbumID), ArtistID: toInt(history.ArtistID), PlaylistID: toInt(history.PlaylistID), @@ -93,24 +110,26 @@ func (h *History) Create(ctx context.Context, history *model.History) error { return nil } +// CreateBatch let's you create a batch of history entries +// It will panic if the skipped field is nil!!! func (h *History) CreateBatch(ctx context.Context, histories []model.History) error { userIDs := make([]int32, 0, len(histories)) trackIDs := make([]int32, 0, len(histories)) playedAts := make([]pgtype.Timestamptz, 0, len(histories)) - skipped := make([]bool, 0, len(histories)) + skippeds := make([]bool, 0, len(histories)) for i := range histories { userIDs = append(userIDs, int32(histories[i].UserID)) trackIDs = append(trackIDs, int32(histories[i].TrackID)) playedAts = append(playedAts, toTime(histories[i].PlayedAt)) - skipped = append(skipped, histories[i].Skipped) + skippeds = append(skippeds, *histories[i].Skipped) } if err := h.repo.queries(ctx).HistoryCreateBatch(ctx, sqlc.HistoryCreateBatchParams{ Column1: userIDs, Column2: trackIDs, Column3: playedAts, - Column4: skipped, + Column4: skippeds, }); err != nil { return fmt.Errorf("create history batch %w", err) } @@ -118,6 +137,18 @@ func (h *History) CreateBatch(ctx context.Context, histories []model.History) er return nil } +func (h *History) Update(ctx context.Context, history model.History) error { + if err := h.repo.queries(ctx).HistoryUpdate(ctx, sqlc.HistoryUpdateParams{ + ID: int32(history.ID), + PlayedAt: toTime(history.PlayedAt), + Skipped: toBool(history.Skipped), + }); err != nil { + return fmt.Errorf("update history %+v | %w", history, err) + } + + return nil +} + func (h *History) DeleteOlder(ctx context.Context, userID int, playedAt time.Time) error { if err := h.repo.queries(ctx).HistoryDeleteUserOlder(ctx, sqlc.HistoryDeleteUserOlderParams{ UserID: int32(userID), diff --git a/internal/database/repository/task.go b/internal/database/repository/task.go index ed9e28f..a5fbf63 100644 --- a/internal/database/repository/task.go +++ b/internal/database/repository/task.go @@ -136,8 +136,8 @@ func (t *Task) Update(ctx context.Context, task model.Task) error { if err := t.repo.queries(ctx).TaskUpdate(ctx, sqlc.TaskUpdateParams{ Uid: task.UID, Name: toString(task.Name), - Active: toBool(task.Active), - Recurring: toBool(task.Recurring), + Active: toBool(&task.Active), + Recurring: toBool(&task.Recurring), }); err != nil { return fmt.Errorf("update task %+v | %w", task, err) } diff --git a/internal/database/repository/util.go b/internal/database/repository/util.go index accb8c0..9928883 100644 --- a/internal/database/repository/util.go +++ b/internal/database/repository/util.go @@ -14,8 +14,13 @@ func toInt(i int) pgtype.Int4 { return pgtype.Int4{Int32: int32(i), Valid: i != 0} } -func toBool(b bool) pgtype.Bool { - return pgtype.Bool{Bool: b, Valid: true} +func toBool(b *bool) pgtype.Bool { + bb := false + if b != nil { + bb = *b + } + + return pgtype.Bool{Bool: bb, Valid: b != nil} } func toTime(t time.Time) pgtype.Timestamptz { diff --git a/internal/server/dto/playlist.go b/internal/server/dto/playlist.go index d9ce584..5c600c0 100644 --- a/internal/server/dto/playlist.go +++ b/internal/server/dto/playlist.go @@ -11,9 +11,9 @@ type Playlist struct { Owner User `json:"owner,omitzero"` Name string `json:"name"` Description string `json:"description,omitzero"` - Public bool `json:"public"` + Public *bool `json:"public,omitzero"` TrackAmount int `json:"track_amount"` - Collaborative bool `json:"collaborative"` + Collaborative *bool `json:"collaborative,omitzero"` HasCover bool `json:"has_cover"` } diff --git a/internal/spotify/api/model.go b/internal/spotify/api/model.go index ae4dc4f..44ce426 100644 --- a/internal/spotify/api/model.go +++ b/internal/spotify/api/model.go @@ -103,11 +103,11 @@ type Playlist struct { } `json:"owner"` Name string `json:"name"` Description string `json:"description"` - Public bool `json:"public"` + Public *bool `json:"public"` Tracks struct { Total int `json:"total"` } `json:"tracks"` - Collaborative bool `json:"collaborative"` + Collaborative *bool `json:"collaborative"` Images []Image `json:"images"` SnapshotID string `json:"snapshot_id"` } diff --git a/internal/spotify/export.go b/internal/spotify/export.go index 088c553..5afa3be 100644 --- a/internal/spotify/export.go +++ b/internal/spotify/export.go @@ -33,7 +33,7 @@ func (e exportTrack) toHistory(userID int) *model.History { return &model.History{ UserID: userID, PlayedAt: e.StoppedAt.Add(time.Duration(-1*e.MsPlayed) * time.Millisecond), - Skipped: e.Skipped, + Skipped: &e.Skipped, Track: model.Track{ SpotifyID: uriToID(e.SpotifyTrackURI), }, diff --git a/internal/spotify/history.go b/internal/spotify/history.go index 6fb7b4e..6f1d6e8 100644 --- a/internal/spotify/history.go +++ b/internal/spotify/history.go @@ -29,10 +29,31 @@ func (c *client) historySync(ctx context.Context, user model.User) error { if previous.Track.SpotifyID == current.Track.SpotifyID { // Same track - // Let's give it a 5 second buffer - if previous.PlayedAt.Add(5 * time.Second).After(currentStart) { + // We don't know if the users is still listening for the first, + // has it on repeat or simply paused it for a while. + // Check first if it's still the same listening time (add a small buffer to count for e.g. network latency) + if previous.PlayedAt.Add(10 * time.Second).After(currentStart) { + // User is still in the same listen return nil } + // Now the user has either paused it for some time or is listening to it on repeat. + // Let's assume if the starting time of the current == the end time of the previous + // that the user is listening on repeat. + // To account for small time differences from crossplay, network latency, ... we'll againa add a buffer + // but we need to add the buffer on both sides. + previousEnd := previous.PlayedAt.Add(time.Duration(previous.Track.DurationMs) * time.Millisecond) + if !previousEnd.Add(-20*time.Second).Before(currentStart) || !previousEnd.Add(20*time.Second).After(currentStart) { + // User has paused for a while. + // We could exit now and it would cover all cases except if + // the user now starts listening to the song on repeat + // To account for that we can move the playedAt time from the previous listen to the start + // of the current track. + // It's not perfect but it's the best we can do with the limited information + previous.PlayedAt = currentStart + return c.history.Update(ctx, *previous) + } + + // We now know that the user is listening to the track on repeat, so let's add it again } track := current.Track.ToModel() @@ -166,3 +187,41 @@ func (c *client) historyShowCheck(ctx context.Context, show *model.Show) error { return nil } + +// historySkipped will populate the skipped field as much as it can +func (c *client) historySkipped(ctx context.Context, user model.User) error { + histories, err := c.history.GetSkippedUnknownPopulated(ctx, user.ID) + if err != nil { + return err + } + + for _, h := range histories { + previous, err := c.history.GetPreviousPopulated(ctx, user.ID, h.PlayedAt) + if err != nil { + return err + } + if previous == nil { + continue + } + if previous.Track.DurationMs == 0 { + continue + } + + previousEnd := previous.PlayedAt.Add(time.Duration(previous.Track.DurationMs) * time.Millisecond) + // Did the next song start before the previous ended? + // Add a buffer of 20 seconds because skips in the last 20 seconds + // of the track don't really count + skipped := false + if previousEnd.Add(-20 * time.Second).After(h.PlayedAt) { + // User skipped + skipped = true + } + + previous.Skipped = &skipped + if err := c.history.Update(ctx, *previous); err != nil { + return err + } + } + + return nil +} diff --git a/internal/spotify/task.go b/internal/spotify/task.go index 29eb6f5..1b0b7e4 100644 --- a/internal/spotify/task.go +++ b/internal/spotify/task.go @@ -2,6 +2,7 @@ package spotify import ( "context" + "errors" "fmt" "github.com/topvennie/sortifyr/internal/database/model" @@ -175,17 +176,13 @@ func (c *client) taskPlaylist(ctx context.Context, users []model.User, results [ if err := c.playlistSync(ctx, user); err != nil { results[i].Error = fmt.Errorf("synchronize playlists %w", err) } - } - if err := c.playlistUpdate(ctx, users[0]); err != nil { - for i := range results { - results[i].Error = fmt.Errorf("update playlists %w", err) + if err := c.playlistUpdate(ctx, user); err != nil { + results[i].Error = errors.Join(fmt.Errorf("update playlists %w", err), results[i].Error) } - } - if err := c.playlistCoverSync(ctx, users[0]); err != nil { - for i := range results { - results[i].Error = fmt.Errorf("synchronize playlist covers %w", err) + if err := c.playlistCoverSync(ctx, user); err != nil { + results[i].Error = errors.Join(fmt.Errorf("synchronize playlist covers %w", err), results[i].Error) } } } @@ -195,17 +192,13 @@ func (c *client) taskAlbum(ctx context.Context, users []model.User, results []ta if err := c.albumSync(ctx, user); err != nil { results[i].Error = fmt.Errorf("synchronize albums %w", err) } - } - if err := c.albumUpdate(ctx, users[0]); err != nil { - for i := range users { - results[i].Error = fmt.Errorf("update albums %w", err) + if err := c.albumUpdate(ctx, user); err != nil { + results[i].Error = errors.Join(fmt.Errorf("update albums %w", err), results[i].Error) } - } - if err := c.albumCoverSync(ctx, users[0]); err != nil { - for i := range users { - results[i].Error = fmt.Errorf("synchronize album covers %w", err) + if err := c.albumCoverSync(ctx, user); err != nil { + results[i].Error = errors.Join(fmt.Errorf("synchronize album covers %w", err), results[i].Error) } } } @@ -223,17 +216,13 @@ func (c *client) taskShow(ctx context.Context, users []model.User, results []tas if err := c.showSync(ctx, user); err != nil { results[i].Error = fmt.Errorf("synchronize shows %w", err) } - } - if err := c.showUpdate(ctx, users[0]); err != nil { - for i := range users { - results[i].Error = fmt.Errorf("update shows %w", err) + if err := c.showUpdate(ctx, user); err != nil { + results[i].Error = errors.Join(fmt.Errorf("update shows %w", err), results[i].Error) } - } - if err := c.showCoverSync(ctx, users[0]); err != nil { - for i := range users { - results[i].Error = fmt.Errorf("synchronize show covers %w", err) + if err := c.showCoverSync(ctx, user); err != nil { + results[i].Error = errors.Join(fmt.Errorf("synchronize shows covers %w", err), results[i].Error) } } } @@ -244,6 +233,12 @@ func (c *client) taskTrack(ctx context.Context, users []model.User, results []ta results[i].Error = fmt.Errorf("update tracks %w", err) } } + + for i, user := range users { + if err := c.historySkipped(ctx, user); err != nil { + results[i].Error = errors.Join(fmt.Errorf("update historic data %w", err), results[i].Error) + } + } } func (c *client) taskUser(ctx context.Context, users []model.User, results []task.TaskResult) { diff --git a/internal/task/manager.go b/internal/task/manager.go index 88e4b52..1767ef8 100644 --- a/internal/task/manager.go +++ b/internal/task/manager.go @@ -87,6 +87,7 @@ func (m *manager) Add(ctx context.Context, newTask Task) error { // Update it task.Name = newTask.Name() task.Active = isRecurring + task.Recurring = isRecurring if err := m.repoTask.Update(ctx, *task); err != nil { return err } diff --git a/pkg/sqlc/history.sql.go b/pkg/sqlc/history.sql.go index 8385124..0f1f229 100644 --- a/pkg/sqlc/history.sql.go +++ b/pkg/sqlc/history.sql.go @@ -25,7 +25,7 @@ type HistoryCreateParams struct { ArtistID pgtype.Int4 PlaylistID pgtype.Int4 ShowID pgtype.Int4 - Skipped bool + Skipped pgtype.Bool } func (q *Queries) HistoryCreate(ctx context.Context, arg HistoryCreateParams) (int32, error) { @@ -94,7 +94,7 @@ WHERE h.user_id = $1::int AND (h.played_at >= $4::timestamptz OR NOT $7) AND (h.played_at <= $5::timestamptz OR NOT $8) AND - (h.skipped = $6 OR NOT $9) + (h.skipped = $6::boolean OR NOT $9) ORDER BY h.played_at DESC LIMIT $2 OFFSET $3 ` @@ -105,7 +105,7 @@ type HistoryGetPopulatedFilteredParams struct { Offset int32 Column4 pgtype.Timestamptz Column5 pgtype.Timestamptz - Skipped bool + Column6 bool FilterStart interface{} FilterEnd interface{} FilterSkipped interface{} @@ -123,7 +123,7 @@ func (q *Queries) HistoryGetPopulatedFiltered(ctx context.Context, arg HistoryGe arg.Offset, arg.Column4, arg.Column5, - arg.Skipped, + arg.Column6, arg.FilterStart, arg.FilterEnd, arg.FilterSkipped, @@ -203,3 +203,71 @@ func (q *Queries) HistoryGetPreviousPopulated(ctx context.Context, arg HistoryGe ) return i, err } + +const historyGetSkippedUnknownPopulated = `-- name: HistoryGetSkippedUnknownPopulated :many +SELECT h.id, h.user_id, h.track_id, h.played_at, h.album_id, h.artist_id, h.playlist_id, h.show_id, h.skipped, t.id, t.spotify_id, t.name, t.popularity, t.updated_at, t.duration_ms +FROM history h +LEFT JOIN tracks t ON t.id = h.track_id +WHERE h.skipped IS NULL AND h.user_id = $1 +ORDER BY played_at ASC +` + +type HistoryGetSkippedUnknownPopulatedRow struct { + History History + Track Track +} + +func (q *Queries) HistoryGetSkippedUnknownPopulated(ctx context.Context, userID int32) ([]HistoryGetSkippedUnknownPopulatedRow, error) { + rows, err := q.db.Query(ctx, historyGetSkippedUnknownPopulated, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []HistoryGetSkippedUnknownPopulatedRow + for rows.Next() { + var i HistoryGetSkippedUnknownPopulatedRow + if err := rows.Scan( + &i.History.ID, + &i.History.UserID, + &i.History.TrackID, + &i.History.PlayedAt, + &i.History.AlbumID, + &i.History.ArtistID, + &i.History.PlaylistID, + &i.History.ShowID, + &i.History.Skipped, + &i.Track.ID, + &i.Track.SpotifyID, + &i.Track.Name, + &i.Track.Popularity, + &i.Track.UpdatedAt, + &i.Track.DurationMs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const historyUpdate = `-- name: HistoryUpdate :exec +UPDATE history +SET + played_at = coalesce($2, played_at), + skipped = coalesce($3, skipped) +WHERE id = $1 +` + +type HistoryUpdateParams struct { + ID int32 + PlayedAt pgtype.Timestamptz + Skipped pgtype.Bool +} + +func (q *Queries) HistoryUpdate(ctx context.Context, arg HistoryUpdateParams) error { + _, err := q.db.Exec(ctx, historyUpdate, arg.ID, arg.PlayedAt, arg.Skipped) + return err +} diff --git a/pkg/sqlc/models.go b/pkg/sqlc/models.go index eaf1d31..9deaf40 100644 --- a/pkg/sqlc/models.go +++ b/pkg/sqlc/models.go @@ -161,7 +161,7 @@ type History struct { ArtistID pgtype.Int4 PlaylistID pgtype.Int4 ShowID pgtype.Int4 - Skipped bool + Skipped pgtype.Bool } type Link struct { diff --git a/ui/src/components/playlist/PlaylistTable.tsx b/ui/src/components/playlist/PlaylistTable.tsx index 5e2481a..261aa60 100644 --- a/ui/src/components/playlist/PlaylistTable.tsx +++ b/ui/src/components/playlist/PlaylistTable.tsx @@ -37,12 +37,16 @@ export const PlaylistTable = ({ playlists, isLoading }: Props) => { { accessor: "public", textAlign: "right", - render: ({ public: p }) => ( -
- {p ? : } -
+ render: ({ public: p }) => { + if (p === undefined) return null - ) + return ( +
+ {p ? : } +
+ + ) + } }, ]} records={records} diff --git a/ui/src/lib/types/api.d.ts b/ui/src/lib/types/api.d.ts index 101e7c5..f4a7ae2 100644 --- a/ui/src/lib/types/api.d.ts +++ b/ui/src/lib/types/api.d.ts @@ -15,9 +15,9 @@ export namespace API { owner?: User; name: string; description?: string; - public: boolean; + public?: boolean; track_amount: number; - collaborative: boolean; + collaborative?: boolean; has_cover: boolean; } diff --git a/ui/src/lib/types/playlist.ts b/ui/src/lib/types/playlist.ts index b279c30..db75fbc 100644 --- a/ui/src/lib/types/playlist.ts +++ b/ui/src/lib/types/playlist.ts @@ -10,9 +10,9 @@ export interface Playlist { owner?: User; name: string; description?: string; - public: boolean; + public?: boolean; trackAmount: number; - collaborative: boolean; + collaborative?: boolean; hasCover: boolean; } diff --git a/ui/src/pages/Settings.tsx b/ui/src/pages/Settings.tsx index ee1bcc3..8aa3047 100644 --- a/ui/src/pages/Settings.tsx +++ b/ui/src/pages/Settings.tsx @@ -44,6 +44,7 @@ export const Settings = () => {

3. When Spotify emails your the download link, save the .zip file to your computer.

4. Click the button below and choose the .zip file to start the import.

+

This will clear all historic data before the most recent song in the zip file.

{(props) => (
From a54e331eafd49870dd6035eadd1fc5fa4f0de613 Mon Sep 17 00:00:00 2001 From: vincent Date: Wed, 31 Dec 2025 13:18:36 +0100 Subject: [PATCH 04/33] feat(history): show play count --- db/queries/history.sql | 4 +- internal/database/model/history.go | 16 ++++---- internal/database/repository/history.go | 30 +++++++++------ internal/server/api/track.go | 19 +++++++--- internal/server/dto/track.go | 24 +++++++----- internal/server/service/track.go | 8 +++- pkg/sqlc/history.sql.go | 48 +++++++++++++----------- ui/src/components/track/TrackHistory.tsx | 39 +++++++++++++++---- ui/src/lib/api/track.ts | 3 ++ ui/src/lib/types/api.d.ts | 1 + ui/src/lib/types/track.ts | 3 ++ 11 files changed, 130 insertions(+), 65 deletions(-) diff --git a/db/queries/history.sql b/db/queries/history.sql index f688782..78edc4d 100644 --- a/db/queries/history.sql +++ b/db/queries/history.sql @@ -7,7 +7,7 @@ ORDER BY h.played_at DESC LIMIT 1; -- name: HistoryGetPopulatedFiltered :many -SELECT sqlc.embed(h), sqlc.embed(t) +SELECT sqlc.embed(h), sqlc.embed(t), count(*) FILTER (WHERE h.user_id = $1::int AND (h.skipped = $7::boolean OR NOT @filter_play_count)) OVER (PARTITION BY h.track_id) AS play_count FROM history h LEFT JOIN tracks t ON t.id = h.track_id WHERE @@ -18,7 +18,7 @@ WHERE ORDER BY h.played_at DESC LIMIT $2 OFFSET $3; --- name: HistoryGetSkippedUnknownPopulated :many +-- name: HistoryGetSkippedNullPopulated :many SELECT sqlc.embed(h), sqlc.embed(t) FROM history h LEFT JOIN tracks t ON t.id = h.track_id diff --git a/internal/database/model/history.go b/internal/database/model/history.go index 1072c97..c37e8ed 100644 --- a/internal/database/model/history.go +++ b/internal/database/model/history.go @@ -18,7 +18,8 @@ type History struct { ShowID int // Non db fields - Track Track + Track Track + PlayCount int } func HistoryModel(h sqlc.History) *History { @@ -36,10 +37,11 @@ func HistoryModel(h sqlc.History) *History { } type HistoryFilter struct { - UserID int - Limit int - Offset int - Start time.Time - End time.Time - Skipped *bool + UserID int + Limit int + Offset int + Start time.Time + End time.Time + Skipped *bool + PlayCountSkipped *bool } diff --git a/internal/database/repository/history.go b/internal/database/repository/history.go index 16badad..c48d311 100644 --- a/internal/database/repository/history.go +++ b/internal/database/repository/history.go @@ -47,16 +47,23 @@ func (h *History) GetPopulatedFiltered(ctx context.Context, filter model.History skipped = *filter.Skipped } + playCountSkipped := false + if filter.PlayCountSkipped != nil { + playCountSkipped = *filter.PlayCountSkipped + } + history, err := h.repo.queries(ctx).HistoryGetPopulatedFiltered(ctx, sqlc.HistoryGetPopulatedFilteredParams{ - Column1: int32(filter.UserID), - Limit: int32(filter.Limit), - Offset: int32(filter.Offset), - Column4: toTime(filter.Start), - FilterStart: !filter.Start.IsZero(), - Column5: toTime(filter.End), - FilterEnd: !filter.End.IsZero(), - Column6: skipped, - FilterSkipped: filter.Skipped != nil, + Column1: int32(filter.UserID), + Limit: int32(filter.Limit), + Offset: int32(filter.Offset), + Column4: toTime(filter.Start), + FilterStart: !filter.Start.IsZero(), + Column5: toTime(filter.End), + FilterEnd: !filter.End.IsZero(), + Column6: skipped, + FilterSkipped: filter.Skipped != nil, + Column7: playCountSkipped, + FilterPlayCount: filter.PlayCountSkipped != nil, }) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -68,13 +75,14 @@ func (h *History) GetPopulatedFiltered(ctx context.Context, filter model.History return utils.SliceMap(history, func(h sqlc.HistoryGetPopulatedFilteredRow) *model.History { history := model.HistoryModel(h.History) history.Track = *model.TrackModel(h.Track) + history.PlayCount = int(h.PlayCount) return history }), nil } func (h *History) GetSkippedUnknownPopulated(ctx context.Context, userID int) ([]*model.History, error) { - skippeds, err := h.repo.queries(ctx).HistoryGetSkippedUnknownPopulated(ctx, int32(userID)) + skippeds, err := h.repo.queries(ctx).HistoryGetSkippedNullPopulated(ctx, int32(userID)) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -82,7 +90,7 @@ func (h *History) GetSkippedUnknownPopulated(ctx context.Context, userID int) ([ return nil, fmt.Errorf("get skipped unknown populated %d | %w", userID, err) } - return utils.SliceMap(skippeds, func(s sqlc.HistoryGetSkippedUnknownPopulatedRow) *model.History { + return utils.SliceMap(skippeds, func(s sqlc.HistoryGetSkippedNullPopulatedRow) *model.History { h := model.HistoryModel(s.History) h.Track = *model.TrackModel(s.Track) diff --git a/internal/server/api/track.go b/internal/server/api/track.go index 201da4b..a7bbc83 100644 --- a/internal/server/api/track.go +++ b/internal/server/api/track.go @@ -1,6 +1,7 @@ package api import ( + "strconv" "time" "github.com/gofiber/fiber/v2" @@ -37,6 +38,13 @@ func (r *Track) getHistory(c *fiber.Ctx) error { return fiber.ErrUnauthorized } + var skipped *bool + if v := c.Query("skipped"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + skipped = &b + } + } + var err error startRaw := c.Query("start") @@ -64,11 +72,12 @@ func (r *Track) getHistory(c *fiber.Ctx) error { } history, err := r.track.GetHistory(c.Context(), dto.HistoryFilter{ - UserID: userID, - Limit: limit, - Offset: (page - 1) * limit, - Start: start, - End: end, + UserID: userID, + Skipped: skipped, + Start: start, + End: end, + Limit: limit, + Offset: (page - 1) * limit, }) if err != nil { return err diff --git a/internal/server/dto/track.go b/internal/server/dto/track.go index bb182ad..38fed33 100644 --- a/internal/server/dto/track.go +++ b/internal/server/dto/track.go @@ -84,6 +84,7 @@ type History struct { HistoryID int `json:"history_id"` PlayedAt time.Time `json:"played_at"` + PlayCount int `json:"play_count,omitzero"` } func HistoryDTO(t *model.Track, h *model.History) History { @@ -91,23 +92,26 @@ func HistoryDTO(t *model.Track, h *model.History) History { Track: TrackDTO(t), HistoryID: h.ID, PlayedAt: h.PlayedAt, + PlayCount: h.PlayCount, } } type HistoryFilter struct { - UserID int - Limit int - Offset int - Start time.Time - End time.Time + UserID int + Skipped *bool + Start time.Time + End time.Time + Limit int + Offset int } func (h HistoryFilter) ToModel() *model.HistoryFilter { return &model.HistoryFilter{ - UserID: h.UserID, - Limit: h.Limit, - Offset: h.Offset, - Start: h.Start, - End: h.End, + UserID: h.UserID, + Skipped: h.Skipped, + Start: h.Start, + End: h.End, + Limit: h.Limit, + Offset: h.Offset, } } diff --git a/internal/server/service/track.go b/internal/server/service/track.go index 4d06d04..ddf93d3 100644 --- a/internal/server/service/track.go +++ b/internal/server/service/track.go @@ -27,7 +27,13 @@ func (s *Service) NewTrack() *Track { } func (t *Track) GetHistory(ctx context.Context, filter dto.HistoryFilter) ([]dto.History, error) { - history, err := t.history.GetPopulatedFiltered(ctx, *filter.ToModel()) + filterModel := filter.ToModel() + if filterModel.Skipped == nil { + tmp := false + filterModel.PlayCountSkipped = &tmp + } + + history, err := t.history.GetPopulatedFiltered(ctx, *filterModel) if err != nil { zap.S().Error(err) return nil, fiber.ErrInternalServerError diff --git a/pkg/sqlc/history.sql.go b/pkg/sqlc/history.sql.go index 0f1f229..f390f44 100644 --- a/pkg/sqlc/history.sql.go +++ b/pkg/sqlc/history.sql.go @@ -87,33 +87,36 @@ func (q *Queries) HistoryDeleteUserOlder(ctx context.Context, arg HistoryDeleteU } const historyGetPopulatedFiltered = `-- name: HistoryGetPopulatedFiltered :many -SELECT h.id, h.user_id, h.track_id, h.played_at, h.album_id, h.artist_id, h.playlist_id, h.show_id, h.skipped, t.id, t.spotify_id, t.name, t.popularity, t.updated_at, t.duration_ms +SELECT h.id, h.user_id, h.track_id, h.played_at, h.album_id, h.artist_id, h.playlist_id, h.show_id, h.skipped, t.id, t.spotify_id, t.name, t.popularity, t.updated_at, t.duration_ms, count(*) FILTER (WHERE h.user_id = $1::int AND (h.skipped = $7::boolean OR NOT $8)) OVER (PARTITION BY h.track_id) AS play_count FROM history h LEFT JOIN tracks t ON t.id = h.track_id WHERE h.user_id = $1::int AND - (h.played_at >= $4::timestamptz OR NOT $7) AND - (h.played_at <= $5::timestamptz OR NOT $8) AND - (h.skipped = $6::boolean OR NOT $9) + (h.played_at >= $4::timestamptz OR NOT $9) AND + (h.played_at <= $5::timestamptz OR NOT $10) AND + (h.skipped = $6::boolean OR NOT $11) ORDER BY h.played_at DESC LIMIT $2 OFFSET $3 ` type HistoryGetPopulatedFilteredParams struct { - Column1 int32 - Limit int32 - Offset int32 - Column4 pgtype.Timestamptz - Column5 pgtype.Timestamptz - Column6 bool - FilterStart interface{} - FilterEnd interface{} - FilterSkipped interface{} + Column1 int32 + Limit int32 + Offset int32 + Column4 pgtype.Timestamptz + Column5 pgtype.Timestamptz + Column6 bool + Column7 bool + FilterPlayCount interface{} + FilterStart interface{} + FilterEnd interface{} + FilterSkipped interface{} } type HistoryGetPopulatedFilteredRow struct { - History History - Track Track + History History + Track Track + PlayCount int64 } func (q *Queries) HistoryGetPopulatedFiltered(ctx context.Context, arg HistoryGetPopulatedFilteredParams) ([]HistoryGetPopulatedFilteredRow, error) { @@ -124,6 +127,8 @@ func (q *Queries) HistoryGetPopulatedFiltered(ctx context.Context, arg HistoryGe arg.Column4, arg.Column5, arg.Column6, + arg.Column7, + arg.FilterPlayCount, arg.FilterStart, arg.FilterEnd, arg.FilterSkipped, @@ -151,6 +156,7 @@ func (q *Queries) HistoryGetPopulatedFiltered(ctx context.Context, arg HistoryGe &i.Track.Popularity, &i.Track.UpdatedAt, &i.Track.DurationMs, + &i.PlayCount, ); err != nil { return nil, err } @@ -204,7 +210,7 @@ func (q *Queries) HistoryGetPreviousPopulated(ctx context.Context, arg HistoryGe return i, err } -const historyGetSkippedUnknownPopulated = `-- name: HistoryGetSkippedUnknownPopulated :many +const historyGetSkippedNullPopulated = `-- name: HistoryGetSkippedNullPopulated :many SELECT h.id, h.user_id, h.track_id, h.played_at, h.album_id, h.artist_id, h.playlist_id, h.show_id, h.skipped, t.id, t.spotify_id, t.name, t.popularity, t.updated_at, t.duration_ms FROM history h LEFT JOIN tracks t ON t.id = h.track_id @@ -212,20 +218,20 @@ WHERE h.skipped IS NULL AND h.user_id = $1 ORDER BY played_at ASC ` -type HistoryGetSkippedUnknownPopulatedRow struct { +type HistoryGetSkippedNullPopulatedRow struct { History History Track Track } -func (q *Queries) HistoryGetSkippedUnknownPopulated(ctx context.Context, userID int32) ([]HistoryGetSkippedUnknownPopulatedRow, error) { - rows, err := q.db.Query(ctx, historyGetSkippedUnknownPopulated, userID) +func (q *Queries) HistoryGetSkippedNullPopulated(ctx context.Context, userID int32) ([]HistoryGetSkippedNullPopulatedRow, error) { + rows, err := q.db.Query(ctx, historyGetSkippedNullPopulated, userID) if err != nil { return nil, err } defer rows.Close() - var items []HistoryGetSkippedUnknownPopulatedRow + var items []HistoryGetSkippedNullPopulatedRow for rows.Next() { - var i HistoryGetSkippedUnknownPopulatedRow + var i HistoryGetSkippedNullPopulatedRow if err := rows.Scan( &i.History.ID, &i.History.UserID, diff --git a/ui/src/components/track/TrackHistory.tsx b/ui/src/components/track/TrackHistory.tsx index 0473b99..eab8089 100644 --- a/ui/src/components/track/TrackHistory.tsx +++ b/ui/src/components/track/TrackHistory.tsx @@ -6,6 +6,8 @@ import { useState } from "react"; import { SectionTitle } from "../atoms/Page"; import { DatePickerInput } from "../molecules/DatePickerInput"; import { Table } from "../molecules/Table"; +import { Group } from "@mantine/core"; +import { Segment } from "../molecules/Segment"; export const TrackHistory = () => { const [range, setRange] = useState<[Date | null, Date | null]>([null, null]) @@ -24,6 +26,14 @@ export const TrackHistory = () => { } } + const handleSkippedChange = (value: string) => { + let skipped: boolean | undefined = undefined + + if (value != "all") skipped = value === "true" + + setFilter({ ...filter, skipped }) + } + const { history, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useTrackGetHistory(filter) const handleBottom = () => { @@ -39,18 +49,31 @@ export const TrackHistory = () => { title="Recently Played" description={`An overview of recently played tracks.\nRun the tracks task if a track has no title.`} /> - + + + +

{formatDate(playedAt)}

} + { accessor: "playCount" }, + { accessor: "playedAt", render: ({ playedAt }) =>

{formatDate(playedAt)}

}, ]} records={history} noRecordsText="No recorded tracks yet" diff --git a/ui/src/lib/api/track.ts b/ui/src/lib/api/track.ts index a362b09..8f916ed 100644 --- a/ui/src/lib/api/track.ts +++ b/ui/src/lib/api/track.ts @@ -15,6 +15,9 @@ export const useTrackGetHistory = (filter?: TrackHistoryFilter) => { limit: PAGE_LIMIT.toString(), }) + if (filter?.skipped !== undefined) { + queryParams.append("skipped", String(filter?.skipped)) + } if (filter?.start !== undefined) { queryParams.append("start", filter.start.toISOString()) } diff --git a/ui/src/lib/types/api.d.ts b/ui/src/lib/types/api.d.ts index f4a7ae2..e024623 100644 --- a/ui/src/lib/types/api.d.ts +++ b/ui/src/lib/types/api.d.ts @@ -72,6 +72,7 @@ export namespace API { export interface TrackHistory extends Track { history_id: number; played_at: string; + play_count?: number; } export interface TrackAdded extends Track { diff --git a/ui/src/lib/types/track.ts b/ui/src/lib/types/track.ts index 4b26009..05b72d3 100644 --- a/ui/src/lib/types/track.ts +++ b/ui/src/lib/types/track.ts @@ -18,6 +18,7 @@ export const convertTrack = (t: API.Track): Track => { export interface TrackHistory extends Track { historyId: number; playedAt: Date; + playCount?: number; } export const convertTrackHistory = (h: API.TrackHistory): TrackHistory => { @@ -25,6 +26,7 @@ export const convertTrackHistory = (h: API.TrackHistory): TrackHistory => { ...convertTrack(h), historyId: h.history_id, playedAt: new Date(h.played_at), + playCount: h.play_count, } } @@ -82,6 +84,7 @@ export interface TrackFilter { } export interface TrackHistoryFilter { + skipped?: boolean; start?: Date; end?: Date; } From 56599393dc39fa84a2beecc8c5f6c1af7062f1f2 Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 1 Jan 2026 14:18:09 +0100 Subject: [PATCH 05/33] feat(generator): preview generators in ui --- ui/src/components/atoms/Button.tsx | 8 ++ ui/src/components/atoms/LinkButton.tsx | 2 +- .../directory/DirectoryTreeNodeEditable.tsx | 7 +- ui/src/components/generator/GeneratorForm.tsx | 114 ++++++++++++++++++ ui/src/components/molecules/Confirm.tsx | 3 +- ui/src/components/molecules/Segment.tsx | 2 +- .../playlist/PlaylistDuplicates.tsx | 5 +- ui/src/layout/NavLayout.tsx | 12 +- ui/src/lib/api/generator.tsx | 15 +++ ui/src/lib/types/generator.ts | 37 ++++++ ui/src/lib/types/track.ts | 4 + ui/src/pages/Directories.tsx | 9 +- ui/src/pages/Links.tsx | 7 +- ui/src/pages/Settings.tsx | 7 +- ui/src/pages/generator/Generator.tsx | 5 + ui/src/pages/generator/GeneratorCreate.tsx | 16 +++ ui/src/pages/generator/GeneratorEdit.tsx | 14 +++ ui/src/pages/generator/GeneratorOverview.tsx | 24 ++++ ui/src/router.tsx | 33 +++++ 19 files changed, 303 insertions(+), 21 deletions(-) create mode 100644 ui/src/components/atoms/Button.tsx create mode 100644 ui/src/components/generator/GeneratorForm.tsx create mode 100644 ui/src/lib/api/generator.tsx create mode 100644 ui/src/lib/types/generator.ts create mode 100644 ui/src/pages/generator/Generator.tsx create mode 100644 ui/src/pages/generator/GeneratorCreate.tsx create mode 100644 ui/src/pages/generator/GeneratorEdit.tsx create mode 100644 ui/src/pages/generator/GeneratorOverview.tsx diff --git a/ui/src/components/atoms/Button.tsx b/ui/src/components/atoms/Button.tsx new file mode 100644 index 0000000..bb7ae67 --- /dev/null +++ b/ui/src/components/atoms/Button.tsx @@ -0,0 +1,8 @@ +import { ButtonProps, Button as MButton } from "@mantine/core" +import { ComponentProps } from "react" + +type Props = ButtonProps & ComponentProps<"button"> + +export const Button = (props: Props) => { + return +} diff --git a/ui/src/components/atoms/LinkButton.tsx b/ui/src/components/atoms/LinkButton.tsx index 4a2476f..f293f9d 100644 --- a/ui/src/components/atoms/LinkButton.tsx +++ b/ui/src/components/atoms/LinkButton.tsx @@ -13,7 +13,7 @@ const ButtonLinkComponent = React.forwardRef< HTMLButtonElement, LinkButtonProps >((props, ref) => { - return - diff --git a/ui/src/components/generator/GeneratorForm.tsx b/ui/src/components/generator/GeneratorForm.tsx new file mode 100644 index 0000000..22dddcc --- /dev/null +++ b/ui/src/components/generator/GeneratorForm.tsx @@ -0,0 +1,114 @@ +import { useGeneratorGenerate } from "@/lib/api/generator"; +import { GeneratorPreset, generatorPresetString, generatorSchema, GeneratorSchema } from "@/lib/types/generator"; +import { ActionIcon, Divider, Group, NumberInput, Stack, Stepper } from "@mantine/core"; +import { useForm, UseFormReturnType } from "@mantine/form"; +import { useQueryClient } from "@tanstack/react-query"; +import { zod4Resolver } from "mantine-form-zod-resolver"; +import { useState } from "react"; +import { LuRotateCcw } from "react-icons/lu"; +import { Button } from "../atoms/Button"; +import { SectionTitle } from "../atoms/Page"; +import { Table } from "../molecules/Table"; +import { notifications } from "@mantine/notifications"; + +const maxSteps = 3 + +export const GeneratorForm = () => { + const [active, setActive] = useState(0) + + const form = useForm({ + initialValues: { + preset: GeneratorPreset.Top, + params: { + trackAmount: 50, + minPlayCount: 5, + }, + }, + validate: zod4Resolver(generatorSchema), + }) + + return ( +
+ + + + + + + + + + + +

{`Step ${active + 1} of ${maxSteps}`}

+ + + + +
+
+ ) +} + +const Preset = ({ form }: { form: UseFormReturnType }) => { + const { data: tracks, isLoading, isRefetching } = useGeneratorGenerate(form.values) + const queryClient = useQueryClient() + + const handleClickPreset = (p: GeneratorPreset) => { + form.setFieldValue("preset", p) + } + + const handleRefetchTracks = () => { + if (form.validate().hasErrors) { + notifications.show({ color: "red", message: "Some parameters are invalid" }) + return + } + + queryClient.invalidateQueries({ queryKey: ["generator", "generate"] }) + } + + return ( + + + + +

Choose a preset

+ + {Object.values(GeneratorPreset).map(p => ( + + ))} + +
+ + +

Configure parameters

+ + + + +
+ + + +

Preview

+ + + +
+
+ + + ) +} diff --git a/ui/src/components/molecules/Confirm.tsx b/ui/src/components/molecules/Confirm.tsx index 46169f9..c14390a 100644 --- a/ui/src/components/molecules/Confirm.tsx +++ b/ui/src/components/molecules/Confirm.tsx @@ -1,5 +1,6 @@ -import { Button, Text, Group, ModalBaseProps, Stack } from "@mantine/core"; +import { Text, Group, ModalBaseProps, Stack } from "@mantine/core"; import { ModalCenter } from "../atoms/ModalCenter"; +import { Button } from "../atoms/Button"; interface Props extends Omit { modalTitle: string; diff --git a/ui/src/components/molecules/Segment.tsx b/ui/src/components/molecules/Segment.tsx index 0147309..88a4671 100644 --- a/ui/src/components/molecules/Segment.tsx +++ b/ui/src/components/molecules/Segment.tsx @@ -11,7 +11,7 @@ export const Segment = ({ secondary, ...props }: Props) => { color={secondary ? "secondary.1" : "primary.3"} styles={{ innerLabel: { - color: secondary ? "black" : undefined, + color: "black", }, }} {...props} diff --git a/ui/src/components/playlist/PlaylistDuplicates.tsx b/ui/src/components/playlist/PlaylistDuplicates.tsx index 13b4cdc..11e25a7 100644 --- a/ui/src/components/playlist/PlaylistDuplicates.tsx +++ b/ui/src/components/playlist/PlaylistDuplicates.tsx @@ -1,6 +1,6 @@ import { usePlaylistGetDuplicates, usePlaylistRemoveDuplicates } from "@/lib/api/playlist" import { Playlist } from "@/lib/types/playlist" -import { Button, Group } from "@mantine/core" +import { Group } from "@mantine/core" import { useDisclosure } from "@mantine/hooks" import { notifications } from "@mantine/notifications" import { sortBy } from "lodash" @@ -11,6 +11,7 @@ import { Confirm } from "../molecules/Confirm" import { Table } from "../molecules/Table" import { PlaylistCover } from "./PlaylistCover" import { getErrorMessage } from "@/lib/utils" +import { Button } from "../atoms/Button" export const PlaylistDuplicates = () => { const { data: playlists, isLoading } = usePlaylistGetDuplicates() @@ -45,7 +46,7 @@ export const PlaylistDuplicates = () => { title="Playlist duplicates" description={`Playlists with duplicate tracks.\nClick on a row to see the duplicate tracks.`} /> - diff --git a/ui/src/layout/NavLayout.tsx b/ui/src/layout/NavLayout.tsx index a5a2638..2355abc 100644 --- a/ui/src/layout/NavLayout.tsx +++ b/ui/src/layout/NavLayout.tsx @@ -1,12 +1,13 @@ import { Avatar } from "@/components/atoms/Avatar"; +import { Button } from "@/components/atoms/Button"; import { LinkButton } from "@/components/atoms/LinkButton"; import { useAuth } from "@/lib/hooks/useAuth"; import { cn, getBuildTime } from "@/lib/utils"; -import { ActionIcon, AppShell, Burger, Button, Divider, Group, ScrollArea, Stack } from "@mantine/core"; +import { ActionIcon, AppShell, Burger, Divider, Group, ScrollArea, Stack } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { LinkProps } from "@tanstack/react-router"; import { ComponentProps, ReactNode, useState } from "react"; -import { LuClock, LuFolderTree, LuLink2, LuListMusic, LuMusic, LuMusic3, LuSettings, LuTriangle } from "react-icons/lu"; +import { LuClock, LuFolderTree, LuLink2, LuListMusic, LuMusic, LuMusic3, LuSettings, LuSlidersHorizontal, LuTriangle } from "react-icons/lu"; type Props = ComponentProps<"div"> @@ -42,6 +43,11 @@ const routes: Route[] = [ icon: , link: { to: "/task" }, }, + { + title: "Generator", + icon: , + link: { to: "/generator" }, + }, { title: "Settings", icon: , @@ -113,7 +119,7 @@ export const NavLayout = ({ className, children, ...props }: Props) => { {userExpanded && ( <> - diff --git a/ui/src/lib/api/generator.tsx b/ui/src/lib/api/generator.tsx new file mode 100644 index 0000000..b70e44d --- /dev/null +++ b/ui/src/lib/api/generator.tsx @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query" +import { GeneratorSchema } from "../types/generator" +import { apiPost } from "./query" +import { convertTracks } from "../types/track" + +const ENDPOINT = "generator" + +export const useGeneratorGenerate = (generator: GeneratorSchema) => { + return useQuery({ + queryKey: ["generator", "generate"], + queryFn: async () => (await apiPost(`${ENDPOINT}/generate`, generator, convertTracks)).data, + staleTime: Infinity, + throwOnError: true, + }) +} diff --git a/ui/src/lib/types/generator.ts b/ui/src/lib/types/generator.ts new file mode 100644 index 0000000..cb44bc6 --- /dev/null +++ b/ui/src/lib/types/generator.ts @@ -0,0 +1,37 @@ +import z from "zod"; +import { JSONBody } from "./general"; + +export enum GeneratorPreset { + Custom = "custom", + Forgotten = "forgotten", + Top = "top", + OldTop = "old_top" +} +export const generatorPresetString: Record = { + [GeneratorPreset.Custom]: "Custom", + [GeneratorPreset.Forgotten]: "Forgotten", + [GeneratorPreset.Top]: "Top", + [GeneratorPreset.OldTop]: "Old Top", +} + +export interface GeneratorParams { + trackAmount: number; + minPlayCount: number; +} + +export interface Generator { + preset: GeneratorPreset; + params: GeneratorParams; +} + +export const generatorParamsSchema = z.object({ + trackAmount: z.number().positive(), + minPlayCount: z.number().positive(), +}) +export type GeneratorParamsSchema = z.infer & JSONBody; + +export const generatorSchema = z.object({ + preset: z.enum(GeneratorPreset), + params: generatorParamsSchema.partial(), +}) +export type GeneratorSchema = z.infer & JSONBody; diff --git a/ui/src/lib/types/track.ts b/ui/src/lib/types/track.ts index 05b72d3..f3529ac 100644 --- a/ui/src/lib/types/track.ts +++ b/ui/src/lib/types/track.ts @@ -15,6 +15,10 @@ export const convertTrack = (t: API.Track): Track => { } } +export const convertTracks = (t: API.Track[]): Track[] => { + return t.map(convertTrack) +} + export interface TrackHistory extends Track { historyId: number; playedAt: Date; diff --git a/ui/src/pages/Directories.tsx b/ui/src/pages/Directories.tsx index 9079335..46140c5 100644 --- a/ui/src/pages/Directories.tsx +++ b/ui/src/pages/Directories.tsx @@ -1,3 +1,4 @@ +import { Button } from "@/components/atoms/Button" import { Page, PageTitle, Section, SectionTitle } from "@/components/atoms/Page" import { DirectoryPlaylistDraggable, DirectoryPlaylistSelector } from "@/components/directory/DirectoryPlaylistSelector" import { DirectoryTree } from "@/components/directory/DirectoryTree" @@ -10,7 +11,7 @@ import { Playlist, PlaylistSchema } from "@/lib/types/playlist" import { getUuid } from "@/lib/utils" import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from '@dnd-kit/core' import { restrictToWindowEdges } from '@dnd-kit/modifiers' -import { Button, Group } from "@mantine/core" +import { Group } from "@mantine/core" import { useDebouncedValue, useDisclosure } from "@mantine/hooks" import { notifications } from "@mantine/notifications" import { useEffect, useMemo, useState } from "react" @@ -138,7 +139,7 @@ const DirectoriesInner = () => { title="Directories" description="Drag playlists here to group them." /> - @@ -152,10 +153,10 @@ const DirectoriesInner = () => { className="flex-1" /> - - diff --git a/ui/src/pages/Links.tsx b/ui/src/pages/Links.tsx index 3a3f043..ba9523c 100644 --- a/ui/src/pages/Links.tsx +++ b/ui/src/pages/Links.tsx @@ -1,3 +1,4 @@ +import { Button } from "@/components/atoms/Button" import { Page, PageTitle, Section, SectionTitle } from "@/components/atoms/Page" import { LinkConnections } from "@/components/link/LinkConnections" import { LinkTree } from "@/components/link/LinkTree" @@ -8,7 +9,7 @@ import { useLinkAnchor } from "@/lib/hooks/useLinkAnchor" import { LinkAnchorProvider } from "@/lib/providers/LinkAnchorProvider" import { Directory } from "@/lib/types/directory" import { Playlist } from "@/lib/types/playlist" -import { Badge, Button, Group, Stack } from "@mantine/core" +import { Badge, Group, Stack } from "@mantine/core" import { useDisclosure, useMediaQuery } from "@mantine/hooks" import { useMemo } from "react" @@ -108,10 +109,10 @@ const LinksInner = () => { - - diff --git a/ui/src/pages/Settings.tsx b/ui/src/pages/Settings.tsx index 8aa3047..3f34a5c 100644 --- a/ui/src/pages/Settings.tsx +++ b/ui/src/pages/Settings.tsx @@ -1,8 +1,9 @@ +import { Button } from "@/components/atoms/Button" import { Page, PageTitle, Section, SectionTitle } from "@/components/atoms/Page" import { useSettingUploadExport } from "@/lib/api/setting" import { CONTENT_TYPE } from "@/lib/types/contentType" import { getErrorMessage } from "@/lib/utils" -import { Button, FileButton, Group, Pill, Stack } from "@mantine/core" +import { FileButton, Group, Pill, Stack } from "@mantine/core" import { notifications } from "@mantine/notifications" import { LuCloudUpload } from "react-icons/lu" @@ -44,11 +45,11 @@ export const Settings = () => {

3. When Spotify emails your the download link, save the .zip file to your computer.

4. Click the button below and choose the .zip file to start the import.

-

This will clear all historic data before the most recent song in the zip file.

+

This will clear all historic data before the most recent song in the zip file.

{(props) => (
- +
)}
diff --git a/ui/src/pages/generator/Generator.tsx b/ui/src/pages/generator/Generator.tsx new file mode 100644 index 0000000..fcc990a --- /dev/null +++ b/ui/src/pages/generator/Generator.tsx @@ -0,0 +1,5 @@ +import { Outlet } from "@tanstack/react-router" + +export const Generator = () => { + return +} diff --git a/ui/src/pages/generator/GeneratorCreate.tsx b/ui/src/pages/generator/GeneratorCreate.tsx new file mode 100644 index 0000000..bf8529c --- /dev/null +++ b/ui/src/pages/generator/GeneratorCreate.tsx @@ -0,0 +1,16 @@ +import { Page, PageTitle, Section } from "@/components/atoms/Page" +import { GeneratorForm } from "@/components/generator/GeneratorForm" + +export const GeneratorCreate = () => { + return ( + + + +
+ +
+
+ ) +} diff --git a/ui/src/pages/generator/GeneratorEdit.tsx b/ui/src/pages/generator/GeneratorEdit.tsx new file mode 100644 index 0000000..9993213 --- /dev/null +++ b/ui/src/pages/generator/GeneratorEdit.tsx @@ -0,0 +1,14 @@ +import { Page, PageTitle } from "@/components/atoms/Page" +import { useParams } from "@tanstack/react-router" + +export const GeneratorEdit = () => { + const { generatorId } = useParams({ from: "/public-layout/generator/edit/$generatorId" }) + + return ( + + + + ) +} diff --git a/ui/src/pages/generator/GeneratorOverview.tsx b/ui/src/pages/generator/GeneratorOverview.tsx new file mode 100644 index 0000000..2954c1e --- /dev/null +++ b/ui/src/pages/generator/GeneratorOverview.tsx @@ -0,0 +1,24 @@ +import { LinkButton } from "@/components/atoms/LinkButton" +import { Page, PageTitle, Section, SectionTitle } from "@/components/atoms/Page" +import { Group } from "@mantine/core" +import { LuSparkles } from "react-icons/lu" + +export const GeneratorOverview = () => { + return ( + + + + } radius="lg">New Generator + + +
+ +
+
+ ) +} diff --git a/ui/src/router.tsx b/ui/src/router.tsx index b3af530..3a29c01 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -10,6 +10,10 @@ import { Links } from "./pages/Links"; import { Tasks } from "./pages/Tasks"; import { Tracks } from "./pages/Tracks"; import { Settings } from "./pages/Settings"; +import { GeneratorOverview } from "./pages/generator/GeneratorOverview"; +import { Generator } from "./pages/generator/Generator"; +import { GeneratorCreate } from "./pages/generator/GeneratorCreate"; +import { GeneratorEdit } from "./pages/generator/GeneratorEdit"; const root = createRootRouteWithContext()({ component: App, @@ -63,6 +67,34 @@ const setting = createRoute({ component: Settings, }) +// +// Generator +// + +const generator = createRoute({ + getParentRoute: () => index, + path: "/generator", + component: Generator, +}) + +const generatorOverview = createRoute({ + getParentRoute: () => generator, + path: "/", + component: GeneratorOverview, +}) + +const generatorCreate = createRoute({ + getParentRoute: () => generator, + path: "/create", + component: GeneratorCreate, +}) + +const generatorEdit = createRoute({ + getParentRoute: () => generator, + path: "/edit/$generatorId", + component: GeneratorEdit, +}) + const routeTree = root.addChildren([ index.addChildren([ home, @@ -72,6 +104,7 @@ const routeTree = root.addChildren([ task, history, setting, + generator.addChildren(([generatorOverview, generatorCreate, generatorEdit])), ]), ]) From 6afebbf17f1fa5fe755bc50db1165b3b2246e815 Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 1 Jan 2026 14:18:58 +0100 Subject: [PATCH 06/33] feat(generator): add api endpoint --- cmd/api/main.go | 3 + db/queries/history.sql | 13 +++- internal/database/model/generator.go | 16 ++++- internal/database/repository/history.go | 36 ++++++++++- internal/server/api/generator.go | 50 +++++++++++++++ internal/server/dto/generator.go | 20 ++++++ internal/server/server.go | 1 + internal/server/service/generator.go | 31 +++++++++ internal/server/service/track.go | 2 +- pkg/sqlc/history.sql.go | 83 +++++++++++++++++++++++-- 10 files changed, 243 insertions(+), 12 deletions(-) create mode 100644 internal/server/api/generator.go create mode 100644 internal/server/dto/generator.go create mode 100644 internal/server/service/generator.go diff --git a/cmd/api/main.go b/cmd/api/main.go index 4b3ea31..321b5c9 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/topvennie/sortifyr/internal/database/repository" + "github.com/topvennie/sortifyr/internal/generator" "github.com/topvennie/sortifyr/internal/server" "github.com/topvennie/sortifyr/internal/server/service" "github.com/topvennie/sortifyr/internal/spotify" @@ -52,6 +53,8 @@ func main() { zap.S().Fatalf("Failed to init the spotify package %v", err) } + generator.Init(*repo) + api := server.New(*service, db.Pool()) zap.S().Infof("Server is running on %s", api.Addr) diff --git a/db/queries/history.sql b/db/queries/history.sql index 78edc4d..4f697a9 100644 --- a/db/queries/history.sql +++ b/db/queries/history.sql @@ -6,7 +6,7 @@ WHERE h.played_at < $1 AND h.user_id = $2 ORDER BY h.played_at DESC LIMIT 1; --- name: HistoryGetPopulatedFiltered :many +-- name: HistoryGetPopulatedFilteredPaginated :many SELECT sqlc.embed(h), sqlc.embed(t), count(*) FILTER (WHERE h.user_id = $1::int AND (h.skipped = $7::boolean OR NOT @filter_play_count)) OVER (PARTITION BY h.track_id) AS play_count FROM history h LEFT JOIN tracks t ON t.id = h.track_id @@ -18,6 +18,17 @@ WHERE ORDER BY h.played_at DESC LIMIT $2 OFFSET $3; +-- name: HistoryGetPopulatedFiltered :many +SELECT sqlc.embed(h), sqlc.embed(t) +FROM history h +LEFT JOIN tracks t ON t.id = h.track_id +WHERE + h.user_id = $1::int AND + (h.played_at >= $2::timestamptz OR NOT @filter_start) AND + (h.played_at <= $3::timestamptz OR NOT @filter_end) AND + (h.skipped = $4::boolean OR NOT @filter_skipped) +ORDER BY h.played_at DESC; + -- name: HistoryGetSkippedNullPopulated :many SELECT sqlc.embed(h), sqlc.embed(t) FROM history h diff --git a/internal/database/model/generator.go b/internal/database/model/generator.go index d6ac3c7..3cae344 100644 --- a/internal/database/model/generator.go +++ b/internal/database/model/generator.go @@ -2,6 +2,7 @@ package model import ( "encoding/json" + "time" "github.com/topvennie/sortifyr/pkg/sqlc" ) @@ -15,7 +16,20 @@ const ( PresetOldTop GeneratorPreset = "old_top" ) -type GeneratorParameters struct{} +type GeneratorWindow struct { + Start time.Time + End time.Time + MinPlays int + BurstInterval time.Duration +} + +type GeneratorParameters struct { + UserID int + TrackAmount int + MinPlayCount int + PeakWindow GeneratorWindow + RecentWindow GeneratorWindow +} type Generator struct { ID int diff --git a/internal/database/repository/history.go b/internal/database/repository/history.go index c48d311..ce2ea7c 100644 --- a/internal/database/repository/history.go +++ b/internal/database/repository/history.go @@ -41,7 +41,7 @@ func (h *History) GetPreviousPopulated(ctx context.Context, userID int, playedAt return previousModel, nil } -func (h *History) GetPopulatedFiltered(ctx context.Context, filter model.HistoryFilter) ([]*model.History, error) { +func (h *History) GetPopulatedFilteredPaginated(ctx context.Context, filter model.HistoryFilter) ([]*model.History, error) { skipped := false if filter.Skipped != nil { skipped = *filter.Skipped @@ -52,7 +52,7 @@ func (h *History) GetPopulatedFiltered(ctx context.Context, filter model.History playCountSkipped = *filter.PlayCountSkipped } - history, err := h.repo.queries(ctx).HistoryGetPopulatedFiltered(ctx, sqlc.HistoryGetPopulatedFilteredParams{ + history, err := h.repo.queries(ctx).HistoryGetPopulatedFilteredPaginated(ctx, sqlc.HistoryGetPopulatedFilteredPaginatedParams{ Column1: int32(filter.UserID), Limit: int32(filter.Limit), Offset: int32(filter.Offset), @@ -72,7 +72,7 @@ func (h *History) GetPopulatedFiltered(ctx context.Context, filter model.History return nil, fmt.Errorf("get filtered populated history %+v | %w", filter, err) } - return utils.SliceMap(history, func(h sqlc.HistoryGetPopulatedFilteredRow) *model.History { + return utils.SliceMap(history, func(h sqlc.HistoryGetPopulatedFilteredPaginatedRow) *model.History { history := model.HistoryModel(h.History) history.Track = *model.TrackModel(h.Track) history.PlayCount = int(h.PlayCount) @@ -81,6 +81,36 @@ func (h *History) GetPopulatedFiltered(ctx context.Context, filter model.History }), nil } +func (h *History) GetPopulatedFiltered(ctx context.Context, filter model.HistoryFilter) ([]*model.History, error) { + skipped := false + if filter.Skipped != nil { + skipped = *filter.Skipped + } + + history, err := h.repo.queries(ctx).HistoryGetPopulatedFiltered(ctx, sqlc.HistoryGetPopulatedFilteredParams{ + Column1: int32(filter.UserID), + Column2: toTime(filter.Start), + FilterStart: !filter.Start.IsZero(), + Column3: toTime(filter.End), + FilterEnd: !filter.End.IsZero(), + Column4: skipped, + FilterSkipped: filter.Skipped != nil, + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get filtered populated history %+v | %w", filter, err) + } + + return utils.SliceMap(history, func(h sqlc.HistoryGetPopulatedFilteredRow) *model.History { + history := model.HistoryModel(h.History) + history.Track = *model.TrackModel(h.Track) + + return history + }), nil +} + func (h *History) GetSkippedUnknownPopulated(ctx context.Context, userID int) ([]*model.History, error) { skippeds, err := h.repo.queries(ctx).HistoryGetSkippedNullPopulated(ctx, int32(userID)) if err != nil { diff --git a/internal/server/api/generator.go b/internal/server/api/generator.go new file mode 100644 index 0000000..1867ae2 --- /dev/null +++ b/internal/server/api/generator.go @@ -0,0 +1,50 @@ +package api + +import ( + "github.com/gofiber/fiber/v2" + "github.com/topvennie/sortifyr/internal/server/dto" + "github.com/topvennie/sortifyr/internal/server/service" +) + +type Generator struct { + router fiber.Router + + generator service.Generator +} + +func NewGenerator(router fiber.Router, service service.Service) *Generator { + api := &Generator{ + router: router.Group("/generator"), + generator: *service.NewGenerator(), + } + + api.createRoutes() + + return api +} + +func (g *Generator) createRoutes() { + g.router.Post("/generate", g.generate) +} + +func (g *Generator) generate(c *fiber.Ctx) error { + userID, ok := c.Locals("userID").(int) + if !ok { + return fiber.ErrUnauthorized + } + + var generator dto.Generator + if err := c.BodyParser(&generator); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + if err := dto.Validate.Struct(generator); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + tracks, err := g.generator.Generate(c.Context(), userID, generator) + if err != nil { + return err + } + + return c.JSON(tracks) +} diff --git a/internal/server/dto/generator.go b/internal/server/dto/generator.go new file mode 100644 index 0000000..3d0f798 --- /dev/null +++ b/internal/server/dto/generator.go @@ -0,0 +1,20 @@ +package dto + +import "github.com/topvennie/sortifyr/internal/database/model" + +type GeneratorParameters struct { + TrackAmount int `json:"track_amount" validate:"min=0"` + MinPlayCount int `json:"min_play_count" validate:"min=0"` +} + +func (g GeneratorParameters) ToModel() model.GeneratorParameters { + return model.GeneratorParameters{ + TrackAmount: g.TrackAmount, + MinPlayCount: g.MinPlayCount, + } +} + +type Generator struct { + Preset model.GeneratorPreset `json:"preset" validate:"required"` + Params GeneratorParameters `json:"params"` +} diff --git a/internal/server/server.go b/internal/server/server.go index 7e78d16..3f10bfe 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -68,6 +68,7 @@ func New(service service.Service, pool *pgxpool.Pool) *Server { routers.NewLink(protectedAPI, service) routers.NewTask(protectedAPI, service) routers.NewTrack(protectedAPI, service) + routers.NewGenerator(protectedAPI, service) routers.NewSetting(protectedAPI, service) // Static files if served in production diff --git a/internal/server/service/generator.go b/internal/server/service/generator.go new file mode 100644 index 0000000..f31650c --- /dev/null +++ b/internal/server/service/generator.go @@ -0,0 +1,31 @@ +package service + +import ( + "context" + + "github.com/gofiber/fiber/v2" + "github.com/topvennie/sortifyr/internal/database/model" + "github.com/topvennie/sortifyr/internal/generator" + "github.com/topvennie/sortifyr/internal/server/dto" + "github.com/topvennie/sortifyr/pkg/utils" + "go.uber.org/zap" +) + +type Generator struct{} + +func (s *Service) NewGenerator() *Generator { + return &Generator{} +} + +func (g *Generator) Generate(ctx context.Context, userID int, gen dto.Generator) ([]dto.Track, error) { + params := gen.Params.ToModel() + params.UserID = userID + + tracks, err := generator.G.Generate(ctx, gen.Preset, params) + if err != nil { + zap.S().Error(err) + return nil, fiber.ErrInternalServerError + } + + return utils.SliceMap(tracks, func(t model.Track) dto.Track { return dto.TrackDTO(&t) }), nil +} diff --git a/internal/server/service/track.go b/internal/server/service/track.go index ddf93d3..04888cf 100644 --- a/internal/server/service/track.go +++ b/internal/server/service/track.go @@ -33,7 +33,7 @@ func (t *Track) GetHistory(ctx context.Context, filter dto.HistoryFilter) ([]dto filterModel.PlayCountSkipped = &tmp } - history, err := t.history.GetPopulatedFiltered(ctx, *filterModel) + history, err := t.history.GetPopulatedFilteredPaginated(ctx, *filterModel) if err != nil { zap.S().Error(err) return nil, fiber.ErrInternalServerError diff --git a/pkg/sqlc/history.sql.go b/pkg/sqlc/history.sql.go index f390f44..522f56e 100644 --- a/pkg/sqlc/history.sql.go +++ b/pkg/sqlc/history.sql.go @@ -87,6 +87,77 @@ func (q *Queries) HistoryDeleteUserOlder(ctx context.Context, arg HistoryDeleteU } const historyGetPopulatedFiltered = `-- name: HistoryGetPopulatedFiltered :many +SELECT h.id, h.user_id, h.track_id, h.played_at, h.album_id, h.artist_id, h.playlist_id, h.show_id, h.skipped, t.id, t.spotify_id, t.name, t.popularity, t.updated_at, t.duration_ms +FROM history h +LEFT JOIN tracks t ON t.id = h.track_id +WHERE + h.user_id = $1::int AND + (h.played_at >= $2::timestamptz OR NOT $5) AND + (h.played_at <= $3::timestamptz OR NOT $6) AND + (h.skipped = $4::boolean OR NOT $7) +ORDER BY h.played_at DESC +` + +type HistoryGetPopulatedFilteredParams struct { + Column1 int32 + Column2 pgtype.Timestamptz + Column3 pgtype.Timestamptz + Column4 bool + FilterStart interface{} + FilterEnd interface{} + FilterSkipped interface{} +} + +type HistoryGetPopulatedFilteredRow struct { + History History + Track Track +} + +func (q *Queries) HistoryGetPopulatedFiltered(ctx context.Context, arg HistoryGetPopulatedFilteredParams) ([]HistoryGetPopulatedFilteredRow, error) { + rows, err := q.db.Query(ctx, historyGetPopulatedFiltered, + arg.Column1, + arg.Column2, + arg.Column3, + arg.Column4, + arg.FilterStart, + arg.FilterEnd, + arg.FilterSkipped, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []HistoryGetPopulatedFilteredRow + for rows.Next() { + var i HistoryGetPopulatedFilteredRow + if err := rows.Scan( + &i.History.ID, + &i.History.UserID, + &i.History.TrackID, + &i.History.PlayedAt, + &i.History.AlbumID, + &i.History.ArtistID, + &i.History.PlaylistID, + &i.History.ShowID, + &i.History.Skipped, + &i.Track.ID, + &i.Track.SpotifyID, + &i.Track.Name, + &i.Track.Popularity, + &i.Track.UpdatedAt, + &i.Track.DurationMs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const historyGetPopulatedFilteredPaginated = `-- name: HistoryGetPopulatedFilteredPaginated :many SELECT h.id, h.user_id, h.track_id, h.played_at, h.album_id, h.artist_id, h.playlist_id, h.show_id, h.skipped, t.id, t.spotify_id, t.name, t.popularity, t.updated_at, t.duration_ms, count(*) FILTER (WHERE h.user_id = $1::int AND (h.skipped = $7::boolean OR NOT $8)) OVER (PARTITION BY h.track_id) AS play_count FROM history h LEFT JOIN tracks t ON t.id = h.track_id @@ -99,7 +170,7 @@ ORDER BY h.played_at DESC LIMIT $2 OFFSET $3 ` -type HistoryGetPopulatedFilteredParams struct { +type HistoryGetPopulatedFilteredPaginatedParams struct { Column1 int32 Limit int32 Offset int32 @@ -113,14 +184,14 @@ type HistoryGetPopulatedFilteredParams struct { FilterSkipped interface{} } -type HistoryGetPopulatedFilteredRow struct { +type HistoryGetPopulatedFilteredPaginatedRow struct { History History Track Track PlayCount int64 } -func (q *Queries) HistoryGetPopulatedFiltered(ctx context.Context, arg HistoryGetPopulatedFilteredParams) ([]HistoryGetPopulatedFilteredRow, error) { - rows, err := q.db.Query(ctx, historyGetPopulatedFiltered, +func (q *Queries) HistoryGetPopulatedFilteredPaginated(ctx context.Context, arg HistoryGetPopulatedFilteredPaginatedParams) ([]HistoryGetPopulatedFilteredPaginatedRow, error) { + rows, err := q.db.Query(ctx, historyGetPopulatedFilteredPaginated, arg.Column1, arg.Limit, arg.Offset, @@ -137,9 +208,9 @@ func (q *Queries) HistoryGetPopulatedFiltered(ctx context.Context, arg HistoryGe return nil, err } defer rows.Close() - var items []HistoryGetPopulatedFilteredRow + var items []HistoryGetPopulatedFilteredPaginatedRow for rows.Next() { - var i HistoryGetPopulatedFilteredRow + var i HistoryGetPopulatedFilteredPaginatedRow if err := rows.Scan( &i.History.ID, &i.History.UserID, From b8b9f79c6328e74f29cfab088204c0fda9a0cab9 Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 1 Jan 2026 14:44:07 +0100 Subject: [PATCH 07/33] chore(spotify): update interval tracks & artists 24 -> 48 h --- internal/spotify/artist.go | 2 +- internal/spotify/track.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/spotify/artist.go b/internal/spotify/artist.go index 73c466c..3e75c81 100644 --- a/internal/spotify/artist.go +++ b/internal/spotify/artist.go @@ -20,7 +20,7 @@ func (c *client) artistUpdate(ctx context.Context, user model.User) error { filtered := filterSpotify(filterSpotifyStruct[*model.Artist]{ Items: artistsDB, - Frequency: 24, + Frequency: 48, SpotifyID: func(a *model.Artist) string { return a.SpotifyID }, UpdatedAt: func(a *model.Artist) time.Time { return a.UpdatedAt }, }) diff --git a/internal/spotify/track.go b/internal/spotify/track.go index 3a7872f..b1cae18 100644 --- a/internal/spotify/track.go +++ b/internal/spotify/track.go @@ -20,7 +20,7 @@ func (c *client) trackUpdate(ctx context.Context, user model.User) error { filtered := filterSpotify(filterSpotifyStruct[*model.Track]{ Items: tracksDB, - Frequency: 24, + Frequency: 48, SpotifyID: func(t *model.Track) string { return t.SpotifyID }, UpdatedAt: func(t *model.Track) time.Time { return t.UpdatedAt }, }) From a6c7e34b85e50fcfc6f972f87cb5adfc148125a4 Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 1 Jan 2026 14:50:34 +0100 Subject: [PATCH 08/33] feat(generator): implement old top tracks --- internal/generator/generate.go | 19 ---- internal/generator/generator.go | 193 +++++++++++++++++++++++++++++++- internal/generator/util.go | 26 +++++ 3 files changed, 213 insertions(+), 25 deletions(-) delete mode 100644 internal/generator/generate.go create mode 100644 internal/generator/util.go diff --git a/internal/generator/generate.go b/internal/generator/generate.go deleted file mode 100644 index 7bb53d4..0000000 --- a/internal/generator/generate.go +++ /dev/null @@ -1,19 +0,0 @@ -package generator - -import "github.com/topvennie/sortifyr/internal/database/model" - -func custom(params model.GeneratorParameters) ([]model.Track, error) { - return nil, nil -} - -func forgotten(params model.GeneratorParameters) ([]model.Track, error) { - return nil, nil -} - -func top(params model.GeneratorParameters) ([]model.Track, error) { - return nil, nil -} - -func oldTop(params model.GeneratorParameters) ([]model.Track, error) { - return nil, nil -} diff --git a/internal/generator/generator.go b/internal/generator/generator.go index b9c0576..4d85b6c 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -1,22 +1,43 @@ // Package generator creates and maintaines playlists based on parameters and presets package generator -import "github.com/topvennie/sortifyr/internal/database/model" +import ( + "context" + "slices" + "strings" + "time" -func Generate(preset model.GeneratorPreset, params model.GeneratorParameters) ([]model.Track, error) { + "github.com/topvennie/sortifyr/internal/database/model" + "github.com/topvennie/sortifyr/internal/database/repository" + "github.com/topvennie/sortifyr/pkg/utils" +) + +type generator struct { + history repository.History +} + +var G *generator + +func Init(repo repository.Repository) { + G = &generator{ + history: *repo.NewHistory(), + } +} + +func (g *generator) Generate(ctx context.Context, preset model.GeneratorPreset, params model.GeneratorParameters) ([]model.Track, error) { var tracks []model.Track var err error switch preset { case model.PresetForgotten: - tracks, err = forgotten(params) + tracks, err = g.forgotten(params) case model.PresetTop: - tracks, err = top(params) + tracks, err = g.top(ctx, params) case model.PresetOldTop: - tracks, err = oldTop(params) + tracks, err = g.oldTop(ctx, params) default: - tracks, err = custom(params) + tracks, err = g.custom(params) } if err != nil { @@ -25,3 +46,163 @@ func Generate(preset model.GeneratorPreset, params model.GeneratorParameters) ([ return tracks, nil } + +type trackPlayCount struct { + track model.Track + playCount int +} + +func (g *generator) custom(params model.GeneratorParameters) ([]model.Track, error) { + return nil, nil +} + +func (g *generator) forgotten(params model.GeneratorParameters) ([]model.Track, error) { + return nil, nil +} + +func (g *generator) top(ctx context.Context, params model.GeneratorParameters) ([]model.Track, error) { + // Set default param values + if params.TrackAmount == 0 { + params.TrackAmount = 50 + } + if params.MinPlayCount == 0 { + params.MinPlayCount = 5 + } + + // Get history for last 14 days + skipped := false + history, err := g.history.GetPopulatedFiltered(ctx, model.HistoryFilter{ + UserID: params.UserID, + Start: time.Now().Add(-14 * 24 * time.Hour), + Skipped: &skipped, + }) + if err != nil { + return nil, err + } + + trackMap := make(map[int]trackPlayCount) + for _, h := range history { + track, ok := trackMap[h.TrackID] + if !ok { + track = trackPlayCount{track: h.Track, playCount: 0} + } + track.playCount++ + trackMap[h.TrackID] = track + } + + tracks := utils.MapValues(trackMap) + + tracks = utils.SliceFilter(tracks, func(t trackPlayCount) bool { return t.playCount > params.MinPlayCount }) + slices.SortFunc(tracks, func(a, b trackPlayCount) int { + if b.playCount == a.playCount { + return strings.Compare(a.track.Name, b.track.Name) + } + + return b.playCount - a.playCount + }) + tracks = tracks[:min(params.TrackAmount, len(tracks))] + slices.SortFunc(tracks, func(a, b trackPlayCount) int { return strings.Compare(a.track.Name, b.track.Name) }) + + return utils.SliceMap(tracks, func(t trackPlayCount) model.Track { return t.track }), nil +} + +func (g *generator) oldTop(ctx context.Context, params model.GeneratorParameters) ([]model.Track, error) { + // Set default param values + // This function assumes that the peak and recent windows fields are either fully populated or not at all + now := time.Now() + if params.PeakWindow.Start.IsZero() { + params.PeakWindow.Start = now.Add(-1 * 24 * 365 * time.Hour) // 365 days ago + params.PeakWindow.End = now.Add(-1 * 24 * 100 * time.Hour) // 100 days ago + params.PeakWindow.MinPlays = 5 + params.PeakWindow.BurstInterval = 24 * 14 * time.Hour // 14 days + } + if params.RecentWindow.Start.IsZero() { + params.RecentWindow.Start = now.Add(-1 * 24 * 14 * time.Hour) // 14 days ago + params.RecentWindow.End = now // now + params.RecentWindow.MinPlays = 2 + params.RecentWindow.BurstInterval = 24 * 14 * time.Hour // 14 days + } + + skipped := false + + // Get the relevant recent history + recent, err := g.history.GetPopulatedFiltered(ctx, model.HistoryFilter{ + UserID: params.UserID, + Start: params.RecentWindow.Start, + End: params.RecentWindow.End, + Skipped: &skipped, + }) + if err != nil { + return nil, err + } + + // Get all play times for each track + recentPlayedAt := make(map[int][]time.Time) + for _, r := range recent { + playedAt, ok := recentPlayedAt[r.TrackID] + if !ok { + playedAt = []time.Time{} + } + recentPlayedAt[r.TrackID] = append(playedAt, r.PlayedAt) + } + + // Get all relevant peak history + old, err := g.history.GetPopulatedFiltered(ctx, model.HistoryFilter{ + UserID: params.UserID, + Start: params.PeakWindow.Start, + End: params.PeakWindow.End, + Skipped: &skipped, + }) + if err != nil { + return nil, err + } + + // Get a map from track id to track + oldMap := make(map[int]model.Track) + for _, o := range old { + if _, ok := oldMap[o.TrackID]; !ok { + oldMap[o.TrackID] = o.Track + } + } + + // Get all play times for each track + oldPlayedAt := make(map[int][]time.Time) + for _, o := range old { + playedAt, ok := oldPlayedAt[o.TrackID] + if !ok { + playedAt = []time.Time{} + } + oldPlayedAt[o.TrackID] = append(playedAt, o.PlayedAt) + } + + trackMap := make(map[int]trackPlayCount) + for k, v := range oldPlayedAt { + // Did we play it too much recently? + if hasBurst(recentPlayedAt[k], params.RecentWindow.MinPlays, params.RecentWindow.BurstInterval) { + continue + } + + // Did we play it enough times in the past? + if !hasBurst(v, params.PeakWindow.MinPlays, params.PeakWindow.BurstInterval) { + continue + } + + trackMap[k] = trackPlayCount{ + track: oldMap[k], + playCount: len(oldPlayedAt[k]), + } + } + + tracks := utils.MapValues(trackMap) + slices.SortFunc(tracks, func(a, b trackPlayCount) int { + if b.playCount == a.playCount { + return strings.Compare(a.track.Name, b.track.Name) + } + + return b.playCount - a.playCount + }) + tracks = tracks[:min(params.TrackAmount, len(tracks))] + slices.SortFunc(tracks, func(a, b trackPlayCount) int { return strings.Compare(a.track.Name, b.track.Name) }) + + return utils.SliceMap(tracks, func(t trackPlayCount) model.Track { return t.track }), nil +} diff --git a/internal/generator/util.go b/internal/generator/util.go new file mode 100644 index 0000000..982eb83 --- /dev/null +++ b/internal/generator/util.go @@ -0,0 +1,26 @@ +package generator + +import ( + "slices" + "time" +) + +func hasBurst(times []time.Time, minPlays int, interval time.Duration) bool { + if len(times) < minPlays { + return false + } + + slices.SortFunc(times, func(a, b time.Time) int { return a.Compare(b) }) + + left := 0 + for right := range times { + for times[right].Sub(times[left]) > interval { + left++ + } + if right-left+1 >= minPlays { + return true + } + } + + return false +} From 6e723f3ede0e60bfbae8c7a8e056c360bcfeec57 Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 1 Jan 2026 23:55:22 +0100 Subject: [PATCH 09/33] feat(generator): complete db model --- .../20251231183248_add_generator_table.sql | 9 +- db/queries/generator.sql | 14 +- internal/database/model/generator.go | 74 +++++++--- internal/generator/generator.go | 137 +++++++++--------- internal/generator/normalize.go | 110 ++++++++++++++ internal/generator/util.go | 10 +- internal/server/api/generator.go | 12 +- internal/server/dto/generator.go | 125 ++++++++++++++-- internal/server/service/generator.go | 11 +- pkg/sqlc/generator.sql.go | 56 ++++++- pkg/sqlc/models.go | 55 +------ 11 files changed, 431 insertions(+), 182 deletions(-) create mode 100644 internal/generator/normalize.go diff --git a/db/migrations/20251231183248_add_generator_table.sql b/db/migrations/20251231183248_add_generator_table.sql index 17cd017..913ceeb 100644 --- a/db/migrations/20251231183248_add_generator_table.sql +++ b/db/migrations/20251231183248_add_generator_table.sql @@ -1,11 +1,12 @@ -- +goose Up -- +goose StatementBegin -CREATE TYPE generator_preset AS ENUM ('custom', 'forgotten', 'top', 'old_top'); - CREATE TABLE generators ( id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, name TEXT NOT NULL, - preset GENERATOR_PRESET NOT NULL, + description TEXT, + playlist_id INTEGER REFERENCES playlists (id), + maintained BOOL NOT NULL, parameters JSONB ); -- +goose StatementEnd @@ -13,6 +14,4 @@ CREATE TABLE generators ( -- +goose Down -- +goose StatementBegin DROP TABLE generators; - -DROP TYPE generator_preset; -- +goose StatementEnd diff --git a/db/queries/generator.sql b/db/queries/generator.sql index 751f5c6..880ed60 100644 --- a/db/queries/generator.sql +++ b/db/queries/generator.sql @@ -1,4 +1,14 @@ -- name: GeneratorCreate :one -INSERT INTO generators (name, preset, parameters) -VALUES ($1, $2, $3) +INSERT INTO generators (user_id, name, description, playlist_id, maintained, parameters) +VALUES ($1, $2, $3, $4, $5, $6) RETURNING id; + +-- name: GeneratorUpdate :exec +UPDATE generators +SET + name = coalesce(sqlc.narg('name'), name), + description = coalesce(sqlc.narg('description'), description), + playlist_id = coalesce(sqlc.narg('playlist_id'), playlist_id), + maintained = coalesce(sqlc.narg('maintained'), maintained), + parameters = coalesce(sqlc.narg('parameters'), parameters) +WHERE id = $1; diff --git a/internal/database/model/generator.go b/internal/database/model/generator.go index 3cae344..b1dc7ec 100644 --- a/internal/database/model/generator.go +++ b/internal/database/model/generator.go @@ -10,42 +10,68 @@ import ( type GeneratorPreset string const ( - PresetCustom GeneratorPreset = "custom" - PresetForgotten GeneratorPreset = "forgotten" - PresetTop GeneratorPreset = "top" - PresetOldTop GeneratorPreset = "old_top" + GeneratorPresetCustom GeneratorPreset = "custom" + GeneratorPresetForgotten GeneratorPreset = "forgotten" + GeneratorPresetTop GeneratorPreset = "top" + GeneratorPresetOldTop GeneratorPreset = "old_top" ) +// We need json tags because the params are saved as jsonb + type GeneratorWindow struct { - Start time.Time - End time.Time - MinPlays int - BurstInterval time.Duration + Start time.Time `json:"start"` + End time.Time `json:"end"` + MinPlays int `json:"min_plays"` + BurstInterval time.Duration `json:"burst_interval"` +} + +type GeneratorPresetCustomParams struct{} + +type GeneratorPresetForgottenParams struct{} + +type GeneratorPresetTopParams struct { + Window GeneratorWindow `json:"window"` } -type GeneratorParameters struct { - UserID int - TrackAmount int - MinPlayCount int - PeakWindow GeneratorWindow - RecentWindow GeneratorWindow +type GeneratorPresetOldTopParams struct { + PeakWindow GeneratorWindow `json:"peak_window"` + RecentWindow GeneratorWindow `json:"recent_window"` +} + +type GeneratorParams struct { + TrackAmount int `json:"track_amount"` + ExcludedPlaylistIDs []int `json:"excluded_playlist_ids"` + ExcludedTrackIDs []int `json:"excluded_track_ids"` + + Preset GeneratorPreset `json:"preset"` + + ParamsCustom *GeneratorPresetCustomParams `json:"params_custom,omitzero"` + ParamsForgotten *GeneratorPresetForgottenParams `json:"params_forgotten,omitzero"` + ParamsTop *GeneratorPresetTopParams `json:"params_top,omitzero"` + ParamsOldTop *GeneratorPresetOldTopParams `json:"params_old_top,omitzero"` } type Generator struct { - ID int - Name string - Preset GeneratorPreset - Parameters GeneratorParameters + ID int + UserID int + Name string + Description string + PlaylistID int + Maintained bool + Params GeneratorParams } func GeneratorModel(g sqlc.Generator) *Generator { - parameters := GeneratorParameters{} - _ = json.Unmarshal(g.Parameters, ¶meters) + params := GeneratorParams{} + _ = json.Unmarshal(g.Parameters, ¶ms) //nolint:errcheck // Data controlled by us, it'll be fine return &Generator{ - ID: int(g.ID), - Name: g.Name, - Preset: GeneratorPreset(g.Preset), - Parameters: parameters, + ID: int(g.ID), + UserID: int(g.UserID), + Name: g.Name, + Description: fromString(g.Description), + PlaylistID: fromInt(g.PlaylistID), + Maintained: g.Maintained, + Params: params, } } diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 4d85b6c..3818e51 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -24,20 +24,22 @@ func Init(repo repository.Repository) { } } -func (g *generator) Generate(ctx context.Context, preset model.GeneratorPreset, params model.GeneratorParameters) ([]model.Track, error) { +func (g *generator) Generate(ctx context.Context, gen model.Generator) ([]model.Track, error) { var tracks []model.Track var err error - switch preset { - case model.PresetForgotten: - tracks, err = g.forgotten(params) - case model.PresetTop: - tracks, err = g.top(ctx, params) - case model.PresetOldTop: - tracks, err = g.oldTop(ctx, params) + normalize(&gen.Params) + + switch gen.Params.Preset { + case model.GeneratorPresetForgotten: + tracks, err = g.forgotten(gen) + case model.GeneratorPresetTop: + tracks, err = g.top(ctx, gen) + case model.GeneratorPresetOldTop: + tracks, err = g.oldTop(ctx, gen) default: - tracks, err = g.custom(params) + tracks, err = g.custom(gen) } if err != nil { @@ -52,47 +54,55 @@ type trackPlayCount struct { playCount int } -func (g *generator) custom(params model.GeneratorParameters) ([]model.Track, error) { +func (g *generator) custom(gen model.Generator) ([]model.Track, error) { return nil, nil } -func (g *generator) forgotten(params model.GeneratorParameters) ([]model.Track, error) { +func (g *generator) forgotten(gen model.Generator) ([]model.Track, error) { return nil, nil } -func (g *generator) top(ctx context.Context, params model.GeneratorParameters) ([]model.Track, error) { - // Set default param values - if params.TrackAmount == 0 { - params.TrackAmount = 50 - } - if params.MinPlayCount == 0 { - params.MinPlayCount = 5 - } +func (g *generator) top(ctx context.Context, gen model.Generator) ([]model.Track, error) { + params := gen.Params.ParamsTop // Get history for last 14 days skipped := false history, err := g.history.GetPopulatedFiltered(ctx, model.HistoryFilter{ - UserID: params.UserID, - Start: time.Now().Add(-14 * 24 * time.Hour), + UserID: gen.UserID, + Start: params.Window.Start, Skipped: &skipped, }) if err != nil { return nil, err } - trackMap := make(map[int]trackPlayCount) + playedAts := make(map[int][]time.Time) for _, h := range history { - track, ok := trackMap[h.TrackID] + playedAt, ok := playedAts[h.TrackID] if !ok { - track = trackPlayCount{track: h.Track, playCount: 0} + playedAt = []time.Time{} } - track.playCount++ - trackMap[h.TrackID] = track + playedAt = append(playedAt, h.PlayedAt) + playedAts[h.TrackID] = playedAt } - tracks := utils.MapValues(trackMap) + tracks := make([]trackPlayCount, 0) + seen := make(map[int]bool) + for _, h := range history { + if ok := seen[h.TrackID]; ok { + continue + } + + seen[h.TrackID] = true + + if hasBurst(playedAts[h.TrackID], params.Window) { + tracks = append(tracks, trackPlayCount{ + track: h.Track, + playCount: len(playedAts[h.TrackID]), + }) + } + } - tracks = utils.SliceFilter(tracks, func(t trackPlayCount) bool { return t.playCount > params.MinPlayCount }) slices.SortFunc(tracks, func(a, b trackPlayCount) int { if b.playCount == a.playCount { return strings.Compare(a.track.Name, b.track.Name) @@ -100,34 +110,19 @@ func (g *generator) top(ctx context.Context, params model.GeneratorParameters) ( return b.playCount - a.playCount }) - tracks = tracks[:min(params.TrackAmount, len(tracks))] + tracks = tracks[:min(gen.Params.TrackAmount, len(tracks))] slices.SortFunc(tracks, func(a, b trackPlayCount) int { return strings.Compare(a.track.Name, b.track.Name) }) return utils.SliceMap(tracks, func(t trackPlayCount) model.Track { return t.track }), nil } -func (g *generator) oldTop(ctx context.Context, params model.GeneratorParameters) ([]model.Track, error) { - // Set default param values - // This function assumes that the peak and recent windows fields are either fully populated or not at all - now := time.Now() - if params.PeakWindow.Start.IsZero() { - params.PeakWindow.Start = now.Add(-1 * 24 * 365 * time.Hour) // 365 days ago - params.PeakWindow.End = now.Add(-1 * 24 * 100 * time.Hour) // 100 days ago - params.PeakWindow.MinPlays = 5 - params.PeakWindow.BurstInterval = 24 * 14 * time.Hour // 14 days - } - if params.RecentWindow.Start.IsZero() { - params.RecentWindow.Start = now.Add(-1 * 24 * 14 * time.Hour) // 14 days ago - params.RecentWindow.End = now // now - params.RecentWindow.MinPlays = 2 - params.RecentWindow.BurstInterval = 24 * 14 * time.Hour // 14 days - } - +func (g *generator) oldTop(ctx context.Context, gen model.Generator) ([]model.Track, error) { + params := gen.Params.ParamsOldTop skipped := false // Get the relevant recent history recent, err := g.history.GetPopulatedFiltered(ctx, model.HistoryFilter{ - UserID: params.UserID, + UserID: gen.UserID, Start: params.RecentWindow.Start, End: params.RecentWindow.End, Skipped: &skipped, @@ -137,18 +132,18 @@ func (g *generator) oldTop(ctx context.Context, params model.GeneratorParameters } // Get all play times for each track - recentPlayedAt := make(map[int][]time.Time) + recentPlayedAts := make(map[int][]time.Time) for _, r := range recent { - playedAt, ok := recentPlayedAt[r.TrackID] + playedAt, ok := recentPlayedAts[r.TrackID] if !ok { playedAt = []time.Time{} } - recentPlayedAt[r.TrackID] = append(playedAt, r.PlayedAt) + recentPlayedAts[r.TrackID] = append(playedAt, r.PlayedAt) } // Get all relevant peak history old, err := g.history.GetPopulatedFiltered(ctx, model.HistoryFilter{ - UserID: params.UserID, + UserID: gen.UserID, Start: params.PeakWindow.Start, End: params.PeakWindow.End, Skipped: &skipped, @@ -157,43 +152,41 @@ func (g *generator) oldTop(ctx context.Context, params model.GeneratorParameters return nil, err } - // Get a map from track id to track - oldMap := make(map[int]model.Track) - for _, o := range old { - if _, ok := oldMap[o.TrackID]; !ok { - oldMap[o.TrackID] = o.Track - } - } - // Get all play times for each track - oldPlayedAt := make(map[int][]time.Time) + oldPlayedAts := make(map[int][]time.Time) for _, o := range old { - playedAt, ok := oldPlayedAt[o.TrackID] + playedAt, ok := oldPlayedAts[o.TrackID] if !ok { playedAt = []time.Time{} } - oldPlayedAt[o.TrackID] = append(playedAt, o.PlayedAt) + oldPlayedAts[o.TrackID] = append(playedAt, o.PlayedAt) } - trackMap := make(map[int]trackPlayCount) - for k, v := range oldPlayedAt { + tracks := make([]trackPlayCount, 0) + seen := make(map[int]bool) + for _, o := range old { + if ok := seen[o.TrackID]; ok { + continue + } + + seen[o.TrackID] = true + // Did we play it too much recently? - if hasBurst(recentPlayedAt[k], params.RecentWindow.MinPlays, params.RecentWindow.BurstInterval) { + if hasBurst(recentPlayedAts[o.TrackID], params.RecentWindow) { continue } // Did we play it enough times in the past? - if !hasBurst(v, params.PeakWindow.MinPlays, params.PeakWindow.BurstInterval) { + if !hasBurst(oldPlayedAts[o.TrackID], params.PeakWindow) { continue } - trackMap[k] = trackPlayCount{ - track: oldMap[k], - playCount: len(oldPlayedAt[k]), - } + tracks = append(tracks, trackPlayCount{ + track: o.Track, + playCount: len(oldPlayedAts[o.TrackID]), + }) } - tracks := utils.MapValues(trackMap) slices.SortFunc(tracks, func(a, b trackPlayCount) int { if b.playCount == a.playCount { return strings.Compare(a.track.Name, b.track.Name) @@ -201,7 +194,7 @@ func (g *generator) oldTop(ctx context.Context, params model.GeneratorParameters return b.playCount - a.playCount }) - tracks = tracks[:min(params.TrackAmount, len(tracks))] + tracks = tracks[:min(gen.Params.TrackAmount, len(tracks))] slices.SortFunc(tracks, func(a, b trackPlayCount) int { return strings.Compare(a.track.Name, b.track.Name) }) return utils.SliceMap(tracks, func(t trackPlayCount) model.Track { return t.track }), nil diff --git a/internal/generator/normalize.go b/internal/generator/normalize.go new file mode 100644 index 0000000..0e0796b --- /dev/null +++ b/internal/generator/normalize.go @@ -0,0 +1,110 @@ +package generator + +import ( + "time" + + "github.com/topvennie/sortifyr/internal/database/model" +) + +// normalize sets default values for the parameters +func normalize(params *model.GeneratorParams) { + if params.TrackAmount == 0 { + params.TrackAmount = 50 + } + + switch params.Preset { + case model.GeneratorPresetCustom: + normalizePresetCustom(params) + params.ParamsForgotten = nil + params.ParamsTop = nil + params.ParamsOldTop = nil + case model.GeneratorPresetForgotten: + normalizePresetForgotten(params) + params.ParamsCustom = nil + params.ParamsTop = nil + params.ParamsOldTop = nil + case model.GeneratorPresetTop: + normalizePresetTop(params) + params.ParamsCustom = nil + params.ParamsForgotten = nil + params.ParamsOldTop = nil + case model.GeneratorPresetOldTop: + normalizePresetOldTop(params) + params.ParamsCustom = nil + params.ParamsForgotten = nil + params.ParamsTop = nil + } +} + +func normalizePresetCustom(params *model.GeneratorParams) { +} + +func normalizePresetForgotten(params *model.GeneratorParams) { +} + +func normalizePresetTop(params *model.GeneratorParams) { + now := time.Now() + + defaultParams := model.GeneratorPresetTopParams{ + Window: model.GeneratorWindow{ + Start: now.Add(-14 * 24 * time.Hour), // 14 days ago + End: now, + MinPlays: 5, + BurstInterval: 14 * 24 * time.Hour, // 14 days + }, + } + + if params.ParamsTop == nil { + params.ParamsTop = &defaultParams + return + } + + normalizeWindow(¶ms.ParamsTop.Window, defaultParams.Window) +} + +func normalizePresetOldTop(params *model.GeneratorParams) { + now := time.Now() + + defaultParams := model.GeneratorPresetOldTopParams{ + PeakWindow: model.GeneratorWindow{ + Start: now.Add(-1 * 24 * 365 * time.Hour), // 365 days ago + End: now.Add(-1 * 24 * 100 * time.Hour), // 100 days ago + MinPlays: 5, + BurstInterval: 24 * 14 * time.Hour, // 14 days + }, + RecentWindow: model.GeneratorWindow{ + Start: now.Add(-1 * 24 * 14 * time.Hour), // 14 days ago + End: now, // now + MinPlays: 2, + BurstInterval: 24 * 14 * time.Hour, // 14 days + }, + } + + if params.ParamsOldTop == nil { + params.ParamsOldTop = &defaultParams + return + } + + normalizeWindow(¶ms.ParamsOldTop.PeakWindow, defaultParams.PeakWindow) + normalizeWindow(¶ms.ParamsOldTop.RecentWindow, defaultParams.RecentWindow) +} + +func normalizeWindow(window *model.GeneratorWindow, normalized model.GeneratorWindow) { + if window.Start.IsZero() { + window.Start = normalized.Start + } + if window.End.IsZero() { + window.End = normalized.End + } + if window.End.Before(window.Start) { + window.End = window.Start + } + + if window.MinPlays == 0 { + window.MinPlays = normalized.MinPlays + } + + if window.BurstInterval == 0 { + window.BurstInterval = normalized.BurstInterval + } +} diff --git a/internal/generator/util.go b/internal/generator/util.go index 982eb83..1f00541 100644 --- a/internal/generator/util.go +++ b/internal/generator/util.go @@ -3,10 +3,12 @@ package generator import ( "slices" "time" + + "github.com/topvennie/sortifyr/internal/database/model" ) -func hasBurst(times []time.Time, minPlays int, interval time.Duration) bool { - if len(times) < minPlays { +func hasBurst(times []time.Time, window model.GeneratorWindow) bool { + if len(times) < window.MinPlays { return false } @@ -14,10 +16,10 @@ func hasBurst(times []time.Time, minPlays int, interval time.Duration) bool { left := 0 for right := range times { - for times[right].Sub(times[left]) > interval { + for times[right].Sub(times[left]) > window.BurstInterval { left++ } - if right-left+1 >= minPlays { + if right-left+1 >= window.MinPlays { return true } } diff --git a/internal/server/api/generator.go b/internal/server/api/generator.go index 1867ae2..4f511a9 100644 --- a/internal/server/api/generator.go +++ b/internal/server/api/generator.go @@ -24,24 +24,24 @@ func NewGenerator(router fiber.Router, service service.Service) *Generator { } func (g *Generator) createRoutes() { - g.router.Post("/generate", g.generate) + g.router.Post("/preview", g.preview) } -func (g *Generator) generate(c *fiber.Ctx) error { +func (g *Generator) preview(c *fiber.Ctx) error { userID, ok := c.Locals("userID").(int) if !ok { return fiber.ErrUnauthorized } - var generator dto.Generator - if err := c.BodyParser(&generator); err != nil { + var generatorParams dto.GeneratorParams + if err := c.BodyParser(&generatorParams); err != nil { return fiber.NewError(fiber.StatusBadRequest, err.Error()) } - if err := dto.Validate.Struct(generator); err != nil { + if err := dto.Validate.Struct(generatorParams); err != nil { return fiber.NewError(fiber.StatusBadRequest, err.Error()) } - tracks, err := g.generator.Generate(c.Context(), userID, generator) + tracks, err := g.generator.Preview(c.Context(), userID, generatorParams) if err != nil { return err } diff --git a/internal/server/dto/generator.go b/internal/server/dto/generator.go index 3d0f798..85a8644 100644 --- a/internal/server/dto/generator.go +++ b/internal/server/dto/generator.go @@ -1,20 +1,125 @@ package dto -import "github.com/topvennie/sortifyr/internal/database/model" +import ( + "time" -type GeneratorParameters struct { - TrackAmount int `json:"track_amount" validate:"min=0"` - MinPlayCount int `json:"min_play_count" validate:"min=0"` + "github.com/topvennie/sortifyr/internal/database/model" +) + +type GeneratorWindow struct { + Start time.Time `json:"start"` + End time.Time `json:"end"` + MinPlays int `json:"min_plays" validate:"min=0"` + BurstIntervalS time.Duration `json:"burst_interval_s"` } -func (g GeneratorParameters) ToModel() model.GeneratorParameters { - return model.GeneratorParameters{ - TrackAmount: g.TrackAmount, - MinPlayCount: g.MinPlayCount, +func GeneratorWindowDTO(g model.GeneratorWindow) GeneratorWindow { + return GeneratorWindow{ + Start: g.Start, + End: g.End, + MinPlays: g.MinPlays, + BurstIntervalS: g.BurstInterval / 1000000, } } -type Generator struct { +func (g GeneratorWindow) ToModel() *model.GeneratorWindow { + return &model.GeneratorWindow{ + Start: g.Start, + End: g.End, + MinPlays: g.MinPlays, + BurstInterval: g.BurstIntervalS * time.Second, + } +} + +type GeneratorPresetCustomParams struct{} + +func (g GeneratorPresetCustomParams) ToModel() *model.GeneratorPresetCustomParams { + return &model.GeneratorPresetCustomParams{} +} + +type GeneratorPresetForgottenParams struct{} + +func (g GeneratorPresetForgottenParams) ToModel() *model.GeneratorPresetForgottenParams { + return &model.GeneratorPresetForgottenParams{} +} + +type GeneratorPresetTopParams struct { + Window GeneratorWindow `json:"window"` +} + +func (g GeneratorPresetTopParams) ToModel() *model.GeneratorPresetTopParams { + return &model.GeneratorPresetTopParams{ + Window: *g.Window.ToModel(), + } +} + +type GeneratorPresetOldTopParams struct { + PeakWindow GeneratorWindow `json:"peak_window"` + RecentWindow GeneratorWindow `json:"recent_window"` +} + +func (g GeneratorPresetOldTopParams) ToModel() *model.GeneratorPresetOldTopParams { + return &model.GeneratorPresetOldTopParams{ + PeakWindow: *g.PeakWindow.ToModel(), + RecentWindow: *g.RecentWindow.ToModel(), + } +} + +type GeneratorParams struct { + TrackAmount int `json:"track_amount" validate:"min=0"` + ExcludedPlaylistIDs []int `json:"excluded_playlist_ids,omitzero"` + ExcludedTrackIDs []int `json:"excluded_track_ids,omitzero"` + Preset model.GeneratorPreset `json:"preset" validate:"required"` - Params GeneratorParameters `json:"params"` + + ParamsCustom *GeneratorPresetCustomParams `json:"params_custom,omitzero"` + ParamsForgotten *GeneratorPresetForgottenParams `json:"params_forgotten,omitzero"` + ParamsTop *GeneratorPresetTopParams `json:"params_top,omitzero"` + ParamsOldTop *GeneratorPresetOldTopParams `json:"params_old_top,omitzero"` +} + +func (g GeneratorParams) ToModel() model.GeneratorParams { + var paramsCustom *model.GeneratorPresetCustomParams + if g.ParamsCustom != nil { + paramsCustom = g.ParamsCustom.ToModel() + } + var paramsForgotten *model.GeneratorPresetForgottenParams + if g.ParamsForgotten != nil { + paramsForgotten = g.ParamsForgotten.ToModel() + } + var paramsTop *model.GeneratorPresetTopParams + if g.ParamsTop != nil { + paramsTop = g.ParamsTop.ToModel() + } + var paramsOldTop *model.GeneratorPresetOldTopParams + if g.ParamsOldTop != nil { + paramsOldTop = g.ParamsOldTop.ToModel() + } + return model.GeneratorParams{ + TrackAmount: g.TrackAmount, + ExcludedPlaylistIDs: g.ExcludedPlaylistIDs, + ExcludedTrackIDs: g.ExcludedTrackIDs, + Preset: g.Preset, + ParamsCustom: paramsCustom, + ParamsForgotten: paramsForgotten, + ParamsTop: paramsTop, + ParamsOldTop: paramsOldTop, + } +} + +type Generator struct { + ID int `json:"id"` + Name string `json:"name" validate:"required"` + Description string `json:"description,omitzero"` + Params GeneratorParams `json:"params" validate:"required"` +} + +func (g Generator) ToModel(userID int) *model.Generator { + return &model.Generator{ + ID: g.ID, + UserID: userID, + Name: g.Name, + Description: g.Description, + Params: g.Params.ToModel(), + } } diff --git a/internal/server/service/generator.go b/internal/server/service/generator.go index f31650c..65295c9 100644 --- a/internal/server/service/generator.go +++ b/internal/server/service/generator.go @@ -17,11 +17,12 @@ func (s *Service) NewGenerator() *Generator { return &Generator{} } -func (g *Generator) Generate(ctx context.Context, userID int, gen dto.Generator) ([]dto.Track, error) { - params := gen.Params.ToModel() - params.UserID = userID - - tracks, err := generator.G.Generate(ctx, gen.Preset, params) +func (g *Generator) Preview(ctx context.Context, userID int, params dto.GeneratorParams) ([]dto.Track, error) { + gen := model.Generator{ + UserID: userID, + Params: params.ToModel(), + } + tracks, err := generator.G.Generate(ctx, gen) if err != nil { zap.S().Error(err) return nil, fiber.ErrInternalServerError diff --git a/pkg/sqlc/generator.sql.go b/pkg/sqlc/generator.sql.go index a08b4df..7ae9104 100644 --- a/pkg/sqlc/generator.sql.go +++ b/pkg/sqlc/generator.sql.go @@ -7,23 +7,67 @@ package sqlc import ( "context" + + "github.com/jackc/pgx/v5/pgtype" ) const generatorCreate = `-- name: GeneratorCreate :one -INSERT INTO generators (name, preset, parameters) -VALUES ($1, $2, $3) +INSERT INTO generators (user_id, name, description, playlist_id, maintained, parameters) +VALUES ($1, $2, $3, $4, $5, $6) RETURNING id ` type GeneratorCreateParams struct { - Name string - Preset GeneratorPreset - Parameters []byte + UserID int32 + Name string + Description pgtype.Text + PlaylistID pgtype.Int4 + Maintained bool + Parameters []byte } func (q *Queries) GeneratorCreate(ctx context.Context, arg GeneratorCreateParams) (int32, error) { - row := q.db.QueryRow(ctx, generatorCreate, arg.Name, arg.Preset, arg.Parameters) + row := q.db.QueryRow(ctx, generatorCreate, + arg.UserID, + arg.Name, + arg.Description, + arg.PlaylistID, + arg.Maintained, + arg.Parameters, + ) var id int32 err := row.Scan(&id) return id, err } + +const generatorUpdate = `-- name: GeneratorUpdate :exec +UPDATE generators +SET + name = coalesce($2, name), + description = coalesce($3, description), + playlist_id = coalesce($4, playlist_id), + maintained = coalesce($5, maintained), + parameters = coalesce($6, parameters) +WHERE id = $1 +` + +type GeneratorUpdateParams struct { + ID int32 + Name pgtype.Text + Description pgtype.Text + PlaylistID pgtype.Int4 + Maintained pgtype.Bool + Parameters []byte +} + +func (q *Queries) GeneratorUpdate(ctx context.Context, arg GeneratorUpdateParams) error { + _, err := q.db.Exec(ctx, generatorUpdate, + arg.ID, + arg.Name, + arg.Description, + arg.PlaylistID, + arg.Maintained, + arg.Parameters, + ) + return err +} diff --git a/pkg/sqlc/models.go b/pkg/sqlc/models.go index 9deaf40..a5b0efc 100644 --- a/pkg/sqlc/models.go +++ b/pkg/sqlc/models.go @@ -11,50 +11,6 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -type GeneratorPreset string - -const ( - GeneratorPresetCustom GeneratorPreset = "custom" - GeneratorPresetForgotten GeneratorPreset = "forgotten" - GeneratorPresetTop GeneratorPreset = "top" - GeneratorPresetOldTop GeneratorPreset = "old_top" -) - -func (e *GeneratorPreset) Scan(src interface{}) error { - switch s := src.(type) { - case []byte: - *e = GeneratorPreset(s) - case string: - *e = GeneratorPreset(s) - default: - return fmt.Errorf("unsupported scan type for GeneratorPreset: %T", src) - } - return nil -} - -type NullGeneratorPreset struct { - GeneratorPreset GeneratorPreset - Valid bool // Valid is true if GeneratorPreset is not NULL -} - -// Scan implements the Scanner interface. -func (ns *NullGeneratorPreset) Scan(value interface{}) error { - if value == nil { - ns.GeneratorPreset, ns.Valid = "", false - return nil - } - ns.Valid = true - return ns.GeneratorPreset.Scan(value) -} - -// Value implements the driver Valuer interface. -func (ns NullGeneratorPreset) Value() (driver.Value, error) { - if !ns.Valid { - return nil, nil - } - return string(ns.GeneratorPreset), nil -} - type TaskResult string const ( @@ -146,10 +102,13 @@ type DirectoryPlaylist struct { } type Generator struct { - ID int32 - Name string - Preset GeneratorPreset - Parameters []byte + ID int32 + UserID int32 + Name string + Description pgtype.Text + PlaylistID pgtype.Int4 + Maintained bool + Parameters []byte } type History struct { From bd22ae9d81cb4df4d3222a38ee1264230e9bb1de Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 1 Jan 2026 23:55:39 +0100 Subject: [PATCH 10/33] chore(generator): rework api models in ui --- ui/src/components/generator/GeneratorForm.tsx | 113 ++++------ .../generator/GeneratorFormPreset.tsx | 193 ++++++++++++++++++ .../lib/api/{generator.tsx => generator.ts} | 6 +- ui/src/lib/types/api.d.ts | 27 +++ ui/src/lib/types/generator.ts | 97 ++++++++- ui/src/lib/utils.ts | 15 ++ 6 files changed, 361 insertions(+), 90 deletions(-) create mode 100644 ui/src/components/generator/GeneratorFormPreset.tsx rename ui/src/lib/api/{generator.tsx => generator.ts} (57%) diff --git a/ui/src/components/generator/GeneratorForm.tsx b/ui/src/components/generator/GeneratorForm.tsx index 22dddcc..6b9af4b 100644 --- a/ui/src/components/generator/GeneratorForm.tsx +++ b/ui/src/components/generator/GeneratorForm.tsx @@ -1,15 +1,11 @@ -import { useGeneratorGenerate } from "@/lib/api/generator"; -import { GeneratorPreset, generatorPresetString, generatorSchema, GeneratorSchema } from "@/lib/types/generator"; -import { ActionIcon, Divider, Group, NumberInput, Stack, Stepper } from "@mantine/core"; -import { useForm, UseFormReturnType } from "@mantine/form"; -import { useQueryClient } from "@tanstack/react-query"; +import { GeneratorPreset, generatorSchema, GeneratorSchema } from "@/lib/types/generator"; +import { daysAgo } from "@/lib/utils"; +import { Divider, Group, Stepper } from "@mantine/core"; +import { useForm } from "@mantine/form"; import { zod4Resolver } from "mantine-form-zod-resolver"; import { useState } from "react"; -import { LuRotateCcw } from "react-icons/lu"; import { Button } from "../atoms/Button"; -import { SectionTitle } from "../atoms/Page"; -import { Table } from "../molecules/Table"; -import { notifications } from "@mantine/notifications"; +import { GeneratorFormPreset } from "./GeneratorFormPreset"; const maxSteps = 3 @@ -18,20 +14,47 @@ export const GeneratorForm = () => { const form = useForm({ initialValues: { - preset: GeneratorPreset.Top, + name: "", + description: undefined, params: { trackAmount: 50, - minPlayCount: 5, - }, + excludedPlaylistIds: undefined, + excludedTrackIds: undefined, + preset: GeneratorPreset.Top, + paramsCustom: {}, + paramsForgotten: {}, + paramsTop: { + window: { + start: daysAgo(14), // 14 days ago + end: new Date(), + minPlays: 5, + burstIntervalS: 14 * 24 * 60 * 60 // 14 days + }, + }, + paramsOldTop: { + peakWindow: { + start: daysAgo(365), // 365 days ago + end: daysAgo(100), // 100 days ago + minPlays: 5, + burstIntervalS: 14 * 24 * 60 * 60 // 14 days + }, + recentWindow: { + start: daysAgo(14), // 14 days ago + end: new Date(), + minPlays: 5, + burstIntervalS: 14 * 24 * 60 * 60 // 14 days + } + } + } }, validate: zod4Resolver(generatorSchema), }) return (
- + - + @@ -50,65 +73,3 @@ export const GeneratorForm = () => { ) } -const Preset = ({ form }: { form: UseFormReturnType }) => { - const { data: tracks, isLoading, isRefetching } = useGeneratorGenerate(form.values) - const queryClient = useQueryClient() - - const handleClickPreset = (p: GeneratorPreset) => { - form.setFieldValue("preset", p) - } - - const handleRefetchTracks = () => { - if (form.validate().hasErrors) { - notifications.show({ color: "red", message: "Some parameters are invalid" }) - return - } - - queryClient.invalidateQueries({ queryKey: ["generator", "generate"] }) - } - - return ( - - - - -

Choose a preset

- - {Object.values(GeneratorPreset).map(p => ( - - ))} - -
- - -

Configure parameters

- - - - -
- - - -

Preview

- - - -
-
- - - ) -} diff --git a/ui/src/components/generator/GeneratorFormPreset.tsx b/ui/src/components/generator/GeneratorFormPreset.tsx new file mode 100644 index 0000000..2b4fc5a --- /dev/null +++ b/ui/src/components/generator/GeneratorFormPreset.tsx @@ -0,0 +1,193 @@ +import { useGeneratorPreview } from "@/lib/api/generator" +import { GeneratorPreset, generatorPresetString, GeneratorSchema, GeneratorWindowSchema } from "@/lib/types/generator" +import { getValueByPath } from "@/lib/utils" +import { ActionIcon, Alert, Group, NumberInput, Stack } from "@mantine/core" +import { DatePickerInput, DatesRangeValue } from "@mantine/dates" +import { UseFormReturnType } from "@mantine/form" +import { notifications } from "@mantine/notifications" +import { useQueryClient } from "@tanstack/react-query" +import { ReactNode, useState } from "react" +import { LuRotateCcw } from "react-icons/lu" +import { Button } from "../atoms/Button" +import { SectionTitle } from "../atoms/Page" +import { Table } from "../molecules/Table" + +type Props = { + form: UseFormReturnType +} + +export const GeneratorFormPreset = ({ form }: Props) => { + const { data: tracks, isLoading, isRefetching } = useGeneratorPreview(form.values) + const queryClient = useQueryClient() + + const handleClickPreset = (p: GeneratorPreset) => { + form.setFieldValue("params.preset", p) + } + + const handleRefetchTracks = () => { + if (form.validateField("params").hasError) { + notifications.show({ color: "red", message: "Some parameters are invalid" }) + console.error(form.errors) + return + } + + queryClient.invalidateQueries({ queryKey: ["generator", "preview"] }) + } + + const getPresetArguments = (preset: GeneratorPreset) => { + switch (preset) { + case GeneratorPreset.Forgotten: + return + case GeneratorPreset.Top: + return + case GeneratorPreset.OldTop: + return + default: + return + } + } + + const [presetArguments, setPresetArguments] = useState(getPresetArguments(form.values.params?.preset ?? GeneratorPreset.Custom)) + form.watch("params.preset", ({ value }) => setPresetArguments(getPresetArguments(value as GeneratorPreset))) + + return ( + + + + +

Choose a preset

+ + {Object.values(GeneratorPreset).map(p => ( + + ))} + + {presetArguments} +
+ + +

General parameters

+ + + +
+ + + +

Preview

+ + + +
+
+ + + ) +} + +const Custom = ({ form }: { form: UseFormReturnType }) => { + return null +} + +const Forgotten = ({ form }: { form: UseFormReturnType }) => { + return null +} + +const Top = ({ form }: { form: UseFormReturnType }) => { + return ( + + + {`Top finds the tracks you're listening to the most right now. + +It looks at your listening history within the selected time range and includes tracks that were played at least the minimum number of times within the given interval.`} + + +

Evaluation window

+ +
+
+ ) +} + +const OldTop = ({ form }: { form: UseFormReturnType }) => { + return ( + + + {`Old Top finds tracks you used to listen to on repeat, but don’t play much anymore. + +It works in two steps: + +1. Historic window: + Finds tracks that were played at least the minimum number of times within the burst interval somewhere in the historic range. + +2. Recent window: + Filters out tracks that were still played frequently in the recent range. + +Example: +If the historic range is 6 months, the minimum plays is 5, and the burst interval is 14 days, it will find tracks you played 5 or more times within any 14-day period during those 6 months — but only if you haven’t played them much recently.`} + + +

Historic listening window

+ +
+ +

Recent listening window

+ +
+
+ ) +} + +const Window = ({ form, path }: { form: UseFormReturnType, path: string }) => { + const window = getValueByPath(form.getValues(), path) + + const [range, setRange] = useState<[Date | null, Date | null]>([window?.start ?? null, window?.end ?? null]) + const [plays, setPlays] = useState(window?.minPlays ?? 0) + const [burst, setBurst] = useState((window?.burstIntervalS ?? 0) / (24 * 60 * 60)) + + const handleRangeChange = (r: DatesRangeValue) => { + form.setFieldValue(`${path}.start`, r[0] ?? undefined) + form.setFieldValue(`${path}.end`, r[1] ?? undefined) + + setRange(r) + } + + const handlePlayChange = (plays: number | string) => { + let newValue: number | undefined = undefined + if (typeof plays === 'number') { + newValue = plays + } + + form.setFieldValue(`${path}.minPlays`, newValue) + setPlays(plays) + } + + const handleIntervalChange = (days: number | string) => { + let newValue: number | undefined = undefined + if (typeof days === 'number') { + newValue = days * 24 * 60 * 60 + } + + form.setFieldValue(`${path}.burstIntervalS`, newValue) + setBurst(days) + } + + return ( + + + + + + ) +} diff --git a/ui/src/lib/api/generator.tsx b/ui/src/lib/api/generator.ts similarity index 57% rename from ui/src/lib/api/generator.tsx rename to ui/src/lib/api/generator.ts index b70e44d..eecb3cb 100644 --- a/ui/src/lib/api/generator.tsx +++ b/ui/src/lib/api/generator.ts @@ -5,10 +5,10 @@ import { convertTracks } from "../types/track" const ENDPOINT = "generator" -export const useGeneratorGenerate = (generator: GeneratorSchema) => { +export const useGeneratorPreview = (generator: GeneratorSchema) => { return useQuery({ - queryKey: ["generator", "generate"], - queryFn: async () => (await apiPost(`${ENDPOINT}/generate`, generator, convertTracks)).data, + queryKey: ["generator", "preview"], + queryFn: async () => (await apiPost(`${ENDPOINT}/preview`, generator.params, convertTracks)).data, staleTime: Infinity, throwOnError: true, }) diff --git a/ui/src/lib/types/api.d.ts b/ui/src/lib/types/api.d.ts index e024623..7d2636b 100644 --- a/ui/src/lib/types/api.d.ts +++ b/ui/src/lib/types/api.d.ts @@ -88,4 +88,31 @@ export namespace API { export interface TrackDuplicate extends Track { amount: number; } + + export interface GeneratorWindow { + start: string; + end: string; + min_plays: number; + burst_interval_s: number; + } + + export interface Generator extends Base { + name: string; + description?: string; + params: { + track_amount: number; + excluded_playlist_ids?: number[]; + excluded_track_ids?: number[]; + preset: string; + params_custom?: {}; + params_forgotten?: {}; + params_top?: { + window: GeneratorWindow; + }; + params_old_top?: { + peak_window: GeneratorWindow; + current_window: GeneratorWindow; + }; + } + } } diff --git a/ui/src/lib/types/generator.ts b/ui/src/lib/types/generator.ts index cb44bc6..7bc918b 100644 --- a/ui/src/lib/types/generator.ts +++ b/ui/src/lib/types/generator.ts @@ -1,5 +1,6 @@ import z from "zod"; import { JSONBody } from "./general"; +import { API } from "./api"; export enum GeneratorPreset { Custom = "custom", @@ -14,24 +15,98 @@ export const generatorPresetString: Record = { [GeneratorPreset.OldTop]: "Old Top", } -export interface GeneratorParams { - trackAmount: number; - minPlayCount: number; +export interface GeneratorWindow { + start: Date; + end: Date; + minPlays: number; + burstIntervalS: number; +} + +export const convertGeneratorWindow = (g: API.GeneratorWindow): GeneratorWindow => { + return { + start: new Date(g.start), + end: new Date(g.end), + minPlays: g.min_plays, + burstIntervalS: g.burst_interval_s, + } } export interface Generator { - preset: GeneratorPreset; - params: GeneratorParams; + id: number; + name: string; + description?: string; + params: { + trackAmount: number; + excludedPlaylistIds: number[]; + excludedTrackIds: number[]; + preset: GeneratorPreset; + paramsCustom?: {}; + paramsForgotten?: {}; + paramsTop?: { + window: GeneratorWindow; + }; + paramsOldTop?: { + peakWindow: GeneratorWindow; + recentWindow: GeneratorWindow; + }; + }; +} + +export const convertGenerator = (g: API.Generator): Generator => { + return { + id: g.id, + name: g.name, + description: g.description, + params: { + trackAmount: g.params.track_amount, + excludedPlaylistIds: g.params.excluded_playlist_ids ?? [], + excludedTrackIds: g.params.excluded_track_ids ?? [], + preset: g.params.preset as GeneratorPreset, + paramsCustom: g.params.params_custom, + paramsForgotten: g.params.params_forgotten, + paramsTop: g.params.params_top ? { + window: convertGeneratorWindow(g.params.params_top.window) + } : undefined, + paramsOldTop: g.params.params_old_top ? { + peakWindow: convertGeneratorWindow(g.params.params_old_top.peak_window), + recentWindow: convertGeneratorWindow(g.params.params_old_top.current_window), + } : undefined, + } + } +} + +export const convertGeneratorSchema = (g: Generator): GeneratorSchema => { + return { + ...g + } } -export const generatorParamsSchema = z.object({ - trackAmount: z.number().positive(), - minPlayCount: z.number().positive(), +export const generatorWindowSchema = z.object({ + start: z.date(), + end: z.date(), + minPlays: z.number().positive(), + burstIntervalS: z.number().positive(), }) -export type GeneratorParamsSchema = z.infer & JSONBody; +export type GeneratorWindowSchema = z.infer & JSONBody; export const generatorSchema = z.object({ - preset: z.enum(GeneratorPreset), - params: generatorParamsSchema.partial(), + id: z.number().positive().optional(), + name: z.string().nonempty(), + description: z.string().optional(), + params: z.object({ + trackAmount: z.number().positive(), + excludedPlaylistIds: z.array(z.number().positive()).optional(), + excludedTrackIds: z.array(z.number().positive()).optional(), + preset: z.enum(GeneratorPreset), + paramsCustom: z.object({}).partial().optional(), + paramsForgotten: z.object({}).partial().optional(), + paramsTop: z.object({ + window: generatorWindowSchema + }).partial().optional(), + paramsOldTop: z.object({ + peakWindow: generatorWindowSchema, + recentWindow: generatorWindowSchema + }).partial().optional(), + }).partial().optional(), }) export type GeneratorSchema = z.infer & JSONBody; diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 504c8ff..edcc14f 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -78,3 +78,18 @@ export async function getErrorMessage(err: Error) { return err.message } + +export function daysAgo(days: number) { + return new Date(Date.now() - days * 24 * 60 * 60 * 1000); +} + +export function getValueByPath(obj: unknown, path: string): T | undefined { + return path + .split(".") + .reduce((acc, key) => { + if (acc == null || typeof acc !== "object") { + return undefined; + } + return (acc as Record)[key]; + }, obj) as T | undefined; +} From 8fdc083efc07d09a27a955d1683e062735d36bfe Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 3 Jan 2026 00:32:02 +0100 Subject: [PATCH 11/33] feat(generator): support excluded tracks & playlists --- db/queries/playlist.sql | 1 - db/queries/playlist_track.sql | 5 + internal/database/model/playlist.go | 10 ++ internal/database/repository/playlist.go | 12 ++ internal/generator/generator.go | 48 +++++- pkg/sqlc/playlist_track.sql.go | 32 ++++ .../directory/DirectoryPlaylistSelector.tsx | 4 +- .../directory/DirectoryTreeNode.tsx | 2 +- ui/src/components/generator/GeneratorForm.tsx | 75 ++++++--- .../generator/GeneratorFormPreset.tsx | 153 ++++++++++++------ .../generator/GeneratorFormTrack.tsx | 57 +++++++ .../generator/GeneratorPlaylistTree.tsx | 122 ++++++++++++++ ui/src/components/link/LinkTree.tsx | 1 - ui/src/components/link/LinkTreeNode.tsx | 2 +- ui/src/components/link/LinkTreePlaylist.tsx | 4 +- ui/src/components/molecules/Stepper.tsx | 37 +++++ ui/src/lib/api/generator.ts | 12 +- ui/src/lib/patches/mantine-datatable.d.ts | 2 + ui/src/pages/generator/GeneratorCreate.tsx | 4 +- 19 files changed, 488 insertions(+), 95 deletions(-) create mode 100644 ui/src/components/generator/GeneratorFormTrack.tsx create mode 100644 ui/src/components/generator/GeneratorPlaylistTree.tsx create mode 100644 ui/src/components/molecules/Stepper.tsx diff --git a/db/queries/playlist.sql b/db/queries/playlist.sql index 237f492..e08329b 100644 --- a/db/queries/playlist.sql +++ b/db/queries/playlist.sql @@ -15,7 +15,6 @@ LEFT JOIN playlist_users pu ON pu.playlist_id = p.id WHERE pu.user_id = $1 AND pu.deleted_at IS NULL ORDER BY p.name; - -- name: PlaylistGetByUserWithOwner :many SELECT sqlc.embed(p), sqlc.embed(u) FROM playlists p diff --git a/db/queries/playlist_track.sql b/db/queries/playlist_track.sql index 049b40a..ca70803 100644 --- a/db/queries/playlist_track.sql +++ b/db/queries/playlist_track.sql @@ -1,3 +1,8 @@ +-- name: PlaylistTrackGetByPlaylistIds :many +SELECT * +FROM playlist_tracks +WHERE playlist_id = ANY($1::int[]); + -- name: PlaylistTrackCreate :one INSERT INTO playlist_tracks (playlist_id, track_id) VALUES ($1, $2) diff --git a/internal/database/model/playlist.go b/internal/database/model/playlist.go index 4987ba6..70d252a 100644 --- a/internal/database/model/playlist.go +++ b/internal/database/model/playlist.go @@ -66,6 +66,16 @@ type PlaylistTrack struct { DeletedAt time.Time } +func PlaylistTrackModel(p sqlc.PlaylistTrack) *PlaylistTrack { + return &PlaylistTrack{ + ID: int(p.ID), + PlaylistID: int(p.PlaylistID), + TrackID: int(p.TrackID), + CreatedAt: p.CreatedAt.Time, + DeletedAt: p.DeletedAt.Time, + } +} + type PlaylistUser struct { ID int UserID int diff --git a/internal/database/repository/playlist.go b/internal/database/repository/playlist.go index 4be2e66..12edddc 100644 --- a/internal/database/repository/playlist.go +++ b/internal/database/repository/playlist.go @@ -120,6 +120,18 @@ func (p *Playlist) GetUnplayableTracksByUser(ctx context.Context, userID int) ([ return utils.MapValues(playlistMap), nil } +func (p *Playlist) GetTrackByPlaylistIDs(ctx context.Context, playlistIDs []int) ([]*model.PlaylistTrack, error) { + tracks, err := p.repo.queries(ctx).PlaylistTrackGetByPlaylistIds(ctx, utils.SliceMap(playlistIDs, func(p int) int32 { return int32(p) })) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get tracks by playlists ids %+v | %w", playlistIDs, err) + } + + return utils.SliceMap(tracks, model.PlaylistTrackModel), nil +} + func (p *Playlist) Create(ctx context.Context, playlist *model.Playlist) error { id, err := p.repo.queries(ctx).PlaylistCreate(ctx, sqlc.PlaylistCreateParams{ SpotifyID: playlist.SpotifyID, diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 3818e51..e93bb69 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -13,14 +13,16 @@ import ( ) type generator struct { - history repository.History + history repository.History + playlist repository.Playlist } var G *generator func Init(repo repository.Repository) { G = &generator{ - history: *repo.NewHistory(), + history: *repo.NewHistory(), + playlist: *repo.NewPlaylist(), } } @@ -65,6 +67,21 @@ func (g *generator) forgotten(gen model.Generator) ([]model.Track, error) { func (g *generator) top(ctx context.Context, gen model.Generator) ([]model.Track, error) { params := gen.Params.ParamsTop + // Get all excluded tracks + // Can be from the excluded tracks list + // Or the excluded playlists list + excludedPlaylistTracks, err := g.playlist.GetTrackByPlaylistIDs(ctx, gen.Params.ExcludedPlaylistIDs) + if err != nil { + return nil, err + } + excludedTracksMap := make(map[int]bool) + for _, t := range excludedPlaylistTracks { + excludedTracksMap[t.TrackID] = true + } + for _, t := range gen.Params.ExcludedTrackIDs { + excludedTracksMap[t] = true + } + // Get history for last 14 days skipped := false history, err := g.history.GetPopulatedFiltered(ctx, model.HistoryFilter{ @@ -95,6 +112,11 @@ func (g *generator) top(ctx context.Context, gen model.Generator) ([]model.Track seen[h.TrackID] = true + // Did the user exclude it + if excludedTracksMap[h.TrackID] { + continue + } + if hasBurst(playedAts[h.TrackID], params.Window) { tracks = append(tracks, trackPlayCount{ track: h.Track, @@ -118,9 +140,24 @@ func (g *generator) top(ctx context.Context, gen model.Generator) ([]model.Track func (g *generator) oldTop(ctx context.Context, gen model.Generator) ([]model.Track, error) { params := gen.Params.ParamsOldTop - skipped := false + + // Get all excluded tracks + // Can be from the excluded tracks list + // Or the excluded playlists list + excludedPlaylistTracks, err := g.playlist.GetTrackByPlaylistIDs(ctx, gen.Params.ExcludedPlaylistIDs) + if err != nil { + return nil, err + } + excludedTracksMap := make(map[int]bool) + for _, t := range excludedPlaylistTracks { + excludedTracksMap[t.TrackID] = true + } + for _, t := range gen.Params.ExcludedTrackIDs { + excludedTracksMap[t] = true + } // Get the relevant recent history + skipped := false recent, err := g.history.GetPopulatedFiltered(ctx, model.HistoryFilter{ UserID: gen.UserID, Start: params.RecentWindow.Start, @@ -171,6 +208,11 @@ func (g *generator) oldTop(ctx context.Context, gen model.Generator) ([]model.Tr seen[o.TrackID] = true + // Did the user exclude it + if excludedTracksMap[o.TrackID] { + continue + } + // Did we play it too much recently? if hasBurst(recentPlayedAts[o.TrackID], params.RecentWindow) { continue diff --git a/pkg/sqlc/playlist_track.sql.go b/pkg/sqlc/playlist_track.sql.go index c07714c..f469d46 100644 --- a/pkg/sqlc/playlist_track.sql.go +++ b/pkg/sqlc/playlist_track.sql.go @@ -42,3 +42,35 @@ func (q *Queries) PlaylistTrackDeleteByPlaylistTrack(ctx context.Context, arg Pl _, err := q.db.Exec(ctx, playlistTrackDeleteByPlaylistTrack, arg.PlaylistID, arg.TrackID) return err } + +const playlistTrackGetByPlaylistIds = `-- name: PlaylistTrackGetByPlaylistIds :many +SELECT id, playlist_id, track_id, deleted_at, created_at +FROM playlist_tracks +WHERE playlist_id = ANY($1::int[]) +` + +func (q *Queries) PlaylistTrackGetByPlaylistIds(ctx context.Context, dollar_1 []int32) ([]PlaylistTrack, error) { + rows, err := q.db.Query(ctx, playlistTrackGetByPlaylistIds, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + var items []PlaylistTrack + for rows.Next() { + var i PlaylistTrack + if err := rows.Scan( + &i.ID, + &i.PlaylistID, + &i.TrackID, + &i.DeletedAt, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/ui/src/components/directory/DirectoryPlaylistSelector.tsx b/ui/src/components/directory/DirectoryPlaylistSelector.tsx index 2a2d46e..222ab04 100644 --- a/ui/src/components/directory/DirectoryPlaylistSelector.tsx +++ b/ui/src/components/directory/DirectoryPlaylistSelector.tsx @@ -35,13 +35,13 @@ const Playlist = ({ playlist }: PlaylistProps) => { return (
- +

{playlist.name}

-

+

{playlist.trackAmount}

diff --git a/ui/src/components/directory/DirectoryTreeNode.tsx b/ui/src/components/directory/DirectoryTreeNode.tsx index b32b6b0..2b2157e 100644 --- a/ui/src/components/directory/DirectoryTreeNode.tsx +++ b/ui/src/components/directory/DirectoryTreeNode.tsx @@ -17,7 +17,7 @@ export const DirectoryTreeNode = ({ directory, level }: Props) => { return ( - setExpanded(prev => !prev)} className="cursor-pointer hover:bg-[var(--mantine-color-background-1)] rounded-xl"> + setExpanded(prev => !prev)} className="cursor-pointer hover:bg-(--mantine-color-background-1) rounded-xl"> diff --git a/ui/src/components/generator/GeneratorForm.tsx b/ui/src/components/generator/GeneratorForm.tsx index 6b9af4b..8182ce5 100644 --- a/ui/src/components/generator/GeneratorForm.tsx +++ b/ui/src/components/generator/GeneratorForm.tsx @@ -1,17 +1,33 @@ import { GeneratorPreset, generatorSchema, GeneratorSchema } from "@/lib/types/generator"; import { daysAgo } from "@/lib/utils"; -import { Divider, Group, Stepper } from "@mantine/core"; import { useForm } from "@mantine/form"; +import { useNavigate } from "@tanstack/react-router"; import { zod4Resolver } from "mantine-form-zod-resolver"; -import { useState } from "react"; -import { Button } from "../atoms/Button"; +import { useMemo, useState } from "react"; +import { Step, Stepper } from "../molecules/Stepper"; import { GeneratorFormPreset } from "./GeneratorFormPreset"; +import { GeneratorFormTrack } from "./GeneratorFormTrack"; +import { Stack } from "@mantine/core"; const maxSteps = 3 +const steps: Step[] = [ + { + title: "Preset & Parameters", + }, + { + title: "Select Tracks", + }, + { + title: "Finalize & Save", + } +] + export const GeneratorForm = () => { const [active, setActive] = useState(0) + const navigate = useNavigate() + const form = useForm({ initialValues: { name: "", @@ -41,7 +57,7 @@ export const GeneratorForm = () => { recentWindow: { start: daysAgo(14), // 14 days ago end: new Date(), - minPlays: 5, + minPlays: 2, burstIntervalS: 14 * 24 * 60 * 60 // 14 days } } @@ -50,26 +66,37 @@ export const GeneratorForm = () => { validate: zod4Resolver(generatorSchema), }) + const handleNextStep = () => { + if (active < maxSteps - 1) { + setActive(prev => prev + 1) + return + } + } + + const handlePrevStep = () => { + if (active === 0) { + navigate({ to: "/generator" }) + return + } + + setActive(prev => prev - 1) + } + + const stepComponent = useMemo(() => { + switch (active) { + case 0: + return + case 1: + return + default: + return null + } + }, [active]) + return ( -
- - - - - - - - - - - -

{`Step ${active + 1} of ${maxSteps}`}

- - - - -
-
+ + + {stepComponent} + ) } - diff --git a/ui/src/components/generator/GeneratorFormPreset.tsx b/ui/src/components/generator/GeneratorFormPreset.tsx index 2b4fc5a..9fa6941 100644 --- a/ui/src/components/generator/GeneratorFormPreset.tsx +++ b/ui/src/components/generator/GeneratorFormPreset.tsx @@ -1,24 +1,27 @@ import { useGeneratorPreview } from "@/lib/api/generator" +import { usePlaylistGetAll } from "@/lib/api/playlist" import { GeneratorPreset, generatorPresetString, GeneratorSchema, GeneratorWindowSchema } from "@/lib/types/generator" import { getValueByPath } from "@/lib/utils" -import { ActionIcon, Alert, Group, NumberInput, Stack } from "@mantine/core" +import { Alert, Group, NumberInput, Stack } from "@mantine/core" import { DatePickerInput, DatesRangeValue } from "@mantine/dates" import { UseFormReturnType } from "@mantine/form" import { notifications } from "@mantine/notifications" -import { useQueryClient } from "@tanstack/react-query" -import { ReactNode, useState } from "react" -import { LuRotateCcw } from "react-icons/lu" +import { ReactNode, useEffect, useState } from "react" import { Button } from "../atoms/Button" -import { SectionTitle } from "../atoms/Page" +import { Section, SectionTitle } from "../atoms/Page" import { Table } from "../molecules/Table" +import { GeneratorPlaylistTree } from "./GeneratorPlaylistTree" type Props = { form: UseFormReturnType + nextStep: () => void; + prevStep: () => void; } -export const GeneratorFormPreset = ({ form }: Props) => { - const { data: tracks, isLoading, isRefetching } = useGeneratorPreview(form.values) - const queryClient = useQueryClient() +export const GeneratorFormPreset = ({ form, nextStep, prevStep }: Props) => { + const { mutate: generatorPreview, data: tracks, isPending } = useGeneratorPreview() + + useEffect(() => generatorPreview(form.getValues()), []) const handleClickPreset = (p: GeneratorPreset) => { form.setFieldValue("params.preset", p) @@ -27,11 +30,10 @@ export const GeneratorFormPreset = ({ form }: Props) => { const handleRefetchTracks = () => { if (form.validateField("params").hasError) { notifications.show({ color: "red", message: "Some parameters are invalid" }) - console.error(form.errors) return } - queryClient.invalidateQueries({ queryKey: ["generator", "preview"] }) + generatorPreview(form.getValues()) } const getPresetArguments = (preset: GeneratorPreset) => { @@ -47,51 +49,104 @@ export const GeneratorFormPreset = ({ form }: Props) => { } } - const [presetArguments, setPresetArguments] = useState(getPresetArguments(form.values.params?.preset ?? GeneratorPreset.Custom)) + const [presetArguments, setPresetArguments] = useState(getPresetArguments(form.getValues().params?.preset ?? GeneratorPreset.Custom)) form.watch("params.preset", ({ value }) => setPresetArguments(getPresetArguments(value as GeneratorPreset))) + // TODO: next give arwning if the preview is not the same as the generator + return ( - - - - -

Choose a preset

- - {Object.values(GeneratorPreset).map(p => ( - - ))} - - {presetArguments} -
+
+
+ - -

General parameters

- - + +

Preset

+ + {Object.values(GeneratorPreset).map(p => ( + + ))} + + {presetArguments} +
+ + +

General parameters

+ + + +
+ + +

Select playlists

+ +
+
+ +
+ + + - - - -

Preview

- - - -
- + + + + + + + ) +} + +const Playlists = ({ form }: { form: UseFormReturnType }) => { + const { data: playlists, isLoading } = usePlaylistGetAll() + const [excluded, setExcluded] = useState(form.getValues().params?.excludedPlaylistIds ?? []) + + const handleToggle = (playlistIds: number[], isExcluded: boolean) => { + let newExcluded: number[] + + if (isExcluded) newExcluded = [...excluded, ...playlistIds.filter(p => !excluded.includes(p))] + else newExcluded = excluded.filter(e => !playlistIds.includes(e)) + + form.setFieldValue("params.excludedPlaylistIds", newExcluded) + setExcluded(newExcluded) + } + + const handleDeselectAll = () => { + const newExcluded = playlists?.map(p => p.id) ?? [] + + form.setFieldValue("params.excludedPlaylistIds", newExcluded) + setExcluded(newExcluded) + } + + const handleSelectAll = () => { + const newExcluded: number[] = [] + + form.setFieldValue("params.excludedPlaylistIds", newExcluded) + setExcluded(newExcluded) + } + + return ( + + + + +

{`Selected ${(playlists?.length ?? 0) - excluded.length}`}

+
+
) } @@ -107,13 +162,12 @@ const Forgotten = ({ form }: { form: UseFormReturnType }) => { const Top = ({ form }: { form: UseFormReturnType }) => { return ( - + {`Top finds the tracks you're listening to the most right now. - It looks at your listening history within the selected time range and includes tracks that were played at least the minimum number of times within the given interval.`} -

Evaluation window

+

Evaluation window

@@ -123,9 +177,8 @@ It looks at your listening history within the selected time range and includes t const OldTop = ({ form }: { form: UseFormReturnType }) => { return ( - + {`Old Top finds tracks you used to listen to on repeat, but don’t play much anymore. - It works in two steps: 1. Historic window: @@ -135,14 +188,14 @@ It works in two steps: Filters out tracks that were still played frequently in the recent range. Example: -If the historic range is 6 months, the minimum plays is 5, and the burst interval is 14 days, it will find tracks you played 5 or more times within any 14-day period during those 6 months — but only if you haven’t played them much recently.`} +If the historic range is 6 months, the minimum plays is 5, and the burst interval is 14 days, it will find tracks you played 5 or more times within any 14-day period during those 6 months. But only if you haven’t played them much recently.`} -

Historic listening window

+

Historic listening window

-

Recent listening window

+

Recent listening window

diff --git a/ui/src/components/generator/GeneratorFormTrack.tsx b/ui/src/components/generator/GeneratorFormTrack.tsx new file mode 100644 index 0000000..02b9ed8 --- /dev/null +++ b/ui/src/components/generator/GeneratorFormTrack.tsx @@ -0,0 +1,57 @@ +import { GeneratorSchema } from "@/lib/types/generator" +import { Button, Group, Stack } from "@mantine/core" +import { UseFormReturnType } from "@mantine/form" +import { SectionTitle } from "../atoms/Page" +import { useGeneratorPreview } from "@/lib/api/generator" +import { useQueryClient } from "@tanstack/react-query" +import { Table } from "../molecules/Table" +import { Track } from "@/lib/types/track" +import { useState } from "react" +import { LuRotateCcw } from "react-icons/lu" + +type Props = { + form: UseFormReturnType +} + +export const GeneratorFormTrack = ({ form }: Props) => { + const { data: tracks, isLoading, isRefetching } = useGeneratorPreview(form.getValues()) + const [selectedRecords, setSelectedRecords] = useState([]); + const queryClient = useQueryClient() + + const handleRefetch = () => { + queryClient.invalidateQueries({ queryKey: ["generator", "preview"] }) + } + + const handleExcludeTracks = () => { + form.setFieldValue("params.excludedTrackIds", [...form.getValues().params?.excludedTrackIds ?? [], ...selectedRecords.map(t => t.id)]) + handleRefetch() + } + + return ( + + + + +
+ + + + + + + ) +} diff --git a/ui/src/components/generator/GeneratorPlaylistTree.tsx b/ui/src/components/generator/GeneratorPlaylistTree.tsx new file mode 100644 index 0000000..ed5e5e1 --- /dev/null +++ b/ui/src/components/generator/GeneratorPlaylistTree.tsx @@ -0,0 +1,122 @@ +import { useDirectoryGetAll } from "@/lib/api/directory"; +import { Playlist } from "@/lib/types/playlist" +import { ActionIcon, Checkbox, Group, Stack } from "@mantine/core"; +import { LoadingSpinner } from "../molecules/LoadingSpinner"; +import { Directory } from "@/lib/types/directory"; +import { useMemo, useState } from "react"; +import { LuFolder, LuFolderOpen, LuTriangle } from "react-icons/lu"; +import { PlaylistCover } from "../playlist/PlaylistCover"; + +type Props = { + playlists: Playlist[]; + excluded: number[]; + isLoading: boolean; + onToggle: (playlistIds: number[], isExcluded: boolean) => void; +} + +const getPlaylists = (directory: Directory): Playlist[] => { + return [ + ...directory.playlists, + ...(directory.children?.flatMap(getPlaylists) ?? []) + ] +} + +export const GeneratorPlaylistTree = ({ playlists, excluded, isLoading: isLoadingPlaylists, onToggle }: Props) => { + const { data: directories, isLoading: isLoadingDirectories } = useDirectoryGetAll() + + const unAssigned = useMemo(() => { + const directoryPlaylists = (directories?.flatMap(getPlaylists) ?? []).map(p => p.id) + return playlists?.filter(p => !directoryPlaylists.includes(p.id)) ?? [] + }, [playlists, directories]) + + const child = () => { + if (isLoadingDirectories || isLoadingPlaylists) return + + return ( + <> + {directories?.length !== 0 && ( + <> +

Directories

+ {directories?.map(d => )} +

Unassigned Playlists

+ + )} + + {unAssigned.map(p => )} + + + ) + } + + return ( + + {child()} + + ) +} + +type TreeNodeProps = { + directory: Directory; + level: number; + excluded: number[]; + onToggle: (playlistIds: number[], isExcluded: boolean) => void; +} + +const offset = 32; + +const TreeNode = ({ directory, level, excluded, onToggle }: TreeNodeProps) => { + const [expanded, setExpanded] = useState(true) + + const playlistsIds = useMemo(() => getPlaylists(directory).map(p => p.id), [directory]) + + const selected: boolean | null = useMemo(() => { + const filtered = excluded.filter(e => playlistsIds.includes(e)) + if (filtered.length === 0) return true + if (filtered.length === playlistsIds.length) return false + return null + }, [excluded]) + + return ( + + + setExpanded(prev => !prev)} variant="transparent" color="black" size="sm"> + + + onToggle(playlistsIds, selected === true || selected === null)} color="secondary.2" /> + {expanded + ? + : + } +

{directory.name}

+

{directory.playlists.length + (directory.children?.length ?? 0)}

+
+ {expanded && ( + <> + + {directory.playlists.map(p => )} + + {directory.children?.map(d => ( + + ))} + + )} +
+ ) +} + +type TreePlaylistProps = { + playlist: Playlist; + excluded: boolean; + onToggle: (playlistIds: number[], isExcluded: boolean) => void; +} + +const TreePlaylist = ({ playlist, excluded, onToggle }: TreePlaylistProps) => { + return ( +
onToggle([playlist.id], !excluded)} className={`flex items-center gap-2 px-2 py-0.5 rounded-xl cursor-pointer ${!excluded && "bg-(--mantine-color-background-2)"}`}> + + +

{playlist.name}

+

{playlist.trackAmount}

+
+ ) +} diff --git a/ui/src/components/link/LinkTree.tsx b/ui/src/components/link/LinkTree.tsx index 473e7e0..8598daf 100644 --- a/ui/src/components/link/LinkTree.tsx +++ b/ui/src/components/link/LinkTree.tsx @@ -18,7 +18,6 @@ type Props = { export const LinkTree = ({ roots, unAssigned, side, title, isLoading, className, ...props }: Props) => { const child = () => { if (isLoading) return - if (roots.length === 0) return null return ( <> diff --git a/ui/src/components/link/LinkTreeNode.tsx b/ui/src/components/link/LinkTreeNode.tsx index 7d7c225..6de2a0e 100644 --- a/ui/src/components/link/LinkTreeNode.tsx +++ b/ui/src/components/link/LinkTreeNode.tsx @@ -45,7 +45,7 @@ export const LinkTreeNode = ({ directory, side, level }: Props) => { onMouseUp={() => finishConnection(id)} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} - className={`rounded-xl ${isHovered && "bg-[var(--mantine-color-background-1)]"}`} + className={`rounded-xl ${isHovered && "bg-(--mantine-color-background-1)"}`} >

{directory.name}

diff --git a/ui/src/components/link/LinkTreePlaylist.tsx b/ui/src/components/link/LinkTreePlaylist.tsx index b279238..98f09c8 100644 --- a/ui/src/components/link/LinkTreePlaylist.tsx +++ b/ui/src/components/link/LinkTreePlaylist.tsx @@ -33,10 +33,10 @@ export const LinkTreePlaylist = ({ playlist, side }: Props) => { onMouseUp={() => finishConnection(id)} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} - className={`flex items-center gap-2 rounded-xl ${isHovered && "bg-[var(--mantine-color-background-1)]"}`} + className={`flex items-center gap-2 rounded-xl ${isHovered && "bg-(--mantine-color-background-1)"}`} > -

{playlist.name}

+

{playlist.name}

{playlist.trackAmount}

{hidden > 0 &&

{`(${hidden})`}

}
registerAnchor(id, { el, side, playlist })} className={`absolute ${side === "left" ? "right-1" : "left-1"}`}> diff --git a/ui/src/components/molecules/Stepper.tsx b/ui/src/components/molecules/Stepper.tsx new file mode 100644 index 0000000..7b597b2 --- /dev/null +++ b/ui/src/components/molecules/Stepper.tsx @@ -0,0 +1,37 @@ +import { LuCheck } from "react-icons/lu"; +import { Fragment } from "react/jsx-runtime"; + +type Props = { + steps: Step[]; + activeStep: number +} + +export type Step = { + title: string; +} + +export const Stepper = ({ steps, activeStep }: Props) => { + return ( +
+ {steps.map((s, idx) => { + const active = idx === activeStep + const prev = idx < activeStep + + return ( + + {idx > 0 &&
} +
+

+ {prev + ? + : idx + 1 + } +

+

{s.title}

+
+ + ) + })} +
+ ) +} diff --git a/ui/src/lib/api/generator.ts b/ui/src/lib/api/generator.ts index eecb3cb..e0e15a4 100644 --- a/ui/src/lib/api/generator.ts +++ b/ui/src/lib/api/generator.ts @@ -1,15 +1,13 @@ -import { useQuery } from "@tanstack/react-query" +import { useMutation } from "@tanstack/react-query" import { GeneratorSchema } from "../types/generator" -import { apiPost } from "./query" import { convertTracks } from "../types/track" +import { apiPost } from "./query" const ENDPOINT = "generator" -export const useGeneratorPreview = (generator: GeneratorSchema) => { - return useQuery({ - queryKey: ["generator", "preview"], - queryFn: async () => (await apiPost(`${ENDPOINT}/preview`, generator.params, convertTracks)).data, - staleTime: Infinity, +export const useGeneratorPreview = () => { + return useMutation({ + mutationFn: async (generator: GeneratorSchema) => (await apiPost(`${ENDPOINT}/preview`, generator.params, convertTracks)).data, throwOnError: true, }) } diff --git a/ui/src/lib/patches/mantine-datatable.d.ts b/ui/src/lib/patches/mantine-datatable.d.ts index 232247b..16075c4 100644 --- a/ui/src/lib/patches/mantine-datatable.d.ts +++ b/ui/src/lib/patches/mantine-datatable.d.ts @@ -45,6 +45,8 @@ declare module "mantine-datatable" { customLoader?: ReactNode; onScrollToBottom?: () => void; + selectionTrigger?: "cell" | "checkbox"; + styles?: { root: (theme: MantineTheme) => Record, header: (theme: MantineTheme) => Record, diff --git a/ui/src/pages/generator/GeneratorCreate.tsx b/ui/src/pages/generator/GeneratorCreate.tsx index bf8529c..69e4614 100644 --- a/ui/src/pages/generator/GeneratorCreate.tsx +++ b/ui/src/pages/generator/GeneratorCreate.tsx @@ -8,9 +8,7 @@ export const GeneratorCreate = () => { title="Create a new playlist generator" /> -
- -
+ ) } From 15b9c19a0b4fc209372f7034eeb98f80cff74212 Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 3 Jan 2026 01:14:39 +0100 Subject: [PATCH 12/33] feat(generator): exclude tracks in the ui --- db/queries/track.sql | 5 + internal/database/repository/track.go | 12 ++ internal/server/api/track.go | 18 +++ internal/server/service/track.go | 10 ++ pkg/sqlc/track.sql.go | 33 +++++ ui/src/components/generator/GeneratorForm.tsx | 2 +- .../generator/GeneratorFormPreset.tsx | 1 - .../generator/GeneratorFormTrack.tsx | 117 +++++++++++++----- ui/src/components/molecules/Table.tsx | 8 +- ui/src/lib/api/generator.ts | 1 + ui/src/lib/api/track.ts | 21 +++- 11 files changed, 188 insertions(+), 40 deletions(-) diff --git a/db/queries/track.sql b/db/queries/track.sql index 11ed6f2..12c58b7 100644 --- a/db/queries/track.sql +++ b/db/queries/track.sql @@ -2,6 +2,11 @@ SELECT * FROM tracks; +-- name: TrackGetAllById :many +SELECT * +FROM tracks +WHERE id = ANY($1::int[]); + -- name: TrackGetBySpotify :one SELECT * FROM tracks diff --git a/internal/database/repository/track.go b/internal/database/repository/track.go index fce17c2..a11aba1 100644 --- a/internal/database/repository/track.go +++ b/internal/database/repository/track.go @@ -33,6 +33,18 @@ func (t *Track) GetAll(ctx context.Context) ([]*model.Track, error) { return utils.SliceMap(tracks, model.TrackModel), nil } +func (t *Track) GetAllByID(ctx context.Context, trackIDs []int) ([]*model.Track, error) { + tracks, err := t.repo.queries(ctx).TrackGetAllById(ctx, utils.SliceMap(trackIDs, func(t int) int32 { return int32(t) })) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get all tracks by id %+v | %w", trackIDs, err) + } + + return utils.SliceMap(tracks, model.TrackModel), nil +} + func (t *Track) GetBySpotify(ctx context.Context, spotifyID string) (*model.Track, error) { track, err := t.repo.queries(ctx).TrackGetBySpotify(ctx, spotifyID) if err != nil { diff --git a/internal/server/api/track.go b/internal/server/api/track.go index a7bbc83..db28bac 100644 --- a/internal/server/api/track.go +++ b/internal/server/api/track.go @@ -27,11 +27,29 @@ func NewTrack(router fiber.Router, service service.Service) *Track { } func (r *Track) createRoutes() { + r.router.Get("/ids", r.getAllByID) r.router.Get("/history", r.getHistory) r.router.Get("/added", r.getAdded) r.router.Get("/deleted", r.getDeleted) } +func (r *Track) getAllByID(c *fiber.Ctx) error { + type params struct { + IDs []int `json:"ids"` + } + var p params + if err := c.QueryParser(&p); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + tracks, err := r.track.GetByIds(c.Context(), p.IDs) + if err != nil { + return err + } + + return c.JSON(tracks) +} + func (r *Track) getHistory(c *fiber.Ctx) error { userID, ok := c.Locals("userID").(int) if !ok { diff --git a/internal/server/service/track.go b/internal/server/service/track.go index 04888cf..fbb16ef 100644 --- a/internal/server/service/track.go +++ b/internal/server/service/track.go @@ -26,6 +26,16 @@ func (s *Service) NewTrack() *Track { } } +func (t *Track) GetByIds(ctx context.Context, trackIDs []int) ([]dto.Track, error) { + tracks, err := t.track.GetAllByID(ctx, trackIDs) + if err != nil { + zap.S().Error(err) + return nil, fiber.ErrInternalServerError + } + + return utils.SliceMap(tracks, dto.TrackDTO), nil +} + func (t *Track) GetHistory(ctx context.Context, filter dto.HistoryFilter) ([]dto.History, error) { filterModel := filter.ToModel() if filterModel.Skipped == nil { diff --git a/pkg/sqlc/track.sql.go b/pkg/sqlc/track.sql.go index e742eb8..e1a0ee4 100644 --- a/pkg/sqlc/track.sql.go +++ b/pkg/sqlc/track.sql.go @@ -68,6 +68,39 @@ func (q *Queries) TrackGetAll(ctx context.Context) ([]Track, error) { return items, nil } +const trackGetAllById = `-- name: TrackGetAllById :many +SELECT id, spotify_id, name, popularity, updated_at, duration_ms +FROM tracks +WHERE id = ANY($1::int[]) +` + +func (q *Queries) TrackGetAllById(ctx context.Context, dollar_1 []int32) ([]Track, error) { + rows, err := q.db.Query(ctx, trackGetAllById, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Track + for rows.Next() { + var i Track + if err := rows.Scan( + &i.ID, + &i.SpotifyID, + &i.Name, + &i.Popularity, + &i.UpdatedAt, + &i.DurationMs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const trackGetAllBySpotify = `-- name: TrackGetAllBySpotify :many SELECT id, spotify_id, name, popularity, updated_at, duration_ms FROM tracks diff --git a/ui/src/components/generator/GeneratorForm.tsx b/ui/src/components/generator/GeneratorForm.tsx index 8182ce5..437de31 100644 --- a/ui/src/components/generator/GeneratorForm.tsx +++ b/ui/src/components/generator/GeneratorForm.tsx @@ -87,7 +87,7 @@ export const GeneratorForm = () => { case 0: return case 1: - return + return default: return null } diff --git a/ui/src/components/generator/GeneratorFormPreset.tsx b/ui/src/components/generator/GeneratorFormPreset.tsx index 9fa6941..e65f069 100644 --- a/ui/src/components/generator/GeneratorFormPreset.tsx +++ b/ui/src/components/generator/GeneratorFormPreset.tsx @@ -20,7 +20,6 @@ type Props = { export const GeneratorFormPreset = ({ form, nextStep, prevStep }: Props) => { const { mutate: generatorPreview, data: tracks, isPending } = useGeneratorPreview() - useEffect(() => generatorPreview(form.getValues()), []) const handleClickPreset = (p: GeneratorPreset) => { diff --git a/ui/src/components/generator/GeneratorFormTrack.tsx b/ui/src/components/generator/GeneratorFormTrack.tsx index 02b9ed8..36aafde 100644 --- a/ui/src/components/generator/GeneratorFormTrack.tsx +++ b/ui/src/components/generator/GeneratorFormTrack.tsx @@ -1,57 +1,108 @@ +import { useGeneratorPreview } from "@/lib/api/generator" +import { useTrackGetAllById } from "@/lib/api/track" import { GeneratorSchema } from "@/lib/types/generator" -import { Button, Group, Stack } from "@mantine/core" +import { Track } from "@/lib/types/track" +import { ActionIcon, Group } from "@mantine/core" import { UseFormReturnType } from "@mantine/form" -import { SectionTitle } from "../atoms/Page" -import { useGeneratorPreview } from "@/lib/api/generator" -import { useQueryClient } from "@tanstack/react-query" +import { useEffect, useState } from "react" +import { Button } from "../atoms/Button" +import { Section, SectionTitle } from "../atoms/Page" import { Table } from "../molecules/Table" -import { Track } from "@/lib/types/track" -import { useState } from "react" -import { LuRotateCcw } from "react-icons/lu" +import { LuTrash2, LuUndo2 } from "react-icons/lu" type Props = { form: UseFormReturnType + nextStep: () => void; + prevStep: () => void; } -export const GeneratorFormTrack = ({ form }: Props) => { - const { data: tracks, isLoading, isRefetching } = useGeneratorPreview(form.getValues()) - const [selectedRecords, setSelectedRecords] = useState([]); - const queryClient = useQueryClient() +export const GeneratorFormTrack = ({ form, nextStep, prevStep }: Props) => { + const { mutate: generatorPreview, data: tracksInitial, isPending } = useGeneratorPreview() + useEffect(() => generatorPreview(form.getValues()), []) + const [tracks, setTracks] = useState(tracksInitial ?? []) + useEffect(() => { + if (!tracksInitial) return + setTracks(tracksInitial) + }, [tracksInitial]) + + const { data: excludedTracksInitial, isLoading } = useTrackGetAllById() + const [excludedTracks, setExcludedTracks] = useState(excludedTracksInitial ?? []) + useEffect(() => { + if (!excludedTracksInitial) return + setExcludedTracks(excludedTracksInitial) + }, [excludedTracksInitial]) - const handleRefetch = () => { - queryClient.invalidateQueries({ queryKey: ["generator", "preview"] }) + const handleRefetchTracks = () => { + generatorPreview(form.getValues()) } - const handleExcludeTracks = () => { - form.setFieldValue("params.excludedTrackIds", [...form.getValues().params?.excludedTrackIds ?? [], ...selectedRecords.map(t => t.id)]) - handleRefetch() + const handleExcludeTrack = (track: Track) => { + form.setFieldValue("params.excludedTrackIds", [...(form.getValues().params?.excludedTrackIds ?? []), track.id]) + setTracks(prev => prev.filter(t => t.id !== track.id)) + setExcludedTracks(prev => [...prev, track].sort((a, b) => a.name.localeCompare(b.name))) + } + + const handleIncludeTrack = (track: Track) => { + form.setFieldValue("params.excludedTrackIds", form.getValues().params?.excludedTrackIds?.filter(t => t !== track.id)) + setTracks(prev => [...prev, track].sort((a, b) => a.name.localeCompare(b.name))) + setExcludedTracks(prev => prev.filter(t => t.id !== track.id)) } return ( - - +
+
+ + + + -
handleExcludeTrack(track)} variant="subtle" color="red"> + }, ]} records={tracks ?? []} - height={512} noRecordsText="No tracks fit the parameters" - fetching={isLoading || isRefetching} - selectedRecords={selectedRecords} - onSelectedRecordsChange={setSelectedRecords} - selectionTrigger="cell" + fetching={isPending || isLoading} + animate={false} /> - - - + + + + - - + + +
+ + +
handleIncludeTrack(track)} variant="subtle" color="black"> + }, + ]} + records={excludedTracks ?? []} + noRecordsText="No excluded tracks" + fetching={isPending || isLoading} + animate={false} + /> + + ) } diff --git a/ui/src/components/molecules/Table.tsx b/ui/src/components/molecules/Table.tsx index 590a3c9..05c374e 100644 --- a/ui/src/components/molecules/Table.tsx +++ b/ui/src/components/molecules/Table.tsx @@ -2,9 +2,11 @@ import { useAutoAnimate } from '@formkit/auto-animate/react'; import { DataTable, DataTableProps } from "mantine-datatable"; import { LoadingSpinner } from "./LoadingSpinner"; -type Props = DataTableProps & Record; +type Props = { + animate?: boolean; +} & DataTableProps & Record; -export const Table = ({ rowExpansion, ...props }: Props) => { +export const Table = ({ animate = true, rowExpansion, ...props }: Props) => { const [bodyRef] = useAutoAnimate(); return ( @@ -23,7 +25,7 @@ export const Table = ({ rowExpansion, ...props }: Props) => { background: theme.colors.secondary[1], }), }} - bodyRef={!rowExpansion ? bodyRef : undefined} + bodyRef={(!rowExpansion && animate) ? bodyRef : undefined} {...props} /> ); diff --git a/ui/src/lib/api/generator.ts b/ui/src/lib/api/generator.ts index e0e15a4..8e5bcd3 100644 --- a/ui/src/lib/api/generator.ts +++ b/ui/src/lib/api/generator.ts @@ -7,6 +7,7 @@ const ENDPOINT = "generator" export const useGeneratorPreview = () => { return useMutation({ + mutationKey: ["generator", "preview"], mutationFn: async (generator: GeneratorSchema) => (await apiPost(`${ENDPOINT}/preview`, generator.params, convertTracks)).data, throwOnError: true, }) diff --git a/ui/src/lib/api/track.ts b/ui/src/lib/api/track.ts index 8f916ed..4092766 100644 --- a/ui/src/lib/api/track.ts +++ b/ui/src/lib/api/track.ts @@ -1,11 +1,28 @@ -import { useInfiniteQuery } from "@tanstack/react-query" +import { useInfiniteQuery, useQuery } from "@tanstack/react-query" import { STALE_TIME } from "../types/staletime" -import { convertTrackHistories, convertTracksAdded, convertTracksDeleted, TrackFilter, TrackHistoryFilter } from "../types/track" +import { convertTrackHistories, convertTracks, convertTracksAdded, convertTracksDeleted, TrackFilter, TrackHistoryFilter } from "../types/track" import { apiGet } from "./query" const ENDPOINT = "track" const PAGE_LIMIT = 100 +export const useTrackGetAllById = (trackIds?: number[]) => { + return useQuery({ + queryKey: ["track", "ids", trackIds], + queryFn: async () => { + const queryParams = new URLSearchParams({ + ids: trackIds?.toString() ?? "[]", + }) + + const url = `${ENDPOINT}/ids?${queryParams.toString()}` + return (await apiGet(url, convertTracks)).data + }, + staleTime: Infinity, + throwOnError: true, + enabled: !!trackIds, + }) +} + export const useTrackGetHistory = (filter?: TrackHistoryFilter) => { const { data, isLoading, fetchNextPage, isFetchingNextPage, hasNextPage, error, refetch, isFetching } = useInfiniteQuery({ queryKey: ["track", "history", JSON.stringify(filter)], From 47dc937a7b14df191d352d4b3afe5cb118863a31 Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 3 Jan 2026 01:50:58 +0100 Subject: [PATCH 13/33] feat(generator): add general form information in ui --- ui/src/components/generator/GeneratorForm.tsx | 13 ++- .../generator/GeneratorFormFinalize.tsx | 47 ++++++++ .../generator/GeneratorFormPreset.tsx | 69 ++++++------ .../generator/GeneratorFormTrack.tsx | 104 +++++++++--------- 4 files changed, 140 insertions(+), 93 deletions(-) create mode 100644 ui/src/components/generator/GeneratorFormFinalize.tsx diff --git a/ui/src/components/generator/GeneratorForm.tsx b/ui/src/components/generator/GeneratorForm.tsx index 437de31..9534e58 100644 --- a/ui/src/components/generator/GeneratorForm.tsx +++ b/ui/src/components/generator/GeneratorForm.tsx @@ -8,8 +8,7 @@ import { Step, Stepper } from "../molecules/Stepper"; import { GeneratorFormPreset } from "./GeneratorFormPreset"; import { GeneratorFormTrack } from "./GeneratorFormTrack"; import { Stack } from "@mantine/core"; - -const maxSteps = 3 +import { GeneratorFormFinalize } from "./GeneratorFormFinalize"; const steps: Step[] = [ { @@ -67,7 +66,7 @@ export const GeneratorForm = () => { }) const handleNextStep = () => { - if (active < maxSteps - 1) { + if (active < steps.length - 1) { setActive(prev => prev + 1) return } @@ -82,21 +81,23 @@ export const GeneratorForm = () => { setActive(prev => prev - 1) } - const stepComponent = useMemo(() => { + const stepComponent = () => { switch (active) { case 0: return case 1: return + case 2: + return default: return null } - }, [active]) + } return ( - {stepComponent} + {stepComponent()} ) } diff --git a/ui/src/components/generator/GeneratorFormFinalize.tsx b/ui/src/components/generator/GeneratorFormFinalize.tsx new file mode 100644 index 0000000..af7c3fe --- /dev/null +++ b/ui/src/components/generator/GeneratorFormFinalize.tsx @@ -0,0 +1,47 @@ +import { GeneratorSchema } from "@/lib/types/generator"; +import { Group, TextInput } from "@mantine/core"; +import { UseFormReturnType } from "@mantine/form"; +import { Button } from "../atoms/Button"; +import { Section, SectionTitle } from "../atoms/Page"; + +type Props = { + form: UseFormReturnType + nextStep: () => void; + prevStep: () => void; +} + +export const GeneratorFormFinalize = ({ form, nextStep, prevStep }: Props) => { + return ( + <> +
+ + + + + + {/* */} + {/* */} + {/*

Create Spotify playlist

*/} + {/*

If set to off, the generator only shows a preview.

*/} + {/*
*/} + {/* */} + {/*
*/} + {/**/} + {/* */} + {/* */} + {/*

Auto-maintain playlist

*/} + {/*

Keep the playlist in sync with the new listening data.

*/} + {/*
*/} + {/* */} + {/*
*/} +
+ + + + + + + ) +} diff --git a/ui/src/components/generator/GeneratorFormPreset.tsx b/ui/src/components/generator/GeneratorFormPreset.tsx index e65f069..a50a287 100644 --- a/ui/src/components/generator/GeneratorFormPreset.tsx +++ b/ui/src/components/generator/GeneratorFormPreset.tsx @@ -54,14 +54,14 @@ export const GeneratorFormPreset = ({ form, nextStep, prevStep }: Props) => { // TODO: next give arwning if the preview is not the same as the generator return ( -
-
- - - + <> +
+
+ +

Preset

{Object.values(GeneratorPreset).map(p => ( @@ -69,44 +69,41 @@ export const GeneratorFormPreset = ({ form, nextStep, prevStep }: Props) => { ))} {presetArguments} - -

General parameters

-
-

Select playlists

-
-
+
+ +
+ + + + -
- - - - - -
- - - - - - + + + + + + + + ) } diff --git a/ui/src/components/generator/GeneratorFormTrack.tsx b/ui/src/components/generator/GeneratorFormTrack.tsx index 36aafde..e68ae23 100644 --- a/ui/src/components/generator/GeneratorFormTrack.tsx +++ b/ui/src/components/generator/GeneratorFormTrack.tsx @@ -49,60 +49,62 @@ export const GeneratorFormTrack = ({ form, nextStep, prevStep }: Props) => { } return ( -
-
- - - - + <> +
+
+ + + + -
handleExcludeTrack(track)} variant="subtle" color="red"> - }, - ]} - records={tracks ?? []} - noRecordsText="No tracks fit the parameters" - fetching={isPending || isLoading} - animate={false} - /> +
handleExcludeTrack(track)} variant="subtle" color="red"> + }, + ]} + records={tracks ?? []} + noRecordsText="No tracks fit the parameters" + fetching={isPending || isLoading} + animate={false} + /> + - - - - - +
+ -
- +
handleIncludeTrack(track)} variant="subtle" color="black"> + }, + ]} + records={excludedTracks ?? []} + noRecordsText="No excluded tracks" + fetching={isPending || isLoading} + animate={false} + /> + + -
handleIncludeTrack(track)} variant="subtle" color="black"> - }, - ]} - records={excludedTracks ?? []} - noRecordsText="No excluded tracks" - fetching={isPending || isLoading} - animate={false} - /> - - + + + + + ) } From 06fb073cb7c8b66fb7144baf289f49c37648e725 Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 3 Jan 2026 02:35:57 +0100 Subject: [PATCH 14/33] feat(generator): add crud api communication --- internal/generator/generator.go | 222 +---------------- internal/generator/playlist.go | 227 ++++++++++++++++++ internal/server/api/generator.go | 68 ++++++ internal/server/dto/generator.go | 61 ++++- internal/server/service/generator.go | 27 +++ ui/src/components/generator/GeneratorForm.tsx | 81 +++++-- .../generator/GeneratorFormPreset.tsx | 1 + ui/src/lib/api/generator.ts | 35 ++- ui/src/lib/types/generator.ts | 4 + ui/src/pages/generator/GeneratorEdit.tsx | 12 + 10 files changed, 500 insertions(+), 238 deletions(-) create mode 100644 internal/generator/playlist.go diff --git a/internal/generator/generator.go b/internal/generator/generator.go index e93bb69..1a6ddd6 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -3,13 +3,9 @@ package generator import ( "context" - "slices" - "strings" - "time" "github.com/topvennie/sortifyr/internal/database/model" "github.com/topvennie/sortifyr/internal/database/repository" - "github.com/topvennie/sortifyr/pkg/utils" ) type generator struct { @@ -26,218 +22,12 @@ func Init(repo repository.Repository) { } } -func (g *generator) Generate(ctx context.Context, gen model.Generator) ([]model.Track, error) { - var tracks []model.Track - var err error - - normalize(&gen.Params) - - switch gen.Params.Preset { - case model.GeneratorPresetForgotten: - tracks, err = g.forgotten(gen) - case model.GeneratorPresetTop: - tracks, err = g.top(ctx, gen) - case model.GeneratorPresetOldTop: - tracks, err = g.oldTop(ctx, gen) - - default: - tracks, err = g.custom(gen) - } - - if err != nil { - return nil, err - } - - return tracks, nil +func (g *generator) Create(ctx context.Context, gen model.Generator) error { + // TODO: Implement + return nil } -type trackPlayCount struct { - track model.Track - playCount int -} - -func (g *generator) custom(gen model.Generator) ([]model.Track, error) { - return nil, nil -} - -func (g *generator) forgotten(gen model.Generator) ([]model.Track, error) { - return nil, nil -} - -func (g *generator) top(ctx context.Context, gen model.Generator) ([]model.Track, error) { - params := gen.Params.ParamsTop - - // Get all excluded tracks - // Can be from the excluded tracks list - // Or the excluded playlists list - excludedPlaylistTracks, err := g.playlist.GetTrackByPlaylistIDs(ctx, gen.Params.ExcludedPlaylistIDs) - if err != nil { - return nil, err - } - excludedTracksMap := make(map[int]bool) - for _, t := range excludedPlaylistTracks { - excludedTracksMap[t.TrackID] = true - } - for _, t := range gen.Params.ExcludedTrackIDs { - excludedTracksMap[t] = true - } - - // Get history for last 14 days - skipped := false - history, err := g.history.GetPopulatedFiltered(ctx, model.HistoryFilter{ - UserID: gen.UserID, - Start: params.Window.Start, - Skipped: &skipped, - }) - if err != nil { - return nil, err - } - - playedAts := make(map[int][]time.Time) - for _, h := range history { - playedAt, ok := playedAts[h.TrackID] - if !ok { - playedAt = []time.Time{} - } - playedAt = append(playedAt, h.PlayedAt) - playedAts[h.TrackID] = playedAt - } - - tracks := make([]trackPlayCount, 0) - seen := make(map[int]bool) - for _, h := range history { - if ok := seen[h.TrackID]; ok { - continue - } - - seen[h.TrackID] = true - - // Did the user exclude it - if excludedTracksMap[h.TrackID] { - continue - } - - if hasBurst(playedAts[h.TrackID], params.Window) { - tracks = append(tracks, trackPlayCount{ - track: h.Track, - playCount: len(playedAts[h.TrackID]), - }) - } - } - - slices.SortFunc(tracks, func(a, b trackPlayCount) int { - if b.playCount == a.playCount { - return strings.Compare(a.track.Name, b.track.Name) - } - - return b.playCount - a.playCount - }) - tracks = tracks[:min(gen.Params.TrackAmount, len(tracks))] - slices.SortFunc(tracks, func(a, b trackPlayCount) int { return strings.Compare(a.track.Name, b.track.Name) }) - - return utils.SliceMap(tracks, func(t trackPlayCount) model.Track { return t.track }), nil -} - -func (g *generator) oldTop(ctx context.Context, gen model.Generator) ([]model.Track, error) { - params := gen.Params.ParamsOldTop - - // Get all excluded tracks - // Can be from the excluded tracks list - // Or the excluded playlists list - excludedPlaylistTracks, err := g.playlist.GetTrackByPlaylistIDs(ctx, gen.Params.ExcludedPlaylistIDs) - if err != nil { - return nil, err - } - excludedTracksMap := make(map[int]bool) - for _, t := range excludedPlaylistTracks { - excludedTracksMap[t.TrackID] = true - } - for _, t := range gen.Params.ExcludedTrackIDs { - excludedTracksMap[t] = true - } - - // Get the relevant recent history - skipped := false - recent, err := g.history.GetPopulatedFiltered(ctx, model.HistoryFilter{ - UserID: gen.UserID, - Start: params.RecentWindow.Start, - End: params.RecentWindow.End, - Skipped: &skipped, - }) - if err != nil { - return nil, err - } - - // Get all play times for each track - recentPlayedAts := make(map[int][]time.Time) - for _, r := range recent { - playedAt, ok := recentPlayedAts[r.TrackID] - if !ok { - playedAt = []time.Time{} - } - recentPlayedAts[r.TrackID] = append(playedAt, r.PlayedAt) - } - - // Get all relevant peak history - old, err := g.history.GetPopulatedFiltered(ctx, model.HistoryFilter{ - UserID: gen.UserID, - Start: params.PeakWindow.Start, - End: params.PeakWindow.End, - Skipped: &skipped, - }) - if err != nil { - return nil, err - } - - // Get all play times for each track - oldPlayedAts := make(map[int][]time.Time) - for _, o := range old { - playedAt, ok := oldPlayedAts[o.TrackID] - if !ok { - playedAt = []time.Time{} - } - oldPlayedAts[o.TrackID] = append(playedAt, o.PlayedAt) - } - - tracks := make([]trackPlayCount, 0) - seen := make(map[int]bool) - for _, o := range old { - if ok := seen[o.TrackID]; ok { - continue - } - - seen[o.TrackID] = true - - // Did the user exclude it - if excludedTracksMap[o.TrackID] { - continue - } - - // Did we play it too much recently? - if hasBurst(recentPlayedAts[o.TrackID], params.RecentWindow) { - continue - } - - // Did we play it enough times in the past? - if !hasBurst(oldPlayedAts[o.TrackID], params.PeakWindow) { - continue - } - - tracks = append(tracks, trackPlayCount{ - track: o.Track, - playCount: len(oldPlayedAts[o.TrackID]), - }) - } - - slices.SortFunc(tracks, func(a, b trackPlayCount) int { - if b.playCount == a.playCount { - return strings.Compare(a.track.Name, b.track.Name) - } - - return b.playCount - a.playCount - }) - tracks = tracks[:min(gen.Params.TrackAmount, len(tracks))] - slices.SortFunc(tracks, func(a, b trackPlayCount) int { return strings.Compare(a.track.Name, b.track.Name) }) - - return utils.SliceMap(tracks, func(t trackPlayCount) model.Track { return t.track }), nil +func (g *generator) Edit(ctx context.Context, gen model.Generator) error { + // TODO: IMplement + return nil } diff --git a/internal/generator/playlist.go b/internal/generator/playlist.go new file mode 100644 index 0000000..d5409bd --- /dev/null +++ b/internal/generator/playlist.go @@ -0,0 +1,227 @@ +package generator + +import ( + "context" + "slices" + "strings" + "time" + + "github.com/topvennie/sortifyr/internal/database/model" + "github.com/topvennie/sortifyr/pkg/utils" +) + +func (g *generator) Generate(ctx context.Context, gen model.Generator) ([]model.Track, error) { + var tracks []model.Track + var err error + + normalize(&gen.Params) + + switch gen.Params.Preset { + case model.GeneratorPresetForgotten: + tracks, err = g.forgotten(gen) + case model.GeneratorPresetTop: + tracks, err = g.top(ctx, gen) + case model.GeneratorPresetOldTop: + tracks, err = g.oldTop(ctx, gen) + + default: + tracks, err = g.custom(gen) + } + + if err != nil { + return nil, err + } + + return tracks, nil +} + +type trackPlayCount struct { + track model.Track + playCount int +} + +func (g *generator) custom(gen model.Generator) ([]model.Track, error) { + return nil, nil +} + +func (g *generator) forgotten(gen model.Generator) ([]model.Track, error) { + return nil, nil +} + +func (g *generator) top(ctx context.Context, gen model.Generator) ([]model.Track, error) { + params := gen.Params.ParamsTop + + // Get all excluded tracks + // Can be from the excluded tracks list + // Or the excluded playlists list + excludedPlaylistTracks, err := g.playlist.GetTrackByPlaylistIDs(ctx, gen.Params.ExcludedPlaylistIDs) + if err != nil { + return nil, err + } + excludedTracksMap := make(map[int]bool) + for _, t := range excludedPlaylistTracks { + excludedTracksMap[t.TrackID] = true + } + for _, t := range gen.Params.ExcludedTrackIDs { + excludedTracksMap[t] = true + } + + // Get history for last 14 days + skipped := false + history, err := g.history.GetPopulatedFiltered(ctx, model.HistoryFilter{ + UserID: gen.UserID, + Start: params.Window.Start, + Skipped: &skipped, + }) + if err != nil { + return nil, err + } + + playedAts := make(map[int][]time.Time) + for _, h := range history { + playedAt, ok := playedAts[h.TrackID] + if !ok { + playedAt = []time.Time{} + } + playedAt = append(playedAt, h.PlayedAt) + playedAts[h.TrackID] = playedAt + } + + tracks := make([]trackPlayCount, 0) + seen := make(map[int]bool) + for _, h := range history { + if ok := seen[h.TrackID]; ok { + continue + } + + seen[h.TrackID] = true + + // Did the user exclude it + if excludedTracksMap[h.TrackID] { + continue + } + + if hasBurst(playedAts[h.TrackID], params.Window) { + tracks = append(tracks, trackPlayCount{ + track: h.Track, + playCount: len(playedAts[h.TrackID]), + }) + } + } + + slices.SortFunc(tracks, func(a, b trackPlayCount) int { + if b.playCount == a.playCount { + return strings.Compare(a.track.Name, b.track.Name) + } + + return b.playCount - a.playCount + }) + tracks = tracks[:min(gen.Params.TrackAmount, len(tracks))] + slices.SortFunc(tracks, func(a, b trackPlayCount) int { return strings.Compare(a.track.Name, b.track.Name) }) + + return utils.SliceMap(tracks, func(t trackPlayCount) model.Track { return t.track }), nil +} + +func (g *generator) oldTop(ctx context.Context, gen model.Generator) ([]model.Track, error) { + params := gen.Params.ParamsOldTop + + // Get all excluded tracks + // Can be from the excluded tracks list + // Or the excluded playlists list + excludedPlaylistTracks, err := g.playlist.GetTrackByPlaylistIDs(ctx, gen.Params.ExcludedPlaylistIDs) + if err != nil { + return nil, err + } + excludedTracksMap := make(map[int]bool) + for _, t := range excludedPlaylistTracks { + excludedTracksMap[t.TrackID] = true + } + for _, t := range gen.Params.ExcludedTrackIDs { + excludedTracksMap[t] = true + } + + // Get the relevant recent history + skipped := false + recent, err := g.history.GetPopulatedFiltered(ctx, model.HistoryFilter{ + UserID: gen.UserID, + Start: params.RecentWindow.Start, + End: params.RecentWindow.End, + Skipped: &skipped, + }) + if err != nil { + return nil, err + } + + // Get all play times for each track + recentPlayedAts := make(map[int][]time.Time) + for _, r := range recent { + playedAt, ok := recentPlayedAts[r.TrackID] + if !ok { + playedAt = []time.Time{} + } + recentPlayedAts[r.TrackID] = append(playedAt, r.PlayedAt) + } + + // Get all relevant peak history + old, err := g.history.GetPopulatedFiltered(ctx, model.HistoryFilter{ + UserID: gen.UserID, + Start: params.PeakWindow.Start, + End: params.PeakWindow.End, + Skipped: &skipped, + }) + if err != nil { + return nil, err + } + + // Get all play times for each track + oldPlayedAts := make(map[int][]time.Time) + for _, o := range old { + playedAt, ok := oldPlayedAts[o.TrackID] + if !ok { + playedAt = []time.Time{} + } + oldPlayedAts[o.TrackID] = append(playedAt, o.PlayedAt) + } + + tracks := make([]trackPlayCount, 0) + seen := make(map[int]bool) + for _, o := range old { + if ok := seen[o.TrackID]; ok { + continue + } + + seen[o.TrackID] = true + + // Did the user exclude it + if excludedTracksMap[o.TrackID] { + continue + } + + // Did we play it too much recently? + if hasBurst(recentPlayedAts[o.TrackID], params.RecentWindow) { + continue + } + + // Did we play it enough times in the past? + if !hasBurst(oldPlayedAts[o.TrackID], params.PeakWindow) { + continue + } + + tracks = append(tracks, trackPlayCount{ + track: o.Track, + playCount: len(oldPlayedAts[o.TrackID]), + }) + } + + slices.SortFunc(tracks, func(a, b trackPlayCount) int { + if b.playCount == a.playCount { + return strings.Compare(a.track.Name, b.track.Name) + } + + return b.playCount - a.playCount + }) + tracks = tracks[:min(gen.Params.TrackAmount, len(tracks))] + slices.SortFunc(tracks, func(a, b trackPlayCount) int { return strings.Compare(a.track.Name, b.track.Name) }) + + return utils.SliceMap(tracks, func(t trackPlayCount) model.Track { return t.track }), nil +} diff --git a/internal/server/api/generator.go b/internal/server/api/generator.go index 4f511a9..01ec658 100644 --- a/internal/server/api/generator.go +++ b/internal/server/api/generator.go @@ -24,7 +24,24 @@ func NewGenerator(router fiber.Router, service service.Service) *Generator { } func (g *Generator) createRoutes() { + g.router.Get("/", g.getAll) g.router.Post("/preview", g.preview) + g.router.Put("/", g.create) + g.router.Post("/:id", g.edit) +} + +func (g *Generator) getAll(c *fiber.Ctx) error { + userID, ok := c.Locals("userID").(int) + if !ok { + return fiber.ErrUnauthorized + } + + generators, err := g.generator.GetByUser(c.Context(), userID) + if err != nil { + return err + } + + return c.JSON(generators) } func (g *Generator) preview(c *fiber.Ctx) error { @@ -48,3 +65,54 @@ func (g *Generator) preview(c *fiber.Ctx) error { return c.JSON(tracks) } + +func (g *Generator) create(c *fiber.Ctx) error { + userID, ok := c.Locals("userID").(int) + if !ok { + return fiber.ErrUnauthorized + } + + var generator dto.Generator + if err := c.BodyParser(&generator); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + if err := dto.Validate.Struct(generator); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + generator.ID = 0 + + generator, err := g.generator.Create(c.Context(), userID, generator) + if err != nil { + return err + } + + return c.JSON(generator) +} + +func (g *Generator) edit(c *fiber.Ctx) error { + userID, ok := c.Locals("userID").(int) + if !ok { + return fiber.ErrUnauthorized + } + + id, err := c.ParamsInt(":id") + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + var generator dto.Generator + if err := c.BodyParser(&generator); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + if err := dto.Validate.Struct(generator); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + generator.ID = id + + generator, err = g.generator.Edit(c.Context(), userID, generator) + if err != nil { + return err + } + + return c.JSON(generator) +} diff --git a/internal/server/dto/generator.go b/internal/server/dto/generator.go index 85a8644..3883675 100644 --- a/internal/server/dto/generator.go +++ b/internal/server/dto/generator.go @@ -13,7 +13,7 @@ type GeneratorWindow struct { BurstIntervalS time.Duration `json:"burst_interval_s"` } -func GeneratorWindowDTO(g model.GeneratorWindow) GeneratorWindow { +func generatorWindowDTO(g model.GeneratorWindow) GeneratorWindow { return GeneratorWindow{ Start: g.Start, End: g.End, @@ -33,12 +33,28 @@ func (g GeneratorWindow) ToModel() *model.GeneratorWindow { type GeneratorPresetCustomParams struct{} +func generatorPresetCustomParamsDTO(params *model.GeneratorPresetCustomParams) *GeneratorPresetCustomParams { + if params == nil { + return nil + } + + return &GeneratorPresetCustomParams{} +} + func (g GeneratorPresetCustomParams) ToModel() *model.GeneratorPresetCustomParams { return &model.GeneratorPresetCustomParams{} } type GeneratorPresetForgottenParams struct{} +func generatorPresetForgottenParamsDTO(params *model.GeneratorPresetForgottenParams) *GeneratorPresetForgottenParams { + if params == nil { + return nil + } + + return &GeneratorPresetForgottenParams{} +} + func (g GeneratorPresetForgottenParams) ToModel() *model.GeneratorPresetForgottenParams { return &model.GeneratorPresetForgottenParams{} } @@ -47,6 +63,16 @@ type GeneratorPresetTopParams struct { Window GeneratorWindow `json:"window"` } +func generatorPresetTopParamsDTO(params *model.GeneratorPresetTopParams) *GeneratorPresetTopParams { + if params == nil { + return nil + } + + return &GeneratorPresetTopParams{ + Window: generatorWindowDTO(params.Window), + } +} + func (g GeneratorPresetTopParams) ToModel() *model.GeneratorPresetTopParams { return &model.GeneratorPresetTopParams{ Window: *g.Window.ToModel(), @@ -58,6 +84,17 @@ type GeneratorPresetOldTopParams struct { RecentWindow GeneratorWindow `json:"recent_window"` } +func generatorPresetOldTopParamsDTO(params *model.GeneratorPresetOldTopParams) *GeneratorPresetOldTopParams { + if params == nil { + return nil + } + + return &GeneratorPresetOldTopParams{ + PeakWindow: generatorWindowDTO(params.PeakWindow), + RecentWindow: generatorWindowDTO(params.RecentWindow), + } +} + func (g GeneratorPresetOldTopParams) ToModel() *model.GeneratorPresetOldTopParams { return &model.GeneratorPresetOldTopParams{ PeakWindow: *g.PeakWindow.ToModel(), @@ -78,6 +115,19 @@ type GeneratorParams struct { ParamsOldTop *GeneratorPresetOldTopParams `json:"params_old_top,omitzero"` } +func generatorParamsDTO(params model.GeneratorParams) GeneratorParams { + return GeneratorParams{ + TrackAmount: params.TrackAmount, + ExcludedPlaylistIDs: params.ExcludedPlaylistIDs, + ExcludedTrackIDs: params.ExcludedTrackIDs, + Preset: params.Preset, + ParamsCustom: generatorPresetCustomParamsDTO(params.ParamsCustom), + ParamsForgotten: generatorPresetForgottenParamsDTO(params.ParamsForgotten), + ParamsTop: generatorPresetTopParamsDTO(params.ParamsTop), + ParamsOldTop: generatorPresetOldTopParamsDTO(params.ParamsOldTop), + } +} + func (g GeneratorParams) ToModel() model.GeneratorParams { var paramsCustom *model.GeneratorPresetCustomParams if g.ParamsCustom != nil { @@ -114,6 +164,15 @@ type Generator struct { Params GeneratorParams `json:"params" validate:"required"` } +func GeneratorDTO(gen *model.Generator) Generator { + return Generator{ + ID: gen.ID, + Name: gen.Name, + Description: gen.Description, + Params: generatorParamsDTO(gen.Params), + } +} + func (g Generator) ToModel(userID int) *model.Generator { return &model.Generator{ ID: g.ID, diff --git a/internal/server/service/generator.go b/internal/server/service/generator.go index 65295c9..5affd18 100644 --- a/internal/server/service/generator.go +++ b/internal/server/service/generator.go @@ -17,6 +17,11 @@ func (s *Service) NewGenerator() *Generator { return &Generator{} } +func (g *Generator) GetByUser(ctx context.Context, userID int) ([]dto.Generator, error) { + // TODO: Implement + return []dto.Generator{}, nil +} + func (g *Generator) Preview(ctx context.Context, userID int, params dto.GeneratorParams) ([]dto.Track, error) { gen := model.Generator{ UserID: userID, @@ -30,3 +35,25 @@ func (g *Generator) Preview(ctx context.Context, userID int, params dto.Generato return utils.SliceMap(tracks, func(t model.Track) dto.Track { return dto.TrackDTO(&t) }), nil } + +func (g *Generator) Create(ctx context.Context, userID int, genSave dto.Generator) (dto.Generator, error) { + gen := genSave.ToModel(userID) + + if err := generator.G.Create(ctx, *gen); err != nil { + zap.S().Error(err) + return dto.Generator{}, fiber.ErrInternalServerError + } + + return dto.GeneratorDTO(gen), nil +} + +func (g *Generator) Edit(ctx context.Context, userID int, genSave dto.Generator) (dto.Generator, error) { + gen := genSave.ToModel(userID) + + if err := generator.G.Edit(ctx, *gen); err != nil { + zap.S().Error(err) + return dto.Generator{}, fiber.ErrInternalServerError + } + + return dto.GeneratorDTO(gen), nil +} diff --git a/ui/src/components/generator/GeneratorForm.tsx b/ui/src/components/generator/GeneratorForm.tsx index 9534e58..3a7c87d 100644 --- a/ui/src/components/generator/GeneratorForm.tsx +++ b/ui/src/components/generator/GeneratorForm.tsx @@ -1,5 +1,5 @@ -import { GeneratorPreset, generatorSchema, GeneratorSchema } from "@/lib/types/generator"; -import { daysAgo } from "@/lib/utils"; +import { convertGeneratorSchema, Generator, GeneratorPreset, generatorSchema, GeneratorSchema } from "@/lib/types/generator"; +import { daysAgo, getErrorMessage } from "@/lib/utils"; import { useForm } from "@mantine/form"; import { useNavigate } from "@tanstack/react-router"; import { zod4Resolver } from "mantine-form-zod-resolver"; @@ -9,26 +9,32 @@ import { GeneratorFormPreset } from "./GeneratorFormPreset"; import { GeneratorFormTrack } from "./GeneratorFormTrack"; import { Stack } from "@mantine/core"; import { GeneratorFormFinalize } from "./GeneratorFormFinalize"; +import { useGeneratorCreate, useGeneratorEdit } from "@/lib/api/generator"; +import { notifications } from "@mantine/notifications"; +import { Confirm } from "../molecules/Confirm"; +import { useDisclosure } from "@mantine/hooks"; + +type Props = { + generator?: Generator; +} const steps: Step[] = [ - { - title: "Preset & Parameters", - }, - { - title: "Select Tracks", - }, - { - title: "Finalize & Save", - } + { title: "Preset & Parameters" }, + { title: "Select Tracks" }, + { title: "Finalize & Save" } ] -export const GeneratorForm = () => { +export const GeneratorForm = ({ generator: initialGenerator }: Props) => { const [active, setActive] = useState(0) + const [opened, { open, close }] = useDisclosure() + + const generatorCreate = useGeneratorCreate() + const generatorEdit = useGeneratorEdit() const navigate = useNavigate() const form = useForm({ - initialValues: { + initialValues: initialGenerator ? convertGeneratorSchema(initialGenerator) : { name: "", description: undefined, params: { @@ -65,11 +71,42 @@ export const GeneratorForm = () => { validate: zod4Resolver(generatorSchema), }) + const handleSubmit = () => { + if (form.validate().hasErrors) { + notifications.show({ color: "red", message: "Some parameters are invalid" }) + console.error(form.errors) + return + } + + const update = !!initialGenerator + + let action + if (update) action = generatorEdit + else action = generatorCreate + + action.mutateAsync(form.getValues(), { + onSuccess: () => notifications.show({ title: form.getValues().name, message: `Generator ${update ? "updated" : "created"}` }), + onError: async (error) => { + const msg = await getErrorMessage(error) + notifications.show({ color: "red", message: msg }) + }, + onSettled: () => close(), + }) + } + const handleNextStep = () => { if (active < steps.length - 1) { setActive(prev => prev + 1) return } + + if (form.validate().hasErrors) { + notifications.show({ color: "red", message: "Some parameters are invalid" }) + console.error(form.errors) + return + } + + open() } const handlePrevStep = () => { @@ -95,9 +132,19 @@ export const GeneratorForm = () => { } return ( - - - {stepComponent()} - + <> + + + {stepComponent()} + + + ) } diff --git a/ui/src/components/generator/GeneratorFormPreset.tsx b/ui/src/components/generator/GeneratorFormPreset.tsx index a50a287..2ff9031 100644 --- a/ui/src/components/generator/GeneratorFormPreset.tsx +++ b/ui/src/components/generator/GeneratorFormPreset.tsx @@ -29,6 +29,7 @@ export const GeneratorFormPreset = ({ form, nextStep, prevStep }: Props) => { const handleRefetchTracks = () => { if (form.validateField("params").hasError) { notifications.show({ color: "red", message: "Some parameters are invalid" }) + console.error(form.errors) return } diff --git a/ui/src/lib/api/generator.ts b/ui/src/lib/api/generator.ts index 8e5bcd3..b6f4303 100644 --- a/ui/src/lib/api/generator.ts +++ b/ui/src/lib/api/generator.ts @@ -1,14 +1,41 @@ -import { useMutation } from "@tanstack/react-query" -import { GeneratorSchema } from "../types/generator" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { convertGenerators, GeneratorSchema } from "../types/generator" import { convertTracks } from "../types/track" -import { apiPost } from "./query" +import { apiGet, apiPost, apiPut } from "./query" +import { STALE_TIME } from "../types/staletime" const ENDPOINT = "generator" +export const useGeneratorGetAll = () => { + return useQuery({ + queryKey: ["generator"], + queryFn: async () => (await apiGet(ENDPOINT, convertGenerators)).data, + staleTime: STALE_TIME.MIN_30, + throwOnError: true, + }) +} + export const useGeneratorPreview = () => { return useMutation({ - mutationKey: ["generator", "preview"], mutationFn: async (generator: GeneratorSchema) => (await apiPost(`${ENDPOINT}/preview`, generator.params, convertTracks)).data, throwOnError: true, }) } + +export const useGeneratorCreate = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (generator: GeneratorSchema) => apiPut(ENDPOINT, generator), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["generator"] }), + }) +} + +export const useGeneratorEdit = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (generator: GeneratorSchema) => apiPost(`${ENDPOINT}/${generator.id}`, generator), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["generator"] }), + }) +} diff --git a/ui/src/lib/types/generator.ts b/ui/src/lib/types/generator.ts index 7bc918b..6c28394 100644 --- a/ui/src/lib/types/generator.ts +++ b/ui/src/lib/types/generator.ts @@ -75,6 +75,10 @@ export const convertGenerator = (g: API.Generator): Generator => { } } +export const convertGenerators = (g: API.Generator[]): Generator[] => { + return g.map(convertGenerator) +} + export const convertGeneratorSchema = (g: Generator): GeneratorSchema => { return { ...g diff --git a/ui/src/pages/generator/GeneratorEdit.tsx b/ui/src/pages/generator/GeneratorEdit.tsx index 9993213..c00a890 100644 --- a/ui/src/pages/generator/GeneratorEdit.tsx +++ b/ui/src/pages/generator/GeneratorEdit.tsx @@ -1,14 +1,26 @@ import { Page, PageTitle } from "@/components/atoms/Page" +import { LoadingSpinner } from "@/components/molecules/LoadingSpinner" +import { useGeneratorGetAll } from "@/lib/api/generator" import { useParams } from "@tanstack/react-router" +import { Error404 } from "../404" +import { GeneratorForm } from "@/components/generator/GeneratorForm" export const GeneratorEdit = () => { const { generatorId } = useParams({ from: "/public-layout/generator/edit/$generatorId" }) + const { data: generators, isLoading } = useGeneratorGetAll() + + if (isLoading) return + + const generator = generators?.find(g => g.id.toString() === generatorId) + if (!generator) return return ( + + ) } From bd63eab2c762f8ebd5483917650c21eb36d4b1f1 Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 3 Jan 2026 12:02:31 +0100 Subject: [PATCH 15/33] feat(generator): add spotify playlist integration in ui --- .../20251231183248_add_generator_table.sql | 8 ++- db/queries/generator.sql | 5 +- internal/database/model/generator.go | 2 + internal/database/model/util.go | 8 +++ internal/generator/generator.go | 4 +- internal/server/dto/generator.go | 10 ++- internal/server/service/generator.go | 18 ++++- pkg/sqlc/generator.sql.go | 11 ++- pkg/sqlc/models.go | 1 + ui/src/components/generator/GeneratorForm.tsx | 22 +++--- .../generator/GeneratorFormFinalize.tsx | 69 ++++++++++++++----- ui/src/lib/types/api.d.ts | 3 + ui/src/lib/types/generator.ts | 9 +++ 13 files changed, 135 insertions(+), 35 deletions(-) diff --git a/db/migrations/20251231183248_add_generator_table.sql b/db/migrations/20251231183248_add_generator_table.sql index 913ceeb..3ffb7a6 100644 --- a/db/migrations/20251231183248_add_generator_table.sql +++ b/db/migrations/20251231183248_add_generator_table.sql @@ -7,7 +7,13 @@ CREATE TABLE generators ( description TEXT, playlist_id INTEGER REFERENCES playlists (id), maintained BOOL NOT NULL, - parameters JSONB + interval INTEGER, + parameters JSONB, + + CONSTRAINT generators_interval_required_if_maintained CHECK ( + maintained = false + OR interval IS NOT NULL + ) ); -- +goose StatementEnd diff --git a/db/queries/generator.sql b/db/queries/generator.sql index 880ed60..b901992 100644 --- a/db/queries/generator.sql +++ b/db/queries/generator.sql @@ -1,6 +1,6 @@ -- name: GeneratorCreate :one -INSERT INTO generators (user_id, name, description, playlist_id, maintained, parameters) -VALUES ($1, $2, $3, $4, $5, $6) +INSERT INTO generators (user_id, name, description, playlist_id, maintained, interval, parameters) +VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id; -- name: GeneratorUpdate :exec @@ -10,5 +10,6 @@ SET description = coalesce(sqlc.narg('description'), description), playlist_id = coalesce(sqlc.narg('playlist_id'), playlist_id), maintained = coalesce(sqlc.narg('maintained'), maintained), + interval = coalesce(sqlc.narg('interval'), interval), parameters = coalesce(sqlc.narg('parameters'), parameters) WHERE id = $1; diff --git a/internal/database/model/generator.go b/internal/database/model/generator.go index b1dc7ec..7fda9ae 100644 --- a/internal/database/model/generator.go +++ b/internal/database/model/generator.go @@ -58,6 +58,7 @@ type Generator struct { Description string PlaylistID int Maintained bool + Interval time.Duration Params GeneratorParams } @@ -72,6 +73,7 @@ func GeneratorModel(g sqlc.Generator) *Generator { Description: fromString(g.Description), PlaylistID: fromInt(g.PlaylistID), Maintained: g.Maintained, + Interval: fromDuration(g.Interval), Params: params, } } diff --git a/internal/database/model/util.go b/internal/database/model/util.go index 69b8fc8..732a215 100644 --- a/internal/database/model/util.go +++ b/internal/database/model/util.go @@ -37,3 +37,11 @@ func fromTime(t pgtype.Timestamptz) time.Time { return time.Time{} } + +func fromDuration(i pgtype.Int4) time.Duration { + if i.Valid { + return time.Duration(i.Int32) + } + + return time.Duration(0) +} diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 1a6ddd6..8da1ab0 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -22,12 +22,12 @@ func Init(repo repository.Repository) { } } -func (g *generator) Create(ctx context.Context, gen model.Generator) error { +func (g *generator) Create(ctx context.Context, gen model.Generator, playlist bool) error { // TODO: Implement return nil } -func (g *generator) Edit(ctx context.Context, gen model.Generator) error { +func (g *generator) Edit(ctx context.Context, gen model.Generator, playlist bool) error { // TODO: IMplement return nil } diff --git a/internal/server/dto/generator.go b/internal/server/dto/generator.go index 3883675..f650714 100644 --- a/internal/server/dto/generator.go +++ b/internal/server/dto/generator.go @@ -18,7 +18,7 @@ func generatorWindowDTO(g model.GeneratorWindow) GeneratorWindow { Start: g.Start, End: g.End, MinPlays: g.MinPlays, - BurstIntervalS: g.BurstInterval / 1000000, + BurstIntervalS: g.BurstInterval / time.Second, } } @@ -161,6 +161,9 @@ type Generator struct { ID int `json:"id"` Name string `json:"name" validate:"required"` Description string `json:"description,omitzero"` + Playlist bool `json:"playlist"` + Maintained bool `json:"maintained"` + IntervalS time.Duration `json:"interval_s"` Params GeneratorParams `json:"params" validate:"required"` } @@ -169,6 +172,9 @@ func GeneratorDTO(gen *model.Generator) Generator { ID: gen.ID, Name: gen.Name, Description: gen.Description, + Playlist: gen.PlaylistID != 0, + Maintained: gen.Maintained, + IntervalS: gen.Interval / time.Second, Params: generatorParamsDTO(gen.Params), } } @@ -179,6 +185,8 @@ func (g Generator) ToModel(userID int) *model.Generator { UserID: userID, Name: g.Name, Description: g.Description, + Maintained: g.Maintained, + Interval: g.IntervalS * time.Second, Params: g.Params.ToModel(), } } diff --git a/internal/server/service/generator.go b/internal/server/service/generator.go index 5affd18..ca16339 100644 --- a/internal/server/service/generator.go +++ b/internal/server/service/generator.go @@ -39,7 +39,14 @@ func (g *Generator) Preview(ctx context.Context, userID int, params dto.Generato func (g *Generator) Create(ctx context.Context, userID int, genSave dto.Generator) (dto.Generator, error) { gen := genSave.ToModel(userID) - if err := generator.G.Create(ctx, *gen); err != nil { + if !genSave.Playlist { + gen.Maintained = false + } + if !genSave.Maintained { + gen.Interval = 0 + } + + if err := generator.G.Create(ctx, *gen, genSave.Playlist); err != nil { zap.S().Error(err) return dto.Generator{}, fiber.ErrInternalServerError } @@ -50,7 +57,14 @@ func (g *Generator) Create(ctx context.Context, userID int, genSave dto.Generato func (g *Generator) Edit(ctx context.Context, userID int, genSave dto.Generator) (dto.Generator, error) { gen := genSave.ToModel(userID) - if err := generator.G.Edit(ctx, *gen); err != nil { + if !genSave.Playlist { + gen.Maintained = false + } + if !genSave.Maintained { + gen.Interval = 0 + } + + if err := generator.G.Edit(ctx, *gen, genSave.Playlist); err != nil { zap.S().Error(err) return dto.Generator{}, fiber.ErrInternalServerError } diff --git a/pkg/sqlc/generator.sql.go b/pkg/sqlc/generator.sql.go index 7ae9104..78c83bd 100644 --- a/pkg/sqlc/generator.sql.go +++ b/pkg/sqlc/generator.sql.go @@ -12,8 +12,8 @@ import ( ) const generatorCreate = `-- name: GeneratorCreate :one -INSERT INTO generators (user_id, name, description, playlist_id, maintained, parameters) -VALUES ($1, $2, $3, $4, $5, $6) +INSERT INTO generators (user_id, name, description, playlist_id, maintained, interval, parameters) +VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id ` @@ -23,6 +23,7 @@ type GeneratorCreateParams struct { Description pgtype.Text PlaylistID pgtype.Int4 Maintained bool + Interval pgtype.Int4 Parameters []byte } @@ -33,6 +34,7 @@ func (q *Queries) GeneratorCreate(ctx context.Context, arg GeneratorCreateParams arg.Description, arg.PlaylistID, arg.Maintained, + arg.Interval, arg.Parameters, ) var id int32 @@ -47,7 +49,8 @@ SET description = coalesce($3, description), playlist_id = coalesce($4, playlist_id), maintained = coalesce($5, maintained), - parameters = coalesce($6, parameters) + interval = coalesce($6, interval), + parameters = coalesce($7, parameters) WHERE id = $1 ` @@ -57,6 +60,7 @@ type GeneratorUpdateParams struct { Description pgtype.Text PlaylistID pgtype.Int4 Maintained pgtype.Bool + Interval pgtype.Int4 Parameters []byte } @@ -67,6 +71,7 @@ func (q *Queries) GeneratorUpdate(ctx context.Context, arg GeneratorUpdateParams arg.Description, arg.PlaylistID, arg.Maintained, + arg.Interval, arg.Parameters, ) return err diff --git a/pkg/sqlc/models.go b/pkg/sqlc/models.go index a5b0efc..a2538a0 100644 --- a/pkg/sqlc/models.go +++ b/pkg/sqlc/models.go @@ -108,6 +108,7 @@ type Generator struct { Description pgtype.Text PlaylistID pgtype.Int4 Maintained bool + Interval pgtype.Int4 Parameters []byte } diff --git a/ui/src/components/generator/GeneratorForm.tsx b/ui/src/components/generator/GeneratorForm.tsx index 3a7c87d..a0bde0b 100644 --- a/ui/src/components/generator/GeneratorForm.tsx +++ b/ui/src/components/generator/GeneratorForm.tsx @@ -1,18 +1,18 @@ +import { useGeneratorCreate, useGeneratorEdit } from "@/lib/api/generator"; import { convertGeneratorSchema, Generator, GeneratorPreset, generatorSchema, GeneratorSchema } from "@/lib/types/generator"; import { daysAgo, getErrorMessage } from "@/lib/utils"; +import { Stack } from "@mantine/core"; import { useForm } from "@mantine/form"; +import { useDisclosure } from "@mantine/hooks"; +import { notifications } from "@mantine/notifications"; import { useNavigate } from "@tanstack/react-router"; import { zod4Resolver } from "mantine-form-zod-resolver"; -import { useMemo, useState } from "react"; +import { useState } from "react"; +import { Confirm } from "../molecules/Confirm"; import { Step, Stepper } from "../molecules/Stepper"; +import { GeneratorFormFinalize } from "./GeneratorFormFinalize"; import { GeneratorFormPreset } from "./GeneratorFormPreset"; import { GeneratorFormTrack } from "./GeneratorFormTrack"; -import { Stack } from "@mantine/core"; -import { GeneratorFormFinalize } from "./GeneratorFormFinalize"; -import { useGeneratorCreate, useGeneratorEdit } from "@/lib/api/generator"; -import { notifications } from "@mantine/notifications"; -import { Confirm } from "../molecules/Confirm"; -import { useDisclosure } from "@mantine/hooks"; type Props = { generator?: Generator; @@ -37,6 +37,9 @@ export const GeneratorForm = ({ generator: initialGenerator }: Props) => { initialValues: initialGenerator ? convertGeneratorSchema(initialGenerator) : { name: "", description: undefined, + playlist: false, + maintained: false, + intervalS: 0, params: { trackAmount: 50, excludedPlaylistIds: undefined, @@ -85,7 +88,10 @@ export const GeneratorForm = ({ generator: initialGenerator }: Props) => { else action = generatorCreate action.mutateAsync(form.getValues(), { - onSuccess: () => notifications.show({ title: form.getValues().name, message: `Generator ${update ? "updated" : "created"}` }), + onSuccess: () => { + notifications.show({ title: form.getValues().name, message: `Generator ${update ? "updated" : "created"}` }) + navigate({ to: "/generator" }) + }, onError: async (error) => { const msg = await getErrorMessage(error) notifications.show({ color: "red", message: msg }) diff --git a/ui/src/components/generator/GeneratorFormFinalize.tsx b/ui/src/components/generator/GeneratorFormFinalize.tsx index af7c3fe..68f4431 100644 --- a/ui/src/components/generator/GeneratorFormFinalize.tsx +++ b/ui/src/components/generator/GeneratorFormFinalize.tsx @@ -1,8 +1,9 @@ import { GeneratorSchema } from "@/lib/types/generator"; -import { Group, TextInput } from "@mantine/core"; +import { Group, NumberInput, Stack, Switch, TextInput } from "@mantine/core"; import { UseFormReturnType } from "@mantine/form"; import { Button } from "../atoms/Button"; import { Section, SectionTitle } from "../atoms/Page"; +import { useState } from "react"; type Props = { form: UseFormReturnType @@ -11,6 +12,40 @@ type Props = { } export const GeneratorFormFinalize = ({ form, nextStep, prevStep }: Props) => { + const [playlist, setPlaylist] = useState(form.getValues().playlist) + const [maintained, setMaintained] = useState(form.getValues().maintained) + const [interval, setInterval] = useState(form.getValues().intervalS) + + const handleTogglePlaylist = () => { + form.setFieldValue("playlist", !playlist) + setPlaylist(prev => !prev) + + if (!playlist) { + form.setFieldValue("maintained", false) + form.setFieldValue("intervalS", 0) + setMaintained(false) + } + } + + const handleToggleMaintained = () => { + form.setFieldValue("maintained", !maintained) + setMaintained(prev => !prev) + + if (!maintained) { + form.setFieldValue("intervalS", 0) + } + } + + const handleChangeInterval = (interval: number | string) => { + let newValue = 0 + if (typeof interval === 'number') { + newValue = interval * 24 * 60 * 60 + } + + form.setFieldValue("intervalS", newValue) + setInterval(interval) + } + return ( <>
@@ -21,21 +56,23 @@ export const GeneratorFormFinalize = ({ form, nextStep, prevStep }: Props) => { - {/* */} - {/* */} - {/*

Create Spotify playlist

*/} - {/*

If set to off, the generator only shows a preview.

*/} - {/*
*/} - {/* */} - {/*
*/} - {/**/} - {/* */} - {/* */} - {/*

Auto-maintain playlist

*/} - {/*

Keep the playlist in sync with the new listening data.

*/} - {/*
*/} - {/* */} - {/*
*/} + + +

Create Spotify playlist

+

If set to off, the generator only shows a preview.

+
+ +
+ + + +

Auto-maintain playlist

+

Keep the playlist in sync with the new listening data.

+
+ +
+ +
diff --git a/ui/src/lib/types/api.d.ts b/ui/src/lib/types/api.d.ts index 7d2636b..962315a 100644 --- a/ui/src/lib/types/api.d.ts +++ b/ui/src/lib/types/api.d.ts @@ -99,6 +99,9 @@ export namespace API { export interface Generator extends Base { name: string; description?: string; + playlist: boolean; + maintained: boolean; + interval_s: number; params: { track_amount: number; excluded_playlist_ids?: number[]; diff --git a/ui/src/lib/types/generator.ts b/ui/src/lib/types/generator.ts index 6c28394..6fe7eff 100644 --- a/ui/src/lib/types/generator.ts +++ b/ui/src/lib/types/generator.ts @@ -35,6 +35,9 @@ export interface Generator { id: number; name: string; description?: string; + playlist: boolean; + maintained: boolean; + intervalS: number; params: { trackAmount: number; excludedPlaylistIds: number[]; @@ -57,6 +60,9 @@ export const convertGenerator = (g: API.Generator): Generator => { id: g.id, name: g.name, description: g.description, + playlist: g.playlist, + maintained: g.maintained, + intervalS: g.interval_s, params: { trackAmount: g.params.track_amount, excludedPlaylistIds: g.params.excluded_playlist_ids ?? [], @@ -97,6 +103,9 @@ export const generatorSchema = z.object({ id: z.number().positive().optional(), name: z.string().nonempty(), description: z.string().optional(), + playlist: z.boolean(), + maintained: z.boolean(), + intervalS: z.number().positive(), params: z.object({ trackAmount: z.number().positive(), excludedPlaylistIds: z.array(z.number().positive()).optional(), From 34bfec11c2524f16427f4714afa57c88f698ae67 Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 3 Jan 2026 14:50:49 +0100 Subject: [PATCH 16/33] feat(task): delete tasks --- internal/task/manager.go | 34 +++++++++++++++++++++++++++++++++- internal/task/task.go | 5 +++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/internal/task/manager.go b/internal/task/manager.go index 1767ef8..92ebb3b 100644 --- a/internal/task/manager.go +++ b/internal/task/manager.go @@ -152,6 +152,38 @@ func (m *manager) Add(ctx context.Context, newTask Task) error { return nil } +// Remove removes a task from the manager +// Running tasks will not be cancelled but removed after execution +func (m *manager) Remove(ctx context.Context, taskUID string) error { + if _, ok := m.jobs[taskUID]; !ok { + return ErrTaskNotExists + } + + // Update db + task, err := m.repoTask.GetByUID(ctx, taskUID) + if err != nil { + return err + } + if task == nil { + return ErrTaskNotExists + } + task.Active = false + if err := m.repoTask.Update(ctx, *task); err != nil { + return err + } + + // Remove from scheduler + m.scheduler.RemoveByTags(taskUID) + + // Remove from internal job list + m.mu.Lock() + defer m.mu.Unlock() + + delete(m.jobs, taskUID) + + return nil +} + // RunRecurringByUID runs a pre existing recurring task given a task UID. func (m *manager) RunRecurringByUID(taskUID string, user model.User) error { var job gocron.Job @@ -162,7 +194,7 @@ func (m *manager) RunRecurringByUID(taskUID string, user model.User) error { } } if job == nil { - return fmt.Errorf("task with uid %s not found", taskUID) + return ErrTaskNotExists } // Set the user argument diff --git a/internal/task/task.go b/internal/task/task.go index 7f2fbc3..50e4c32 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -12,8 +12,9 @@ import ( ) var ( - IntervalOnce = time.Duration(0) - ErrTaskExists = errors.New("task already exists") + IntervalOnce = time.Duration(0) + ErrTaskExists = errors.New("task already exists") + ErrTaskNotExists = errors.New("task doesn't exist") ) // Init intializes the global task manager instance From e55f88eb64a7eeaa4333075fad73e672e9810730 Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 3 Jan 2026 16:23:57 +0100 Subject: [PATCH 17/33] refactor(spotify): split sync and api package --- internal/{spotify/api => spotifyapi}/album.go | 10 ++--- internal/{spotify/api => spotifyapi}/api.go | 20 +++++---- .../{spotify/api => spotifyapi}/artist.go | 6 +-- internal/{spotify/api => spotifyapi}/image.go | 4 +- internal/{spotify/api => spotifyapi}/model.go | 2 +- .../{spotify/api => spotifyapi}/player.go | 6 +-- .../{spotify/api => spotifyapi}/playlist.go | 45 ++++++++++++++++--- internal/{spotify/api => spotifyapi}/query.go | 8 ++-- internal/{spotify/api => spotifyapi}/show.go | 8 ++-- internal/{spotify/api => spotifyapi}/track.go | 6 +-- internal/{spotify/api => spotifyapi}/user.go | 4 +- internal/{spotify/api => spotifyapi}/util.go | 2 +- internal/{spotify => spotifysync}/album.go | 14 +++--- internal/{spotify => spotifysync}/artist.go | 8 ++-- internal/{spotify => spotifysync}/export.go | 2 +- internal/{spotify => spotifysync}/history.go | 5 ++- internal/{spotify => spotifysync}/link.go | 5 ++- internal/{spotify => spotifysync}/playlist.go | 30 +++++++++---- internal/{spotify => spotifysync}/show.go | 12 ++--- internal/{spotify => spotifysync}/spotify.go | 19 +------- internal/{spotify => spotifysync}/task.go | 2 +- internal/{spotify => spotifysync}/track.go | 10 ++--- internal/{spotify => spotifysync}/user.go | 5 ++- internal/{spotify => spotifysync}/util.go | 5 ++- 24 files changed, 139 insertions(+), 99 deletions(-) rename internal/{spotify/api => spotifyapi}/album.go (87%) rename internal/{spotify/api => spotifyapi}/api.go (74%) rename internal/{spotify/api => spotifyapi}/artist.go (89%) rename internal/{spotify/api => spotifyapi}/image.go (91%) rename internal/{spotify/api => spotifyapi}/model.go (99%) rename internal/{spotify/api => spotifyapi}/player.go (83%) rename internal/{spotify/api => spotifyapi}/playlist.go (77%) rename internal/{spotify/api => spotifyapi}/query.go (94%) rename internal/{spotify/api => spotifyapi}/show.go (86%) rename internal/{spotify/api => spotifyapi}/track.go (88%) rename internal/{spotify/api => spotifyapi}/user.go (83%) rename internal/{spotify/api => spotifyapi}/util.go (92%) rename internal/{spotify => spotifysync}/album.go (87%) rename internal/{spotify => spotifysync}/artist.go (82%) rename internal/{spotify => spotifysync}/export.go (99%) rename internal/{spotify => spotifysync}/history.go (97%) rename internal/{spotify => spotifysync}/link.go (94%) rename internal/{spotify => spotifysync}/playlist.go (83%) rename internal/{spotify => spotifysync}/show.go (83%) rename internal/{spotify => spotifysync}/spotify.go (75%) rename internal/{spotify => spotifysync}/task.go (99%) rename internal/{spotify => spotifysync}/track.go (87%) rename internal/{spotify => spotifysync}/user.go (89%) rename internal/{spotify => spotifysync}/util.go (97%) diff --git a/internal/spotify/api/album.go b/internal/spotifyapi/album.go similarity index 87% rename from internal/spotify/api/album.go rename to internal/spotifyapi/album.go index f29a272..2c31bb6 100644 --- a/internal/spotify/api/album.go +++ b/internal/spotifyapi/album.go @@ -1,4 +1,4 @@ -package api +package spotifyapi import ( "context" @@ -9,7 +9,7 @@ import ( "github.com/topvennie/sortifyr/internal/database/model" ) -func (c *Client) AlbumGet(ctx context.Context, user model.User, spotifyID string) (Album, error) { +func (c *client) AlbumGet(ctx context.Context, user model.User, spotifyID string) (Album, error) { var resp Album if err := c.request(ctx, user, http.MethodGet, "albums/"+spotifyID, http.NoBody, &resp); err != nil { @@ -24,7 +24,7 @@ type albumUserResponse struct { Items []Album `json:"items"` } -func (c *Client) AlbumGetUser(ctx context.Context, user model.User) ([]Album, error) { +func (c *client) AlbumGetUser(ctx context.Context, user model.User) ([]Album, error) { albums := make([]Album, 0) total := 51 @@ -48,7 +48,7 @@ type albumAllResponse struct { Albums []Album `json:"albums"` } -func (c *Client) AlbumGetAll(ctx context.Context, user model.User, albumIDs []string) ([]Album, error) { +func (c *client) AlbumGetAll(ctx context.Context, user model.User, albumIDs []string) ([]Album, error) { albums := make([]Album, 0, len(albumIDs)) limit := 20 @@ -72,7 +72,7 @@ type albumTrackResponse struct { Items []Track `json:"items"` } -func (c *Client) AlbumGetTrackAll(ctx context.Context, user model.User, spotifyID string) ([]Track, error) { +func (c *client) AlbumGetTrackAll(ctx context.Context, user model.User, spotifyID string) ([]Track, error) { tracks := make([]Track, 0) total := 51 diff --git a/internal/spotify/api/api.go b/internal/spotifyapi/api.go similarity index 74% rename from internal/spotify/api/api.go rename to internal/spotifyapi/api.go index c897fd8..d2454ce 100644 --- a/internal/spotify/api/api.go +++ b/internal/spotifyapi/api.go @@ -1,5 +1,5 @@ -// Package api interacts with the spotify api -package api +// Package spotifyapi interacts with the spotify api +package spotifyapi import ( "context" @@ -12,26 +12,30 @@ import ( "github.com/topvennie/sortifyr/pkg/redis" ) -type Client struct { +type client struct { clientID string clientSecret string } -func New() (*Client, error) { +var C *client + +func Init() error { clientID := config.GetString("auth.spotify.client.id") clientSecret := config.GetString("auth.spotify.client.secret") if clientID == "" || clientSecret == "" { - return nil, errors.New("client id or client secret not set") + return errors.New("client id or client secret not set") } - return &Client{ + C = &client{ clientID: clientID, clientSecret: clientSecret, - }, nil + } + + return nil } -func (c *Client) NewUser(ctx context.Context, user model.User, accessToken, refreshToken string, expiresIn time.Duration) error { +func (c *client) NewUser(ctx context.Context, user model.User, accessToken, refreshToken string, expiresIn time.Duration) error { if _, err := redis.C.Set(ctx, accessKey(user), accessToken, expiresIn).Result(); err != nil { return fmt.Errorf("set access token %w", err) } diff --git a/internal/spotify/api/artist.go b/internal/spotifyapi/artist.go similarity index 89% rename from internal/spotify/api/artist.go rename to internal/spotifyapi/artist.go index cd234c4..6d16e18 100644 --- a/internal/spotify/api/artist.go +++ b/internal/spotifyapi/artist.go @@ -1,4 +1,4 @@ -package api +package spotifyapi import ( "context" @@ -12,7 +12,7 @@ import ( "github.com/topvennie/sortifyr/pkg/concurrent" ) -func (c *Client) ArtistGet(ctx context.Context, user model.User, spotifyID string) (Artist, error) { +func (c *client) ArtistGet(ctx context.Context, user model.User, spotifyID string) (Artist, error) { var resp Artist if err := c.request(ctx, user, http.MethodGet, "artists/"+spotifyID, http.NoBody, &resp); err != nil { @@ -26,7 +26,7 @@ type artistAllResponse struct { Artists []Artist `json:"artists"` } -func (c *Client) ArtistGetAll(ctx context.Context, user model.User, artistIDs []string) ([]Artist, error) { +func (c *client) ArtistGetAll(ctx context.Context, user model.User, artistIDs []string) ([]Artist, error) { wg := concurrent.NewLimitedWaitGroup(12) var mu sync.Mutex diff --git a/internal/spotify/api/image.go b/internal/spotifyapi/image.go similarity index 91% rename from internal/spotify/api/image.go rename to internal/spotifyapi/image.go index 0a1e244..f693ff1 100644 --- a/internal/spotify/api/image.go +++ b/internal/spotifyapi/image.go @@ -1,4 +1,4 @@ -package api +package spotifyapi import ( "context" @@ -9,7 +9,7 @@ import ( "github.com/topvennie/sortifyr/pkg/image" ) -func (c *Client) ImageGet(ctx context.Context, url string) ([]byte, error) { +func (c *client) ImageGet(ctx context.Context, url string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) if err != nil { return nil, fmt.Errorf("new http request %w", err) diff --git a/internal/spotify/api/model.go b/internal/spotifyapi/model.go similarity index 99% rename from internal/spotify/api/model.go rename to internal/spotifyapi/model.go index 44ce426..643fa39 100644 --- a/internal/spotify/api/model.go +++ b/internal/spotifyapi/model.go @@ -1,4 +1,4 @@ -package api +package spotifyapi import ( "time" diff --git a/internal/spotify/api/player.go b/internal/spotifyapi/player.go similarity index 83% rename from internal/spotify/api/player.go rename to internal/spotifyapi/player.go index c77e3c2..b783f64 100644 --- a/internal/spotify/api/player.go +++ b/internal/spotifyapi/player.go @@ -1,4 +1,4 @@ -package api +package spotifyapi import ( "context" @@ -13,7 +13,7 @@ type playerHistoryResponse struct { Items []History `json:"items"` } -func (c *Client) PlayerGetHistory(ctx context.Context, user model.User) ([]History, error) { +func (c *client) PlayerGetHistory(ctx context.Context, user model.User) ([]History, error) { var resp playerHistoryResponse if err := c.request(ctx, user, http.MethodGet, "me/player/recently-played?limit=50", http.NoBody, &resp); err != nil { return nil, err @@ -26,7 +26,7 @@ type playerCurrentResponse struct { Current } -func (c *Client) PlayerGetCurrent(ctx context.Context, user model.User) (Current, error) { +func (c *client) PlayerGetCurrent(ctx context.Context, user model.User) (Current, error) { var resp playerCurrentResponse if err := c.request(ctx, user, http.MethodGet, "me/player/currently-playing", http.NoBody, &resp); err != nil { if errors.Is(err, io.EOF) { diff --git a/internal/spotify/api/playlist.go b/internal/spotifyapi/playlist.go similarity index 77% rename from internal/spotify/api/playlist.go rename to internal/spotifyapi/playlist.go index 28af648..d26bbc4 100644 --- a/internal/spotify/api/playlist.go +++ b/internal/spotifyapi/playlist.go @@ -1,4 +1,4 @@ -package api +package spotifyapi import ( "bytes" @@ -13,7 +13,7 @@ import ( "github.com/topvennie/sortifyr/pkg/utils" ) -func (c *Client) PlaylistGet(ctx context.Context, user model.User, spotifyID string) (Playlist, error) { +func (c *client) PlaylistGet(ctx context.Context, user model.User, spotifyID string) (Playlist, error) { var resp Playlist if err := c.request(ctx, user, http.MethodGet, "playlists/"+spotifyID, http.NoBody, &resp); err != nil { @@ -28,7 +28,7 @@ type playlistUserResponse struct { Items []Playlist `json:"items"` } -func (c *Client) PlaylistGetUser(ctx context.Context, user model.User) ([]Playlist, error) { +func (c *client) PlaylistGetUser(ctx context.Context, user model.User) ([]Playlist, error) { playlists := make([]Playlist, 0) total := 51 @@ -57,7 +57,7 @@ type playlistTrackResponse struct { Items []playlistTrackAPI `json:"items"` } -func (c *Client) PlaylistGetTrackAll(ctx context.Context, user model.User, spotifyID string) ([]Track, error) { +func (c *client) PlaylistGetTrackAll(ctx context.Context, user model.User, spotifyID string) ([]Track, error) { wg := concurrent.NewLimitedWaitGroup(12) var mu sync.Mutex @@ -102,7 +102,7 @@ type playlistTrackAddPayload struct { URIs []string `json:"uris"` } -func (c *Client) PlaylistPostTrackAll(ctx context.Context, user model.User, spotifyID string, tracks []model.Track) error { +func (c *client) PlaylistPostTrackAll(ctx context.Context, user model.User, spotifyID string, tracks []model.Track) error { current := 0 total := len(tracks) @@ -139,7 +139,7 @@ type playlistTrackRemoveURIPayload struct { URI string `json:"uri"` } -func (c *Client) PlaylistDeleteTrackAll(ctx context.Context, user model.User, spotifyID, snapshotID string, tracks []model.Track) error { +func (c *client) PlaylistDeleteTrackAll(ctx context.Context, user model.User, spotifyID, snapshotID string, tracks []model.Track) error { current := 0 total := len(tracks) @@ -169,3 +169,36 @@ func (c *Client) PlaylistDeleteTrackAll(ctx context.Context, user model.User, sp return nil } + +type playlistCreatePayload struct { + Name string `json:"name"` + Description string `json:"description"` + Public bool `json:"public"` + Collaborative bool `json:"collaborative"` +} + +func (c *client) PlaylistCreate(ctx context.Context, user model.User, playlist *model.Playlist) error { + payload := playlistCreatePayload{ + Name: playlist.Name, + Description: playlist.Description, + Public: *playlist.Public, + Collaborative: *playlist.Collaborative, + } + + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal create playlist payload %+v | %w", payload, err) + } + + body := bytes.NewReader(data) + + var resp Playlist + if err := c.request(ctx, user, http.MethodPost, fmt.Sprintf("/users/%s/playlists", user.UID), body, &resp); err != nil { + return err + } + + playlist.SpotifyID = resp.SpotifyID + playlist.SnapshotID = resp.SnapshotID + + return nil +} diff --git a/internal/spotify/api/query.go b/internal/spotifyapi/query.go similarity index 94% rename from internal/spotify/api/query.go rename to internal/spotifyapi/query.go index 715e35e..251920f 100644 --- a/internal/spotify/api/query.go +++ b/internal/spotifyapi/query.go @@ -1,4 +1,4 @@ -package api +package spotifyapi import ( "context" @@ -33,7 +33,7 @@ type accountResponse struct { RefreshToken string `json:"refresh_token"` } -func (c *Client) refreshToken(ctx context.Context, user model.User) error { +func (c *client) refreshToken(ctx context.Context, user model.User) error { zap.S().Info("Refreshing spotify access token") refreshToken, err := redis.C.Get(ctx, refreshKey(user)).Result() @@ -96,7 +96,7 @@ func (c *Client) refreshToken(ctx context.Context, user model.User) error { return nil } -func (c *Client) getAccessToken(ctx context.Context, user model.User) (string, error) { +func (c *client) getAccessToken(ctx context.Context, user model.User) (string, error) { accessToken, err := redis.C.Get(ctx, accessKey(user)).Result() if err != nil { if !errors.Is(err, redis.ErrNil) { @@ -113,7 +113,7 @@ func (c *Client) getAccessToken(ctx context.Context, user model.User) (string, e return accessToken, nil } -func (c *Client) request(ctx context.Context, user model.User, method, url string, body io.Reader, target any) error { +func (c *client) request(ctx context.Context, user model.User, method, url string, body io.Reader, target any) error { zap.S().Infof("do %s request for url %s", method, url) accessToken, err := c.getAccessToken(ctx, user) diff --git a/internal/spotify/api/show.go b/internal/spotifyapi/show.go similarity index 86% rename from internal/spotify/api/show.go rename to internal/spotifyapi/show.go index 8a57f22..2e4926d 100644 --- a/internal/spotify/api/show.go +++ b/internal/spotifyapi/show.go @@ -1,4 +1,4 @@ -package api +package spotifyapi import ( "context" @@ -9,7 +9,7 @@ import ( "github.com/topvennie/sortifyr/internal/database/model" ) -func (c *Client) ShowGet(ctx context.Context, user model.User, spotifyID string) (Show, error) { +func (c *client) ShowGet(ctx context.Context, user model.User, spotifyID string) (Show, error) { var resp Show if err := c.request(ctx, user, http.MethodGet, "shows/"+spotifyID, http.NoBody, &resp); err != nil { @@ -24,7 +24,7 @@ type showUserResponse struct { Items []Show `json:"items"` } -func (c *Client) ShowGetUser(ctx context.Context, user model.User) ([]Show, error) { +func (c *client) ShowGetUser(ctx context.Context, user model.User) ([]Show, error) { shows := make([]Show, 0) total := 51 @@ -48,7 +48,7 @@ type showAllResponse struct { Shows []Show `json:"shows"` } -func (c *Client) ShowGetAll(ctx context.Context, user model.User, showsIDs []string) ([]Show, error) { +func (c *client) ShowGetAll(ctx context.Context, user model.User, showsIDs []string) ([]Show, error) { shows := make([]Show, 0, len(showsIDs)) limit := 50 diff --git a/internal/spotify/api/track.go b/internal/spotifyapi/track.go similarity index 88% rename from internal/spotify/api/track.go rename to internal/spotifyapi/track.go index b3d514b..d25abec 100644 --- a/internal/spotify/api/track.go +++ b/internal/spotifyapi/track.go @@ -1,4 +1,4 @@ -package api +package spotifyapi import ( "context" @@ -12,7 +12,7 @@ import ( "github.com/topvennie/sortifyr/pkg/concurrent" ) -func (c *Client) TrackGet(ctx context.Context, user model.User, spotifyID string) (Track, error) { +func (c *client) TrackGet(ctx context.Context, user model.User, spotifyID string) (Track, error) { var resp Track if err := c.request(ctx, user, http.MethodGet, "tracks/"+spotifyID, http.NoBody, &resp); err != nil { @@ -26,7 +26,7 @@ type trackAllResponse struct { Tracks []Track `json:"tracks"` } -func (c *Client) TrackGetAll(ctx context.Context, user model.User, trackIDs []string) ([]Track, error) { +func (c *client) TrackGetAll(ctx context.Context, user model.User, trackIDs []string) ([]Track, error) { wg := concurrent.NewLimitedWaitGroup(12) var mu sync.Mutex diff --git a/internal/spotify/api/user.go b/internal/spotifyapi/user.go similarity index 83% rename from internal/spotify/api/user.go rename to internal/spotifyapi/user.go index c1b8bcd..e30708c 100644 --- a/internal/spotify/api/user.go +++ b/internal/spotifyapi/user.go @@ -1,4 +1,4 @@ -package api +package spotifyapi import ( "context" @@ -11,7 +11,7 @@ type userResponse struct { DisplayName string `json:"display_name"` } -func (c *Client) UserGet(ctx context.Context, user, spotifyUser model.User) (model.User, error) { +func (c *client) UserGet(ctx context.Context, user, spotifyUser model.User) (model.User, error) { var resp userResponse if err := c.request(ctx, user, http.MethodGet, "users/"+spotifyUser.UID, http.NoBody, &resp); err != nil { diff --git a/internal/spotify/api/util.go b/internal/spotifyapi/util.go similarity index 92% rename from internal/spotify/api/util.go rename to internal/spotifyapi/util.go index 11b6279..a64cffc 100644 --- a/internal/spotify/api/util.go +++ b/internal/spotifyapi/util.go @@ -1,4 +1,4 @@ -package api +package spotifyapi import "github.com/topvennie/sortifyr/internal/database/model" diff --git a/internal/spotify/album.go b/internal/spotifysync/album.go similarity index 87% rename from internal/spotify/album.go rename to internal/spotifysync/album.go index 6ce154a..be80ea2 100644 --- a/internal/spotify/album.go +++ b/internal/spotifysync/album.go @@ -1,11 +1,11 @@ -package spotify +package spotifysync import ( "context" "time" "github.com/topvennie/sortifyr/internal/database/model" - "github.com/topvennie/sortifyr/internal/spotify/api" + "github.com/topvennie/sortifyr/internal/spotifyapi" "github.com/topvennie/sortifyr/pkg/utils" ) @@ -16,11 +16,11 @@ func (c *client) albumSync(ctx context.Context, user model.User) error { return err } - albumsSpotifyAPI, err := c.api.AlbumGetUser(ctx, user) + albumsSpotifyAPI, err := spotifyapi.C.AlbumGetUser(ctx, user) if err != nil { return err } - albumsSpotify := utils.SliceMap(albumsSpotifyAPI, func(a api.Album) model.Album { return a.ToModel() }) + albumsSpotify := utils.SliceMap(albumsSpotifyAPI, func(a spotifyapi.Album) model.Album { return a.ToModel() }) return syncUserData(syncUserDataStruct[model.Album]{ DB: utils.SliceDereference(albumsDB), @@ -56,11 +56,11 @@ func (c *client) albumUpdate(ctx context.Context, user model.User) error { return nil } - albumsSpotifyAPI, err := c.api.AlbumGetAll(ctx, user, filtered) + albumsSpotifyAPI, err := spotifyapi.C.AlbumGetAll(ctx, user, filtered) if err != nil { return err } - albumsSpotify := utils.SliceMap(albumsSpotifyAPI, func(a api.Album) model.Album { return a.ToModel() }) + albumsSpotify := utils.SliceMap(albumsSpotifyAPI, func(a spotifyapi.Album) model.Album { return a.ToModel() }) for i := range albumsSpotify { albumDB, ok := utils.SliceFind(albumsDB, func(a *model.Album) bool { return a.Equal(albumsSpotify[i]) }) @@ -86,7 +86,7 @@ func (c *client) albumUpdate(ctx context.Context, user model.User) error { return err } - artistsSpotify := utils.SliceMap(albumsSpotifyAPI[i].Artists, func(a api.Artist) model.Artist { return a.ToModel() }) + artistsSpotify := utils.SliceMap(albumsSpotifyAPI[i].Artists, func(a spotifyapi.Artist) model.Artist { return a.ToModel() }) if err := syncUserData(syncUserDataStruct[model.Artist]{ DB: utils.SliceDereference(artistsDB), diff --git a/internal/spotify/artist.go b/internal/spotifysync/artist.go similarity index 82% rename from internal/spotify/artist.go rename to internal/spotifysync/artist.go index 3e75c81..e302cfd 100644 --- a/internal/spotify/artist.go +++ b/internal/spotifysync/artist.go @@ -1,11 +1,11 @@ -package spotify +package spotifysync import ( "context" "time" "github.com/topvennie/sortifyr/internal/database/model" - "github.com/topvennie/sortifyr/internal/spotify/api" + "github.com/topvennie/sortifyr/internal/spotifyapi" "github.com/topvennie/sortifyr/pkg/utils" ) @@ -28,11 +28,11 @@ func (c *client) artistUpdate(ctx context.Context, user model.User) error { return nil } - artistsSpotifyAPI, err := c.api.ArtistGetAll(ctx, user, filtered) + artistsSpotifyAPI, err := spotifyapi.C.ArtistGetAll(ctx, user, filtered) if err != nil { return err } - artistsSpotify := utils.SliceMap(artistsSpotifyAPI, func(a api.Artist) model.Artist { return a.ToModel() }) + artistsSpotify := utils.SliceMap(artistsSpotifyAPI, func(a spotifyapi.Artist) model.Artist { return a.ToModel() }) for i := range artistsSpotify { artistDB, ok := utils.SliceFind(artistsDB, func(a *model.Artist) bool { return a.Equal(artistsSpotify[i]) }) diff --git a/internal/spotify/export.go b/internal/spotifysync/export.go similarity index 99% rename from internal/spotify/export.go rename to internal/spotifysync/export.go index 5afa3be..875b8b8 100644 --- a/internal/spotify/export.go +++ b/internal/spotifysync/export.go @@ -1,4 +1,4 @@ -package spotify +package spotifysync import ( "archive/zip" diff --git a/internal/spotify/history.go b/internal/spotifysync/history.go similarity index 97% rename from internal/spotify/history.go rename to internal/spotifysync/history.go index 6f1d6e8..e86c548 100644 --- a/internal/spotify/history.go +++ b/internal/spotifysync/history.go @@ -1,14 +1,15 @@ -package spotify +package spotifysync import ( "context" "time" "github.com/topvennie/sortifyr/internal/database/model" + "github.com/topvennie/sortifyr/internal/spotifyapi" ) func (c *client) historySync(ctx context.Context, user model.User) error { - current, err := c.api.PlayerGetCurrent(ctx, user) + current, err := spotifyapi.C.PlayerGetCurrent(ctx, user) if err != nil { return err } diff --git a/internal/spotify/link.go b/internal/spotifysync/link.go similarity index 94% rename from internal/spotify/link.go rename to internal/spotifysync/link.go index a28dd57..70016c1 100644 --- a/internal/spotify/link.go +++ b/internal/spotifysync/link.go @@ -1,10 +1,11 @@ -package spotify +package spotifysync import ( "context" "fmt" "github.com/topvennie/sortifyr/internal/database/model" + "github.com/topvennie/sortifyr/internal/spotifyapi" "github.com/topvennie/sortifyr/pkg/utils" ) @@ -101,7 +102,7 @@ func (c *client) linkOneSync(ctx context.Context, user model.User, source, targe } } - if err := c.api.PlaylistPostTrackAll(ctx, user, target.SpotifyID, toAdd); err != nil { + if err := spotifyapi.C.PlaylistPostTrackAll(ctx, user, target.SpotifyID, toAdd); err != nil { return err } diff --git a/internal/spotify/playlist.go b/internal/spotifysync/playlist.go similarity index 83% rename from internal/spotify/playlist.go rename to internal/spotifysync/playlist.go index 4b41ca2..fe1e423 100644 --- a/internal/spotify/playlist.go +++ b/internal/spotifysync/playlist.go @@ -1,10 +1,10 @@ -package spotify +package spotifysync import ( "context" "github.com/topvennie/sortifyr/internal/database/model" - "github.com/topvennie/sortifyr/internal/spotify/api" + "github.com/topvennie/sortifyr/internal/spotifyapi" "github.com/topvennie/sortifyr/pkg/utils" ) @@ -14,7 +14,7 @@ func (c *client) playlistSync(ctx context.Context, user model.User) error { return err } - playlistsSpotifyAPI, err := c.api.PlaylistGetUser(ctx, user) + playlistsSpotifyAPI, err := spotifyapi.C.PlaylistGetUser(ctx, user) if err != nil { return err } @@ -60,7 +60,7 @@ func (c *client) playlistUpdate(ctx context.Context, user model.User) error { return err } - playlistsSpotifyAPI, err := c.api.PlaylistGetUser(ctx, user) + playlistsSpotifyAPI, err := spotifyapi.C.PlaylistGetUser(ctx, user) if err != nil { return err } @@ -111,11 +111,11 @@ func (c *client) playlistUpdate(ctx context.Context, user model.User) error { return err } - tracksSpotifyAPI, err := c.api.PlaylistGetTrackAll(ctx, user, playlistsSpotify[i].SpotifyID) + tracksSpotifyAPI, err := spotifyapi.C.PlaylistGetTrackAll(ctx, user, playlistsSpotify[i].SpotifyID) if err != nil { return err } - tracksSpotify := utils.SliceMap(tracksSpotifyAPI, func(t api.Track) model.Track { return t.ToModel() }) + tracksSpotify := utils.SliceMap(tracksSpotifyAPI, func(t spotifyapi.Track) model.Track { return t.ToModel() }) if err := syncUserData(syncUserDataStruct[model.Track]{ DB: utils.SliceDereference(tracksDB), @@ -199,15 +199,29 @@ func (c *client) playlistRemoveDuplicates(ctx context.Context, user model.User) filtered := utils.SliceFilter(unique, func(t model.Track) bool { return t.SpotifyID != "" }) // Remove them all - if err := c.api.PlaylistDeleteTrackAll(ctx, user, playlists[i].SpotifyID, playlists[i].SnapshotID, filtered); err != nil { + if err := spotifyapi.C.PlaylistDeleteTrackAll(ctx, user, playlists[i].SpotifyID, playlists[i].SnapshotID, filtered); err != nil { return err } // Add them all back - if err := c.api.PlaylistPostTrackAll(ctx, user, playlists[i].SpotifyID, filtered); err != nil { + if err := spotifyapi.C.PlaylistPostTrackAll(ctx, user, playlists[i].SpotifyID, filtered); err != nil { return err } } return nil } + +func (c *client) PlaylistCreate(ctx context.Context, user model.User, playlist *model.Playlist) error { + // TODO: Implement + return nil +} + +func (c *client) PlaylistAddTracks(ctx context.Context, user model.User, playlist model.Playlist, tracks []model.Track) error { + return spotifyapi.C.PlaylistPostTrackAll(ctx, user, playlist.SpotifyID, tracks) +} + +func (c *client) PlaylistDelete(ctx context.Context, user model.User, playlist model.Playlist) error { + // TODO: Implement + return nil +} diff --git a/internal/spotify/show.go b/internal/spotifysync/show.go similarity index 83% rename from internal/spotify/show.go rename to internal/spotifysync/show.go index 597818a..0f6ba9b 100644 --- a/internal/spotify/show.go +++ b/internal/spotifysync/show.go @@ -1,11 +1,11 @@ -package spotify +package spotifysync import ( "context" "time" "github.com/topvennie/sortifyr/internal/database/model" - "github.com/topvennie/sortifyr/internal/spotify/api" + "github.com/topvennie/sortifyr/internal/spotifyapi" "github.com/topvennie/sortifyr/pkg/utils" ) @@ -16,11 +16,11 @@ func (c *client) showSync(ctx context.Context, user model.User) error { return err } - showsSpotifyAPI, err := c.api.ShowGetUser(ctx, user) + showsSpotifyAPI, err := spotifyapi.C.ShowGetUser(ctx, user) if err != nil { return err } - showsSpotify := utils.SliceMap(showsSpotifyAPI, func(s api.Show) model.Show { return s.ToModel() }) + showsSpotify := utils.SliceMap(showsSpotifyAPI, func(s spotifyapi.Show) model.Show { return s.ToModel() }) return syncUserData(syncUserDataStruct[model.Show]{ DB: utils.SliceDereference(showsDB), @@ -56,11 +56,11 @@ func (c *client) showUpdate(ctx context.Context, user model.User) error { return nil } - showsSpotifyAPI, err := c.api.ShowGetAll(ctx, user, utils.SliceMap(showsDB, func(s *model.Show) string { return s.SpotifyID })) + showsSpotifyAPI, err := spotifyapi.C.ShowGetAll(ctx, user, utils.SliceMap(showsDB, func(s *model.Show) string { return s.SpotifyID })) if err != nil { return err } - showsSpotify := utils.SliceMap(showsSpotifyAPI, func(s api.Show) model.Show { return s.ToModel() }) + showsSpotify := utils.SliceMap(showsSpotifyAPI, func(s spotifyapi.Show) model.Show { return s.ToModel() }) for i := range showsSpotify { showDB, ok := utils.SliceFind(showsDB, func(s *model.Show) bool { return s.Equal(showsSpotify[i]) }) diff --git a/internal/spotify/spotify.go b/internal/spotifysync/spotify.go similarity index 75% rename from internal/spotify/spotify.go rename to internal/spotifysync/spotify.go index a945d54..cca2cb3 100644 --- a/internal/spotify/spotify.go +++ b/internal/spotifysync/spotify.go @@ -1,13 +1,10 @@ -// Package spotify syncrhonizes our local database with the spotify api -package spotify +// Package spotifysync syncronizes our local database with the spotify api +package spotifysync import ( "context" - "time" - "github.com/topvennie/sortifyr/internal/database/model" "github.com/topvennie/sortifyr/internal/database/repository" - "github.com/topvennie/sortifyr/internal/spotify/api" ) // Data updates are typically split in 3 parts @@ -26,8 +23,6 @@ import ( // then you get an array of simplified playlist objects type client struct { - api api.Client - album repository.Album artist repository.Artist directory repository.Directory @@ -42,13 +37,7 @@ type client struct { var C *client func Init(repo repository.Repository) error { - apiClient, err := api.New() - if err != nil { - return err - } - C = &client{ - api: *apiClient, album: *repo.NewAlbum(), artist: *repo.NewArtist(), directory: *repo.NewDirectory(), @@ -66,7 +55,3 @@ func Init(repo repository.Repository) error { return nil } - -func (c *client) NewUser(ctx context.Context, user model.User, accessToken, refreshToken string, expiresIn time.Duration) error { - return c.api.NewUser(ctx, user, accessToken, refreshToken, expiresIn) -} diff --git a/internal/spotify/task.go b/internal/spotifysync/task.go similarity index 99% rename from internal/spotify/task.go rename to internal/spotifysync/task.go index 1b0b7e4..7a9b9bf 100644 --- a/internal/spotify/task.go +++ b/internal/spotifysync/task.go @@ -1,4 +1,4 @@ -package spotify +package spotifysync import ( "context" diff --git a/internal/spotify/track.go b/internal/spotifysync/track.go similarity index 87% rename from internal/spotify/track.go rename to internal/spotifysync/track.go index b1cae18..9d3b579 100644 --- a/internal/spotify/track.go +++ b/internal/spotifysync/track.go @@ -1,11 +1,11 @@ -package spotify +package spotifysync import ( "context" "time" "github.com/topvennie/sortifyr/internal/database/model" - "github.com/topvennie/sortifyr/internal/spotify/api" + "github.com/topvennie/sortifyr/internal/spotifyapi" "github.com/topvennie/sortifyr/pkg/utils" ) @@ -28,11 +28,11 @@ func (c *client) trackUpdate(ctx context.Context, user model.User) error { return nil } - tracksSpotifyAPI, err := c.api.TrackGetAll(ctx, user, filtered) + tracksSpotifyAPI, err := spotifyapi.C.TrackGetAll(ctx, user, filtered) if err != nil { return nil } - tracksSpotify := utils.SliceMap(tracksSpotifyAPI, func(t api.Track) model.Track { return t.ToModel() }) + tracksSpotify := utils.SliceMap(tracksSpotifyAPI, func(t spotifyapi.Track) model.Track { return t.ToModel() }) for i := range tracksSpotify { trackDB, ok := utils.SliceFind(tracksDB, func(t *model.Track) bool { return t.Equal(tracksSpotify[i]) }) @@ -58,7 +58,7 @@ func (c *client) trackUpdate(ctx context.Context, user model.User) error { return err } - artistsSpotify := utils.SliceMap(tracksSpotifyAPI[i].Artists, func(a api.Artist) model.Artist { return a.ToModel() }) + artistsSpotify := utils.SliceMap(tracksSpotifyAPI[i].Artists, func(a spotifyapi.Artist) model.Artist { return a.ToModel() }) if err := syncUserData(syncUserDataStruct[model.Artist]{ DB: utils.SliceDereference(artistsDB), diff --git a/internal/spotify/user.go b/internal/spotifysync/user.go similarity index 89% rename from internal/spotify/user.go rename to internal/spotifysync/user.go index e297ad3..ca51506 100644 --- a/internal/spotify/user.go +++ b/internal/spotifysync/user.go @@ -1,9 +1,10 @@ -package spotify +package spotifysync import ( "context" "github.com/topvennie/sortifyr/internal/database/model" + "github.com/topvennie/sortifyr/internal/spotifyapi" "github.com/topvennie/sortifyr/pkg/utils" ) @@ -24,7 +25,7 @@ func (c *client) syncUser(ctx context.Context, user model.User) error { // Get all spotify users usersSpotify := make([]model.User, 0, len(usersDB)) for _, userDB := range usersDB { - newUser, err := c.api.UserGet(ctx, user, userDB) + newUser, err := spotifyapi.C.UserGet(ctx, user, userDB) if err != nil { return err } diff --git a/internal/spotify/util.go b/internal/spotifysync/util.go similarity index 97% rename from internal/spotify/util.go rename to internal/spotifysync/util.go index 3e8fe5d..b03562e 100644 --- a/internal/spotify/util.go +++ b/internal/spotifysync/util.go @@ -1,4 +1,4 @@ -package spotify +package spotifysync import ( "bytes" @@ -11,6 +11,7 @@ import ( "time" "github.com/google/uuid" + "github.com/topvennie/sortifyr/internal/spotifyapi" "github.com/topvennie/sortifyr/pkg/concurrent" "github.com/topvennie/sortifyr/pkg/storage" ) @@ -126,7 +127,7 @@ func (c *client) syncCover(ctx context.Context, s []syncCoverStruct) error { } wg.Go(func() { - cover, err := c.api.ImageGet(ctx, item.CoverURL) + cover, err := spotifyapi.C.ImageGet(ctx, item.CoverURL) if err != nil { mu.Lock() errs = append(errs, err) From d0212c9a3d18c63c4eb9dc62a4ec378dcab6a3bc Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 3 Jan 2026 17:03:11 +0100 Subject: [PATCH 18/33] refactor(server): move some tasks to services --- internal/server/api/auth.go | 4 +- internal/server/service/playlist.go | 49 +++++++- internal/server/service/setting.go | 189 +++++++++++++++++++++++++++- internal/spotifysync/export.go | 178 -------------------------- internal/spotifysync/history.go | 2 +- internal/spotifysync/playlist.go | 46 ------- internal/spotifysync/task.go | 68 ++-------- internal/spotifysync/util.go | 2 +- 8 files changed, 245 insertions(+), 293 deletions(-) delete mode 100644 internal/spotifysync/export.go diff --git a/internal/server/api/auth.go b/internal/server/api/auth.go index 7b84235..8ea735d 100644 --- a/internal/server/api/auth.go +++ b/internal/server/api/auth.go @@ -11,7 +11,7 @@ import ( "github.com/shareed2k/goth_fiber" "github.com/topvennie/sortifyr/internal/server/dto" "github.com/topvennie/sortifyr/internal/server/service" - "github.com/topvennie/sortifyr/internal/spotify" + "github.com/topvennie/sortifyr/internal/spotifyapi" "github.com/topvennie/sortifyr/pkg/config" "go.uber.org/zap" ) @@ -92,7 +92,7 @@ func (r *Auth) loginCallback(c *fiber.Ctx) error { } } - if err := spotify.C.NewUser(c.Context(), *dtoUser.ToModel(), user.AccessToken, user.RefreshToken, time.Until(user.ExpiresAt)); err != nil { + if err := spotifyapi.C.NewUser(c.Context(), *dtoUser.ToModel(), user.AccessToken, user.RefreshToken, time.Until(user.ExpiresAt)); err != nil { zap.S().Error(err) return fiber.ErrInternalServerError } diff --git a/internal/server/service/playlist.go b/internal/server/service/playlist.go index 5600e58..63f1099 100644 --- a/internal/server/service/playlist.go +++ b/internal/server/service/playlist.go @@ -8,13 +8,15 @@ import ( "github.com/topvennie/sortifyr/internal/database/model" "github.com/topvennie/sortifyr/internal/database/repository" "github.com/topvennie/sortifyr/internal/server/dto" - "github.com/topvennie/sortifyr/internal/spotify" + "github.com/topvennie/sortifyr/internal/spotifyapi" "github.com/topvennie/sortifyr/internal/task" "github.com/topvennie/sortifyr/pkg/storage" "github.com/topvennie/sortifyr/pkg/utils" "go.uber.org/zap" ) +const taskPlaylistDuplicateUID = "task-playlist-duplicate" + type Playlist struct { service Service @@ -109,7 +111,18 @@ func (p *Playlist) RemoveDuplicates(ctx context.Context, userID int) error { return fiber.ErrUnauthorized } - if err := spotify.C.TaskPlaylistDuplicate(ctx, *user); err != nil { + if err := task.Manager.Add(ctx, task.NewTask( + taskPlaylistDuplicateUID, + "Playlist Duplicates Removal", + task.IntervalOnce, + func(ctx context.Context, _ []model.User) []task.TaskResult { + return []task.TaskResult{{ + User: *user, + Message: "", + Error: p.removeDuplicatesTask(ctx, *user), + }} + }, + )); err != nil { if errors.Is(err, task.ErrTaskExists) { return fiber.NewError(fiber.StatusBadRequest, "Task is already running") } @@ -119,3 +132,35 @@ func (p *Playlist) RemoveDuplicates(ctx context.Context, userID int) error { return nil } + +func (p *Playlist) removeDuplicatesTask(ctx context.Context, user model.User) error { + // Spotify's API will remove every instance of a track in a playlist + // That's why we first need to make an API call to delete them + // Which deleted all instances + // And then add them back so that we dont lose them. + playlists, err := p.playlist.GetDuplicateTracksByUser(ctx, user.ID) + if err != nil { + return err + } + + for i := range playlists { + if playlists[i].SnapshotID == "" { + continue + } + + unique := utils.SliceUniqueFunc(playlists[i].Duplicates, func(t model.Track) string { return t.SpotifyID }) + filtered := utils.SliceFilter(unique, func(t model.Track) bool { return t.SpotifyID != "" }) + + // Remove them all + if err := spotifyapi.C.PlaylistDeleteTrackAll(ctx, user, playlists[i].SpotifyID, playlists[i].SnapshotID, filtered); err != nil { + return err + } + + // Add them all back + if err := spotifyapi.C.PlaylistPostTrackAll(ctx, user, playlists[i].SpotifyID, filtered); err != nil { + return err + } + } + + return nil +} diff --git a/internal/server/service/setting.go b/internal/server/service/setting.go index f17b106..ab2c80c 100644 --- a/internal/server/service/setting.go +++ b/internal/server/service/setting.go @@ -1,25 +1,46 @@ package service import ( + "archive/zip" + "bytes" "context" + "encoding/json" "errors" + "fmt" + "io" + "regexp" + "slices" + "strconv" + "time" "github.com/gofiber/fiber/v2" + "github.com/topvennie/sortifyr/internal/database/model" "github.com/topvennie/sortifyr/internal/database/repository" - "github.com/topvennie/sortifyr/internal/spotify" + "github.com/topvennie/sortifyr/internal/spotifysync" "github.com/topvennie/sortifyr/internal/task" "go.uber.org/zap" ) +const taskExportUID = "task-export" + +var ( + exportNameReg = regexp.MustCompile(`(?i)^.*_audio_.*\.json$`) + exportIndexReg = regexp.MustCompile(`_(\d+).json$`) +) + type Setting struct { service Service - user repository.User + history repository.History + track repository.Track + user repository.User } func (s *Service) NewSetting() *Setting { return &Setting{ service: *s, + history: *s.repo.NewHistory(), + track: *s.repo.NewTrack(), user: *s.repo.NewUser(), } } @@ -34,7 +55,18 @@ func (s *Setting) Export(ctx context.Context, userID int, zip []byte) error { return fiber.ErrUnauthorized } - if err := spotify.C.TaskExport(ctx, *user, zip); err != nil { + if err := task.Manager.Add(ctx, task.NewTask( + taskExportUID, + "Import Spotify Export", + task.IntervalOnce, + func(ctx context.Context, _ []model.User) []task.TaskResult { + return []task.TaskResult{{ + User: *user, + Message: "", + Error: s.exportTask(ctx, *user, zip), + }} + }, + )); err != nil { if errors.Is(err, task.ErrTaskExists) { return fiber.NewError(fiber.StatusBadRequest, "Task is already running") } @@ -44,3 +76,154 @@ func (s *Setting) Export(ctx context.Context, userID int, zip []byte) error { return nil } + +type exportTaskTrack struct { + StoppedAt time.Time `json:"ts"` + Username string `json:"username"` + MsPlayed int `json:"ms_played"` + SpotifyTrackURI string `json:"spotify_track_uri"` + Skipped bool `json:"skipped"` +} + +func (e exportTaskTrack) toHistory(userID int) *model.History { + return &model.History{ + UserID: userID, + PlayedAt: e.StoppedAt.Add(time.Duration(-1*e.MsPlayed) * time.Millisecond), + Skipped: &e.Skipped, + Track: model.Track{ + SpotifyID: spotifysync.UriToID(e.SpotifyTrackURI), + }, + } +} + +func (s *Setting) exportTask(ctx context.Context, user model.User, zipFile []byte) error { + zr, err := zip.NewReader(bytes.NewReader(zipFile), int64(len(zipFile))) + if err != nil { + return fmt.Errorf("read zip file %w", err) + } + + // Get all files + files := make([]*zip.File, 0) + + for _, f := range zr.File { + // Skip directories + if f.FileInfo().IsDir() { + continue + } + + // Only read the audio files + if match := exportNameReg.FindString(f.FileInfo().Name()); match == "" { + continue + } + + files = append(files, f) + } + if len(files) == 0 { + return nil + } + + // Go from the most recent to the oldest + // Required for the exportFile function + slices.SortFunc(files, func(a, b *zip.File) int { + aIdx := exportIndex(a.FileInfo().Name()) + bIdx := exportIndex(b.FileInfo().Name()) + + return bIdx - aIdx + }) + + for _, f := range files { + rc, err := f.Open() + if err != nil { + return fmt.Errorf("open file %s | %w", f.Name, err) + } + + content, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return fmt.Errorf("read file content %s | %w", f.FileInfo().Name(), err) + } + + if err := s.exportTaskFile(ctx, user, content); err != nil { + return err + } + } + + return nil +} + +func (s *Setting) exportTaskFile(ctx context.Context, user model.User, file []byte) error { + var exportTracks []exportTaskTrack + if err := json.Unmarshal(file, &exportTracks); err != nil { + return fmt.Errorf("parse file content to json %w", err) + } + + // Get all entries + exportHistory := make([]model.History, 0, len(exportTracks)) + spotifyIDs := make([]string, 0, len(exportTracks)) + for _, t := range exportTracks { + if t.SpotifyTrackURI == "" { + continue + } + + h := *t.toHistory(user.ID) + + exportHistory = append(exportHistory, h) + spotifyIDs = append(spotifyIDs, h.Track.SpotifyID) + } + slices.SortFunc(exportHistory, func(a, b model.History) int { return int(a.PlayedAt.UnixMilli() - b.PlayedAt.UnixMilli()) }) + + if len(exportHistory) == 0 { + return nil + } + + // Get all already saved tracks + tracksDB, err := s.track.GetAllBySpotify(ctx, spotifyIDs) + if err != nil { + return err + } + trackMap := make(map[string]int) + for i := range tracksDB { + trackMap[tracksDB[i].SpotifyID] = tracksDB[i].ID + } + + // Populate db with any new track + for i := range exportHistory { + trackID, ok := trackMap[exportHistory[i].Track.SpotifyID] + if !ok { + // We don't have the track yet + track := model.Track{SpotifyID: exportHistory[i].Track.SpotifyID} + if err := s.track.Create(ctx, &track); err != nil { + return err + } + trackID = track.ID + trackMap[track.SpotifyID] = track.ID + } + + exportHistory[i].TrackID = trackID + } + + // Delete the old entries + // This logic assumes that we go from the most recent track to the oldest + if err := s.history.DeleteOlder(ctx, user.ID, exportHistory[len(exportHistory)-1].PlayedAt); err != nil { + return err + } + if err := s.history.CreateBatch(ctx, exportHistory); err != nil { + return err + } + + return nil +} + +func exportIndex(name string) int { + matches := exportIndexReg.FindStringSubmatch(name) + if len(matches) != 2 { + return -1 + } + + idx, err := strconv.Atoi(matches[1]) + if err != nil { + return -1 + } + + return idx +} diff --git a/internal/spotifysync/export.go b/internal/spotifysync/export.go deleted file mode 100644 index 875b8b8..0000000 --- a/internal/spotifysync/export.go +++ /dev/null @@ -1,178 +0,0 @@ -package spotifysync - -import ( - "archive/zip" - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "regexp" - "slices" - "strconv" - "time" - - "github.com/topvennie/sortifyr/internal/database/model" - "github.com/topvennie/sortifyr/internal/task" -) - -var ( - exportNameReg = regexp.MustCompile(`(?i)^.*_audio_.*\.json$`) - exportIndexReg = regexp.MustCompile(`_(\d+).json$`) -) - -type exportTrack struct { - StoppedAt time.Time `json:"ts"` - Username string `json:"username"` - MsPlayed int `json:"ms_played"` - SpotifyTrackURI string `json:"spotify_track_uri"` - Skipped bool `json:"skipped"` -} - -func (e exportTrack) toHistory(userID int) *model.History { - return &model.History{ - UserID: userID, - PlayedAt: e.StoppedAt.Add(time.Duration(-1*e.MsPlayed) * time.Millisecond), - Skipped: &e.Skipped, - Track: model.Track{ - SpotifyID: uriToID(e.SpotifyTrackURI), - }, - } -} - -func (c *client) exportZip(ctx context.Context, user model.User, data []byte) error { - zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) - if err != nil { - return fmt.Errorf("read zip file %w", err) - } - - // Get all files - files := make([]*zip.File, 0) - - for _, f := range zr.File { - // Skip directories - if f.FileInfo().IsDir() { - continue - } - - // Only read the audio files - if match := exportNameReg.FindString(f.FileInfo().Name()); match == "" { - continue - } - - files = append(files, f) - } - if len(files) == 0 { - return nil - } - - // Go from the most recent to the oldest - // Required for the exportFile function - slices.SortFunc(files, func(a, b *zip.File) int { - aIdx := exportIndex(a.FileInfo().Name()) - bIdx := exportIndex(b.FileInfo().Name()) - - return bIdx - aIdx - }) - - for _, f := range files { - rc, err := f.Open() - if err != nil { - return fmt.Errorf("open file %s | %w", f.Name, err) - } - - content, err := io.ReadAll(rc) - rc.Close() - if err != nil { - return fmt.Errorf("read file content %s | %w", f.FileInfo().Name(), err) - } - - if err := c.exportFile(ctx, user, content); err != nil { - return err - } - } - - // Run the track task to update any new tracks - if err := task.Manager.RunRecurringByUID(taskTrackUID, user); err != nil { - return err - } - - return nil -} - -func (c *client) exportFile(ctx context.Context, user model.User, content []byte) error { - var exportTracks []exportTrack - if err := json.Unmarshal(content, &exportTracks); err != nil { - return fmt.Errorf("parse file content to json %w", err) - } - - // Get all entries - exportHistory := make([]model.History, 0, len(exportTracks)) - spotifyIDs := make([]string, 0, len(exportTracks)) - for _, t := range exportTracks { - if t.SpotifyTrackURI == "" { - continue - } - - h := *t.toHistory(user.ID) - - exportHistory = append(exportHistory, h) - spotifyIDs = append(spotifyIDs, h.Track.SpotifyID) - } - slices.SortFunc(exportHistory, func(a, b model.History) int { return int(a.PlayedAt.UnixMilli() - b.PlayedAt.UnixMilli()) }) - - if len(exportHistory) == 0 { - return nil - } - - // Get all already saved tracks - tracksDB, err := c.track.GetAllBySpotify(ctx, spotifyIDs) - if err != nil { - return err - } - trackMap := make(map[string]int) - for i := range tracksDB { - trackMap[tracksDB[i].SpotifyID] = tracksDB[i].ID - } - - // Populate db with any new track - for i := range exportHistory { - trackID, ok := trackMap[exportHistory[i].Track.SpotifyID] - if !ok { - // We don't have the track yet - track := model.Track{SpotifyID: exportHistory[i].Track.SpotifyID} - if err := c.track.Create(ctx, &track); err != nil { - return err - } - trackID = track.ID - trackMap[track.SpotifyID] = track.ID - } - - exportHistory[i].TrackID = trackID - } - - // Delete the old entries - // This logic assumes that we go from the most recent track to the oldest - if err := c.history.DeleteOlder(ctx, user.ID, exportHistory[len(exportHistory)-1].PlayedAt); err != nil { - return err - } - if err := c.history.CreateBatch(ctx, exportHistory); err != nil { - return err - } - - return nil -} - -func exportIndex(name string) int { - matches := exportIndexReg.FindStringSubmatch(name) - if len(matches) != 2 { - return -1 - } - - idx, err := strconv.Atoi(matches[1]) - if err != nil { - return -1 - } - - return idx -} diff --git a/internal/spotifysync/history.go b/internal/spotifysync/history.go index e86c548..13c2df2 100644 --- a/internal/spotifysync/history.go +++ b/internal/spotifysync/history.go @@ -68,7 +68,7 @@ func (c *client) historySync(ctx context.Context, user model.User) error { TrackID: track.ID, } - contextSpotifyID := uriToID(current.Context.URI) + contextSpotifyID := UriToID(current.Context.URI) switch current.Context.Type { case "album": diff --git a/internal/spotifysync/playlist.go b/internal/spotifysync/playlist.go index fe1e423..4b0e845 100644 --- a/internal/spotifysync/playlist.go +++ b/internal/spotifysync/playlist.go @@ -179,49 +179,3 @@ func (c *client) playlistCoverSync(ctx context.Context, user model.User) error { } })) } - -func (c *client) playlistRemoveDuplicates(ctx context.Context, user model.User) error { - // Spotify's API will remove every instance of a track in a playlist - // That's why we first need to make an API call to delete them - // Which deleted all instances - // And then add them back so that we dont lose them. - playlists, err := c.playlist.GetDuplicateTracksByUser(ctx, user.ID) - if err != nil { - return err - } - - for i := range playlists { - if playlists[i].SnapshotID == "" { - continue - } - - unique := utils.SliceUniqueFunc(playlists[i].Duplicates, func(t model.Track) string { return t.SpotifyID }) - filtered := utils.SliceFilter(unique, func(t model.Track) bool { return t.SpotifyID != "" }) - - // Remove them all - if err := spotifyapi.C.PlaylistDeleteTrackAll(ctx, user, playlists[i].SpotifyID, playlists[i].SnapshotID, filtered); err != nil { - return err - } - - // Add them all back - if err := spotifyapi.C.PlaylistPostTrackAll(ctx, user, playlists[i].SpotifyID, filtered); err != nil { - return err - } - } - - return nil -} - -func (c *client) PlaylistCreate(ctx context.Context, user model.User, playlist *model.Playlist) error { - // TODO: Implement - return nil -} - -func (c *client) PlaylistAddTracks(ctx context.Context, user model.User, playlist model.Playlist, tracks []model.Track) error { - return spotifyapi.C.PlaylistPostTrackAll(ctx, user, playlist.SpotifyID, tracks) -} - -func (c *client) PlaylistDelete(ctx context.Context, user model.User, playlist model.Playlist) error { - // TODO: Implement - return nil -} diff --git a/internal/spotifysync/task.go b/internal/spotifysync/task.go index 7a9b9bf..54c8603 100644 --- a/internal/spotifysync/task.go +++ b/internal/spotifysync/task.go @@ -11,68 +11,16 @@ import ( ) const ( - taskArtistUID = "task-artist" - taskAlbumUID = "task-album" - taskHistoryUID = "task-history" - taskLinkUID = "task-link" - taskPlaylistUID = "task-playlist" - taskPlaylistDuplicateUID = "task-playlist-duplicate" - taskShowUID = "task-show" - taskTrackUID = "task-track" - taskUserUID = "task-user" - taskExportUID = "task-export" + taskArtistUID = "task-artist" + taskAlbumUID = "task-album" + taskHistoryUID = "task-history" + taskLinkUID = "task-link" + taskPlaylistUID = "task-playlist" + taskShowUID = "task-show" + taskTrackUID = "task-track" + taskUserUID = "task-user" ) -func (c *client) TaskPlaylistDuplicate(ctx context.Context, user model.User) error { - if err := task.Manager.Add(ctx, task.NewTask( - taskPlaylistDuplicateUID, - "Playlist Duplicates Remove", - task.IntervalOnce, - func(ctx context.Context, _ []model.User) []task.TaskResult { - results := []task.TaskResult{{ - User: user, - Message: "", - Error: nil, - }} - - if err := c.playlistRemoveDuplicates(ctx, user); err != nil { - results[0].Error = err - } - - return results - }, - )); err != nil { - return err - } - - return nil -} - -func (c *client) TaskExport(ctx context.Context, user model.User, zip []byte) error { - if err := task.Manager.Add(ctx, task.NewTask( - taskExportUID, - "Import Spotify Export", - task.IntervalOnce, - func(ctx context.Context, _ []model.User) []task.TaskResult { - results := []task.TaskResult{{ - User: user, - Message: "", - Error: nil, - }} - - if err := c.exportZip(ctx, user, zip); err != nil { - results[0].Error = err - } - - return results - }, - )); err != nil { - return err - } - - return nil -} - func (c *client) taskRegister(ctx context.Context) error { if err := task.Manager.Add(ctx, task.NewTask( taskPlaylistUID, diff --git a/internal/spotifysync/util.go b/internal/spotifysync/util.go index b03562e..ef42423 100644 --- a/internal/spotifysync/util.go +++ b/internal/spotifysync/util.go @@ -16,7 +16,7 @@ import ( "github.com/topvennie/sortifyr/pkg/storage" ) -func uriToID(uri string) string { +func UriToID(uri string) string { parts := strings.Split(uri, ":") if len(parts) != 3 { return "" From 92311f5b44f7331543ad936a534cd5e99a7c5429 Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 3 Jan 2026 18:47:41 +0100 Subject: [PATCH 19/33] feat(generator): implement creating and maintaining logic --- cmd/api/main.go | 15 +- .../20251231183248_add_generator_table.sql | 4 +- db/queries/generator.sql | 18 +- internal/database/model/generator.go | 6 +- internal/database/repository/generator.go | 94 ++++++++ internal/database/repository/util.go | 4 + .../generator/{playlist.go => generate.go} | 0 internal/generator/generator.go | 228 +++++++++++++++++- internal/generator/task.go | 196 +++++++++++++++ internal/server/api/generator.go | 16 +- internal/server/dto/generator.go | 20 +- internal/server/service/generator.go | 42 +++- internal/spotifyapi/playlist.go | 4 + pkg/sqlc/generator.sql.go | 73 +++++- pkg/sqlc/models.go | 2 + 15 files changed, 680 insertions(+), 42 deletions(-) create mode 100644 internal/database/repository/generator.go rename internal/generator/{playlist.go => generate.go} (100%) create mode 100644 internal/generator/task.go diff --git a/cmd/api/main.go b/cmd/api/main.go index 321b5c9..4d175f3 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -7,7 +7,8 @@ import ( "github.com/topvennie/sortifyr/internal/generator" "github.com/topvennie/sortifyr/internal/server" "github.com/topvennie/sortifyr/internal/server/service" - "github.com/topvennie/sortifyr/internal/spotify" + "github.com/topvennie/sortifyr/internal/spotifyapi" + "github.com/topvennie/sortifyr/internal/spotifysync" "github.com/topvennie/sortifyr/internal/task" "github.com/topvennie/sortifyr/pkg/config" "github.com/topvennie/sortifyr/pkg/db" @@ -49,11 +50,17 @@ func main() { zap.S().Fatalf("Failed to init the task package %v", err) } - if err := spotify.Init(*repo); err != nil { - zap.S().Fatalf("Failed to init the spotify package %v", err) + if err := spotifyapi.Init(); err != nil { + zap.S().Fatalf("Failed to init the spotify api package %v", err) } - generator.Init(*repo) + if err := spotifysync.Init(*repo); err != nil { + zap.S().Fatalf("Failed to init the spotify sync package %v", err) + } + + if err := generator.Init(*repo); err != nil { + zap.S().Fatalf("Failed to init the generator package %v", err) + } api := server.New(*service, db.Pool()) diff --git a/db/migrations/20251231183248_add_generator_table.sql b/db/migrations/20251231183248_add_generator_table.sql index 3ffb7a6..355a79b 100644 --- a/db/migrations/20251231183248_add_generator_table.sql +++ b/db/migrations/20251231183248_add_generator_table.sql @@ -6,9 +6,11 @@ CREATE TABLE generators ( name TEXT NOT NULL, description TEXT, playlist_id INTEGER REFERENCES playlists (id), - maintained BOOL NOT NULL, + maintained BOOLEAN NOT NULL, interval INTEGER, + outdated BOOLEAN NOT NULL, parameters JSONB, + updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT generators_interval_required_if_maintained CHECK ( maintained = false diff --git a/db/queries/generator.sql b/db/queries/generator.sql index b901992..a853769 100644 --- a/db/queries/generator.sql +++ b/db/queries/generator.sql @@ -1,6 +1,16 @@ +-- name: GeneratorGet :one +SELECT * +FROM generators +WHERE id = $1; + +-- name: GeneratorGetByUser :many +SELECT * +FROM generators +WHERE user_id = $1; + -- name: GeneratorCreate :one -INSERT INTO generators (user_id, name, description, playlist_id, maintained, interval, parameters) -VALUES ($1, $2, $3, $4, $5, $6, $7) +INSERT INTO generators (user_id, name, description, playlist_id, maintained, interval, outdated, parameters, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) RETURNING id; -- name: GeneratorUpdate :exec @@ -11,5 +21,7 @@ SET playlist_id = coalesce(sqlc.narg('playlist_id'), playlist_id), maintained = coalesce(sqlc.narg('maintained'), maintained), interval = coalesce(sqlc.narg('interval'), interval), - parameters = coalesce(sqlc.narg('parameters'), parameters) + outdated = coalesce(sqlc.narg('outdated'), outdated), + parameters = coalesce(sqlc.narg('parameters'), parameters), + updated_at = NOW() WHERE id = $1; diff --git a/internal/database/model/generator.go b/internal/database/model/generator.go index 7fda9ae..ed7fec1 100644 --- a/internal/database/model/generator.go +++ b/internal/database/model/generator.go @@ -59,12 +59,14 @@ type Generator struct { PlaylistID int Maintained bool Interval time.Duration + Outdated bool Params GeneratorParams + UpdatedAt time.Time } func GeneratorModel(g sqlc.Generator) *Generator { params := GeneratorParams{} - _ = json.Unmarshal(g.Parameters, ¶ms) //nolint:errcheck // Data controlled by us, it'll be fine + _ = json.Unmarshal(g.Parameters, ¶ms) // nolint:errcheck // Data controlled by us, it'll be fine, ...right? return &Generator{ ID: int(g.ID), @@ -74,6 +76,8 @@ func GeneratorModel(g sqlc.Generator) *Generator { PlaylistID: fromInt(g.PlaylistID), Maintained: g.Maintained, Interval: fromDuration(g.Interval), + Outdated: g.Outdated, Params: params, + UpdatedAt: g.UpdatedAt.Time, } } diff --git a/internal/database/repository/generator.go b/internal/database/repository/generator.go new file mode 100644 index 0000000..0b1bce7 --- /dev/null +++ b/internal/database/repository/generator.go @@ -0,0 +1,94 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + + "github.com/topvennie/sortifyr/internal/database/model" + "github.com/topvennie/sortifyr/pkg/sqlc" + "github.com/topvennie/sortifyr/pkg/utils" +) + +type Generator struct { + repo Repository +} + +func (r *Repository) NewGenerator() *Generator { + return &Generator{ + repo: *r, + } +} + +func (g *Generator) Get(ctx context.Context, id int) (*model.Generator, error) { + gen, err := g.repo.queries(ctx).GeneratorGet(ctx, int32(id)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get generator by id %d | %w", id, err) + } + + return model.GeneratorModel(gen), nil +} + +func (g *Generator) GetByUser(ctx context.Context, userID int) ([]*model.Generator, error) { + gens, err := g.repo.queries(ctx).GeneratorGetByUser(ctx, int32(userID)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get generators by user %d | %w", userID, err) + } + + return utils.SliceMap(gens, model.GeneratorModel), nil +} + +func (g *Generator) Create(ctx context.Context, gen *model.Generator) error { + params, err := json.Marshal(gen.Params) + if err != nil { + return fmt.Errorf("create generator marshal params %+v | %w", *gen, err) + } + + id, err := g.repo.queries(ctx).GeneratorCreate(ctx, sqlc.GeneratorCreateParams{ + UserID: int32(gen.UserID), + Name: gen.Name, + Description: toString(gen.Description), + PlaylistID: toInt(gen.PlaylistID), + Maintained: gen.Maintained, + Interval: toDuration(gen.Interval), + Outdated: gen.Outdated, + Parameters: params, + }) + if err != nil { + return fmt.Errorf("create generator %+v | %w", *gen, err) + } + + gen.ID = int(id) + + return nil +} + +func (g *Generator) Update(ctx context.Context, gen model.Generator) error { + params, err := json.Marshal(gen.Params) + if err != nil { + return fmt.Errorf("update generator marshal params %+v | %w", gen, err) + } + + if err := g.repo.queries(ctx).GeneratorUpdate(ctx, sqlc.GeneratorUpdateParams{ + ID: int32(gen.ID), + Name: toString(gen.Name), + Description: toString(gen.Description), + PlaylistID: toInt(gen.PlaylistID), + Maintained: toBool(&gen.Maintained), + Interval: toDuration(gen.Interval), + Outdated: toBool(&gen.Outdated), + Parameters: params, + }); err != nil { + return fmt.Errorf("update generator %+v | %w", gen, err) + } + + return nil +} diff --git a/internal/database/repository/util.go b/internal/database/repository/util.go index 9928883..d2316ff 100644 --- a/internal/database/repository/util.go +++ b/internal/database/repository/util.go @@ -26,3 +26,7 @@ func toBool(b *bool) pgtype.Bool { func toTime(t time.Time) pgtype.Timestamptz { return pgtype.Timestamptz{Time: t, Valid: !t.IsZero()} } + +func toDuration(d time.Duration) pgtype.Int4 { + return pgtype.Int4{Int32: int32(d.Nanoseconds()), Valid: d.Nanoseconds() > 0} +} diff --git a/internal/generator/playlist.go b/internal/generator/generate.go similarity index 100% rename from internal/generator/playlist.go rename to internal/generator/generate.go diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 8da1ab0..a86f22d 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -3,31 +3,241 @@ package generator import ( "context" + "errors" + "fmt" + "strconv" + "time" "github.com/topvennie/sortifyr/internal/database/model" "github.com/topvennie/sortifyr/internal/database/repository" + "github.com/topvennie/sortifyr/internal/spotifyapi" + "github.com/topvennie/sortifyr/internal/task" ) type generator struct { - history repository.History - playlist repository.Playlist + generator repository.Generator + history repository.History + playlist repository.Playlist + track repository.Track + user repository.User } var G *generator -func Init(repo repository.Repository) { +func Init(repo repository.Repository) error { G = &generator{ - history: *repo.NewHistory(), - playlist: *repo.NewPlaylist(), + generator: *repo.NewGenerator(), + history: *repo.NewHistory(), + playlist: *repo.NewPlaylist(), + track: *repo.NewTrack(), + user: *repo.NewUser(), } + + if err := G.taskRegister(context.Background()); err != nil { + return err + } + + return nil } -func (g *generator) Create(ctx context.Context, gen model.Generator, playlist bool) error { - // TODO: Implement +func (g *generator) Create(ctx context.Context, gen model.Generator, createPlaylist bool) error { + // Get user + user, err := g.user.GetByID(ctx, gen.UserID) + if err != nil { + return err + } + if user == nil { + return fmt.Errorf("user not found %d", gen.UserID) + } + + // Create playlist + gen.PlaylistID = 0 + if createPlaylist { + // Get tracks + tracks, err := g.Generate(ctx, gen) + if err != nil { + return err + } + + // Create playlist in Spotify + description := "Created by Sortifyr" + if gen.Maintained { + days := int(gen.Interval.Nanoseconds() / int64(24*time.Hour)) + daysStr := strconv.Itoa(days) + "days" + if days == 1 { + daysStr = "day" + } + description = fmt.Sprintf("Created and maintained (every %s) by Sortifyr", daysStr) + } + public := false + collaborative := false + + playlist := model.Playlist{ + OwnerID: gen.UserID, + Name: gen.Name, + Description: description, + Public: &public, + Collaborative: &collaborative, + } + + if err := spotifyapi.C.PlaylistCreate(ctx, *user, &playlist); err != nil { + return err + } + + gen.PlaylistID = playlist.ID + + // Add tracks to the playlist + if err := spotifyapi.C.PlaylistPostTrackAll(ctx, *user, playlist.SpotifyID, tracks); err != nil { + return err + } + } + + // Save in database + if err := g.generator.Create(ctx, &gen); err != nil { + return err + } + + // Start task for maintaince + if gen.Maintained { + if gen.Interval.Nanoseconds() == 0 { + return fmt.Errorf("interval is equal to 0 %+v", gen) + } + + if err := task.Manager.Add(ctx, task.NewTask( + getTaskUID(&gen), + getTaskName(&gen), + gen.Interval, + func(ctx context.Context, _ []model.User) []task.TaskResult { + return []task.TaskResult{{ + User: *user, + Message: "", + Error: g.maintain(ctx, *user, gen.ID), + }} + }, + )); err != nil { + return err + } + } + return nil } -func (g *generator) Edit(ctx context.Context, gen model.Generator, playlist bool) error { - // TODO: IMplement +func (g *generator) Update(ctx context.Context, gen model.Generator, createPlaylist bool) error { + // Get user + user, err := g.user.GetByID(ctx, gen.UserID) + if err != nil { + return err + } + if user == nil { + return fmt.Errorf("user not found %d", gen.UserID) + } + + // Get old generator + oldGen, err := g.generator.Get(ctx, gen.ID) + if err != nil { + return err + } + if oldGen == nil { + return fmt.Errorf("gen with id %d not found", gen.ID) + } + + // We're going to be very lazy + // If the old generator had a playlist, delete it + // If the new generator has a playlist, create it + // This will sometimes delete and create a playlist after one another + + // Delete the old playlist + if oldGen.PlaylistID != 0 { + playlist, err := g.playlist.Get(ctx, oldGen.PlaylistID) + if err != nil { + return err + } + if playlist == nil { + return fmt.Errorf("playlist with id %d not found", oldGen.PlaylistID) + } + + if err := spotifyapi.C.PlaylistDelete(ctx, *user, playlist.SpotifyID); err != nil { + return err + } + } + + // Create the new playlist + gen.PlaylistID = 0 + if createPlaylist { + // Get tracks + tracks, err := g.Generate(ctx, gen) + if err != nil { + return err + } + + // Create playlist in Spotify + description := "Created by Sortifyr" + if gen.Maintained { + days := int(gen.Interval.Nanoseconds() / int64(24*time.Hour)) + daysStr := strconv.Itoa(days) + "days" + if days == 1 { + daysStr = "day" + } + description = fmt.Sprintf("Created and maintained (every %s) by Sortifyr", daysStr) + } + public := false + collaborative := false + + playlist := model.Playlist{ + OwnerID: gen.UserID, + Name: gen.Name, + Description: description, + Public: &public, + Collaborative: &collaborative, + } + + if err := spotifyapi.C.PlaylistCreate(ctx, *user, &playlist); err != nil { + return err + } + + gen.PlaylistID = playlist.ID + + // Add tracks to the playlist + if err := spotifyapi.C.PlaylistPostTrackAll(ctx, *user, playlist.SpotifyID, tracks); err != nil { + return err + } + } + + // Update in database + if err := g.generator.Update(ctx, gen); err != nil { + return err + } + + // Remove old task + if oldGen.Maintained { + if err := task.Manager.Remove(ctx, getTaskUID(oldGen)); err != nil { + if !errors.Is(err, task.ErrTaskNotExists) { + return err + } + } + } + + // Start task for maintaince + if gen.Maintained { + if gen.Interval.Nanoseconds() == 0 { + return fmt.Errorf("interval is equal to 0 %+v", gen) + } + + if err := task.Manager.Add(ctx, task.NewTask( + getTaskUID(&gen), + getTaskName(&gen), + gen.Interval, + func(ctx context.Context, _ []model.User) []task.TaskResult { + return []task.TaskResult{{ + User: *user, + Message: "", + Error: g.maintain(ctx, *user, gen.ID), + }} + }, + )); err != nil { + return err + } + } + return nil } diff --git a/internal/generator/task.go b/internal/generator/task.go new file mode 100644 index 0000000..9e016db --- /dev/null +++ b/internal/generator/task.go @@ -0,0 +1,196 @@ +package generator + +import ( + "context" + "errors" + "fmt" + "slices" + "strconv" + + "github.com/topvennie/sortifyr/internal/database/model" + "github.com/topvennie/sortifyr/internal/spotifyapi" + "github.com/topvennie/sortifyr/internal/task" + "github.com/topvennie/sortifyr/pkg/config" +) + +const taskUID = "task-generator" + +func getTaskUID(gen *model.Generator) string { + if gen == nil { + return taskUID + } + + return taskUID + "-" + strconv.Itoa(gen.ID) +} + +func getTaskName(gen *model.Generator) string { + if gen == nil { + return "Generator" + } + + return "Generator - " + gen.Name +} + +func (g *generator) taskRegister(ctx context.Context) error { + if err := task.Manager.Add(ctx, task.NewTask( + getTaskUID(nil), + getTaskName(nil), + config.GetDefaultDuration("task.generator_s", 60*60), + func(ctx context.Context, users []model.User) []task.TaskResult { + results := make([]task.TaskResult, 0, len(users)) + + for _, user := range users { + results = append(results, task.TaskResult{ + User: user, + Message: "", + Error: g.sync(ctx, user), + }) + } + + return results + }, + )); err != nil { + return err + } + + return nil +} + +// sync will update all the generator information +func (g *generator) sync(ctx context.Context, user model.User) error { + gens, err := g.generator.GetByUser(ctx, user.ID) + if err != nil { + return err + } + + for _, gen := range gens { + if err := g.syncOne(ctx, user, gen); err != nil { + return err + } + } + + return nil +} + +func (g *generator) syncOne(ctx context.Context, user model.User, gen *model.Generator) error { + gen.Outdated = false + + // If the generator has a playlist, does it still exist? + var playlist *model.Playlist + if gen.PlaylistID != 0 { + playlists, err := g.playlist.GetByUser(ctx, user.ID) + if err != nil { + return err + } + idx := slices.IndexFunc(playlists, func(p *model.Playlist) bool { return p.ID == gen.PlaylistID }) + if idx == -1 { + // Playlist is gone + // The user probably deleted it manually + gen.PlaylistID = 0 + gen.Maintained = false + gen.Interval = 0 + gen.Outdated = false + } else { + playlist = playlists[idx] + } + } + + // If the generator still has a playlist, is it up to date? + if playlist != nil { + // Get the current tracks + oldTracks, err := g.track.GetByPlaylist(ctx, playlist.ID) + if err != nil { + return err + } + + // Get the new tracks + newTracks, err := g.Generate(ctx, *gen) + if err != nil { + return err + } + + if len(oldTracks) != len(newTracks) { + gen.Outdated = true + } else { + slices.SortFunc(oldTracks, func(a, b *model.Track) int { return a.ID - b.ID }) + slices.SortFunc(newTracks, func(a, b model.Track) int { return a.ID - b.ID }) + + for i := range oldTracks { + if !oldTracks[i].Equal(newTracks[i]) { + gen.Outdated = true + break + } + } + } + } + + if err := g.generator.Update(ctx, *gen); err != nil { + return err + } + + return nil +} + +// maintain updates a maintained playlist to contain the newest tracks +func (g *generator) maintain(ctx context.Context, user model.User, genID int) error { + // Refresh generator data + gen, err := g.generator.Get(ctx, genID) + if err != nil { + return err + } + // Double check all values + if !gen.Maintained { + return errors.New("generator is no longer maintained") + } + + playlist, err := g.playlist.Get(ctx, gen.PlaylistID) + if err != nil { + return err + } + if playlist == nil { + return fmt.Errorf("no playlist with id %d", gen.PlaylistID) + } + + // Get the current tracks + oldTracks, err := g.track.GetByPlaylist(ctx, playlist.ID) + if err != nil { + return err + } + + // Get the new tracks + newTracks, err := g.Generate(ctx, *gen) + if err != nil { + return err + } + + // Find which ones to add and to remove + toAdd := make([]model.Track, 0) + toRemove := make([]model.Track, 0) + + for i := range newTracks { + if idx := slices.IndexFunc(oldTracks, func(t *model.Track) bool { return newTracks[i].Equal(*t) }); idx == -1 { + toAdd = append(toAdd, newTracks[i]) + } + } + + for i := range oldTracks { + if idx := slices.IndexFunc(newTracks, func(t model.Track) bool { return oldTracks[i].Equal(t) }); idx == -1 { + toRemove = append(toRemove, *oldTracks[i]) + } + } + + // Do the actions + if err := spotifyapi.C.PlaylistDeleteTrackAll(ctx, user, playlist.SpotifyID, playlist.SnapshotID, toRemove); err != nil { + return err + } + if err := spotifyapi.C.PlaylistPostTrackAll(ctx, user, playlist.SpotifyID, toAdd); err != nil { + return err + } + + gen.Outdated = false + if err := g.generator.Update(ctx, *gen); err != nil { + return err + } + + return nil +} diff --git a/internal/server/api/generator.go b/internal/server/api/generator.go index 01ec658..dc61af3 100644 --- a/internal/server/api/generator.go +++ b/internal/server/api/generator.go @@ -27,7 +27,7 @@ func (g *Generator) createRoutes() { g.router.Get("/", g.getAll) g.router.Post("/preview", g.preview) g.router.Put("/", g.create) - g.router.Post("/:id", g.edit) + g.router.Post("/:id", g.update) } func (g *Generator) getAll(c *fiber.Ctx) error { @@ -72,7 +72,7 @@ func (g *Generator) create(c *fiber.Ctx) error { return fiber.ErrUnauthorized } - var generator dto.Generator + var generator dto.GeneratorSave if err := c.BodyParser(&generator); err != nil { return fiber.NewError(fiber.StatusBadRequest, err.Error()) } @@ -81,15 +81,15 @@ func (g *Generator) create(c *fiber.Ctx) error { } generator.ID = 0 - generator, err := g.generator.Create(c.Context(), userID, generator) + newGen, err := g.generator.Create(c.Context(), userID, generator) if err != nil { return err } - return c.JSON(generator) + return c.JSON(newGen) } -func (g *Generator) edit(c *fiber.Ctx) error { +func (g *Generator) update(c *fiber.Ctx) error { userID, ok := c.Locals("userID").(int) if !ok { return fiber.ErrUnauthorized @@ -100,7 +100,7 @@ func (g *Generator) edit(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, err.Error()) } - var generator dto.Generator + var generator dto.GeneratorSave if err := c.BodyParser(&generator); err != nil { return fiber.NewError(fiber.StatusBadRequest, err.Error()) } @@ -109,10 +109,10 @@ func (g *Generator) edit(c *fiber.Ctx) error { } generator.ID = id - generator, err = g.generator.Edit(c.Context(), userID, generator) + newGen, err := g.generator.Update(c.Context(), userID, generator) if err != nil { return err } - return c.JSON(generator) + return c.JSON(newGen) } diff --git a/internal/server/dto/generator.go b/internal/server/dto/generator.go index f650714..435e731 100644 --- a/internal/server/dto/generator.go +++ b/internal/server/dto/generator.go @@ -161,10 +161,12 @@ type Generator struct { ID int `json:"id"` Name string `json:"name" validate:"required"` Description string `json:"description,omitzero"` - Playlist bool `json:"playlist"` + PlaylistID int `json:"playlist_id"` Maintained bool `json:"maintained"` IntervalS time.Duration `json:"interval_s"` + Outdated bool `json:"outdated"` Params GeneratorParams `json:"params" validate:"required"` + UpdatedAt time.Time `json:"updated_at"` } func GeneratorDTO(gen *model.Generator) Generator { @@ -172,14 +174,26 @@ func GeneratorDTO(gen *model.Generator) Generator { ID: gen.ID, Name: gen.Name, Description: gen.Description, - Playlist: gen.PlaylistID != 0, + PlaylistID: gen.PlaylistID, Maintained: gen.Maintained, IntervalS: gen.Interval / time.Second, + Outdated: gen.Outdated, Params: generatorParamsDTO(gen.Params), + UpdatedAt: gen.UpdatedAt, } } -func (g Generator) ToModel(userID int) *model.Generator { +type GeneratorSave struct { + ID int `json:"id"` + Name string `json:"name" validate:"required"` + Description string `json:"description,omitzero"` + CreatePlaylist bool `json:"create_playlist"` + Maintained bool `json:"maintained"` + IntervalS time.Duration `json:"interval_s"` + Params GeneratorParams `json:"params" validate:"required"` +} + +func (g GeneratorSave) ToModel(userID int) *model.Generator { return &model.Generator{ ID: g.ID, UserID: userID, diff --git a/internal/server/service/generator.go b/internal/server/service/generator.go index ca16339..63027de 100644 --- a/internal/server/service/generator.go +++ b/internal/server/service/generator.go @@ -5,21 +5,31 @@ import ( "github.com/gofiber/fiber/v2" "github.com/topvennie/sortifyr/internal/database/model" + "github.com/topvennie/sortifyr/internal/database/repository" "github.com/topvennie/sortifyr/internal/generator" "github.com/topvennie/sortifyr/internal/server/dto" "github.com/topvennie/sortifyr/pkg/utils" "go.uber.org/zap" ) -type Generator struct{} +type Generator struct { + generator repository.Generator +} func (s *Service) NewGenerator() *Generator { - return &Generator{} + return &Generator{ + generator: *s.repo.NewGenerator(), + } } func (g *Generator) GetByUser(ctx context.Context, userID int) ([]dto.Generator, error) { - // TODO: Implement - return []dto.Generator{}, nil + gens, err := g.generator.GetByUser(ctx, userID) + if err != nil { + zap.S().Error(err) + return nil, fiber.ErrInternalServerError + } + + return utils.SliceMap(gens, dto.GeneratorDTO), nil } func (g *Generator) Preview(ctx context.Context, userID int, params dto.GeneratorParams) ([]dto.Track, error) { @@ -36,17 +46,17 @@ func (g *Generator) Preview(ctx context.Context, userID int, params dto.Generato return utils.SliceMap(tracks, func(t model.Track) dto.Track { return dto.TrackDTO(&t) }), nil } -func (g *Generator) Create(ctx context.Context, userID int, genSave dto.Generator) (dto.Generator, error) { +func (g *Generator) Create(ctx context.Context, userID int, genSave dto.GeneratorSave) (dto.Generator, error) { gen := genSave.ToModel(userID) - if !genSave.Playlist { + if !genSave.CreatePlaylist { gen.Maintained = false } if !genSave.Maintained { gen.Interval = 0 } - if err := generator.G.Create(ctx, *gen, genSave.Playlist); err != nil { + if err := generator.G.Create(ctx, *gen, genSave.CreatePlaylist); err != nil { zap.S().Error(err) return dto.Generator{}, fiber.ErrInternalServerError } @@ -54,17 +64,29 @@ func (g *Generator) Create(ctx context.Context, userID int, genSave dto.Generato return dto.GeneratorDTO(gen), nil } -func (g *Generator) Edit(ctx context.Context, userID int, genSave dto.Generator) (dto.Generator, error) { +func (g *Generator) Update(ctx context.Context, userID int, genSave dto.GeneratorSave) (dto.Generator, error) { + oldGen, err := g.generator.Get(ctx, genSave.ID) + if err != nil { + zap.S().Error(err) + return dto.Generator{}, fiber.ErrInternalServerError + } + if oldGen == nil { + return dto.Generator{}, fiber.ErrNotFound + } + if oldGen.UserID != userID { + return dto.Generator{}, fiber.ErrForbidden + } + gen := genSave.ToModel(userID) - if !genSave.Playlist { + if !genSave.CreatePlaylist { gen.Maintained = false } if !genSave.Maintained { gen.Interval = 0 } - if err := generator.G.Edit(ctx, *gen, genSave.Playlist); err != nil { + if err := generator.G.Update(ctx, *gen, genSave.CreatePlaylist); err != nil { zap.S().Error(err) return dto.Generator{}, fiber.ErrInternalServerError } diff --git a/internal/spotifyapi/playlist.go b/internal/spotifyapi/playlist.go index d26bbc4..d35e702 100644 --- a/internal/spotifyapi/playlist.go +++ b/internal/spotifyapi/playlist.go @@ -202,3 +202,7 @@ func (c *client) PlaylistCreate(ctx context.Context, user model.User, playlist * return nil } + +func (c *client) PlaylistDelete(ctx context.Context, user model.User, spotifyID string) error { + return c.request(ctx, user, http.MethodDelete, fmt.Sprintf("/playlists/%s/followers", spotifyID), http.NoBody, noResp) +} diff --git a/pkg/sqlc/generator.sql.go b/pkg/sqlc/generator.sql.go index 78c83bd..9016ce4 100644 --- a/pkg/sqlc/generator.sql.go +++ b/pkg/sqlc/generator.sql.go @@ -12,8 +12,8 @@ import ( ) const generatorCreate = `-- name: GeneratorCreate :one -INSERT INTO generators (user_id, name, description, playlist_id, maintained, interval, parameters) -VALUES ($1, $2, $3, $4, $5, $6, $7) +INSERT INTO generators (user_id, name, description, playlist_id, maintained, interval, outdated, parameters, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) RETURNING id ` @@ -24,6 +24,7 @@ type GeneratorCreateParams struct { PlaylistID pgtype.Int4 Maintained bool Interval pgtype.Int4 + Outdated bool Parameters []byte } @@ -35,6 +36,7 @@ func (q *Queries) GeneratorCreate(ctx context.Context, arg GeneratorCreateParams arg.PlaylistID, arg.Maintained, arg.Interval, + arg.Outdated, arg.Parameters, ) var id int32 @@ -42,6 +44,67 @@ func (q *Queries) GeneratorCreate(ctx context.Context, arg GeneratorCreateParams return id, err } +const generatorGet = `-- name: GeneratorGet :one +SELECT id, user_id, name, description, playlist_id, maintained, interval, outdated, parameters, updated_at +FROM generators +WHERE id = $1 +` + +func (q *Queries) GeneratorGet(ctx context.Context, id int32) (Generator, error) { + row := q.db.QueryRow(ctx, generatorGet, id) + var i Generator + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.PlaylistID, + &i.Maintained, + &i.Interval, + &i.Outdated, + &i.Parameters, + &i.UpdatedAt, + ) + return i, err +} + +const generatorGetByUser = `-- name: GeneratorGetByUser :many +SELECT id, user_id, name, description, playlist_id, maintained, interval, outdated, parameters, updated_at +FROM generators +WHERE user_id = $1 +` + +func (q *Queries) GeneratorGetByUser(ctx context.Context, userID int32) ([]Generator, error) { + rows, err := q.db.Query(ctx, generatorGetByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Generator + for rows.Next() { + var i Generator + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.PlaylistID, + &i.Maintained, + &i.Interval, + &i.Outdated, + &i.Parameters, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const generatorUpdate = `-- name: GeneratorUpdate :exec UPDATE generators SET @@ -50,7 +113,9 @@ SET playlist_id = coalesce($4, playlist_id), maintained = coalesce($5, maintained), interval = coalesce($6, interval), - parameters = coalesce($7, parameters) + outdated = coalesce($7, outdated), + parameters = coalesce($8, parameters), + updated_at = NOW() WHERE id = $1 ` @@ -61,6 +126,7 @@ type GeneratorUpdateParams struct { PlaylistID pgtype.Int4 Maintained pgtype.Bool Interval pgtype.Int4 + Outdated pgtype.Bool Parameters []byte } @@ -72,6 +138,7 @@ func (q *Queries) GeneratorUpdate(ctx context.Context, arg GeneratorUpdateParams arg.PlaylistID, arg.Maintained, arg.Interval, + arg.Outdated, arg.Parameters, ) return err diff --git a/pkg/sqlc/models.go b/pkg/sqlc/models.go index a2538a0..bdd6eb5 100644 --- a/pkg/sqlc/models.go +++ b/pkg/sqlc/models.go @@ -109,7 +109,9 @@ type Generator struct { PlaylistID pgtype.Int4 Maintained bool Interval pgtype.Int4 + Outdated bool Parameters []byte + UpdatedAt pgtype.Timestamptz } type History struct { From 0dbfd77ea2838d945f548b534a4b553c0407244f Mon Sep 17 00:00:00 2001 From: vincent Date: Sun, 4 Jan 2026 13:01:22 +0100 Subject: [PATCH 20/33] fix(generator): various sync issues --- .dockerignore | 1 + .../20251231183248_add_generator_table.sql | 2 +- db/queries/generator.sql | 6 ++ internal/database/model/generator.go | 3 + internal/database/model/util.go | 4 +- internal/database/repository/generator.go | 17 +++++ internal/database/repository/util.go | 4 +- internal/generator/generate.go | 12 ++-- internal/generator/generator.go | 33 ++++++--- internal/generator/normalize.go | 13 ++-- internal/generator/task.go | 26 ++++++- internal/server/api/generator.go | 2 +- internal/server/dto/generator.go | 12 ++-- internal/server/service/generator.go | 15 +++- internal/server/service/task.go | 1 + internal/spotifyapi/query.go | 6 +- pkg/sqlc/generator.sql.go | 52 +++++++++++++- pkg/sqlc/models.go | 2 +- ui/src/components/generator/GeneratorForm.tsx | 19 +++-- .../generator/GeneratorFormFinalize.tsx | 16 ++--- ui/src/components/molecules/Confirm.tsx | 5 +- .../playlist/PlaylistDuplicates.tsx | 7 +- ui/src/layout/NavLayout.tsx | 10 +-- ui/src/lib/types/api.d.ts | 8 ++- ui/src/lib/types/generator.ts | 23 +++--- ui/src/pages/Settings.tsx | 7 +- ui/src/pages/generator/GeneratorCreate.tsx | 2 +- ui/src/pages/generator/GeneratorOverview.tsx | 70 ++++++++++++++++++- 28 files changed, 298 insertions(+), 80 deletions(-) diff --git a/.dockerignore b/.dockerignore index 4a90f96..c1c4e2d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,3 +5,4 @@ node_modules/ .gitignore Dockerfile README.md +screenshots/ diff --git a/db/migrations/20251231183248_add_generator_table.sql b/db/migrations/20251231183248_add_generator_table.sql index 355a79b..293782a 100644 --- a/db/migrations/20251231183248_add_generator_table.sql +++ b/db/migrations/20251231183248_add_generator_table.sql @@ -7,7 +7,7 @@ CREATE TABLE generators ( description TEXT, playlist_id INTEGER REFERENCES playlists (id), maintained BOOLEAN NOT NULL, - interval INTEGER, + interval BIGINT, outdated BOOLEAN NOT NULL, parameters JSONB, updated_at TIMESTAMPTZ NOT NULL, diff --git a/db/queries/generator.sql b/db/queries/generator.sql index a853769..5088f22 100644 --- a/db/queries/generator.sql +++ b/db/queries/generator.sql @@ -3,6 +3,12 @@ SELECT * FROM generators WHERE id = $1; +-- name: GeneratorGetMaintainedPopulated :many +SELECT sqlc.embed(g), sqlc.embed(u) +FROM generators g +LEFT JOIN users u ON u.id = g.user_id +WHERE g.maintained = true; + -- name: GeneratorGetByUser :many SELECT * FROM generators diff --git a/internal/database/model/generator.go b/internal/database/model/generator.go index ed7fec1..8906b47 100644 --- a/internal/database/model/generator.go +++ b/internal/database/model/generator.go @@ -62,6 +62,9 @@ type Generator struct { Outdated bool Params GeneratorParams UpdatedAt time.Time + + // Non db fields + User User } func GeneratorModel(g sqlc.Generator) *Generator { diff --git a/internal/database/model/util.go b/internal/database/model/util.go index 732a215..1af5af4 100644 --- a/internal/database/model/util.go +++ b/internal/database/model/util.go @@ -38,9 +38,9 @@ func fromTime(t pgtype.Timestamptz) time.Time { return time.Time{} } -func fromDuration(i pgtype.Int4) time.Duration { +func fromDuration(i pgtype.Int8) time.Duration { if i.Valid { - return time.Duration(i.Int32) + return time.Duration(i.Int64) } return time.Duration(0) diff --git a/internal/database/repository/generator.go b/internal/database/repository/generator.go index 0b1bce7..7e2b530 100644 --- a/internal/database/repository/generator.go +++ b/internal/database/repository/generator.go @@ -46,6 +46,23 @@ func (g *Generator) GetByUser(ctx context.Context, userID int) ([]*model.Generat return utils.SliceMap(gens, model.GeneratorModel), nil } +func (g *Generator) GetMaintainedPopulated(ctx context.Context) ([]*model.Generator, error) { + gens, err := g.repo.queries(ctx).GeneratorGetMaintainedPopulated(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get maintained generators populated %w", err) + } + + return utils.SliceMap(gens, func(g sqlc.GeneratorGetMaintainedPopulatedRow) *model.Generator { + gen := model.GeneratorModel(g.Generator) + gen.User = *model.UserModel(g.User) + + return gen + }), nil +} + func (g *Generator) Create(ctx context.Context, gen *model.Generator) error { params, err := json.Marshal(gen.Params) if err != nil { diff --git a/internal/database/repository/util.go b/internal/database/repository/util.go index d2316ff..c66083e 100644 --- a/internal/database/repository/util.go +++ b/internal/database/repository/util.go @@ -27,6 +27,6 @@ func toTime(t time.Time) pgtype.Timestamptz { return pgtype.Timestamptz{Time: t, Valid: !t.IsZero()} } -func toDuration(d time.Duration) pgtype.Int4 { - return pgtype.Int4{Int32: int32(d.Nanoseconds()), Valid: d.Nanoseconds() > 0} +func toDuration(d time.Duration) pgtype.Int8 { + return pgtype.Int8{Int64: d.Nanoseconds(), Valid: d.Nanoseconds() > 0} } diff --git a/internal/generator/generate.go b/internal/generator/generate.go index d5409bd..bb1dc07 100644 --- a/internal/generator/generate.go +++ b/internal/generator/generate.go @@ -10,22 +10,22 @@ import ( "github.com/topvennie/sortifyr/pkg/utils" ) -func (g *generator) Generate(ctx context.Context, gen model.Generator) ([]model.Track, error) { +func (g *generator) Generate(ctx context.Context, gen *model.Generator) ([]model.Track, error) { var tracks []model.Track var err error - normalize(&gen.Params) + normalize(gen) switch gen.Params.Preset { case model.GeneratorPresetForgotten: - tracks, err = g.forgotten(gen) + tracks, err = g.forgotten(*gen) case model.GeneratorPresetTop: - tracks, err = g.top(ctx, gen) + tracks, err = g.top(ctx, *gen) case model.GeneratorPresetOldTop: - tracks, err = g.oldTop(ctx, gen) + tracks, err = g.oldTop(ctx, *gen) default: - tracks, err = g.custom(gen) + tracks, err = g.custom(*gen) } if err != nil { diff --git a/internal/generator/generator.go b/internal/generator/generator.go index a86f22d..feb1320 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -40,7 +40,7 @@ func Init(repo repository.Repository) error { return nil } -func (g *generator) Create(ctx context.Context, gen model.Generator, createPlaylist bool) error { +func (g *generator) Create(ctx context.Context, gen *model.Generator, createPlaylist bool) error { // Get user user, err := g.user.GetByID(ctx, gen.UserID) if err != nil { @@ -83,6 +83,12 @@ func (g *generator) Create(ctx context.Context, gen model.Generator, createPlayl if err := spotifyapi.C.PlaylistCreate(ctx, *user, &playlist); err != nil { return err } + if err := g.playlist.Create(ctx, &playlist); err != nil { + return err + } + if err := g.playlist.CreateUser(ctx, &model.PlaylistUser{UserID: user.ID, PlaylistID: playlist.ID}); err != nil { + return err + } gen.PlaylistID = playlist.ID @@ -90,22 +96,24 @@ func (g *generator) Create(ctx context.Context, gen model.Generator, createPlayl if err := spotifyapi.C.PlaylistPostTrackAll(ctx, *user, playlist.SpotifyID, tracks); err != nil { return err } + } else { + normalize(gen) } // Save in database - if err := g.generator.Create(ctx, &gen); err != nil { + if err := g.generator.Create(ctx, gen); err != nil { return err } // Start task for maintaince if gen.Maintained { if gen.Interval.Nanoseconds() == 0 { - return fmt.Errorf("interval is equal to 0 %+v", gen) + return fmt.Errorf("interval is equal to 0 %+v", *gen) } if err := task.Manager.Add(ctx, task.NewTask( - getTaskUID(&gen), - getTaskName(&gen), + getTaskUID(gen), + getTaskName(gen), gen.Interval, func(ctx context.Context, _ []model.User) []task.TaskResult { return []task.TaskResult{{ @@ -122,7 +130,7 @@ func (g *generator) Create(ctx context.Context, gen model.Generator, createPlayl return nil } -func (g *generator) Update(ctx context.Context, gen model.Generator, createPlaylist bool) error { +func (g *generator) Update(ctx context.Context, gen *model.Generator, createPlaylist bool) error { // Get user user, err := g.user.GetByID(ctx, gen.UserID) if err != nil { @@ -156,6 +164,9 @@ func (g *generator) Update(ctx context.Context, gen model.Generator, createPlayl return fmt.Errorf("playlist with id %d not found", oldGen.PlaylistID) } + if err := g.playlist.DeleteUserByUserPlaylist(ctx, model.PlaylistUser{UserID: user.ID, PlaylistID: playlist.ID}); err != nil { + return err + } if err := spotifyapi.C.PlaylistDelete(ctx, *user, playlist.SpotifyID); err != nil { return err } @@ -201,10 +212,12 @@ func (g *generator) Update(ctx context.Context, gen model.Generator, createPlayl if err := spotifyapi.C.PlaylistPostTrackAll(ctx, *user, playlist.SpotifyID, tracks); err != nil { return err } + } else { + normalize(gen) } // Update in database - if err := g.generator.Update(ctx, gen); err != nil { + if err := g.generator.Update(ctx, *gen); err != nil { return err } @@ -220,12 +233,12 @@ func (g *generator) Update(ctx context.Context, gen model.Generator, createPlayl // Start task for maintaince if gen.Maintained { if gen.Interval.Nanoseconds() == 0 { - return fmt.Errorf("interval is equal to 0 %+v", gen) + return fmt.Errorf("interval is equal to 0 %+v", *gen) } if err := task.Manager.Add(ctx, task.NewTask( - getTaskUID(&gen), - getTaskName(&gen), + getTaskUID(gen), + getTaskName(gen), gen.Interval, func(ctx context.Context, _ []model.User) []task.TaskResult { return []task.TaskResult{{ diff --git a/internal/generator/normalize.go b/internal/generator/normalize.go index 0e0796b..b40b663 100644 --- a/internal/generator/normalize.go +++ b/internal/generator/normalize.go @@ -7,33 +7,36 @@ import ( ) // normalize sets default values for the parameters -func normalize(params *model.GeneratorParams) { +func normalize(gen *model.Generator) { + params := gen.Params if params.TrackAmount == 0 { params.TrackAmount = 50 } switch params.Preset { case model.GeneratorPresetCustom: - normalizePresetCustom(params) + normalizePresetCustom(¶ms) params.ParamsForgotten = nil params.ParamsTop = nil params.ParamsOldTop = nil case model.GeneratorPresetForgotten: - normalizePresetForgotten(params) + normalizePresetForgotten(¶ms) params.ParamsCustom = nil params.ParamsTop = nil params.ParamsOldTop = nil case model.GeneratorPresetTop: - normalizePresetTop(params) + normalizePresetTop(¶ms) params.ParamsCustom = nil params.ParamsForgotten = nil params.ParamsOldTop = nil case model.GeneratorPresetOldTop: - normalizePresetOldTop(params) + normalizePresetOldTop(¶ms) params.ParamsCustom = nil params.ParamsForgotten = nil params.ParamsTop = nil } + + gen.Params = params } func normalizePresetCustom(params *model.GeneratorParams) { diff --git a/internal/generator/task.go b/internal/generator/task.go index 9e016db..b3bac19 100644 --- a/internal/generator/task.go +++ b/internal/generator/task.go @@ -53,6 +53,28 @@ func (g *generator) taskRegister(ctx context.Context) error { return err } + gens, err := g.generator.GetMaintainedPopulated(ctx) + if err != nil { + return err + } + + for _, gen := range gens { + if err := task.Manager.Add(ctx, task.NewTask( + getTaskUID(gen), + getTaskName(gen), + gen.Interval, + func(ctx context.Context, _ []model.User) []task.TaskResult { + return []task.TaskResult{{ + User: gen.User, + Message: "", + Error: g.maintain(ctx, gen.User, gen.ID), + }} + }, + )); err != nil { + return err + } + } + return nil } @@ -104,7 +126,7 @@ func (g *generator) syncOne(ctx context.Context, user model.User, gen *model.Gen } // Get the new tracks - newTracks, err := g.Generate(ctx, *gen) + newTracks, err := g.Generate(ctx, gen) if err != nil { return err } @@ -158,7 +180,7 @@ func (g *generator) maintain(ctx context.Context, user model.User, genID int) er } // Get the new tracks - newTracks, err := g.Generate(ctx, *gen) + newTracks, err := g.Generate(ctx, gen) if err != nil { return err } diff --git a/internal/server/api/generator.go b/internal/server/api/generator.go index dc61af3..7b8b859 100644 --- a/internal/server/api/generator.go +++ b/internal/server/api/generator.go @@ -95,7 +95,7 @@ func (g *Generator) update(c *fiber.Ctx) error { return fiber.ErrUnauthorized } - id, err := c.ParamsInt(":id") + id, err := c.ParamsInt("id") if err != nil { return fiber.NewError(fiber.StatusBadRequest, err.Error()) } diff --git a/internal/server/dto/generator.go b/internal/server/dto/generator.go index 435e731..b13c191 100644 --- a/internal/server/dto/generator.go +++ b/internal/server/dto/generator.go @@ -161,9 +161,9 @@ type Generator struct { ID int `json:"id"` Name string `json:"name" validate:"required"` Description string `json:"description,omitzero"` - PlaylistID int `json:"playlist_id"` + PlaylistID int `json:"playlist_id,omitzero"` Maintained bool `json:"maintained"` - IntervalS time.Duration `json:"interval_s"` + IntervalS int `json:"interval_s"` Outdated bool `json:"outdated"` Params GeneratorParams `json:"params" validate:"required"` UpdatedAt time.Time `json:"updated_at"` @@ -176,7 +176,7 @@ func GeneratorDTO(gen *model.Generator) Generator { Description: gen.Description, PlaylistID: gen.PlaylistID, Maintained: gen.Maintained, - IntervalS: gen.Interval / time.Second, + IntervalS: int(gen.Interval.Seconds()), Outdated: gen.Outdated, Params: generatorParamsDTO(gen.Params), UpdatedAt: gen.UpdatedAt, @@ -186,10 +186,10 @@ func GeneratorDTO(gen *model.Generator) Generator { type GeneratorSave struct { ID int `json:"id"` Name string `json:"name" validate:"required"` - Description string `json:"description,omitzero"` + Description string `json:"description"` CreatePlaylist bool `json:"create_playlist"` Maintained bool `json:"maintained"` - IntervalS time.Duration `json:"interval_s"` + IntervalS int `json:"interval_s"` Params GeneratorParams `json:"params" validate:"required"` } @@ -200,7 +200,7 @@ func (g GeneratorSave) ToModel(userID int) *model.Generator { Name: g.Name, Description: g.Description, Maintained: g.Maintained, - Interval: g.IntervalS * time.Second, + Interval: time.Duration(g.IntervalS) * time.Second, Params: g.Params.ToModel(), } } diff --git a/internal/server/service/generator.go b/internal/server/service/generator.go index 63027de..1b2b7e6 100644 --- a/internal/server/service/generator.go +++ b/internal/server/service/generator.go @@ -2,6 +2,7 @@ package service import ( "context" + "time" "github.com/gofiber/fiber/v2" "github.com/topvennie/sortifyr/internal/database/model" @@ -37,7 +38,7 @@ func (g *Generator) Preview(ctx context.Context, userID int, params dto.Generato UserID: userID, Params: params.ToModel(), } - tracks, err := generator.G.Generate(ctx, gen) + tracks, err := generator.G.Generate(ctx, &gen) if err != nil { zap.S().Error(err) return nil, fiber.ErrInternalServerError @@ -55,12 +56,20 @@ func (g *Generator) Create(ctx context.Context, userID int, genSave dto.Generato if !genSave.Maintained { gen.Interval = 0 } + if gen.Maintained && gen.Interval < 24*time.Hour { + return dto.Generator{}, fiber.NewError(fiber.StatusBadRequest, "interval needs to be > 0 if maintained") + } + + zap.S().Debug(genSave) + zap.S().Debug(*gen) - if err := generator.G.Create(ctx, *gen, genSave.CreatePlaylist); err != nil { + if err := generator.G.Create(ctx, gen, genSave.CreatePlaylist); err != nil { zap.S().Error(err) return dto.Generator{}, fiber.ErrInternalServerError } + zap.S().Debug(*gen) + return dto.GeneratorDTO(gen), nil } @@ -86,7 +95,7 @@ func (g *Generator) Update(ctx context.Context, userID int, genSave dto.Generato gen.Interval = 0 } - if err := generator.G.Update(ctx, *gen, genSave.CreatePlaylist); err != nil { + if err := generator.G.Update(ctx, gen, genSave.CreatePlaylist); err != nil { zap.S().Error(err) return dto.Generator{}, fiber.ErrInternalServerError } diff --git a/internal/server/service/task.go b/internal/server/service/task.go index 8e1772b..5b9cd59 100644 --- a/internal/server/service/task.go +++ b/internal/server/service/task.go @@ -100,6 +100,7 @@ func (t *Task) Start(ctx context.Context, userID int, taskUID string) error { if taskModel == nil { return fiber.ErrNotFound } + zap.S().Debug(*taskModel) if !taskModel.Recurring || !taskModel.Active { return fiber.ErrBadRequest } diff --git a/internal/spotifyapi/query.go b/internal/spotifyapi/query.go index 251920f..d508835 100644 --- a/internal/spotifyapi/query.go +++ b/internal/spotifyapi/query.go @@ -151,8 +151,10 @@ func (c *client) request(ctx context.Context, user model.User, method, url strin return c.request(ctx, user, method, url, body, target) } - if err := json.NewDecoder(resp.Body).Decode(target); err != nil { - return fmt.Errorf("decode body to json %w", err) + if target != noResp { + if err := json.NewDecoder(resp.Body).Decode(target); err != nil { + return fmt.Errorf("decode body to json %w", err) + } } return nil diff --git a/pkg/sqlc/generator.sql.go b/pkg/sqlc/generator.sql.go index 9016ce4..7c2d82c 100644 --- a/pkg/sqlc/generator.sql.go +++ b/pkg/sqlc/generator.sql.go @@ -23,7 +23,7 @@ type GeneratorCreateParams struct { Description pgtype.Text PlaylistID pgtype.Int4 Maintained bool - Interval pgtype.Int4 + Interval pgtype.Int8 Outdated bool Parameters []byte } @@ -105,6 +105,54 @@ func (q *Queries) GeneratorGetByUser(ctx context.Context, userID int32) ([]Gener return items, nil } +const generatorGetMaintainedPopulated = `-- name: GeneratorGetMaintainedPopulated :many +SELECT g.id, g.user_id, g.name, g.description, g.playlist_id, g.maintained, g.interval, g.outdated, g.parameters, g.updated_at, u.id, u.uid, u.name, u.display_name, u.email +FROM generators g +LEFT JOIN users u ON u.id = g.user_id +WHERE g.maintained = true +` + +type GeneratorGetMaintainedPopulatedRow struct { + Generator Generator + User User +} + +func (q *Queries) GeneratorGetMaintainedPopulated(ctx context.Context) ([]GeneratorGetMaintainedPopulatedRow, error) { + rows, err := q.db.Query(ctx, generatorGetMaintainedPopulated) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GeneratorGetMaintainedPopulatedRow + for rows.Next() { + var i GeneratorGetMaintainedPopulatedRow + if err := rows.Scan( + &i.Generator.ID, + &i.Generator.UserID, + &i.Generator.Name, + &i.Generator.Description, + &i.Generator.PlaylistID, + &i.Generator.Maintained, + &i.Generator.Interval, + &i.Generator.Outdated, + &i.Generator.Parameters, + &i.Generator.UpdatedAt, + &i.User.ID, + &i.User.Uid, + &i.User.Name, + &i.User.DisplayName, + &i.User.Email, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const generatorUpdate = `-- name: GeneratorUpdate :exec UPDATE generators SET @@ -125,7 +173,7 @@ type GeneratorUpdateParams struct { Description pgtype.Text PlaylistID pgtype.Int4 Maintained pgtype.Bool - Interval pgtype.Int4 + Interval pgtype.Int8 Outdated pgtype.Bool Parameters []byte } diff --git a/pkg/sqlc/models.go b/pkg/sqlc/models.go index bdd6eb5..63253d4 100644 --- a/pkg/sqlc/models.go +++ b/pkg/sqlc/models.go @@ -108,7 +108,7 @@ type Generator struct { Description pgtype.Text PlaylistID pgtype.Int4 Maintained bool - Interval pgtype.Int4 + Interval pgtype.Int8 Outdated bool Parameters []byte UpdatedAt pgtype.Timestamptz diff --git a/ui/src/components/generator/GeneratorForm.tsx b/ui/src/components/generator/GeneratorForm.tsx index a0bde0b..17afec9 100644 --- a/ui/src/components/generator/GeneratorForm.tsx +++ b/ui/src/components/generator/GeneratorForm.tsx @@ -18,6 +18,8 @@ type Props = { generator?: Generator; } +// TODO: Before saving an update, warn about deleting + const steps: Step[] = [ { title: "Preset & Parameters" }, { title: "Select Tracks" }, @@ -27,6 +29,7 @@ const steps: Step[] = [ export const GeneratorForm = ({ generator: initialGenerator }: Props) => { const [active, setActive] = useState(0) const [opened, { open, close }] = useDisclosure() + const [submitting, setSubmitting] = useState(false) const generatorCreate = useGeneratorCreate() const generatorEdit = useGeneratorEdit() @@ -36,14 +39,14 @@ export const GeneratorForm = ({ generator: initialGenerator }: Props) => { const form = useForm({ initialValues: initialGenerator ? convertGeneratorSchema(initialGenerator) : { name: "", - description: undefined, - playlist: false, + description: "", + createPlaylist: false, maintained: false, intervalS: 0, params: { trackAmount: 50, - excludedPlaylistIds: undefined, - excludedTrackIds: undefined, + excludedPlaylistIds: [], + excludedTrackIds: [], preset: GeneratorPreset.Top, paramsCustom: {}, paramsForgotten: {}, @@ -87,6 +90,8 @@ export const GeneratorForm = ({ generator: initialGenerator }: Props) => { if (update) action = generatorEdit else action = generatorCreate + setSubmitting(true) + action.mutateAsync(form.getValues(), { onSuccess: () => { notifications.show({ title: form.getValues().name, message: `Generator ${update ? "updated" : "created"}` }) @@ -96,7 +101,10 @@ export const GeneratorForm = ({ generator: initialGenerator }: Props) => { const msg = await getErrorMessage(error) notifications.show({ color: "red", message: msg }) }, - onSettled: () => close(), + onSettled: () => { + close() + setSubmitting(false) + }, }) } @@ -150,6 +158,7 @@ export const GeneratorForm = ({ generator: initialGenerator }: Props) => { title="Save Generator" description="Are you sure you want to save the generator?" onConfirm={handleSubmit} + loading={submitting} /> ) diff --git a/ui/src/components/generator/GeneratorFormFinalize.tsx b/ui/src/components/generator/GeneratorFormFinalize.tsx index 68f4431..019f4d1 100644 --- a/ui/src/components/generator/GeneratorFormFinalize.tsx +++ b/ui/src/components/generator/GeneratorFormFinalize.tsx @@ -12,15 +12,15 @@ type Props = { } export const GeneratorFormFinalize = ({ form, nextStep, prevStep }: Props) => { - const [playlist, setPlaylist] = useState(form.getValues().playlist) + const [createPlaylist, setCreatePlaylist] = useState(form.getValues().createPlaylist) const [maintained, setMaintained] = useState(form.getValues().maintained) - const [interval, setInterval] = useState(form.getValues().intervalS) + const [interval, setInterval] = useState(form.getValues().intervalS / (24 * 60 * 60)) const handleTogglePlaylist = () => { - form.setFieldValue("playlist", !playlist) - setPlaylist(prev => !prev) + form.setFieldValue("createPlaylist", !createPlaylist) + setCreatePlaylist(prev => !prev) - if (!playlist) { + if (!createPlaylist) { form.setFieldValue("maintained", false) form.setFieldValue("intervalS", 0) setMaintained(false) @@ -61,15 +61,15 @@ export const GeneratorFormFinalize = ({ form, nextStep, prevStep }: Props) => {

Create Spotify playlist

If set to off, the generator only shows a preview.

- +
-

Auto-maintain playlist

+

Auto-maintain playlist

Keep the playlist in sync with the new listening data.

- +
diff --git a/ui/src/components/molecules/Confirm.tsx b/ui/src/components/molecules/Confirm.tsx index c14390a..4ae986f 100644 --- a/ui/src/components/molecules/Confirm.tsx +++ b/ui/src/components/molecules/Confirm.tsx @@ -7,9 +7,10 @@ interface Props extends Omit { title: string; description?: string; onConfirm: () => void; + loading?: boolean; } -export const Confirm = ({ modalTitle, title, description, onConfirm, ...props }: Props) => { +export const Confirm = ({ modalTitle, title, description, onConfirm, loading = false, ...props }: Props) => { return ( @@ -19,7 +20,7 @@ export const Confirm = ({ modalTitle, title, description, onConfirm, ...props }: - diff --git a/ui/src/components/playlist/PlaylistDuplicates.tsx b/ui/src/components/playlist/PlaylistDuplicates.tsx index 11e25a7..41ccdd4 100644 --- a/ui/src/components/playlist/PlaylistDuplicates.tsx +++ b/ui/src/components/playlist/PlaylistDuplicates.tsx @@ -12,11 +12,16 @@ import { Table } from "../molecules/Table" import { PlaylistCover } from "./PlaylistCover" import { getErrorMessage } from "@/lib/utils" import { Button } from "../atoms/Button" +import { useTaskGetAll } from "@/lib/api/task" +import { TaskStatus } from "@/lib/types/task" export const PlaylistDuplicates = () => { const { data: playlists, isLoading } = usePlaylistGetDuplicates() const removeDuplicates = usePlaylistRemoveDuplicates() + const { data: tasks } = useTaskGetAll() + const task = tasks?.find(t => t.uid === "task-playlist-duplicate") + const [sortStatus, setSortStatus] = useState>({ columnAccessor: "name", direction: "asc", @@ -46,7 +51,7 @@ export const PlaylistDuplicates = () => { title="Playlist duplicates" description={`Playlists with duplicate tracks.\nClick on a row to see the duplicate tracks.`} /> - diff --git a/ui/src/layout/NavLayout.tsx b/ui/src/layout/NavLayout.tsx index 2355abc..d4cb047 100644 --- a/ui/src/layout/NavLayout.tsx +++ b/ui/src/layout/NavLayout.tsx @@ -38,16 +38,16 @@ const routes: Route[] = [ icon: , link: { to: "/link" }, }, - { - title: "Background Tasks", - icon: , - link: { to: "/task" }, - }, { title: "Generator", icon: , link: { to: "/generator" }, }, + { + title: "Background Tasks", + icon: , + link: { to: "/task" }, + }, { title: "Settings", icon: , diff --git a/ui/src/lib/types/api.d.ts b/ui/src/lib/types/api.d.ts index 962315a..63629cb 100644 --- a/ui/src/lib/types/api.d.ts +++ b/ui/src/lib/types/api.d.ts @@ -99,9 +99,10 @@ export namespace API { export interface Generator extends Base { name: string; description?: string; - playlist: boolean; + playlist_id?: number; maintained: boolean; interval_s: number; + outdated: boolean; params: { track_amount: number; excluded_playlist_ids?: number[]; @@ -114,8 +115,9 @@ export namespace API { }; params_old_top?: { peak_window: GeneratorWindow; - current_window: GeneratorWindow; + recent_window: GeneratorWindow; }; - } + }; + last_update?: string; } } diff --git a/ui/src/lib/types/generator.ts b/ui/src/lib/types/generator.ts index 6fe7eff..2f2c7cb 100644 --- a/ui/src/lib/types/generator.ts +++ b/ui/src/lib/types/generator.ts @@ -35,9 +35,10 @@ export interface Generator { id: number; name: string; description?: string; - playlist: boolean; + playlistId?: number; maintained: boolean; intervalS: number; + outdated: boolean; params: { trackAmount: number; excludedPlaylistIds: number[]; @@ -53,6 +54,7 @@ export interface Generator { recentWindow: GeneratorWindow; }; }; + lastUpdate?: Date; } export const convertGenerator = (g: API.Generator): Generator => { @@ -60,9 +62,10 @@ export const convertGenerator = (g: API.Generator): Generator => { id: g.id, name: g.name, description: g.description, - playlist: g.playlist, + playlistId: g.playlist_id, maintained: g.maintained, intervalS: g.interval_s, + outdated: g.outdated, params: { trackAmount: g.params.track_amount, excludedPlaylistIds: g.params.excluded_playlist_ids ?? [], @@ -75,9 +78,10 @@ export const convertGenerator = (g: API.Generator): Generator => { } : undefined, paramsOldTop: g.params.params_old_top ? { peakWindow: convertGeneratorWindow(g.params.params_old_top.peak_window), - recentWindow: convertGeneratorWindow(g.params.params_old_top.current_window), + recentWindow: convertGeneratorWindow(g.params.params_old_top.recent_window), } : undefined, - } + }, + lastUpdate: g.last_update ? new Date(g.last_update) : undefined, } } @@ -87,7 +91,8 @@ export const convertGenerators = (g: API.Generator[]): Generator[] => { export const convertGeneratorSchema = (g: Generator): GeneratorSchema => { return { - ...g + ...g, + createPlaylist: g.playlistId !== 0, } } @@ -103,13 +108,13 @@ export const generatorSchema = z.object({ id: z.number().positive().optional(), name: z.string().nonempty(), description: z.string().optional(), - playlist: z.boolean(), + createPlaylist: z.boolean(), maintained: z.boolean(), - intervalS: z.number().positive(), + intervalS: z.number().nonnegative(), params: z.object({ trackAmount: z.number().positive(), - excludedPlaylistIds: z.array(z.number().positive()).optional(), - excludedTrackIds: z.array(z.number().positive()).optional(), + excludedPlaylistIds: z.array(z.number().positive()), + excludedTrackIds: z.array(z.number().positive()), preset: z.enum(GeneratorPreset), paramsCustom: z.object({}).partial().optional(), paramsForgotten: z.object({}).partial().optional(), diff --git a/ui/src/pages/Settings.tsx b/ui/src/pages/Settings.tsx index 3f34a5c..e6358a7 100644 --- a/ui/src/pages/Settings.tsx +++ b/ui/src/pages/Settings.tsx @@ -1,13 +1,18 @@ import { Button } from "@/components/atoms/Button" import { Page, PageTitle, Section, SectionTitle } from "@/components/atoms/Page" import { useSettingUploadExport } from "@/lib/api/setting" +import { useTaskGetAll } from "@/lib/api/task" import { CONTENT_TYPE } from "@/lib/types/contentType" +import { TaskStatus } from "@/lib/types/task" import { getErrorMessage } from "@/lib/utils" import { FileButton, Group, Pill, Stack } from "@mantine/core" import { notifications } from "@mantine/notifications" import { LuCloudUpload } from "react-icons/lu" export const Settings = () => { + const { data: tasks } = useTaskGetAll() + const task = tasks?.find(t => t.uid === "task-export") + const uploadExport = useSettingUploadExport() const handleExport = (file: File | null) => { @@ -49,7 +54,7 @@ export const Settings = () => { {(props) => (
- +
)}
diff --git a/ui/src/pages/generator/GeneratorCreate.tsx b/ui/src/pages/generator/GeneratorCreate.tsx index 69e4614..78df4b0 100644 --- a/ui/src/pages/generator/GeneratorCreate.tsx +++ b/ui/src/pages/generator/GeneratorCreate.tsx @@ -1,4 +1,4 @@ -import { Page, PageTitle, Section } from "@/components/atoms/Page" +import { Page, PageTitle } from "@/components/atoms/Page" import { GeneratorForm } from "@/components/generator/GeneratorForm" export const GeneratorCreate = () => { diff --git a/ui/src/pages/generator/GeneratorOverview.tsx b/ui/src/pages/generator/GeneratorOverview.tsx index 2954c1e..63b729c 100644 --- a/ui/src/pages/generator/GeneratorOverview.tsx +++ b/ui/src/pages/generator/GeneratorOverview.tsx @@ -1,9 +1,21 @@ import { LinkButton } from "@/components/atoms/LinkButton" import { Page, PageTitle, Section, SectionTitle } from "@/components/atoms/Page" -import { Group } from "@mantine/core" -import { LuSparkles } from "react-icons/lu" +import { Table } from "@/components/molecules/Table" +import { useGeneratorGetAll } from "@/lib/api/generator" +import { Generator } from "@/lib/types/generator" +import { ActionIcon, Badge, Group } from "@mantine/core" +import { useNavigate } from "@tanstack/react-router" +import { LuCheck, LuPencil, LuSparkles, LuTrash2, LuUndo2 } from "react-icons/lu" export const GeneratorOverview = () => { + const { data: generators, isLoading } = useGeneratorGetAll() + + const navigate = useNavigate() + + const handleEdit = (gen: Generator) => { + navigate({ to: "/generator/edit/$generatorId", params: { generatorId: gen.id.toString() } }) + } + return ( @@ -18,6 +30,60 @@ export const GeneratorOverview = () => { + +
( +
+

{name}

+

{description}

+
+ ) + }, + { + accessor: "playlist", + title: "Playlist", + textAlign: "right", + render: ({ playlistId }) => playlistId && , + }, + { + accessor: "maintained", + title: "Maintained", + textAlign: "right", + render: ({ playlistId, maintained, intervalS }) => { + if (!playlistId) return null + const days = Math.floor(intervalS / (60 * 60 * 24)) + + return {maintained ? `Every${days !== 1 ? ' ' + days : ''} day${days !== 1 ? 's' : ''}` : "One off"} + }, + }, + { + accessor: "outdated", + title: "Status", + textAlign: "right", + render: ({ maintained, outdated }) => maintained && {outdated ? "Outdated" : "Up to date"}, + }, + { + accessor: "actions", + title: "", + width: 106, + render: gen => ( +
+
+ + handleEdit(gen)} variant="subtle" color="black"> + +
+ ) + }, + ]} + records={generators ?? []} + fetching={isLoading} + noRecordsText="No generators" + /> ) From 16a7daf394fca0e7048fe47f975fbc706f0a5b36 Mon Sep 17 00:00:00 2001 From: vincent Date: Sun, 4 Jan 2026 15:08:30 +0100 Subject: [PATCH 21/33] feat(task): hide tasks --- internal/generator/generator.go | 2 ++ internal/generator/task.go | 2 ++ internal/server/service/playlist.go | 1 + internal/server/service/setting.go | 1 + internal/spotifysync/task.go | 8 ++++++++ internal/task/manager.go | 6 ++++++ internal/task/task.go | 11 ++++++++++- 7 files changed, 30 insertions(+), 1 deletion(-) diff --git a/internal/generator/generator.go b/internal/generator/generator.go index feb1320..8f45f18 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -115,6 +115,7 @@ func (g *generator) Create(ctx context.Context, gen *model.Generator, createPlay getTaskUID(gen), getTaskName(gen), gen.Interval, + true, func(ctx context.Context, _ []model.User) []task.TaskResult { return []task.TaskResult{{ User: *user, @@ -240,6 +241,7 @@ func (g *generator) Update(ctx context.Context, gen *model.Generator, createPlay getTaskUID(gen), getTaskName(gen), gen.Interval, + true, func(ctx context.Context, _ []model.User) []task.TaskResult { return []task.TaskResult{{ User: *user, diff --git a/internal/generator/task.go b/internal/generator/task.go index b3bac19..f9c9cd9 100644 --- a/internal/generator/task.go +++ b/internal/generator/task.go @@ -36,6 +36,7 @@ func (g *generator) taskRegister(ctx context.Context) error { getTaskUID(nil), getTaskName(nil), config.GetDefaultDuration("task.generator_s", 60*60), + false, func(ctx context.Context, users []model.User) []task.TaskResult { results := make([]task.TaskResult, 0, len(users)) @@ -63,6 +64,7 @@ func (g *generator) taskRegister(ctx context.Context) error { getTaskUID(gen), getTaskName(gen), gen.Interval, + true, func(ctx context.Context, _ []model.User) []task.TaskResult { return []task.TaskResult{{ User: gen.User, diff --git a/internal/server/service/playlist.go b/internal/server/service/playlist.go index 63f1099..309cd3e 100644 --- a/internal/server/service/playlist.go +++ b/internal/server/service/playlist.go @@ -115,6 +115,7 @@ func (p *Playlist) RemoveDuplicates(ctx context.Context, userID int) error { taskPlaylistDuplicateUID, "Playlist Duplicates Removal", task.IntervalOnce, + false, func(ctx context.Context, _ []model.User) []task.TaskResult { return []task.TaskResult{{ User: *user, diff --git a/internal/server/service/setting.go b/internal/server/service/setting.go index ab2c80c..f1139c7 100644 --- a/internal/server/service/setting.go +++ b/internal/server/service/setting.go @@ -59,6 +59,7 @@ func (s *Setting) Export(ctx context.Context, userID int, zip []byte) error { taskExportUID, "Import Spotify Export", task.IntervalOnce, + false, func(ctx context.Context, _ []model.User) []task.TaskResult { return []task.TaskResult{{ User: *user, diff --git a/internal/spotifysync/task.go b/internal/spotifysync/task.go index 54c8603..aa978ec 100644 --- a/internal/spotifysync/task.go +++ b/internal/spotifysync/task.go @@ -26,6 +26,7 @@ func (c *client) taskRegister(ctx context.Context) error { taskPlaylistUID, "Playlist", config.GetDefaultDuration("task.playlist_s", 60*60), + false, c.taskWrap(c.taskPlaylist), )); err != nil { return err @@ -35,6 +36,7 @@ func (c *client) taskRegister(ctx context.Context) error { taskAlbumUID, "Album", config.GetDefaultDuration("task.album_s", 60*60), + false, c.taskWrap(c.taskAlbum), )); err != nil { return err @@ -44,6 +46,7 @@ func (c *client) taskRegister(ctx context.Context) error { taskShowUID, "Show", config.GetDefaultDuration("task.show_s", 12*60*60), + false, c.taskWrap(c.taskShow), )); err != nil { return err @@ -53,6 +56,7 @@ func (c *client) taskRegister(ctx context.Context) error { taskTrackUID, "Track", config.GetDefaultDuration("task.track_s", 5*60), + false, c.taskWrap(c.taskTrack), )); err != nil { return err @@ -62,6 +66,7 @@ func (c *client) taskRegister(ctx context.Context) error { taskArtistUID, "Artist", config.GetDefaultDuration("task.artist_s", 5*60), + false, c.taskWrap(c.taskArtist), )); err != nil { return err @@ -71,6 +76,7 @@ func (c *client) taskRegister(ctx context.Context) error { taskUserUID, "User", config.GetDefaultDuration("task.user_s", 6*60*60), + false, c.taskWrap(c.taskUser), )); err != nil { return err @@ -80,6 +86,7 @@ func (c *client) taskRegister(ctx context.Context) error { taskHistoryUID, "Current", config.GetDefaultDuration("task.history_s", 15), + false, c.taskWrap(c.taskHistory), )); err != nil { return err @@ -89,6 +96,7 @@ func (c *client) taskRegister(ctx context.Context) error { taskLinkUID, "Link", config.GetDefaultDuration("task.link_s", 12*60*60), + false, c.taskWrap(c.taskLink), )); err != nil { return err diff --git a/internal/task/manager.go b/internal/task/manager.go index 92ebb3b..99d9fe2 100644 --- a/internal/task/manager.go +++ b/internal/task/manager.go @@ -22,6 +22,7 @@ type job struct { task model.Task status Status interval time.Duration + hidden bool users []model.User // If it's not empty then an user triggered it and is waiting on it } @@ -146,6 +147,7 @@ func (m *manager) Add(ctx context.Context, newTask Task) error { task: *task, status: status, interval: newTask.Interval(), + hidden: newTask.Hidden(), users: []model.User{}, } @@ -239,6 +241,10 @@ func (m *manager) Tasks() ([]Stat, error) { } if j, ok := jobsLocal[taskUID]; ok { + if j.hidden { + continue + } + lastRun, err := job.LastRun() if err != nil { return nil, fmt.Errorf("get last run for task %s | %w", job.Name(), err) diff --git a/internal/task/task.go b/internal/task/task.go index 50e4c32..9419d6d 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -44,6 +44,9 @@ type Task interface { // Interval returns the time between executions. // An interval == IntervalOnce means it will only run once. Interval() time.Duration + // Hidden determines if the task is returned when Tasks is + // called on the manager. + Hidden() bool // The function that actually gets executed when it's time // The user slice contains all users for who the task needs to executed // In reality this will either be a single user (if the user started the task from the api) @@ -84,6 +87,7 @@ type internalTask struct { uid string name string interval time.Duration + hidden bool fn func(context.Context, []model.User) []TaskResult ctx context.Context } @@ -95,7 +99,7 @@ var _ Task = (*internalTask)(nil) // It supports an optional context, if none is given the background context is used // Logs (info level) when a task starts and ends // Logs (error level) any error that occurs during the task execution -func NewTask(uid, name string, interval time.Duration, fn func(context.Context, []model.User) []TaskResult, ctx ...context.Context) Task { +func NewTask(uid, name string, interval time.Duration, hidden bool, fn func(context.Context, []model.User) []TaskResult, ctx ...context.Context) Task { c := context.Background() if len(ctx) > 0 { c = ctx[0] @@ -105,6 +109,7 @@ func NewTask(uid, name string, interval time.Duration, fn func(context.Context, uid: uid, name: name, interval: interval, + hidden: hidden, fn: fn, ctx: c, } @@ -122,6 +127,10 @@ func (t *internalTask) Interval() time.Duration { return t.interval } +func (t *internalTask) Hidden() bool { + return t.hidden +} + func (t *internalTask) Func() func(context.Context, []model.User) []TaskResult { return func(ctx context.Context, users []model.User) []TaskResult { zap.S().Infof("Task running %s", t.name) From c0b41f87c829ac7a43d1b28adf0afbe8ae21c710 Mon Sep 17 00:00:00 2001 From: vincent Date: Sun, 4 Jan 2026 15:26:07 +0100 Subject: [PATCH 22/33] feat(generator): refresh playlist at will --- internal/generator/generator.go | 26 ++++++++++++++ internal/generator/task.go | 1 + internal/server/api/generator.go | 19 +++++++++++ internal/server/service/generator.go | 36 ++++++++++++++++++++ ui/src/lib/api/generator.ts | 11 +++++- ui/src/pages/generator/GeneratorOverview.tsx | 22 +++++++++--- 6 files changed, 110 insertions(+), 5 deletions(-) diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 8f45f18..db15421 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -40,6 +40,32 @@ func Init(repo repository.Repository) error { return nil } +func (g *generator) Refresh(ctx context.Context, user model.User, gen model.Generator) error { + if gen.PlaylistID == 0 { + return errors.New("generator doesn't have a playlist") + } + + // If the generator is maintained then we can just run the task + if gen.Maintained { + return task.Manager.RunRecurringByUID(getTaskUID(&gen), user) + } + + // Else we need to run it once + return task.Manager.Add(ctx, task.NewTask( + getTaskUID(&gen), + getTaskName(&gen), + task.IntervalOnce, + true, + func(ctx context.Context, _ []model.User) []task.TaskResult { + return []task.TaskResult{{ + User: user, + Message: "", + Error: g.maintain(ctx, user, gen.ID), + }} + }, + )) +} + func (g *generator) Create(ctx context.Context, gen *model.Generator, createPlaylist bool) error { // Get user user, err := g.user.GetByID(ctx, gen.UserID) diff --git a/internal/generator/task.go b/internal/generator/task.go index f9c9cd9..0ef9309 100644 --- a/internal/generator/task.go +++ b/internal/generator/task.go @@ -156,6 +156,7 @@ func (g *generator) syncOne(ctx context.Context, user model.User, gen *model.Gen } // maintain updates a maintained playlist to contain the newest tracks +// TODO: Go over it func (g *generator) maintain(ctx context.Context, user model.User, genID int) error { // Refresh generator data gen, err := g.generator.Get(ctx, genID) diff --git a/internal/server/api/generator.go b/internal/server/api/generator.go index 7b8b859..fed44cd 100644 --- a/internal/server/api/generator.go +++ b/internal/server/api/generator.go @@ -26,6 +26,7 @@ func NewGenerator(router fiber.Router, service service.Service) *Generator { func (g *Generator) createRoutes() { g.router.Get("/", g.getAll) g.router.Post("/preview", g.preview) + g.router.Post("/refesh/:id", g.refresh) g.router.Put("/", g.create) g.router.Post("/:id", g.update) } @@ -66,6 +67,24 @@ func (g *Generator) preview(c *fiber.Ctx) error { return c.JSON(tracks) } +func (g *Generator) refresh(c *fiber.Ctx) error { + userID, ok := c.Locals("userID").(int) + if !ok { + return fiber.ErrUnauthorized + } + + genID, err := c.ParamsInt("id") + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if err := g.generator.Refresh(c.Context(), userID, genID); err != nil { + return err + } + + return c.SendStatus(fiber.StatusNoContent) +} + func (g *Generator) create(c *fiber.Ctx) error { userID, ok := c.Locals("userID").(int) if !ok { diff --git a/internal/server/service/generator.go b/internal/server/service/generator.go index 1b2b7e6..3e2f719 100644 --- a/internal/server/service/generator.go +++ b/internal/server/service/generator.go @@ -15,11 +15,13 @@ import ( type Generator struct { generator repository.Generator + user repository.User } func (s *Service) NewGenerator() *Generator { return &Generator{ generator: *s.repo.NewGenerator(), + user: *s.repo.NewUser(), } } @@ -47,6 +49,40 @@ func (g *Generator) Preview(ctx context.Context, userID int, params dto.Generato return utils.SliceMap(tracks, func(t model.Track) dto.Track { return dto.TrackDTO(&t) }), nil } +func (g *Generator) Refresh(ctx context.Context, userID, genID int) error { + user, err := g.user.GetByID(ctx, userID) + if err != nil { + zap.S().Error(err) + return fiber.ErrInternalServerError + } + if user == nil { + return fiber.ErrUnauthorized + } + + gen, err := g.generator.Get(ctx, genID) + if err != nil { + return fiber.ErrInternalServerError + } + if gen == nil { + return fiber.ErrNotFound + } + + if gen.PlaylistID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "generator does not have a playlist") + } + + if gen.UserID != userID { + return fiber.ErrForbidden + } + + if err := generator.G.Refresh(*user, *gen); err != nil { + zap.S().Error(err) + return fiber.ErrInternalServerError + } + + return nil +} + func (g *Generator) Create(ctx context.Context, userID int, genSave dto.GeneratorSave) (dto.Generator, error) { gen := genSave.ToModel(userID) diff --git a/ui/src/lib/api/generator.ts b/ui/src/lib/api/generator.ts index b6f4303..ad174ca 100644 --- a/ui/src/lib/api/generator.ts +++ b/ui/src/lib/api/generator.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { convertGenerators, GeneratorSchema } from "../types/generator" +import { convertGenerators, Generator, GeneratorSchema } from "../types/generator" import { convertTracks } from "../types/track" import { apiGet, apiPost, apiPut } from "./query" import { STALE_TIME } from "../types/staletime" @@ -22,6 +22,15 @@ export const useGeneratorPreview = () => { }) } +export const useGeneratorRefresh = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (generator: Pick) => apiPost(`${ENDPOINT}/refresh/${generator.id}`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["task"] }), + }) +} + export const useGeneratorCreate = () => { const queryClient = useQueryClient() diff --git a/ui/src/pages/generator/GeneratorOverview.tsx b/ui/src/pages/generator/GeneratorOverview.tsx index 63b729c..d9fba34 100644 --- a/ui/src/pages/generator/GeneratorOverview.tsx +++ b/ui/src/pages/generator/GeneratorOverview.tsx @@ -1,21 +1,35 @@ import { LinkButton } from "@/components/atoms/LinkButton" import { Page, PageTitle, Section, SectionTitle } from "@/components/atoms/Page" import { Table } from "@/components/molecules/Table" -import { useGeneratorGetAll } from "@/lib/api/generator" +import { useGeneratorGetAll, useGeneratorRefresh } from "@/lib/api/generator" import { Generator } from "@/lib/types/generator" +import { getErrorMessage } from "@/lib/utils" import { ActionIcon, Badge, Group } from "@mantine/core" +import { notifications } from "@mantine/notifications" import { useNavigate } from "@tanstack/react-router" -import { LuCheck, LuPencil, LuSparkles, LuTrash2, LuUndo2 } from "react-icons/lu" +import { LuCheck, LuListRestart, LuPencil, LuSparkles, LuTrash2 } from "react-icons/lu" export const GeneratorOverview = () => { const { data: generators, isLoading } = useGeneratorGetAll() + const generatorRefresh = useGeneratorRefresh() const navigate = useNavigate() + const handleRefresh = (gen: Generator) => { + generatorRefresh.mutateAsync(gen, { + onSuccess: () => notifications.show({ message: "Updating generator" }), + onError: async error => { + const msg = await getErrorMessage(error) + notifications.show({ color: "red", message: msg }) + } + }) + } + const handleEdit = (gen: Generator) => { navigate({ to: "/generator/edit/$generatorId", params: { generatorId: gen.id.toString() } }) } + return ( @@ -69,11 +83,11 @@ export const GeneratorOverview = () => { { accessor: "actions", title: "", - width: 106, + width: 120, render: gen => (
- + handleRefresh(gen)} variant="subtle" color="black" hidden={!gen.playlistId}> handleEdit(gen)} variant="subtle" color="black">
From 6c2ce88daa2540f8cc6d148a53420feb5ae0dcce Mon Sep 17 00:00:00 2001 From: vincent Date: Sun, 4 Jan 2026 23:26:37 +0100 Subject: [PATCH 23/33] feat(generator): add delete functionality --- db/queries/generator.sql | 4 + internal/database/repository/generator.go | 8 + internal/generator/generator.go | 26 +++ internal/server/api/generator.go | 28 +++ internal/server/service/generator.go | 33 +++- pkg/sqlc/generator.sql.go | 10 ++ ui/src/components/molecules/Confirm.tsx | 7 +- ui/src/lib/api/generator.ts | 19 +- ui/src/pages/generator/GeneratorOverview.tsx | 178 ++++++++++++------- 9 files changed, 242 insertions(+), 71 deletions(-) diff --git a/db/queries/generator.sql b/db/queries/generator.sql index 5088f22..f8774d4 100644 --- a/db/queries/generator.sql +++ b/db/queries/generator.sql @@ -31,3 +31,7 @@ SET parameters = coalesce(sqlc.narg('parameters'), parameters), updated_at = NOW() WHERE id = $1; + +-- name: GeneratorDelete :exec +DELETE FROM generators +WHERE id = $1; diff --git a/internal/database/repository/generator.go b/internal/database/repository/generator.go index 7e2b530..6c68807 100644 --- a/internal/database/repository/generator.go +++ b/internal/database/repository/generator.go @@ -109,3 +109,11 @@ func (g *Generator) Update(ctx context.Context, gen model.Generator) error { return nil } + +func (g *Generator) Delete(ctx context.Context, genID int) error { + if err := g.repo.queries(ctx).GeneratorDelete(ctx, int32(genID)); err != nil { + return fmt.Errorf("delete generator %d | %w", genID, err) + } + + return nil +} diff --git a/internal/generator/generator.go b/internal/generator/generator.go index db15421..3a66e29 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -282,3 +282,29 @@ func (g *generator) Update(ctx context.Context, gen *model.Generator, createPlay return nil } + +func (g *generator) Delete(ctx context.Context, user model.User, gen model.Generator, deletePlaylist bool) error { + if err := g.generator.Delete(ctx, gen.ID); err != nil { + return err + } + + if gen.PlaylistID != 0 && deletePlaylist { + playlist, err := g.playlist.Get(ctx, gen.PlaylistID) + if err != nil { + return err + } + if playlist == nil { + return fmt.Errorf("db unsynced %+v | %w", gen, err) + } + + if err := spotifyapi.C.PlaylistDelete(ctx, user, playlist.SpotifyID); err != nil { + return fmt.Errorf("delete playlist %w", err) + } + + if err := g.playlist.DeleteUserByUserPlaylist(ctx, model.PlaylistUser{UserID: user.ID, PlaylistID: playlist.ID}); err != nil { + return err + } + } + + return nil +} diff --git a/internal/server/api/generator.go b/internal/server/api/generator.go index fed44cd..cb7a2d7 100644 --- a/internal/server/api/generator.go +++ b/internal/server/api/generator.go @@ -1,6 +1,8 @@ package api import ( + "strconv" + "github.com/gofiber/fiber/v2" "github.com/topvennie/sortifyr/internal/server/dto" "github.com/topvennie/sortifyr/internal/server/service" @@ -29,6 +31,7 @@ func (g *Generator) createRoutes() { g.router.Post("/refesh/:id", g.refresh) g.router.Put("/", g.create) g.router.Post("/:id", g.update) + g.router.Delete("/:id", g.delete) } func (g *Generator) getAll(c *fiber.Ctx) error { @@ -135,3 +138,28 @@ func (g *Generator) update(c *fiber.Ctx) error { return c.JSON(newGen) } + +func (g *Generator) delete(c *fiber.Ctx) error { + userID, ok := c.Locals("userID").(int) + if !ok { + return fiber.ErrUnauthorized + } + + genID, err := c.ParamsInt("id") + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + deletePlaylist := false + if v := c.Query("deletePlaylist"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + deletePlaylist = b + } + } + + if err := g.generator.Delete(c.Context(), userID, genID, deletePlaylist); err != nil { + return err + } + + return c.SendStatus(fiber.StatusNoContent) +} diff --git a/internal/server/service/generator.go b/internal/server/service/generator.go index 3e2f719..88f96c4 100644 --- a/internal/server/service/generator.go +++ b/internal/server/service/generator.go @@ -75,7 +75,7 @@ func (g *Generator) Refresh(ctx context.Context, userID, genID int) error { return fiber.ErrForbidden } - if err := generator.G.Refresh(*user, *gen); err != nil { + if err := generator.G.Refresh(ctx, *user, *gen); err != nil { zap.S().Error(err) return fiber.ErrInternalServerError } @@ -138,3 +138,34 @@ func (g *Generator) Update(ctx context.Context, userID int, genSave dto.Generato return dto.GeneratorDTO(gen), nil } + +func (g *Generator) Delete(ctx context.Context, userID, genID int, deletePlaylist bool) error { + user, err := g.user.GetByID(ctx, userID) + if err != nil { + zap.S().Error(err) + return fiber.ErrInternalServerError + } + if user == nil { + return fiber.ErrUnauthorized + } + + gen, err := g.generator.Get(ctx, genID) + if err != nil { + zap.S().Error(err) + return fiber.ErrInternalServerError + } + if gen == nil { + return fiber.ErrNotFound + } + + if gen.UserID != userID { + return fiber.ErrForbidden + } + + if err := generator.G.Delete(ctx, *user, *gen, deletePlaylist); err != nil { + zap.S().Error(err) + return fiber.ErrInternalServerError + } + + return nil +} diff --git a/pkg/sqlc/generator.sql.go b/pkg/sqlc/generator.sql.go index 7c2d82c..4f5fe48 100644 --- a/pkg/sqlc/generator.sql.go +++ b/pkg/sqlc/generator.sql.go @@ -44,6 +44,16 @@ func (q *Queries) GeneratorCreate(ctx context.Context, arg GeneratorCreateParams return id, err } +const generatorDelete = `-- name: GeneratorDelete :exec +DELETE FROM generators +WHERE id = $1 +` + +func (q *Queries) GeneratorDelete(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, generatorDelete, id) + return err +} + const generatorGet = `-- name: GeneratorGet :one SELECT id, user_id, name, description, playlist_id, maintained, interval, outdated, parameters, updated_at FROM generators diff --git a/ui/src/components/molecules/Confirm.tsx b/ui/src/components/molecules/Confirm.tsx index 4ae986f..9f8c22b 100644 --- a/ui/src/components/molecules/Confirm.tsx +++ b/ui/src/components/molecules/Confirm.tsx @@ -1,21 +1,24 @@ import { Text, Group, ModalBaseProps, Stack } from "@mantine/core"; import { ModalCenter } from "../atoms/ModalCenter"; import { Button } from "../atoms/Button"; +import { ReactNode } from "react"; -interface Props extends Omit { +interface Props extends Omit { modalTitle: string; title: string; description?: string; + content?: ReactNode; onConfirm: () => void; loading?: boolean; } -export const Confirm = ({ modalTitle, title, description, onConfirm, loading = false, ...props }: Props) => { +export const Confirm = ({ modalTitle, title, description, content, onConfirm, loading = false, ...props }: Props) => { return ( {title} {description && {description}} + {content}
( -
-

{name}

-

{description}

-
- ) - }, - { - accessor: "playlist", - title: "Playlist", - textAlign: "right", - render: ({ playlistId }) => playlistId && , - }, - { - accessor: "maintained", - title: "Maintained", - textAlign: "right", - render: ({ playlistId, maintained, intervalS }) => { - if (!playlistId) return null - const days = Math.floor(intervalS / (60 * 60 * 24)) +
( +
+

{name}

+

{description}

+
+ ) + }, + { + accessor: "playlist", + title: "Playlist", + textAlign: "right", + render: ({ playlistId }) => playlistId && , + }, + { + accessor: "maintained", + title: "Maintained", + textAlign: "right", + render: ({ playlistId, maintained, intervalS }) => { + if (!playlistId) return null + const days = Math.floor(intervalS / (60 * 60 * 24)) - return {maintained ? `Every${days !== 1 ? ' ' + days : ''} day${days !== 1 ? 's' : ''}` : "One off"} + return {maintained ? `Every${days !== 1 ? ' ' + days : ''} day${days !== 1 ? 's' : ''}` : "One off"} + }, }, - }, - { - accessor: "outdated", - title: "Status", - textAlign: "right", - render: ({ maintained, outdated }) => maintained && {outdated ? "Outdated" : "Up to date"}, - }, - { - accessor: "actions", - title: "", - width: 120, - render: gen => ( -
-
- handleRefresh(gen)} variant="subtle" color="black" hidden={!gen.playlistId}> - handleEdit(gen)} variant="subtle" color="black"> - -
- ) - }, - ]} - records={generators ?? []} - fetching={isLoading} - noRecordsText="No generators" - /> - - + { + accessor: "outdated", + title: "Status", + textAlign: "right", + render: ({ maintained, outdated }) => maintained && {outdated ? "Outdated" : "Up to date"}, + }, + { + accessor: "actions", + title: "", + width: 120, + render: gen => ( +
+
+ handleRefresh(gen)} variant="subtle" color="black" hidden={!gen.playlistId}> + handleEdit(gen)} variant="subtle" color="black"> + handleDeleteInit(gen)} variant="subtle" color="red"> +
+ ) + }, + ]} + records={generators ?? []} + fetching={isLoading} + noRecordsText="No generators" + /> + + + + setCheckedPlaylist(e.target.checked)} label="Delete Spotify playlist" /> + + )} + onConfirm={handleDelete} + /> + + ) } From 4d2224d91bb2993978ba7cf86acc0a57ca6f825e Mon Sep 17 00:00:00 2001 From: vincent Date: Mon, 5 Jan 2026 01:49:01 +0100 Subject: [PATCH 24/33] feat(generator): save generator tracks seperately --- .../20251231183248_add_generator_table.sql | 16 +++-- db/queries/generator.sql | 15 ++-- db/queries/generator_track.sql | 10 +++ db/queries/track.sql | 7 ++ internal/database/model/generator.go | 47 +++++++------ internal/database/repository/generator.go | 68 +++++++++++-------- internal/database/repository/track.go | 12 ++++ 7 files changed, 110 insertions(+), 65 deletions(-) create mode 100644 db/queries/generator_track.sql diff --git a/db/migrations/20251231183248_add_generator_table.sql b/db/migrations/20251231183248_add_generator_table.sql index 293782a..31c82cb 100644 --- a/db/migrations/20251231183248_add_generator_table.sql +++ b/db/migrations/20251231183248_add_generator_table.sql @@ -6,20 +6,22 @@ CREATE TABLE generators ( name TEXT NOT NULL, description TEXT, playlist_id INTEGER REFERENCES playlists (id), - maintained BOOLEAN NOT NULL, interval BIGINT, - outdated BOOLEAN NOT NULL, + spotify_outdated BOOLEAN NOT NULL, parameters JSONB, - updated_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); - CONSTRAINT generators_interval_required_if_maintained CHECK ( - maintained = false - OR interval IS NOT NULL - ) +CREATE TABLE generator_tracks ( + id SERIAL PRIMARY KEY, + generator_id INTEGER NOT NULL REFERENCES generators (id) ON DELETE CASCADE, + track_id INTEGER NOT NULL REFERENCES tracks (id) ON DELETE CASCADE ); -- +goose StatementEnd -- +goose Down -- +goose StatementBegin +DROP TABLE generator_tracks; + DROP TABLE generators; -- +goose StatementEnd diff --git a/db/queries/generator.sql b/db/queries/generator.sql index f8774d4..87758cd 100644 --- a/db/queries/generator.sql +++ b/db/queries/generator.sql @@ -3,11 +3,9 @@ SELECT * FROM generators WHERE id = $1; --- name: GeneratorGetMaintainedPopulated :many -SELECT sqlc.embed(g), sqlc.embed(u) -FROM generators g -LEFT JOIN users u ON u.id = g.user_id -WHERE g.maintained = true; +-- name: GeneratorGetAll :many +SELECT * +FROM generators; -- name: GeneratorGetByUser :many SELECT * @@ -15,8 +13,8 @@ FROM generators WHERE user_id = $1; -- name: GeneratorCreate :one -INSERT INTO generators (user_id, name, description, playlist_id, maintained, interval, outdated, parameters, updated_at) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) +INSERT INTO generators (user_id, name, description, playlist_id, interval, spotify_outdated, parameters, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) RETURNING id; -- name: GeneratorUpdate :exec @@ -25,9 +23,8 @@ SET name = coalesce(sqlc.narg('name'), name), description = coalesce(sqlc.narg('description'), description), playlist_id = coalesce(sqlc.narg('playlist_id'), playlist_id), - maintained = coalesce(sqlc.narg('maintained'), maintained), interval = coalesce(sqlc.narg('interval'), interval), - outdated = coalesce(sqlc.narg('outdated'), outdated), + spotify_outdated = coalesce(sqlc.narg('spotify_outdated'), spotify_outdated), parameters = coalesce(sqlc.narg('parameters'), parameters), updated_at = NOW() WHERE id = $1; diff --git a/db/queries/generator_track.sql b/db/queries/generator_track.sql new file mode 100644 index 0000000..54bb4f3 --- /dev/null +++ b/db/queries/generator_track.sql @@ -0,0 +1,10 @@ +-- name: GeneratorTrackCreateBatch :exec +INSERT INTO generator_tracks (generator_id, track_id) +VALUES ( + UNNEST($1::int[]), + UNNEST($2::int[]) +); + +-- name: GeneratorTrackDeleteByGenerator :exec +DELETE FROM generator_tracks +WHERE generator_id = $1; diff --git a/db/queries/track.sql b/db/queries/track.sql index 12c58b7..f5abe48 100644 --- a/db/queries/track.sql +++ b/db/queries/track.sql @@ -28,6 +28,13 @@ FROM tracks t LEFT JOIN playlist_tracks pt ON pt.track_id = t.id WHERE pt.playlist_id = $1 AND pt.deleted_at IS NULL; +-- name: TrackGetByGenerator :many +SELECT t.* +FROM tracks t +LEFT JOIN generator_tracks gt ON gt.track_id = t.id +WHERE gt.generator_id = $1 +ORDER BY t.name; + -- name: TrackGetCreatedFilteredPopulated :many SELECT sqlc.embed(t), sqlc.embed(pt), sqlc.embed(p), sqlc.embed(u) FROM tracks t diff --git a/internal/database/model/generator.go b/internal/database/model/generator.go index 8906b47..6be2629 100644 --- a/internal/database/model/generator.go +++ b/internal/database/model/generator.go @@ -52,19 +52,19 @@ type GeneratorParams struct { } type Generator struct { - ID int - UserID int - Name string - Description string - PlaylistID int - Maintained bool - Interval time.Duration - Outdated bool - Params GeneratorParams - UpdatedAt time.Time + ID int + UserID int + Name string + Description string + PlaylistID int + Interval time.Duration + SpotifyOutdated bool + Params GeneratorParams + UpdatedAt time.Time // Non db fields - User User + User User + Tracks []Track } func GeneratorModel(g sqlc.Generator) *Generator { @@ -72,15 +72,20 @@ func GeneratorModel(g sqlc.Generator) *Generator { _ = json.Unmarshal(g.Parameters, ¶ms) // nolint:errcheck // Data controlled by us, it'll be fine, ...right? return &Generator{ - ID: int(g.ID), - UserID: int(g.UserID), - Name: g.Name, - Description: fromString(g.Description), - PlaylistID: fromInt(g.PlaylistID), - Maintained: g.Maintained, - Interval: fromDuration(g.Interval), - Outdated: g.Outdated, - Params: params, - UpdatedAt: g.UpdatedAt.Time, + ID: int(g.ID), + UserID: int(g.UserID), + Name: g.Name, + Description: fromString(g.Description), + PlaylistID: fromInt(g.PlaylistID), + Interval: fromDuration(g.Interval), + SpotifyOutdated: g.SpotifyOutdated, + Params: params, + UpdatedAt: g.UpdatedAt.Time, } } + +type GeneratorTrack struct { + ID int + GeneratorID int + TrackID int +} diff --git a/internal/database/repository/generator.go b/internal/database/repository/generator.go index 6c68807..6df9288 100644 --- a/internal/database/repository/generator.go +++ b/internal/database/repository/generator.go @@ -34,33 +34,28 @@ func (g *Generator) Get(ctx context.Context, id int) (*model.Generator, error) { return model.GeneratorModel(gen), nil } -func (g *Generator) GetByUser(ctx context.Context, userID int) ([]*model.Generator, error) { - gens, err := g.repo.queries(ctx).GeneratorGetByUser(ctx, int32(userID)) +func (g *Generator) GetAll(ctx context.Context) ([]*model.Generator, error) { + gens, err := g.repo.queries(ctx).GeneratorGetAll(ctx) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } - return nil, fmt.Errorf("get generators by user %d | %w", userID, err) + return nil, fmt.Errorf("get all generators %w", err) } return utils.SliceMap(gens, model.GeneratorModel), nil } -func (g *Generator) GetMaintainedPopulated(ctx context.Context) ([]*model.Generator, error) { - gens, err := g.repo.queries(ctx).GeneratorGetMaintainedPopulated(ctx) +func (g *Generator) GetByUser(ctx context.Context, userID int) ([]*model.Generator, error) { + gens, err := g.repo.queries(ctx).GeneratorGetByUser(ctx, int32(userID)) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } - return nil, fmt.Errorf("get maintained generators populated %w", err) + return nil, fmt.Errorf("get generators by user %d | %w", userID, err) } - return utils.SliceMap(gens, func(g sqlc.GeneratorGetMaintainedPopulatedRow) *model.Generator { - gen := model.GeneratorModel(g.Generator) - gen.User = *model.UserModel(g.User) - - return gen - }), nil + return utils.SliceMap(gens, model.GeneratorModel), nil } func (g *Generator) Create(ctx context.Context, gen *model.Generator) error { @@ -70,14 +65,13 @@ func (g *Generator) Create(ctx context.Context, gen *model.Generator) error { } id, err := g.repo.queries(ctx).GeneratorCreate(ctx, sqlc.GeneratorCreateParams{ - UserID: int32(gen.UserID), - Name: gen.Name, - Description: toString(gen.Description), - PlaylistID: toInt(gen.PlaylistID), - Maintained: gen.Maintained, - Interval: toDuration(gen.Interval), - Outdated: gen.Outdated, - Parameters: params, + UserID: int32(gen.UserID), + Name: gen.Name, + Description: toString(gen.Description), + PlaylistID: toInt(gen.PlaylistID), + Interval: toDuration(gen.Interval), + SpotifyOutdated: gen.SpotifyOutdated, + Parameters: params, }) if err != nil { return fmt.Errorf("create generator %+v | %w", *gen, err) @@ -88,6 +82,17 @@ func (g *Generator) Create(ctx context.Context, gen *model.Generator) error { return nil } +func (g *Generator) CreateTrackBatch(ctx context.Context, tracks []model.GeneratorTrack) error { + if err := g.repo.queries(ctx).GeneratorTrackCreateBatch(ctx, sqlc.GeneratorTrackCreateBatchParams{ + Column1: utils.SliceMap(tracks, func(t model.GeneratorTrack) int32 { return int32(t.GeneratorID) }), + Column2: utils.SliceMap(tracks, func(t model.GeneratorTrack) int32 { return int32(t.TrackID) }), + }); err != nil { + return fmt.Errorf("create generator track batch %w", err) + } + + return nil +} + func (g *Generator) Update(ctx context.Context, gen model.Generator) error { params, err := json.Marshal(gen.Params) if err != nil { @@ -95,14 +100,13 @@ func (g *Generator) Update(ctx context.Context, gen model.Generator) error { } if err := g.repo.queries(ctx).GeneratorUpdate(ctx, sqlc.GeneratorUpdateParams{ - ID: int32(gen.ID), - Name: toString(gen.Name), - Description: toString(gen.Description), - PlaylistID: toInt(gen.PlaylistID), - Maintained: toBool(&gen.Maintained), - Interval: toDuration(gen.Interval), - Outdated: toBool(&gen.Outdated), - Parameters: params, + ID: int32(gen.ID), + Name: toString(gen.Name), + Description: toString(gen.Description), + PlaylistID: toInt(gen.PlaylistID), + Interval: toDuration(gen.Interval), + SpotifyOutdated: toBool(&gen.SpotifyOutdated), + Parameters: params, }); err != nil { return fmt.Errorf("update generator %+v | %w", gen, err) } @@ -117,3 +121,11 @@ func (g *Generator) Delete(ctx context.Context, genID int) error { return nil } + +func (g *Generator) DeleteTrackByGenerator(ctx context.Context, id int) error { + if err := g.repo.queries(ctx).GeneratorTrackDeleteByGenerator(ctx, int32(id)); err != nil { + return fmt.Errorf("delete generator track by generator %d | %w", id, err) + } + + return nil +} diff --git a/internal/database/repository/track.go b/internal/database/repository/track.go index a11aba1..e8b2f48 100644 --- a/internal/database/repository/track.go +++ b/internal/database/repository/track.go @@ -93,6 +93,18 @@ func (t *Track) GetByPlaylist(ctx context.Context, playlistID int) ([]*model.Tra return utils.SliceMap(tracks, model.TrackModel), nil } +func (t *Track) GetByGenerator(ctx context.Context, generatorID int) ([]*model.Track, error) { + tracks, err := t.repo.queries(ctx).TrackGetByGenerator(ctx, int32(generatorID)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get tracks by generator %d | %w", generatorID, err) + } + + return utils.SliceMap(tracks, model.TrackModel), nil +} + func (t *Track) GetCreatedFiltered(ctx context.Context, filter model.TrackFilter) ([]*model.Track, error) { params := sqlc.TrackGetCreatedFilteredPopulatedParams{ Column3: int32(filter.UserID), From 5e315e3a2b79729b9e2cafb6e0a30639175d2f47 Mon Sep 17 00:00:00 2001 From: vincent Date: Mon, 5 Jan 2026 01:49:16 +0100 Subject: [PATCH 25/33] refactor(generator): simplify spotify playlist options --- internal/generator/generator.go | 140 +++++++------- internal/generator/task.go | 172 +++++++++--------- internal/server/dto/generator.go | 40 ++-- internal/server/service/generator.go | 28 +-- internal/spotifysync/task.go | 16 +- pkg/config/config.go | 10 +- pkg/sqlc/generator.sql.go | 110 +++++------ pkg/sqlc/generator_track.sql.go | 38 ++++ pkg/sqlc/models.go | 23 ++- pkg/sqlc/track.sql.go | 35 ++++ ui/src/components/generator/GeneratorForm.tsx | 3 +- .../generator/GeneratorFormFinalize.tsx | 49 +---- ui/src/lib/types/api.d.ts | 5 +- ui/src/lib/types/generator.ts | 85 +++++---- ui/src/pages/generator/GeneratorOverview.tsx | 17 +- 15 files changed, 376 insertions(+), 395 deletions(-) create mode 100644 pkg/sqlc/generator_track.sql.go diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 3a66e29..1197ba6 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -41,16 +41,13 @@ func Init(repo repository.Repository) error { } func (g *generator) Refresh(ctx context.Context, user model.User, gen model.Generator) error { - if gen.PlaylistID == 0 { - return errors.New("generator doesn't have a playlist") - } - - // If the generator is maintained then we can just run the task - if gen.Maintained { + // If the generator has an interval then it has a scheduled task to update it. + // So we can just run that. + if gen.Interval > 0 { return task.Manager.RunRecurringByUID(getTaskUID(&gen), user) } - // Else we need to run it once + // Else we need to add a one time task to return task.Manager.Add(ctx, task.NewTask( getTaskUID(&gen), getTaskName(&gen), @@ -60,7 +57,7 @@ func (g *generator) Refresh(ctx context.Context, user model.User, gen model.Gene return []task.TaskResult{{ User: user, Message: "", - Error: g.maintain(ctx, user, gen.ID), + Error: g.refresh(ctx, user, gen.ID), }} }, )) @@ -76,18 +73,12 @@ func (g *generator) Create(ctx context.Context, gen *model.Generator, createPlay return fmt.Errorf("user not found %d", gen.UserID) } - // Create playlist + // Create playlist in spotify and the db gen.PlaylistID = 0 if createPlaylist { - // Get tracks - tracks, err := g.Generate(ctx, gen) - if err != nil { - return err - } - // Create playlist in Spotify description := "Created by Sortifyr" - if gen.Maintained { + if gen.Interval > 0 { days := int(gen.Interval.Nanoseconds() / int64(24*time.Hour)) daysStr := strconv.Itoa(days) + "days" if days == 1 { @@ -109,6 +100,8 @@ func (g *generator) Create(ctx context.Context, gen *model.Generator, createPlay if err := spotifyapi.C.PlaylistCreate(ctx, *user, &playlist); err != nil { return err } + + // Create playlist in db and link to user if err := g.playlist.Create(ctx, &playlist); err != nil { return err } @@ -117,13 +110,6 @@ func (g *generator) Create(ctx context.Context, gen *model.Generator, createPlay } gen.PlaylistID = playlist.ID - - // Add tracks to the playlist - if err := spotifyapi.C.PlaylistPostTrackAll(ctx, *user, playlist.SpotifyID, tracks); err != nil { - return err - } - } else { - normalize(gen) } // Save in database @@ -131,27 +117,28 @@ func (g *generator) Create(ctx context.Context, gen *model.Generator, createPlay return err } - // Start task for maintaince - if gen.Maintained { - if gen.Interval.Nanoseconds() == 0 { - return fmt.Errorf("interval is equal to 0 %+v", *gen) - } + // Add tracks to the playlist + // If the generator has an interval then the task will do it on the first run + // Else we need to just run the task once + interval := task.IntervalOnce + if gen.Interval > 0 { + interval = gen.Interval + } - if err := task.Manager.Add(ctx, task.NewTask( - getTaskUID(gen), - getTaskName(gen), - gen.Interval, - true, - func(ctx context.Context, _ []model.User) []task.TaskResult { - return []task.TaskResult{{ - User: *user, - Message: "", - Error: g.maintain(ctx, *user, gen.ID), - }} - }, - )); err != nil { - return err - } + if err := task.Manager.Add(ctx, task.NewTask( + getTaskUID(gen), + getTaskName(gen), + interval, + true, + func(ctx context.Context, _ []model.User) []task.TaskResult { + return []task.TaskResult{{ + User: *user, + Message: "", + Error: g.refresh(ctx, *user, gen.ID), + }} + }, + )); err != nil { + return err } return nil @@ -180,6 +167,7 @@ func (g *generator) Update(ctx context.Context, gen *model.Generator, createPlay // If the old generator had a playlist, delete it // If the new generator has a playlist, create it // This will sometimes delete and create a playlist after one another + // But it makes our lives simpler // Delete the old playlist if oldGen.PlaylistID != 0 { @@ -191,9 +179,11 @@ func (g *generator) Update(ctx context.Context, gen *model.Generator, createPlay return fmt.Errorf("playlist with id %d not found", oldGen.PlaylistID) } + // Delete it from our db if err := g.playlist.DeleteUserByUserPlaylist(ctx, model.PlaylistUser{UserID: user.ID, PlaylistID: playlist.ID}); err != nil { return err } + // Delete it from Spotify if err := spotifyapi.C.PlaylistDelete(ctx, *user, playlist.SpotifyID); err != nil { return err } @@ -202,15 +192,9 @@ func (g *generator) Update(ctx context.Context, gen *model.Generator, createPlay // Create the new playlist gen.PlaylistID = 0 if createPlaylist { - // Get tracks - tracks, err := g.Generate(ctx, gen) - if err != nil { - return err - } - // Create playlist in Spotify description := "Created by Sortifyr" - if gen.Maintained { + if gen.Interval > 0 { days := int(gen.Interval.Nanoseconds() / int64(24*time.Hour)) daysStr := strconv.Itoa(days) + "days" if days == 1 { @@ -233,14 +217,15 @@ func (g *generator) Update(ctx context.Context, gen *model.Generator, createPlay return err } - gen.PlaylistID = playlist.ID - - // Add tracks to the playlist - if err := spotifyapi.C.PlaylistPostTrackAll(ctx, *user, playlist.SpotifyID, tracks); err != nil { + // Create playlist in db and link to user + if err := g.playlist.Create(ctx, &playlist); err != nil { return err } - } else { - normalize(gen) + if err := g.playlist.CreateUser(ctx, &model.PlaylistUser{UserID: user.ID, PlaylistID: playlist.ID}); err != nil { + return err + } + + gen.PlaylistID = playlist.ID } // Update in database @@ -249,7 +234,7 @@ func (g *generator) Update(ctx context.Context, gen *model.Generator, createPlay } // Remove old task - if oldGen.Maintained { + if oldGen.Interval > 0 { if err := task.Manager.Remove(ctx, getTaskUID(oldGen)); err != nil { if !errors.Is(err, task.ErrTaskNotExists) { return err @@ -257,27 +242,28 @@ func (g *generator) Update(ctx context.Context, gen *model.Generator, createPlay } } - // Start task for maintaince - if gen.Maintained { - if gen.Interval.Nanoseconds() == 0 { - return fmt.Errorf("interval is equal to 0 %+v", *gen) - } + // Add tracks to the playlist + // If the generator is maintained then the task will do it on the first run + // Else we need to just run the task once + interval := task.IntervalOnce + if gen.Interval > 0 { + interval = gen.Interval + } - if err := task.Manager.Add(ctx, task.NewTask( - getTaskUID(gen), - getTaskName(gen), - gen.Interval, - true, - func(ctx context.Context, _ []model.User) []task.TaskResult { - return []task.TaskResult{{ - User: *user, - Message: "", - Error: g.maintain(ctx, *user, gen.ID), - }} - }, - )); err != nil { - return err - } + if err := task.Manager.Add(ctx, task.NewTask( + getTaskUID(gen), + getTaskName(gen), + interval, + true, + func(ctx context.Context, _ []model.User) []task.TaskResult { + return []task.TaskResult{{ + User: *user, + Message: "", + Error: g.refresh(ctx, *user, gen.ID), + }} + }, + )); err != nil { + return err } return nil diff --git a/internal/generator/task.go b/internal/generator/task.go index 0ef9309..cf09c87 100644 --- a/internal/generator/task.go +++ b/internal/generator/task.go @@ -3,7 +3,6 @@ package generator import ( "context" "errors" - "fmt" "slices" "strconv" @@ -11,6 +10,7 @@ import ( "github.com/topvennie/sortifyr/internal/spotifyapi" "github.com/topvennie/sortifyr/internal/task" "github.com/topvennie/sortifyr/pkg/config" + "github.com/topvennie/sortifyr/pkg/utils" ) const taskUID = "task-generator" @@ -35,7 +35,7 @@ func (g *generator) taskRegister(ctx context.Context) error { if err := task.Manager.Add(ctx, task.NewTask( getTaskUID(nil), getTaskName(nil), - config.GetDefaultDuration("task.generator_s", 60*60), + config.GetDefaultDurationS("task.generator_s", 60*60), false, func(ctx context.Context, users []model.User) []task.TaskResult { results := make([]task.TaskResult, 0, len(users)) @@ -44,7 +44,7 @@ func (g *generator) taskRegister(ctx context.Context) error { results = append(results, task.TaskResult{ User: user, Message: "", - Error: g.sync(ctx, user), + Error: g.spotifyStatus(ctx, user), }) } @@ -54,12 +54,16 @@ func (g *generator) taskRegister(ctx context.Context) error { return err } - gens, err := g.generator.GetMaintainedPopulated(ctx) + gens, err := g.generator.GetAll(ctx) if err != nil { return err } for _, gen := range gens { + if gen.Interval == 0 { + continue + } + if err := task.Manager.Add(ctx, task.NewTask( getTaskUID(gen), getTaskName(gen), @@ -69,7 +73,7 @@ func (g *generator) taskRegister(ctx context.Context) error { return []task.TaskResult{{ User: gen.User, Message: "", - Error: g.maintain(ctx, gen.User, gen.ID), + Error: g.refresh(ctx, gen.User, gen.ID), }} }, )); err != nil { @@ -80,15 +84,16 @@ func (g *generator) taskRegister(ctx context.Context) error { return nil } -// sync will update all the generator information -func (g *generator) sync(ctx context.Context, user model.User) error { +// spotifyStatus will check if the spotify playlist is up to date +// It does NOT update the tracks from the generator +func (g *generator) spotifyStatus(ctx context.Context, user model.User) error { gens, err := g.generator.GetByUser(ctx, user.ID) if err != nil { return err } for _, gen := range gens { - if err := g.syncOne(ctx, user, gen); err != nil { + if err := g.spotifyStatusOne(ctx, user, gen); err != nil { return err } } @@ -96,56 +101,49 @@ func (g *generator) sync(ctx context.Context, user model.User) error { return nil } -func (g *generator) syncOne(ctx context.Context, user model.User, gen *model.Generator) error { - gen.Outdated = false +func (g *generator) spotifyStatusOne(ctx context.Context, user model.User, gen *model.Generator) error { + if gen.PlaylistID == 0 { + // Generator doesn't have a spotify playlist + return nil + } + + gen.SpotifyOutdated = false - // If the generator has a playlist, does it still exist? var playlist *model.Playlist - if gen.PlaylistID != 0 { - playlists, err := g.playlist.GetByUser(ctx, user.ID) - if err != nil { - return err - } - idx := slices.IndexFunc(playlists, func(p *model.Playlist) bool { return p.ID == gen.PlaylistID }) - if idx == -1 { - // Playlist is gone - // The user probably deleted it manually - gen.PlaylistID = 0 - gen.Maintained = false - gen.Interval = 0 - gen.Outdated = false - } else { - playlist = playlists[idx] - } + // Get all the user's playlists + playlists, err := g.playlist.GetByUser(ctx, user.ID) + if err != nil { + return err + } + // Find the playlist + idx := slices.IndexFunc(playlists, func(p *model.Playlist) bool { return p.ID == gen.PlaylistID }) + if idx == -1 { + // Playlist is gone + // The user probably deleted it manually + gen.PlaylistID = 0 + gen.SpotifyOutdated = true + } else { + playlist = playlists[idx] } - // If the generator still has a playlist, is it up to date? + // Is the playlist still up to date? if playlist != nil { - // Get the current tracks - oldTracks, err := g.track.GetByPlaylist(ctx, playlist.ID) + // Get the playlist tracks + playlistTracks, err := g.track.GetByPlaylist(ctx, playlist.ID) if err != nil { return err } - // Get the new tracks - newTracks, err := g.Generate(ctx, gen) + // Get the generator tracks + genTracks, err := g.track.GetByGenerator(ctx, gen.ID) if err != nil { return err } - if len(oldTracks) != len(newTracks) { - gen.Outdated = true - } else { - slices.SortFunc(oldTracks, func(a, b *model.Track) int { return a.ID - b.ID }) - slices.SortFunc(newTracks, func(a, b model.Track) int { return a.ID - b.ID }) - - for i := range oldTracks { - if !oldTracks[i].Equal(newTracks[i]) { - gen.Outdated = true - break - } - } - } + slices.SortFunc(playlistTracks, func(a, b *model.Track) int { return a.ID - b.ID }) + slices.SortFunc(genTracks, func(a, b *model.Track) int { return a.ID - b.ID }) + + gen.SpotifyOutdated = !slices.EqualFunc(playlistTracks, genTracks, func(a, b *model.Track) bool { return a.Equal(*b) }) } if err := g.generator.Update(ctx, *gen); err != nil { @@ -155,64 +153,76 @@ func (g *generator) syncOne(ctx context.Context, user model.User, gen *model.Gen return nil } -// maintain updates a maintained playlist to contain the newest tracks -// TODO: Go over it -func (g *generator) maintain(ctx context.Context, user model.User, genID int) error { - // Refresh generator data +// refresh will refresh the generator tracks and update the Spotify playlist if applicable +func (g *generator) refresh(ctx context.Context, user model.User, genID int) error { gen, err := g.generator.Get(ctx, genID) if err != nil { return err } - // Double check all values - if !gen.Maintained { - return errors.New("generator is no longer maintained") - } - playlist, err := g.playlist.Get(ctx, gen.PlaylistID) + newTracks, err := G.Generate(ctx, gen) if err != nil { return err } - if playlist == nil { - return fmt.Errorf("no playlist with id %d", gen.PlaylistID) - } + slices.SortFunc(newTracks, func(a, b model.Track) int { return a.ID - b.ID }) - // Get the current tracks - oldTracks, err := g.track.GetByPlaylist(ctx, playlist.ID) + // Update the generator database tracks + dbTracks, err := g.track.GetByGenerator(ctx, gen.ID) if err != nil { return err } + slices.SortFunc(dbTracks, func(a, b *model.Track) int { return a.ID - b.ID }) - // Get the new tracks - newTracks, err := g.Generate(ctx, gen) - if err != nil { - return err + // Update the db if needed + equal := slices.EqualFunc(newTracks, dbTracks, func(a model.Track, b *model.Track) bool { return b.Equal(a) }) + if !equal { + if err := g.generator.DeleteTrackByGenerator(ctx, gen.ID); err != nil { + return err + } + if err := g.generator.CreateTrackBatch(ctx, utils.SliceMap(newTracks, func(t model.Track) model.GeneratorTrack { + return model.GeneratorTrack{GeneratorID: gen.ID, TrackID: t.ID} + })); err != nil { + return err + } } - // Find which ones to add and to remove - toAdd := make([]model.Track, 0) - toRemove := make([]model.Track, 0) + // Update the Spotify playlist + if gen.PlaylistID != 0 { + playlist, err := g.playlist.Get(ctx, gen.PlaylistID) + if err != nil { + return err + } + if playlist == nil { + return errors.New("db unsyned") + } - for i := range newTracks { - if idx := slices.IndexFunc(oldTracks, func(t *model.Track) bool { return newTracks[i].Equal(*t) }); idx == -1 { - toAdd = append(toAdd, newTracks[i]) + playlistTracks, err := g.track.GetByPlaylist(ctx, playlist.ID) + if err != nil { + return err } - } - for i := range oldTracks { - if idx := slices.IndexFunc(newTracks, func(t model.Track) bool { return oldTracks[i].Equal(t) }); idx == -1 { - toRemove = append(toRemove, *oldTracks[i]) + toCreate := []model.Track{} + toDelete := []model.Track{} + for i := range newTracks { + if idx := slices.IndexFunc(playlistTracks, func(t *model.Track) bool { return t.Equal(newTracks[i]) }); idx == -1 { + toCreate = append(toCreate, newTracks[i]) + } + } + for i := range playlistTracks { + if idx := slices.IndexFunc(newTracks, func(t model.Track) bool { return playlistTracks[i].Equal(t) }); idx == -1 { + toDelete = append(toDelete, *playlistTracks[i]) + } } - } - // Do the actions - if err := spotifyapi.C.PlaylistDeleteTrackAll(ctx, user, playlist.SpotifyID, playlist.SnapshotID, toRemove); err != nil { - return err - } - if err := spotifyapi.C.PlaylistPostTrackAll(ctx, user, playlist.SpotifyID, toAdd); err != nil { - return err + if err := spotifyapi.C.PlaylistDeleteTrackAll(ctx, user, playlist.SpotifyID, playlist.SnapshotID, toDelete); err != nil { + return err + } + if err := spotifyapi.C.PlaylistPostTrackAll(ctx, user, playlist.SpotifyID, toCreate); err != nil { + return err + } } - gen.Outdated = false + gen.SpotifyOutdated = false if err := g.generator.Update(ctx, *gen); err != nil { return err } diff --git a/internal/server/dto/generator.go b/internal/server/dto/generator.go index b13c191..2270800 100644 --- a/internal/server/dto/generator.go +++ b/internal/server/dto/generator.go @@ -158,28 +158,26 @@ func (g GeneratorParams) ToModel() model.GeneratorParams { } type Generator struct { - ID int `json:"id"` - Name string `json:"name" validate:"required"` - Description string `json:"description,omitzero"` - PlaylistID int `json:"playlist_id,omitzero"` - Maintained bool `json:"maintained"` - IntervalS int `json:"interval_s"` - Outdated bool `json:"outdated"` - Params GeneratorParams `json:"params" validate:"required"` - UpdatedAt time.Time `json:"updated_at"` + ID int `json:"id"` + Name string `json:"name" validate:"required"` + Description string `json:"description,omitzero"` + PlaylistID int `json:"playlist_id,omitzero"` + IntervalDays int `json:"interval_days"` + SpotifyOutdated bool `json:"spotify_outdated"` + Params GeneratorParams `json:"params" validate:"required"` + UpdatedAt time.Time `json:"updated_at"` } func GeneratorDTO(gen *model.Generator) Generator { return Generator{ - ID: gen.ID, - Name: gen.Name, - Description: gen.Description, - PlaylistID: gen.PlaylistID, - Maintained: gen.Maintained, - IntervalS: int(gen.Interval.Seconds()), - Outdated: gen.Outdated, - Params: generatorParamsDTO(gen.Params), - UpdatedAt: gen.UpdatedAt, + ID: gen.ID, + Name: gen.Name, + Description: gen.Description, + PlaylistID: gen.PlaylistID, + IntervalDays: int(gen.Interval.Hours() / 24), + SpotifyOutdated: gen.SpotifyOutdated, + Params: generatorParamsDTO(gen.Params), + UpdatedAt: gen.UpdatedAt, } } @@ -188,8 +186,7 @@ type GeneratorSave struct { Name string `json:"name" validate:"required"` Description string `json:"description"` CreatePlaylist bool `json:"create_playlist"` - Maintained bool `json:"maintained"` - IntervalS int `json:"interval_s"` + IntervalDays int `json:"interval_days" validate:"min=0"` Params GeneratorParams `json:"params" validate:"required"` } @@ -199,8 +196,7 @@ func (g GeneratorSave) ToModel(userID int) *model.Generator { UserID: userID, Name: g.Name, Description: g.Description, - Maintained: g.Maintained, - Interval: time.Duration(g.IntervalS) * time.Second, + Interval: time.Duration(g.IntervalDays) * 24 * time.Hour, Params: g.Params.ToModel(), } } diff --git a/internal/server/service/generator.go b/internal/server/service/generator.go index 88f96c4..3fee59a 100644 --- a/internal/server/service/generator.go +++ b/internal/server/service/generator.go @@ -2,7 +2,6 @@ package service import ( "context" - "time" "github.com/gofiber/fiber/v2" "github.com/topvennie/sortifyr/internal/database/model" @@ -61,16 +60,12 @@ func (g *Generator) Refresh(ctx context.Context, userID, genID int) error { gen, err := g.generator.Get(ctx, genID) if err != nil { + zap.S().Error(err) return fiber.ErrInternalServerError } if gen == nil { return fiber.ErrNotFound } - - if gen.PlaylistID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "generator does not have a playlist") - } - if gen.UserID != userID { return fiber.ErrForbidden } @@ -86,19 +81,6 @@ func (g *Generator) Refresh(ctx context.Context, userID, genID int) error { func (g *Generator) Create(ctx context.Context, userID int, genSave dto.GeneratorSave) (dto.Generator, error) { gen := genSave.ToModel(userID) - if !genSave.CreatePlaylist { - gen.Maintained = false - } - if !genSave.Maintained { - gen.Interval = 0 - } - if gen.Maintained && gen.Interval < 24*time.Hour { - return dto.Generator{}, fiber.NewError(fiber.StatusBadRequest, "interval needs to be > 0 if maintained") - } - - zap.S().Debug(genSave) - zap.S().Debug(*gen) - if err := generator.G.Create(ctx, gen, genSave.CreatePlaylist); err != nil { zap.S().Error(err) return dto.Generator{}, fiber.ErrInternalServerError @@ -124,13 +106,6 @@ func (g *Generator) Update(ctx context.Context, userID int, genSave dto.Generato gen := genSave.ToModel(userID) - if !genSave.CreatePlaylist { - gen.Maintained = false - } - if !genSave.Maintained { - gen.Interval = 0 - } - if err := generator.G.Update(ctx, gen, genSave.CreatePlaylist); err != nil { zap.S().Error(err) return dto.Generator{}, fiber.ErrInternalServerError @@ -157,7 +132,6 @@ func (g *Generator) Delete(ctx context.Context, userID, genID int, deletePlaylis if gen == nil { return fiber.ErrNotFound } - if gen.UserID != userID { return fiber.ErrForbidden } diff --git a/internal/spotifysync/task.go b/internal/spotifysync/task.go index aa978ec..b30f621 100644 --- a/internal/spotifysync/task.go +++ b/internal/spotifysync/task.go @@ -25,7 +25,7 @@ func (c *client) taskRegister(ctx context.Context) error { if err := task.Manager.Add(ctx, task.NewTask( taskPlaylistUID, "Playlist", - config.GetDefaultDuration("task.playlist_s", 60*60), + config.GetDefaultDurationS("task.playlist_s", 60*60), false, c.taskWrap(c.taskPlaylist), )); err != nil { @@ -35,7 +35,7 @@ func (c *client) taskRegister(ctx context.Context) error { if err := task.Manager.Add(ctx, task.NewTask( taskAlbumUID, "Album", - config.GetDefaultDuration("task.album_s", 60*60), + config.GetDefaultDurationS("task.album_s", 60*60), false, c.taskWrap(c.taskAlbum), )); err != nil { @@ -45,7 +45,7 @@ func (c *client) taskRegister(ctx context.Context) error { if err := task.Manager.Add(ctx, task.NewTask( taskShowUID, "Show", - config.GetDefaultDuration("task.show_s", 12*60*60), + config.GetDefaultDurationS("task.show_s", 12*60*60), false, c.taskWrap(c.taskShow), )); err != nil { @@ -55,7 +55,7 @@ func (c *client) taskRegister(ctx context.Context) error { if err := task.Manager.Add(ctx, task.NewTask( taskTrackUID, "Track", - config.GetDefaultDuration("task.track_s", 5*60), + config.GetDefaultDurationS("task.track_s", 5*60), false, c.taskWrap(c.taskTrack), )); err != nil { @@ -65,7 +65,7 @@ func (c *client) taskRegister(ctx context.Context) error { if err := task.Manager.Add(ctx, task.NewTask( taskArtistUID, "Artist", - config.GetDefaultDuration("task.artist_s", 5*60), + config.GetDefaultDurationS("task.artist_s", 5*60), false, c.taskWrap(c.taskArtist), )); err != nil { @@ -75,7 +75,7 @@ func (c *client) taskRegister(ctx context.Context) error { if err := task.Manager.Add(ctx, task.NewTask( taskUserUID, "User", - config.GetDefaultDuration("task.user_s", 6*60*60), + config.GetDefaultDurationS("task.user_s", 6*60*60), false, c.taskWrap(c.taskUser), )); err != nil { @@ -85,7 +85,7 @@ func (c *client) taskRegister(ctx context.Context) error { if err := task.Manager.Add(ctx, task.NewTask( taskHistoryUID, "Current", - config.GetDefaultDuration("task.history_s", 15), + config.GetDefaultDurationS("task.history_s", 15), false, c.taskWrap(c.taskHistory), )); err != nil { @@ -95,7 +95,7 @@ func (c *client) taskRegister(ctx context.Context) error { if err := task.Manager.Add(ctx, task.NewTask( taskLinkUID, "Link", - config.GetDefaultDuration("task.link_s", 12*60*60), + config.GetDefaultDurationS("task.link_s", 12*60*60), false, c.taskWrap(c.taskLink), )); err != nil { diff --git a/pkg/config/config.go b/pkg/config/config.go index cdf73d3..38d3fce 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -75,17 +75,17 @@ func GetDefaultBool(key string, defaultVal bool) bool { return GetBool(key) } -// GetDuration returns the value of the key as a duration -func GetDuration(key string) time.Duration { +// GetDurationS returns the value of the key as a duration +func GetDurationS(key string) time.Duration { bindEnv(key) return viper.GetDuration(key) * time.Second } -// GetDefaultDuration returns the value of the in time.Duration or a default value +// GetDefaultDurationS returns the value of the in time.Duration or a default value // The default value should be the amount of seconds and gets transformed to a time.Duration -func GetDefaultDuration(key string, defaultVal int) time.Duration { +func GetDefaultDurationS(key string, defaultVal int) time.Duration { viper.SetDefault(key, defaultVal) - return GetDuration(key) + return GetDurationS(key) } func IsDev() bool { diff --git a/pkg/sqlc/generator.sql.go b/pkg/sqlc/generator.sql.go index 4f5fe48..c731311 100644 --- a/pkg/sqlc/generator.sql.go +++ b/pkg/sqlc/generator.sql.go @@ -12,20 +12,19 @@ import ( ) const generatorCreate = `-- name: GeneratorCreate :one -INSERT INTO generators (user_id, name, description, playlist_id, maintained, interval, outdated, parameters, updated_at) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) +INSERT INTO generators (user_id, name, description, playlist_id, interval, spotify_outdated, parameters, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) RETURNING id ` type GeneratorCreateParams struct { - UserID int32 - Name string - Description pgtype.Text - PlaylistID pgtype.Int4 - Maintained bool - Interval pgtype.Int8 - Outdated bool - Parameters []byte + UserID int32 + Name string + Description pgtype.Text + PlaylistID pgtype.Int4 + Interval pgtype.Int8 + SpotifyOutdated bool + Parameters []byte } func (q *Queries) GeneratorCreate(ctx context.Context, arg GeneratorCreateParams) (int32, error) { @@ -34,9 +33,8 @@ func (q *Queries) GeneratorCreate(ctx context.Context, arg GeneratorCreateParams arg.Name, arg.Description, arg.PlaylistID, - arg.Maintained, arg.Interval, - arg.Outdated, + arg.SpotifyOutdated, arg.Parameters, ) var id int32 @@ -55,7 +53,7 @@ func (q *Queries) GeneratorDelete(ctx context.Context, id int32) error { } const generatorGet = `-- name: GeneratorGet :one -SELECT id, user_id, name, description, playlist_id, maintained, interval, outdated, parameters, updated_at +SELECT id, user_id, name, description, playlist_id, interval, spotify_outdated, parameters, updated_at FROM generators WHERE id = $1 ` @@ -69,23 +67,21 @@ func (q *Queries) GeneratorGet(ctx context.Context, id int32) (Generator, error) &i.Name, &i.Description, &i.PlaylistID, - &i.Maintained, &i.Interval, - &i.Outdated, + &i.SpotifyOutdated, &i.Parameters, &i.UpdatedAt, ) return i, err } -const generatorGetByUser = `-- name: GeneratorGetByUser :many -SELECT id, user_id, name, description, playlist_id, maintained, interval, outdated, parameters, updated_at +const generatorGetAll = `-- name: GeneratorGetAll :many +SELECT id, user_id, name, description, playlist_id, interval, spotify_outdated, parameters, updated_at FROM generators -WHERE user_id = $1 ` -func (q *Queries) GeneratorGetByUser(ctx context.Context, userID int32) ([]Generator, error) { - rows, err := q.db.Query(ctx, generatorGetByUser, userID) +func (q *Queries) GeneratorGetAll(ctx context.Context) ([]Generator, error) { + rows, err := q.db.Query(ctx, generatorGetAll) if err != nil { return nil, err } @@ -99,9 +95,8 @@ func (q *Queries) GeneratorGetByUser(ctx context.Context, userID int32) ([]Gener &i.Name, &i.Description, &i.PlaylistID, - &i.Maintained, &i.Interval, - &i.Outdated, + &i.SpotifyOutdated, &i.Parameters, &i.UpdatedAt, ); err != nil { @@ -115,43 +110,31 @@ func (q *Queries) GeneratorGetByUser(ctx context.Context, userID int32) ([]Gener return items, nil } -const generatorGetMaintainedPopulated = `-- name: GeneratorGetMaintainedPopulated :many -SELECT g.id, g.user_id, g.name, g.description, g.playlist_id, g.maintained, g.interval, g.outdated, g.parameters, g.updated_at, u.id, u.uid, u.name, u.display_name, u.email -FROM generators g -LEFT JOIN users u ON u.id = g.user_id -WHERE g.maintained = true +const generatorGetByUser = `-- name: GeneratorGetByUser :many +SELECT id, user_id, name, description, playlist_id, interval, spotify_outdated, parameters, updated_at +FROM generators +WHERE user_id = $1 ` -type GeneratorGetMaintainedPopulatedRow struct { - Generator Generator - User User -} - -func (q *Queries) GeneratorGetMaintainedPopulated(ctx context.Context) ([]GeneratorGetMaintainedPopulatedRow, error) { - rows, err := q.db.Query(ctx, generatorGetMaintainedPopulated) +func (q *Queries) GeneratorGetByUser(ctx context.Context, userID int32) ([]Generator, error) { + rows, err := q.db.Query(ctx, generatorGetByUser, userID) if err != nil { return nil, err } defer rows.Close() - var items []GeneratorGetMaintainedPopulatedRow + var items []Generator for rows.Next() { - var i GeneratorGetMaintainedPopulatedRow + var i Generator if err := rows.Scan( - &i.Generator.ID, - &i.Generator.UserID, - &i.Generator.Name, - &i.Generator.Description, - &i.Generator.PlaylistID, - &i.Generator.Maintained, - &i.Generator.Interval, - &i.Generator.Outdated, - &i.Generator.Parameters, - &i.Generator.UpdatedAt, - &i.User.ID, - &i.User.Uid, - &i.User.Name, - &i.User.DisplayName, - &i.User.Email, + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.PlaylistID, + &i.Interval, + &i.SpotifyOutdated, + &i.Parameters, + &i.UpdatedAt, ); err != nil { return nil, err } @@ -169,23 +152,21 @@ SET name = coalesce($2, name), description = coalesce($3, description), playlist_id = coalesce($4, playlist_id), - maintained = coalesce($5, maintained), - interval = coalesce($6, interval), - outdated = coalesce($7, outdated), - parameters = coalesce($8, parameters), + interval = coalesce($5, interval), + spotify_outdated = coalesce($6, spotify_outdated), + parameters = coalesce($7, parameters), updated_at = NOW() WHERE id = $1 ` type GeneratorUpdateParams struct { - ID int32 - Name pgtype.Text - Description pgtype.Text - PlaylistID pgtype.Int4 - Maintained pgtype.Bool - Interval pgtype.Int8 - Outdated pgtype.Bool - Parameters []byte + ID int32 + Name pgtype.Text + Description pgtype.Text + PlaylistID pgtype.Int4 + Interval pgtype.Int8 + SpotifyOutdated pgtype.Bool + Parameters []byte } func (q *Queries) GeneratorUpdate(ctx context.Context, arg GeneratorUpdateParams) error { @@ -194,9 +175,8 @@ func (q *Queries) GeneratorUpdate(ctx context.Context, arg GeneratorUpdateParams arg.Name, arg.Description, arg.PlaylistID, - arg.Maintained, arg.Interval, - arg.Outdated, + arg.SpotifyOutdated, arg.Parameters, ) return err diff --git a/pkg/sqlc/generator_track.sql.go b/pkg/sqlc/generator_track.sql.go new file mode 100644 index 0000000..cf4e9ad --- /dev/null +++ b/pkg/sqlc/generator_track.sql.go @@ -0,0 +1,38 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: generator_track.sql + +package sqlc + +import ( + "context" +) + +const generatorTrackCreateBatch = `-- name: GeneratorTrackCreateBatch :exec +INSERT INTO generator_tracks (generator_id, track_id) +VALUES ( + UNNEST($1::int[]), + UNNEST($2::int[]) +) +` + +type GeneratorTrackCreateBatchParams struct { + Column1 []int32 + Column2 []int32 +} + +func (q *Queries) GeneratorTrackCreateBatch(ctx context.Context, arg GeneratorTrackCreateBatchParams) error { + _, err := q.db.Exec(ctx, generatorTrackCreateBatch, arg.Column1, arg.Column2) + return err +} + +const generatorTrackDeleteByGenerator = `-- name: GeneratorTrackDeleteByGenerator :exec +DELETE FROM generator_tracks +WHERE generator_id = $1 +` + +func (q *Queries) GeneratorTrackDeleteByGenerator(ctx context.Context, generatorID int32) error { + _, err := q.db.Exec(ctx, generatorTrackDeleteByGenerator, generatorID) + return err +} diff --git a/pkg/sqlc/models.go b/pkg/sqlc/models.go index 63253d4..f3006ca 100644 --- a/pkg/sqlc/models.go +++ b/pkg/sqlc/models.go @@ -102,16 +102,21 @@ type DirectoryPlaylist struct { } type Generator struct { + ID int32 + UserID int32 + Name string + Description pgtype.Text + PlaylistID pgtype.Int4 + Interval pgtype.Int8 + SpotifyOutdated bool + Parameters []byte + UpdatedAt pgtype.Timestamptz +} + +type GeneratorTrack struct { ID int32 - UserID int32 - Name string - Description pgtype.Text - PlaylistID pgtype.Int4 - Maintained bool - Interval pgtype.Int8 - Outdated bool - Parameters []byte - UpdatedAt pgtype.Timestamptz + GeneratorID int32 + TrackID int32 } type History struct { diff --git a/pkg/sqlc/track.sql.go b/pkg/sqlc/track.sql.go index e1a0ee4..edf3854 100644 --- a/pkg/sqlc/track.sql.go +++ b/pkg/sqlc/track.sql.go @@ -134,6 +134,41 @@ func (q *Queries) TrackGetAllBySpotify(ctx context.Context, dollar_1 []string) ( return items, nil } +const trackGetByGenerator = `-- name: TrackGetByGenerator :many +SELECT t.id, t.spotify_id, t.name, t.popularity, t.updated_at, t.duration_ms +FROM tracks t +LEFT JOIN generator_tracks gt ON gt.track_id = t.id +WHERE gt.generator_id = $1 +ORDER BY t.name +` + +func (q *Queries) TrackGetByGenerator(ctx context.Context, generatorID int32) ([]Track, error) { + rows, err := q.db.Query(ctx, trackGetByGenerator, generatorID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Track + for rows.Next() { + var i Track + if err := rows.Scan( + &i.ID, + &i.SpotifyID, + &i.Name, + &i.Popularity, + &i.UpdatedAt, + &i.DurationMs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const trackGetByName = `-- name: TrackGetByName :many SELECT id, spotify_id, name, popularity, updated_at, duration_ms FROM tracks diff --git a/ui/src/components/generator/GeneratorForm.tsx b/ui/src/components/generator/GeneratorForm.tsx index 17afec9..a9fcb70 100644 --- a/ui/src/components/generator/GeneratorForm.tsx +++ b/ui/src/components/generator/GeneratorForm.tsx @@ -41,8 +41,7 @@ export const GeneratorForm = ({ generator: initialGenerator }: Props) => { name: "", description: "", createPlaylist: false, - maintained: false, - intervalS: 0, + intervalDays: 0, params: { trackAmount: 50, excludedPlaylistIds: [], diff --git a/ui/src/components/generator/GeneratorFormFinalize.tsx b/ui/src/components/generator/GeneratorFormFinalize.tsx index 019f4d1..ee820ba 100644 --- a/ui/src/components/generator/GeneratorFormFinalize.tsx +++ b/ui/src/components/generator/GeneratorFormFinalize.tsx @@ -3,7 +3,6 @@ import { Group, NumberInput, Stack, Switch, TextInput } from "@mantine/core"; import { UseFormReturnType } from "@mantine/form"; import { Button } from "../atoms/Button"; import { Section, SectionTitle } from "../atoms/Page"; -import { useState } from "react"; type Props = { form: UseFormReturnType @@ -12,40 +11,6 @@ type Props = { } export const GeneratorFormFinalize = ({ form, nextStep, prevStep }: Props) => { - const [createPlaylist, setCreatePlaylist] = useState(form.getValues().createPlaylist) - const [maintained, setMaintained] = useState(form.getValues().maintained) - const [interval, setInterval] = useState(form.getValues().intervalS / (24 * 60 * 60)) - - const handleTogglePlaylist = () => { - form.setFieldValue("createPlaylist", !createPlaylist) - setCreatePlaylist(prev => !prev) - - if (!createPlaylist) { - form.setFieldValue("maintained", false) - form.setFieldValue("intervalS", 0) - setMaintained(false) - } - } - - const handleToggleMaintained = () => { - form.setFieldValue("maintained", !maintained) - setMaintained(prev => !prev) - - if (!maintained) { - form.setFieldValue("intervalS", 0) - } - } - - const handleChangeInterval = (interval: number | string) => { - let newValue = 0 - if (typeof interval === 'number') { - newValue = interval * 24 * 60 * 60 - } - - form.setFieldValue("intervalS", newValue) - setInterval(interval) - } - return ( <>
@@ -58,21 +23,13 @@ export const GeneratorFormFinalize = ({ form, nextStep, prevStep }: Props) => { -

Create Spotify playlist

+

Create Spotify playlist

If set to off, the generator only shows a preview.

- -
- - - -

Auto-maintain playlist

-

Keep the playlist in sync with the new listening data.

-
- +
- +
diff --git a/ui/src/lib/types/api.d.ts b/ui/src/lib/types/api.d.ts index 63629cb..8f24c06 100644 --- a/ui/src/lib/types/api.d.ts +++ b/ui/src/lib/types/api.d.ts @@ -100,9 +100,8 @@ export namespace API { name: string; description?: string; playlist_id?: number; - maintained: boolean; - interval_s: number; - outdated: boolean; + interval_days: number; + spotify_outdated: boolean; params: { track_amount: number; excluded_playlist_ids?: number[]; diff --git a/ui/src/lib/types/generator.ts b/ui/src/lib/types/generator.ts index 2f2c7cb..e90a3e9 100644 --- a/ui/src/lib/types/generator.ts +++ b/ui/src/lib/types/generator.ts @@ -31,29 +31,48 @@ export const convertGeneratorWindow = (g: API.GeneratorWindow): GeneratorWindow } } +export interface GeneratorParams { + trackAmount: number; + excludedPlaylistIds: number[]; + excludedTrackIds: number[]; + preset: GeneratorPreset; + paramsCustom?: {}; + paramsForgotten?: {}; + paramsTop?: { + window: GeneratorWindow; + }; + paramsOldTop?: { + peakWindow: GeneratorWindow; + recentWindow: GeneratorWindow; + }; +} + +export const convertGeneratorParams = (g: Pick): GeneratorParams => { + return { + trackAmount: g.params.track_amount, + excludedPlaylistIds: g.params.excluded_playlist_ids ?? [], + excludedTrackIds: g.params.excluded_track_ids ?? [], + preset: g.params.preset as GeneratorPreset, + paramsCustom: g.params.params_custom, + paramsForgotten: g.params.params_forgotten, + paramsTop: g.params.params_top ? { + window: convertGeneratorWindow(g.params.params_top.window) + } : undefined, + paramsOldTop: g.params.params_old_top ? { + peakWindow: convertGeneratorWindow(g.params.params_old_top.peak_window), + recentWindow: convertGeneratorWindow(g.params.params_old_top.recent_window), + } : undefined, + } +} + export interface Generator { id: number; name: string; description?: string; playlistId?: number; - maintained: boolean; - intervalS: number; - outdated: boolean; - params: { - trackAmount: number; - excludedPlaylistIds: number[]; - excludedTrackIds: number[]; - preset: GeneratorPreset; - paramsCustom?: {}; - paramsForgotten?: {}; - paramsTop?: { - window: GeneratorWindow; - }; - paramsOldTop?: { - peakWindow: GeneratorWindow; - recentWindow: GeneratorWindow; - }; - }; + intervalDays: number; + spotifyOutdated: boolean; + params: GeneratorParams; lastUpdate?: Date; } @@ -63,24 +82,9 @@ export const convertGenerator = (g: API.Generator): Generator => { name: g.name, description: g.description, playlistId: g.playlist_id, - maintained: g.maintained, - intervalS: g.interval_s, - outdated: g.outdated, - params: { - trackAmount: g.params.track_amount, - excludedPlaylistIds: g.params.excluded_playlist_ids ?? [], - excludedTrackIds: g.params.excluded_track_ids ?? [], - preset: g.params.preset as GeneratorPreset, - paramsCustom: g.params.params_custom, - paramsForgotten: g.params.params_forgotten, - paramsTop: g.params.params_top ? { - window: convertGeneratorWindow(g.params.params_top.window) - } : undefined, - paramsOldTop: g.params.params_old_top ? { - peakWindow: convertGeneratorWindow(g.params.params_old_top.peak_window), - recentWindow: convertGeneratorWindow(g.params.params_old_top.recent_window), - } : undefined, - }, + intervalDays: g.interval_days, + spotifyOutdated: g.spotify_outdated, + params: convertGeneratorParams(g), lastUpdate: g.last_update ? new Date(g.last_update) : undefined, } } @@ -91,8 +95,12 @@ export const convertGenerators = (g: API.Generator[]): Generator[] => { export const convertGeneratorSchema = (g: Generator): GeneratorSchema => { return { - ...g, + id: g.id, + name: g.name, + description: g.description, createPlaylist: g.playlistId !== 0, + intervalDays: g.intervalDays, + params: { ...g.params }, } } @@ -109,8 +117,7 @@ export const generatorSchema = z.object({ name: z.string().nonempty(), description: z.string().optional(), createPlaylist: z.boolean(), - maintained: z.boolean(), - intervalS: z.number().nonnegative(), + intervalDays: z.number().nonnegative(), params: z.object({ trackAmount: z.number().positive(), excludedPlaylistIds: z.array(z.number().positive()), diff --git a/ui/src/pages/generator/GeneratorOverview.tsx b/ui/src/pages/generator/GeneratorOverview.tsx index abe4623..15a930d 100644 --- a/ui/src/pages/generator/GeneratorOverview.tsx +++ b/ui/src/pages/generator/GeneratorOverview.tsx @@ -93,21 +93,16 @@ export const GeneratorOverview = () => { render: ({ playlistId }) => playlistId && , }, { - accessor: "maintained", - title: "Maintained", + accessor: "interval_days", + title: "Refresh", textAlign: "right", - render: ({ playlistId, maintained, intervalS }) => { - if (!playlistId) return null - const days = Math.floor(intervalS / (60 * 60 * 24)) - - return {maintained ? `Every${days !== 1 ? ' ' + days : ''} day${days !== 1 ? 's' : ''}` : "One off"} - }, + render: ({ intervalDays }) => intervalDays > 0 &&

{`Every${intervalDays !== 1 ? ' ' + intervalDays : ''} day${intervalDays !== 1 ? 's' : ''}`}

}, { - accessor: "outdated", - title: "Status", + accessor: "spotifyOutdated", + title: "Spotify Status", textAlign: "right", - render: ({ maintained, outdated }) => maintained && {outdated ? "Outdated" : "Up to date"}, + render: ({ playlistId, spotifyOutdated }) => playlistId && {spotifyOutdated ? "Outdated" : "Up to date"}, }, { accessor: "actions", From ec36a7c8e2b354c4e0cb8cce267aa1cf04f19655 Mon Sep 17 00:00:00 2001 From: vincent Date: Mon, 5 Jan 2026 02:14:20 +0100 Subject: [PATCH 26/33] feat(generator): show tracks in overview table --- db/queries/generator.sql | 10 ++-- internal/database/repository/generator.go | 17 +++++-- internal/generator/task.go | 12 ++--- internal/server/dto/generator.go | 3 ++ internal/server/service/generator.go | 8 +++- pkg/sqlc/generator.sql.go | 47 ++++++++++++------- .../playlist/PlaylistDuplicates.tsx | 15 +++++- .../playlist/PlaylistUnplayable.tsx | 15 +++++- ui/src/components/task/TaskHistory.tsx | 18 +++---- ui/src/layout/NavLayout.tsx | 2 +- ui/src/lib/types/api.d.ts | 1 + ui/src/lib/types/generator.ts | 3 ++ ui/src/pages/generator/GeneratorOverview.tsx | 30 ++++++++++-- 13 files changed, 129 insertions(+), 52 deletions(-) diff --git a/db/queries/generator.sql b/db/queries/generator.sql index 87758cd..9ea4a33 100644 --- a/db/queries/generator.sql +++ b/db/queries/generator.sql @@ -7,10 +7,12 @@ WHERE id = $1; SELECT * FROM generators; --- name: GeneratorGetByUser :many -SELECT * -FROM generators -WHERE user_id = $1; +-- name: GeneratorGetByUserPopulated :many +SELECT sqlc.embed(g), sqlc.embed(t) +FROM generators g +LEFT JOIN generator_tracks gt ON gt.generator_id = g.id +LEFT JOIN tracks t ON t.id = gt.track_id +WHERE g.user_id = $1; -- name: GeneratorCreate :one INSERT INTO generators (user_id, name, description, playlist_id, interval, spotify_outdated, parameters, updated_at) diff --git a/internal/database/repository/generator.go b/internal/database/repository/generator.go index 6df9288..480bc4d 100644 --- a/internal/database/repository/generator.go +++ b/internal/database/repository/generator.go @@ -46,8 +46,8 @@ func (g *Generator) GetAll(ctx context.Context) ([]*model.Generator, error) { return utils.SliceMap(gens, model.GeneratorModel), nil } -func (g *Generator) GetByUser(ctx context.Context, userID int) ([]*model.Generator, error) { - gens, err := g.repo.queries(ctx).GeneratorGetByUser(ctx, int32(userID)) +func (g *Generator) GetByUserPopulated(ctx context.Context, userID int) ([]*model.Generator, error) { + gens, err := g.repo.queries(ctx).GeneratorGetByUserPopulated(ctx, int32(userID)) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -55,7 +55,18 @@ func (g *Generator) GetByUser(ctx context.Context, userID int) ([]*model.Generat return nil, fmt.Errorf("get generators by user %d | %w", userID, err) } - return utils.SliceMap(gens, model.GeneratorModel), nil + genMap := make(map[int32]*model.Generator) + for i := range gens { + g, ok := genMap[gens[i].Generator.ID] + if !ok { + g = model.GeneratorModel(gens[i].Generator) + } + + g.Tracks = append(g.Tracks, *model.TrackModel(gens[i].Track)) + genMap[gens[i].Generator.ID] = g + } + + return utils.MapValues(genMap), nil } func (g *Generator) Create(ctx context.Context, gen *model.Generator) error { diff --git a/internal/generator/task.go b/internal/generator/task.go index cf09c87..d6bd754 100644 --- a/internal/generator/task.go +++ b/internal/generator/task.go @@ -87,7 +87,7 @@ func (g *generator) taskRegister(ctx context.Context) error { // spotifyStatus will check if the spotify playlist is up to date // It does NOT update the tracks from the generator func (g *generator) spotifyStatus(ctx context.Context, user model.User) error { - gens, err := g.generator.GetByUser(ctx, user.ID) + gens, err := g.generator.GetByUserPopulated(ctx, user.ID) if err != nil { return err } @@ -134,16 +134,10 @@ func (g *generator) spotifyStatusOne(ctx context.Context, user model.User, gen * return err } - // Get the generator tracks - genTracks, err := g.track.GetByGenerator(ctx, gen.ID) - if err != nil { - return err - } - slices.SortFunc(playlistTracks, func(a, b *model.Track) int { return a.ID - b.ID }) - slices.SortFunc(genTracks, func(a, b *model.Track) int { return a.ID - b.ID }) + slices.SortFunc(gen.Tracks, func(a, b model.Track) int { return a.ID - b.ID }) - gen.SpotifyOutdated = !slices.EqualFunc(playlistTracks, genTracks, func(a, b *model.Track) bool { return a.Equal(*b) }) + gen.SpotifyOutdated = !slices.EqualFunc(playlistTracks, gen.Tracks, func(a *model.Track, b model.Track) bool { return a.Equal(b) }) } if err := g.generator.Update(ctx, *gen); err != nil { diff --git a/internal/server/dto/generator.go b/internal/server/dto/generator.go index 2270800..52dfad5 100644 --- a/internal/server/dto/generator.go +++ b/internal/server/dto/generator.go @@ -4,6 +4,7 @@ import ( "time" "github.com/topvennie/sortifyr/internal/database/model" + "github.com/topvennie/sortifyr/pkg/utils" ) type GeneratorWindow struct { @@ -165,6 +166,7 @@ type Generator struct { IntervalDays int `json:"interval_days"` SpotifyOutdated bool `json:"spotify_outdated"` Params GeneratorParams `json:"params" validate:"required"` + Tracks []Track `json:"tracks"` UpdatedAt time.Time `json:"updated_at"` } @@ -177,6 +179,7 @@ func GeneratorDTO(gen *model.Generator) Generator { IntervalDays: int(gen.Interval.Hours() / 24), SpotifyOutdated: gen.SpotifyOutdated, Params: generatorParamsDTO(gen.Params), + Tracks: utils.SliceMap(gen.Tracks, func(t model.Track) Track { return TrackDTO(&t) }), UpdatedAt: gen.UpdatedAt, } } diff --git a/internal/server/service/generator.go b/internal/server/service/generator.go index 3fee59a..4e97c57 100644 --- a/internal/server/service/generator.go +++ b/internal/server/service/generator.go @@ -2,6 +2,8 @@ package service import ( "context" + "slices" + "strings" "github.com/gofiber/fiber/v2" "github.com/topvennie/sortifyr/internal/database/model" @@ -25,12 +27,16 @@ func (s *Service) NewGenerator() *Generator { } func (g *Generator) GetByUser(ctx context.Context, userID int) ([]dto.Generator, error) { - gens, err := g.generator.GetByUser(ctx, userID) + gens, err := g.generator.GetByUserPopulated(ctx, userID) if err != nil { zap.S().Error(err) return nil, fiber.ErrInternalServerError } + for _, gen := range gens { + slices.SortFunc(gen.Tracks, func(a, b model.Track) int { return strings.Compare(a.Name, b.Name) }) + } + return utils.SliceMap(gens, dto.GeneratorDTO), nil } diff --git a/pkg/sqlc/generator.sql.go b/pkg/sqlc/generator.sql.go index c731311..e686973 100644 --- a/pkg/sqlc/generator.sql.go +++ b/pkg/sqlc/generator.sql.go @@ -110,31 +110,44 @@ func (q *Queries) GeneratorGetAll(ctx context.Context) ([]Generator, error) { return items, nil } -const generatorGetByUser = `-- name: GeneratorGetByUser :many -SELECT id, user_id, name, description, playlist_id, interval, spotify_outdated, parameters, updated_at -FROM generators -WHERE user_id = $1 +const generatorGetByUserPopulated = `-- name: GeneratorGetByUserPopulated :many +SELECT g.id, g.user_id, g.name, g.description, g.playlist_id, g.interval, g.spotify_outdated, g.parameters, g.updated_at, t.id, t.spotify_id, t.name, t.popularity, t.updated_at, t.duration_ms +FROM generators g +LEFT JOIN generator_tracks gt ON gt.generator_id = g.id +LEFT JOIN tracks t ON t.id = gt.track_id +WHERE g.user_id = $1 ` -func (q *Queries) GeneratorGetByUser(ctx context.Context, userID int32) ([]Generator, error) { - rows, err := q.db.Query(ctx, generatorGetByUser, userID) +type GeneratorGetByUserPopulatedRow struct { + Generator Generator + Track Track +} + +func (q *Queries) GeneratorGetByUserPopulated(ctx context.Context, userID int32) ([]GeneratorGetByUserPopulatedRow, error) { + rows, err := q.db.Query(ctx, generatorGetByUserPopulated, userID) if err != nil { return nil, err } defer rows.Close() - var items []Generator + var items []GeneratorGetByUserPopulatedRow for rows.Next() { - var i Generator + var i GeneratorGetByUserPopulatedRow if err := rows.Scan( - &i.ID, - &i.UserID, - &i.Name, - &i.Description, - &i.PlaylistID, - &i.Interval, - &i.SpotifyOutdated, - &i.Parameters, - &i.UpdatedAt, + &i.Generator.ID, + &i.Generator.UserID, + &i.Generator.Name, + &i.Generator.Description, + &i.Generator.PlaylistID, + &i.Generator.Interval, + &i.Generator.SpotifyOutdated, + &i.Generator.Parameters, + &i.Generator.UpdatedAt, + &i.Track.ID, + &i.Track.SpotifyID, + &i.Track.Name, + &i.Track.Popularity, + &i.Track.UpdatedAt, + &i.Track.DurationMs, ); err != nil { return nil, err } diff --git a/ui/src/components/playlist/PlaylistDuplicates.tsx b/ui/src/components/playlist/PlaylistDuplicates.tsx index 41ccdd4..9d8f0d5 100644 --- a/ui/src/components/playlist/PlaylistDuplicates.tsx +++ b/ui/src/components/playlist/PlaylistDuplicates.tsx @@ -14,6 +14,7 @@ import { getErrorMessage } from "@/lib/utils" import { Button } from "../atoms/Button" import { useTaskGetAll } from "@/lib/api/task" import { TaskStatus } from "@/lib/types/task" +import { LuTriangle } from "react-icons/lu" export const PlaylistDuplicates = () => { const { data: playlists, isLoading } = usePlaylistGetDuplicates() @@ -33,6 +34,8 @@ export const PlaylistDuplicates = () => { const [opened, { open, close }] = useDisclosure() + const [expandedRecordIds, setExpandedRecordIds] = useState([]); + const handleRemove = () => { removeDuplicates.mutateAsync(undefined, { onSuccess: () => notifications.show({ message: "Duplicates are getting removed. Go to the task page to see the result.", }), @@ -57,6 +60,12 @@ export const PlaylistDuplicates = () => {
+ }, { accessor: "id", title: "", @@ -92,7 +101,11 @@ export const PlaylistDuplicates = () => { height={180} className="m-4" /> - ) + ), + expanded: { + recordIds: expandedRecordIds, + onRecordIdsChange: setExpandedRecordIds, + } }} records={records} noRecordsText="No playlists with duplicate tracks" diff --git a/ui/src/components/playlist/PlaylistUnplayable.tsx b/ui/src/components/playlist/PlaylistUnplayable.tsx index 44533fd..11670f5 100644 --- a/ui/src/components/playlist/PlaylistUnplayable.tsx +++ b/ui/src/components/playlist/PlaylistUnplayable.tsx @@ -6,10 +6,13 @@ import { useState, useMemo } from "react" import { SectionTitle } from "../atoms/Page" import { Table } from "../molecules/Table" import { PlaylistCover } from "./PlaylistCover" +import { LuTriangle } from "react-icons/lu" export const PlaylistUnplayables = () => { const { data: playlists, isLoading } = usePlaylistGetUnplayables() + const [expandedRecordIds, setExpandedRecordIds] = useState([]); + const [sortStatus, setSortStatus] = useState>({ columnAccessor: "name", direction: "asc", @@ -27,6 +30,12 @@ export const PlaylistUnplayables = () => { />
+ }, { accessor: "id", title: "", @@ -55,7 +64,11 @@ export const PlaylistUnplayables = () => { height={180} className="m-4" /> - ) + ), + expanded: { + recordIds: expandedRecordIds, + onRecordIdsChange: setExpandedRecordIds, + } }} records={records} noRecordsText="No playlists with unplayable tracks" diff --git a/ui/src/components/task/TaskHistory.tsx b/ui/src/components/task/TaskHistory.tsx index 3a8f32e..9a44311 100644 --- a/ui/src/components/task/TaskHistory.tsx +++ b/ui/src/components/task/TaskHistory.tsx @@ -1,7 +1,6 @@ import { useTaskGetHistory } from "@/lib/api/task"; import { TaskHistoryFilter } from "@/lib/types/task"; import { formatDate } from "@/lib/utils"; -import { ActionIcon } from "@mantine/core"; import { useState } from "react"; import { LuTriangle } from "react-icons/lu"; import { Table } from "../molecules/Table"; @@ -24,7 +23,7 @@ const formatDuration = (nanos: number) => { export const TaskHistory = ({ filter }: Props) => { const { history, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useTaskGetHistory(filter) - const [expandedIds, setExpandedIds] = useState([]) + const [expandedRecordIds, setExpandedRecordIds] = useState([]); const handleBottom = () => { if (!hasNextPage) return @@ -45,15 +44,10 @@ export const TaskHistory = ({ filter }: Props) => { render: ({ result }) => }, { - accessor: "error", + accessor: "expanded", title: "", - width: 40, - textAlign: 'right', - render: ({ id, error }) => error && ( - - - - ) + width: 24, + render: ({ id, error }) => error && }, ]} rowExpansion={{ @@ -65,8 +59,8 @@ export const TaskHistory = ({ filter }: Props) => { ), expandable: ({ record: { error } }) => Boolean(error), expanded: { - recordIds: expandedIds, - onRecordIdsChange: setExpandedIds, + recordIds: expandedRecordIds, + onRecordIdsChange: setExpandedRecordIds, }, }} records={history} diff --git a/ui/src/layout/NavLayout.tsx b/ui/src/layout/NavLayout.tsx index d4cb047..81892d8 100644 --- a/ui/src/layout/NavLayout.tsx +++ b/ui/src/layout/NavLayout.tsx @@ -113,7 +113,7 @@ export const NavLayout = ({ className, children, ...props }: Props) => {

{user?.name}

setUserExpanded(prev => !prev)} variant="light" color="black" size="sm" className="ml-auto"> - + {userExpanded && ( diff --git a/ui/src/lib/types/api.d.ts b/ui/src/lib/types/api.d.ts index 8f24c06..bd1c759 100644 --- a/ui/src/lib/types/api.d.ts +++ b/ui/src/lib/types/api.d.ts @@ -117,6 +117,7 @@ export namespace API { recent_window: GeneratorWindow; }; }; + tracks: Track[]; last_update?: string; } } diff --git a/ui/src/lib/types/generator.ts b/ui/src/lib/types/generator.ts index e90a3e9..e562632 100644 --- a/ui/src/lib/types/generator.ts +++ b/ui/src/lib/types/generator.ts @@ -1,6 +1,7 @@ import z from "zod"; import { JSONBody } from "./general"; import { API } from "./api"; +import { convertTracks, Track } from "./track"; export enum GeneratorPreset { Custom = "custom", @@ -73,6 +74,7 @@ export interface Generator { intervalDays: number; spotifyOutdated: boolean; params: GeneratorParams; + tracks: Track[]; lastUpdate?: Date; } @@ -85,6 +87,7 @@ export const convertGenerator = (g: API.Generator): Generator => { intervalDays: g.interval_days, spotifyOutdated: g.spotify_outdated, params: convertGeneratorParams(g), + tracks: convertTracks(g.tracks), lastUpdate: g.last_update ? new Date(g.last_update) : undefined, } } diff --git a/ui/src/pages/generator/GeneratorOverview.tsx b/ui/src/pages/generator/GeneratorOverview.tsx index 15a930d..837a2e0 100644 --- a/ui/src/pages/generator/GeneratorOverview.tsx +++ b/ui/src/pages/generator/GeneratorOverview.tsx @@ -10,7 +10,7 @@ import { useDisclosure } from "@mantine/hooks" import { notifications } from "@mantine/notifications" import { useNavigate } from "@tanstack/react-router" import { useState } from "react" -import { LuCheck, LuListRestart, LuPencil, LuSparkles, LuTrash2 } from "react-icons/lu" +import { LuCheck, LuListRestart, LuPencil, LuSparkles, LuTrash2, LuTriangle } from "react-icons/lu" export const GeneratorOverview = () => { const { data: generators, isLoading } = useGeneratorGetAll() @@ -23,6 +23,8 @@ export const GeneratorOverview = () => { const navigate = useNavigate() + const [expandedRecordIds, setExpandedRecordIds] = useState([]); + const handleRefresh = (gen: Generator) => { generatorRefresh.mutateAsync(gen, { onSuccess: () => notifications.show({ message: "Updating generator" }), @@ -56,8 +58,6 @@ export const GeneratorOverview = () => { }) } - // TODO: Disable refresh when the task is running - return ( <> @@ -76,6 +76,12 @@ export const GeneratorOverview = () => {
+ }, { accessor: "name", title: "Name & Description", @@ -118,6 +124,24 @@ export const GeneratorOverview = () => { ) }, ]} + rowExpansion={{ + content: ({ record: { tracks } }) => ( +
+ ), + expanded: { + recordIds: expandedRecordIds, + onRecordIdsChange: setExpandedRecordIds, + } + }} records={generators ?? []} fetching={isLoading} noRecordsText="No generators" From d9af0a6e619dcb7457c67da0ab23b45c308b3581 Mon Sep 17 00:00:00 2001 From: vincent Date: Mon, 5 Jan 2026 13:43:47 +0100 Subject: [PATCH 27/33] chore(setting): run track task after import --- internal/server/service/setting.go | 4 ++++ internal/spotifysync/task.go | 32 +++++++++++++++--------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/internal/server/service/setting.go b/internal/server/service/setting.go index f1139c7..f311db3 100644 --- a/internal/server/service/setting.go +++ b/internal/server/service/setting.go @@ -149,6 +149,10 @@ func (s *Setting) exportTask(ctx context.Context, user model.User, zipFile []byt } } + if err := task.Manager.RunRecurringByUID(spotifysync.TaskTrackUID, user); err != nil { + return err + } + return nil } diff --git a/internal/spotifysync/task.go b/internal/spotifysync/task.go index b30f621..c5b62c2 100644 --- a/internal/spotifysync/task.go +++ b/internal/spotifysync/task.go @@ -11,19 +11,19 @@ import ( ) const ( - taskArtistUID = "task-artist" - taskAlbumUID = "task-album" - taskHistoryUID = "task-history" - taskLinkUID = "task-link" - taskPlaylistUID = "task-playlist" - taskShowUID = "task-show" - taskTrackUID = "task-track" - taskUserUID = "task-user" + TaskArtistUID = "task-artist" + TaskAlbumUID = "task-album" + TaskHistoryUID = "task-history" + TaskLinkUID = "task-link" + TaskPlaylistUID = "task-playlist" + TaskShowUID = "task-show" + TaskTrackUID = "task-track" + TaskUserUID = "task-user" ) func (c *client) taskRegister(ctx context.Context) error { if err := task.Manager.Add(ctx, task.NewTask( - taskPlaylistUID, + TaskPlaylistUID, "Playlist", config.GetDefaultDurationS("task.playlist_s", 60*60), false, @@ -33,7 +33,7 @@ func (c *client) taskRegister(ctx context.Context) error { } if err := task.Manager.Add(ctx, task.NewTask( - taskAlbumUID, + TaskAlbumUID, "Album", config.GetDefaultDurationS("task.album_s", 60*60), false, @@ -43,7 +43,7 @@ func (c *client) taskRegister(ctx context.Context) error { } if err := task.Manager.Add(ctx, task.NewTask( - taskShowUID, + TaskShowUID, "Show", config.GetDefaultDurationS("task.show_s", 12*60*60), false, @@ -53,7 +53,7 @@ func (c *client) taskRegister(ctx context.Context) error { } if err := task.Manager.Add(ctx, task.NewTask( - taskTrackUID, + TaskTrackUID, "Track", config.GetDefaultDurationS("task.track_s", 5*60), false, @@ -63,7 +63,7 @@ func (c *client) taskRegister(ctx context.Context) error { } if err := task.Manager.Add(ctx, task.NewTask( - taskArtistUID, + TaskArtistUID, "Artist", config.GetDefaultDurationS("task.artist_s", 5*60), false, @@ -73,7 +73,7 @@ func (c *client) taskRegister(ctx context.Context) error { } if err := task.Manager.Add(ctx, task.NewTask( - taskUserUID, + TaskUserUID, "User", config.GetDefaultDurationS("task.user_s", 6*60*60), false, @@ -83,7 +83,7 @@ func (c *client) taskRegister(ctx context.Context) error { } if err := task.Manager.Add(ctx, task.NewTask( - taskHistoryUID, + TaskHistoryUID, "Current", config.GetDefaultDurationS("task.history_s", 15), false, @@ -93,7 +93,7 @@ func (c *client) taskRegister(ctx context.Context) error { } if err := task.Manager.Add(ctx, task.NewTask( - taskLinkUID, + TaskLinkUID, "Link", config.GetDefaultDurationS("task.link_s", 12*60*60), false, From 2beb69293dd7fb1c1bb0662e41d3d73a1497a058 Mon Sep 17 00:00:00 2001 From: vincent Date: Mon, 5 Jan 2026 16:02:07 +0100 Subject: [PATCH 28/33] chore(generator): use sliders instead of inputs --- internal/server/api/generator.go | 2 +- internal/server/dto/generator.go | 18 +-- internal/server/service/generator.go | 2 - internal/server/service/task.go | 1 - ui/src/components/generator/GeneratorForm.tsx | 16 +-- .../generator/GeneratorFormFinalize.tsx | 30 ++++- .../generator/GeneratorFormPreset.tsx | 125 ++++++++++++------ .../components/molecules/DatePickerInput.tsx | 1 - ui/src/components/molecules/NumberInput.tsx | 17 +++ ui/src/components/molecules/TextInput.tsx | 12 ++ ui/src/components/track/TrackHistory.tsx | 5 +- ui/src/lib/types/api.d.ts | 2 +- ui/src/lib/types/generator.ts | 6 +- ui/src/pages/generator/GeneratorOverview.tsx | 34 +++-- 14 files changed, 194 insertions(+), 77 deletions(-) create mode 100644 ui/src/components/molecules/NumberInput.tsx create mode 100644 ui/src/components/molecules/TextInput.tsx diff --git a/internal/server/api/generator.go b/internal/server/api/generator.go index cb7a2d7..d9bda4e 100644 --- a/internal/server/api/generator.go +++ b/internal/server/api/generator.go @@ -28,7 +28,7 @@ func NewGenerator(router fiber.Router, service service.Service) *Generator { func (g *Generator) createRoutes() { g.router.Get("/", g.getAll) g.router.Post("/preview", g.preview) - g.router.Post("/refesh/:id", g.refresh) + g.router.Post("/refresh/:id", g.refresh) g.router.Put("/", g.create) g.router.Post("/:id", g.update) g.router.Delete("/:id", g.delete) diff --git a/internal/server/dto/generator.go b/internal/server/dto/generator.go index 52dfad5..308caee 100644 --- a/internal/server/dto/generator.go +++ b/internal/server/dto/generator.go @@ -8,18 +8,18 @@ import ( ) type GeneratorWindow struct { - Start time.Time `json:"start"` - End time.Time `json:"end"` - MinPlays int `json:"min_plays" validate:"min=0"` - BurstIntervalS time.Duration `json:"burst_interval_s"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + MinPlays int `json:"min_plays" validate:"min=0"` + BurstIntervalDays int `json:"burst_interval_days"` } func generatorWindowDTO(g model.GeneratorWindow) GeneratorWindow { return GeneratorWindow{ - Start: g.Start, - End: g.End, - MinPlays: g.MinPlays, - BurstIntervalS: g.BurstInterval / time.Second, + Start: g.Start, + End: g.End, + MinPlays: g.MinPlays, + BurstIntervalDays: int(g.BurstInterval.Hours() / 24), } } @@ -28,7 +28,7 @@ func (g GeneratorWindow) ToModel() *model.GeneratorWindow { Start: g.Start, End: g.End, MinPlays: g.MinPlays, - BurstInterval: g.BurstIntervalS * time.Second, + BurstInterval: time.Duration(g.BurstIntervalDays) * 24 * time.Hour, } } diff --git a/internal/server/service/generator.go b/internal/server/service/generator.go index 4e97c57..7edb892 100644 --- a/internal/server/service/generator.go +++ b/internal/server/service/generator.go @@ -92,8 +92,6 @@ func (g *Generator) Create(ctx context.Context, userID int, genSave dto.Generato return dto.Generator{}, fiber.ErrInternalServerError } - zap.S().Debug(*gen) - return dto.GeneratorDTO(gen), nil } diff --git a/internal/server/service/task.go b/internal/server/service/task.go index 5b9cd59..8e1772b 100644 --- a/internal/server/service/task.go +++ b/internal/server/service/task.go @@ -100,7 +100,6 @@ func (t *Task) Start(ctx context.Context, userID int, taskUID string) error { if taskModel == nil { return fiber.ErrNotFound } - zap.S().Debug(*taskModel) if !taskModel.Recurring || !taskModel.Active { return fiber.ErrBadRequest } diff --git a/ui/src/components/generator/GeneratorForm.tsx b/ui/src/components/generator/GeneratorForm.tsx index a9fcb70..1933bc0 100644 --- a/ui/src/components/generator/GeneratorForm.tsx +++ b/ui/src/components/generator/GeneratorForm.tsx @@ -41,7 +41,7 @@ export const GeneratorForm = ({ generator: initialGenerator }: Props) => { name: "", description: "", createPlaylist: false, - intervalDays: 0, + intervalDays: 1, params: { trackAmount: 50, excludedPlaylistIds: [], @@ -51,24 +51,24 @@ export const GeneratorForm = ({ generator: initialGenerator }: Props) => { paramsForgotten: {}, paramsTop: { window: { - start: daysAgo(14), // 14 days ago + start: daysAgo(14), end: new Date(), minPlays: 5, - burstIntervalS: 14 * 24 * 60 * 60 // 14 days + burstIntervalDays: 14 }, }, paramsOldTop: { peakWindow: { - start: daysAgo(365), // 365 days ago - end: daysAgo(100), // 100 days ago + start: daysAgo(365), + end: daysAgo(100), minPlays: 5, - burstIntervalS: 14 * 24 * 60 * 60 // 14 days + burstIntervalDays: 14 }, recentWindow: { - start: daysAgo(14), // 14 days ago + start: daysAgo(14), end: new Date(), minPlays: 2, - burstIntervalS: 14 * 24 * 60 * 60 // 14 days + burstIntervalDays: 14 } } } diff --git a/ui/src/components/generator/GeneratorFormFinalize.tsx b/ui/src/components/generator/GeneratorFormFinalize.tsx index ee820ba..c97a2b8 100644 --- a/ui/src/components/generator/GeneratorFormFinalize.tsx +++ b/ui/src/components/generator/GeneratorFormFinalize.tsx @@ -1,8 +1,10 @@ import { GeneratorSchema } from "@/lib/types/generator"; -import { Group, NumberInput, Stack, Switch, TextInput } from "@mantine/core"; +import { Group, Slider, Stack, Switch } from "@mantine/core"; import { UseFormReturnType } from "@mantine/form"; +import { useState } from "react"; import { Button } from "../atoms/Button"; import { Section, SectionTitle } from "../atoms/Page"; +import { TextInput } from "../molecules/TextInput"; type Props = { form: UseFormReturnType @@ -11,6 +13,13 @@ type Props = { } export const GeneratorFormFinalize = ({ form, nextStep, prevStep }: Props) => { + const [interval, setInterval] = useState(form.getValues().intervalDays) + + const handleIntervalChange = (value: number) => { + form.setFieldValue("intervalDays", value) + setInterval(value) + } + return ( <>
@@ -29,7 +38,24 @@ export const GeneratorFormFinalize = ({ form, nextStep, prevStep }: Props) => { - + + +

Update Interval (days)

+

Frequency to regenerate the tracks

+ + ({ value: i + 1 }))} + min={0} + max={30} + className="flex-1" + /> +

{interval.toString().padStart(2, "0")}

+
+
diff --git a/ui/src/components/generator/GeneratorFormPreset.tsx b/ui/src/components/generator/GeneratorFormPreset.tsx index 2ff9031..c2f5868 100644 --- a/ui/src/components/generator/GeneratorFormPreset.tsx +++ b/ui/src/components/generator/GeneratorFormPreset.tsx @@ -2,13 +2,14 @@ import { useGeneratorPreview } from "@/lib/api/generator" import { usePlaylistGetAll } from "@/lib/api/playlist" import { GeneratorPreset, generatorPresetString, GeneratorSchema, GeneratorWindowSchema } from "@/lib/types/generator" import { getValueByPath } from "@/lib/utils" -import { Alert, Group, NumberInput, Stack } from "@mantine/core" -import { DatePickerInput, DatesRangeValue } from "@mantine/dates" +import { Alert, Group, Slider, Stack } from "@mantine/core" +import { DatesRangeValue } from "@mantine/dates" import { UseFormReturnType } from "@mantine/form" import { notifications } from "@mantine/notifications" import { ReactNode, useEffect, useState } from "react" import { Button } from "../atoms/Button" import { Section, SectionTitle } from "../atoms/Page" +import { DatePickerInput } from "../molecules/DatePickerInput" import { Table } from "../molecules/Table" import { GeneratorPlaylistTree } from "./GeneratorPlaylistTree" @@ -22,10 +23,17 @@ export const GeneratorFormPreset = ({ form, nextStep, prevStep }: Props) => { const { mutate: generatorPreview, data: tracks, isPending } = useGeneratorPreview() useEffect(() => generatorPreview(form.getValues()), []) + const [maxTracks, setMaxTracks] = useState(form.getValues().params?.trackAmount ?? 50) + const handleClickPreset = (p: GeneratorPreset) => { form.setFieldValue("params.preset", p) } + const handleMaxTracksChange = (amount: number) => { + form.setFieldValue("params.maxTracks", amount) + setMaxTracks(amount) + } + const handleRefetchTracks = () => { if (form.validateField("params").hasError) { notifications.show({ color: "red", message: "Some parameters are invalid" }) @@ -72,9 +80,23 @@ export const GeneratorFormPreset = ({ form, nextStep, prevStep }: Props) => { {presetArguments}

General parameters

- - - + +

Maximum Tracks

+

The maximum amount of tracks

+ + ({ value: (i + 1) * 5 }))} + min={1} + max={200} + className="flex-1" + /> +

{maxTracks.toString().padStart(3, "0")}

+
+

Select playlists

@@ -163,10 +185,8 @@ const Top = ({ form }: { form: UseFormReturnType }) => { {`Top finds the tracks you're listening to the most right now. It looks at your listening history within the selected time range and includes tracks that were played at least the minimum number of times within the given interval.`} - -

Evaluation window

- -
+

Evaluation window

+ ) } @@ -187,14 +207,10 @@ It works in two steps: Example: If the historic range is 6 months, the minimum plays is 5, and the burst interval is 14 days, it will find tracks you played 5 or more times within any 14-day period during those 6 months. But only if you haven’t played them much recently.`} - -

Historic listening window

- -
- -

Recent listening window

- -
+

Historic listening window

+ +

Recent listening window

+ ) } @@ -203,8 +219,8 @@ const Window = ({ form, path }: { form: UseFormReturnType, path const window = getValueByPath(form.getValues(), path) const [range, setRange] = useState<[Date | null, Date | null]>([window?.start ?? null, window?.end ?? null]) - const [plays, setPlays] = useState(window?.minPlays ?? 0) - const [burst, setBurst] = useState((window?.burstIntervalS ?? 0) / (24 * 60 * 60)) + const [plays, setPlays] = useState(window?.minPlays ?? 0) + const [burst, setBurst] = useState(window?.burstIntervalDays ?? 0) const handleRangeChange = (r: DatesRangeValue) => { form.setFieldValue(`${path}.start`, r[0] ?? undefined) @@ -213,31 +229,62 @@ const Window = ({ form, path }: { form: UseFormReturnType, path setRange(r) } - const handlePlayChange = (plays: number | string) => { - let newValue: number | undefined = undefined - if (typeof plays === 'number') { - newValue = plays - } - - form.setFieldValue(`${path}.minPlays`, newValue) + const handlePlayChange = (plays: number) => { + form.setFieldValue(`${path}.minPlays`, plays) setPlays(plays) } - const handleIntervalChange = (days: number | string) => { - let newValue: number | undefined = undefined - if (typeof days === 'number') { - newValue = days * 24 * 60 * 60 - } - - form.setFieldValue(`${path}.burstIntervalS`, newValue) - setBurst(days) + const handleBurstChange = (burst: number) => { + form.setFieldValue(`${path}.burstIntervalDays`, burst) + setBurst(burst) } return ( - - - - - + + +

Plays

+

The minimum amount of plays to trigger the window

+ + ({ value: i + 1 }))} + min={1} + max={20} + className="flex-1" + /> +

{plays.toString().padStart(2, "0")}

+
+
+ +

Burst Interval (days)

+

Time window in which the minimum plays must occur

+ + ({ value: i + 1 }))} + min={1} + max={30} + className="flex-1" + /> +

{burst.toString().padStart(2, "0")}

+
+
+ +

Time Range

+

The total time range in which to look for the minimum amount of plays in the interval

+ +
+
) } diff --git a/ui/src/components/molecules/DatePickerInput.tsx b/ui/src/components/molecules/DatePickerInput.tsx index c167119..bc3bf0a 100644 --- a/ui/src/components/molecules/DatePickerInput.tsx +++ b/ui/src/components/molecules/DatePickerInput.tsx @@ -6,7 +6,6 @@ type Props = DatePickerInputProps; export const DatePickerInput = (props: Props) => { return ( } radius="lg" styles={{ diff --git a/ui/src/components/molecules/NumberInput.tsx b/ui/src/components/molecules/NumberInput.tsx new file mode 100644 index 0000000..ebd82c0 --- /dev/null +++ b/ui/src/components/molecules/NumberInput.tsx @@ -0,0 +1,17 @@ +import { NumberInput as MNumberInput, NumberInputProps } from "@mantine/core"; + +type Props = NumberInputProps + +export const NumberInput = (props: Props) => { + return ( + + ) +} diff --git a/ui/src/components/molecules/TextInput.tsx b/ui/src/components/molecules/TextInput.tsx new file mode 100644 index 0000000..ba5ec80 --- /dev/null +++ b/ui/src/components/molecules/TextInput.tsx @@ -0,0 +1,12 @@ +import { TextInput as MTextInput, TextInputProps } from "@mantine/core"; + +type Props = TextInputProps + +export const TextInput = (props: Props) => { + return ( + + ) +} diff --git a/ui/src/components/track/TrackHistory.tsx b/ui/src/components/track/TrackHistory.tsx index eab8089..7e39b22 100644 --- a/ui/src/components/track/TrackHistory.tsx +++ b/ui/src/components/track/TrackHistory.tsx @@ -1,13 +1,13 @@ import { useTrackGetHistory } from "@/lib/api/track"; import { TrackHistoryFilter } from "@/lib/types/track"; import { formatDate } from "@/lib/utils"; +import { Group } from "@mantine/core"; import { DatesRangeValue } from '@mantine/dates'; import { useState } from "react"; import { SectionTitle } from "../atoms/Page"; import { DatePickerInput } from "../molecules/DatePickerInput"; -import { Table } from "../molecules/Table"; -import { Group } from "@mantine/core"; import { Segment } from "../molecules/Segment"; +import { Table } from "../molecules/Table"; export const TrackHistory = () => { const [range, setRange] = useState<[Date | null, Date | null]>([null, null]) @@ -56,6 +56,7 @@ export const TrackHistory = () => { placeholder="Filter by date range" value={range} onChange={handleRangeChange} + clearable /> { @@ -28,7 +28,7 @@ export const convertGeneratorWindow = (g: API.GeneratorWindow): GeneratorWindow start: new Date(g.start), end: new Date(g.end), minPlays: g.min_plays, - burstIntervalS: g.burst_interval_s, + burstIntervalDays: g.burst_interval_days, } } @@ -111,7 +111,7 @@ export const generatorWindowSchema = z.object({ start: z.date(), end: z.date(), minPlays: z.number().positive(), - burstIntervalS: z.number().positive(), + burstIntervalDays: z.number().positive(), }) export type GeneratorWindowSchema = z.infer & JSONBody; diff --git a/ui/src/pages/generator/GeneratorOverview.tsx b/ui/src/pages/generator/GeneratorOverview.tsx index 837a2e0..faf8374 100644 --- a/ui/src/pages/generator/GeneratorOverview.tsx +++ b/ui/src/pages/generator/GeneratorOverview.tsx @@ -9,7 +9,7 @@ import { ActionIcon, Badge, Checkbox, Group, Stack } from "@mantine/core" import { useDisclosure } from "@mantine/hooks" import { notifications } from "@mantine/notifications" import { useNavigate } from "@tanstack/react-router" -import { useState } from "react" +import { MouseEvent, useEffect, useState } from "react" import { LuCheck, LuListRestart, LuPencil, LuSparkles, LuTrash2, LuTriangle } from "react-icons/lu" export const GeneratorOverview = () => { @@ -21,25 +21,43 @@ export const GeneratorOverview = () => { const [checkedPlaylist, setCheckedPlaylist] = useState(false) const [opened, { open, close }] = useDisclosure() + const [refreshing, setRefreshing] = useState(false) + useEffect(() => { + if (!refreshing) return; + + const timer = setTimeout(() => { + setRefreshing(false); + }, 5000); + + return () => clearTimeout(timer); + }, [refreshing]); + const navigate = useNavigate() const [expandedRecordIds, setExpandedRecordIds] = useState([]); - const handleRefresh = (gen: Generator) => { + const handleRefresh = (e: MouseEvent, gen: Generator) => { + e.stopPropagation() + setRefreshing(true) + generatorRefresh.mutateAsync(gen, { onSuccess: () => notifications.show({ message: "Updating generator" }), onError: async error => { const msg = await getErrorMessage(error) notifications.show({ color: "red", message: msg }) - } + }, }) } - const handleEdit = (gen: Generator) => { + const handleEdit = (e: MouseEvent, gen: Generator) => { + e.stopPropagation() + navigate({ to: "/generator/edit/$generatorId", params: { generatorId: gen.id.toString() } }) } - const handleDeleteInit = (gen: Generator) => { + const handleDeleteInit = (e: MouseEvent, gen: Generator) => { + e.stopPropagation() + setGeneratorToDelete(gen) setCheckedPlaylist(false) open() @@ -117,9 +135,9 @@ export const GeneratorOverview = () => { render: gen => (
- handleRefresh(gen)} variant="subtle" color="black" hidden={!gen.playlistId}> - handleEdit(gen)} variant="subtle" color="black"> - handleDeleteInit(gen)} variant="subtle" color="red"> + handleRefresh(e, gen)} variant="subtle" color="black" loading={refreshing}> + handleEdit(e, gen)} variant="subtle" color="black"> + handleDeleteInit(e, gen)} variant="subtle" color="red">
) }, From c7f601815942db78e15fb05fea35df3f570357c5 Mon Sep 17 00:00:00 2001 From: vincent Date: Mon, 5 Jan 2026 16:26:52 +0100 Subject: [PATCH 29/33] chore(generator): show warning if preview is outdated --- .../generator/GeneratorFormPreset.tsx | 43 +++++++++++++++++-- .../generator/GeneratorFormTrack.tsx | 43 +++++++++++++++++-- ui/src/lib/types/generator.ts | 33 +++++++------- 3 files changed, 96 insertions(+), 23 deletions(-) diff --git a/ui/src/components/generator/GeneratorFormPreset.tsx b/ui/src/components/generator/GeneratorFormPreset.tsx index c2f5868..6e469fc 100644 --- a/ui/src/components/generator/GeneratorFormPreset.tsx +++ b/ui/src/components/generator/GeneratorFormPreset.tsx @@ -1,14 +1,17 @@ import { useGeneratorPreview } from "@/lib/api/generator" import { usePlaylistGetAll } from "@/lib/api/playlist" -import { GeneratorPreset, generatorPresetString, GeneratorSchema, GeneratorWindowSchema } from "@/lib/types/generator" +import { GeneratorParamsSchema, GeneratorPreset, generatorPresetString, GeneratorSchema, GeneratorWindowSchema } from "@/lib/types/generator" import { getValueByPath } from "@/lib/utils" import { Alert, Group, Slider, Stack } from "@mantine/core" import { DatesRangeValue } from "@mantine/dates" import { UseFormReturnType } from "@mantine/form" +import { useDisclosure } from "@mantine/hooks" import { notifications } from "@mantine/notifications" +import { isEqual } from "lodash" import { ReactNode, useEffect, useState } from "react" import { Button } from "../atoms/Button" import { Section, SectionTitle } from "../atoms/Page" +import { Confirm } from "../molecules/Confirm" import { DatePickerInput } from "../molecules/DatePickerInput" import { Table } from "../molecules/Table" import { GeneratorPlaylistTree } from "./GeneratorPlaylistTree" @@ -20,8 +23,29 @@ type Props = { } export const GeneratorFormPreset = ({ form, nextStep, prevStep }: Props) => { + const [params, setParams] = useState | undefined>(form.getValues().params) + const { mutate: generatorPreview, data: tracks, isPending } = useGeneratorPreview() - useEffect(() => generatorPreview(form.getValues()), []) + useEffect(() => { + const values = form.getValues() + generatorPreview(values) + setParams(values.params) + }, []) + + const [opened, { open, close }] = useDisclosure() + const handleNextInit = () => { + if (!isEqual(params, form.getValues().params)) { + open() + return + } + + nextStep() + } + + const handleNext = () => { + close() + nextStep() + } const [maxTracks, setMaxTracks] = useState(form.getValues().params?.trackAmount ?? 50) @@ -41,7 +65,9 @@ export const GeneratorFormPreset = ({ form, nextStep, prevStep }: Props) => { return } - generatorPreview(form.getValues()) + const values = form.getValues() + generatorPreview(values) + setParams(values.params) } const getPresetArguments = (preset: GeneratorPreset) => { @@ -124,8 +150,17 @@ export const GeneratorFormPreset = ({ form, nextStep, prevStep }: Props) => { - + + + ) } diff --git a/ui/src/components/generator/GeneratorFormTrack.tsx b/ui/src/components/generator/GeneratorFormTrack.tsx index e68ae23..25282d9 100644 --- a/ui/src/components/generator/GeneratorFormTrack.tsx +++ b/ui/src/components/generator/GeneratorFormTrack.tsx @@ -1,6 +1,6 @@ import { useGeneratorPreview } from "@/lib/api/generator" import { useTrackGetAllById } from "@/lib/api/track" -import { GeneratorSchema } from "@/lib/types/generator" +import { GeneratorParamsSchema, GeneratorSchema } from "@/lib/types/generator" import { Track } from "@/lib/types/track" import { ActionIcon, Group } from "@mantine/core" import { UseFormReturnType } from "@mantine/form" @@ -9,6 +9,9 @@ import { Button } from "../atoms/Button" import { Section, SectionTitle } from "../atoms/Page" import { Table } from "../molecules/Table" import { LuTrash2, LuUndo2 } from "react-icons/lu" +import { useDisclosure } from "@mantine/hooks" +import { Confirm } from "../molecules/Confirm" +import { isEqual } from "lodash" type Props = { form: UseFormReturnType @@ -17,8 +20,29 @@ type Props = { } export const GeneratorFormTrack = ({ form, nextStep, prevStep }: Props) => { + const [params, setParams] = useState | undefined>(form.getValues().params) const { mutate: generatorPreview, data: tracksInitial, isPending } = useGeneratorPreview() - useEffect(() => generatorPreview(form.getValues()), []) + useEffect(() => { + const values = form.getValues() + generatorPreview(values) + setParams(values.params) + }, []) + + const [opened, { open, close }] = useDisclosure() + const handleNextInit = () => { + if (!isEqual(params, form.getValues().params)) { + open() + return + } + + nextStep() + } + + const handleNext = () => { + close() + nextStep() + } + const [tracks, setTracks] = useState(tracksInitial ?? []) useEffect(() => { if (!tracksInitial) return @@ -33,7 +57,9 @@ export const GeneratorFormTrack = ({ form, nextStep, prevStep }: Props) => { }, [excludedTracksInitial]) const handleRefetchTracks = () => { - generatorPreview(form.getValues()) + const values = form.getValues() + generatorPreview(values) + setParams(values.params) } const handleExcludeTrack = (track: Track) => { @@ -103,8 +129,17 @@ export const GeneratorFormTrack = ({ form, nextStep, prevStep }: Props) => { - + + + ) } diff --git a/ui/src/lib/types/generator.ts b/ui/src/lib/types/generator.ts index e140049..a559a57 100644 --- a/ui/src/lib/types/generator.ts +++ b/ui/src/lib/types/generator.ts @@ -115,26 +115,29 @@ export const generatorWindowSchema = z.object({ }) export type GeneratorWindowSchema = z.infer & JSONBody; +export const generatorParamsSchema = z.object({ + trackAmount: z.number().positive(), + excludedPlaylistIds: z.array(z.number().positive()), + excludedTrackIds: z.array(z.number().positive()), + preset: z.enum(GeneratorPreset), + paramsCustom: z.object({}).partial().optional(), + paramsForgotten: z.object({}).partial().optional(), + paramsTop: z.object({ + window: generatorWindowSchema + }).partial().optional(), + paramsOldTop: z.object({ + peakWindow: generatorWindowSchema, + recentWindow: generatorWindowSchema + }).partial().optional(), +}) +export type GeneratorParamsSchema = z.infer & JSONBody; + export const generatorSchema = z.object({ id: z.number().positive().optional(), name: z.string().nonempty(), description: z.string().optional(), createPlaylist: z.boolean(), intervalDays: z.number().nonnegative(), - params: z.object({ - trackAmount: z.number().positive(), - excludedPlaylistIds: z.array(z.number().positive()), - excludedTrackIds: z.array(z.number().positive()), - preset: z.enum(GeneratorPreset), - paramsCustom: z.object({}).partial().optional(), - paramsForgotten: z.object({}).partial().optional(), - paramsTop: z.object({ - window: generatorWindowSchema - }).partial().optional(), - paramsOldTop: z.object({ - peakWindow: generatorWindowSchema, - recentWindow: generatorWindowSchema - }).partial().optional(), - }).partial().optional(), + params: generatorParamsSchema.partial().optional(), }) export type GeneratorSchema = z.infer & JSONBody; From 8516d403a74e1f87cbd6d42432c8f6e66499479e Mon Sep 17 00:00:00 2001 From: vincent Date: Mon, 5 Jan 2026 23:26:53 +0100 Subject: [PATCH 30/33] fix(generator): various sync issues --- db/queries/generator.sql | 14 ++- internal/database/model/track.go | 14 ++- internal/database/repository/generator.go | 29 +++-- internal/generator/generator.go | 107 ++++++++++-------- internal/generator/normalize.go | 2 +- internal/generator/task.go | 22 +++- internal/server/api/generator.go | 2 +- pkg/sqlc/generator.sql.go | 55 +++++---- ui/src/components/generator/GeneratorForm.tsx | 2 +- .../generator/GeneratorFormFinalize.tsx | 8 +- .../generator/GeneratorFormPreset.tsx | 2 +- ui/src/lib/api/generator.ts | 5 +- ui/src/lib/types/generator.ts | 2 +- ui/src/pages/generator/GeneratorOverview.tsx | 5 +- 14 files changed, 164 insertions(+), 105 deletions(-) diff --git a/db/queries/generator.sql b/db/queries/generator.sql index 9ea4a33..dc5b075 100644 --- a/db/queries/generator.sql +++ b/db/queries/generator.sql @@ -4,15 +4,19 @@ FROM generators WHERE id = $1; -- name: GeneratorGetAll :many -SELECT * -FROM generators; +SELECT sqlc.embed(g), sqlc.embed(u) +FROM generators g +LEFT JOIN users u ON u.id = g.user_id; -- name: GeneratorGetByUserPopulated :many -SELECT sqlc.embed(g), sqlc.embed(t) +SELECT + sqlc.embed(g), + COALESCE(json_agg(t.*) FILTER (WHERE t.id IS NOT NULL), '[]')::jsonb AS tracks FROM generators g LEFT JOIN generator_tracks gt ON gt.generator_id = g.id LEFT JOIN tracks t ON t.id = gt.track_id -WHERE g.user_id = $1; +WHERE g.user_id = $1 +GROUP BY g.id; -- name: GeneratorCreate :one INSERT INTO generators (user_id, name, description, playlist_id, interval, spotify_outdated, parameters, updated_at) @@ -24,7 +28,7 @@ UPDATE generators SET name = coalesce(sqlc.narg('name'), name), description = coalesce(sqlc.narg('description'), description), - playlist_id = coalesce(sqlc.narg('playlist_id'), playlist_id), + playlist_id = sqlc.narg('playlist_id'), interval = coalesce(sqlc.narg('interval'), interval), spotify_outdated = coalesce(sqlc.narg('spotify_outdated'), spotify_outdated), parameters = coalesce(sqlc.narg('parameters'), parameters), diff --git a/internal/database/model/track.go b/internal/database/model/track.go index 4aea4b2..a533778 100644 --- a/internal/database/model/track.go +++ b/internal/database/model/track.go @@ -6,13 +6,15 @@ import ( "github.com/topvennie/sortifyr/pkg/sqlc" ) +// JSON tags required for the generator repository + type Track struct { - ID int - SpotifyID string - Name string - Popularity int - DurationMs int - UpdatedAt time.Time + ID int `json:"id"` + SpotifyID string `json:"spotify_id"` + Name string `json:"name"` + Popularity int `json:"popularity"` + DurationMs int `json:"duration_ms"` + UpdatedAt time.Time `json:"updated_at"` // Non db fields Artists []Artist diff --git a/internal/database/repository/generator.go b/internal/database/repository/generator.go index 480bc4d..8dddc2a 100644 --- a/internal/database/repository/generator.go +++ b/internal/database/repository/generator.go @@ -43,11 +43,16 @@ func (g *Generator) GetAll(ctx context.Context) ([]*model.Generator, error) { return nil, fmt.Errorf("get all generators %w", err) } - return utils.SliceMap(gens, model.GeneratorModel), nil + return utils.SliceMap(gens, func(g sqlc.GeneratorGetAllRow) *model.Generator { + gen := model.GeneratorModel(g.Generator) + gen.User = *model.UserModel(g.User) + + return gen + }), nil } func (g *Generator) GetByUserPopulated(ctx context.Context, userID int) ([]*model.Generator, error) { - gens, err := g.repo.queries(ctx).GeneratorGetByUserPopulated(ctx, int32(userID)) + gensDB, err := g.repo.queries(ctx).GeneratorGetByUserPopulated(ctx, int32(userID)) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -55,18 +60,22 @@ func (g *Generator) GetByUserPopulated(ctx context.Context, userID int) ([]*mode return nil, fmt.Errorf("get generators by user %d | %w", userID, err) } - genMap := make(map[int32]*model.Generator) - for i := range gens { - g, ok := genMap[gens[i].Generator.ID] - if !ok { - g = model.GeneratorModel(gens[i].Generator) + gens := make([]*model.Generator, 0, len(gensDB)) + for i := range gensDB { + gen := model.GeneratorModel(gensDB[i].Generator) + + var tracks []model.Track + if len(gensDB[i].Tracks) > 0 { + if err := json.Unmarshal(gensDB[i].Tracks, &tracks); err != nil { + return nil, fmt.Errorf("unmarshal generator tracks for get generator by user %d | %w", userID, err) + } } - g.Tracks = append(g.Tracks, *model.TrackModel(gens[i].Track)) - genMap[gens[i].Generator.ID] = g + gen.Tracks = tracks + gens = append(gens, gen) } - return utils.MapValues(genMap), nil + return gens, nil } func (g *Generator) Create(ctx context.Context, gen *model.Generator) error { diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 1197ba6..23c83ab 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -12,6 +12,7 @@ import ( "github.com/topvennie/sortifyr/internal/database/repository" "github.com/topvennie/sortifyr/internal/spotifyapi" "github.com/topvennie/sortifyr/internal/task" + "github.com/topvennie/sortifyr/pkg/utils" ) type generator struct { @@ -144,6 +145,7 @@ func (g *generator) Create(ctx context.Context, gen *model.Generator, createPlay return nil } +// nolint:gocognit // It's fine func (g *generator) Update(ctx context.Context, gen *model.Generator, createPlaylist bool) error { // Get user user, err := g.user.GetByID(ctx, gen.UserID) @@ -163,13 +165,6 @@ func (g *generator) Update(ctx context.Context, gen *model.Generator, createPlay return fmt.Errorf("gen with id %d not found", gen.ID) } - // We're going to be very lazy - // If the old generator had a playlist, delete it - // If the new generator has a playlist, create it - // This will sometimes delete and create a playlist after one another - // But it makes our lives simpler - - // Delete the old playlist if oldGen.PlaylistID != 0 { playlist, err := g.playlist.Get(ctx, oldGen.PlaylistID) if err != nil { @@ -179,53 +174,71 @@ func (g *generator) Update(ctx context.Context, gen *model.Generator, createPlay return fmt.Errorf("playlist with id %d not found", oldGen.PlaylistID) } - // Delete it from our db - if err := g.playlist.DeleteUserByUserPlaylist(ctx, model.PlaylistUser{UserID: user.ID, PlaylistID: playlist.ID}); err != nil { - return err - } - // Delete it from Spotify - if err := spotifyapi.C.PlaylistDelete(ctx, *user, playlist.SpotifyID); err != nil { - return err + if createPlaylist { + // Delete all tracks + tracks, err := g.track.GetByPlaylist(ctx, playlist.ID) + if err != nil { + return err + } + + if err := spotifyapi.C.PlaylistDeleteTrackAll(ctx, *user, playlist.SpotifyID, playlist.SnapshotID, utils.SliceDereference(tracks)); err != nil { + return err + } + } else { + // Delete playlist + // Delete it from our db + if err := g.playlist.DeleteUserByUserPlaylist(ctx, model.PlaylistUser{UserID: user.ID, PlaylistID: playlist.ID}); err != nil { + return err + } + // Delete it from Spotify + if err := spotifyapi.C.PlaylistDelete(ctx, *user, playlist.SpotifyID); err != nil { + return err + } } } // Create the new playlist gen.PlaylistID = 0 if createPlaylist { - // Create playlist in Spotify - description := "Created by Sortifyr" - if gen.Interval > 0 { - days := int(gen.Interval.Nanoseconds() / int64(24*time.Hour)) - daysStr := strconv.Itoa(days) + "days" - if days == 1 { - daysStr = "day" + if oldGen.PlaylistID == 0 { + // Create playlist in Spotify + description := "Created by Sortifyr" + if gen.Interval > 0 { + days := int(gen.Interval.Nanoseconds() / int64(24*time.Hour)) + daysStr := strconv.Itoa(days) + "days" + if days == 1 { + daysStr = "day" + } + description = fmt.Sprintf("Created and maintained (every %s) by Sortifyr", daysStr) + } + public := false + collaborative := false + + playlist := model.Playlist{ + OwnerID: gen.UserID, + Name: gen.Name, + Description: description, + Public: &public, + Collaborative: &collaborative, } - description = fmt.Sprintf("Created and maintained (every %s) by Sortifyr", daysStr) - } - public := false - collaborative := false - playlist := model.Playlist{ - OwnerID: gen.UserID, - Name: gen.Name, - Description: description, - Public: &public, - Collaborative: &collaborative, - } + if err := spotifyapi.C.PlaylistCreate(ctx, *user, &playlist); err != nil { + return err + } - if err := spotifyapi.C.PlaylistCreate(ctx, *user, &playlist); err != nil { - return err - } + // Create playlist in db and link to user + if err := g.playlist.Create(ctx, &playlist); err != nil { + return err + } + if err := g.playlist.CreateUser(ctx, &model.PlaylistUser{UserID: user.ID, PlaylistID: playlist.ID}); err != nil { + return err + } - // Create playlist in db and link to user - if err := g.playlist.Create(ctx, &playlist); err != nil { - return err - } - if err := g.playlist.CreateUser(ctx, &model.PlaylistUser{UserID: user.ID, PlaylistID: playlist.ID}); err != nil { - return err + gen.PlaylistID = playlist.ID + } else { + // Use the old playlist + gen.PlaylistID = oldGen.PlaylistID } - - gen.PlaylistID = playlist.ID } // Update in database @@ -270,10 +283,6 @@ func (g *generator) Update(ctx context.Context, gen *model.Generator, createPlay } func (g *generator) Delete(ctx context.Context, user model.User, gen model.Generator, deletePlaylist bool) error { - if err := g.generator.Delete(ctx, gen.ID); err != nil { - return err - } - if gen.PlaylistID != 0 && deletePlaylist { playlist, err := g.playlist.Get(ctx, gen.PlaylistID) if err != nil { @@ -292,5 +301,9 @@ func (g *generator) Delete(ctx context.Context, user model.User, gen model.Gener } } + if err := g.generator.Delete(ctx, gen.ID); err != nil { + return err + } + return nil } diff --git a/internal/generator/normalize.go b/internal/generator/normalize.go index b40b663..cf578ed 100644 --- a/internal/generator/normalize.go +++ b/internal/generator/normalize.go @@ -76,7 +76,7 @@ func normalizePresetOldTop(params *model.GeneratorParams) { BurstInterval: 24 * 14 * time.Hour, // 14 days }, RecentWindow: model.GeneratorWindow{ - Start: now.Add(-1 * 24 * 14 * time.Hour), // 14 days ago + Start: now.Add(-1 * 24 * 30 * time.Hour), // 30 days ago End: now, // now MinPlays: 2, BurstInterval: 24 * 14 * time.Hour, // 14 days diff --git a/internal/generator/task.go b/internal/generator/task.go index d6bd754..9fe3039 100644 --- a/internal/generator/task.go +++ b/internal/generator/task.go @@ -8,9 +8,11 @@ import ( "github.com/topvennie/sortifyr/internal/database/model" "github.com/topvennie/sortifyr/internal/spotifyapi" + "github.com/topvennie/sortifyr/internal/spotifysync" "github.com/topvennie/sortifyr/internal/task" "github.com/topvennie/sortifyr/pkg/config" "github.com/topvennie/sortifyr/pkg/utils" + "go.uber.org/zap" ) const taskUID = "task-generator" @@ -122,6 +124,7 @@ func (g *generator) spotifyStatusOne(ctx context.Context, user model.User, gen * // The user probably deleted it manually gen.PlaylistID = 0 gen.SpotifyOutdated = true + zap.S().Debug("No playlist") } else { playlist = playlists[idx] } @@ -138,6 +141,8 @@ func (g *generator) spotifyStatusOne(ctx context.Context, user model.User, gen * slices.SortFunc(gen.Tracks, func(a, b model.Track) int { return a.ID - b.ID }) gen.SpotifyOutdated = !slices.EqualFunc(playlistTracks, gen.Tracks, func(a *model.Track, b model.Track) bool { return a.Equal(b) }) + zap.S().Debug("slices euqla") + zap.S().Debug(!slices.EqualFunc(playlistTracks, gen.Tracks, func(a *model.Track, b model.Track) bool { return a.Equal(b) })) } if err := g.generator.Update(ctx, *gen); err != nil { @@ -168,8 +173,7 @@ func (g *generator) refresh(ctx context.Context, user model.User, genID int) err slices.SortFunc(dbTracks, func(a, b *model.Track) int { return a.ID - b.ID }) // Update the db if needed - equal := slices.EqualFunc(newTracks, dbTracks, func(a model.Track, b *model.Track) bool { return b.Equal(a) }) - if !equal { + if equal := slices.EqualFunc(newTracks, dbTracks, func(a model.Track, b *model.Track) bool { return b.Equal(a) }); !equal { if err := g.generator.DeleteTrackByGenerator(ctx, gen.ID); err != nil { return err } @@ -190,21 +194,23 @@ func (g *generator) refresh(ctx context.Context, user model.User, genID int) err return errors.New("db unsyned") } - playlistTracks, err := g.track.GetByPlaylist(ctx, playlist.ID) + // Get the latest playlist tracks + playlistTracksAPI, err := spotifyapi.C.PlaylistGetTrackAll(ctx, user, playlist.SpotifyID) if err != nil { return err } + playlistTracks := utils.SliceMap(playlistTracksAPI, func(t spotifyapi.Track) model.Track { return t.ToModel() }) toCreate := []model.Track{} toDelete := []model.Track{} for i := range newTracks { - if idx := slices.IndexFunc(playlistTracks, func(t *model.Track) bool { return t.Equal(newTracks[i]) }); idx == -1 { + if idx := slices.IndexFunc(playlistTracks, func(t model.Track) bool { return t.Equal(newTracks[i]) }); idx == -1 { toCreate = append(toCreate, newTracks[i]) } } for i := range playlistTracks { if idx := slices.IndexFunc(newTracks, func(t model.Track) bool { return playlistTracks[i].Equal(t) }); idx == -1 { - toDelete = append(toDelete, *playlistTracks[i]) + toDelete = append(toDelete, playlistTracks[i]) } } @@ -214,6 +220,12 @@ func (g *generator) refresh(ctx context.Context, user model.User, genID int) err if err := spotifyapi.C.PlaylistPostTrackAll(ctx, user, playlist.SpotifyID, toCreate); err != nil { return err } + + if len(toCreate) > 0 || len(toDelete) > 0 { + if err := task.Manager.RunRecurringByUID(spotifysync.TaskPlaylistUID, user); err != nil { + return err + } + } } gen.SpotifyOutdated = false diff --git a/internal/server/api/generator.go b/internal/server/api/generator.go index d9bda4e..bd04472 100644 --- a/internal/server/api/generator.go +++ b/internal/server/api/generator.go @@ -151,7 +151,7 @@ func (g *Generator) delete(c *fiber.Ctx) error { } deletePlaylist := false - if v := c.Query("deletePlaylist"); v != "" { + if v := c.Query("delete_playlist"); v != "" { if b, err := strconv.ParseBool(v); err == nil { deletePlaylist = b } diff --git a/pkg/sqlc/generator.sql.go b/pkg/sqlc/generator.sql.go index e686973..47efefd 100644 --- a/pkg/sqlc/generator.sql.go +++ b/pkg/sqlc/generator.sql.go @@ -76,29 +76,40 @@ func (q *Queries) GeneratorGet(ctx context.Context, id int32) (Generator, error) } const generatorGetAll = `-- name: GeneratorGetAll :many -SELECT id, user_id, name, description, playlist_id, interval, spotify_outdated, parameters, updated_at -FROM generators +SELECT g.id, g.user_id, g.name, g.description, g.playlist_id, g.interval, g.spotify_outdated, g.parameters, g.updated_at, u.id, u.uid, u.name, u.display_name, u.email +FROM generators g +LEFT JOIN users u ON u.id = g.user_id ` -func (q *Queries) GeneratorGetAll(ctx context.Context) ([]Generator, error) { +type GeneratorGetAllRow struct { + Generator Generator + User User +} + +func (q *Queries) GeneratorGetAll(ctx context.Context) ([]GeneratorGetAllRow, error) { rows, err := q.db.Query(ctx, generatorGetAll) if err != nil { return nil, err } defer rows.Close() - var items []Generator + var items []GeneratorGetAllRow for rows.Next() { - var i Generator + var i GeneratorGetAllRow if err := rows.Scan( - &i.ID, - &i.UserID, - &i.Name, - &i.Description, - &i.PlaylistID, - &i.Interval, - &i.SpotifyOutdated, - &i.Parameters, - &i.UpdatedAt, + &i.Generator.ID, + &i.Generator.UserID, + &i.Generator.Name, + &i.Generator.Description, + &i.Generator.PlaylistID, + &i.Generator.Interval, + &i.Generator.SpotifyOutdated, + &i.Generator.Parameters, + &i.Generator.UpdatedAt, + &i.User.ID, + &i.User.Uid, + &i.User.Name, + &i.User.DisplayName, + &i.User.Email, ); err != nil { return nil, err } @@ -111,16 +122,19 @@ func (q *Queries) GeneratorGetAll(ctx context.Context) ([]Generator, error) { } const generatorGetByUserPopulated = `-- name: GeneratorGetByUserPopulated :many -SELECT g.id, g.user_id, g.name, g.description, g.playlist_id, g.interval, g.spotify_outdated, g.parameters, g.updated_at, t.id, t.spotify_id, t.name, t.popularity, t.updated_at, t.duration_ms +SELECT + g.id, g.user_id, g.name, g.description, g.playlist_id, g.interval, g.spotify_outdated, g.parameters, g.updated_at, + COALESCE(json_agg(t.*) FILTER (WHERE t.id IS NOT NULL), '[]')::jsonb AS tracks FROM generators g LEFT JOIN generator_tracks gt ON gt.generator_id = g.id LEFT JOIN tracks t ON t.id = gt.track_id WHERE g.user_id = $1 +GROUP BY g.id ` type GeneratorGetByUserPopulatedRow struct { Generator Generator - Track Track + Tracks []byte } func (q *Queries) GeneratorGetByUserPopulated(ctx context.Context, userID int32) ([]GeneratorGetByUserPopulatedRow, error) { @@ -142,12 +156,7 @@ func (q *Queries) GeneratorGetByUserPopulated(ctx context.Context, userID int32) &i.Generator.SpotifyOutdated, &i.Generator.Parameters, &i.Generator.UpdatedAt, - &i.Track.ID, - &i.Track.SpotifyID, - &i.Track.Name, - &i.Track.Popularity, - &i.Track.UpdatedAt, - &i.Track.DurationMs, + &i.Tracks, ); err != nil { return nil, err } @@ -164,7 +173,7 @@ UPDATE generators SET name = coalesce($2, name), description = coalesce($3, description), - playlist_id = coalesce($4, playlist_id), + playlist_id = $4, interval = coalesce($5, interval), spotify_outdated = coalesce($6, spotify_outdated), parameters = coalesce($7, parameters), diff --git a/ui/src/components/generator/GeneratorForm.tsx b/ui/src/components/generator/GeneratorForm.tsx index 1933bc0..049b92e 100644 --- a/ui/src/components/generator/GeneratorForm.tsx +++ b/ui/src/components/generator/GeneratorForm.tsx @@ -65,7 +65,7 @@ export const GeneratorForm = ({ generator: initialGenerator }: Props) => { burstIntervalDays: 14 }, recentWindow: { - start: daysAgo(14), + start: daysAgo(30), end: new Date(), minPlays: 2, burstIntervalDays: 14 diff --git a/ui/src/components/generator/GeneratorFormFinalize.tsx b/ui/src/components/generator/GeneratorFormFinalize.tsx index c97a2b8..a10fb4c 100644 --- a/ui/src/components/generator/GeneratorFormFinalize.tsx +++ b/ui/src/components/generator/GeneratorFormFinalize.tsx @@ -13,8 +13,14 @@ type Props = { } export const GeneratorFormFinalize = ({ form, nextStep, prevStep }: Props) => { + const [createPlaylist, useCreatePlaylist] = useState(form.getValues().createPlaylist) const [interval, setInterval] = useState(form.getValues().intervalDays) + const handleCreatePlaylistChange = (checked: boolean) => { + form.setFieldValue("createPlaylist", checked) + useCreatePlaylist(checked) + } + const handleIntervalChange = (value: number) => { form.setFieldValue("intervalDays", value) setInterval(value) @@ -35,7 +41,7 @@ export const GeneratorFormFinalize = ({ form, nextStep, prevStep }: Props) => {

Create Spotify playlist

If set to off, the generator only shows a preview.

- + handleCreatePlaylistChange(e.target.checked)} color="secondary.1" /> diff --git a/ui/src/components/generator/GeneratorFormPreset.tsx b/ui/src/components/generator/GeneratorFormPreset.tsx index 6e469fc..8aed53e 100644 --- a/ui/src/components/generator/GeneratorFormPreset.tsx +++ b/ui/src/components/generator/GeneratorFormPreset.tsx @@ -54,7 +54,7 @@ export const GeneratorFormPreset = ({ form, nextStep, prevStep }: Props) => { } const handleMaxTracksChange = (amount: number) => { - form.setFieldValue("params.maxTracks", amount) + form.setFieldValue("params.trackAmount", amount) setMaxTracks(amount) } diff --git a/ui/src/lib/api/generator.ts b/ui/src/lib/api/generator.ts index 6d3793e..72a274e 100644 --- a/ui/src/lib/api/generator.ts +++ b/ui/src/lib/api/generator.ts @@ -27,7 +27,10 @@ export const useGeneratorRefresh = () => { return useMutation({ mutationFn: (generator: Pick) => apiPost(`${ENDPOINT}/refresh/${generator.id}`), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["task"] }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["task"] }) + queryClient.invalidateQueries({ queryKey: ["generator"] }) + }, }) } diff --git a/ui/src/lib/types/generator.ts b/ui/src/lib/types/generator.ts index a559a57..694b10c 100644 --- a/ui/src/lib/types/generator.ts +++ b/ui/src/lib/types/generator.ts @@ -101,7 +101,7 @@ export const convertGeneratorSchema = (g: Generator): GeneratorSchema => { id: g.id, name: g.name, description: g.description, - createPlaylist: g.playlistId !== 0, + createPlaylist: (g.playlistId ?? 0) !== 0, intervalDays: g.intervalDays, params: { ...g.params }, } diff --git a/ui/src/pages/generator/GeneratorOverview.tsx b/ui/src/pages/generator/GeneratorOverview.tsx index faf8374..404ca30 100644 --- a/ui/src/pages/generator/GeneratorOverview.tsx +++ b/ui/src/pages/generator/GeneratorOverview.tsx @@ -41,7 +41,7 @@ export const GeneratorOverview = () => { setRefreshing(true) generatorRefresh.mutateAsync(gen, { - onSuccess: () => notifications.show({ message: "Updating generator" }), + onSuccess: () => notifications.show({ title: "Updating generator", message: "A refresh is required to see the status update" }), onError: async error => { const msg = await getErrorMessage(error) notifications.show({ color: "red", message: msg }) @@ -126,7 +126,7 @@ export const GeneratorOverview = () => { accessor: "spotifyOutdated", title: "Spotify Status", textAlign: "right", - render: ({ playlistId, spotifyOutdated }) => playlistId && {spotifyOutdated ? "Outdated" : "Up to date"}, + render: ({ playlistId, spotifyOutdated }) => playlistId && {spotifyOutdated ? "Not Synced" : "Synced"}, }, { accessor: "actions", @@ -151,6 +151,7 @@ export const GeneratorOverview = () => { { accessor: "name" }, ]} records={tracks} + noRecordsText="No tracks" height={180} className="m-4" /> From 9988e26a9c68d694044bd9da205a001c29a0fe61 Mon Sep 17 00:00:00 2001 From: vincent Date: Tue, 6 Jan 2026 12:30:44 +0100 Subject: [PATCH 31/33] chore(generator): remove forgotten preset --- internal/database/model/generator.go | 16 +++++------ internal/generator/generate.go | 6 ----- internal/generator/normalize.go | 11 -------- internal/server/dto/generator.go | 27 +++---------------- ui/src/components/generator/GeneratorForm.tsx | 1 - .../generator/GeneratorFormPreset.tsx | 6 ----- ui/src/lib/types/api.d.ts | 1 - ui/src/lib/types/generator.ts | 5 ---- 8 files changed, 9 insertions(+), 64 deletions(-) diff --git a/internal/database/model/generator.go b/internal/database/model/generator.go index 6be2629..fc04d77 100644 --- a/internal/database/model/generator.go +++ b/internal/database/model/generator.go @@ -10,10 +10,9 @@ import ( type GeneratorPreset string const ( - GeneratorPresetCustom GeneratorPreset = "custom" - GeneratorPresetForgotten GeneratorPreset = "forgotten" - GeneratorPresetTop GeneratorPreset = "top" - GeneratorPresetOldTop GeneratorPreset = "old_top" + GeneratorPresetCustom GeneratorPreset = "custom" + GeneratorPresetTop GeneratorPreset = "top" + GeneratorPresetOldTop GeneratorPreset = "old_top" ) // We need json tags because the params are saved as jsonb @@ -27,8 +26,6 @@ type GeneratorWindow struct { type GeneratorPresetCustomParams struct{} -type GeneratorPresetForgottenParams struct{} - type GeneratorPresetTopParams struct { Window GeneratorWindow `json:"window"` } @@ -45,10 +42,9 @@ type GeneratorParams struct { Preset GeneratorPreset `json:"preset"` - ParamsCustom *GeneratorPresetCustomParams `json:"params_custom,omitzero"` - ParamsForgotten *GeneratorPresetForgottenParams `json:"params_forgotten,omitzero"` - ParamsTop *GeneratorPresetTopParams `json:"params_top,omitzero"` - ParamsOldTop *GeneratorPresetOldTopParams `json:"params_old_top,omitzero"` + ParamsCustom *GeneratorPresetCustomParams `json:"params_custom,omitzero"` + ParamsTop *GeneratorPresetTopParams `json:"params_top,omitzero"` + ParamsOldTop *GeneratorPresetOldTopParams `json:"params_old_top,omitzero"` } type Generator struct { diff --git a/internal/generator/generate.go b/internal/generator/generate.go index bb1dc07..868faac 100644 --- a/internal/generator/generate.go +++ b/internal/generator/generate.go @@ -17,8 +17,6 @@ func (g *generator) Generate(ctx context.Context, gen *model.Generator) ([]model normalize(gen) switch gen.Params.Preset { - case model.GeneratorPresetForgotten: - tracks, err = g.forgotten(*gen) case model.GeneratorPresetTop: tracks, err = g.top(ctx, *gen) case model.GeneratorPresetOldTop: @@ -44,10 +42,6 @@ func (g *generator) custom(gen model.Generator) ([]model.Track, error) { return nil, nil } -func (g *generator) forgotten(gen model.Generator) ([]model.Track, error) { - return nil, nil -} - func (g *generator) top(ctx context.Context, gen model.Generator) ([]model.Track, error) { params := gen.Params.ParamsTop diff --git a/internal/generator/normalize.go b/internal/generator/normalize.go index cf578ed..37c98d4 100644 --- a/internal/generator/normalize.go +++ b/internal/generator/normalize.go @@ -16,23 +16,15 @@ func normalize(gen *model.Generator) { switch params.Preset { case model.GeneratorPresetCustom: normalizePresetCustom(¶ms) - params.ParamsForgotten = nil - params.ParamsTop = nil - params.ParamsOldTop = nil - case model.GeneratorPresetForgotten: - normalizePresetForgotten(¶ms) - params.ParamsCustom = nil params.ParamsTop = nil params.ParamsOldTop = nil case model.GeneratorPresetTop: normalizePresetTop(¶ms) params.ParamsCustom = nil - params.ParamsForgotten = nil params.ParamsOldTop = nil case model.GeneratorPresetOldTop: normalizePresetOldTop(¶ms) params.ParamsCustom = nil - params.ParamsForgotten = nil params.ParamsTop = nil } @@ -42,9 +34,6 @@ func normalize(gen *model.Generator) { func normalizePresetCustom(params *model.GeneratorParams) { } -func normalizePresetForgotten(params *model.GeneratorParams) { -} - func normalizePresetTop(params *model.GeneratorParams) { now := time.Now() diff --git a/internal/server/dto/generator.go b/internal/server/dto/generator.go index 308caee..c2bca36 100644 --- a/internal/server/dto/generator.go +++ b/internal/server/dto/generator.go @@ -46,20 +46,6 @@ func (g GeneratorPresetCustomParams) ToModel() *model.GeneratorPresetCustomParam return &model.GeneratorPresetCustomParams{} } -type GeneratorPresetForgottenParams struct{} - -func generatorPresetForgottenParamsDTO(params *model.GeneratorPresetForgottenParams) *GeneratorPresetForgottenParams { - if params == nil { - return nil - } - - return &GeneratorPresetForgottenParams{} -} - -func (g GeneratorPresetForgottenParams) ToModel() *model.GeneratorPresetForgottenParams { - return &model.GeneratorPresetForgottenParams{} -} - type GeneratorPresetTopParams struct { Window GeneratorWindow `json:"window"` } @@ -110,10 +96,9 @@ type GeneratorParams struct { Preset model.GeneratorPreset `json:"preset" validate:"required"` - ParamsCustom *GeneratorPresetCustomParams `json:"params_custom,omitzero"` - ParamsForgotten *GeneratorPresetForgottenParams `json:"params_forgotten,omitzero"` - ParamsTop *GeneratorPresetTopParams `json:"params_top,omitzero"` - ParamsOldTop *GeneratorPresetOldTopParams `json:"params_old_top,omitzero"` + ParamsCustom *GeneratorPresetCustomParams `json:"params_custom,omitzero"` + ParamsTop *GeneratorPresetTopParams `json:"params_top,omitzero"` + ParamsOldTop *GeneratorPresetOldTopParams `json:"params_old_top,omitzero"` } func generatorParamsDTO(params model.GeneratorParams) GeneratorParams { @@ -123,7 +108,6 @@ func generatorParamsDTO(params model.GeneratorParams) GeneratorParams { ExcludedTrackIDs: params.ExcludedTrackIDs, Preset: params.Preset, ParamsCustom: generatorPresetCustomParamsDTO(params.ParamsCustom), - ParamsForgotten: generatorPresetForgottenParamsDTO(params.ParamsForgotten), ParamsTop: generatorPresetTopParamsDTO(params.ParamsTop), ParamsOldTop: generatorPresetOldTopParamsDTO(params.ParamsOldTop), } @@ -134,10 +118,6 @@ func (g GeneratorParams) ToModel() model.GeneratorParams { if g.ParamsCustom != nil { paramsCustom = g.ParamsCustom.ToModel() } - var paramsForgotten *model.GeneratorPresetForgottenParams - if g.ParamsForgotten != nil { - paramsForgotten = g.ParamsForgotten.ToModel() - } var paramsTop *model.GeneratorPresetTopParams if g.ParamsTop != nil { paramsTop = g.ParamsTop.ToModel() @@ -152,7 +132,6 @@ func (g GeneratorParams) ToModel() model.GeneratorParams { ExcludedTrackIDs: g.ExcludedTrackIDs, Preset: g.Preset, ParamsCustom: paramsCustom, - ParamsForgotten: paramsForgotten, ParamsTop: paramsTop, ParamsOldTop: paramsOldTop, } diff --git a/ui/src/components/generator/GeneratorForm.tsx b/ui/src/components/generator/GeneratorForm.tsx index 049b92e..18dbfaf 100644 --- a/ui/src/components/generator/GeneratorForm.tsx +++ b/ui/src/components/generator/GeneratorForm.tsx @@ -48,7 +48,6 @@ export const GeneratorForm = ({ generator: initialGenerator }: Props) => { excludedTrackIds: [], preset: GeneratorPreset.Top, paramsCustom: {}, - paramsForgotten: {}, paramsTop: { window: { start: daysAgo(14), diff --git a/ui/src/components/generator/GeneratorFormPreset.tsx b/ui/src/components/generator/GeneratorFormPreset.tsx index 8aed53e..4e8a01b 100644 --- a/ui/src/components/generator/GeneratorFormPreset.tsx +++ b/ui/src/components/generator/GeneratorFormPreset.tsx @@ -72,8 +72,6 @@ export const GeneratorFormPreset = ({ form, nextStep, prevStep }: Props) => { const getPresetArguments = (preset: GeneratorPreset) => { switch (preset) { - case GeneratorPreset.Forgotten: - return case GeneratorPreset.Top: return case GeneratorPreset.OldTop: @@ -209,10 +207,6 @@ const Custom = ({ form }: { form: UseFormReturnType }) => { return null } -const Forgotten = ({ form }: { form: UseFormReturnType }) => { - return null -} - const Top = ({ form }: { form: UseFormReturnType }) => { return ( diff --git a/ui/src/lib/types/api.d.ts b/ui/src/lib/types/api.d.ts index 8df537b..e22be7a 100644 --- a/ui/src/lib/types/api.d.ts +++ b/ui/src/lib/types/api.d.ts @@ -108,7 +108,6 @@ export namespace API { excluded_track_ids?: number[]; preset: string; params_custom?: {}; - params_forgotten?: {}; params_top?: { window: GeneratorWindow; }; diff --git a/ui/src/lib/types/generator.ts b/ui/src/lib/types/generator.ts index 694b10c..87aa7e0 100644 --- a/ui/src/lib/types/generator.ts +++ b/ui/src/lib/types/generator.ts @@ -5,13 +5,11 @@ import { convertTracks, Track } from "./track"; export enum GeneratorPreset { Custom = "custom", - Forgotten = "forgotten", Top = "top", OldTop = "old_top" } export const generatorPresetString: Record = { [GeneratorPreset.Custom]: "Custom", - [GeneratorPreset.Forgotten]: "Forgotten", [GeneratorPreset.Top]: "Top", [GeneratorPreset.OldTop]: "Old Top", } @@ -38,7 +36,6 @@ export interface GeneratorParams { excludedTrackIds: number[]; preset: GeneratorPreset; paramsCustom?: {}; - paramsForgotten?: {}; paramsTop?: { window: GeneratorWindow; }; @@ -55,7 +52,6 @@ export const convertGeneratorParams = (g: Pick): Genera excludedTrackIds: g.params.excluded_track_ids ?? [], preset: g.params.preset as GeneratorPreset, paramsCustom: g.params.params_custom, - paramsForgotten: g.params.params_forgotten, paramsTop: g.params.params_top ? { window: convertGeneratorWindow(g.params.params_top.window) } : undefined, @@ -121,7 +117,6 @@ export const generatorParamsSchema = z.object({ excludedTrackIds: z.array(z.number().positive()), preset: z.enum(GeneratorPreset), paramsCustom: z.object({}).partial().optional(), - paramsForgotten: z.object({}).partial().optional(), paramsTop: z.object({ window: generatorWindowSchema }).partial().optional(), From 9b8a8312d1acc0247ca1b5f21f23832ba4ed90fa Mon Sep 17 00:00:00 2001 From: vincent Date: Tue, 6 Jan 2026 12:37:24 +0100 Subject: [PATCH 32/33] chore(generator): remove custom preset --- internal/database/model/generator.go | 4 ---- internal/generator/generate.go | 7 ------- internal/generator/normalize.go | 9 -------- internal/server/dto/generator.go | 21 ------------------- ui/src/components/generator/GeneratorForm.tsx | 1 - .../generator/GeneratorFormPreset.tsx | 8 +------ ui/src/lib/types/api.d.ts | 1 - ui/src/lib/types/generator.ts | 5 ----- 8 files changed, 1 insertion(+), 55 deletions(-) diff --git a/internal/database/model/generator.go b/internal/database/model/generator.go index fc04d77..b84918d 100644 --- a/internal/database/model/generator.go +++ b/internal/database/model/generator.go @@ -10,7 +10,6 @@ import ( type GeneratorPreset string const ( - GeneratorPresetCustom GeneratorPreset = "custom" GeneratorPresetTop GeneratorPreset = "top" GeneratorPresetOldTop GeneratorPreset = "old_top" ) @@ -24,8 +23,6 @@ type GeneratorWindow struct { BurstInterval time.Duration `json:"burst_interval"` } -type GeneratorPresetCustomParams struct{} - type GeneratorPresetTopParams struct { Window GeneratorWindow `json:"window"` } @@ -42,7 +39,6 @@ type GeneratorParams struct { Preset GeneratorPreset `json:"preset"` - ParamsCustom *GeneratorPresetCustomParams `json:"params_custom,omitzero"` ParamsTop *GeneratorPresetTopParams `json:"params_top,omitzero"` ParamsOldTop *GeneratorPresetOldTopParams `json:"params_old_top,omitzero"` } diff --git a/internal/generator/generate.go b/internal/generator/generate.go index 868faac..bc365e9 100644 --- a/internal/generator/generate.go +++ b/internal/generator/generate.go @@ -21,9 +21,6 @@ func (g *generator) Generate(ctx context.Context, gen *model.Generator) ([]model tracks, err = g.top(ctx, *gen) case model.GeneratorPresetOldTop: tracks, err = g.oldTop(ctx, *gen) - - default: - tracks, err = g.custom(*gen) } if err != nil { @@ -38,10 +35,6 @@ type trackPlayCount struct { playCount int } -func (g *generator) custom(gen model.Generator) ([]model.Track, error) { - return nil, nil -} - func (g *generator) top(ctx context.Context, gen model.Generator) ([]model.Track, error) { params := gen.Params.ParamsTop diff --git a/internal/generator/normalize.go b/internal/generator/normalize.go index 37c98d4..726ac2e 100644 --- a/internal/generator/normalize.go +++ b/internal/generator/normalize.go @@ -14,26 +14,17 @@ func normalize(gen *model.Generator) { } switch params.Preset { - case model.GeneratorPresetCustom: - normalizePresetCustom(¶ms) - params.ParamsTop = nil - params.ParamsOldTop = nil case model.GeneratorPresetTop: normalizePresetTop(¶ms) - params.ParamsCustom = nil params.ParamsOldTop = nil case model.GeneratorPresetOldTop: normalizePresetOldTop(¶ms) - params.ParamsCustom = nil params.ParamsTop = nil } gen.Params = params } -func normalizePresetCustom(params *model.GeneratorParams) { -} - func normalizePresetTop(params *model.GeneratorParams) { now := time.Now() diff --git a/internal/server/dto/generator.go b/internal/server/dto/generator.go index c2bca36..be1c3c6 100644 --- a/internal/server/dto/generator.go +++ b/internal/server/dto/generator.go @@ -32,20 +32,6 @@ func (g GeneratorWindow) ToModel() *model.GeneratorWindow { } } -type GeneratorPresetCustomParams struct{} - -func generatorPresetCustomParamsDTO(params *model.GeneratorPresetCustomParams) *GeneratorPresetCustomParams { - if params == nil { - return nil - } - - return &GeneratorPresetCustomParams{} -} - -func (g GeneratorPresetCustomParams) ToModel() *model.GeneratorPresetCustomParams { - return &model.GeneratorPresetCustomParams{} -} - type GeneratorPresetTopParams struct { Window GeneratorWindow `json:"window"` } @@ -96,7 +82,6 @@ type GeneratorParams struct { Preset model.GeneratorPreset `json:"preset" validate:"required"` - ParamsCustom *GeneratorPresetCustomParams `json:"params_custom,omitzero"` ParamsTop *GeneratorPresetTopParams `json:"params_top,omitzero"` ParamsOldTop *GeneratorPresetOldTopParams `json:"params_old_top,omitzero"` } @@ -107,17 +92,12 @@ func generatorParamsDTO(params model.GeneratorParams) GeneratorParams { ExcludedPlaylistIDs: params.ExcludedPlaylistIDs, ExcludedTrackIDs: params.ExcludedTrackIDs, Preset: params.Preset, - ParamsCustom: generatorPresetCustomParamsDTO(params.ParamsCustom), ParamsTop: generatorPresetTopParamsDTO(params.ParamsTop), ParamsOldTop: generatorPresetOldTopParamsDTO(params.ParamsOldTop), } } func (g GeneratorParams) ToModel() model.GeneratorParams { - var paramsCustom *model.GeneratorPresetCustomParams - if g.ParamsCustom != nil { - paramsCustom = g.ParamsCustom.ToModel() - } var paramsTop *model.GeneratorPresetTopParams if g.ParamsTop != nil { paramsTop = g.ParamsTop.ToModel() @@ -131,7 +111,6 @@ func (g GeneratorParams) ToModel() model.GeneratorParams { ExcludedPlaylistIDs: g.ExcludedPlaylistIDs, ExcludedTrackIDs: g.ExcludedTrackIDs, Preset: g.Preset, - ParamsCustom: paramsCustom, ParamsTop: paramsTop, ParamsOldTop: paramsOldTop, } diff --git a/ui/src/components/generator/GeneratorForm.tsx b/ui/src/components/generator/GeneratorForm.tsx index 18dbfaf..ca27cb5 100644 --- a/ui/src/components/generator/GeneratorForm.tsx +++ b/ui/src/components/generator/GeneratorForm.tsx @@ -47,7 +47,6 @@ export const GeneratorForm = ({ generator: initialGenerator }: Props) => { excludedPlaylistIds: [], excludedTrackIds: [], preset: GeneratorPreset.Top, - paramsCustom: {}, paramsTop: { window: { start: daysAgo(14), diff --git a/ui/src/components/generator/GeneratorFormPreset.tsx b/ui/src/components/generator/GeneratorFormPreset.tsx index 4e8a01b..03780c8 100644 --- a/ui/src/components/generator/GeneratorFormPreset.tsx +++ b/ui/src/components/generator/GeneratorFormPreset.tsx @@ -76,12 +76,10 @@ export const GeneratorFormPreset = ({ form, nextStep, prevStep }: Props) => { return case GeneratorPreset.OldTop: return - default: - return } } - const [presetArguments, setPresetArguments] = useState(getPresetArguments(form.getValues().params?.preset ?? GeneratorPreset.Custom)) + const [presetArguments, setPresetArguments] = useState(getPresetArguments(form.getValues().params?.preset ?? GeneratorPreset.Top)) form.watch("params.preset", ({ value }) => setPresetArguments(getPresetArguments(value as GeneratorPreset))) // TODO: next give arwning if the preview is not the same as the generator @@ -203,10 +201,6 @@ const Playlists = ({ form }: { form: UseFormReturnType }) => { ) } -const Custom = ({ form }: { form: UseFormReturnType }) => { - return null -} - const Top = ({ form }: { form: UseFormReturnType }) => { return ( diff --git a/ui/src/lib/types/api.d.ts b/ui/src/lib/types/api.d.ts index e22be7a..51ae260 100644 --- a/ui/src/lib/types/api.d.ts +++ b/ui/src/lib/types/api.d.ts @@ -107,7 +107,6 @@ export namespace API { excluded_playlist_ids?: number[]; excluded_track_ids?: number[]; preset: string; - params_custom?: {}; params_top?: { window: GeneratorWindow; }; diff --git a/ui/src/lib/types/generator.ts b/ui/src/lib/types/generator.ts index 87aa7e0..f3b13e0 100644 --- a/ui/src/lib/types/generator.ts +++ b/ui/src/lib/types/generator.ts @@ -4,12 +4,10 @@ import { API } from "./api"; import { convertTracks, Track } from "./track"; export enum GeneratorPreset { - Custom = "custom", Top = "top", OldTop = "old_top" } export const generatorPresetString: Record = { - [GeneratorPreset.Custom]: "Custom", [GeneratorPreset.Top]: "Top", [GeneratorPreset.OldTop]: "Old Top", } @@ -35,7 +33,6 @@ export interface GeneratorParams { excludedPlaylistIds: number[]; excludedTrackIds: number[]; preset: GeneratorPreset; - paramsCustom?: {}; paramsTop?: { window: GeneratorWindow; }; @@ -51,7 +48,6 @@ export const convertGeneratorParams = (g: Pick): Genera excludedPlaylistIds: g.params.excluded_playlist_ids ?? [], excludedTrackIds: g.params.excluded_track_ids ?? [], preset: g.params.preset as GeneratorPreset, - paramsCustom: g.params.params_custom, paramsTop: g.params.params_top ? { window: convertGeneratorWindow(g.params.params_top.window) } : undefined, @@ -116,7 +112,6 @@ export const generatorParamsSchema = z.object({ excludedPlaylistIds: z.array(z.number().positive()), excludedTrackIds: z.array(z.number().positive()), preset: z.enum(GeneratorPreset), - paramsCustom: z.object({}).partial().optional(), paramsTop: z.object({ window: generatorWindowSchema }).partial().optional(), From c526716b23f83779d23e5c7ff74f307bf5706ce4 Mon Sep 17 00:00:00 2001 From: vincent Date: Tue, 6 Jan 2026 12:42:02 +0100 Subject: [PATCH 33/33] lint: fix --- internal/server/api/track.go | 2 +- internal/server/service/setting.go | 2 +- internal/server/service/track.go | 2 +- internal/spotifysync/history.go | 2 +- internal/spotifysync/util.go | 2 +- ui/src/components/generator/GeneratorFormFinalize.tsx | 4 ++-- ui/src/components/generator/GeneratorFormPreset.tsx | 2 +- ui/src/components/generator/GeneratorFormTrack.tsx | 2 +- ui/src/components/generator/GeneratorPlaylistTree.tsx | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/server/api/track.go b/internal/server/api/track.go index db28bac..3d2f040 100644 --- a/internal/server/api/track.go +++ b/internal/server/api/track.go @@ -42,7 +42,7 @@ func (r *Track) getAllByID(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, err.Error()) } - tracks, err := r.track.GetByIds(c.Context(), p.IDs) + tracks, err := r.track.GetByIDs(c.Context(), p.IDs) if err != nil { return err } diff --git a/internal/server/service/setting.go b/internal/server/service/setting.go index f311db3..8f81edc 100644 --- a/internal/server/service/setting.go +++ b/internal/server/service/setting.go @@ -92,7 +92,7 @@ func (e exportTaskTrack) toHistory(userID int) *model.History { PlayedAt: e.StoppedAt.Add(time.Duration(-1*e.MsPlayed) * time.Millisecond), Skipped: &e.Skipped, Track: model.Track{ - SpotifyID: spotifysync.UriToID(e.SpotifyTrackURI), + SpotifyID: spotifysync.URIToID(e.SpotifyTrackURI), }, } } diff --git a/internal/server/service/track.go b/internal/server/service/track.go index fbb16ef..6290061 100644 --- a/internal/server/service/track.go +++ b/internal/server/service/track.go @@ -26,7 +26,7 @@ func (s *Service) NewTrack() *Track { } } -func (t *Track) GetByIds(ctx context.Context, trackIDs []int) ([]dto.Track, error) { +func (t *Track) GetByIDs(ctx context.Context, trackIDs []int) ([]dto.Track, error) { tracks, err := t.track.GetAllByID(ctx, trackIDs) if err != nil { zap.S().Error(err) diff --git a/internal/spotifysync/history.go b/internal/spotifysync/history.go index 13c2df2..8aee793 100644 --- a/internal/spotifysync/history.go +++ b/internal/spotifysync/history.go @@ -68,7 +68,7 @@ func (c *client) historySync(ctx context.Context, user model.User) error { TrackID: track.ID, } - contextSpotifyID := UriToID(current.Context.URI) + contextSpotifyID := URIToID(current.Context.URI) switch current.Context.Type { case "album": diff --git a/internal/spotifysync/util.go b/internal/spotifysync/util.go index ef42423..a33d3f5 100644 --- a/internal/spotifysync/util.go +++ b/internal/spotifysync/util.go @@ -16,7 +16,7 @@ import ( "github.com/topvennie/sortifyr/pkg/storage" ) -func UriToID(uri string) string { +func URIToID(uri string) string { parts := strings.Split(uri, ":") if len(parts) != 3 { return "" diff --git a/ui/src/components/generator/GeneratorFormFinalize.tsx b/ui/src/components/generator/GeneratorFormFinalize.tsx index a10fb4c..0c63f93 100644 --- a/ui/src/components/generator/GeneratorFormFinalize.tsx +++ b/ui/src/components/generator/GeneratorFormFinalize.tsx @@ -13,12 +13,12 @@ type Props = { } export const GeneratorFormFinalize = ({ form, nextStep, prevStep }: Props) => { - const [createPlaylist, useCreatePlaylist] = useState(form.getValues().createPlaylist) + const [createPlaylist, setCreatePlaylist] = useState(form.getValues().createPlaylist) const [interval, setInterval] = useState(form.getValues().intervalDays) const handleCreatePlaylistChange = (checked: boolean) => { form.setFieldValue("createPlaylist", checked) - useCreatePlaylist(checked) + setCreatePlaylist(checked) } const handleIntervalChange = (value: number) => { diff --git a/ui/src/components/generator/GeneratorFormPreset.tsx b/ui/src/components/generator/GeneratorFormPreset.tsx index 03780c8..62984af 100644 --- a/ui/src/components/generator/GeneratorFormPreset.tsx +++ b/ui/src/components/generator/GeneratorFormPreset.tsx @@ -30,7 +30,7 @@ export const GeneratorFormPreset = ({ form, nextStep, prevStep }: Props) => { const values = form.getValues() generatorPreview(values) setParams(values.params) - }, []) + }, []) // eslint-disable-line react-hooks/exhaustive-deps const [opened, { open, close }] = useDisclosure() const handleNextInit = () => { diff --git a/ui/src/components/generator/GeneratorFormTrack.tsx b/ui/src/components/generator/GeneratorFormTrack.tsx index 25282d9..363d9f4 100644 --- a/ui/src/components/generator/GeneratorFormTrack.tsx +++ b/ui/src/components/generator/GeneratorFormTrack.tsx @@ -26,7 +26,7 @@ export const GeneratorFormTrack = ({ form, nextStep, prevStep }: Props) => { const values = form.getValues() generatorPreview(values) setParams(values.params) - }, []) + }, []) // eslint-disable-line react-hooks/exhaustive-deps const [opened, { open, close }] = useDisclosure() const handleNextInit = () => { diff --git a/ui/src/components/generator/GeneratorPlaylistTree.tsx b/ui/src/components/generator/GeneratorPlaylistTree.tsx index ed5e5e1..b5a60fa 100644 --- a/ui/src/components/generator/GeneratorPlaylistTree.tsx +++ b/ui/src/components/generator/GeneratorPlaylistTree.tsx @@ -74,7 +74,7 @@ const TreeNode = ({ directory, level, excluded, onToggle }: TreeNodeProps) => { if (filtered.length === 0) return true if (filtered.length === playlistsIds.length) return false return null - }, [excluded]) + }, [excluded, playlistsIds]) return (