···11+package database
22+33+import (
44+ "context"
55+ "database/sql"
66+ "strings"
77+)
88+99+// Expands inner arrays in the slice of args, so it can be used in a query with variadic arguments.
1010+func ExpandArgs(args []any) []any {
1111+ out := make([]any, 0)
1212+ for _, arg := range args {
1313+ switch v := arg.(type) {
1414+ case []string:
1515+ for _, s := range v {
1616+ out = append(out, s)
1717+ }
1818+ case []int:
1919+ for _, i := range v {
2020+ out = append(out, i)
2121+ }
2222+ default:
2323+ out = append(out, arg)
2424+ }
2525+ }
2626+ return out
2727+}
2828+2929+// QueryMany executes a query and returns a slice of results. It is a small helper function reducing
3030+// the boilerplate code needed to execute a query and scan the results. The scanFunc is used to scan
3131+// each row into a result.
3232+func QueryMany[T any](
3333+ ctx context.Context, db *sql.DB, query string, args []any, scanFunc func(*sql.Rows) (T, error),
3434+) ([]T, error) {
3535+ rows, err := db.QueryContext(ctx, query, args...)
3636+ if err != nil {
3737+ return nil, err
3838+ }
3939+ defer rows.Close()
4040+4141+ var results []T
4242+ for rows.Next() {
4343+ result, err := scanFunc(rows)
4444+ if err != nil {
4545+ return nil, err
4646+ }
4747+ results = append(results, result)
4848+ }
4949+ return results, nil
5050+}
5151+5252+// QueryOne executes a query and returns a single result. It is a small helper function reducing the
5353+// boilerplate code needed to execute a query and scan the results. The scanFunc is used to scan
5454+// the row into a result.
5555+func QueryOne[T any](
5656+ ctx context.Context, db *sql.DB, query string, args []any, scanFunc func(*sql.Rows) (T, error),
5757+) (T, error) {
5858+ rows, err := db.QueryContext(ctx, query, args...)
5959+ if err != nil {
6060+ return *new(T), err
6161+ }
6262+ defer rows.Close()
6363+6464+ if !rows.Next() {
6565+ return *new(T), nil
6666+ }
6767+6868+ return scanFunc(rows)
6969+}
7070+7171+// Generates a string of placeholders for a SQL query. For example, if count is 3, the result will
7272+// be "?, ?, ?".
7373+func GeneratePlaceholders(count int) string {
7474+ placeholders := make([]string, count)
7575+ for i := range placeholders {
7676+ placeholders[i] = "?"
7777+ }
7878+ return strings.Join(placeholders, ", ")
7979+}
+159
pkg/enum/enum.go
···11+package enum
22+33+import (
44+ "fmt"
55+ "sort"
66+)
77+88+// A functional function that transforms items using a mapping function.
99+func Map[T any, U any](items []T, fn func(T) U) []U {
1010+ var result []U
1111+1212+ for _, item := range items {
1313+ result = append(result, fn(item))
1414+ }
1515+1616+ return result
1717+}
1818+1919+// A functional function that reduces items to a single value using a reduction function.
2020+func Reduce[T any, U any](items []T, fn func(U, T) U, initial U) U {
2121+ result := initial
2222+2323+ for _, item := range items {
2424+ result = fn(result, item)
2525+ }
2626+2727+ return result
2828+}
2929+3030+// A functional function that removes duplicate items based on a key function. The returned slice
3131+// will contain only the items that are unique according to the returned value of the key function.
3232+func UniqueBy[T any, K comparable](items []T, fn func(T) K) []T {
3333+ keys := make(map[K]struct{})
3434+ var result []T
3535+3636+ for _, item := range items {
3737+ key := fn(item)
3838+ if _, exists := keys[key]; exists {
3939+ continue
4040+ }
4141+ keys[key] = struct{}{}
4242+ result = append(result, item)
4343+ }
4444+4545+ return result
4646+}
4747+4848+// GroupBy groups elements of a slice by a key derived from each element.
4949+// The keyer function extracts the key from each element.
5050+func GroupBy[T any, K comparable](items []T, keyer func(T) K) map[K][]T {
5151+ groups := make(map[K][]T)
5252+ for _, item := range items {
5353+ key := keyer(item)
5454+ groups[key] = append(groups[key], item)
5555+ }
5656+ return groups
5757+}
5858+5959+// GroupByWithValue groups elements of a slice by a key derived from each element,
6060+// and maps each element to a value derived from the element.
6161+func GroupByWithValue[T any, K comparable, V any](items []T, keyer func(T) K, valuer func(T) V) map[K][]V {
6262+ groups := make(map[K][]V)
6363+ for _, item := range items {
6464+ key := keyer(item)
6565+ value := valuer(item)
6666+ groups[key] = append(groups[key], value)
6767+ }
6868+ return groups
6969+}
7070+7171+// Combine two slices into one map, where the first slice should have unique values that can be
7272+// used as keys. If the slices are not the same length, the extra values will be ignored
7373+func ZipMap[K comparable, T any](keys []K, values []T) map[K]T {
7474+ result := make(map[K]T)
7575+ for i, key := range keys {
7676+ if i < len(values) {
7777+ result[key] = values[i]
7878+ }
7979+ }
8080+ return result
8181+}
8282+8383+// Combine two slices into one map, where the first slice should have unique values that can be
8484+// used as keys
8585+func ZipMapSafe[K comparable, T any](keys []K, values []T) (map[K]T, error) {
8686+ if len(keys) != len(values) {
8787+ return nil, fmt.Errorf("keys and values must have the same length")
8888+ }
8989+9090+ result := make(map[K]T)
9191+ for i, key := range keys {
9292+ if i < len(values) {
9393+ result[key] = values[i]
9494+ }
9595+ }
9696+ return result, nil
9797+}
9898+9999+// Create a slice of values from values inside the given slice that match the predicate
100100+func Filter[T any](items []T, predicate func(T) bool) []T {
101101+ var result []T
102102+ for _, item := range items {
103103+ if predicate(item) {
104104+ result = append(result, item)
105105+ }
106106+ }
107107+ return result
108108+}
109109+110110+// Return the first value from the values inside the given slice that match the predicate, if no
111111+// values can be found, it will return an empty value with a false boolean
112112+func Find[T any](items []T, predicate func(T) bool) (T, bool) {
113113+ for _, item := range items {
114114+ if predicate(item) {
115115+ return item, true
116116+ }
117117+ }
118118+ var zero T
119119+ return zero, false
120120+}
121121+122122+func Flatten[T any](items [][]T) []T {
123123+ var result []T
124124+ for _, item := range items {
125125+ result = append(result, item...)
126126+ }
127127+ return result
128128+}
129129+130130+// OrderByKeys reorders items so that their keys appear in the same order as inputKeys. Items whose
131131+// key is not present in inputKeys are placed at the end (while preserving their relative order).
132132+func OrderByKeys[T any, K comparable](items []T, keys []K, keyFn func(T) K) []T {
133133+ if len(items) == 0 || len(keys) == 0 {
134134+ return items
135135+ }
136136+137137+ pos := make(map[K]int, len(keys))
138138+ for i, k := range keys {
139139+ pos[k] = i
140140+ }
141141+142142+ const notFound = int(^uint(0) >> 1) // max int
143143+144144+ sort.SliceStable(items, func(i, j int) bool {
145145+ pi, ok := pos[keyFn(items[i])]
146146+ if !ok {
147147+ pi = notFound
148148+ }
149149+150150+ pj, ok := pos[keyFn(items[j])]
151151+ if !ok {
152152+ pj = notFound
153153+ }
154154+155155+ return pi < pj
156156+ })
157157+158158+ return items
159159+}
+48
pkg/pagination/pagination.go
···11+package pagination
22+33+type Page struct {
44+ Total int
55+ Page int
66+ Size int
77+}
88+99+func New(filter Filter, total int) Page {
1010+ return Page{
1111+ Total: total,
1212+ Page: filter.page(),
1313+ Size: filter.size(),
1414+ }
1515+}
1616+1717+type Filter struct {
1818+ Page int
1919+ Size int
2020+}
2121+2222+func (f Filter) page() int {
2323+ if f.Page <= 0 {
2424+ return 1
2525+ }
2626+2727+ return f.Page
2828+}
2929+3030+func (f Filter) Offset() int {
3131+ return (f.page() - 1) * f.size()
3232+}
3333+3434+func (f Filter) Limit() int {
3535+ return f.size()
3636+}
3737+3838+func (f Filter) size() int {
3939+ if f.Size == 0 {
4040+ return 25
4141+ }
4242+4343+ if f.Size > 100 {
4444+ return 100
4545+ }
4646+4747+ return f.Size
4848+}
···11+-- +goose Up
22+-- +goose StatementBegin
33+CREATE TABLE artist_credit (
44+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55+ name TEXT NOT NULL,
66+ artist_count INTEGER NOT NULL,
77+ ref_count INTEGER DEFAULT 0,
88+ created TEXT DEFAULT CURRENT_TIMESTAMP,
99+ edits_pending INTEGER NOT NULL DEFAULT 0 CHECK (edits_pending >= 0),
1010+ gid TEXT NOT NULL UNIQUE
1111+);
1212+1313+CREATE INDEX idx__artist_credit__gid ON artist_credit(gid);
1414+CREATE INDEX idx__artist_credit__name ON artist_credit(name);
1515+-- +goose StatementEnd
1616+1717+-- +goose Down
1818+-- +goose StatementBegin
1919+DROP TABLE artist_credit;
2020+-- +goose StatementEnd
···11+-- +goose Up
22+-- +goose StatementBegin
33+CREATE TABLE medium (
44+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55+ release INTEGER NOT NULL,
66+ position INTEGER NOT NULL,
77+ format INTEGER,
88+ name TEXT,
99+ edits_pending INTEGER NOT NULL DEFAULT 0 CHECK (edits_pending >= 0),
1010+ last_updated TEXT DEFAULT CURRENT_TIMESTAMP,
1111+ track_count INTEGER NOT NULL DEFAULT 0,
1212+ gid TEXT NOT NULL UNIQUE,
1313+ FOREIGN KEY (release) REFERENCES release(id),
1414+ FOREIGN KEY (format) REFERENCES medium_format(id)
1515+);
1616+1717+CREATE INDEX idx__medium__release ON medium(release);
1818+CREATE INDEX idx__medium__position ON medium(position);
1919+CREATE INDEX idx__medium__gid ON medium(gid);
2020+CREATE INDEX idx__medium__format ON medium(format);
2121+2222+-- +goose StatementEnd
2323+2424+-- +goose Down
2525+-- +goose StatementBegin
2626+DROP TABLE medium;
2727+-- +goose StatementEnd
···11+-- +goose Up
22+-- +goose StatementBegin
33+CREATE TABLE track (
44+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55+ gid TEXT NOT NULL UNIQUE,
66+ recording INTEGER NOT NULL,
77+ medium INTEGER NOT NULL,
88+ position INTEGER NOT NULL,
99+ number TEXT,
1010+ name TEXT NOT NULL,
1111+ artist_credit INTEGER NOT NULL,
1212+ length INTEGER CHECK (length IS NULL OR length > 0),
1313+ edits_pending INTEGER NOT NULL DEFAULT 0 CHECK (edits_pending >= 0),
1414+ last_updated TEXT DEFAULT CURRENT_TIMESTAMP,
1515+ is_data_track BOOLEAN NOT NULL DEFAULT FALSE,
1616+ FOREIGN KEY (recording) REFERENCES recording(id),
1717+ FOREIGN KEY (medium) REFERENCES medium(id),
1818+ FOREIGN KEY (artist_credit) REFERENCES artist_credit(id)
1919+);
2020+2121+CREATE INDEX idx__track__medium ON track(medium);
2222+CREATE INDEX idx__track__position ON track(position);
2323+CREATE INDEX idx__track__gid ON track(gid);
2424+CREATE INDEX idx__track__name ON track(name);
2525+CREATE INDEX idx__track__recording ON track(recording);
2626+CREATE INDEX idx__track__artist_credit ON track(artist_credit);
2727+2828+-- +goose StatementEnd
2929+3030+-- +goose Down
3131+-- +goose StatementBegin
3232+DROP TABLE track;
3333+-- +goose StatementEnd
···11+-- +goose Up
22+-- +goose StatementBegin
33+CREATE TABLE scrobble (
44+ user_id INTEGER NOT NULL,
55+ recording_mbid TEXT NOT NULL,
66+ played_at TIMESTAMP NOT NULL
77+);
88+99+CREATE INDEX scrobble_user_id_idx ON scrobble(user_id);
1010+CREATE INDEX scrobble_recording_mbid_idx ON scrobble(recording_mbid);
1111+CREATE INDEX scrobble_played_at_idx ON scrobble(played_at);
1212+-- +goose StatementEnd
1313+1414+-- +goose Down
1515+-- +goose StatementBegin
1616+DROP TABLE scrobble;
1717+-- +goose StatementEnd
+57
scripts/migrations/statistics.py
···11+import duckdb
22+import os
33+44+STATS_DATABASE_PATH = os.getenv("STATS_DATABASE_PATH") or "private/database/statistics.dev.duckdb"
55+66+def create_recording_mbid__artist_mbid(conn: duckdb.DuckDBPyConnection):
77+ query = """
88+ CREATE TABLE recording_mbid__artist_mbid (
99+ recording_mbid TEXT NOT NULL,
1010+ artist_mbid TEXT NOT NULL,
1111+ PRIMARY KEY (recording_mbid, artist_mbid)
1212+ );
1313+ """
1414+1515+ conn.execute(query)
1616+1717+1818+def create_recording_mbid__release_group_mbid(conn: duckdb.DuckDBPyConnection):
1919+ query = """
2020+ CREATE TABLE recording_mbid__release_group_mbid (
2121+ recording_mbid TEXT NOT NULL,
2222+ release_group_mbid TEXT NOT NULL,
2323+ PRIMARY KEY (recording_mbid, release_group_mbid)
2424+ );
2525+ """
2626+2727+ conn.execute(query)
2828+2929+3030+def scrobble(conn: duckdb.DuckDBPyConnection):
3131+ query = """
3232+ CREATE TABLE scrobble (
3333+ user_id INTEGER NOT NULL,
3434+ recording_mbid TEXT NOT NULL,
3535+ played_at TIMESTAMP NOT NULL
3636+ );
3737+3838+ CREATE INDEX scrobble_user_id_idx ON scrobble(user_id);
3939+ CREATE INDEX scrobble_recording_mbid_idx ON scrobble(recording_mbid);
4040+ CREATE INDEX scrobble_played_at_idx ON scrobble(played_at);
4141+ """
4242+4343+ conn.execute(query)
4444+4545+4646+def main():
4747+ conn = duckdb.connect(STATS_DATABASE_PATH)
4848+4949+ create_recording_mbid__artist_mbid(conn)
5050+ create_recording_mbid__release_group_mbid(conn)
5151+ scrobble(conn)
5252+5353+ conn.close()
5454+5555+5656+if __name__ == "__main__":
5757+ main()