Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
c5901d9
feat(generator): add db models
Topvennie Dec 29, 2025
b57fcc2
chore(history): remove 20 seconds required playtime
Topvennie Dec 30, 2025
0ed6c7b
feat(history): calculate skipped value
Topvennie Dec 30, 2025
a54e331
feat(history): show play count
Topvennie Dec 31, 2025
5659939
feat(generator): preview generators in ui
Topvennie Jan 1, 2026
6afebbf
feat(generator): add api endpoint
Topvennie Jan 1, 2026
b8b9f79
chore(spotify): update interval tracks & artists 24 -> 48 h
Topvennie Jan 1, 2026
a6c7e34
feat(generator): implement old top tracks
Topvennie Jan 1, 2026
6e723f3
feat(generator): complete db model
Topvennie Jan 1, 2026
bd22ae9
chore(generator): rework api models in ui
Topvennie Jan 1, 2026
8fdc083
feat(generator): support excluded tracks & playlists
Topvennie Jan 2, 2026
15b9c19
feat(generator): exclude tracks in the ui
Topvennie Jan 3, 2026
47dc937
feat(generator): add general form information in ui
Topvennie Jan 3, 2026
06fb073
feat(generator): add crud api communication
Topvennie Jan 3, 2026
bd63eab
feat(generator): add spotify playlist integration in ui
Topvennie Jan 3, 2026
34bfec1
feat(task): delete tasks
Topvennie Jan 3, 2026
e55f88e
refactor(spotify): split sync and api package
Topvennie Jan 3, 2026
d0212c9
refactor(server): move some tasks to services
Topvennie Jan 3, 2026
92311f5
feat(generator): implement creating and maintaining logic
Topvennie Jan 3, 2026
0dbfd77
fix(generator): various sync issues
Topvennie Jan 4, 2026
16a7daf
feat(task): hide tasks
Topvennie Jan 4, 2026
c0b41f8
feat(generator): refresh playlist at will
Topvennie Jan 4, 2026
6c2ce88
feat(generator): add delete functionality
Topvennie Jan 4, 2026
4d2224d
feat(generator): save generator tracks seperately
Topvennie Jan 5, 2026
5e315e3
refactor(generator): simplify spotify playlist options
Topvennie Jan 5, 2026
ec36a7c
feat(generator): show tracks in overview table
Topvennie Jan 5, 2026
d9af0a6
chore(setting): run track task after import
Topvennie Jan 5, 2026
2beb692
chore(generator): use sliders instead of inputs
Topvennie Jan 5, 2026
c7f6018
chore(generator): show warning if preview is outdated
Topvennie Jan 5, 2026
8516d40
fix(generator): various sync issues
Topvennie Jan 5, 2026
9988e26
chore(generator): remove forgotten preset
Topvennie Jan 6, 2026
9b8a831
chore(generator): remove custom preset
Topvennie Jan 6, 2026
c526716
lint: fix
Topvennie Jan 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ node_modules/
.gitignore
Dockerfile
README.md
screenshots/
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
</details>
Expand Down
16 changes: 13 additions & 3 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ 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"
"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"
Expand Down Expand Up @@ -48,8 +50,16 @@ 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)
}

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())
Expand Down
17 changes: 17 additions & 0 deletions db/migrations/20251230104421_alter_history_add_skipped.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE history
ADD COLUMN skipped BOOLEAN;

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
27 changes: 27 additions & 0 deletions db/migrations/20251231183248_add_generator_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE generators (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
playlist_id INTEGER REFERENCES playlists (id),
interval BIGINT,
spotify_outdated BOOLEAN NOT NULL,
parameters JSONB,
updated_at TIMESTAMPTZ 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
40 changes: 40 additions & 0 deletions db/queries/generator.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
-- name: GeneratorGet :one
SELECT *
FROM generators
WHERE id = $1;

-- name: GeneratorGetAll :many
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),
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;

-- name: GeneratorCreate :one
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
UPDATE generators
SET
name = coalesce(sqlc.narg('name'), name),
description = coalesce(sqlc.narg('description'), description),
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),
updated_at = NOW()
WHERE id = $1;

-- name: GeneratorDelete :exec
DELETE FROM generators
WHERE id = $1;
10 changes: 10 additions & 0 deletions db/queries/generator_track.sql
Original file line number Diff line number Diff line change
@@ -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;
52 changes: 40 additions & 12 deletions db/queries/history.sql
Original file line number Diff line number Diff line change
@@ -1,34 +1,62 @@
-- 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
SELECT sqlc.embed(h), sqlc.embed(t)
-- 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
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::boolean OR NOT @filter_skipped)
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
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)
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: 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;
1 change: 0 additions & 1 deletion db/queries/playlist.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions db/queries/playlist_track.sql
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
18 changes: 15 additions & 3 deletions db/queries/track.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
SELECT *
FROM tracks;

-- name: TrackGetAllById :many
SELECT *
FROM tracks
WHERE id = ANY($1::int[]);

-- name: TrackGetBySpotify :one
SELECT *
FROM tracks
Expand All @@ -23,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
Expand Down Expand Up @@ -51,16 +63,16 @@ 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
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;
83 changes: 83 additions & 0 deletions internal/database/model/generator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package model

import (
"encoding/json"
"time"

"github.com/topvennie/sortifyr/pkg/sqlc"
)

type GeneratorPreset string

const (
GeneratorPresetTop GeneratorPreset = "top"
GeneratorPresetOldTop GeneratorPreset = "old_top"
)

// We need json tags because the params are saved as jsonb

type GeneratorWindow struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
MinPlays int `json:"min_plays"`
BurstInterval time.Duration `json:"burst_interval"`
}

type GeneratorPresetTopParams struct {
Window GeneratorWindow `json:"window"`
}

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"`

ParamsTop *GeneratorPresetTopParams `json:"params_top,omitzero"`
ParamsOldTop *GeneratorPresetOldTopParams `json:"params_old_top,omitzero"`
}

type Generator struct {
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
Tracks []Track
}

func GeneratorModel(g sqlc.Generator) *Generator {
params := GeneratorParams{}
_ = json.Unmarshal(g.Parameters, &params) // 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),
Interval: fromDuration(g.Interval),
SpotifyOutdated: g.SpotifyOutdated,
Params: params,
UpdatedAt: g.UpdatedAt.Time,
}
}

type GeneratorTrack struct {
ID int
GeneratorID int
TrackID int
}
Loading