A website inspired by Last.fm that will keep track of your listening statistics
lastfm music statistics
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Initial commit

oscar345 e462b830

+2911
+7
.gitignore
··· 1 + **.DS_Store 2 + **/mbdump-sample 3 + **/storage/images 4 + **secrets.env 5 + **production.env 6 + **/private/database 7 + **/bin
+1
.tool-versions
··· 1 + golang 1.25.4
+1
README.md
··· 1 + # Keep Track
+13
cmd/server/main.go
··· 1 + package main 2 + 3 + import ( 4 + "log" 5 + 6 + "github.com/oscar345/keeptrack/internal/server" 7 + ) 8 + 9 + func main() { 10 + if err := server.New().Start(); err != nil { 11 + log.Panicln(err) 12 + } 13 + }
+17
config/development.env
··· 1 + ## App configuration 2 + PORT=3000 3 + HOST=127.0.0.1 4 + 5 + ## Database configuration 6 + APP_DATABASE_PATH=private/database/app.dev.db 7 + MUSICBRAINZ_DATABASE_PATH=private/database/musicbrainz.dev.db 8 + PROTO_DATABASE_PATH=private/database/proto.dev.db 9 + STATISTICS_DATABASE_PATH=private/database/statistics.dev.duckdb 10 + 11 + ## Storage configuration 12 + STORAGE_DISK_PATH=public/storage 13 + 14 + ## Services configuration 15 + ## Spotify 16 + SPOTIFY_REDIRECT_URL=http://127.0.0.1:3000/users/settings/spotify/callback 17 + # Other spotify API configuation exists in the secrets.env file
+39
go.mod
··· 1 + module github.com/oscar345/keeptrack 2 + 3 + go 1.25.4 4 + 5 + require ( 6 + github.com/duckdb/duckdb-go/v2 v2.5.4 7 + github.com/go-chi/chi/v5 v5.2.3 8 + github.com/joho/godotenv v1.5.1 9 + github.com/mattn/go-sqlite3 v1.14.33 10 + ) 11 + 12 + require ( 13 + github.com/apache/arrow-go/v18 v18.4.1 // indirect 14 + github.com/duckdb/duckdb-go-bindings v0.1.24 // indirect 15 + github.com/duckdb/duckdb-go-bindings/darwin-amd64 v0.1.24 // indirect 16 + github.com/duckdb/duckdb-go-bindings/darwin-arm64 v0.1.24 // indirect 17 + github.com/duckdb/duckdb-go-bindings/linux-amd64 v0.1.24 // indirect 18 + github.com/duckdb/duckdb-go-bindings/linux-arm64 v0.1.24 // indirect 19 + github.com/duckdb/duckdb-go-bindings/windows-amd64 v0.1.24 // indirect 20 + github.com/duckdb/duckdb-go/arrowmapping v0.0.27 // indirect 21 + github.com/duckdb/duckdb-go/mapping v0.0.27 // indirect 22 + github.com/ggicci/httpin v0.20.2 // indirect 23 + github.com/ggicci/owl v0.8.2 // indirect 24 + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 25 + github.com/goccy/go-json v0.10.5 // indirect 26 + github.com/google/flatbuffers v25.9.23+incompatible // indirect 27 + github.com/google/uuid v1.6.0 // indirect 28 + github.com/klauspost/compress v1.18.2 // indirect 29 + github.com/klauspost/cpuid/v2 v2.3.0 // indirect 30 + github.com/pierrec/lz4/v4 v4.1.22 // indirect 31 + github.com/zeebo/xxh3 v1.0.2 // indirect 32 + golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect 33 + golang.org/x/mod v0.31.0 // indirect 34 + golang.org/x/sync v0.19.0 // indirect 35 + golang.org/x/sys v0.39.0 // indirect 36 + golang.org/x/telemetry v0.0.0-20251208220230-2638a1023523 // indirect 37 + golang.org/x/tools v0.40.0 // indirect 38 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect 39 + )
+86
go.sum
··· 1 + github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= 2 + github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= 3 + github.com/apache/arrow-go/v18 v18.4.1 h1:q/jVkBWCJOB9reDgaIZIdruLQUb1kbkvOnOFezVH1C4= 4 + github.com/apache/arrow-go/v18 v18.4.1/go.mod h1:tLyFubsAl17bvFdUAy24bsSvA/6ww95Iqi67fTpGu3E= 5 + github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc= 6 + github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g= 7 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 8 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 + github.com/duckdb/duckdb-go-bindings v0.1.24 h1:p1v3GruGHGcZD69cWauH6QrOX32oooqdUAxrWK3Fo6o= 10 + github.com/duckdb/duckdb-go-bindings v0.1.24/go.mod h1:WA7U/o+b37MK2kiOPPueVZ+FIxt5AZFCjszi8hHeH18= 11 + github.com/duckdb/duckdb-go-bindings/darwin-amd64 v0.1.24 h1:XhqMj+bvpTIm+hMeps1Kk94r2eclAswk2ISFs4jMm+g= 12 + github.com/duckdb/duckdb-go-bindings/darwin-amd64 v0.1.24/go.mod h1:jfbOHwGZqNCpMAxV4g4g5jmWr0gKdMvh2fGusPubxC4= 13 + github.com/duckdb/duckdb-go-bindings/darwin-arm64 v0.1.24 h1:OyHr5PykY5FG81jchpRoESMDQX1HK66PdNsfxoHxbwM= 14 + github.com/duckdb/duckdb-go-bindings/darwin-arm64 v0.1.24/go.mod h1:zLVtv1a7TBuTPvuAi32AIbnuw7jjaX5JElZ+urv1ydc= 15 + github.com/duckdb/duckdb-go-bindings/linux-amd64 v0.1.24 h1:6Y4VarmcT7Oe8stwta4dOLlUX8aG4ciG9VhFKnp91a4= 16 + github.com/duckdb/duckdb-go-bindings/linux-amd64 v0.1.24/go.mod h1:GCaBoYnuLZEva7BXzdXehTbqh9VSvpLB80xcmxGBGs8= 17 + github.com/duckdb/duckdb-go-bindings/linux-arm64 v0.1.24 h1:NCAGH7o1RsJv631EQGOqs94ABtmYZO6JjMHkv7GIgG8= 18 + github.com/duckdb/duckdb-go-bindings/linux-arm64 v0.1.24/go.mod h1:kpQSpJmDSSZQ3ikbZR1/8UqecqMeUkWFjFX2xZxlCuI= 19 + github.com/duckdb/duckdb-go-bindings/windows-amd64 v0.1.24 h1:JOupXaHMMu8zLgq7v9uxPjl1CXSJHlISCxopMiqtkzU= 20 + github.com/duckdb/duckdb-go-bindings/windows-amd64 v0.1.24/go.mod h1:wa+egSGXTPS16NPADFCK1yFyt3VSXxUS6Pt2fLnvRPM= 21 + github.com/duckdb/duckdb-go/arrowmapping v0.0.27 h1:w0XKX+EJpAN4XOQlKxSxSKZq/tCVbRfTRBp98jA0q8M= 22 + github.com/duckdb/duckdb-go/arrowmapping v0.0.27/go.mod h1:VkFx49Icor1bbxOPxAU8jRzwL0nTXICOthxVq4KqOqQ= 23 + github.com/duckdb/duckdb-go/mapping v0.0.27 h1:QEta+qPEKmfhd89U8vnm4MVslj1UscmkyJwu8x+OtME= 24 + github.com/duckdb/duckdb-go/mapping v0.0.27/go.mod h1:7C4QWJWG6UOV9b0iWanfF5ML1ivJPX45Kz+VmlvRlTA= 25 + github.com/duckdb/duckdb-go/v2 v2.5.4 h1:+ip+wPCwf7Eu/dXxp19aLCxwpLUaeOy2UV/peBphXK0= 26 + github.com/duckdb/duckdb-go/v2 v2.5.4/go.mod h1:CeobOFmWpf7MTDb+MW08/zIWP8TQ2jbPbMgGo5761tY= 27 + github.com/ggicci/httpin v0.20.2 h1:SmXSM/jg58H2W4+fIcF+6bo4JXQW/f8oeHYHYmwecmk= 28 + github.com/ggicci/httpin v0.20.2/go.mod h1:lQaLWTYNcs4eo8WoESBqqT4fUc9dgdIKeHweZMj17No= 29 + github.com/ggicci/owl v0.8.2 h1:og+lhqpzSMPDdEB+NJfzoAJARP7qCG3f8uUC3xvGukA= 30 + github.com/ggicci/owl v0.8.2/go.mod h1:PHRD57u41vFN5UtFz2SF79yTVoM3HlWpjMiE+ZU2dj4= 31 + github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= 32 + github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 33 + github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 34 + github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 35 + github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 36 + github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 37 + github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= 38 + github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 39 + github.com/google/flatbuffers v25.9.23+incompatible h1:rGZKv+wOb6QPzIdkM2KxhBZCDrA0DeN6DNmRDrqIsQU= 40 + github.com/google/flatbuffers v25.9.23+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= 41 + github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 42 + github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 43 + github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 44 + github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 45 + github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 46 + github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 47 + github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= 48 + github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= 49 + github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= 50 + github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= 51 + github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 52 + github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 53 + github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= 54 + github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 55 + github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= 56 + github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= 57 + github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= 58 + github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= 59 + github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= 60 + github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 61 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 62 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 63 + github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 64 + github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 65 + github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= 66 + github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= 67 + github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= 68 + github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= 69 + golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= 70 + golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= 71 + golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= 72 + golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= 73 + golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 74 + golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 75 + golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 76 + golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 77 + golang.org/x/telemetry v0.0.0-20251208220230-2638a1023523 h1:H52Mhyrc44wBgLTGzq6+0cmuVuF3LURCSXsLMOqfFos= 78 + golang.org/x/telemetry v0.0.0-20251208220230-2638a1023523/go.mod h1:ArQvPJS723nJQietgilmZA+shuB3CZxH1n2iXq9VSfs= 79 + golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= 80 + golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= 81 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= 82 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 83 + gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= 84 + gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= 85 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 86 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+84
internal/config/config.go
··· 1 + package config 2 + 3 + import ( 4 + "log" 5 + "sync" 6 + 7 + "github.com/joho/godotenv" 8 + "github.com/oscar345/keeptrack/pkg/utilities" 9 + ) 10 + 11 + var ( 12 + once sync.Once 13 + config Config 14 + ) 15 + 16 + func Load() Config { 17 + once.Do(func() { 18 + env := Environment(utilities.GetEnvOrDefault("ENVIRONMENT", "dev")) 19 + 20 + var err error 21 + 22 + switch env { 23 + case Development: 24 + err = godotenv.Load("config/development.env") 25 + case Production: 26 + err = godotenv.Load("config/production.env") 27 + case Test: 28 + err = godotenv.Load("config/test.env") 29 + } 30 + 31 + if err != nil { 32 + log.Panicln(err) 33 + } 34 + 35 + if err := godotenv.Load("config/secrets.env"); err != nil { 36 + log.Panicln(err) 37 + } 38 + 39 + config = createConfig(env) 40 + }) 41 + 42 + return config 43 + } 44 + 45 + func createConfig(env Environment) Config { 46 + return Config{ 47 + Environment: env, 48 + 49 + Server: ServerConfig{ 50 + Port: utilities.GetEnvOrDefault("PORT", "3000"), 51 + Host: utilities.GetEnvOrDefault("HOST", "localhost"), 52 + }, 53 + 54 + AppDatabase: DatabaseConfig{ 55 + Path: utilities.GetEnvOrPanic("APP_DATABASE_PATH"), 56 + }, 57 + MusicbrainzDatabase: DatabaseConfig{ 58 + Path: utilities.GetEnvOrPanic("MUSICBRAINZ_DATABASE_PATH"), 59 + }, 60 + ProtoDatabase: DatabaseConfig{ 61 + Path: utilities.GetEnvOrPanic("PROTO_DATABASE_PATH"), 62 + }, 63 + StatisticsDatabase: DatabaseConfig{ 64 + Path: utilities.GetEnvOrPanic("STATISTICS_DATABASE_PATH"), 65 + }, 66 + 67 + Storage: StorageConfig{ 68 + Disk: DiskStorageConfig{ 69 + Path: utilities.GetEnvOrPanic("STORAGE_DISK_PATH"), 70 + }, 71 + }, 72 + 73 + Services: ServicesConfig{ 74 + FanartTV: FanartTVConfig{APIKey: utilities.GetEnvOrPanic("FANART_TV_API_KEY")}, 75 + Spotify: SpotifyConfig{ 76 + OAuthConfig: OAuthConfig{ 77 + ClientID: utilities.GetEnvOrPanic("SPOTIFY_CLIENT_ID"), 78 + ClientSecret: utilities.GetEnvOrPanic("SPOTIFY_CLIENT_SECRET"), 79 + RedirectURL: utilities.GetEnvOrPanic("SPOTIFY_REDIRECT_URL"), 80 + }, 81 + }, 82 + }, 83 + } 84 + }
+57
internal/config/models.go
··· 1 + package config 2 + 3 + type Environment string 4 + 5 + const ( 6 + Development Environment = "dev" 7 + Production Environment = "prod" 8 + Test Environment = "test" 9 + ) 10 + 11 + type Config struct { 12 + Environment Environment 13 + Server ServerConfig 14 + AppDatabase DatabaseConfig 15 + MusicbrainzDatabase DatabaseConfig 16 + ProtoDatabase DatabaseConfig 17 + StatisticsDatabase DatabaseConfig 18 + Services ServicesConfig 19 + Storage StorageConfig 20 + } 21 + 22 + type StorageConfig struct { 23 + URL string 24 + Disk DiskStorageConfig 25 + } 26 + 27 + type DiskStorageConfig struct { 28 + Path string 29 + } 30 + 31 + type ServicesConfig struct { 32 + FanartTV FanartTVConfig 33 + Spotify SpotifyConfig 34 + } 35 + 36 + type ServerConfig struct { 37 + Port string 38 + Host string 39 + } 40 + 41 + type DatabaseConfig struct { 42 + Path string 43 + } 44 + 45 + type FanartTVConfig struct { 46 + APIKey string 47 + } 48 + 49 + type SpotifyConfig struct { 50 + OAuthConfig 51 + } 52 + 53 + type OAuthConfig struct { 54 + ClientID string 55 + ClientSecret string 56 + RedirectURL string 57 + }
+13
internal/filters/catalog.go
··· 1 + package filters 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/oscar345/keeptrack/pkg/pagination" 7 + ) 8 + 9 + type Count struct { 10 + Pagination pagination.Filter 11 + From time.Time 12 + To time.Time 13 + }
+103
internal/image/artist.go
··· 1 + package image 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + "net/url" 11 + "strconv" 12 + 13 + "github.com/oscar345/keeptrack/pkg/enum" 14 + ) 15 + 16 + type ArtistImageFetcher interface { 17 + ListImages(ctx context.Context, mbid string) ([]Image, error) 18 + FetchImage(ctx context.Context, image Image) ([]byte, error) 19 + } 20 + 21 + var _ ArtistImageFetcher = (*ArtistImageFetcherFanArtTV)(nil) 22 + 23 + type ArtistImageFetcherFanArtTV struct { 24 + apikey string 25 + client *http.Client 26 + } 27 + 28 + func NewArtistImageFetcherFanArtTV(apikey string) *ArtistImageFetcherFanArtTV { 29 + return &ArtistImageFetcherFanArtTV{ 30 + apikey: apikey, 31 + client: http.DefaultClient, 32 + } 33 + } 34 + 35 + func (f *ArtistImageFetcherFanArtTV) ListImages(ctx context.Context, mbid string) ([]Image, error) { 36 + type artistThumb struct { 37 + Url string `json:"url"` 38 + Width string `json:"width"` 39 + Height string `json:"height"` 40 + } 41 + 42 + type response struct { 43 + ArtistThumbs []artistThumb `json:"artistthumb"` 44 + } 45 + 46 + var ( 47 + data response 48 + images []Image 49 + ) 50 + 51 + fanartURL := fmt.Sprintf("https://webservice.fanart.tv/v3.2/music/%s?api_key=%s", mbid, f.apikey) 52 + 53 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fanartURL, nil) 54 + if err != nil { 55 + return images, err 56 + } 57 + 58 + resp, err := f.client.Do(req) 59 + if err != nil { 60 + return images, err 61 + } 62 + defer resp.Body.Close() 63 + 64 + body, err := io.ReadAll(resp.Body) 65 + if err != nil { 66 + return images, err 67 + } 68 + 69 + if resp.StatusCode != http.StatusOK { 70 + return images, fmt.Errorf("fanart.tv returned status code %d", resp.StatusCode) 71 + } 72 + 73 + if err := json.Unmarshal(body, &data); err != nil { 74 + return images, err 75 + } 76 + 77 + images = enum.Map(data.ArtistThumbs, func(thumb artistThumb) Image { 78 + imageUrl, _ := url.Parse(thumb.Url) 79 + width, err := strconv.Atoi(thumb.Width) 80 + if err != nil { 81 + width = 0 82 + } 83 + height, err := strconv.Atoi(thumb.Height) 84 + if err != nil { 85 + height = 0 86 + } 87 + return Image{URL: imageUrl, Width: width, Height: height} 88 + }) 89 + 90 + return images, nil 91 + } 92 + 93 + func (f *ArtistImageFetcherFanArtTV) ChooseImage(images []Image) (Image, error) { 94 + if len(images) == 0 { 95 + return Image{}, errors.New("no images to choose from") 96 + } 97 + 98 + return images[0], nil 99 + } 100 + 101 + func (f *ArtistImageFetcherFanArtTV) FetchImage(ctx context.Context, image Image) ([]byte, error) { 102 + return fetchImageGeneral(ctx, f.client, image) 103 + }
+34
internal/image/image.go
··· 1 + package image 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "net/http" 8 + "net/url" 9 + ) 10 + 11 + type Image struct { 12 + URL *url.URL 13 + Width int 14 + Height int 15 + } 16 + 17 + func fetchImageGeneral(ctx context.Context, client *http.Client, image Image) ([]byte, error) { 18 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, image.URL.String(), nil) 19 + if err != nil { 20 + return nil, err 21 + } 22 + 23 + resp, err := client.Do(req) 24 + if err != nil { 25 + return nil, err 26 + } 27 + defer resp.Body.Close() 28 + 29 + if resp.StatusCode != http.StatusOK { 30 + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 31 + } 32 + 33 + return io.ReadAll(resp.Body) 34 + }
+18
internal/models/catalog.go
··· 1 + package models 2 + 3 + type Artist struct { 4 + MBID string 5 + Name string 6 + Count int 7 + ImageURL string 8 + } 9 + 10 + type Recording struct { 11 + MBID string 12 + Name string 13 + } 14 + 15 + type Release struct { 16 + MBID string 17 + Name string 18 + }
+6
internal/models/scrobble.go
··· 1 + package models 2 + 3 + type CountMBID struct { 4 + Count int 5 + MBID string 6 + }
+61
internal/repo/db/artist.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + 8 + "github.com/oscar345/keeptrack/internal/models" 9 + "github.com/oscar345/keeptrack/internal/repo" 10 + "github.com/oscar345/keeptrack/pkg/database" 11 + "github.com/oscar345/keeptrack/pkg/enum" 12 + ) 13 + 14 + var _ repo.ArtistRepo = (*ArtistRepoDB)(nil) 15 + 16 + type ArtistRepoDB struct { 17 + db *sql.DB // sqlite 18 + } 19 + 20 + func NewArtistRepoDB(db *sql.DB) *ArtistRepoDB { 21 + return &ArtistRepoDB{db: db} 22 + } 23 + 24 + func (repo *ArtistRepoDB) ListByIDs(ctx context.Context, mbids []string) (map[string]models.Artist, error) { 25 + var statement = /*sql*/ ` 26 + SELECT artist.gid, artist.name FROM artist WHERE artist.gid IN (%s); 27 + ` 28 + statement = fmt.Sprintf(statement, database.GeneratePlaceholders(len(mbids))) 29 + 30 + args := []any{mbids} 31 + args = database.ExpandArgs(args) 32 + 33 + items, err := database.QueryMany(ctx, repo.db, statement, args, func(r *sql.Rows) (models.Artist, error) { 34 + var artist models.Artist 35 + if err := r.Scan(&artist.MBID, &artist.Name); err != nil { 36 + return models.Artist{}, err 37 + } 38 + return artist, nil 39 + }) 40 + 41 + if err != nil { 42 + return nil, err 43 + } 44 + 45 + ordered := enum.OrderByKeys(items, mbids, func(item models.Artist) string { return item.MBID }) 46 + return enum.ZipMap(mbids, ordered), nil 47 + } 48 + 49 + func (repo *ArtistRepoDB) GetByID(ctx context.Context, mbid string) (models.Artist, error) { 50 + const statement = /*sql*/ ` 51 + SELECT artist.mbid, artist.name FROM artist WHERE artist.mbid = ?; 52 + ` 53 + 54 + return database.QueryOne(ctx, repo.db, statement, []any{mbid}, func(r *sql.Rows) (models.Artist, error) { 55 + var artist models.Artist 56 + if err := r.Scan(&artist.MBID, &artist.Name); err != nil { 57 + return models.Artist{}, err 58 + } 59 + return artist, nil 60 + }) 61 + }
+79
internal/repo/db/artist_scrobble.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + 8 + "github.com/oscar345/keeptrack/internal/filters" 9 + "github.com/oscar345/keeptrack/internal/models" 10 + "github.com/oscar345/keeptrack/internal/repo" 11 + "github.com/oscar345/keeptrack/pkg/database" 12 + "github.com/oscar345/keeptrack/pkg/pagination" 13 + ) 14 + 15 + var _ repo.ArtistScrobbleRepo = (*ArtistScrobbleRepoDB)(nil) 16 + 17 + type ArtistScrobbleRepoDB struct { 18 + duck *sql.DB // duckdb 19 + } 20 + 21 + func NewArtistScrobbleRepoDB(duck *sql.DB) *ArtistScrobbleRepoDB { 22 + return &ArtistScrobbleRepoDB{duck: duck} 23 + } 24 + 25 + func (repo *ArtistScrobbleRepoDB) ListCountByUser( 26 + ctx context.Context, userID int, filter filters.Count, 27 + ) ([]models.CountMBID, pagination.Page, error) { 28 + var statement = /*sql*/ ` 29 + WITH scrobbles AS ( 30 + SELECT 31 + recording_mbid__artist_mbid.artist_mbid as mbid, 32 + count(scrobble) as amount 33 + FROM scrobble 34 + INNER JOIN recording_mbid__artist_mbid 35 + ON recording_mbid__artist_mbid.recording_mbid = scrobble.recording_mbid 36 + WHERE scrobble.user_id = ? %s 37 + GROUP BY recording_mbid__artist_mbid.artist_mbid 38 + ) 39 + SELECT 40 + scrobbles.mbid, 41 + scrobbles.amount, 42 + COUNT(*) OVER () AS total 43 + FROM scrobbles 44 + ORDER BY amount DESC 45 + OFFSET ? 46 + LIMIT ?; 47 + ` 48 + 49 + where := "" 50 + args := []any{userID} 51 + 52 + if !filter.From.IsZero() { 53 + where += " AND scrobble.played_at >= ?" 54 + args = append(args, filter.From.UnixMicro()) 55 + } 56 + 57 + if !filter.To.IsZero() { 58 + where += " AND scrobble.played_at <= ?" 59 + args = append(args, filter.To.UnixMicro()) 60 + } 61 + 62 + statement = fmt.Sprintf(statement, where) 63 + args = append(args, filter.Pagination.Offset(), filter.Pagination.Limit()) 64 + 65 + var total int 66 + items, err := database.QueryMany(ctx, repo.duck, statement, args, func(r *sql.Rows) (models.CountMBID, error) { 67 + var model models.CountMBID 68 + if err := r.Scan(&model.MBID, &model.Count, &total); err != nil { 69 + return model, err 70 + } 71 + return model, nil 72 + }) 73 + 74 + if err != nil { 75 + return nil, pagination.Page{}, err 76 + } 77 + 78 + return items, pagination.New(filter.Pagination, total), nil 79 + }
+34
internal/repo/db/recording.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + 7 + "github.com/oscar345/keeptrack/internal/models" 8 + "github.com/oscar345/keeptrack/internal/repo" 9 + "github.com/oscar345/keeptrack/pkg/database" 10 + ) 11 + 12 + var _ repo.RecordingRepo = (*RecordingRepoDB)(nil) 13 + 14 + type RecordingRepoDB struct { 15 + db *sql.DB 16 + } 17 + 18 + func NewRecordingRepoDB(db *sql.DB) *RecordingRepoDB { 19 + return &RecordingRepoDB{db: db} 20 + } 21 + 22 + func (repo *RecordingRepoDB) GetByID(ctx context.Context, mbid string) (models.Recording, error) { 23 + const statement = /*sql*/ ` 24 + SELECT recording.name, recording.mbid FROM recording WHERE recording.mbid = ?; 25 + ` 26 + 27 + return database.QueryOne(ctx, repo.db, statement, []any{mbid}, func(r *sql.Rows) (models.Recording, error) { 28 + var model models.Recording 29 + if err := r.Scan(&model.Name, &model.MBID); err != nil { 30 + return model, err 31 + } 32 + return model, nil 33 + }) 34 + }
+22
internal/repo/repo.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + 6 + "github.com/oscar345/keeptrack/internal/filters" 7 + "github.com/oscar345/keeptrack/internal/models" 8 + "github.com/oscar345/keeptrack/pkg/pagination" 9 + ) 10 + 11 + type ArtistRepo interface { 12 + ListByIDs(ctx context.Context, mbids []string) (map[string]models.Artist, error) 13 + GetByID(ctx context.Context, mbid string) (models.Artist, error) 14 + } 15 + 16 + type ArtistScrobbleRepo interface { 17 + ListCountByUser(ctx context.Context, userID int, filter filters.Count) ([]models.CountMBID, pagination.Page, error) 18 + } 19 + 20 + type RecordingRepo interface { 21 + GetByID(ctx context.Context, mbid string) (models.Recording, error) 22 + }
+58
internal/server/server.go
··· 1 + package server 2 + 3 + import ( 4 + "database/sql" 5 + "log" 6 + "net/http" 7 + 8 + _ "github.com/duckdb/duckdb-go/v2" 9 + _ "github.com/mattn/go-sqlite3" 10 + "github.com/oscar345/keeptrack/internal/config" 11 + "github.com/oscar345/keeptrack/internal/image" 12 + "github.com/oscar345/keeptrack/internal/repo/db" 13 + "github.com/oscar345/keeptrack/internal/web/router" 14 + "github.com/oscar345/keeptrack/pkg/storage" 15 + "github.com/oscar345/keeptrack/pkg/utilities" 16 + ) 17 + 18 + type Server struct { 19 + address string 20 + config *config.Config 21 + } 22 + 23 + func New() *Server { 24 + cfg := config.Load() 25 + 26 + return &Server{ 27 + config: &cfg, 28 + address: utilities.HostAndPortToAddress(cfg.Server.Host, cfg.Server.Port), 29 + } 30 + } 31 + 32 + func (s *Server) Start() error { 33 + musicbrainzDB := utilities.OpenDatabase("sqlite3", s.config.MusicbrainzDatabase.Path, func(db *sql.DB) { 34 + db.Exec("PRAGMA journal_mode = WAL") 35 + db.Exec("PRAGMA synchronous = NORMAL") 36 + }) 37 + defer musicbrainzDB.Close() 38 + 39 + statisticsDB := utilities.OpenDatabase("duckdb", s.config.StatisticsDatabase.Path, func(d *sql.DB) { 40 + d.Exec("SET threads TO 4") 41 + }) 42 + defer statisticsDB.Close() 43 + 44 + server := http.Server{ 45 + Addr: s.address, 46 + Handler: router.New( 47 + db.NewArtistRepoDB(musicbrainzDB), 48 + db.NewArtistScrobbleRepoDB(statisticsDB), 49 + image.NewArtistImageFetcherFanArtTV(s.config.Services.FanartTV.APIKey), 50 + db.NewRecordingRepoDB(musicbrainzDB), 51 + storage.NewDiskStorage("/public", s.config.Storage.Disk.Path), 52 + s.config, 53 + ), 54 + } 55 + log.Printf("Listening on http://%s\n", s.address) 56 + 57 + return server.ListenAndServe() 58 + }
+130
internal/services/catalog.go
··· 1 + package services 2 + 3 + import ( 4 + "context" 5 + "log" 6 + "path" 7 + "sync" 8 + 9 + "github.com/oscar345/keeptrack/internal/filters" 10 + "github.com/oscar345/keeptrack/internal/image" 11 + "github.com/oscar345/keeptrack/internal/models" 12 + "github.com/oscar345/keeptrack/internal/repo" 13 + "github.com/oscar345/keeptrack/pkg/enum" 14 + "github.com/oscar345/keeptrack/pkg/pagination" 15 + storagesvc "github.com/oscar345/keeptrack/pkg/storage" 16 + "golang.org/x/sync/errgroup" 17 + ) 18 + 19 + type ArtistService struct { 20 + artistRepo repo.ArtistRepo 21 + artistScrobbleRepo repo.ArtistScrobbleRepo 22 + artistImageFetcher image.ArtistImageFetcher 23 + storage storagesvc.Storage 24 + } 25 + 26 + func NewArtistService( 27 + artistRepo repo.ArtistRepo, 28 + artistScrobbleRepo repo.ArtistScrobbleRepo, 29 + artistImageFetcher image.ArtistImageFetcher, 30 + storage storagesvc.Storage, 31 + ) ArtistService { 32 + return ArtistService{ 33 + artistRepo: artistRepo, 34 + artistScrobbleRepo: artistScrobbleRepo, 35 + artistImageFetcher: artistImageFetcher, 36 + storage: storage, 37 + } 38 + } 39 + 40 + func (as *ArtistService) ListArtistByUserCount( 41 + ctx context.Context, id int, filter filters.Count, 42 + ) ([]models.Artist, pagination.Page, error) { 43 + counts, page, err := as.artistScrobbleRepo.ListCountByUser(ctx, id, filter) 44 + if err != nil { 45 + return nil, page, err 46 + } 47 + 48 + mbids := enum.Map(counts, func(item models.CountMBID) string { 49 + return item.MBID 50 + }) 51 + 52 + artists, err := as.artistRepo.ListByIDs(ctx, mbids) 53 + if err != nil { 54 + return nil, page, err 55 + } 56 + 57 + images := as.ListArtistImages(ctx, mbids) 58 + 59 + items := enum.Map(counts, func(item models.CountMBID) models.Artist { 60 + artist := artists[item.MBID] 61 + image := images[item.MBID] 62 + return models.Artist{ 63 + MBID: item.MBID, 64 + Name: artist.Name, 65 + Count: item.Count, 66 + ImageURL: image, 67 + } 68 + }) 69 + 70 + return items, page, nil 71 + } 72 + 73 + func (as *ArtistService) ListArtistImages(ctx context.Context, mbids []string) map[string]string { 74 + g, ctx := errgroup.WithContext(ctx) 75 + 76 + var mu sync.Mutex 77 + result := make(map[string]string, len(mbids)) 78 + 79 + for _, mbid := range mbids { 80 + mbid := mbid 81 + g.Go(func() error { 82 + if url, err := getArtistImage(ctx, as.artistImageFetcher, as.storage, mbid); err == nil { 83 + mu.Lock() 84 + result[mbid] = url 85 + mu.Unlock() 86 + } else { 87 + log.Printf("failed to get artist image for %s: %v", mbid, err) 88 + } 89 + return nil 90 + }) 91 + } 92 + 93 + _ = g.Wait() 94 + return result 95 + } 96 + 97 + func createArtistImageFilename(mbid string) string { 98 + return path.Join("images", "artists", mbid) 99 + } 100 + 101 + func getArtistImage( 102 + ctx context.Context, fetcher image.ArtistImageFetcher, storage storagesvc.Storage, mbid string, 103 + ) (string, error) { 104 + filename := createArtistImageFilename(mbid) 105 + 106 + if storage.Exists(filename) { 107 + return storage.GetURL(filename), nil 108 + } 109 + 110 + images, err := fetcher.ListImages(ctx, mbid) 111 + if err != nil { 112 + return "", err 113 + } 114 + 115 + if len(images) == 0 { 116 + return "", nil 117 + } 118 + image := images[0] 119 + 120 + content, err := fetcher.FetchImage(ctx, image) 121 + if err != nil { 122 + return "", err 123 + } 124 + 125 + if err := storage.Save(content, filename); err != nil { 126 + return "", err 127 + } 128 + 129 + return storage.GetURL(filename), nil 130 + }
+45
internal/web/handlers/index.go
··· 1 + package handlers 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strconv" 7 + 8 + "github.com/ggicci/httpin" 9 + "github.com/go-chi/chi/v5" 10 + "github.com/oscar345/keeptrack/internal/services" 11 + "github.com/oscar345/keeptrack/internal/web/requests" 12 + "github.com/oscar345/keeptrack/internal/web/responses" 13 + "github.com/oscar345/keeptrack/pkg/enum" 14 + ) 15 + 16 + type IndexHandler struct { 17 + artistService services.ArtistService 18 + } 19 + 20 + func NewIndexHandler(artistService services.ArtistService) IndexHandler { 21 + return IndexHandler{ 22 + artistService: artistService, 23 + } 24 + } 25 + 26 + func (handler *IndexHandler) Index(w http.ResponseWriter, r *http.Request) { 27 + id, err := strconv.Atoi(chi.URLParam(r, "id")) 28 + if err != nil { 29 + http.Error(w, err.Error(), http.StatusBadRequest) 30 + return 31 + } 32 + 33 + filter, err := r.Context().Value(httpin.Input).(*requests.Count).Filter() 34 + 35 + if err != nil { 36 + http.Error(w, err.Error(), http.StatusBadRequest) 37 + return 38 + } 39 + 40 + items, page, err := handler.artistService.ListArtistByUserCount(r.Context(), id, filter) 41 + artists := enum.Map(items, responses.NewArtistFromModel) 42 + 43 + w.Header().Set("Content-Type", "application/json") 44 + json.NewEncoder(w).Encode(responses.Paginate(page, artists)) 45 + }
+50
internal/web/requests/catalog.go
··· 1 + package requests 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/oscar345/keeptrack/internal/filters" 7 + "github.com/oscar345/keeptrack/pkg/pagination" 8 + ) 9 + 10 + type Pagination struct { 11 + Page int `json:"page" in:"query=page"` 12 + Size int `json:"size" in:"query=size"` 13 + } 14 + 15 + type Count struct { 16 + Pagination `json:"pagination"` 17 + From string `json:"from" in:"query=from"` 18 + To string `json:"to" in:"query=to"` 19 + } 20 + 21 + func ParseTime(value string, dest *time.Time) error { 22 + if value == "" { 23 + return nil 24 + } 25 + parsed, err := time.Parse(time.RFC3339, value) 26 + if err != nil { 27 + return err 28 + } 29 + dest = &parsed 30 + return nil 31 + } 32 + 33 + func (req Count) Filter() (filters.Count, error) { 34 + var filter filters.Count 35 + 36 + if err := ParseTime(req.From, &filter.From); err != nil { 37 + return filter, err 38 + } 39 + 40 + if err := ParseTime(req.To, &filter.To); err != nil { 41 + return filter, err 42 + } 43 + 44 + filter.Pagination = pagination.Filter{ 45 + Page: req.Pagination.Page, 46 + Size: req.Pagination.Size, 47 + } 48 + 49 + return filter, nil 50 + }
+62
internal/web/responses/catalog.go
··· 1 + package responses 2 + 3 + import ( 4 + "github.com/oscar345/keeptrack/internal/models" 5 + "github.com/oscar345/keeptrack/pkg/pagination" 6 + ) 7 + 8 + type Artist struct { 9 + MBID string `json:"mbid"` 10 + Name string `json:"name"` 11 + Count int `json:"count"` 12 + ImageURL string `json:"image_url"` 13 + } 14 + 15 + func NewArtistFromModel(model models.Artist) Artist { 16 + return Artist{ 17 + MBID: model.MBID, 18 + Name: model.Name, 19 + Count: model.Count, 20 + ImageURL: model.ImageURL, 21 + } 22 + } 23 + 24 + type Recording struct { 25 + MBID string `json:"mbid"` 26 + Name string `json:"name"` 27 + } 28 + 29 + func NewRecordingFromModel(model models.Recording) Recording { 30 + return Recording{ 31 + MBID: model.MBID, 32 + Name: model.Name, 33 + } 34 + } 35 + 36 + type Release struct { 37 + MBID string `json:"mbid"` 38 + Name string `json:"name"` 39 + } 40 + 41 + func NewReleaseFromModel(model models.Release) Release { 42 + return Release{ 43 + MBID: model.MBID, 44 + Name: model.Name, 45 + } 46 + } 47 + 48 + type Page[T any] struct { 49 + Page int `json:"page"` 50 + Size int `json:"size"` 51 + Total int `json:"total"` 52 + Items []T `json:"items"` 53 + } 54 + 55 + func Paginate[T any](page pagination.Page, items []T) Page[T] { 56 + return Page[T]{ 57 + Page: page.Page, 58 + Size: page.Size, 59 + Total: page.Total, 60 + Items: items, 61 + } 62 + }
+57
internal/web/router/router.go
··· 1 + package router 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/ggicci/httpin" 7 + "github.com/go-chi/chi/v5" 8 + "github.com/go-chi/chi/v5/middleware" 9 + "github.com/oscar345/keeptrack/internal/config" 10 + "github.com/oscar345/keeptrack/internal/image" 11 + "github.com/oscar345/keeptrack/internal/repo" 12 + "github.com/oscar345/keeptrack/internal/services" 13 + "github.com/oscar345/keeptrack/internal/web/handlers" 14 + "github.com/oscar345/keeptrack/internal/web/requests" 15 + storagesvc "github.com/oscar345/keeptrack/pkg/storage" 16 + ) 17 + 18 + type Server struct { 19 + artistService services.ArtistService 20 + config *config.Config 21 + } 22 + 23 + func New( 24 + artistRepo repo.ArtistRepo, 25 + artistScrobbleRepo repo.ArtistScrobbleRepo, 26 + artistImageFetcher image.ArtistImageFetcher, 27 + recordingRepo repo.RecordingRepo, 28 + storage storagesvc.Storage, 29 + config *config.Config, 30 + ) http.Handler { 31 + server := &Server{ 32 + artistService: services.NewArtistService( 33 + artistRepo, artistScrobbleRepo, artistImageFetcher, storage, 34 + ), 35 + config: config, 36 + } 37 + 38 + r := chi.NewRouter() 39 + 40 + r.Use( 41 + middleware.Logger, 42 + middleware.RequestID, 43 + middleware.CleanPath, 44 + ) 45 + 46 + r.Group(server.index()) 47 + 48 + return r 49 + } 50 + 51 + func (s *Server) index() func(chi.Router) { 52 + handler := handlers.NewIndexHandler(s.artistService) 53 + 54 + return func(r chi.Router) { 55 + r.With(httpin.NewInput(requests.Count{})).Get("/{id}", handler.Index) 56 + } 57 + }
+1
pkg/bridge/bridge.go
··· 1 + package bridge
+27
pkg/database/insert.go
··· 1 + package database 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "maps" 8 + "slices" 9 + "strings" 10 + ) 11 + 12 + func Insert(ctx context.Context, db *sql.DB, table string, values map[string]any) (int, error) { 13 + var id int 14 + statement := /*sql*/ ` 15 + INSERT INTO %s (%s) VALUES (%s) RETURNING id 16 + ` 17 + 18 + columns := slices.Collect(maps.Keys(values)) 19 + placeholders := strings.Join(slices.Repeat([]string{"?"}, len(columns)), ", ") 20 + statement = fmt.Sprintf(statement, table, columns, placeholders) 21 + args := slices.Collect(maps.Values(values)) 22 + 23 + return QueryOne(ctx, db, statement, args, func(r *sql.Rows) (int, error) { 24 + err := r.Scan(&id) 25 + return id, err 26 + }) 27 + }
+79
pkg/database/query.go
··· 1 + package database 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "strings" 7 + ) 8 + 9 + // Expands inner arrays in the slice of args, so it can be used in a query with variadic arguments. 10 + func ExpandArgs(args []any) []any { 11 + out := make([]any, 0) 12 + for _, arg := range args { 13 + switch v := arg.(type) { 14 + case []string: 15 + for _, s := range v { 16 + out = append(out, s) 17 + } 18 + case []int: 19 + for _, i := range v { 20 + out = append(out, i) 21 + } 22 + default: 23 + out = append(out, arg) 24 + } 25 + } 26 + return out 27 + } 28 + 29 + // QueryMany executes a query and returns a slice of results. It is a small helper function reducing 30 + // the boilerplate code needed to execute a query and scan the results. The scanFunc is used to scan 31 + // each row into a result. 32 + func QueryMany[T any]( 33 + ctx context.Context, db *sql.DB, query string, args []any, scanFunc func(*sql.Rows) (T, error), 34 + ) ([]T, error) { 35 + rows, err := db.QueryContext(ctx, query, args...) 36 + if err != nil { 37 + return nil, err 38 + } 39 + defer rows.Close() 40 + 41 + var results []T 42 + for rows.Next() { 43 + result, err := scanFunc(rows) 44 + if err != nil { 45 + return nil, err 46 + } 47 + results = append(results, result) 48 + } 49 + return results, nil 50 + } 51 + 52 + // QueryOne executes a query and returns a single result. It is a small helper function reducing the 53 + // boilerplate code needed to execute a query and scan the results. The scanFunc is used to scan 54 + // the row into a result. 55 + func QueryOne[T any]( 56 + ctx context.Context, db *sql.DB, query string, args []any, scanFunc func(*sql.Rows) (T, error), 57 + ) (T, error) { 58 + rows, err := db.QueryContext(ctx, query, args...) 59 + if err != nil { 60 + return *new(T), err 61 + } 62 + defer rows.Close() 63 + 64 + if !rows.Next() { 65 + return *new(T), nil 66 + } 67 + 68 + return scanFunc(rows) 69 + } 70 + 71 + // Generates a string of placeholders for a SQL query. For example, if count is 3, the result will 72 + // be "?, ?, ?". 73 + func GeneratePlaceholders(count int) string { 74 + placeholders := make([]string, count) 75 + for i := range placeholders { 76 + placeholders[i] = "?" 77 + } 78 + return strings.Join(placeholders, ", ") 79 + }
+159
pkg/enum/enum.go
··· 1 + package enum 2 + 3 + import ( 4 + "fmt" 5 + "sort" 6 + ) 7 + 8 + // A functional function that transforms items using a mapping function. 9 + func Map[T any, U any](items []T, fn func(T) U) []U { 10 + var result []U 11 + 12 + for _, item := range items { 13 + result = append(result, fn(item)) 14 + } 15 + 16 + return result 17 + } 18 + 19 + // A functional function that reduces items to a single value using a reduction function. 20 + func Reduce[T any, U any](items []T, fn func(U, T) U, initial U) U { 21 + result := initial 22 + 23 + for _, item := range items { 24 + result = fn(result, item) 25 + } 26 + 27 + return result 28 + } 29 + 30 + // A functional function that removes duplicate items based on a key function. The returned slice 31 + // will contain only the items that are unique according to the returned value of the key function. 32 + func UniqueBy[T any, K comparable](items []T, fn func(T) K) []T { 33 + keys := make(map[K]struct{}) 34 + var result []T 35 + 36 + for _, item := range items { 37 + key := fn(item) 38 + if _, exists := keys[key]; exists { 39 + continue 40 + } 41 + keys[key] = struct{}{} 42 + result = append(result, item) 43 + } 44 + 45 + return result 46 + } 47 + 48 + // GroupBy groups elements of a slice by a key derived from each element. 49 + // The keyer function extracts the key from each element. 50 + func GroupBy[T any, K comparable](items []T, keyer func(T) K) map[K][]T { 51 + groups := make(map[K][]T) 52 + for _, item := range items { 53 + key := keyer(item) 54 + groups[key] = append(groups[key], item) 55 + } 56 + return groups 57 + } 58 + 59 + // GroupByWithValue groups elements of a slice by a key derived from each element, 60 + // and maps each element to a value derived from the element. 61 + func GroupByWithValue[T any, K comparable, V any](items []T, keyer func(T) K, valuer func(T) V) map[K][]V { 62 + groups := make(map[K][]V) 63 + for _, item := range items { 64 + key := keyer(item) 65 + value := valuer(item) 66 + groups[key] = append(groups[key], value) 67 + } 68 + return groups 69 + } 70 + 71 + // Combine two slices into one map, where the first slice should have unique values that can be 72 + // used as keys. If the slices are not the same length, the extra values will be ignored 73 + func ZipMap[K comparable, T any](keys []K, values []T) map[K]T { 74 + result := make(map[K]T) 75 + for i, key := range keys { 76 + if i < len(values) { 77 + result[key] = values[i] 78 + } 79 + } 80 + return result 81 + } 82 + 83 + // Combine two slices into one map, where the first slice should have unique values that can be 84 + // used as keys 85 + func ZipMapSafe[K comparable, T any](keys []K, values []T) (map[K]T, error) { 86 + if len(keys) != len(values) { 87 + return nil, fmt.Errorf("keys and values must have the same length") 88 + } 89 + 90 + result := make(map[K]T) 91 + for i, key := range keys { 92 + if i < len(values) { 93 + result[key] = values[i] 94 + } 95 + } 96 + return result, nil 97 + } 98 + 99 + // Create a slice of values from values inside the given slice that match the predicate 100 + func Filter[T any](items []T, predicate func(T) bool) []T { 101 + var result []T 102 + for _, item := range items { 103 + if predicate(item) { 104 + result = append(result, item) 105 + } 106 + } 107 + return result 108 + } 109 + 110 + // Return the first value from the values inside the given slice that match the predicate, if no 111 + // values can be found, it will return an empty value with a false boolean 112 + func Find[T any](items []T, predicate func(T) bool) (T, bool) { 113 + for _, item := range items { 114 + if predicate(item) { 115 + return item, true 116 + } 117 + } 118 + var zero T 119 + return zero, false 120 + } 121 + 122 + func Flatten[T any](items [][]T) []T { 123 + var result []T 124 + for _, item := range items { 125 + result = append(result, item...) 126 + } 127 + return result 128 + } 129 + 130 + // OrderByKeys reorders items so that their keys appear in the same order as inputKeys. Items whose 131 + // key is not present in inputKeys are placed at the end (while preserving their relative order). 132 + func OrderByKeys[T any, K comparable](items []T, keys []K, keyFn func(T) K) []T { 133 + if len(items) == 0 || len(keys) == 0 { 134 + return items 135 + } 136 + 137 + pos := make(map[K]int, len(keys)) 138 + for i, k := range keys { 139 + pos[k] = i 140 + } 141 + 142 + const notFound = int(^uint(0) >> 1) // max int 143 + 144 + sort.SliceStable(items, func(i, j int) bool { 145 + pi, ok := pos[keyFn(items[i])] 146 + if !ok { 147 + pi = notFound 148 + } 149 + 150 + pj, ok := pos[keyFn(items[j])] 151 + if !ok { 152 + pj = notFound 153 + } 154 + 155 + return pi < pj 156 + }) 157 + 158 + return items 159 + }
+48
pkg/pagination/pagination.go
··· 1 + package pagination 2 + 3 + type Page struct { 4 + Total int 5 + Page int 6 + Size int 7 + } 8 + 9 + func New(filter Filter, total int) Page { 10 + return Page{ 11 + Total: total, 12 + Page: filter.page(), 13 + Size: filter.size(), 14 + } 15 + } 16 + 17 + type Filter struct { 18 + Page int 19 + Size int 20 + } 21 + 22 + func (f Filter) page() int { 23 + if f.Page <= 0 { 24 + return 1 25 + } 26 + 27 + return f.Page 28 + } 29 + 30 + func (f Filter) Offset() int { 31 + return (f.page() - 1) * f.size() 32 + } 33 + 34 + func (f Filter) Limit() int { 35 + return f.size() 36 + } 37 + 38 + func (f Filter) size() int { 39 + if f.Size == 0 { 40 + return 25 41 + } 42 + 43 + if f.Size > 100 { 44 + return 100 45 + } 46 + 47 + return f.Size 48 + }
+62
pkg/storage/storage.go
··· 1 + package storage 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path" 7 + ) 8 + 9 + type Storage interface { 10 + Save(data []byte, name string) error 11 + Exists(name string) bool 12 + GetURL(name string) string 13 + Delete(name string) error 14 + GetPath(name string) string 15 + GetBaseURL(name string) string 16 + } 17 + 18 + var _ Storage = (*DiskStorage)(nil) 19 + 20 + type DiskStorage struct { 21 + BaseURL string 22 + Path string 23 + } 24 + 25 + func NewDiskStorage(baseURL, path string) *DiskStorage { 26 + return &DiskStorage{ 27 + BaseURL: baseURL, 28 + Path: path, 29 + } 30 + } 31 + 32 + func (s *DiskStorage) Save(data []byte, name string) error { 33 + newpath := path.Join(s.Path, name) 34 + if err := os.MkdirAll(path.Dir(newpath), 0755); err != nil { 35 + return err 36 + } 37 + return os.WriteFile(newpath, data, 0644) 38 + } 39 + 40 + func (s *DiskStorage) Exists(name string) bool { 41 + _, err := os.Stat(path.Join(s.Path, name)) 42 + return err == nil 43 + } 44 + 45 + func (s *DiskStorage) GetURL(name string) string { 46 + return s.BaseURL + "/" + name 47 + } 48 + 49 + func (s *DiskStorage) Delete(name string) error { 50 + if !s.Exists(name) { 51 + return fmt.Errorf("file %s not found", name) 52 + } 53 + return os.Remove(path.Join(s.Path, name)) 54 + } 55 + 56 + func (s *DiskStorage) GetPath(name string) string { 57 + return path.Join(s.Path, name) 58 + } 59 + 60 + func (s *DiskStorage) GetBaseURL(name string) string { 61 + return s.BaseURL 62 + }
+74
pkg/utilities/db.go
··· 1 + package utilities 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "log" 7 + "os" 8 + "path/filepath" 9 + ) 10 + 11 + // For this application, we use multiple database that do have to interact with each other. This 12 + // function opens the databases with correct names and paths to the database. The databases will be 13 + // opened with WAL mode and normal synchronous mode. 14 + func OpenDatabaseWithAttachments(path string, attachments map[string]string) (*sql.DB, error) { 15 + statements := make(map[string]string) 16 + 17 + for name, dbPath := range attachments { 18 + fullpath, err := filepath.Abs(dbPath) 19 + 20 + if err != nil { 21 + return nil, err 22 + } 23 + 24 + statements[name] = fmt.Sprintf(`ATTACH DATABASE '%s' AS %s`, fullpath, name) 25 + } 26 + 27 + fullpath, err := filepath.Abs(path) 28 + if err != nil { 29 + return nil, err 30 + } 31 + 32 + if _, err := os.Stat(fullpath); err != nil { 33 + return nil, err 34 + } 35 + 36 + db, err := sql.Open("sqlite3", fullpath) 37 + if err != nil { 38 + return nil, err 39 + } 40 + 41 + _, err = db.Exec("PRAGMA journal_mode = WAL;") 42 + if err != nil { 43 + return nil, err 44 + } 45 + 46 + _, err = db.Exec("PRAGMA synchronous = NORMAL;") 47 + if err != nil { 48 + return nil, err 49 + } 50 + 51 + for name, statement := range statements { 52 + db.Exec(statement) 53 + db.Exec(fmt.Sprintf("PRAGMA %s.journal_mode = WAL;", name)) 54 + db.Exec(fmt.Sprintf("PRAGMA %s.synchronous = NORMAL;", name)) 55 + } 56 + 57 + db.Exec("PRAGMA optimize;") 58 + 59 + return db, nil 60 + } 61 + 62 + // Opens a database connection, otherwise fails. Also a setup function is needed for the database to 63 + // set connection based settings. 64 + func OpenDatabase(driver string, dsn string, setupfunc func(*sql.DB)) *sql.DB { 65 + db, err := sql.Open(driver, dsn) 66 + 67 + if err != nil { 68 + log.Panicln(err) 69 + } 70 + 71 + setupfunc(db) 72 + 73 + return db 74 + }
+34
pkg/utilities/utilities.go
··· 1 + package utilities 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "os" 7 + ) 8 + 9 + func GetEnvOrDefault(key string, defaultValue string) string { 10 + if value, ok := os.LookupEnv(key); ok { 11 + return value 12 + } 13 + return defaultValue 14 + } 15 + 16 + func GetEnvOrPanic(key string) string { 17 + if value, ok := os.LookupEnv(key); ok { 18 + return value 19 + } 20 + log.Panic("Missing required environment variable: " + key) 21 + return "" 22 + } 23 + 24 + func HostAndPortToAddress(host string, port string) string { 25 + return fmt.Sprintf("%s:%s", host, port) 26 + } 27 + 28 + // `DerefOrDefault` returns the value of item if it is not nil, otherwise it returns default. 29 + func DerefOrDefault[T any](item *T, default_ T) T { 30 + if item == nil { 31 + return default_ 32 + } 33 + return *item 34 + }
+32
private/migrations/musicbrainz/20251113075219_create_artist.sql
··· 1 + -- +goose Up 2 + -- +goose StatementBegin 3 + CREATE TABLE artist ( -- replicate (verbose) 4 + id INTEGER PRIMARY KEY AUTOINCREMENT, 5 + gid TEXT NOT NULL UNIQUE, 6 + name TEXT NOT NULL, 7 + sort_name TEXT NOT NULL, 8 + begin_date_year INTEGER, 9 + begin_date_month INTEGER, 10 + begin_date_day INTEGER, 11 + end_date_year INTEGER, 12 + end_date_month INTEGER, 13 + end_date_day INTEGER, 14 + type INTEGER, -- references artist_type.id 15 + area INTEGER, -- references area.id 16 + gender INTEGER, -- references gender.id 17 + comment TEXT, 18 + edits_pending INTEGER NOT NULL DEFAULT 0 CHECK (edits_pending >= 0), 19 + last_updated TEXT DEFAULT CURRENT_TIMESTAMP, 20 + ended BOOLEAN DEFAULT FALSE, 21 + begin_area INTEGER, -- references area.id 22 + end_area INTEGER -- references area.id 23 + ); 24 + 25 + CREATE INDEX idx__artist__gid ON artist(gid); 26 + CREATE INDEX idx__artist__name ON artist(name); 27 + -- +goose StatementEnd 28 + 29 + -- +goose Down 30 + -- +goose StatementBegin 31 + DROP TABLE artist; 32 + -- +goose StatementEnd
+20
private/migrations/musicbrainz/20251113080351_create_artist_credit.sql
··· 1 + -- +goose Up 2 + -- +goose StatementBegin 3 + CREATE TABLE artist_credit ( 4 + id INTEGER PRIMARY KEY AUTOINCREMENT, 5 + name TEXT NOT NULL, 6 + artist_count INTEGER NOT NULL, 7 + ref_count INTEGER DEFAULT 0, 8 + created TEXT DEFAULT CURRENT_TIMESTAMP, 9 + edits_pending INTEGER NOT NULL DEFAULT 0 CHECK (edits_pending >= 0), 10 + gid TEXT NOT NULL UNIQUE 11 + ); 12 + 13 + CREATE INDEX idx__artist_credit__gid ON artist_credit(gid); 14 + CREATE INDEX idx__artist_credit__name ON artist_credit(name); 15 + -- +goose StatementEnd 16 + 17 + -- +goose Down 18 + -- +goose StatementBegin 19 + DROP TABLE artist_credit; 20 + -- +goose StatementEnd
+23
private/migrations/musicbrainz/20251113080429_create_artist_credit_name.sql
··· 1 + -- +goose Up 2 + -- +goose StatementBegin 3 + CREATE TABLE artist_credit_name ( 4 + artist_credit INTEGER NOT NULL, 5 + position INTEGER NOT NULL, 6 + artist INTEGER NOT NULL, 7 + name TEXT NOT NULL, 8 + join_phrase TEXT, 9 + PRIMARY KEY (artist_credit, position), 10 + FOREIGN KEY (artist_credit) REFERENCES artist_credit(id) ON DELETE CASCADE, 11 + FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE 12 + ); 13 + 14 + 15 + CREATE INDEX idx__artist_credit_name__artist_credit ON artist_credit_name(artist_credit); 16 + CREATE INDEX idx__artist_credit_name__artist ON artist_credit_name(artist); 17 + 18 + -- +goose StatementEnd 19 + 20 + -- +goose Down 21 + -- +goose StatementBegin 22 + DROP TABLE artist_credit_name; 23 + -- +goose StatementEnd
+18
private/migrations/musicbrainz/20251113080522_create_release_group_primary_type.sql
··· 1 + -- +goose Up 2 + -- +goose StatementBegin 3 + CREATE TABLE release_group_primary_type ( 4 + id INTEGER PRIMARY KEY AUTOINCREMENT, 5 + name TEXT NOT NULL, 6 + parent INTEGER, 7 + child_order INTEGER NOT NULL, 8 + description TEXT, 9 + gid TEXT NOT NULL UNIQUE 10 + ); 11 + 12 + CREATE INDEX idx__release_group_primary_type__child_order ON release_group_primary_type (child_order); 13 + -- +goose StatementEnd 14 + 15 + -- +goose Down 16 + -- +goose StatementBegin 17 + DROP TABLE release_group_primary_type; 18 + -- +goose StatementEnd
+26
private/migrations/musicbrainz/20251113080523_create_release_group.sql
··· 1 + -- +goose Up 2 + -- +goose StatementBegin 3 + CREATE TABLE release_group ( 4 + id INTEGER PRIMARY KEY AUTOINCREMENT, 5 + gid TEXT NOT NULL UNIQUE, 6 + name TEXT NOT NULL, 7 + artist_credit INTEGER NOT NULL, 8 + type INTEGER, 9 + comment TEXT, 10 + edits_pending INTEGER DEFAULT 0 CHECK (edits_pending >= 0), 11 + last_updated TEXT DEFAULT CURRENT_TIMESTAMP, 12 + FOREIGN KEY (artist_credit) REFERENCES artist_credit(id) 13 + FOREIGN KEY (type) REFERENCES release_group_primary_type(id) 14 + ); 15 + 16 + CREATE INDEX idx__release_group__artist_credit ON release_group(artist_credit); 17 + CREATE INDEX idx__release_group__gid ON release_group(gid); 18 + CREATE INDEX idx__release_group__name ON release_group(name); 19 + CREATE INDEX idx__release_group__type ON release_group(type); 20 + 21 + -- +goose StatementEnd 22 + 23 + -- +goose Down 24 + -- +goose StatementBegin 25 + DROP TABLE release_group; 26 + -- +goose StatementEnd
+32
private/migrations/musicbrainz/20251113080546_create_release.sql
··· 1 + -- +goose Up 2 + -- +goose StatementBegin 3 + CREATE TABLE release ( 4 + id INTEGER PRIMARY KEY AUTOINCREMENT, 5 + gid TEXT NOT NULL UNIQUE, 6 + name TEXT NOT NULL, 7 + artist_credit INTEGER NOT NULL, 8 + release_group INTEGER NOT NULL, 9 + status INTEGER, 10 + packaging INTEGER, 11 + language INTEGER, 12 + script INTEGER, 13 + barcode TEXT(255), 14 + comment TEXT, 15 + edits_pending INTEGER DEFAULT 0 CHECK (edits_pending >= 0), 16 + quality INTEGER DEFAULT -1, 17 + last_updated TEXT DEFAULT CURRENT_TIMESTAMP, 18 + FOREIGN KEY (artist_credit) REFERENCES artist_credit(id), 19 + FOREIGN KEY (release_group) REFERENCES release_group(id) 20 + ); 21 + 22 + CREATE INDEX idx__release__artist_credit ON release(artist_credit); 23 + CREATE INDEX idx__release__gid ON release(gid); 24 + CREATE INDEX idx__release__name ON release(name); 25 + CREATE INDEX idx__release__release_group ON release(release_group); 26 + 27 + -- +goose StatementEnd 28 + 29 + -- +goose Down 30 + -- +goose StatementBegin 31 + DROP TABLE release; 32 + -- +goose StatementEnd
+23
private/migrations/musicbrainz/20251113080716_create_medium_format.sql
··· 1 + -- +goose Up 2 + -- +goose StatementBegin 3 + CREATE TABLE medium_format ( 4 + id INTEGER PRIMARY KEY AUTOINCREMENT, 5 + name TEXT NOT NULL, 6 + parent INTEGER, 7 + child_order INTEGER, 8 + year INTEGER, 9 + has_discids BOOLEAN, 10 + description TEXT, 11 + gid TEXT NOT NULL, 12 + FOREIGN KEY (parent) REFERENCES medium_format(id) 13 + FOREIGN KEY (child_order) REFERENCES medium_format(id) 14 + ); 15 + 16 + CREATE INDEX idx__medium_format__name ON medium_format(name); 17 + CREATE INDEX idx__medium_format__gid ON medium_format(gid); 18 + -- +goose StatementEnd 19 + 20 + -- +goose Down 21 + -- +goose StatementBegin 22 + DROP TABLE medium_format; 23 + -- +goose StatementEnd
+27
private/migrations/musicbrainz/20251113080717_create_medium.sql
··· 1 + -- +goose Up 2 + -- +goose StatementBegin 3 + CREATE TABLE medium ( 4 + id INTEGER PRIMARY KEY AUTOINCREMENT, 5 + release INTEGER NOT NULL, 6 + position INTEGER NOT NULL, 7 + format INTEGER, 8 + name TEXT, 9 + edits_pending INTEGER NOT NULL DEFAULT 0 CHECK (edits_pending >= 0), 10 + last_updated TEXT DEFAULT CURRENT_TIMESTAMP, 11 + track_count INTEGER NOT NULL DEFAULT 0, 12 + gid TEXT NOT NULL UNIQUE, 13 + FOREIGN KEY (release) REFERENCES release(id), 14 + FOREIGN KEY (format) REFERENCES medium_format(id) 15 + ); 16 + 17 + CREATE INDEX idx__medium__release ON medium(release); 18 + CREATE INDEX idx__medium__position ON medium(position); 19 + CREATE INDEX idx__medium__gid ON medium(gid); 20 + CREATE INDEX idx__medium__format ON medium(format); 21 + 22 + -- +goose StatementEnd 23 + 24 + -- +goose Down 25 + -- +goose StatementBegin 26 + DROP TABLE medium; 27 + -- +goose StatementEnd
+25
private/migrations/musicbrainz/20251113080745_create_recording.sql
··· 1 + -- +goose Up 2 + -- +goose StatementBegin 3 + CREATE TABLE recording ( 4 + id INTEGER PRIMARY KEY AUTOINCREMENT, 5 + gid TEXT NOT NULL UNIQUE, 6 + name TEXT NOT NULL, 7 + artist_credit INTEGER NOT NULL, 8 + length INTEGER CHECK (length IS NULL OR length > 0), 9 + comment TEXT, 10 + edits_pending INTEGER NOT NULL DEFAULT 0 CHECK (edits_pending >= 0), 11 + last_updated TEXT DEFAULT CURRENT_TIMESTAMP, 12 + video INTEGER NOT NULL DEFAULT 0, 13 + FOREIGN KEY (artist_credit) REFERENCES artist_credit(id) 14 + ); 15 + 16 + CREATE INDEX idx__recording__artist_credit ON recording(artist_credit); 17 + CREATE INDEX idx__recording__gid ON recording(gid); 18 + CREATE INDEX idx__recording__name ON recording(name); 19 + 20 + -- +goose StatementEnd 21 + 22 + -- +goose Down 23 + -- +goose StatementBegin 24 + DROP TABLE recording; 25 + -- +goose StatementEnd
+33
private/migrations/musicbrainz/20251113080817_create_track.sql
··· 1 + -- +goose Up 2 + -- +goose StatementBegin 3 + CREATE TABLE track ( 4 + id INTEGER PRIMARY KEY AUTOINCREMENT, 5 + gid TEXT NOT NULL UNIQUE, 6 + recording INTEGER NOT NULL, 7 + medium INTEGER NOT NULL, 8 + position INTEGER NOT NULL, 9 + number TEXT, 10 + name TEXT NOT NULL, 11 + artist_credit INTEGER NOT NULL, 12 + length INTEGER CHECK (length IS NULL OR length > 0), 13 + edits_pending INTEGER NOT NULL DEFAULT 0 CHECK (edits_pending >= 0), 14 + last_updated TEXT DEFAULT CURRENT_TIMESTAMP, 15 + is_data_track BOOLEAN NOT NULL DEFAULT FALSE, 16 + FOREIGN KEY (recording) REFERENCES recording(id), 17 + FOREIGN KEY (medium) REFERENCES medium(id), 18 + FOREIGN KEY (artist_credit) REFERENCES artist_credit(id) 19 + ); 20 + 21 + CREATE INDEX idx__track__medium ON track(medium); 22 + CREATE INDEX idx__track__position ON track(position); 23 + CREATE INDEX idx__track__gid ON track(gid); 24 + CREATE INDEX idx__track__name ON track(name); 25 + CREATE INDEX idx__track__recording ON track(recording); 26 + CREATE INDEX idx__track__artist_credit ON track(artist_credit); 27 + 28 + -- +goose StatementEnd 29 + 30 + -- +goose Down 31 + -- +goose StatementBegin 32 + DROP TABLE track; 33 + -- +goose StatementEnd
+21
private/migrations/musicbrainz/20251113080850_create_isrc.sql
··· 1 + -- +goose Up 2 + -- +goose StatementBegin 3 + CREATE TABLE isrc ( 4 + id INTEGER PRIMARY KEY AUTOINCREMENT, 5 + recording INTEGER NOT NULL, 6 + isrc TEXT NOT NULL, 7 + source INTEGER, 8 + edits_pending INTEGER NOT NULL DEFAULT 0 CHECK (edits_pending >= 0), 9 + created TEXT DEFAULT CURRENT_TIMESTAMP, 10 + FOREIGN KEY (recording) REFERENCES recording(id) 11 + ); 12 + 13 + CREATE INDEX idx__isrc__recording ON isrc(recording); 14 + CREATE INDEX idx__isrc__isrc ON isrc(isrc); 15 + 16 + -- +goose StatementEnd 17 + 18 + -- +goose Down 19 + -- +goose StatementBegin 20 + DROP TABLE isrc; 21 + -- +goose StatementEnd
+26
private/migrations/musicbrainz/20251113081049_create_release_country.sql
··· 1 + -- +goose Up 2 + -- +goose StatementBegin 3 + CREATE TABLE release_country ( 4 + release INTEGER NOT NULL, 5 + country INTEGER NOT NULL, 6 + date_year SMALLINT, 7 + date_month SMALLINT, 8 + date_day SMALLINT, 9 + 10 + date DATE GENERATED ALWAYS AS ( 11 + date_year || '-' || printf('%02d', date_month) || '-' || printf('%02d', date_day) 12 + ) STORED, 13 + 14 + PRIMARY KEY (release, country), 15 + FOREIGN KEY (release) REFERENCES release(id) ON DELETE CASCADE 16 + ); 17 + 18 + CREATE INDEX idx__release_country__release ON release_country(release); 19 + CREATE INDEX idx__release_country__country ON release_country(country); 20 + 21 + -- +goose StatementEnd 22 + 23 + -- +goose Down 24 + -- +goose StatementBegin 25 + DROP TABLE release_country; 26 + -- +goose StatementEnd
+18
private/migrations/musicbrainz/20251210204606_create_release_group_secondary_type.sql
··· 1 + -- +goose Up 2 + -- +goose StatementBegin 3 + CREATE TABLE release_group_secondary_type ( 4 + id INTEGER PRIMARY KEY AUTOINCREMENT, 5 + name TEXT NOT NULL, 6 + parent INTEGER, 7 + child_order INTEGER NOT NULL, 8 + description TEXT, 9 + gid TEXT NOT NULL UNIQUE 10 + ); 11 + 12 + CREATE INDEX idx__release_group_secondary_type__child_order ON release_group_secondary_type(child_order); 13 + -- +goose StatementEnd 14 + 15 + -- +goose Down 16 + -- +goose StatementBegin 17 + DROP TABLE release_group_secondary_type; 18 + -- +goose StatementEnd
+19
private/migrations/musicbrainz/20251210204614_create_release_group_secondary_type_join.sql
··· 1 + -- +goose Up 2 + -- +goose StatementBegin 3 + CREATE TABLE release_group_secondary_type_join ( 4 + release_group INTEGER NOT NULL, 5 + secondary_type INTEGER NOT NULL, 6 + created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 + PRIMARY KEY (release_group, secondary_type), 8 + FOREIGN KEY (release_group) REFERENCES release_group(id), 9 + FOREIGN KEY (secondary_type) REFERENCES secondary_type(id) 10 + ); 11 + 12 + CREATE INDEX idx__release_group_secondary_type_join__release_group ON release_group_secondary_type_join(release_group); 13 + CREATE INDEX idx__release_group_secondary_type_join__secondary_type ON release_group_secondary_type_join(secondary_type); 14 + -- +goose StatementEnd 15 + 16 + -- +goose Down 17 + -- +goose StatementBegin 18 + DROP TABLE release_group_secondary_type_join; 19 + -- +goose StatementEnd
+13
private/migrations/statistics/20260106154827_create_recording_mbid_artist_mbid.sql
··· 1 + -- +goose Up 2 + -- +goose StatementBegin 3 + CREATE TABLE recording_mbid__artist_mbid ( 4 + recording_mbid TEXT NOT NULL, 5 + artist_mbid TEXT NOT NULL, 6 + PRIMARY KEY (recording_mbid, artist_mbid) 7 + ); 8 + -- +goose StatementEnd 9 + 10 + -- +goose Down 11 + -- +goose StatementBegin 12 + DROP TABLE recording_mbid__artist_mbid; 13 + -- +goose StatementEnd
+13
private/migrations/statistics/20260106155021_create_recording_mbid_release_group_mbid.sql
··· 1 + -- +goose Up 2 + -- +goose StatementBegin 3 + CREATE TABLE recording_mbid__release_group_mbid ( 4 + recording_mbid TEXT NOT NULL, 5 + release_group_mbid TEXT NOT NULL, 6 + PRIMARY KEY (recording_mbid, release_group_mbid) 7 + ); 8 + -- +goose StatementEnd 9 + 10 + -- +goose Down 11 + -- +goose StatementBegin 12 + DROP TABLE recording_mbid__release_group_mbid; 13 + -- +goose StatementEnd
+17
private/migrations/statistics/20260106211739_create_scrobble.sql
··· 1 + -- +goose Up 2 + -- +goose StatementBegin 3 + CREATE TABLE scrobble ( 4 + user_id INTEGER NOT NULL, 5 + recording_mbid TEXT NOT NULL, 6 + played_at TIMESTAMP NOT NULL 7 + ); 8 + 9 + CREATE INDEX scrobble_user_id_idx ON scrobble(user_id); 10 + CREATE INDEX scrobble_recording_mbid_idx ON scrobble(recording_mbid); 11 + CREATE INDEX scrobble_played_at_idx ON scrobble(played_at); 12 + -- +goose StatementEnd 13 + 14 + -- +goose Down 15 + -- +goose StatementBegin 16 + DROP TABLE scrobble; 17 + -- +goose StatementEnd
+57
scripts/migrations/statistics.py
··· 1 + import duckdb 2 + import os 3 + 4 + STATS_DATABASE_PATH = os.getenv("STATS_DATABASE_PATH") or "private/database/statistics.dev.duckdb" 5 + 6 + def create_recording_mbid__artist_mbid(conn: duckdb.DuckDBPyConnection): 7 + query = """ 8 + CREATE TABLE recording_mbid__artist_mbid ( 9 + recording_mbid TEXT NOT NULL, 10 + artist_mbid TEXT NOT NULL, 11 + PRIMARY KEY (recording_mbid, artist_mbid) 12 + ); 13 + """ 14 + 15 + conn.execute(query) 16 + 17 + 18 + def create_recording_mbid__release_group_mbid(conn: duckdb.DuckDBPyConnection): 19 + query = """ 20 + CREATE TABLE recording_mbid__release_group_mbid ( 21 + recording_mbid TEXT NOT NULL, 22 + release_group_mbid TEXT NOT NULL, 23 + PRIMARY KEY (recording_mbid, release_group_mbid) 24 + ); 25 + """ 26 + 27 + conn.execute(query) 28 + 29 + 30 + def scrobble(conn: duckdb.DuckDBPyConnection): 31 + query = """ 32 + CREATE TABLE scrobble ( 33 + user_id INTEGER NOT NULL, 34 + recording_mbid TEXT NOT NULL, 35 + played_at TIMESTAMP NOT NULL 36 + ); 37 + 38 + CREATE INDEX scrobble_user_id_idx ON scrobble(user_id); 39 + CREATE INDEX scrobble_recording_mbid_idx ON scrobble(recording_mbid); 40 + CREATE INDEX scrobble_played_at_idx ON scrobble(played_at); 41 + """ 42 + 43 + conn.execute(query) 44 + 45 + 46 + def main(): 47 + conn = duckdb.connect(STATS_DATABASE_PATH) 48 + 49 + create_recording_mbid__artist_mbid(conn) 50 + create_recording_mbid__release_group_mbid(conn) 51 + scrobble(conn) 52 + 53 + conn.close() 54 + 55 + 56 + if __name__ == "__main__": 57 + main()
+15
scripts/pyproject.toml
··· 1 + [project] 2 + name="scripts" 3 + version="0.1.0" 4 + dependencies = [ 5 + "adbc-driver-manager==1.6.0", 6 + "adbc-driver-sqlite==1.6.0", 7 + "duckdb>=1.4.3", 8 + "faker==39.0.0", 9 + "polars==1.35.2", 10 + "pyarrow>=22.0.0", 11 + "tqdm==4.67.1", 12 + ] 13 + 14 + [tool.ty.environment] 15 + python = "./.venv"
+344
scripts/seeds/musicbrainz.py
··· 1 + import os 2 + import sqlite3 3 + 4 + import polars as pl 5 + from tqdm import tqdm 6 + from typing import Any 7 + 8 + DATABASE_PATH = os.getenv("DATABASE_PATH") or "private/database/musicbrainz.dev.db" 9 + DATABASE_URL = f"sqlite:///{DATABASE_PATH}" 10 + 11 + 12 + def write_to_database( 13 + conn: sqlite3.Cursor, 14 + table_name: str, 15 + csv_path: str, 16 + columns: list[str], 17 + schema_overrides: dict[str, Any] | None = None, 18 + batch_size: int = 500_000, 19 + ): 20 + insert_sql = f""" 21 + INSERT INTO {table_name} 22 + VALUES ({",".join(["?"] * len(columns))}) 23 + """ 24 + 25 + # Estimate rows cheaply 26 + with open(csv_path, "rb") as f: 27 + total_rows = sum(1 for _ in f) 28 + 29 + _ = conn.execute("BEGIN") 30 + 31 + with tqdm( 32 + total=total_rows, 33 + desc=f"Inserting {table_name}", 34 + unit="rows", 35 + leave=True, 36 + ) as pbar: 37 + reader = pl.read_csv_batched( 38 + csv_path, 39 + separator="\t", 40 + has_header=False, 41 + quote_char=None, 42 + null_values=["\\N"], 43 + new_columns=columns, 44 + schema_overrides=schema_overrides, 45 + batch_size=batch_size, 46 + ) 47 + 48 + batches = reader.next_batches(1) 49 + while batches is not None: 50 + for batch in batches: 51 + conn.executemany(insert_sql, batch.iter_rows()) 52 + pbar.update(batch.height) 53 + batches = reader.next_batches(1) 54 + 55 + _ = conn.execute("COMMIT") 56 + 57 + 58 + def filename(table: str) -> str: 59 + return f"private/data/mbdump-sample/mbdump/{table}" 60 + 61 + 62 + 63 + def artist(conn: sqlite3.Cursor): 64 + columns = [ 65 + "id", 66 + "gid", 67 + "name", 68 + "sort_name", 69 + "begin_date_year", 70 + "begin_date_month", 71 + "begin_date_day", 72 + "end_date_year", 73 + "end_date_month", 74 + "end_date_day", 75 + "type", 76 + "area", 77 + "gender", 78 + "comment", 79 + "edits_pending", 80 + "last_updated", 81 + "ended", 82 + "begin_area", 83 + "end_area", 84 + ] 85 + 86 + write_to_database(conn, "artist", filename("artist"), columns, None, 1000000) 87 + 88 + 89 + def artist_credit(conn: sqlite3.Cursor): 90 + columns = [ 91 + "id", 92 + "name", 93 + "artist_count", 94 + "ref_count", 95 + "created", 96 + "edits_pending", 97 + "gid", 98 + ] 99 + 100 + write_to_database( 101 + conn, "artist_credit", filename("artist_credit"), columns, None, 1000000 102 + ) 103 + 104 + 105 + def artist_credit_name(conn: sqlite3.Cursor): 106 + columns = [ 107 + "artist_credit", 108 + "position", 109 + "artist", 110 + "name", 111 + "join_phrase", 112 + ] 113 + write_to_database( 114 + conn, 115 + "artist_credit_name", 116 + filename("artist_credit_name"), 117 + columns, 118 + None, 119 + 1000000, 120 + ) 121 + 122 + 123 + def release_group(conn: sqlite3.Cursor): 124 + columns = [ 125 + "id", 126 + "gid", 127 + "name", 128 + "artist_credit", 129 + "type", 130 + "comment", 131 + "edits_pending", 132 + "last_updated", 133 + ] 134 + write_to_database( 135 + conn, 136 + "release_group", 137 + filename("release_group"), 138 + columns, 139 + {"comment": pl.Utf8}, 140 + 1000000, 141 + ) 142 + 143 + 144 + def release(conn: sqlite3.Cursor): 145 + columns = [ 146 + "id", 147 + "gid", 148 + "name", 149 + "artist_credit", 150 + "release_group", 151 + "status", 152 + "packaging", 153 + "language", 154 + "script", 155 + "barcode", 156 + "comment", 157 + "edits_pending", 158 + "quality", 159 + "last_updated", 160 + ] 161 + write_to_database( 162 + conn, 163 + "release", 164 + filename("release"), 165 + columns, 166 + {"comment": pl.Utf8, "barcode": pl.Utf8}, 167 + 1000000, 168 + ) 169 + 170 + 171 + def release_country(conn: sqlite3.Cursor): 172 + columns = [ 173 + "release", 174 + "country", 175 + "date_year", 176 + "date_month", 177 + "date_day", 178 + ] 179 + write_to_database( 180 + conn, "release_country", filename("release_country"), columns, None, 1000000 181 + ) 182 + 183 + 184 + def medium_format(conn: sqlite3.Cursor): 185 + columns = [ 186 + "id", 187 + "name", 188 + "parent", 189 + "child_order", 190 + "year", 191 + "has_discids", 192 + "description", 193 + "gid", 194 + ] 195 + write_to_database( 196 + conn, "medium_format", filename("medium_format"), columns, None, 1000000 197 + ) 198 + 199 + 200 + def medium(conn: sqlite3.Cursor): 201 + columns = [ 202 + "id", 203 + "release", 204 + "position", 205 + "format", 206 + "name", 207 + "edits_pending", 208 + "last_updated", 209 + "track_count", 210 + "gid", 211 + ] 212 + write_to_database(conn, "medium", filename("medium"), columns, None, 1000000) 213 + 214 + 215 + def track(conn: sqlite3.Cursor): 216 + columns = [ 217 + "id", 218 + "gid", 219 + "recording", 220 + "medium", 221 + "position", 222 + "number", 223 + "name", 224 + "artist_credit", 225 + "length", 226 + "edits_pending", 227 + "last_updated", 228 + "is_data_track", 229 + ] 230 + 231 + write_to_database( 232 + conn, "track", filename("track"), columns, {"number": pl.Utf8}, 1000000 233 + ) 234 + 235 + 236 + def isrc(conn: sqlite3.Cursor): 237 + columns = ["id", "recording", "isrc", "source", "edits_pending", "created"] 238 + write_to_database( 239 + conn, "isrc", filename("isrc"), columns, {"isrc": pl.Utf8}, 1000000 240 + ) 241 + 242 + 243 + def recording(conn: sqlite3.Cursor): 244 + columns = [ 245 + "id", 246 + "gid", 247 + "name", 248 + "artist_credit", 249 + "length", 250 + "comment", 251 + "edits_pending", 252 + "last_updated", 253 + "video", 254 + ] 255 + write_to_database( 256 + conn, "recording", filename("recording"), columns, {"barcode": pl.Utf8}, 1000000 257 + ) 258 + 259 + 260 + def release_group_primary_type(conn: sqlite3.Cursor): 261 + columns = [ 262 + "id", 263 + "name", 264 + "parent", 265 + "child_order", 266 + "description", 267 + "gid", 268 + ] 269 + 270 + write_to_database( 271 + conn, 272 + "release_group_primary_type", 273 + filename("release_group_primary_type"), 274 + columns, 275 + None, 276 + 1000000, 277 + ) 278 + 279 + 280 + def release_group_secondary_type(conn: sqlite3.Cursor): 281 + columns = [ 282 + "id", 283 + "name", 284 + "parent", 285 + "child_order", 286 + "description", 287 + "gid", 288 + ] 289 + write_to_database( 290 + conn, 291 + "release_group_secondary_type", 292 + filename("release_group_secondary_type"), 293 + columns, 294 + None, 295 + 1000000, 296 + ) 297 + 298 + 299 + def release_group_secondary_type_join(conn: sqlite3.Cursor): 300 + columns = [ 301 + "release_group", 302 + "secondary_type", 303 + "created", 304 + ] 305 + write_to_database( 306 + conn, 307 + "release_group_secondary_type_join", 308 + filename("release_group_secondary_type_join"), 309 + columns, 310 + None, 311 + 1000000, 312 + ) 313 + 314 + 315 + def main(): 316 + conn = sqlite3.connect(DATABASE_PATH) 317 + 318 + conn = conn.execute("PRAGMA journal_mode=WAL;") 319 + conn = conn.execute("PRAGMA synchronous=OFF;") 320 + conn = conn.execute("PRAGMA temp_store=MEMORY;") 321 + conn = conn.execute("PRAGMA foreign_keys = OFF;") 322 + conn = conn.execute("PRAGMA cache_size=-8000000;") 323 + conn = conn.execute("PRAGMA mmap_size = 30000000000;") # 30GB if disk allows 324 + conn = conn.execute("PRAGMA locking_mode = EXCLUSIVE;") 325 + conn = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);") 326 + 327 + artist(conn) 328 + artist_credit(conn) 329 + artist_credit_name(conn) 330 + release_group_primary_type(conn) 331 + release_group(conn) 332 + release(conn) 333 + medium_format(conn) 334 + medium(conn) 335 + recording(conn) 336 + track(conn) 337 + isrc(conn) 338 + release_country(conn) 339 + release_group_secondary_type(conn) 340 + release_group_secondary_type_join(conn) 341 + 342 + 343 + if __name__ == "__main__": 344 + main()
+81
scripts/seeds/statistics.py
··· 1 + from faker import Faker 2 + import random 3 + from tqdm import tqdm 4 + import sqlite3 5 + import duckdb 6 + import os 7 + import polars as pl 8 + 9 + 10 + STATS_DATABASE_PATH = os.getenv("STATS_DATABASE_PATH") or "private/database/statistics.dev.duckdb" 11 + MB_DATABASE_PATH = os.getenv("MB_DATABASE_PATH") or "private/database/musicbrainz.dev.db" 12 + 13 + 14 + query_artist = """ 15 + SELECT r.gid AS recording_mbid, 16 + a.gid as artist_mbid 17 + FROM recording r 18 + INNER JOIN artist_credit ac ON r.artist_credit = ac.id 19 + INNER JOIN artist_credit_name acn ON acn.artist_credit = ac.id 20 + INNER JOIN artist a ON acn.artist = a.id 21 + """ 22 + 23 + query_release = """ 24 + SELECT r.gid AS recording_mbid, 25 + rel.gid AS release_mbid 26 + FROM recording r 27 + INNER JOIN track t ON t.recording = r.id 28 + INNER JOIN medium m ON m.id = t.medium 29 + INNER JOIN release rel ON rel.id = m.release 30 + """ 31 + 32 + query_release_group = """ 33 + SELECT r.gid AS recording_mbid, 34 + rg.gid AS release_group_mbid 35 + FROM recording r 36 + INNER JOIN track t ON t.recording = r.id 37 + INNER JOIN medium m ON m.id = t.medium 38 + INNER JOIN release rel ON rel.id = m.release 39 + INNER JOIN release_group rg ON rg.id = rel.release_group 40 + """ 41 + 42 + def artist_mbid__recording__mbid( 43 + mb_conn: sqlite3.Connection, stats_conn: duckdb.DuckDBPyConnection 44 + ): 45 + for df_chunk in pl.read_database( 46 + query_artist, connection=mb_conn, batch_size=100000, iter_batches=True 47 + ): 48 + stats_conn.register("chunk", df_chunk) 49 + stats_conn.execute(""" 50 + INSERT OR IGNORE INTO recording_mbid__artist_mbid SELECT * FROM chunk 51 + """) 52 + stats_conn.unregister("chunk") 53 + 54 + 55 + 56 + def release_group_mbid__recording__mbid( 57 + mb_conn: sqlite3.Connection, stats_conn: duckdb.DuckDBPyConnection 58 + ): 59 + for df_chunk in pl.read_database( 60 + query_release, connection=mb_conn, batch_size=100000, iter_batches=True 61 + ): 62 + stats_conn.register("chunk", df_chunk) 63 + stats_conn.execute(""" 64 + INSERT OR IGNORE INTO recording_mbid__release_group_mbid SELECT * FROM chunk 65 + """) 66 + stats_conn.unregister("chunk") 67 + 68 + 69 + def main(): 70 + stats_conn = duckdb.connect(STATS_DATABASE_PATH) 71 + mb_conn = sqlite3.connect(MB_DATABASE_PATH) 72 + 73 + artist_mbid__recording__mbid(mb_conn, stats_conn) 74 + release_group_mbid__recording__mbid(mb_conn, stats_conn) 75 + 76 + stats_conn.close() 77 + mb_conn.close() 78 + 79 + 80 + if __name__ == "__main__": 81 + main()
+15
scripts/seeds/statistics.sql
··· 1 + ATTACH './private/database/musicbrainz.dev.db' AS mb (TYPE SQLITE); 2 + 3 + INSERT INTO scrobble (recording_mbid, user_id, played_at) 4 + SELECT 5 + gid AS recording_mbid, 6 + 1 + CAST(random() * 1000 AS INTEGER) AS user_id, 7 + TIMESTAMP '2000-01-01' 8 + + INTERVAL (random() * 25 * 365 * 24 * 60 * 60) SECOND 9 + FROM ( 10 + SELECT gid 11 + FROM mb.recording 12 + CROSS JOIN range(50) 13 + ORDER BY random() 14 + LIMIT 50_000_000 15 + );
+205
scripts/uv.lock
··· 1 + version = 1 2 + revision = 3 3 + requires-python = ">=3.13" 4 + 5 + [[package]] 6 + name = "adbc-driver-manager" 7 + version = "1.6.0" 8 + source = { registry = "https://pypi.org/simple" } 9 + dependencies = [ 10 + { name = "typing-extensions" }, 11 + ] 12 + sdist = { url = "https://files.pythonhosted.org/packages/89/ed/e2b548e9ffe19a405ea4afb0679805b7da981bdc0366017cb6c826e1dae1/adbc_driver_manager-1.6.0.tar.gz", hash = "sha256:618659313a5c712f7938ab35e8f8bae1b80e9ed0c7a8582b2ec9174a88a442ba", size = 109319, upload-time = "2025-05-06T00:43:14.08Z" } 13 + wheels = [ 14 + { url = "https://files.pythonhosted.org/packages/7b/0a/1bd66b56514f7412fb737cf9ec38a1e32576ab6b2ed5aab74e890fb10b50/adbc_driver_manager-1.6.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:f75a65f5fb4aeac33b8b08c054335ae5a7bc5de848d7b036398bff876119cc27", size = 383339, upload-time = "2025-05-06T00:42:29.487Z" }, 15 + { url = "https://files.pythonhosted.org/packages/18/5a/c8ad32c5d0689aae1a9fbf4acfd5605664b3d077298dc27a6e216e601691/adbc_driver_manager-1.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0a9e2be3fca404e3b78b6fafb1e61d5a08565a7815debc53d049cc5fbe0c955d", size = 368543, upload-time = "2025-05-06T00:42:30.765Z" }, 16 + { url = "https://files.pythonhosted.org/packages/33/bb/a9e1daa66b09b33852a4e592e951a29e6ee055d88e792b64eb5761a4f011/adbc_driver_manager-1.6.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83dfde4c8d2f130be23048800117a8f3166b797d1442d74135ce7611ab26e812", size = 2141507, upload-time = "2025-05-06T00:42:32.246Z" }, 17 + { url = "https://files.pythonhosted.org/packages/d3/49/b5e260deff3d218a17fe23a1313bb3c033d846bf74505c297f74d2c8abfe/adbc_driver_manager-1.6.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41972465fa4db46bf151cc37000d0bd29c87c2eabbc81f502f0b6932c235f213", size = 2173133, upload-time = "2025-05-06T00:42:33.933Z" }, 18 + { url = "https://files.pythonhosted.org/packages/bf/5f/a04791038cb659c8e1e7fb4a22d75a9fd3e3109a22822bd80beea0046dc4/adbc_driver_manager-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:0e8ffb182fafe1e6ae12964a833700daacc55f7abfdc2ada8b5214b18108d87b", size = 535018, upload-time = "2025-05-06T00:42:35.574Z" }, 19 + ] 20 + 21 + [[package]] 22 + name = "adbc-driver-sqlite" 23 + version = "1.6.0" 24 + source = { registry = "https://pypi.org/simple" } 25 + dependencies = [ 26 + { name = "adbc-driver-manager" }, 27 + { name = "importlib-resources" }, 28 + ] 29 + sdist = { url = "https://files.pythonhosted.org/packages/35/b1/c6e3c4e6740413c580b40085c21870a09ca9181bcfaa8a7aba98b93c2d7e/adbc_driver_sqlite-1.6.0.tar.gz", hash = "sha256:f3d6db788afde92b9cb1eecfc08fdcee301a7ee86c50e21357db0bb3e80b991b", size = 17038, upload-time = "2025-05-06T00:43:16.959Z" } 30 + wheels = [ 31 + { url = "https://files.pythonhosted.org/packages/b8/a0/a635440c9521be7924ab1daf76d64b77c93042b1901026acb1c018ce789a/adbc_driver_sqlite-1.6.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:b68dbe03c6a26ac1a3ec089ad6f067fe60a6eb4e26a0bffd230d8bf4a9f58f21", size = 1041104, upload-time = "2025-05-06T00:43:05.182Z" }, 32 + { url = "https://files.pythonhosted.org/packages/72/25/52a079ddfde95d08c37bed59fcd5e2e1d0f180fc552885f925058ebc922f/adbc_driver_sqlite-1.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:55e163c08dbb7b068f692e359f3dadb9f3349a720d1b2cf39aec44925a1bed3a", size = 1011451, upload-time = "2025-05-06T00:43:06.521Z" }, 33 + { url = "https://files.pythonhosted.org/packages/73/28/be596c01913a81fed3dda451764b216221ba074c2a690733eef7528c152a/adbc_driver_sqlite-1.6.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4bf7bd287d4dc76bc865d0c88ffd6c22a606965298febf714e6b9439a0ff5f3", size = 955262, upload-time = "2025-05-06T00:43:07.773Z" }, 34 + { url = "https://files.pythonhosted.org/packages/92/ec/ce41f3c84b5b4f762e4de9072574714c623e959924bf1bbaf1548612cb6e/adbc_driver_sqlite-1.6.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:922a561c84c53f8fac42f1d636dfb915a265de3ebd39c35cf0d1cbe58d668616", size = 976623, upload-time = "2025-05-06T00:43:09.016Z" }, 35 + { url = "https://files.pythonhosted.org/packages/9d/6e/0231878df1e7b71607f6c71c08dd68ea168622496b000d9be79860c1e32d/adbc_driver_sqlite-1.6.0-py3-none-win_amd64.whl", hash = "sha256:1e28c378759915dd39eae02da98b189d33b9604c3c54e08caf256457f63727b2", size = 862809, upload-time = "2025-05-06T00:43:10.471Z" }, 36 + ] 37 + 38 + [[package]] 39 + name = "colorama" 40 + version = "0.4.6" 41 + source = { registry = "https://pypi.org/simple" } 42 + sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } 43 + wheels = [ 44 + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, 45 + ] 46 + 47 + [[package]] 48 + name = "duckdb" 49 + version = "1.4.3" 50 + source = { registry = "https://pypi.org/simple" } 51 + sdist = { url = "https://files.pythonhosted.org/packages/7f/da/17c3eb5458af69d54dedc8d18e4a32ceaa8ce4d4c699d45d6d8287e790c3/duckdb-1.4.3.tar.gz", hash = "sha256:fea43e03604c713e25a25211ada87d30cd2a044d8f27afab5deba26ac49e5268", size = 18478418, upload-time = "2025-12-09T10:59:22.945Z" } 52 + wheels = [ 53 + { url = "https://files.pythonhosted.org/packages/fd/76/288cca43a10ddd082788e1a71f1dc68d9130b5d078c3ffd0edf2f3a8719f/duckdb-1.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16952ac05bd7e7b39946695452bf450db1ebbe387e1e7178e10f593f2ea7b9a8", size = 29033392, upload-time = "2025-12-09T10:58:34.631Z" }, 54 + { url = "https://files.pythonhosted.org/packages/64/07/cbad3d3da24af4d1add9bccb5fb390fac726ffa0c0cebd29bf5591cef334/duckdb-1.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de984cd24a6cbefdd6d4a349f7b9a46e583ca3e58ce10d8def0b20a6e5fcbe78", size = 15414567, upload-time = "2025-12-09T10:58:37.051Z" }, 55 + { url = "https://files.pythonhosted.org/packages/c4/19/57af0cc66ba2ffb8900f567c9aec188c6ab2a7b3f2260e9c6c3c5f9b57b1/duckdb-1.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e5457dda91b67258aae30fb1a0df84183a9f6cd27abac1d5536c0d876c6dfa1", size = 13740960, upload-time = "2025-12-09T10:58:39.658Z" }, 56 + { url = "https://files.pythonhosted.org/packages/73/dd/23152458cf5fd51e813fadda60b9b5f011517634aa4bb9301f5f3aa951d8/duckdb-1.4.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:006aca6a6d6736c441b02ff5c7600b099bb8b7f4de094b8b062137efddce42df", size = 18484312, upload-time = "2025-12-09T10:58:42.054Z" }, 57 + { url = "https://files.pythonhosted.org/packages/1a/7b/adf3f611f11997fc429d4b00a730604b65d952417f36a10c4be6e38e064d/duckdb-1.4.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2813f4635f4d6681cc3304020374c46aca82758c6740d7edbc237fe3aae2744", size = 20495571, upload-time = "2025-12-09T10:58:44.646Z" }, 58 + { url = "https://files.pythonhosted.org/packages/40/d5/6b7ddda7713a788ab2d622c7267ec317718f2bdc746ce1fca49b7ff0e50f/duckdb-1.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:6db124f53a3edcb32b0a896ad3519e37477f7e67bf4811cb41ab60c1ef74e4c8", size = 12335680, upload-time = "2025-12-09T10:58:46.883Z" }, 59 + { url = "https://files.pythonhosted.org/packages/e8/28/0670135cf54525081fded9bac1254f78984e3b96a6059cd15aca262e3430/duckdb-1.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:a8b0a8764e1b5dd043d168c8f749314f7a1252b5a260fa415adaa26fa3b958fd", size = 13075161, upload-time = "2025-12-09T10:58:49.47Z" }, 60 + { url = "https://files.pythonhosted.org/packages/b6/f4/a38651e478fa41eeb8e43a0a9c0d4cd8633adea856e3ac5ac95124b0fdbf/duckdb-1.4.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:316711a9e852bcfe1ed6241a5f654983f67e909e290495f3562cccdf43be8180", size = 29042272, upload-time = "2025-12-09T10:58:51.826Z" }, 61 + { url = "https://files.pythonhosted.org/packages/16/de/2cf171a66098ce5aeeb7371511bd2b3d7b73a2090603b0b9df39f8aaf814/duckdb-1.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9e625b2b4d52bafa1fd0ebdb0990c3961dac8bb00e30d327185de95b68202131", size = 15419343, upload-time = "2025-12-09T10:58:54.439Z" }, 62 + { url = "https://files.pythonhosted.org/packages/35/28/6b0a7830828d4e9a37420d87e80fe6171d2869a9d3d960bf5d7c3b8c7ee4/duckdb-1.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:130c6760f6c573f9c9fe9aba56adba0fab48811a4871b7b8fd667318b4a3e8da", size = 13748905, upload-time = "2025-12-09T10:58:56.656Z" }, 63 + { url = "https://files.pythonhosted.org/packages/15/4d/778628e194d63967870873b9581c8a6b4626974aa4fbe09f32708a2d3d3a/duckdb-1.4.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c88effaa557a11267706b01419c542fe42f893dee66e5a6daa5974ea2d4a46", size = 18487261, upload-time = "2025-12-09T10:58:58.866Z" }, 64 + { url = "https://files.pythonhosted.org/packages/c6/5f/87e43af2e4a0135f9675449563e7c2f9b6f1fe6a2d1691c96b091f3904dd/duckdb-1.4.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b35491db98ccd11d151165497c084a9d29d3dc42fc80abea2715a6c861ca43d", size = 20497138, upload-time = "2025-12-09T10:59:01.241Z" }, 65 + { url = "https://files.pythonhosted.org/packages/94/41/abec537cc7c519121a2a83b9a6f180af8915fabb433777dc147744513e74/duckdb-1.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:23b12854032c1a58d0452e2b212afa908d4ce64171862f3792ba9a596ba7c765", size = 12836056, upload-time = "2025-12-09T10:59:03.388Z" }, 66 + { url = "https://files.pythonhosted.org/packages/b1/5a/8af5b96ce5622b6168854f479ce846cf7fb589813dcc7d8724233c37ded3/duckdb-1.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:90f241f25cffe7241bf9f376754a5845c74775e00e1c5731119dc88cd71e0cb2", size = 13527759, upload-time = "2025-12-09T10:59:05.496Z" }, 67 + ] 68 + 69 + [[package]] 70 + name = "faker" 71 + version = "39.0.0" 72 + source = { registry = "https://pypi.org/simple" } 73 + dependencies = [ 74 + { name = "tzdata" }, 75 + ] 76 + sdist = { url = "https://files.pythonhosted.org/packages/30/b9/0897fb5888ddda099dc0f314a8a9afb5faa7e52eaf6865c00686dfb394db/faker-39.0.0.tar.gz", hash = "sha256:ddae46d3b27e01cea7894651d687b33bcbe19a45ef044042c721ceac6d3da0ff", size = 1941757, upload-time = "2025-12-17T19:19:04.762Z" } 77 + wheels = [ 78 + { url = "https://files.pythonhosted.org/packages/eb/5a/26cdb1b10a55ac6eb11a738cea14865fa753606c4897d7be0f5dc230df00/faker-39.0.0-py3-none-any.whl", hash = "sha256:c72f1fca8f1a24b8da10fcaa45739135a19772218ddd61b86b7ea1b8c790dce7", size = 1980775, upload-time = "2025-12-17T19:19:02.926Z" }, 79 + ] 80 + 81 + [[package]] 82 + name = "importlib-resources" 83 + version = "6.5.2" 84 + source = { registry = "https://pypi.org/simple" } 85 + sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } 86 + wheels = [ 87 + { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, 88 + ] 89 + 90 + [[package]] 91 + name = "polars" 92 + version = "1.35.2" 93 + source = { registry = "https://pypi.org/simple" } 94 + dependencies = [ 95 + { name = "polars-runtime-32" }, 96 + ] 97 + sdist = { url = "https://files.pythonhosted.org/packages/fa/43/09d4738aa24394751cb7e5d1fc4b5ef461d796efcadd9d00c79578332063/polars-1.35.2.tar.gz", hash = "sha256:ae458b05ca6e7ca2c089342c70793f92f1103c502dc1b14b56f0a04f2cc1d205", size = 694895, upload-time = "2025-11-09T13:20:05.921Z" } 98 + wheels = [ 99 + { url = "https://files.pythonhosted.org/packages/b4/9a/24e4b890c7ee4358964aa92c4d1865df0e8831f7df6abaa3a39914521724/polars-1.35.2-py3-none-any.whl", hash = "sha256:5e8057c8289ac148c793478323b726faea933d9776bd6b8a554b0ab7c03db87e", size = 783597, upload-time = "2025-11-09T13:18:51.361Z" }, 100 + ] 101 + 102 + [[package]] 103 + name = "polars-runtime-32" 104 + version = "1.35.2" 105 + source = { registry = "https://pypi.org/simple" } 106 + sdist = { url = "https://files.pythonhosted.org/packages/cb/75/ac1256ace28c832a0997b20ba9d10a9d3739bd4d457c1eb1e7d196b6f88b/polars_runtime_32-1.35.2.tar.gz", hash = "sha256:6e6e35733ec52abe54b7d30d245e6586b027d433315d20edfb4a5d162c79fe90", size = 2694387, upload-time = "2025-11-09T13:20:07.624Z" } 107 + wheels = [ 108 + { url = "https://files.pythonhosted.org/packages/66/de/a532b81e68e636483a5dd764d72e106215543f3ef49a142272b277ada8fe/polars_runtime_32-1.35.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e465d12a29e8df06ea78947e50bd361cdf77535cd904fd562666a8a9374e7e3a", size = 40524507, upload-time = "2025-11-09T13:18:55.727Z" }, 109 + { url = "https://files.pythonhosted.org/packages/2d/0b/679751ea6aeaa7b3e33a70ba17f9c8150310792583f3ecf9bb1ce15fe15c/polars_runtime_32-1.35.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef2b029b78f64fb53f126654c0bfa654045c7546bd0de3009d08bd52d660e8cc", size = 36700154, upload-time = "2025-11-09T13:18:59.78Z" }, 110 + { url = "https://files.pythonhosted.org/packages/e2/c8/fd9f48dd6b89ae9cff53d896b51d08579ef9c739e46ea87a647b376c8ca2/polars_runtime_32-1.35.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85dda0994b5dff7f456bb2f4bbd22be9a9e5c5e28670e23fedb13601ec99a46d", size = 41317788, upload-time = "2025-11-09T13:19:03.949Z" }, 111 + { url = "https://files.pythonhosted.org/packages/67/89/e09d9897a70b607e22a36c9eae85a5b829581108fd1e3d4292e5c0f52939/polars_runtime_32-1.35.2-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:3b9006902fc51b768ff747c0f74bd4ce04005ee8aeb290ce9c07ce1cbe1b58a9", size = 37850590, upload-time = "2025-11-09T13:19:08.154Z" }, 112 + { url = "https://files.pythonhosted.org/packages/dc/40/96a808ca5cc8707894e196315227f04a0c82136b7fb25570bc51ea33b88d/polars_runtime_32-1.35.2-cp39-abi3-win_amd64.whl", hash = "sha256:ddc015fac39735592e2e7c834c02193ba4d257bb4c8c7478b9ebe440b0756b84", size = 41290019, upload-time = "2025-11-09T13:19:12.214Z" }, 113 + { url = "https://files.pythonhosted.org/packages/f4/d1/8d1b28d007da43c750367c8bf5cb0f22758c16b1104b2b73b9acadb2d17a/polars_runtime_32-1.35.2-cp39-abi3-win_arm64.whl", hash = "sha256:6861145aa321a44eda7cc6694fb7751cb7aa0f21026df51b5faa52e64f9dc39b", size = 36955684, upload-time = "2025-11-09T13:19:15.666Z" }, 114 + ] 115 + 116 + [[package]] 117 + name = "pyarrow" 118 + version = "22.0.0" 119 + source = { registry = "https://pypi.org/simple" } 120 + sdist = { url = "https://files.pythonhosted.org/packages/30/53/04a7fdc63e6056116c9ddc8b43bc28c12cdd181b85cbeadb79278475f3ae/pyarrow-22.0.0.tar.gz", hash = "sha256:3d600dc583260d845c7d8a6db540339dd883081925da2bd1c5cb808f720b3cd9", size = 1151151, upload-time = "2025-10-24T12:30:00.762Z" } 121 + wheels = [ 122 + { url = "https://files.pythonhosted.org/packages/a6/d6/d0fac16a2963002fc22c8fa75180a838737203d558f0ed3b564c4a54eef5/pyarrow-22.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e6e95176209257803a8b3d0394f21604e796dadb643d2f7ca21b66c9c0b30c9a", size = 34204629, upload-time = "2025-10-24T10:06:20.274Z" }, 123 + { url = "https://files.pythonhosted.org/packages/c6/9c/1d6357347fbae062ad3f17082f9ebc29cc733321e892c0d2085f42a2212b/pyarrow-22.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:001ea83a58024818826a9e3f89bf9310a114f7e26dfe404a4c32686f97bd7901", size = 35985783, upload-time = "2025-10-24T10:06:27.301Z" }, 124 + { url = "https://files.pythonhosted.org/packages/ff/c0/782344c2ce58afbea010150df07e3a2f5fdad299cd631697ae7bd3bac6e3/pyarrow-22.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ce20fe000754f477c8a9125543f1936ea5b8867c5406757c224d745ed033e691", size = 45020999, upload-time = "2025-10-24T10:06:35.387Z" }, 125 + { url = "https://files.pythonhosted.org/packages/1b/8b/5362443737a5307a7b67c1017c42cd104213189b4970bf607e05faf9c525/pyarrow-22.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e0a15757fccb38c410947df156f9749ae4a3c89b2393741a50521f39a8cf202a", size = 47724601, upload-time = "2025-10-24T10:06:43.551Z" }, 126 + { url = "https://files.pythonhosted.org/packages/69/4d/76e567a4fc2e190ee6072967cb4672b7d9249ac59ae65af2d7e3047afa3b/pyarrow-22.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cedb9dd9358e4ea1d9bce3665ce0797f6adf97ff142c8e25b46ba9cdd508e9b6", size = 48001050, upload-time = "2025-10-24T10:06:52.284Z" }, 127 + { url = "https://files.pythonhosted.org/packages/01/5e/5653f0535d2a1aef8223cee9d92944cb6bccfee5cf1cd3f462d7cb022790/pyarrow-22.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:252be4a05f9d9185bb8c18e83764ebcfea7185076c07a7a662253af3a8c07941", size = 50307877, upload-time = "2025-10-24T10:07:02.405Z" }, 128 + { url = "https://files.pythonhosted.org/packages/2d/f8/1d0bd75bf9328a3b826e24a16e5517cd7f9fbf8d34a3184a4566ef5a7f29/pyarrow-22.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:a4893d31e5ef780b6edcaf63122df0f8d321088bb0dee4c8c06eccb1ca28d145", size = 27977099, upload-time = "2025-10-24T10:08:07.259Z" }, 129 + { url = "https://files.pythonhosted.org/packages/90/81/db56870c997805bf2b0f6eeeb2d68458bf4654652dccdcf1bf7a42d80903/pyarrow-22.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:f7fe3dbe871294ba70d789be16b6e7e52b418311e166e0e3cba9522f0f437fb1", size = 34336685, upload-time = "2025-10-24T10:07:11.47Z" }, 130 + { url = "https://files.pythonhosted.org/packages/1c/98/0727947f199aba8a120f47dfc229eeb05df15bcd7a6f1b669e9f882afc58/pyarrow-22.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:ba95112d15fd4f1105fb2402c4eab9068f0554435e9b7085924bcfaac2cc306f", size = 36032158, upload-time = "2025-10-24T10:07:18.626Z" }, 131 + { url = "https://files.pythonhosted.org/packages/96/b4/9babdef9c01720a0785945c7cf550e4acd0ebcd7bdd2e6f0aa7981fa85e2/pyarrow-22.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c064e28361c05d72eed8e744c9605cbd6d2bb7481a511c74071fd9b24bc65d7d", size = 44892060, upload-time = "2025-10-24T10:07:26.002Z" }, 132 + { url = "https://files.pythonhosted.org/packages/f8/ca/2f8804edd6279f78a37062d813de3f16f29183874447ef6d1aadbb4efa0f/pyarrow-22.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6f9762274496c244d951c819348afbcf212714902742225f649cf02823a6a10f", size = 47504395, upload-time = "2025-10-24T10:07:34.09Z" }, 133 + { url = "https://files.pythonhosted.org/packages/b9/f0/77aa5198fd3943682b2e4faaf179a674f0edea0d55d326d83cb2277d9363/pyarrow-22.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a9d9ffdc2ab696f6b15b4d1f7cec6658e1d788124418cb30030afbae31c64746", size = 48066216, upload-time = "2025-10-24T10:07:43.528Z" }, 134 + { url = "https://files.pythonhosted.org/packages/79/87/a1937b6e78b2aff18b706d738c9e46ade5bfcf11b294e39c87706a0089ac/pyarrow-22.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ec1a15968a9d80da01e1d30349b2b0d7cc91e96588ee324ce1b5228175043e95", size = 50288552, upload-time = "2025-10-24T10:07:53.519Z" }, 135 + { url = "https://files.pythonhosted.org/packages/60/ae/b5a5811e11f25788ccfdaa8f26b6791c9807119dffcf80514505527c384c/pyarrow-22.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bba208d9c7decf9961998edf5c65e3ea4355d5818dd6cd0f6809bec1afb951cc", size = 28262504, upload-time = "2025-10-24T10:08:00.932Z" }, 136 + { url = "https://files.pythonhosted.org/packages/bd/b0/0fa4d28a8edb42b0a7144edd20befd04173ac79819547216f8a9f36f9e50/pyarrow-22.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9bddc2cade6561f6820d4cd73f99a0243532ad506bc510a75a5a65a522b2d74d", size = 34224062, upload-time = "2025-10-24T10:08:14.101Z" }, 137 + { url = "https://files.pythonhosted.org/packages/0f/a8/7a719076b3c1be0acef56a07220c586f25cd24de0e3f3102b438d18ae5df/pyarrow-22.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e70ff90c64419709d38c8932ea9fe1cc98415c4f87ea8da81719e43f02534bc9", size = 35990057, upload-time = "2025-10-24T10:08:21.842Z" }, 138 + { url = "https://files.pythonhosted.org/packages/89/3c/359ed54c93b47fb6fe30ed16cdf50e3f0e8b9ccfb11b86218c3619ae50a8/pyarrow-22.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:92843c305330aa94a36e706c16209cd4df274693e777ca47112617db7d0ef3d7", size = 45068002, upload-time = "2025-10-24T10:08:29.034Z" }, 139 + { url = "https://files.pythonhosted.org/packages/55/fc/4945896cc8638536ee787a3bd6ce7cec8ec9acf452d78ec39ab328efa0a1/pyarrow-22.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:6dda1ddac033d27421c20d7a7943eec60be44e0db4e079f33cc5af3b8280ccde", size = 47737765, upload-time = "2025-10-24T10:08:38.559Z" }, 140 + { url = "https://files.pythonhosted.org/packages/cd/5e/7cb7edeb2abfaa1f79b5d5eb89432356155c8426f75d3753cbcb9592c0fd/pyarrow-22.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:84378110dd9a6c06323b41b56e129c504d157d1a983ce8f5443761eb5256bafc", size = 48048139, upload-time = "2025-10-24T10:08:46.784Z" }, 141 + { url = "https://files.pythonhosted.org/packages/88/c6/546baa7c48185f5e9d6e59277c4b19f30f48c94d9dd938c2a80d4d6b067c/pyarrow-22.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:854794239111d2b88b40b6ef92aa478024d1e5074f364033e73e21e3f76b25e0", size = 50314244, upload-time = "2025-10-24T10:08:55.771Z" }, 142 + { url = "https://files.pythonhosted.org/packages/3c/79/755ff2d145aafec8d347bf18f95e4e81c00127f06d080135dfc86aea417c/pyarrow-22.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:b883fe6fd85adad7932b3271c38ac289c65b7337c2c132e9569f9d3940620730", size = 28757501, upload-time = "2025-10-24T10:09:59.891Z" }, 143 + { url = "https://files.pythonhosted.org/packages/0e/d2/237d75ac28ced3147912954e3c1a174df43a95f4f88e467809118a8165e0/pyarrow-22.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7a820d8ae11facf32585507c11f04e3f38343c1e784c9b5a8b1da5c930547fe2", size = 34355506, upload-time = "2025-10-24T10:09:02.953Z" }, 144 + { url = "https://files.pythonhosted.org/packages/1e/2c/733dfffe6d3069740f98e57ff81007809067d68626c5faef293434d11bd6/pyarrow-22.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:c6ec3675d98915bf1ec8b3c7986422682f7232ea76cad276f4c8abd5b7319b70", size = 36047312, upload-time = "2025-10-24T10:09:10.334Z" }, 145 + { url = "https://files.pythonhosted.org/packages/7c/2b/29d6e3782dc1f299727462c1543af357a0f2c1d3c160ce199950d9ca51eb/pyarrow-22.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3e739edd001b04f654b166204fc7a9de896cf6007eaff33409ee9e50ceaff754", size = 45081609, upload-time = "2025-10-24T10:09:18.61Z" }, 146 + { url = "https://files.pythonhosted.org/packages/8d/42/aa9355ecc05997915af1b7b947a7f66c02dcaa927f3203b87871c114ba10/pyarrow-22.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7388ac685cab5b279a41dfe0a6ccd99e4dbf322edfb63e02fc0443bf24134e91", size = 47703663, upload-time = "2025-10-24T10:09:27.369Z" }, 147 + { url = "https://files.pythonhosted.org/packages/ee/62/45abedde480168e83a1de005b7b7043fd553321c1e8c5a9a114425f64842/pyarrow-22.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f633074f36dbc33d5c05b5dc75371e5660f1dbf9c8b1d95669def05e5425989c", size = 48066543, upload-time = "2025-10-24T10:09:34.908Z" }, 148 + { url = "https://files.pythonhosted.org/packages/84/e9/7878940a5b072e4f3bf998770acafeae13b267f9893af5f6d4ab3904b67e/pyarrow-22.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4c19236ae2402a8663a2c8f21f1870a03cc57f0bef7e4b6eb3238cc82944de80", size = 50288838, upload-time = "2025-10-24T10:09:44.394Z" }, 149 + { url = "https://files.pythonhosted.org/packages/7b/03/f335d6c52b4a4761bcc83499789a1e2e16d9d201a58c327a9b5cc9a41bd9/pyarrow-22.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0c34fe18094686194f204a3b1787a27456897d8a2d62caf84b61e8dfbc0252ae", size = 29185594, upload-time = "2025-10-24T10:09:53.111Z" }, 150 + ] 151 + 152 + [[package]] 153 + name = "scripts" 154 + version = "0.1.0" 155 + source = { virtual = "." } 156 + dependencies = [ 157 + { name = "adbc-driver-manager" }, 158 + { name = "adbc-driver-sqlite" }, 159 + { name = "duckdb" }, 160 + { name = "faker" }, 161 + { name = "polars" }, 162 + { name = "pyarrow" }, 163 + { name = "tqdm" }, 164 + ] 165 + 166 + [package.metadata] 167 + requires-dist = [ 168 + { name = "adbc-driver-manager", specifier = "==1.6.0" }, 169 + { name = "adbc-driver-sqlite", specifier = "==1.6.0" }, 170 + { name = "duckdb", specifier = ">=1.4.3" }, 171 + { name = "faker", specifier = "==39.0.0" }, 172 + { name = "polars", specifier = "==1.35.2" }, 173 + { name = "pyarrow", specifier = ">=22.0.0" }, 174 + { name = "tqdm", specifier = "==4.67.1" }, 175 + ] 176 + 177 + [[package]] 178 + name = "tqdm" 179 + version = "4.67.1" 180 + source = { registry = "https://pypi.org/simple" } 181 + dependencies = [ 182 + { name = "colorama", marker = "sys_platform == 'win32'" }, 183 + ] 184 + sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } 185 + wheels = [ 186 + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, 187 + ] 188 + 189 + [[package]] 190 + name = "typing-extensions" 191 + version = "4.15.0" 192 + source = { registry = "https://pypi.org/simple" } 193 + sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } 194 + wheels = [ 195 + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, 196 + ] 197 + 198 + [[package]] 199 + name = "tzdata" 200 + version = "2025.3" 201 + source = { registry = "https://pypi.org/simple" } 202 + sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } 203 + wheels = [ 204 + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, 205 + ]
+115
taskfile.yml
··· 1 + # https://taskfile.dev 2 + 3 + version: "3" 4 + 5 + vars: 6 + MB_GOOSE_DRIVER: sqlite3 7 + MB_GOOSE_MIGRATIONS_PATH: private/migrations/musicbrainz 8 + MB_DB_PATH: private/database/musicbrainz.dev.db 9 + 10 + APP_GOOSE_DRIVER: sqlite3 11 + APP_GOOSE_MIGRATIONS_PATH: private/migrations/app 12 + APP_DB_PATH: private/database/app.dev.db 13 + 14 + STATS_GOOSE_DRIVER: sqlite3 15 + STATS_GOOSE_MIGRATIONS_PATH: private/migrations/statistics 16 + STATS_DB_PATH: private/database/statistics.dev.duckdb 17 + 18 + tasks: 19 + migrate:up.mb: 20 + cmd: goose {{ .MB_GOOSE_DRIVER }} {{ .MB_DB_PATH }} -dir {{ .MB_GOOSE_MIGRATIONS_PATH }} up 21 + silent: true 22 + migrate:create.mb: 23 + cmd: goose {{ .MB_GOOSE_DRIVER }} {{ .MB_DB_PATH }} -dir {{ .MB_GOOSE_MIGRATIONS_PATH }} create {{ .CLI_ARGS }} sql 24 + silent: true 25 + migrate:down.mb: 26 + cmd: goose {{ .MB_GOOSE_DRIVER }} {{ .MB_DB_PATH }} -dir {{ .MB_GOOSE_MIGRATIONS_PATH }} down 27 + silent: true 28 + seed.mb: 29 + cmds: 30 + - source scripts/.venv/bin/activate 31 + - python scripts/seeds/musicbrainz.py 32 + reset.mb: 33 + cmds: 34 + - rm -f {{ .MB_DB_PATH }} 35 + - task: migrate:up.mb 36 + - task: seed.mb 37 + 38 + migrate:up.app: 39 + cmd: goose {{ .APP_GOOSE_DRIVER }} {{ .APP_DB_PATH }} -dir {{ .APP_GOOSE_MIGRATIONS_PATH }} up 40 + silent: true 41 + migrate:create.app: 42 + cmd: goose {{ .APP_GOOSE_DRIVER }} {{ .APP_DB_PATH }} -dir {{ .APP_GOOSE_MIGRATIONS_PATH }} create {{ .CLI_ARGS }} sql 43 + silent: true 44 + migrate:down.app: 45 + cmd: goose {{ .APP_GOOSE_DRIVER }} {{ .APP_DB_PATH }} -dir {{ .APP_GOOSE_MIGRATIONS_PATH }} down 46 + silent: true 47 + seed.app: 48 + cmds: 49 + - sqlite3 {{ .APP_DB_PATH }} < scripts/seed_app.sql 50 + - source scripts/.venv/bin/activate 51 + - python scripts/seed_app.py 52 + reset.app: 53 + cmds: 54 + - rm -rf {{ .APP_DB_PATH }} 55 + - task: migrate:up.app 56 + - task: seed.app 57 + 58 + # migrate:up.stats: 59 + # cmd: goose {{ .STATS_GOOSE_DRIVER }} {{ .STATS_DB_PATH }} -dir {{ .STATS_GOOSE_MIGRATIONS_PATH }} up 60 + # silent: true 61 + migrate:up.stats: 62 + cmd: python scripts/migrations/statistics.py 63 + 64 + migrate:create.stats: 65 + cmd: goose {{ .STATS_GOOSE_DRIVER }} {{ .STATS_DB_PATH }} -dir {{ .STATS_GOOSE_MIGRATIONS_PATH }} create {{ .CLI_ARGS }} sql 66 + silent: true 67 + 68 + migrate:down.stats: 69 + cmd: goose {{ .STATS_GOOSE_DRIVER }} {{ .STATS_DB_PATH }} -dir {{ .STATS_GOOSE_MIGRATIONS_PATH }} down 70 + silent: true 71 + seed.stats: 72 + cmds: 73 + - source scripts/.venv/bin/activate 74 + - python scripts/seeds/statistics.py 75 + reset.stats: 76 + cmds: 77 + - rm -rf {{ .STATS_DB_PATH }} 78 + - duckdb -no-stdin {{ .STATS_DB_PATH }} 79 + - task: migrate:up.stats 80 + - task: seed.stats 81 + - duckdb private/database/statistics.dev.duckdb < scripts/seeds/statistics.sql 82 + 83 + build:*: 84 + cmd: go build -o bin/{{ index .MATCH 0 }} cmd/{{ index .MATCH 0 }}/main.go 85 + 86 + run:*: 87 + silent: true 88 + cmds: 89 + - task: build:{{ index .MATCH 0 }} 90 + - ./bin/{{ index .MATCH 0 }} {{ .CLI_ARGS }} 91 + 92 + generate:schemas: 93 + silent: true 94 + cmds: 95 + - task: build:bridge 96 + - ./bin/bridge schema --package github.com/oscar345/keeptrack/internal/models --path web/src/lib/.gen/schemas.ts 97 + 98 + generate:routes: 99 + silent: true 100 + cmds: 101 + - task: build:bridge 102 + - ./bin/bridge route --path web/src/lib/.gen/routes.ts 103 + 104 + serve: 105 + watch: true 106 + sources: 107 + - "**/*.go" 108 + deps: 109 + - task: run:server 110 + - task: serve-frontend 111 + cmd: echo "serving" 112 + 113 + serve-frontend: 114 + dir: web 115 + cmd: npm run dev
+67
test/test_repo/artist_test.go
··· 1 + package testrepo 2 + 3 + import ( 4 + "maps" 5 + "slices" 6 + "testing" 7 + 8 + "github.com/oscar345/keeptrack/internal/repo/db" 9 + ) 10 + 11 + func TestArtistRepoDBListByIDs(t *testing.T) { 12 + repo := db.NewArtistRepoDB(DBmb) 13 + 14 + mbids := []string{ 15 + "ff3fcff7-6c85-49d6-b954-ed7011a44e5a", 16 + "127d7cb3-d63c-411f-91fe-41f5ee692725", 17 + "eefd7c1e-abcf-4ccc-ba60-0fd435c9061f", 18 + "e38bb7a2-c3e5-4be2-894b-7078c40b9955", 19 + "d770374d-05e9-4ed3-a068-3fbd4e6e4dd6", 20 + "8bfac288-ccc5-448d-9573-c33ea2aa5c30", 21 + "125ec42a-7229-4250-afc5-e057484327fe", 22 + } 23 + 24 + artists, err := repo.ListByIDs(t.Context(), mbids) 25 + if err != nil { 26 + t.Fatal(err) 27 + } 28 + 29 + if len(artists) != len(mbids) { 30 + t.Fatalf("expected %d artists, got %d", len(mbids), len(artists)) 31 + } 32 + 33 + for i, artist := range slices.Collect(maps.Values(artists)) { 34 + if !slices.Contains(mbids, artist.MBID) { 35 + t.Fatalf("expected mbid %s, got %s", mbids[i], artist.MBID) 36 + } 37 + } 38 + } 39 + 40 + func TestArtistRepoDBListByIDsWithWrongID(t *testing.T) { 41 + repo := db.NewArtistRepoDB(DBmb) 42 + 43 + mbids := []string{ 44 + "ff3fcff7-6c85-49d6-b954-ed7011a44e5a", 45 + "127d7cb3-d63c-411f-91fe-41f5ee692725", 46 + "eefd7c1e-abcf-4ccc-ba60-0fd435c9061f", 47 + "e38bb7a2-c3e5-4be2-894b-7078c40b9955", 48 + "d770374d-05e9-4ed3-a068-3fbd4e6e4dd6", 49 + "8bfac288-ccc5-448d-9573-c33ea2aa5c30", 50 + "125ec42a-7229-4250-afc5-e057484327fr", 51 + } 52 + 53 + artists, err := repo.ListByIDs(t.Context(), mbids) 54 + if err != nil { 55 + t.Fatal(err) 56 + } 57 + 58 + if len(artists) != len(mbids)-1 { 59 + t.Fatalf("expected %d artists, got %d", len(mbids), len(artists)) 60 + } 61 + 62 + for i, artist := range slices.Collect(maps.Values(artists)) { 63 + if !slices.Contains(mbids, artist.MBID) { 64 + t.Fatalf("expected mbid %s, got %s", mbids[i], artist.MBID) 65 + } 66 + } 67 + }
+34
test/test_repo/main_test.go
··· 1 + package testrepo 2 + 3 + import ( 4 + "database/sql" 5 + "log" 6 + "os" 7 + "testing" 8 + 9 + _ "github.com/mattn/go-sqlite3" 10 + "github.com/oscar345/keeptrack/pkg/utilities" 11 + "github.com/oscar345/keeptrack/test/testutilities" 12 + ) 13 + 14 + var DBmb *sql.DB 15 + 16 + func UnwrapError(err error) { 17 + if err != nil { 18 + log.Fatal(err) 19 + } 20 + } 21 + 22 + func TestMain(m *testing.M) { 23 + var err error 24 + log.Println("Setting up test database") 25 + 26 + DBmb, err = utilities.OpenDatabaseWithAttachments( 27 + testutilities.FromRoot("private/database/musicbrainz.dev.db"), map[string]string{}, 28 + ) 29 + UnwrapError(err) 30 + 31 + code := m.Run() 32 + 33 + os.Exit(code) 34 + }
+31
test/testutilities/helpers.go
··· 1 + package testutilities 2 + 3 + import ( 4 + "bytes" 5 + "os/exec" 6 + "path/filepath" 7 + "strings" 8 + "sync" 9 + ) 10 + 11 + var ( 12 + moduleRoot string 13 + once sync.Once 14 + ) 15 + 16 + func ModuleRoot() string { 17 + once.Do(func() { 18 + cmd := exec.Command("go", "list", "-m", "-f", "{{.Dir}}") 19 + var out bytes.Buffer 20 + cmd.Stdout = &out 21 + if err := cmd.Run(); err != nil { 22 + panic("failed to determine module root") 23 + } 24 + moduleRoot = strings.TrimSpace(out.String()) 25 + }) 26 + return moduleRoot 27 + } 28 + 29 + func FromRoot(path string) string { 30 + return filepath.Join(ModuleRoot(), path) 31 + }