From 3b494c97f2f5358d4108b0d01c80a8bc8176a847 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sat, 23 Aug 2025 02:04:25 +0200 Subject: [PATCH 01/15] refactor: backend --- .env.example | 6 +- .githooks/pre-commit | 4 +- .github/workflows/golangci-lint.yml | 8 +- .github/workflows/sqlc-diff.yml | 10 +- .golangci.yml | 86 ++- .tool-versions | 3 +- cmd/backend/backend.go | 4 +- cmd/tui/tui.go | 4 +- config/development.yaml | 190 ++++--- ...0822221717_alter_tap_add_category_enum.sql | 40 ++ db/queries/event.sql | 37 +- db/queries/gamification.sql | 31 +- db/queries/message.sql | 41 +- db/queries/scan.sql | 44 +- db/queries/season.sql | 30 +- db/queries/song.sql | 9 +- db/queries/tap.sql | 54 +- go.mod | 150 +++-- go.sum | 522 +++++++++++++----- internal/api/api.go | 17 - internal/api/message/message.go | 69 --- internal/api/song/song.go | 56 -- internal/{pkg => }/buzzer/buzzer.go | 0 internal/database/model/event.go | 27 + internal/database/model/gamification.go | 19 + internal/database/model/message.go | 25 + internal/database/model/scan.go | 21 + internal/database/model/season.go | 24 + internal/database/model/song.go | 115 ++++ internal/database/model/tap.go | 37 ++ internal/database/repository/event.go | 72 +++ internal/database/repository/gamification.go | 57 ++ internal/database/repository/message.go | 49 ++ internal/database/repository/repository.go | 40 ++ internal/database/repository/scan.go | 61 ++ internal/database/repository/season.go | 87 +++ internal/database/repository/song.go | 97 ++++ internal/database/repository/tap.go | 67 +++ internal/event/api.go | 100 ++++ internal/event/event.go | 47 ++ internal/gamification/api.go | 96 ++++ internal/gamification/gamification.go | 44 ++ internal/{pkg => }/lyrics/instrumental.go | 8 +- internal/{pkg => }/lyrics/lrc.go | 8 +- internal/{pkg => }/lyrics/lyrics.go | 6 +- internal/{pkg => }/lyrics/missing.go | 8 +- internal/{pkg => }/lyrics/plain.go | 8 +- internal/pkg/db/dto/dto.go | 9 - internal/pkg/db/dto/event.go | 47 -- internal/pkg/db/dto/gamification.go | 47 -- internal/pkg/db/dto/message.go | 36 -- internal/pkg/db/dto/scan.go | 41 -- internal/pkg/db/dto/season.go | 58 -- internal/pkg/db/dto/song.go | 163 ------ internal/pkg/db/sqlc/event.sql.go | 174 ------ internal/pkg/db/sqlc/gamification.sql.go | 149 ----- internal/pkg/db/sqlc/message.sql.go | 188 ------- internal/pkg/db/sqlc/scan.sql.go | 161 ------ internal/pkg/db/sqlc/season.sql.go | 176 ------ internal/pkg/db/sqlc/tap.sql.go | 287 ---------- internal/pkg/event/api.go | 117 ---- internal/pkg/event/event.go | 105 ---- internal/pkg/gamification/api.go | 78 --- internal/pkg/gamification/gamification.go | 45 -- internal/pkg/tap/api.go | 37 -- internal/pkg/tap/tap.go | 113 ---- internal/pkg/zess/api.go | 58 -- internal/pkg/zess/zess.go | 94 ---- internal/server/api/message.go | 45 ++ internal/server/api/song.go | 44 ++ internal/server/dto/dto.go | 6 + internal/server/dto/message.go | 3 + internal/server/dto/song.go | 3 + internal/server/server.go | 62 +++ internal/server/service/message.go | 18 + internal/server/service/service.go | 21 + internal/server/service/song.go | 18 + internal/{pkg => }/song/account.go | 0 internal/{pkg => }/song/api.go | 6 +- internal/{pkg => }/song/song.go | 81 +-- internal/tap/api.go | 70 +++ internal/tap/tap.go | 70 +++ internal/zess/api.go | 82 +++ internal/zess/zess.go | 76 +++ makefile | 60 +- pkg/config/config.go | 23 +- pkg/db/db.go | 15 + internal/pkg/db/db.go => pkg/db/psql.go | 47 +- pkg/logger/{logger.go => zap.go} | 36 +- {internal/pkg/db => pkg}/sqlc/db.go | 2 +- pkg/sqlc/event.sql.go | 118 ++++ pkg/sqlc/gamification.sql.go | 69 +++ pkg/sqlc/message.sql.go | 62 +++ {internal/pkg/db => pkg}/sqlc/models.go | 52 +- pkg/sqlc/scan.sql.go | 71 +++ pkg/sqlc/season.sql.go | 122 ++++ {internal/pkg/db => pkg}/sqlc/song.sql.go | 39 +- pkg/sqlc/tap.sql.go | 89 +++ pkg/util/map.go | 10 - pkg/util/slice.go | 31 -- pkg/utils/http.go | 37 ++ pkg/utils/map.go | 29 + pkg/utils/slice.go | 131 +++++ sqlc.yml | 2 +- tui/view/gamification/gamification.go | 3 +- tui/view/message/message.go | 2 +- tui/view/song/song.go | 16 +- tui/view/song/view.go | 4 +- tui/view/tap/tap.go | 2 +- tui/view/zess/style.go | 2 +- tui/view/zess/zess.go | 4 +- 111 files changed, 3314 insertions(+), 3090 deletions(-) create mode 100644 db/migrations/20250822221717_alter_tap_add_category_enum.sql delete mode 100644 internal/api/api.go delete mode 100644 internal/api/message/message.go delete mode 100644 internal/api/song/song.go rename internal/{pkg => }/buzzer/buzzer.go (100%) create mode 100644 internal/database/model/event.go create mode 100644 internal/database/model/gamification.go create mode 100644 internal/database/model/message.go create mode 100644 internal/database/model/scan.go create mode 100644 internal/database/model/season.go create mode 100644 internal/database/model/song.go create mode 100644 internal/database/model/tap.go create mode 100644 internal/database/repository/event.go create mode 100644 internal/database/repository/gamification.go create mode 100644 internal/database/repository/message.go create mode 100644 internal/database/repository/repository.go create mode 100644 internal/database/repository/scan.go create mode 100644 internal/database/repository/season.go create mode 100644 internal/database/repository/song.go create mode 100644 internal/database/repository/tap.go create mode 100644 internal/event/api.go create mode 100644 internal/event/event.go create mode 100644 internal/gamification/api.go create mode 100644 internal/gamification/gamification.go rename internal/{pkg => }/lyrics/instrumental.go (98%) rename internal/{pkg => }/lyrics/lrc.go (94%) rename internal/{pkg => }/lyrics/lyrics.go (87%) rename internal/{pkg => }/lyrics/missing.go (88%) rename internal/{pkg => }/lyrics/plain.go (88%) delete mode 100644 internal/pkg/db/dto/dto.go delete mode 100644 internal/pkg/db/dto/event.go delete mode 100644 internal/pkg/db/dto/gamification.go delete mode 100644 internal/pkg/db/dto/message.go delete mode 100644 internal/pkg/db/dto/scan.go delete mode 100644 internal/pkg/db/dto/season.go delete mode 100644 internal/pkg/db/dto/song.go delete mode 100644 internal/pkg/db/sqlc/event.sql.go delete mode 100644 internal/pkg/db/sqlc/gamification.sql.go delete mode 100644 internal/pkg/db/sqlc/message.sql.go delete mode 100644 internal/pkg/db/sqlc/scan.sql.go delete mode 100644 internal/pkg/db/sqlc/season.sql.go delete mode 100644 internal/pkg/db/sqlc/tap.sql.go delete mode 100644 internal/pkg/event/api.go delete mode 100644 internal/pkg/event/event.go delete mode 100644 internal/pkg/gamification/api.go delete mode 100644 internal/pkg/gamification/gamification.go delete mode 100644 internal/pkg/tap/api.go delete mode 100644 internal/pkg/tap/tap.go delete mode 100644 internal/pkg/zess/api.go delete mode 100644 internal/pkg/zess/zess.go create mode 100644 internal/server/api/message.go create mode 100644 internal/server/api/song.go create mode 100644 internal/server/dto/dto.go create mode 100644 internal/server/dto/message.go create mode 100644 internal/server/dto/song.go create mode 100644 internal/server/server.go create mode 100644 internal/server/service/message.go create mode 100644 internal/server/service/service.go create mode 100644 internal/server/service/song.go rename internal/{pkg => }/song/account.go (100%) rename internal/{pkg => }/song/api.go (95%) rename internal/{pkg => }/song/song.go (64%) create mode 100644 internal/tap/api.go create mode 100644 internal/tap/tap.go create mode 100644 internal/zess/api.go create mode 100644 internal/zess/zess.go create mode 100644 pkg/db/db.go rename internal/pkg/db/db.go => pkg/db/psql.go (51%) rename pkg/logger/{logger.go => zap.go} (52%) rename {internal/pkg/db => pkg}/sqlc/db.go (96%) create mode 100644 pkg/sqlc/event.sql.go create mode 100644 pkg/sqlc/gamification.sql.go create mode 100644 pkg/sqlc/message.sql.go rename {internal/pkg/db => pkg}/sqlc/models.go (56%) create mode 100644 pkg/sqlc/scan.sql.go create mode 100644 pkg/sqlc/season.sql.go rename {internal/pkg/db => pkg}/sqlc/song.sql.go (95%) create mode 100644 pkg/sqlc/tap.sql.go delete mode 100644 pkg/util/map.go delete mode 100644 pkg/util/slice.go create mode 100644 pkg/utils/http.go create mode 100644 pkg/utils/map.go create mode 100644 pkg/utils/slice.go diff --git a/.env.example b/.env.example index 580ea18..d788a66 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,3 @@ -APP_ENV = development -SONG_SPOTIFY_CLIENT_ID = your_client_id -SONG_SPOTIFY_CLIENT_SECRET = your_client_secret +APP_ENV= +SONG_SPOTIFY_CLIENT_ID= +SONG_SPOTIFY_CLIENT_SECRET= diff --git a/.githooks/pre-commit b/.githooks/pre-commit index dfd1704..908d561 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -3,6 +3,6 @@ echo "Linting" golangci-lint run if [ $? -ne 0 ]; then - echo "golangci-lint failed. Please fix the errors before committing." - exit 1 + echo "golangci-lint failed." + exit 1 fi diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 54f3d7e..ae8a9ca 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -11,7 +11,7 @@ permissions: pull-requests: read jobs: - golangci: + lint: name: lint runs-on: ubuntu-latest @@ -22,9 +22,9 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: 1.23.1 + go-version: 1.25.0 - name: golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v7 with: - version: v1.60 + version: v2.4.0 diff --git a/.github/workflows/sqlc-diff.yml b/.github/workflows/sqlc-diff.yml index d5b3ce0..7a01341 100644 --- a/.github/workflows/sqlc-diff.yml +++ b/.github/workflows/sqlc-diff.yml @@ -5,8 +5,8 @@ jobs: diff: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: sqlc-dev/setup-sqlc@v3 - with: - sqlc-version: '1.27.0' - - run: sqlc diff + - uses: actions/checkout@v4 + - uses: sqlc-dev/setup-sqlc@v3 + with: + sqlc-version: "1.29.0" + - run: sqlc diff diff --git a/.golangci.yml b/.golangci.yml index a0c6843..d279320 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,35 +1,65 @@ +version: "2" + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + fix: true + run: timeout: 5m modules-download-mode: readonly linters: enable: - - bodyclose # checks whether HTTP response body is closed successfully - - copyloopvar # detects copy loop variable - - errcheck # checks for unchecked errors in go programs - - errname # checks that sentinel errors are prefixed with the `Err` and error types are suffixed with the `Error` - - gochecknoinits # checks that no init functions are present in Go code - - goimports # check import statements are formatted correctly - - gosimple # checks for code simplifications in Go code - - govet # runs the go vet tool - - importas # enforces consistent import aliases - - ineffassign # detects when assignments to existing variables are not used - - noctx # finds sending http request without context.Context - - paralleltest # detects missing usage of t.Parallel() method in go tests - - prealloc # finds slice declarations that could potentially be preallocated - - revive # checks for golang coding style - - rowserrcheck # checks whether Err of rows is checked successfully - - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed - - staticcheck # Applies static code analysis - - tenv # detects using os.Setenv instead of t.Setenv - - testpackage # makes you use a separate _test package - - thelper # detects golang test helpers without t.Helper() call and checks consistency of test helpers - - unconvert # removes unnecessary type conversions - - unparam # removes unused function parameters - - unused # finds unused variables - fast: true + - copyloopvar # https://github.com/karamaru-alpha/copyloopvar?tab=readme-ov-file + - errchkjson # https://github.com/breml/errchkjson + - errname # https://github.com/Antonboom/errname + - errorlint # https://github.com/polyfloyd/go-errorlint + - exhaustive # https://github.com/nishanths/exhaustive + - exptostd # https://github.com/ldez/exptostd + - gocritic # https://github.com/go-critic/go-critic?tab=readme-ov-file + - loggercheck # https://github.com/timonwong/loggercheck + - perfsprint # https://github.com/catenacyber/perfsprint + - prealloc # https://github.com/alexkohler/prealloc + - revive # https://github.com/mgechev/revive?tab=readme-ov-file#available-rules + - unconvert # https://github.com/mdempsky/unconvert + - unparam # https://github.com/mvdan/unparam -issues: - exclude-use-default: false - max-issues-per-linter: 0 - max-same-issues: 0 + settings: + copyloopvar: + check-alias: true + errchkjson: + report-no-exported: true + exhaustive: + default-signifies-exhaustive: true + loggercheck: + kitlog: false + klog: false + logr: false + slog: false + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: dot-imports + - name: empty-block + - name: error-naming + - name: error-return + - name: error-strings + - name: errorf + - name: increment-decrement + - name: indent-error-flow + - name: range + - name: receiver-naming + - name: redefines-builtin-id + - name: superfluous-else + - name: time-naming + - name: unexported-return + - name: unreachable-code + - name: unused-parameter + - name: var-declaration + - name: var-naming + arguments: + - [] + - [] + - - skip-package-name-checks: true diff --git a/.tool-versions b/.tool-versions index c9953ae..3a6ce55 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1,2 @@ -golang 1.23.1 +golang 1.25.0 +golangci-lint 2.4.0 diff --git a/cmd/backend/backend.go b/cmd/backend/backend.go index 6c464bc..ddeea6f 100644 --- a/cmd/backend/backend.go +++ b/cmd/backend/backend.go @@ -3,8 +3,8 @@ package main import ( "github.com/zeusWPI/scc/internal/cmd" - "github.com/zeusWPI/scc/internal/pkg/db" "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/pkg/db" "github.com/zeusWPI/scc/pkg/logger" "go.uber.org/zap" ) @@ -26,7 +26,7 @@ func main() { zap.S().Info("Initializing backend") // Database - db, err := db.New() + db, err := db.NewPSQL() if err != nil { zap.S().Fatal("DB: Fatal error\n", err) } diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go index b2d6669..ea2604f 100644 --- a/cmd/tui/tui.go +++ b/cmd/tui/tui.go @@ -3,8 +3,8 @@ package main import ( "github.com/zeusWPI/scc/internal/cmd" - "github.com/zeusWPI/scc/internal/pkg/db" "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/pkg/db" "github.com/zeusWPI/scc/pkg/logger" "go.uber.org/zap" ) @@ -26,7 +26,7 @@ func main() { zap.S().Info("Initializing TUI") // Database - db, err := db.New() + db, err := db.NewPSQL() if err != nil { zap.S().Fatal("DB: Fatal error\n", err) } diff --git a/config/development.yaml b/config/development.yaml index 50ddcb3..a7ce00d 100644 --- a/config/development.yaml +++ b/config/development.yaml @@ -1,114 +1,112 @@ server: - host: "0.0.0.0" - port: 3000 + host: "0.0.0.0" + port: 3000 db: - host: "localhost" - port: 5432 - user: "postgres" - password: "postgres" + host: "localhost" + port: 5432 + user: "postgres" + password: "postgres" backend: - buzzer: - song: - - "-n" - - "-f880" - - "-l100" - - "-d0" - - "-n" - - "-f988" - - "-l100" - - "-d0" - - "-n" - - "-f588" - - "-l100" - - "-d0" - - "-n" - - "-f989" - - "-l100" - - "-d0" - - "-n" - - "-f660" - - "-l200" - - "-d0" - - "-n" - - "-f660" - - "-l200" - - "-d0" - - "-n" - - "-f588" - - "-l100" - - "-d0" - - "-n" - - "-f555" - - "-l100" - - "-d0" - - "-n" - - "-f495" - - "-l100" - - "-d0" - - event: - website: "https://zeus.gent/events/" - website_poster: "https://git.zeus.gent/ZeusWPI/visueel/raw/branch/master" - interval_s: 3600 + buzzer: + song: + - "-n" + - "-f880" + - "-l100" + - "-d0" + - "-n" + - "-f988" + - "-l100" + - "-d0" + - "-n" + - "-f588" + - "-l100" + - "-d0" + - "-n" + - "-f989" + - "-l100" + - "-d0" + - "-n" + - "-f660" + - "-l200" + - "-d0" + - "-n" + - "-f660" + - "-l200" + - "-d0" + - "-n" + - "-f588" + - "-l100" + - "-d0" + - "-n" + - "-f555" + - "-l100" + - "-d0" + - "-n" + - "-f495" + - "-l100" + - "-d0" - gamification: - api: "https://gamification.zeus.gent" - interval_s: 3600 + event: + url: "https://events.zeus.gent/api/v1" + interval_s: 3600 - song: - spotify_api: "https://api.spotify.com/v1" - spotify_api_account: "https://accounts.spotify.com/api/token" - lrclib_api: "https://lrclib.net/api" + gamification: + url: "https://gamification.zeus.gent" + interval_s: 3600 - tap: - api: "https://tap.zeus.gent" - beers: - - "Schelfaut" - - "Duvel" - - "Fourchette" - - "Jupiler" - - "Karmeliet" - - "Kriek" - - "Chouffe" - - "Maes" - - "Somersby" - - "Sportzot" - - "Stella" - interval_s: 60 + song: + spotify_url: "https://api.spotify.com/v1" + spotify_url_account: "https://accounts.spotify.com/api/token" + lrclib_url: "https://lrclib.net/api" - zess: - api: "https://zess.zeus.gent/api" - interval_season_s: 300 - interval_scan_s: 60 + tap: + url: "https://tap.zeus.gent" + beers: + - "Schelfaut" + - "Duvel" + - "Fourchette" + - "Jupiler" + - "Karmeliet" + - "Kriek" + - "Chouffe" + - "Maes" + - "Somersby" + - "Sportzot" + - "Stella" + interval_s: 60 + zess: + url: "https://zess.zeus.gent/api" + interval_season_s: 3600 + interval_scan_s: 60 tui: - screen: - cammie: - interval_s: 300 + screen: + cammie: + interval_s: 300 - view: - event: - interval_s: 3600 + view: + event: + interval_s: 3600 - gamification: - interval_s: 3600 + gamification: + interval_s: 3600 - message: - interval_s: 1 + message: + interval_s: 1 - song: - interval_current_s: 5 - interval_history_s: 5 - interval_monthly_stats_s: 300 - interval_stats_s: 3600 + song: + interval_current_s: 5 + interval_history_s: 5 + interval_monthly_stats_s: 300 + interval_stats_s: 3600 - tap: - interval_s: 60 + tap: + interval_s: 60 - zess: - weeks: 10 - interval_scan_s: 60 - interval_season_s: 3600 + zess: + weeks: 10 + interval_scan_s: 60 + interval_season_s: 3600 diff --git a/db/migrations/20250822221717_alter_tap_add_category_enum.sql b/db/migrations/20250822221717_alter_tap_add_category_enum.sql new file mode 100644 index 0000000..6a750fc --- /dev/null +++ b/db/migrations/20250822221717_alter_tap_add_category_enum.sql @@ -0,0 +1,40 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TYPE TAP_CATEGORY AS ENUM ('soft', 'mate', 'beer', 'food', 'unknown'); + +ALTER TABLE tap +ADD COLUMN new_category TAP_CATEGORY NOT NULL DEFAULT 'unknown'; + +UPDATE tap +SET new_category = CASE + WHEN LOWER(category) IN ('soft', 'mate', 'beer', 'food', 'unknown') + THEN LOWER(category)::TAP_CATEGORY + ELSE 'unknown'::TAP_CATEGORY +END; + +ALTER TABLE tap +DROP COLUMN category; + +ALTER TABLE tap +RENAME new_category TO category; + +ALTER TABLE tap +ALTER COLUMN category DROP DEFAULT +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE tap +ADD COLUMN old_category TEXT; + +UPDATE tap +SET old_category = category::TEXT; + +ALTER TABLE tap +DROP COLUMN category; + +ALTER TABLE tap +RENAME COLUMN old_category TO category; + +DROP TYPE TAP_CATEGORY; +-- +goose StatementEnd diff --git a/db/queries/event.sql b/db/queries/event.sql index 9646c2b..f05f898 100644 --- a/db/queries/event.sql +++ b/db/queries/event.sql @@ -1,33 +1,9 @@ --- CRUD - - --- name: GetAllEvents :many -SELECT * -FROM event; - --- name: CreateEvent :one -INSERT INTO event (name, date, academic_year, location, poster) -VALUES ($1, $2, $3, $4, $5) -RETURNING *; - --- name: DeleteEvent :exec -DELETE FROM event -WHERE id = $1; - - --- Other - - --- name: GetEventByAcademicYear :many +-- name: EventGetAllByYear :many SELECT * FROM event WHERE academic_year = $1; --- name: DeleteEventByAcademicYear :exec -DELETE FROM event -WHERE academic_year = $1; - --- name: GetEventsCurrentAcademicYear :many +-- name: EventGetAllByCurrentYear :many SELECT * FROM event WHERE academic_year = ( @@ -35,3 +11,12 @@ WHERE academic_year = ( FROM event ) ORDER BY date ASC; + +-- name: EventCreate :one +INSERT INTO event (name, date, academic_year, location, poster) +VALUES ($1, $2, $3, $4, $5) +RETURNING id; + + +-- name: EventDeleteAll :exec +DELETE FROM event; diff --git a/db/queries/gamification.sql b/db/queries/gamification.sql index 3113a19..a5fe85b 100644 --- a/db/queries/gamification.sql +++ b/db/queries/gamification.sql @@ -1,32 +1,13 @@ --- CRUD - --- name: GetAllGamification :many +-- name: GamificationGetAll :many SELECT * -FROM gamification; +FROM gamification +ORDER BY score DESC; --- name: CreateGamification :one +-- name: GamificationCreate :one INSERT INTO gamification (name, score, avatar) VALUES ($1, $2, $3) -RETURNING *; +RETURNING id; --- name: DeleteGamification :execrows -DELETE FROM gamification -WHERE id = $1; - --- name: DeleteGamificationAll :execrows +-- name: GamificationDeleteAll :exec DELETE FROM gamification; - --- Other - - --- name: UpdateGamificationScore :one -UPDATE gamification -SET score = $1 -WHERE id = $2 -RETURNING *; - --- name: GetAllGamificationByScore :many -SELECT * -FROM gamification -ORDER BY score DESC; diff --git a/db/queries/message.sql b/db/queries/message.sql index 2b615ed..61a8e29 100644 --- a/db/queries/message.sql +++ b/db/queries/message.sql @@ -1,41 +1,10 @@ --- CRUD - --- name: GetAllMessages :many -SELECT * -FROM message; - --- name: GetMessageByID :one +-- name: MessageGetSinceID :many SELECT * FROM message -WHERE id = $1; +WHERE id > $1 +ORDER BY created_at ASC; --- name: CreateMessage :one +-- name: MessageCreate :one INSERT INTO message (name, ip, message) VALUES ($1, $2, $3) -RETURNING *; - --- name: UpdateMessage :one -UPDATE message -SET name = $1, ip = $2, message = $3 -WHERE id = $4 -RETURNING *; - --- name: DeleteMessage :execrows -DELETE FROM message -WHERE id = $1; - - --- Other - - --- name: GetLastMessage :one -SELECT * -FROM message -ORDER BY id DESC -LIMIT 1; - --- name: GetMessageSinceID :many -SELECT * -FROM message -WHERE id > $1 -ORDER BY created_at ASC; +RETURNING id; diff --git a/db/queries/scan.sql b/db/queries/scan.sql index 706ffc5..c91e92f 100644 --- a/db/queries/scan.sql +++ b/db/queries/scan.sql @@ -1,47 +1,17 @@ --- CRUD - --- name: GetAllScans :many -SELECT * -FROM scan; - --- name: GetScanByID :one -SELECT * -FROM scan -WHERE id = $1; - --- name: CreateScan :one -INSERT INTO scan (scan_id, scan_time) -VALUES ($1, $2) -RETURNING *; - --- name: UpdateScan :one -UPDATE scan -SET scan_id = $1, scan_time = $2 -WHERE id = $3 -RETURNING *; - --- name: DeleteScan :execrows -DELETE FROM scan -WHERE id = $1; - - --- Other - - --- name: GetLastScan :one +-- name: ScanGetLast :one SELECT * FROM scan ORDER BY id DESC LIMIT 1; --- name: GetAllScansSinceID :many +-- name: ScanGetAllSinceID :many SELECT * FROM scan WHERE id > $1 ORDER BY scan_id, scan_time ASC; --- name: GetScansInCurrentSeason :one -SELECT COUNT(*) AS amount -FROM scan -WHERE scan_time >= (SELECT start_date FROM season WHERE current = true) AND - scan_time <= (SELECT end_date FROM season WHERE current = true); +-- name: ScanCreate :one +INSERT INTO scan (scan_id, scan_time) +VALUES ($1, $2) +RETURNING id; + diff --git a/db/queries/season.sql b/db/queries/season.sql index 94287be..e7e7952 100644 --- a/db/queries/season.sql +++ b/db/queries/season.sql @@ -1,38 +1,22 @@ --- CRUD - --- name: GetAllSeasons :many +-- name: SeasonGetAll :many SELECT * FROM season; --- name: GetSeasonByID :one +-- name: SeasonGetCurrent :one SELECT * FROM season -WHERE id = $1; +WHERE current = true; --- name: CreateSeason :one +-- name: SeasonCreate :one INSERT INTO season (name, start, "end", current) VALUES ($1, $2, $3, $4) -RETURNING *; +RETURNING id; --- name: UpdateSeason :one +-- name: SeasonUpdate :exec UPDATE season SET name = $1, start = $2, "end" = $3, current = $4 WHERE id = $5 RETURNING *; --- name: DeleteSeason :execrows -DELETE FROM season -WHERE id = $1; - --- name: DeleteSeasonAll :execrows +-- name: SeasonDeleteAll :exec DELETE FROM season; - - - --- Other - - --- name: GetSeasonCurrent :one -SELECT * -FROM season -WHERE current = true; diff --git a/db/queries/song.sql b/db/queries/song.sql index 210f367..c8a100c 100644 --- a/db/queries/song.sql +++ b/db/queries/song.sql @@ -3,7 +3,7 @@ -- name: CreateSong :one INSERT INTO song (title, album, spotify_id, duration_ms, lyrics_type, lyrics) VALUES ($1, $2, $3, $4, $5, $6) -RETURNING *; +RETURNING id; -- name: CreateSongHistory :one INSERT INTO song_history (song_id) @@ -23,7 +23,7 @@ RETURNING *; -- name: CreateSongArtistSong :one INSERT INTO song_artist_song (artist_id, song_id) VALUES ($1, $2) -RETURNING *; +RETURNING id; -- name: CreateSongArtistGenre :one INSERT INTO song_artist_genre (artist_id, genre_id) @@ -54,11 +54,6 @@ SELECT * FROM song_genre WHERE genre = $1; --- name: GetSongArtistByName :one -SELECT * -FROM song_artist -WHERE name = $1; - -- name: GetLastSongFull :many SELECT s.id, s.title AS song_title, s.spotify_id, s.album, s.duration_ms, s.lyrics_type, s.lyrics, sh.created_at, a.id AS artist_id, a.name AS artist_name, a.spotify_id AS artist_spotify_id, a.followers AS artist_followers, a.popularity AS artist_popularity, g.id AS genre_id, g.genre AS genre, sh.created_at FROM song_history sh diff --git a/db/queries/tap.sql b/db/queries/tap.sql index d2a4929..92bd755 100644 --- a/db/queries/tap.sql +++ b/db/queries/tap.sql @@ -1,56 +1,16 @@ --- CRUD - --- name: GetAllTaps :many -SELECT * -FROM tap; - --- name: GetTapByID :one -SELECT * -FROM tap -WHERE id = $1; - --- name: CreateTap :one -INSERT INTO tap (order_id, order_created_at, name, category) -VALUES ($1, $2, $3, $4) -RETURNING *; - --- name: UpdateTap :one -UPDATE tap -SET order_id = $1, order_created_at = $2, name = $3, category = $4 -WHERE id = $5 -RETURNING *; - --- name: DeleteTap :execrows -DELETE FROM tap -WHERE id = $1; - - --- Other - - --- name: GetTapByOrderID :one -SELECT * -FROM tap -WHERE order_id = $1; - --- name: GetTapByCategory :many -SELECT * -FROM tap -WHERE category = $1; - --- name: GetLastOrderByOrderID :one +-- name: TapGetLast :one SELECT * FROM tap ORDER BY order_id DESC LIMIT 1; --- name: GetOrderCount :many +-- name: TapGetCountByCategory :many SELECT category, COUNT(*) FROM tap GROUP BY category; --- name: GetOrderCountByCategorySinceOrderID :many -SELECT category, COUNT(*), MAX(order_created_at)::TIMESTAMP AS latest_order_created_at -FROM tap -WHERE order_id >= $1 -GROUP BY category; +-- name: TapCreate :one +INSERT INTO tap (order_id, order_created_at, name, category) +VALUES ($1, $2, $3, $4) +RETURNING id; + diff --git a/go.mod b/go.mod index 0cac582..eeaa094 100644 --- a/go.mod +++ b/go.mod @@ -1,84 +1,154 @@ module github.com/zeusWPI/scc -go 1.23.1 +go 1.25.0 require ( github.com/NimbleMarkets/ntcharts v0.3.1 - github.com/charmbracelet/bubbletea v1.2.4 - github.com/charmbracelet/lipgloss v1.0.0 + github.com/charmbracelet/bubbletea v1.3.6 + github.com/charmbracelet/lipgloss v1.1.0 github.com/disintegration/imaging v1.6.2 - github.com/go-playground/validator/v10 v10.23.0 + github.com/go-playground/validator/v10 v10.27.0 github.com/gocolly/colly v1.2.0 github.com/gofiber/contrib/fiberzap v1.0.2 - github.com/gofiber/fiber/v2 v2.52.5 - github.com/jackc/pgx/v5 v5.7.2 + github.com/gofiber/fiber/v2 v2.52.9 + github.com/jackc/pgx/v5 v5.7.5 github.com/joho/godotenv v1.5.1 github.com/lucasb-eyer/go-colorful v1.2.0 - github.com/spf13/viper v1.19.0 + github.com/spf13/viper v1.20.1 go.uber.org/zap v1.27.0 ) require ( - github.com/PuerkitoBio/goquery v1.10.0 // indirect - github.com/andybalholm/brotli v1.1.1 // indirect + cel.dev/expr v0.19.1 // indirect + filippo.io/edwards25519 v1.1.0 // indirect + github.com/ClickHouse/ch-go v0.65.1 // indirect + github.com/ClickHouse/clickhouse-go/v2 v2.34.0 // indirect + github.com/PuerkitoBio/goquery v1.10.3 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/antchfx/htmlquery v1.3.4 // indirect - github.com/antchfx/xmlquery v1.4.3 // indirect - github.com/antchfx/xpath v1.3.3 // indirect + github.com/antchfx/xmlquery v1.4.4 // indirect + github.com/antchfx/xpath v1.3.5 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbles v0.20.0 // indirect - github.com/charmbracelet/x/ansi v0.6.0 // indirect + github.com/charmbracelet/bubbles v0.21.0 // indirect + github.com/charmbracelet/colorprofile v0.3.2 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/coder/websocket v1.8.13 // indirect + github.com/cubicdaiya/gonp v1.0.4 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/elastic/go-sysinfo v1.15.3 // indirect + github.com/elastic/go-windows v1.0.2 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.7 // indirect + github.com/fatih/structtag v1.2.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/go-faster/city v1.0.1 // indirect + github.com/go-faster/errors v0.7.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-sql-driver/mysql v1.9.2 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gobwas/glob v0.2.3 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect + github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/google/cel-go v0.24.1 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect github.com/kennygrant/sanitize v1.2.4 // indirect - github.com/klauspost/compress v1.17.11 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/lrstanley/bubblezone v0.0.0-20241221063659-0f12a2876fb2 // indirect - github.com/magiconair/properties v1.8.9 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/lrstanley/bubblezone v1.0.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/mfridman/xflag v0.1.0 // indirect + github.com/microsoft/go-mssqldb v1.8.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.15.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/paulmach/orb v0.11.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pganalyze/pg_query_go/v6 v6.1.0 // indirect + github.com/pierrec/lz4/v4 v4.1.22 // indirect + github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb // indirect + github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect + github.com/pingcap/log v1.1.0 // indirect + github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 // indirect + github.com/pressly/goose/v3 v3.24.3 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sagikazarmark/locafero v0.6.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/riza-io/grpc-go v0.2.0 // indirect + github.com/sagikazarmark/locafero v0.10.0 // indirect github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.7.1 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.14.0 // indirect + github.com/spf13/cast v1.9.2 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.7 // indirect + github.com/sqlc-dev/sqlc v1.29.0 // indirect + github.com/stoewer/go-strcase v1.2.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/temoto/robotstxt v1.1.2 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect + github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.58.0 // indirect - github.com/valyala/tcplisten v1.0.0 // indirect + github.com/valyala/fasthttp v1.65.0 // indirect + github.com/vertica/vertica-sql-go v1.3.3 // indirect + github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect + github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77 // indirect + github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1 // indirect + github.com/ziutek/mymysql v1.5.4 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect - golang.org/x/image v0.23.0 // indirect - golang.org/x/net v0.33.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect + golang.org/x/image v0.30.0 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/telemetry v0.0.0-20250807160809-1a19826ec488 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/tools v0.36.0 // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/protobuf v1.36.0 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect + google.golang.org/grpc v1.71.1 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + howett.net/plist v1.0.1 // indirect + modernc.org/libc v1.65.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.10.0 // indirect + modernc.org/sqlite v1.37.0 // indirect +) + +tool ( + github.com/pressly/goose/v3/cmd/goose + github.com/sqlc-dev/sqlc/cmd/sqlc + golang.org/x/tools/cmd/deadcode ) diff --git a/go.sum b/go.sum index 6e012d2..8b37c49 100644 --- a/go.sum +++ b/go.sum @@ -1,282 +1,448 @@ -github.com/NimbleMarkets/ntcharts v0.2.0 h1:uVpvUL9fZk/LGsc8E00kdBLHwh60llfvci+2JpJ6EDI= -github.com/NimbleMarkets/ntcharts v0.2.0/go.mod h1:BLzvdpQAv4NpGbOTsi3fCRzeDk276PGezkp75gD73kY= +cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4= +cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/ClickHouse/ch-go v0.65.1 h1:SLuxmLl5Mjj44/XbINsK2HFvzqup0s6rwKLFH347ZhU= +github.com/ClickHouse/ch-go v0.65.1/go.mod h1:bsodgURwmrkvkBe5jw1qnGDgyITsYErfONKAHn05nv4= +github.com/ClickHouse/clickhouse-go/v2 v2.34.0 h1:Y4rqkdrRHgExvC4o/NTbLdY5LFQ3LHS77/RNFxFX3Co= +github.com/ClickHouse/clickhouse-go/v2 v2.34.0/go.mod h1:yioSINoRLVZkLyDzdMXPLRIqhDvel8iLBlwh6Iefso8= github.com/NimbleMarkets/ntcharts v0.3.1 h1:EH4O80RMy5rqDmZM7aWjTbCSuRDDJ5fXOv/qAzdwOjk= github.com/NimbleMarkets/ntcharts v0.3.1/go.mod h1:zVeRqYkh2n59YPe1bflaSL4O2aD2ZemNmrbdEqZ70hk= -github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4= -github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4= -github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= -github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= -github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= -github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= -github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= +github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= +github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= -github.com/antchfx/htmlquery v1.3.3 h1:x6tVzrRhVNfECDaVxnZi1mEGrQg3mjE/rxbH2Pe6dNE= -github.com/antchfx/htmlquery v1.3.3/go.mod h1:WeU3N7/rL6mb6dCwtE30dURBnBieKDC/fR8t6X+cKjU= github.com/antchfx/htmlquery v1.3.4 h1:Isd0srPkni2iNTWCwVj/72t7uCphFeor5Q8nCzj1jdQ= github.com/antchfx/htmlquery v1.3.4/go.mod h1:K9os0BwIEmLAvTqaNSua8tXLWRWZpocZIH73OzWQbwM= -github.com/antchfx/xmlquery v1.4.2 h1:MZKd9+wblwxfQ1zd1AdrTsqVaMjMCwow3IqkCSe00KA= -github.com/antchfx/xmlquery v1.4.2/go.mod h1:QXhvf5ldTuGqhd1SHNvvtlhhdQLks4dD0awIVhXIDTA= -github.com/antchfx/xmlquery v1.4.3 h1:f6jhxCzANrWfa93O+NmRWvieVyLs+R2Szfpy+YrZaww= -github.com/antchfx/xmlquery v1.4.3/go.mod h1:AEPEEPYE9GnA2mj5Ur2L5Q5/2PycJ0N9Fusrx9b12fc= -github.com/antchfx/xpath v1.3.2 h1:LNjzlsSjinu3bQpw9hWMY9ocB80oLOWuQqFvO6xt51U= -github.com/antchfx/xpath v1.3.2/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= -github.com/antchfx/xpath v1.3.3 h1:tmuPQa1Uye0Ym1Zn65vxPgfltWb/Lxu2jeqIGteJSRs= +github.com/antchfx/xmlquery v1.4.4 h1:mxMEkdYP3pjKSftxss4nUHfjBhnMk4imGoR96FRY2dg= +github.com/antchfx/xmlquery v1.4.4/go.mod h1:AEPEEPYE9GnA2mj5Ur2L5Q5/2PycJ0N9Fusrx9b12fc= github.com/antchfx/xpath v1.3.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= +github.com/antchfx/xpath v1.3.5 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ= +github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= -github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= -github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= -github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= -github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= -github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= -github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM= -github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= -github.com/charmbracelet/x/ansi v0.6.0 h1:qOznutrb93gx9oMiGf7caF7bqqubh6YIM0SWKyA08pA= -github.com/charmbracelet/x/ansi v0.6.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= +github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= +github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws= +github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/elastic/go-sysinfo v1.8.1/go.mod h1:JfllUnzoQV/JRYymbH3dO1yggI3mV2oTKSXsDHM+uIM= +github.com/elastic/go-sysinfo v1.15.3 h1:W+RnmhKFkqPTCRoFq2VCTmsT4p/fwpo+3gKNQsn1XU0= +github.com/elastic/go-sysinfo v1.15.3/go.mod h1:K/cNrqYTDrSoMh2oDkYEMS2+a72GRxMvNP+GC+vRIlo= +github.com/elastic/go-windows v1.0.0/go.mod h1:TsU0Nrp7/y3+VwE82FoZF8gC/XFg/Elz6CcloAxnPgU= +github.com/elastic/go-windows v1.0.2 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI= +github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= -github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= -github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA= -github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= -github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= +github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gocolly/colly v1.2.0 h1:qRz9YAn8FIH0qzgNUw+HT9UN7wm1oF9OBAilwEWpyrI= github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA= github.com/gofiber/contrib/fiberzap v1.0.2 h1:EQwhggtszVfIdBeXxN9Xrmld71es34Ufs+ef8VMqZxc= github.com/gofiber/contrib/fiberzap v1.0.2/go.mod h1:jGO8BHU4gRI9U0JtM6zj2CIhYfgVmW5JxziN8NTgVwE= -github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= -github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= +github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/cel-go v0.24.1 h1:jsBCtxG8mM5wiUJDSGUqU0K7Mtr3w7Eyv00rw4DiZxI= +github.com/google/cel-go v0.24.1/go.mod h1:Hdf9TqOaTNSFQA1ybQaRqATVoK7m/zcf7IMhGXP5zI8= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= -github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= -github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= -github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= -github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o= github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= -github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= -github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e h1:OLwZ8xVaeVrru0xyeuOX+fne0gQTFEGlzfNjipCbxlU= -github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e/go.mod h1:NQ34EGeu8FAYGBMDzwhfNJL8YQYoWZP5xYJPRDAwN3E= -github.com/lrstanley/bubblezone v0.0.0-20241221063659-0f12a2876fb2 h1:iILPmPi4ytvFMzb90E7S7if5cdlyboFLXgBRe+7tLAA= -github.com/lrstanley/bubblezone v0.0.0-20241221063659-0f12a2876fb2/go.mod h1:NQ34EGeu8FAYGBMDzwhfNJL8YQYoWZP5xYJPRDAwN3E= +github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA= +github.com/lrstanley/bubblezone v1.0.0/go.mod h1:kcTekA8HE/0Ll2bWzqHlhA2c513KDNLW7uDfDP4Mly8= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= -github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/mfridman/xflag v0.1.0 h1:TWZrZwG1QklFX5S4j1vxfF1sZbZeZSGofMwPMLAF29M= +github.com/mfridman/xflag v0.1.0/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxWWnjRaE= +github.com/microsoft/go-mssqldb v1.8.0 h1:7cyZ/AT7ycDsEoWPIXibd+aVKFtteUNhDGf3aobP+tw= +github.com/microsoft/go-mssqldb v1.8.0/go.mod h1:6znkekS3T2vp0waiMhen4GPU1BiAsrP+iXHcE7a7rFo= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= -github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= -github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU= +github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= +github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pganalyze/pg_query_go/v6 v6.1.0 h1:jG5ZLhcVgL1FAw4C/0VNQaVmX1SUJx71wBGdtTtBvls= +github.com/pganalyze/pg_query_go/v6 v6.1.0/go.mod h1:nvTHIuoud6e1SfrUaFwHqT0i4b5Nr+1rPWVds3B5+50= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb h1:3pSi4EDG6hg0orE1ndHkXvX6Qdq2cZn8gAPir8ymKZk= +github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg= +github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 h1:tdMsjOqUR7YXHoBitzdebTvOjs/swniBTOLy5XiMtuE= +github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86/go.mod h1:exzhVYca3WRtd6gclGNErRWb1qEgff3LYta0LvRmON4= +github.com/pingcap/log v1.1.0 h1:ELiPxACz7vdo1qAvvaWJg1NrYFoY6gqAh/+Uo6aXdD8= +github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4= +github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 h1:W3rpAI3bubR6VWOcwxDIG0Gz9G5rl5b3SL116T0vBt0= +github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0/go.mod h1:+8feuexTKcXHZF/dkDfvCwEyBAmgb4paFc3/WeYV2eE= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM= +github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/procfs v0.0.0-20190425082905-87a4384529e0/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ= +github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= -github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc= +github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= -github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= +github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= +github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= +github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/sqlc-dev/sqlc v1.29.0 h1:HQctoD7y/i29Bao53qXO7CZ/BV9NcvpGpsJWvz9nKWs= +github.com/sqlc-dev/sqlc v1.29.0/go.mod h1:BavmYw11px5AdPOjAVHmb9fctP5A8GTziC38wBF9tp0= +github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg= github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d h1:dOMI4+zEbDI37KGb0TI44GUAwxHF9cMsIoDTJ7UmgfU= +github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d/go.mod h1:l8xTsYB90uaVdMHXMCxKKLSgw5wLYBwBKKefNIUnm9s= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= -github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= -github.com/valyala/fasthttp v1.58.0 h1:GGB2dWxSbEprU9j0iMJHgdKYJVDyjrOwF9RE59PbRuE= -github.com/valyala/fasthttp v1.58.0/go.mod h1:SYXvHHaFp7QZHGKSHmoMipInhrI5StHrhDTYVEjK/Kw= -github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= -github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8= +github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4= +github.com/vertica/vertica-sql-go v1.3.3 h1:fL+FKEAEy5ONmsvya2WH5T8bhkvY27y/Ik3ReR2T+Qw= +github.com/vertica/vertica-sql-go v1.3.3/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4= +github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo= +github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= +github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77 h1:LY6cI8cP4B9rrpTleZk95+08kl2gF4rixG7+V/dwL6Q= +github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= +github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1 h1:ixAiqjj2S/dNuJqrz4AxSqgw2P5OBMXp68hB5nNriUk= +github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1/go.mod h1:l5sSv153E18VvYcsmr51hok9Sjc16tEC8AXGbwrk+ho= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= +github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= +go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= -golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= -golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= -golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo= -golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= +golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw= -golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs= -golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= -golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= +golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= +golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= -golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= -golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/telemetry v0.0.0-20250807160809-1a19826ec488 h1:3doPGa+Gg4snce233aCWnbZVFsyFMo/dR40KK/6skyE= +golang.org/x/telemetry v0.0.0-20250807160809-1a19826ec488/go.mod h1:fGb/2+tgXXjhjHsTNdVEEMZNWA0quBnfrO+AfoDSAKw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= @@ -284,6 +450,7 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= @@ -291,31 +458,92 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24= +google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI= +google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -google.golang.org/protobuf v1.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ= -google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= +howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= +howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y= +modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= +modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= +modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= diff --git a/internal/api/api.go b/internal/api/api.go deleted file mode 100644 index ab2b292..0000000 --- a/internal/api/api.go +++ /dev/null @@ -1,17 +0,0 @@ -// Package api provides all the API endpoints -package api - -import ( - "github.com/gofiber/fiber/v2" - "github.com/zeusWPI/scc/internal/api/message" - apiSong "github.com/zeusWPI/scc/internal/api/song" - "github.com/zeusWPI/scc/internal/pkg/buzzer" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/song" -) - -// New creates a new API instance -func New(router fiber.Router, db *db.DB, buzz *buzzer.Buzzer, song *song.Song) { - message.New(router, db, buzz) - apiSong.New(router, db, song) -} diff --git a/internal/api/message/message.go b/internal/api/message/message.go deleted file mode 100644 index fc663c2..0000000 --- a/internal/api/message/message.go +++ /dev/null @@ -1,69 +0,0 @@ -// Package message provides the API regarding the cammie chat messages -package message - -import ( - "github.com/gofiber/fiber/v2" - "github.com/zeusWPI/scc/internal/pkg/buzzer" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/db/dto" - "github.com/zeusWPI/scc/pkg/util" - "go.uber.org/zap" -) - -// Router is the message API router -type Router struct { - router fiber.Router - db *db.DB - buzz *buzzer.Buzzer -} - -// New creates a new message API instance -func New(router fiber.Router, db *db.DB, buzz *buzzer.Buzzer) *Router { - api := &Router{ - router: router.Group("/messages"), - db: db, - buzz: buzz, - } - api.createRoutes() - - return api -} - -func (r *Router) createRoutes() { - r.router.Get("/", r.getAll) - r.router.Post("/", r.create) -} - -func (r *Router) getAll(c *fiber.Ctx) error { - messages, err := r.db.Queries.GetAllMessages(c.Context()) - if err != nil { - zap.S().Error("DB: Get all messages\n", err) - return c.SendStatus(fiber.StatusInternalServerError) - } - - return c.JSON(util.SliceMap(messages, dto.MessageDTO)) -} - -func (r *Router) create(c *fiber.Ctx) error { - message := new(dto.Message) - - if err := c.BodyParser(message); err != nil { - zap.S().Error("API: Message body parser\n", err) - return c.SendStatus(fiber.StatusBadRequest) - } - - if err := dto.Validate.Struct(message); err != nil { - zap.S().Error("API: Message validation\n", err) - return c.SendStatus(fiber.StatusBadRequest) - } - - messageDB, err := r.db.Queries.CreateMessage(c.Context(), message.CreateParams()) - if err != nil { - zap.S().Error("DB: Create message\n", err) - return c.SendStatus(fiber.StatusInternalServerError) - } - - r.buzz.Play() - - return c.Status(fiber.StatusCreated).JSON(dto.MessageDTO(messageDB)) -} diff --git a/internal/api/song/song.go b/internal/api/song/song.go deleted file mode 100644 index 220663f..0000000 --- a/internal/api/song/song.go +++ /dev/null @@ -1,56 +0,0 @@ -// Package song provides the API regarding songs integration -package song - -import ( - "github.com/gofiber/fiber/v2" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/db/dto" - "github.com/zeusWPI/scc/internal/pkg/song" - "go.uber.org/zap" -) - -// Router is the song API router -type Router struct { - router fiber.Router - db *db.DB - song *song.Song -} - -// New creates a new song API instance -func New(router fiber.Router, db *db.DB, song *song.Song) *Router { - api := &Router{ - router: router.Group("/song"), - db: db, - song: song, - } - api.createRoutes() - - return api -} - -func (r *Router) createRoutes() { - r.router.Post("/", r.new) -} - -func (r *Router) new(c *fiber.Ctx) error { - song := new(dto.Song) - - if err := c.BodyParser(song); err != nil { - zap.S().Error("API: Song body parser\n", err) - return c.SendStatus(fiber.StatusBadRequest) - } - - if err := dto.Validate.Struct(song); err != nil { - zap.S().Error("API: Song validation\n", err) - return c.SendStatus(fiber.StatusBadRequest) - } - - go func() { - err := r.song.Track(song) - if err != nil { - zap.S().Error("Song: Get Track\n", err) - } - }() - - return c.SendStatus(fiber.StatusOK) -} diff --git a/internal/pkg/buzzer/buzzer.go b/internal/buzzer/buzzer.go similarity index 100% rename from internal/pkg/buzzer/buzzer.go rename to internal/buzzer/buzzer.go diff --git a/internal/database/model/event.go b/internal/database/model/event.go new file mode 100644 index 0000000..c439b62 --- /dev/null +++ b/internal/database/model/event.go @@ -0,0 +1,27 @@ +package model + +import ( + "time" + + "github.com/zeusWPI/scc/pkg/sqlc" +) + +type Event struct { + ID int + Name string + Date time.Time + AcademicYear string + Location string + Poster []byte +} + +func EventModel(e sqlc.Event) *Event { + return &Event{ + ID: int(e.ID), + Name: e.Name, + Date: e.Date.Time, + AcademicYear: e.AcademicYear, + Location: e.Location, + Poster: e.Poster, + } +} diff --git a/internal/database/model/gamification.go b/internal/database/model/gamification.go new file mode 100644 index 0000000..4e6b567 --- /dev/null +++ b/internal/database/model/gamification.go @@ -0,0 +1,19 @@ +package model + +import "github.com/zeusWPI/scc/pkg/sqlc" + +type Gamification struct { + ID int + Name string + Score int + Avatar []byte +} + +func GamificationModel(g sqlc.Gamification) *Gamification { + return &Gamification{ + ID: int(g.ID), + Name: g.Name, + Score: int(g.Score), + Avatar: g.Avatar, + } +} diff --git a/internal/database/model/message.go b/internal/database/model/message.go new file mode 100644 index 0000000..a1a13d2 --- /dev/null +++ b/internal/database/model/message.go @@ -0,0 +1,25 @@ +package model + +import ( + "time" + + "github.com/zeusWPI/scc/pkg/sqlc" +) + +type Message struct { + ID int + Name string + IP string + Message string + CreatedAt time.Time +} + +func MessageModel(m sqlc.Message) *Message { + return &Message{ + ID: int(m.ID), + Name: m.Name, + IP: m.Ip, + Message: m.Message, + CreatedAt: m.CreatedAt.Time, + } +} diff --git a/internal/database/model/scan.go b/internal/database/model/scan.go new file mode 100644 index 0000000..51e83b5 --- /dev/null +++ b/internal/database/model/scan.go @@ -0,0 +1,21 @@ +package model + +import ( + "time" + + "github.com/zeusWPI/scc/pkg/sqlc" +) + +type Scan struct { + ID int + ScanID int + ScanTime time.Time +} + +func ScanModel(s sqlc.Scan) *Scan { + return &Scan{ + ID: int(s.ID), + ScanID: int(s.ScanID), + ScanTime: s.ScanTime.Time, + } +} diff --git a/internal/database/model/season.go b/internal/database/model/season.go new file mode 100644 index 0000000..0f0482d --- /dev/null +++ b/internal/database/model/season.go @@ -0,0 +1,24 @@ +package model + +import ( + "github.com/zeusWPI/scc/pkg/date" + "github.com/zeusWPI/scc/pkg/sqlc" +) + +type Season struct { + ID int + Name string + Start date.Date + End date.Date + Current bool +} + +func SeasonModel(s sqlc.Season) *Season { + return &Season{ + ID: int(s.ID), + Name: s.Name, + Start: date.Date(s.Start.Time), + End: date.Date(s.End.Time), + Current: s.Current, + } +} diff --git a/internal/database/model/song.go b/internal/database/model/song.go new file mode 100644 index 0000000..66df55d --- /dev/null +++ b/internal/database/model/song.go @@ -0,0 +1,115 @@ +package model + +import ( + "time" + + "github.com/zeusWPI/scc/pkg/sqlc" +) + +type SongGenre struct { + ID int + Genre string +} + +type SongArtist struct { + ID int + Name string + SpotifyID string + Followers int + Popularity int + Genres []SongGenre +} + +type Song struct { + ID int + Title string + Album string + SpotifyID string + DurationMS int + LyricsType string + Lyrics string + CreatedAt time.Time + Artists []SongArtist +} + +func SongModel(s sqlc.Song) *Song { + var lyricsType string + if s.LyricsType.Valid { + lyricsType = s.Lyrics.String + } + + var lyrics string + if s.Lyrics.Valid { + lyrics = s.Lyrics.String + } + + return &Song{ + ID: int(s.ID), + Title: s.Title, + Album: s.Album, + SpotifyID: s.SpotifyID, + DurationMS: int(s.DurationMs), + LyricsType: lyricsType, + Lyrics: lyrics, + } +} + +func SongModelHistory(songs []sqlc.GetLastSongFullRow) *Song { + if len(songs) == 0 { + return nil + } + + var lyricsType string + if songs[0].LyricsType.Valid { + lyricsType = songs[0].LyricsType.String + } + var lyrics string + if songs[0].Lyrics.Valid { + lyrics = songs[0].Lyrics.String + } + + artistsMap := make(map[int32]SongArtist) + for _, song := range songs { + if !song.ArtistID.Valid { + continue + } + + // Get artist + artist, ok := artistsMap[song.ArtistID.Int32] + if !ok { + // Artist doesn't exist yet, add him + artist = SongArtist{ + ID: int(song.ArtistID.Int32), + Name: song.ArtistName.String, + SpotifyID: song.ArtistSpotifyID.String, + Followers: int(song.ArtistFollowers.Int32), + Popularity: int(song.ArtistPopularity.Int32), + Genres: make([]SongGenre, 0), + } + artistsMap[song.ArtistID.Int32] = artist + } + + // Add genre + artist.Genres = append(artist.Genres, SongGenre{ + ID: int(song.GenreID.Int32), + Genre: song.Genre.String, + }) + } + + artists := make([]SongArtist, 0, len(artistsMap)) + for _, artist := range artistsMap { + artists = append(artists, artist) + } + + return &Song{ + ID: int(songs[0].ID), + Title: songs[0].SongTitle, + Album: songs[0].Album, + SpotifyID: songs[0].SpotifyID, + DurationMS: int(songs[0].DurationMs), + LyricsType: lyricsType, + Lyrics: lyrics, + CreatedAt: songs[0].CreatedAt.Time, + Artists: artists, + } +} diff --git a/internal/database/model/tap.go b/internal/database/model/tap.go new file mode 100644 index 0000000..61cc651 --- /dev/null +++ b/internal/database/model/tap.go @@ -0,0 +1,37 @@ +package model + +import ( + "time" + + "github.com/zeusWPI/scc/pkg/sqlc" +) + +type TapCategory string + +const ( + Soft TapCategory = "soft" + Mate TapCategory = "mate" + Beer TapCategory = "beer" + Food TapCategory = "food" + Unknown TapCategory = "unknown" +) + +type Tap struct { + ID int + OrderID int + Name string + Category TapCategory + CreatedAt time.Time +} + +func TapModel(t sqlc.Tap) *Tap { + return &Tap{ + ID: int(t.ID), + OrderID: int(t.OrderID), + Name: t.Name, + Category: TapCategory(t.Category), + CreatedAt: t.CreatedAt.Time, + } +} + +type TapCount map[TapCategory]int diff --git a/internal/database/repository/event.go b/internal/database/repository/event.go new file mode 100644 index 0000000..9070910 --- /dev/null +++ b/internal/database/repository/event.go @@ -0,0 +1,72 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/pkg/sqlc" + "github.com/zeusWPI/scc/pkg/utils" +) + +type Event struct { + repo Repository +} + +func (r *Repository) NewEvent() *Event { + return &Event{ + repo: *r, + } +} + +func (e *Event) GetByAcademicYear(ctx context.Context, year string) ([]*model.Event, error) { + events, err := e.repo.queries(ctx).EventGetAllByYear(ctx, year) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get by academic year %s | %w", year, err) + } + return nil, nil + } + + return utils.SliceMap(events, model.EventModel), nil +} + +func (e *Event) GetByCurrentAcademicYear(ctx context.Context) ([]*model.Event, error) { + events, err := e.repo.queries(ctx).EventGetAllByCurrentYear(ctx) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get by current academic year %w", err) + } + return nil, nil + } + + return utils.SliceMap(events, model.EventModel), nil +} + +func (e *Event) Create(ctx context.Context, event *model.Event) error { + id, err := e.repo.queries(ctx).EventCreate(ctx, sqlc.EventCreateParams{ + Name: event.Name, + Date: pgtype.Timestamptz{Time: event.Date, Valid: !event.Date.IsZero()}, + AcademicYear: event.AcademicYear, + Location: event.Location, + Poster: event.Poster, + }) + if err != nil { + return fmt.Errorf("create event %+v | %w", *event, err) + } + + event.ID = int(id) + + return nil +} + +func (e *Event) DeleteAll(ctx context.Context) error { + if err := e.repo.queries(ctx).EventDeleteAll(ctx); err != nil { + return fmt.Errorf("delete all events %w", err) + } + + return nil +} diff --git a/internal/database/repository/gamification.go b/internal/database/repository/gamification.go new file mode 100644 index 0000000..a734ffa --- /dev/null +++ b/internal/database/repository/gamification.go @@ -0,0 +1,57 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/pkg/sqlc" + "github.com/zeusWPI/scc/pkg/utils" +) + +type Gamification struct { + repo Repository +} + +func (r *Repository) NewGamification() *Gamification { + return &Gamification{ + repo: *r, + } +} + +func (g *Gamification) GetAll(ctx context.Context) ([]*model.Gamification, error) { + gams, err := g.repo.queries(ctx).GamificationGetAll(ctx) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get all gamification %w", err) + } + return nil, nil + } + + return utils.SliceMap(gams, model.GamificationModel), nil +} + +func (g *Gamification) Create(ctx context.Context, gam *model.Gamification) error { + id, err := g.repo.queries(ctx).GamificationCreate(ctx, sqlc.GamificationCreateParams{ + Name: gam.Name, + Score: int32(gam.Score), + Avatar: gam.Avatar, + }) + if err != nil { + return fmt.Errorf("create gamification %+v | %w", *gam, err) + } + + gam.ID = int(id) + + return nil +} + +func (g *Gamification) DeleteAll(ctx context.Context) error { + if err := g.repo.queries(ctx).GamificationDeleteAll(ctx); err != nil { + return fmt.Errorf("delete all gamifications %w", err) + } + + return nil +} diff --git a/internal/database/repository/message.go b/internal/database/repository/message.go new file mode 100644 index 0000000..a3c6fad --- /dev/null +++ b/internal/database/repository/message.go @@ -0,0 +1,49 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/pkg/sqlc" + "github.com/zeusWPI/scc/pkg/utils" +) + +type Message struct { + repo Repository +} + +func (r *Repository) NewMessage() *Message { + return &Message{ + repo: *r, + } +} + +func (m *Message) GetSinceID(ctx context.Context, id int) ([]*model.Message, error) { + messages, err := m.repo.queries(ctx).MessageGetSinceID(ctx, int32(id)) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get messages since id %d | %w", id, err) + } + return nil, nil + } + + return utils.SliceMap(messages, model.MessageModel), nil +} + +func (m *Message) Create(ctx context.Context, message *model.Message) error { + id, err := m.repo.queries(ctx).MessageCreate(ctx, sqlc.MessageCreateParams{ + Name: message.Name, + Ip: message.IP, + Message: message.Message, + }) + if err != nil { + return fmt.Errorf("create message %w", err) + } + + message.ID = int(id) + + return nil +} diff --git a/internal/database/repository/repository.go b/internal/database/repository/repository.go new file mode 100644 index 0000000..c45ce9f --- /dev/null +++ b/internal/database/repository/repository.go @@ -0,0 +1,40 @@ +// Package repository interacts with the databank and returns models +package repository + +import ( + "context" + + "github.com/zeusWPI/scc/pkg/db" + "github.com/zeusWPI/scc/pkg/sqlc" +) + +type Repository struct { + db db.DB +} + +type contextKey string + +const queryKey = contextKey("queries") + +func New(db db.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) queries(ctx context.Context) *sqlc.Queries { + if q, ok := ctx.Value(queryKey).(*sqlc.Queries); ok { + return q + } + + return r.db.Queries() +} + +func (r *Repository) WithRollback(ctx context.Context, fn func(ctx context.Context) error) error { + if _, ok := ctx.Value(queryKey).(*sqlc.Queries); ok { + return fn(ctx) + } + + return r.db.WithRollback(ctx, func(q *sqlc.Queries) error { + txCtx := context.WithValue(ctx, queryKey, q) + return fn(txCtx) + }) +} diff --git a/internal/database/repository/scan.go b/internal/database/repository/scan.go new file mode 100644 index 0000000..4deb12d --- /dev/null +++ b/internal/database/repository/scan.go @@ -0,0 +1,61 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/pkg/sqlc" + "github.com/zeusWPI/scc/pkg/utils" +) + +type Scan struct { + repo Repository +} + +func (r *Repository) NewScan() *Scan { + return &Scan{ + repo: *r, + } +} + +func (s *Scan) GetLast(ctx context.Context) (*model.Scan, error) { + scan, err := s.repo.queries(ctx).ScanGetLast(ctx) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get last scan %w", err) + } + return nil, nil + } + + return model.ScanModel(scan), nil +} + +func (s *Scan) GetAllSinceID(ctx context.Context, id int) ([]*model.Scan, error) { + scans, err := s.repo.queries(ctx).ScanGetAllSinceID(ctx, int32(id)) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get scans since id %d | %w", id, err) + } + return nil, nil + } + + return utils.SliceMap(scans, model.ScanModel), nil +} + +func (s *Scan) Create(ctx context.Context, scan *model.Scan) error { + id, err := s.repo.queries(ctx).ScanCreate(ctx, sqlc.ScanCreateParams{ + ScanID: int32(scan.ScanID), + ScanTime: pgtype.Timestamptz{Time: scan.ScanTime, Valid: !scan.ScanTime.IsZero()}, + }) + if err != nil { + return fmt.Errorf("create scan %+v | %w", *scan, err) + } + + scan.ID = int(id) + + return nil +} diff --git a/internal/database/repository/season.go b/internal/database/repository/season.go new file mode 100644 index 0000000..14eacee --- /dev/null +++ b/internal/database/repository/season.go @@ -0,0 +1,87 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/pkg/sqlc" + "github.com/zeusWPI/scc/pkg/utils" +) + +type Season struct { + repo Repository +} + +func (r *Repository) NewSeason() *Season { + return &Season{ + repo: *r, + } +} + +// TODO: Check still used +func (s *Season) GetAll(ctx context.Context) ([]*model.Season, error) { + seasons, err := s.repo.queries(ctx).SeasonGetAll(ctx) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("season get all %w", err) + } + return nil, nil + } + + return utils.SliceMap(seasons, model.SeasonModel), nil +} + +func (s *Season) GetCurrent(ctx context.Context) (*model.Season, error) { + season, err := s.repo.queries(ctx).SeasonGetCurrent(ctx) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("season get current %w", err) + } + return nil, nil + } + + return model.SeasonModel(season), nil +} + +func (s *Season) Create(ctx context.Context, season *model.Season) error { + id, err := s.repo.queries(ctx).SeasonCreate(ctx, sqlc.SeasonCreateParams{ + Name: season.Name, + Start: pgtype.Timestamp{Time: time.Time(season.Start), Valid: !time.Time(season.Start).IsZero()}, + End: pgtype.Timestamp{Time: time.Time(season.End), Valid: !time.Time(season.End).IsZero()}, + Current: season.Current, + }) + if err != nil { + return fmt.Errorf("create season %+v | %w", *season, err) + } + + season.ID = int(id) + + return nil +} + +func (s *Season) Update(ctx context.Context, season model.Season) error { + if err := s.repo.queries(ctx).SeasonUpdate(ctx, sqlc.SeasonUpdateParams{ + ID: int32(season.ID), + Name: season.Name, + Start: pgtype.Timestamp{Time: time.Time(season.Start), Valid: !time.Time(season.Start).IsZero()}, + End: pgtype.Timestamp{Time: time.Time(season.End), Valid: !time.Time(season.End).IsZero()}, + Current: season.Current, + }); err != nil { + return fmt.Errorf("update season %+v | %w", season, err) + } + + return nil +} + +func (s *Season) DeleteAll(ctx context.Context) error { + if err := s.repo.queries(ctx).SeasonDeleteAll(ctx); err != nil { + return fmt.Errorf("delete all seasons %w", err) + } + + return nil +} diff --git a/internal/database/repository/song.go b/internal/database/repository/song.go new file mode 100644 index 0000000..dd080ac --- /dev/null +++ b/internal/database/repository/song.go @@ -0,0 +1,97 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/pkg/sqlc" +) + +type Song struct { + repo Repository +} + +func (r *Repository) NewSong() *Song { + return &Song{ + repo: *r, + } +} + +func (s *Song) GetBySpotifyID(ctx context.Context, id string) (*model.Song, error) { + song, err := s.repo.queries(ctx).GetSongBySpotifyID(ctx, id) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get song by spotify id %s | %w", id, err) + } + return nil, nil + } + + return model.SongModel(song), nil +} + +func (s *Song) GetLast(ctx context.Context) (*model.Song, error) { + song, err := s.repo.queries(ctx).GetLastSongFull(ctx) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get last song %w", err) + } + return nil, nil + } + + return model.SongModelHistory(song), nil +} + +func (s *Song) GetArtistBySpotifyID(ctx context.Context, id string) (*model.SongArtist, error) { + artist, err := s.repo.queries(ctx).GetSongArtistBySpotifyID(ctx, id) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get artist by spotify id %s | %w", id, err) + } + + return &model.SongArtist{ + ID: int(artist.ID), + Name: artist.Name, + SpotifyID: artist.SpotifyID, + Followers: int(artist.Followers), + Popularity: int(artist.Popularity), + }, nil +} + +func (s *Song) Create(ctx context.Context, song *model.Song) error { + id, err := s.repo.queries(ctx).CreateSong(ctx, sqlc.CreateSongParams{ + Title: song.Title, + Album: song.Album, + SpotifyID: song.SpotifyID, + DurationMs: int32(song.DurationMS), + LyricsType: pgtype.Text{String: song.LyricsType, Valid: song.LyricsType != ""}, + Lyrics: pgtype.Text{String: song.Lyrics, Valid: song.Lyrics != ""}, + }) + if err != nil { + return fmt.Errorf("create song %+v | %w", *song, err) + } + + song.ID = int(id) + + return nil +} + +func (s *Song) CreateHistory(ctx context.Context, id int) error { + if _, err := s.repo.queries(ctx).CreateSongHistory(ctx, int32(id)); err != nil { + return fmt.Errorf("create song history %d %w", id, err) + } + + return nil +} + +func (s *Song) CreateArtist(ctx context.Context, ) error { + id, err := s.repo.queries(ctx).CreateSongArtistSong(ctx, sqlc.CreateSongArtistSongParams{ + ArtistID: artist.SpotifyID, + SongID: , + }) +} diff --git a/internal/database/repository/tap.go b/internal/database/repository/tap.go new file mode 100644 index 0000000..11adc4b --- /dev/null +++ b/internal/database/repository/tap.go @@ -0,0 +1,67 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/pkg/sqlc" +) + +type Tap struct { + repo Repository +} + +func (r *Repository) NewTap() *Tap { + return &Tap{ + repo: *r, + } +} + +func (t *Tap) GetLast(ctx context.Context) (*model.Tap, error) { + tap, err := t.repo.queries(ctx).TapGetLast(ctx) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get last tap %w", err) + } + return nil, nil + } + + return model.TapModel(tap), nil +} + +func (t *Tap) GetCountByCategory(ctx context.Context) (model.TapCount, error) { + counts, err := t.repo.queries(ctx).TapGetCountByCategory(ctx) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get tap count by category %w", err) + } + return nil, nil + } + + tapCount := model.TapCount{} + for _, count := range counts { + tapCount[model.TapCategory(count.Category)] = int(count.Count) + } + + return tapCount, nil +} + +func (t *Tap) Create(ctx context.Context, tap *model.Tap) error { + id, err := t.repo.queries(ctx).TapCreate(ctx, sqlc.TapCreateParams{ + Name: tap.Name, + Category: sqlc.TapCategory(tap.Category), + OrderID: int32(tap.OrderID), + OrderCreatedAt: pgtype.Timestamptz{Time: tap.CreatedAt, Valid: !tap.CreatedAt.IsZero()}, + }) + if err != nil { + return fmt.Errorf("create tap %+v | %w", *tap, err) + } + + tap.ID = int(id) + + return nil +} diff --git a/internal/event/api.go b/internal/event/api.go new file mode 100644 index 0000000..6d34862 --- /dev/null +++ b/internal/event/api.go @@ -0,0 +1,100 @@ +package event + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "sync" + "time" + + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/pkg/utils" +) + +type eventAPI struct { + ID int `json:"id"` + Name string `json:"Name"` + Location string `json:"location"` + Start time.Time `json:"start_time"` + End time.Time `json:"end_time"` + YearStart int `json:"year_start"` + YearEnd int `json:"year_end"` + Poster []byte +} + +func (e eventAPI) toModel() model.Event { + return model.Event{ + Name: e.Name, + Date: e.Start, + AcademicYear: fmt.Sprintf("%d-%d", e.YearStart, e.YearEnd), + Location: e.Location, + Poster: e.Poster, + } +} + +func (e *Event) getPoster(ctx context.Context, event *eventAPI) error { + resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ + Method: "GET", + URL: fmt.Sprintf("%s/event/poster/%d?original=true&scc=true", e.url, event.ID), + }) + if err != nil { + return fmt.Errorf("get poster %+v | %w", *event, err) + } + + defer func() { + _ = resp.Body.Close() + }() + + bytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read poster bytes %+v | %w", *event, err) + } + + event.Poster = bytes + + return nil +} + +func (e *Event) getEvents(ctx context.Context) ([]model.Event, error) { + resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ + Method: "GET", + URL: fmt.Sprintf("%s/event", e.url), + }) + if err != nil { + return nil, fmt.Errorf("get events %w", err) + } + + defer func() { + _ = resp.Body.Close() + }() + + var events []eventAPI + if err := json.NewDecoder(resp.Body).Decode(&events); err != nil { + return nil, fmt.Errorf("decode event api %w", err) + } + + var errs []error + + var mu sync.Mutex + var wg sync.WaitGroup + + for _, event := range events { + wg.Go(func() { + if err := e.getPoster(ctx, &event); err != nil { + mu.Lock() + errs = append(errs, err) + mu.Unlock() + } + }) + } + + wg.Wait() + + if errs != nil { + return nil, errors.Join(errs...) + } + + return utils.SliceMap(events, func(e eventAPI) model.Event { return e.toModel() }), nil +} diff --git a/internal/event/event.go b/internal/event/event.go new file mode 100644 index 0000000..5b36cae --- /dev/null +++ b/internal/event/event.go @@ -0,0 +1,47 @@ +// Package event keeps the current event database in sync +package event + +import ( + "context" + + "github.com/zeusWPI/scc/internal/database/repository" + "github.com/zeusWPI/scc/pkg/config" +) + +type Event struct { + repo repository.Repository + event repository.Event + url string +} + +// TODO: Check if colly is removed + +func New(repo repository.Repository) *Event { + return &Event{ + repo: repo, + event: *repo.NewEvent(), + url: config.GetDefaultString("backend.event.url", "https://events.zeus.gent/api/v1"), + } +} + +// Update gets all events from the website of this academic year +func (e *Event) Update(ctx context.Context) error { + events, err := e.getEvents(ctx) + if err != nil { + return err + } + + return e.repo.WithRollback(ctx, func(ctx context.Context) error { + if err := e.event.DeleteAll(ctx); err != nil { + return err + } + + for _, event := range events { + if err := e.event.Create(ctx, &event); err != nil { + return err + } + } + + return nil + }) +} diff --git a/internal/gamification/api.go b/internal/gamification/api.go new file mode 100644 index 0000000..98bd1e3 --- /dev/null +++ b/internal/gamification/api.go @@ -0,0 +1,96 @@ +package gamification + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "sync" + + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/pkg/utils" +) + +type gamificationAPI struct { + Name string `json:"github_name"` + Score int `json:"score"` + AvatarURL string `json:"avatar_url"` + Avatar []byte +} + +func (g gamificationAPI) toModel() model.Gamification { + return model.Gamification{ + Name: g.Name, + Score: g.Score, + Avatar: g.Avatar, + } +} + +func (g *Gamification) getAvatar(ctx context.Context, gam *gamificationAPI) error { + resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ + Method: "GET", + URL: gam.AvatarURL, + }) + if err != nil { + return fmt.Errorf("get avatar url %+v | %w", *gam, err) + } + + defer func() { + _ = resp.Body.Close() + }() + + bytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read avatar bytes %+v | %w", *gam, err) + } + + gam.Avatar = bytes + + return nil +} + +func (g *Gamification) getLeaderboard(ctx context.Context) ([]model.Gamification, error) { + resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ + Method: "GET", + URL: fmt.Sprintf("%s/top4", g.url), + Headers: map[string]string{ + "Accept": "application/json", + }, + }) + if err != nil { + return nil, fmt.Errorf("get top 4 gamification %w", err) + } + + defer func() { + _ = resp.Body.Close() + }() + + var gams []gamificationAPI + if err := json.NewDecoder(resp.Body).Decode(&gams); err != nil { + return nil, fmt.Errorf("decode gamification response %w", err) + } + + var errs []error + + var mu sync.Mutex + var wg sync.WaitGroup + + for _, gam := range gams { + wg.Go(func() { + if err := g.getAvatar(ctx, &gam); err != nil { + mu.Lock() + errs = append(errs, err) + mu.Unlock() + } + }) + } + + wg.Wait() + + if errs != nil { + return nil, errors.Join(errs...) + } + + return utils.SliceMap(gams, func(g gamificationAPI) model.Gamification { return g.toModel() }), nil +} diff --git a/internal/gamification/gamification.go b/internal/gamification/gamification.go new file mode 100644 index 0000000..b55ed8e --- /dev/null +++ b/internal/gamification/gamification.go @@ -0,0 +1,44 @@ +// Package gamification provides all gamification related logic +package gamification + +import ( + "context" + + "github.com/zeusWPI/scc/internal/database/repository" + "github.com/zeusWPI/scc/pkg/config" +) + +type Gamification struct { + repo repository.Repository + gam repository.Gamification + url string +} + +func New(repo repository.Repository) *Gamification { + return &Gamification{ + repo: repo, + gam: *repo.NewGamification(), + url: config.GetDefaultString("backend.gamification.url", "https://gamification.zeus.gent"), + } +} + +func (g *Gamification) Update(ctx context.Context) error { + leaderboard, err := g.getLeaderboard(ctx) + if err != nil { + return err + } + + return g.repo.WithRollback(ctx, func(ctx context.Context) error { + if err := g.gam.DeleteAll(ctx); err != nil { + return err + } + + for _, l := range leaderboard { + if err := g.gam.Create(ctx, &l); err != nil { + return err + } + } + + return nil + }) +} diff --git a/internal/pkg/lyrics/instrumental.go b/internal/lyrics/instrumental.go similarity index 98% rename from internal/pkg/lyrics/instrumental.go rename to internal/lyrics/instrumental.go index a34d73e..3803309 100644 --- a/internal/pkg/lyrics/instrumental.go +++ b/internal/lyrics/instrumental.go @@ -5,22 +5,22 @@ import ( "math/rand/v2" "time" - "github.com/zeusWPI/scc/internal/pkg/db/dto" + "github.com/zeusWPI/scc/internal/database/model" ) // Instrumental represents the lyrics for an instrumental song type Instrumental struct { - song dto.Song + song model.Song lyrics []Lyric i int } -func newInstrumental(song dto.Song) Lyrics { +func newInstrumental(song model.Song) Lyrics { return &Instrumental{song: song, lyrics: generateInstrumental(time.Duration(song.DurationMS) * time.Millisecond), i: 0} } // GetSong returns the song associated to the lyrics -func (i *Instrumental) GetSong() dto.Song { +func (i *Instrumental) GetSong() model.Song { return i.song } diff --git a/internal/pkg/lyrics/lrc.go b/internal/lyrics/lrc.go similarity index 94% rename from internal/pkg/lyrics/lrc.go rename to internal/lyrics/lrc.go index 02f9b1f..c37882c 100644 --- a/internal/pkg/lyrics/lrc.go +++ b/internal/lyrics/lrc.go @@ -6,24 +6,24 @@ import ( "strings" "time" - "github.com/zeusWPI/scc/internal/pkg/db/dto" + "github.com/zeusWPI/scc/internal/database/model" ) var re = regexp.MustCompile(`^\[(\d{2}):(\d{2})\.(\d{2})\]`) // LRC represents synced lyrics type LRC struct { - song dto.Song + song model.Song lyrics []Lyric i int } -func newLRC(song dto.Song) Lyrics { +func newLRC(song model.Song) Lyrics { return &LRC{song: song, lyrics: parseLRC(song.Lyrics, time.Duration(song.DurationMS)), i: 0} } // GetSong returns the song associated to the lyrics -func (l *LRC) GetSong() dto.Song { +func (l *LRC) GetSong() model.Song { return l.song } diff --git a/internal/pkg/lyrics/lyrics.go b/internal/lyrics/lyrics.go similarity index 87% rename from internal/pkg/lyrics/lyrics.go rename to internal/lyrics/lyrics.go index 2c3cb23..c5812c1 100644 --- a/internal/pkg/lyrics/lyrics.go +++ b/internal/lyrics/lyrics.go @@ -4,12 +4,12 @@ package lyrics import ( "time" - "github.com/zeusWPI/scc/internal/pkg/db/dto" + "github.com/zeusWPI/scc/internal/database/model" ) // Lyrics is the common interface for different lyric types type Lyrics interface { - GetSong() dto.Song + GetSong() model.Song Previous(int) []Lyric Current() (Lyric, bool) Next() (Lyric, bool) @@ -24,7 +24,7 @@ type Lyric struct { } // New returns a new object that implements the Lyrics interface -func New(song dto.Song) Lyrics { +func New(song model.Song) Lyrics { // Basic sync if song.LyricsType == "synced" { return newLRC(song) diff --git a/internal/pkg/lyrics/missing.go b/internal/lyrics/missing.go similarity index 88% rename from internal/pkg/lyrics/missing.go rename to internal/lyrics/missing.go index 1602d2c..d0ae335 100644 --- a/internal/pkg/lyrics/missing.go +++ b/internal/lyrics/missing.go @@ -3,16 +3,16 @@ package lyrics import ( "time" - "github.com/zeusWPI/scc/internal/pkg/db/dto" + "github.com/zeusWPI/scc/internal/database/model" ) // Missing represents lyrics that are absent type Missing struct { - song dto.Song + song model.Song lyrics Lyric } -func newMissing(song dto.Song) Lyrics { +func newMissing(song model.Song) Lyrics { lyric := Lyric{ Text: "Missing lyrics\n\nHelp the open source community by adding them to\nhttps://lrclib.net/", Duration: time.Duration(song.DurationMS) * time.Millisecond, @@ -22,7 +22,7 @@ func newMissing(song dto.Song) Lyrics { } // GetSong returns the song associated to the lyrics -func (m *Missing) GetSong() dto.Song { +func (m *Missing) GetSong() model.Song { return m.song } diff --git a/internal/pkg/lyrics/plain.go b/internal/lyrics/plain.go similarity index 88% rename from internal/pkg/lyrics/plain.go rename to internal/lyrics/plain.go index 2771969..e04e2a7 100644 --- a/internal/pkg/lyrics/plain.go +++ b/internal/lyrics/plain.go @@ -3,16 +3,16 @@ package lyrics import ( "time" - "github.com/zeusWPI/scc/internal/pkg/db/dto" + "github.com/zeusWPI/scc/internal/database/model" ) // Plain represents lyrics that don't have timestamps or songs without lyrics type Plain struct { - song dto.Song + song model.Song lyrics Lyric } -func newPlain(song dto.Song) Lyrics { +func newPlain(song model.Song) Lyrics { lyric := Lyric{ Text: song.Lyrics, Duration: time.Duration(song.DurationMS) * time.Millisecond, @@ -21,7 +21,7 @@ func newPlain(song dto.Song) Lyrics { } // GetSong returns the song associated to the lyrics -func (p *Plain) GetSong() dto.Song { +func (p *Plain) GetSong() model.Song { return p.song } diff --git a/internal/pkg/db/dto/dto.go b/internal/pkg/db/dto/dto.go deleted file mode 100644 index 08133bd..0000000 --- a/internal/pkg/db/dto/dto.go +++ /dev/null @@ -1,9 +0,0 @@ -// Package dto provides the data transfer objects for the database -package dto - -import ( - "github.com/go-playground/validator/v10" -) - -// Validate is a validator instance for JSON transferable objects -var Validate = validator.New(validator.WithRequiredStructEnabled()) diff --git a/internal/pkg/db/dto/event.go b/internal/pkg/db/dto/event.go deleted file mode 100644 index dabf0c5..0000000 --- a/internal/pkg/db/dto/event.go +++ /dev/null @@ -1,47 +0,0 @@ -package dto - -import ( - "bytes" - "time" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" -) - -// Event represents the DTO object for event -type Event struct { - ID int32 - Name string - Date time.Time - AcademicYear string - Location string - Poster []byte -} - -// EventDTO converts a sqlc Event object to a DTO Event -func EventDTO(e sqlc.Event) *Event { - return &Event{ - ID: e.ID, - Name: e.Name, - Date: e.Date.Time, - AcademicYear: e.AcademicYear, - Location: e.Location, - Poster: e.Poster, - } -} - -// Equal compares 2 events -func (e *Event) Equal(e2 Event) bool { - return e.Name == e2.Name && e.Date.Equal(e2.Date) && e.AcademicYear == e2.AcademicYear && e.Location == e2.Location && bytes.Equal(e.Poster, e2.Poster) -} - -// CreateParams converts a Event DTO to a sqlc CreateEventParams object -func (e *Event) CreateParams() sqlc.CreateEventParams { - return sqlc.CreateEventParams{ - Name: e.Name, - Date: pgtype.Timestamptz{Time: e.Date, Valid: true}, - AcademicYear: e.AcademicYear, - Location: e.Location, - Poster: e.Poster, - } -} diff --git a/internal/pkg/db/dto/gamification.go b/internal/pkg/db/dto/gamification.go deleted file mode 100644 index bb2181e..0000000 --- a/internal/pkg/db/dto/gamification.go +++ /dev/null @@ -1,47 +0,0 @@ -package dto - -import ( - "bytes" - - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" -) - -// Gamification represents the DTO object for gamification -type Gamification struct { - ID int32 `json:"id"` - Name string `json:"github_name"` - Score int32 `json:"score"` - Avatar []byte `json:"avatar"` -} - -// GamificationDTO converts a sqlc Gamification object to a DTO gamification -func GamificationDTO(gam sqlc.Gamification) *Gamification { - return &Gamification{ - ID: gam.ID, - Name: gam.Name, - Score: gam.Score, - Avatar: gam.Avatar, - } -} - -// Equal compares 2 Gamification objects for equality -func (g *Gamification) Equal(g2 Gamification) bool { - return g.Name == g2.Name && g.Score == g2.Score && bytes.Equal(g.Avatar, g2.Avatar) -} - -// CreateParams converts a Gamification DTO to a sqlc CreateGamificationParams object -func (g *Gamification) CreateParams() sqlc.CreateGamificationParams { - return sqlc.CreateGamificationParams{ - Name: g.Name, - Score: g.Score, - Avatar: g.Avatar, - } -} - -// UpdateScoreParams converts a Gamification DTO to a sqlc UpdateScoreParams object -func (g *Gamification) UpdateScoreParams() sqlc.UpdateGamificationScoreParams { - return sqlc.UpdateGamificationScoreParams{ - ID: g.ID, - Score: g.Score, - } -} diff --git a/internal/pkg/db/dto/message.go b/internal/pkg/db/dto/message.go deleted file mode 100644 index 75672a8..0000000 --- a/internal/pkg/db/dto/message.go +++ /dev/null @@ -1,36 +0,0 @@ -package dto - -import ( - "time" - - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" -) - -// Message is the DTO for the message -type Message struct { - ID int32 `json:"id"` - Name string `json:"name" validate:"required"` - IP string `json:"ip" validate:"required"` - Message string `json:"message" validate:"required"` - CreatedAt time.Time `json:"created_at"` -} - -// MessageDTO converts a sqlc.Message to a Message -func MessageDTO(message sqlc.Message) *Message { - return &Message{ - ID: message.ID, - Name: message.Name, - IP: message.Ip, - Message: message.Message, - CreatedAt: message.CreatedAt.Time, - } -} - -// CreateParams converts a Message to sqlc.CreateMessageParams -func (m *Message) CreateParams() sqlc.CreateMessageParams { - return sqlc.CreateMessageParams{ - Name: m.Name, - Ip: m.IP, - Message: m.Message, - } -} diff --git a/internal/pkg/db/dto/scan.go b/internal/pkg/db/dto/scan.go deleted file mode 100644 index 5622e24..0000000 --- a/internal/pkg/db/dto/scan.go +++ /dev/null @@ -1,41 +0,0 @@ -package dto - -import ( - "time" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" -) - -// Scan is the DTO for the scan -type Scan struct { - ID int32 `json:"id"` - ScanID int32 `json:"scan_id"` - ScanTime time.Time `json:"scan_time" validate:"required"` -} - -// ScanDTO converts a sqlc.Scan to a Scan -func ScanDTO(scan sqlc.Scan) *Scan { - return &Scan{ - ID: scan.ID, - ScanID: scan.ScanID, - ScanTime: scan.ScanTime.Time, - } -} - -// CreateParams converts a Scan to sqlc.CreateScanParams -func (s *Scan) CreateParams() sqlc.CreateScanParams { - return sqlc.CreateScanParams{ - ScanID: s.ScanID, - ScanTime: pgtype.Timestamptz{Time: s.ScanTime, Valid: true}, - } -} - -// UpdateParams converts a Scan to sqlc.UpdateScanParams -func (s *Scan) UpdateParams() sqlc.UpdateScanParams { - return sqlc.UpdateScanParams{ - ID: s.ID, - ScanID: s.ScanID, - ScanTime: pgtype.Timestamptz{Time: s.ScanTime, Valid: true}, - } -} diff --git a/internal/pkg/db/dto/season.go b/internal/pkg/db/dto/season.go deleted file mode 100644 index ca75630..0000000 --- a/internal/pkg/db/dto/season.go +++ /dev/null @@ -1,58 +0,0 @@ -package dto - -import ( - "github.com/jackc/pgx/v5/pgtype" - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" - "github.com/zeusWPI/scc/pkg/date" -) - -// Season is the DTO for the season -type Season struct { - ID int32 `json:"id"` - Name string `json:"name" validate:"required"` - Start date.Date `json:"start" validate:"required"` - End date.Date `json:"end" validate:"required"` - Current bool `json:"is_current" validate:"boolean"` -} - -// SeasonDTO converts a sqlc.Season to a Season -func SeasonDTO(season sqlc.Season) *Season { - return &Season{ - ID: season.ID, - Name: season.Name, - Start: date.Date(season.Start.Time), - End: date.Date(season.End.Time), - Current: season.Current, - } -} - -// SeasonCmp compares two seasons -// Returns an int so it can be used in compare functions -func SeasonCmp(s1, s2 *Season) int { - if s1.ID == s2.ID && s1.Name == s2.Name && s1.Start == s2.Start && s1.End == s2.End && s1.Current == s2.Current { - return 0 - } - - return 1 -} - -// CreateParams converts a Season to sqlc.CreateSeasonParams -func (s *Season) CreateParams() sqlc.CreateSeasonParams { - return sqlc.CreateSeasonParams{ - Name: s.Name, - Start: pgtype.Timestamp{Time: s.Start.ToTime(), Valid: true}, - End: pgtype.Timestamp{Time: s.End.ToTime(), Valid: true}, - Current: s.Current, - } -} - -// UpdateParams converts a Season to sqlc.UpdateSeasonParams -func (s *Season) UpdateParams() sqlc.UpdateSeasonParams { - return sqlc.UpdateSeasonParams{ - ID: s.ID, - Name: s.Name, - End: pgtype.Timestamp{Time: s.End.ToTime(), Valid: true}, - Start: pgtype.Timestamp{Time: s.Start.ToTime(), Valid: true}, - Current: s.Current, - } -} diff --git a/internal/pkg/db/dto/song.go b/internal/pkg/db/dto/song.go deleted file mode 100644 index 401b3dc..0000000 --- a/internal/pkg/db/dto/song.go +++ /dev/null @@ -1,163 +0,0 @@ -package dto - -import ( - "time" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" -) - -// Song is the DTO for a song -type Song struct { - ID int32 `json:"id"` - Title string `json:"title"` - Album string `json:"album"` - SpotifyID string `json:"spotify_id" validate:"required"` - DurationMS int32 `json:"duration_ms"` - LyricsType string `json:"lyrics_type"` // Either 'synced' ,'plain' or 'instrumental' - Lyrics string `json:"lyrics"` - CreatedAt time.Time `json:"created_at"` - Artists []SongArtist `json:"artists"` -} - -// SongArtist is the DTO for a song artist -type SongArtist struct { - ID int32 `json:"id"` - Name string `json:"name"` - SpotifyID string `json:"spotify_id"` - Followers int32 `json:"followers"` - Popularity int32 `json:"popularity"` - Genres []SongGenre `json:"genres"` -} - -// SongGenre is the DTO for a song genre -type SongGenre struct { - ID int32 `json:"id"` - Genre string `json:"genre"` -} - -// SongDTO converts a sqlc.Song to a Song -func SongDTO(song sqlc.Song) *Song { - var lyricsType string - if song.LyricsType.Valid { - lyricsType = song.Lyrics.String - } - var lyrics string - if song.Lyrics.Valid { - lyrics = song.Lyrics.String - } - - return &Song{ - ID: song.ID, - Title: song.Title, - Album: song.Album, - SpotifyID: song.SpotifyID, - DurationMS: song.DurationMs, - LyricsType: lyricsType, - Lyrics: lyrics, - } -} - -// SongDTOHistory converts a sqlc.GetLastSongFullRow array to a Song -func SongDTOHistory(songs []sqlc.GetLastSongFullRow) *Song { - if len(songs) == 0 { - return nil - } - - var lyricsType string - if songs[0].LyricsType.Valid { - lyricsType = songs[0].LyricsType.String - } - var lyrics string - if songs[0].Lyrics.Valid { - lyrics = songs[0].Lyrics.String - } - - artistsMap := make(map[int32]SongArtist) - for _, song := range songs { - if !song.ArtistID.Valid { - continue - } - - // Get artist - artist, ok := artistsMap[song.ArtistID.Int32] - if !ok { - // Artist doesn't exist yet, add him - artist = SongArtist{ - ID: song.ArtistID.Int32, - Name: song.ArtistName.String, - SpotifyID: song.ArtistSpotifyID.String, - Followers: song.ArtistFollowers.Int32, - Popularity: song.ArtistPopularity.Int32, - Genres: make([]SongGenre, 0), - } - artistsMap[song.ArtistID.Int32] = artist - } - - // Add genre - artist.Genres = append(artist.Genres, SongGenre{ - ID: song.GenreID.Int32, - Genre: song.Genre.String, - }) - } - - artists := make([]SongArtist, 0, len(artistsMap)) - for _, artist := range artistsMap { - artists = append(artists, artist) - } - - return &Song{ - ID: songs[0].ID, - Title: songs[0].SongTitle, - Album: songs[0].Album, - SpotifyID: songs[0].SpotifyID, - DurationMS: songs[0].DurationMs, - LyricsType: lyricsType, - Lyrics: lyrics, - CreatedAt: songs[0].CreatedAt.Time, - Artists: artists, - } -} - -// CreateSongParams converts a Song DTO to a sqlc CreateSongParams object -func (s *Song) CreateSongParams() *sqlc.CreateSongParams { - return &sqlc.CreateSongParams{ - Title: s.Title, - Album: s.Album, - SpotifyID: s.SpotifyID, - DurationMs: s.DurationMS, - LyricsType: pgtype.Text{String: s.LyricsType, Valid: true}, - Lyrics: pgtype.Text{String: s.Lyrics, Valid: true}, - } -} - -// CreateSongGenreParams converts a Song DTO to a string to create a new genre -func (s *Song) CreateSongGenreParams(idxArtist, idxGenre int) string { - return s.Artists[idxArtist].Genres[idxGenre].Genre -} - -// CreateSongArtistParams converts a Song DTO to a sqlc CreateSongArtistParams object -func (s *Song) CreateSongArtistParams(idxArtist int) *sqlc.CreateSongArtistParams { - return &sqlc.CreateSongArtistParams{ - Name: s.Artists[idxArtist].Name, - SpotifyID: s.Artists[idxArtist].SpotifyID, - Followers: s.Artists[idxArtist].Followers, - Popularity: s.Artists[idxArtist].Popularity, - } -} - -// CreateSongArtistSongParams converts a Song DTO to a sqlc CreateSongArtistSongParams object -func (s *Song) CreateSongArtistSongParams(idxArtist int) *sqlc.CreateSongArtistSongParams { - return &sqlc.CreateSongArtistSongParams{ - ArtistID: s.Artists[idxArtist].ID, - SongID: s.ID, - } -} - -// CreateSongArtistGenreParamas converts a Song DTO to a sqlc CreateSongArtistGenreParams object -func (s *Song) CreateSongArtistGenreParamas(idxArtist, idxGenre int) *sqlc.CreateSongArtistGenreParams { - return &sqlc.CreateSongArtistGenreParams{ - ArtistID: s.Artists[idxArtist].ID, - GenreID: s.Artists[idxArtist].Genres[idxGenre].ID, - } -} diff --git a/internal/pkg/db/sqlc/event.sql.go b/internal/pkg/db/sqlc/event.sql.go deleted file mode 100644 index 02a56b8..0000000 --- a/internal/pkg/db/sqlc/event.sql.go +++ /dev/null @@ -1,174 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.27.0 -// source: event.sql - -package sqlc - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createEvent = `-- name: CreateEvent :one -INSERT INTO event (name, date, academic_year, location, poster) -VALUES ($1, $2, $3, $4, $5) -RETURNING id, name, date, academic_year, location, poster -` - -type CreateEventParams struct { - Name string - Date pgtype.Timestamptz - AcademicYear string - Location string - Poster []byte -} - -func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event, error) { - row := q.db.QueryRow(ctx, createEvent, - arg.Name, - arg.Date, - arg.AcademicYear, - arg.Location, - arg.Poster, - ) - var i Event - err := row.Scan( - &i.ID, - &i.Name, - &i.Date, - &i.AcademicYear, - &i.Location, - &i.Poster, - ) - return i, err -} - -const deleteEvent = `-- name: DeleteEvent :exec -DELETE FROM event -WHERE id = $1 -` - -func (q *Queries) DeleteEvent(ctx context.Context, id int32) error { - _, err := q.db.Exec(ctx, deleteEvent, id) - return err -} - -const deleteEventByAcademicYear = `-- name: DeleteEventByAcademicYear :exec -DELETE FROM event -WHERE academic_year = $1 -` - -func (q *Queries) DeleteEventByAcademicYear(ctx context.Context, academicYear string) error { - _, err := q.db.Exec(ctx, deleteEventByAcademicYear, academicYear) - return err -} - -const getAllEvents = `-- name: GetAllEvents :many - - -SELECT id, name, date, academic_year, location, poster -FROM event -` - -// CRUD -func (q *Queries) GetAllEvents(ctx context.Context) ([]Event, error) { - rows, err := q.db.Query(ctx, getAllEvents) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Event - for rows.Next() { - var i Event - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Date, - &i.AcademicYear, - &i.Location, - &i.Poster, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getEventByAcademicYear = `-- name: GetEventByAcademicYear :many - - -SELECT id, name, date, academic_year, location, poster -FROM event -WHERE academic_year = $1 -` - -// Other -func (q *Queries) GetEventByAcademicYear(ctx context.Context, academicYear string) ([]Event, error) { - rows, err := q.db.Query(ctx, getEventByAcademicYear, academicYear) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Event - for rows.Next() { - var i Event - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Date, - &i.AcademicYear, - &i.Location, - &i.Poster, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getEventsCurrentAcademicYear = `-- name: GetEventsCurrentAcademicYear :many -SELECT id, name, date, academic_year, location, poster -FROM event -WHERE academic_year = ( - SELECT MAX(academic_year) - FROM event -) -ORDER BY date ASC -` - -func (q *Queries) GetEventsCurrentAcademicYear(ctx context.Context) ([]Event, error) { - rows, err := q.db.Query(ctx, getEventsCurrentAcademicYear) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Event - for rows.Next() { - var i Event - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Date, - &i.AcademicYear, - &i.Location, - &i.Poster, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/internal/pkg/db/sqlc/gamification.sql.go b/internal/pkg/db/sqlc/gamification.sql.go deleted file mode 100644 index 50d75df..0000000 --- a/internal/pkg/db/sqlc/gamification.sql.go +++ /dev/null @@ -1,149 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.27.0 -// source: gamification.sql - -package sqlc - -import ( - "context" -) - -const createGamification = `-- name: CreateGamification :one -INSERT INTO gamification (name, score, avatar) -VALUES ($1, $2, $3) -RETURNING id, name, score, avatar -` - -type CreateGamificationParams struct { - Name string - Score int32 - Avatar []byte -} - -func (q *Queries) CreateGamification(ctx context.Context, arg CreateGamificationParams) (Gamification, error) { - row := q.db.QueryRow(ctx, createGamification, arg.Name, arg.Score, arg.Avatar) - var i Gamification - err := row.Scan( - &i.ID, - &i.Name, - &i.Score, - &i.Avatar, - ) - return i, err -} - -const deleteGamification = `-- name: DeleteGamification :execrows -DELETE FROM gamification -WHERE id = $1 -` - -func (q *Queries) DeleteGamification(ctx context.Context, id int32) (int64, error) { - result, err := q.db.Exec(ctx, deleteGamification, id) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const deleteGamificationAll = `-- name: DeleteGamificationAll :execrows -DELETE FROM gamification -` - -func (q *Queries) DeleteGamificationAll(ctx context.Context) (int64, error) { - result, err := q.db.Exec(ctx, deleteGamificationAll) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const getAllGamification = `-- name: GetAllGamification :many - -SELECT id, name, score, avatar -FROM gamification -` - -// CRUD -func (q *Queries) GetAllGamification(ctx context.Context) ([]Gamification, error) { - rows, err := q.db.Query(ctx, getAllGamification) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Gamification - for rows.Next() { - var i Gamification - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Score, - &i.Avatar, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getAllGamificationByScore = `-- name: GetAllGamificationByScore :many -SELECT id, name, score, avatar -FROM gamification -ORDER BY score DESC -` - -func (q *Queries) GetAllGamificationByScore(ctx context.Context) ([]Gamification, error) { - rows, err := q.db.Query(ctx, getAllGamificationByScore) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Gamification - for rows.Next() { - var i Gamification - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Score, - &i.Avatar, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const updateGamificationScore = `-- name: UpdateGamificationScore :one - - -UPDATE gamification -SET score = $1 -WHERE id = $2 -RETURNING id, name, score, avatar -` - -type UpdateGamificationScoreParams struct { - Score int32 - ID int32 -} - -// Other -func (q *Queries) UpdateGamificationScore(ctx context.Context, arg UpdateGamificationScoreParams) (Gamification, error) { - row := q.db.QueryRow(ctx, updateGamificationScore, arg.Score, arg.ID) - var i Gamification - err := row.Scan( - &i.ID, - &i.Name, - &i.Score, - &i.Avatar, - ) - return i, err -} diff --git a/internal/pkg/db/sqlc/message.sql.go b/internal/pkg/db/sqlc/message.sql.go deleted file mode 100644 index 194b970..0000000 --- a/internal/pkg/db/sqlc/message.sql.go +++ /dev/null @@ -1,188 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.27.0 -// source: message.sql - -package sqlc - -import ( - "context" -) - -const createMessage = `-- name: CreateMessage :one -INSERT INTO message (name, ip, message) -VALUES ($1, $2, $3) -RETURNING id, name, ip, message, created_at -` - -type CreateMessageParams struct { - Name string - Ip string - Message string -} - -func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) { - row := q.db.QueryRow(ctx, createMessage, arg.Name, arg.Ip, arg.Message) - var i Message - err := row.Scan( - &i.ID, - &i.Name, - &i.Ip, - &i.Message, - &i.CreatedAt, - ) - return i, err -} - -const deleteMessage = `-- name: DeleteMessage :execrows -DELETE FROM message -WHERE id = $1 -` - -func (q *Queries) DeleteMessage(ctx context.Context, id int32) (int64, error) { - result, err := q.db.Exec(ctx, deleteMessage, id) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const getAllMessages = `-- name: GetAllMessages :many - -SELECT id, name, ip, message, created_at -FROM message -` - -// CRUD -func (q *Queries) GetAllMessages(ctx context.Context) ([]Message, error) { - rows, err := q.db.Query(ctx, getAllMessages) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Message - for rows.Next() { - var i Message - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Ip, - &i.Message, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getLastMessage = `-- name: GetLastMessage :one - - -SELECT id, name, ip, message, created_at -FROM message -ORDER BY id DESC -LIMIT 1 -` - -// Other -func (q *Queries) GetLastMessage(ctx context.Context) (Message, error) { - row := q.db.QueryRow(ctx, getLastMessage) - var i Message - err := row.Scan( - &i.ID, - &i.Name, - &i.Ip, - &i.Message, - &i.CreatedAt, - ) - return i, err -} - -const getMessageByID = `-- name: GetMessageByID :one -SELECT id, name, ip, message, created_at -FROM message -WHERE id = $1 -` - -func (q *Queries) GetMessageByID(ctx context.Context, id int32) (Message, error) { - row := q.db.QueryRow(ctx, getMessageByID, id) - var i Message - err := row.Scan( - &i.ID, - &i.Name, - &i.Ip, - &i.Message, - &i.CreatedAt, - ) - return i, err -} - -const getMessageSinceID = `-- name: GetMessageSinceID :many -SELECT id, name, ip, message, created_at -FROM message -WHERE id > $1 -ORDER BY created_at ASC -` - -func (q *Queries) GetMessageSinceID(ctx context.Context, id int32) ([]Message, error) { - rows, err := q.db.Query(ctx, getMessageSinceID, id) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Message - for rows.Next() { - var i Message - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Ip, - &i.Message, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const updateMessage = `-- name: UpdateMessage :one -UPDATE message -SET name = $1, ip = $2, message = $3 -WHERE id = $4 -RETURNING id, name, ip, message, created_at -` - -type UpdateMessageParams struct { - Name string - Ip string - Message string - ID int32 -} - -func (q *Queries) UpdateMessage(ctx context.Context, arg UpdateMessageParams) (Message, error) { - row := q.db.QueryRow(ctx, updateMessage, - arg.Name, - arg.Ip, - arg.Message, - arg.ID, - ) - var i Message - err := row.Scan( - &i.ID, - &i.Name, - &i.Ip, - &i.Message, - &i.CreatedAt, - ) - return i, err -} diff --git a/internal/pkg/db/sqlc/scan.sql.go b/internal/pkg/db/sqlc/scan.sql.go deleted file mode 100644 index 38a6499..0000000 --- a/internal/pkg/db/sqlc/scan.sql.go +++ /dev/null @@ -1,161 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.27.0 -// source: scan.sql - -package sqlc - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createScan = `-- name: CreateScan :one -INSERT INTO scan (scan_id, scan_time) -VALUES ($1, $2) -RETURNING id, scan_time, scan_id -` - -type CreateScanParams struct { - ScanID int32 - ScanTime pgtype.Timestamptz -} - -func (q *Queries) CreateScan(ctx context.Context, arg CreateScanParams) (Scan, error) { - row := q.db.QueryRow(ctx, createScan, arg.ScanID, arg.ScanTime) - var i Scan - err := row.Scan(&i.ID, &i.ScanTime, &i.ScanID) - return i, err -} - -const deleteScan = `-- name: DeleteScan :execrows -DELETE FROM scan -WHERE id = $1 -` - -func (q *Queries) DeleteScan(ctx context.Context, id int32) (int64, error) { - result, err := q.db.Exec(ctx, deleteScan, id) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const getAllScans = `-- name: GetAllScans :many - -SELECT id, scan_time, scan_id -FROM scan -` - -// CRUD -func (q *Queries) GetAllScans(ctx context.Context) ([]Scan, error) { - rows, err := q.db.Query(ctx, getAllScans) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Scan - for rows.Next() { - var i Scan - if err := rows.Scan(&i.ID, &i.ScanTime, &i.ScanID); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getAllScansSinceID = `-- name: GetAllScansSinceID :many -SELECT id, scan_time, scan_id -FROM scan -WHERE id > $1 -ORDER BY scan_id, scan_time ASC -` - -func (q *Queries) GetAllScansSinceID(ctx context.Context, id int32) ([]Scan, error) { - rows, err := q.db.Query(ctx, getAllScansSinceID, id) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Scan - for rows.Next() { - var i Scan - if err := rows.Scan(&i.ID, &i.ScanTime, &i.ScanID); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getLastScan = `-- name: GetLastScan :one - - -SELECT id, scan_time, scan_id -FROM scan -ORDER BY id DESC -LIMIT 1 -` - -// Other -func (q *Queries) GetLastScan(ctx context.Context) (Scan, error) { - row := q.db.QueryRow(ctx, getLastScan) - var i Scan - err := row.Scan(&i.ID, &i.ScanTime, &i.ScanID) - return i, err -} - -const getScanByID = `-- name: GetScanByID :one -SELECT id, scan_time, scan_id -FROM scan -WHERE id = $1 -` - -func (q *Queries) GetScanByID(ctx context.Context, id int32) (Scan, error) { - row := q.db.QueryRow(ctx, getScanByID, id) - var i Scan - err := row.Scan(&i.ID, &i.ScanTime, &i.ScanID) - return i, err -} - -const getScansInCurrentSeason = `-- name: GetScansInCurrentSeason :one -SELECT COUNT(*) AS amount -FROM scan -WHERE scan_time >= (SELECT start_date FROM season WHERE current = true) AND - scan_time <= (SELECT end_date FROM season WHERE current = true) -` - -func (q *Queries) GetScansInCurrentSeason(ctx context.Context) (int64, error) { - row := q.db.QueryRow(ctx, getScansInCurrentSeason) - var amount int64 - err := row.Scan(&amount) - return amount, err -} - -const updateScan = `-- name: UpdateScan :one -UPDATE scan -SET scan_id = $1, scan_time = $2 -WHERE id = $3 -RETURNING id, scan_time, scan_id -` - -type UpdateScanParams struct { - ScanID int32 - ScanTime pgtype.Timestamptz - ID int32 -} - -func (q *Queries) UpdateScan(ctx context.Context, arg UpdateScanParams) (Scan, error) { - row := q.db.QueryRow(ctx, updateScan, arg.ScanID, arg.ScanTime, arg.ID) - var i Scan - err := row.Scan(&i.ID, &i.ScanTime, &i.ScanID) - return i, err -} diff --git a/internal/pkg/db/sqlc/season.sql.go b/internal/pkg/db/sqlc/season.sql.go deleted file mode 100644 index e40e0d8..0000000 --- a/internal/pkg/db/sqlc/season.sql.go +++ /dev/null @@ -1,176 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.27.0 -// source: season.sql - -package sqlc - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createSeason = `-- name: CreateSeason :one -INSERT INTO season (name, start, "end", current) -VALUES ($1, $2, $3, $4) -RETURNING id, name, start, "end", current -` - -type CreateSeasonParams struct { - Name string - Start pgtype.Timestamp - End pgtype.Timestamp - Current bool -} - -func (q *Queries) CreateSeason(ctx context.Context, arg CreateSeasonParams) (Season, error) { - row := q.db.QueryRow(ctx, createSeason, - arg.Name, - arg.Start, - arg.End, - arg.Current, - ) - var i Season - err := row.Scan( - &i.ID, - &i.Name, - &i.Start, - &i.End, - &i.Current, - ) - return i, err -} - -const deleteSeason = `-- name: DeleteSeason :execrows -DELETE FROM season -WHERE id = $1 -` - -func (q *Queries) DeleteSeason(ctx context.Context, id int32) (int64, error) { - result, err := q.db.Exec(ctx, deleteSeason, id) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const deleteSeasonAll = `-- name: DeleteSeasonAll :execrows -DELETE FROM season -` - -func (q *Queries) DeleteSeasonAll(ctx context.Context) (int64, error) { - result, err := q.db.Exec(ctx, deleteSeasonAll) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const getAllSeasons = `-- name: GetAllSeasons :many - -SELECT id, name, start, "end", current -FROM season -` - -// CRUD -func (q *Queries) GetAllSeasons(ctx context.Context) ([]Season, error) { - rows, err := q.db.Query(ctx, getAllSeasons) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Season - for rows.Next() { - var i Season - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Start, - &i.End, - &i.Current, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getSeasonByID = `-- name: GetSeasonByID :one -SELECT id, name, start, "end", current -FROM season -WHERE id = $1 -` - -func (q *Queries) GetSeasonByID(ctx context.Context, id int32) (Season, error) { - row := q.db.QueryRow(ctx, getSeasonByID, id) - var i Season - err := row.Scan( - &i.ID, - &i.Name, - &i.Start, - &i.End, - &i.Current, - ) - return i, err -} - -const getSeasonCurrent = `-- name: GetSeasonCurrent :one - - -SELECT id, name, start, "end", current -FROM season -WHERE current = true -` - -// Other -func (q *Queries) GetSeasonCurrent(ctx context.Context) (Season, error) { - row := q.db.QueryRow(ctx, getSeasonCurrent) - var i Season - err := row.Scan( - &i.ID, - &i.Name, - &i.Start, - &i.End, - &i.Current, - ) - return i, err -} - -const updateSeason = `-- name: UpdateSeason :one -UPDATE season -SET name = $1, start = $2, "end" = $3, current = $4 -WHERE id = $5 -RETURNING id, name, start, "end", current -` - -type UpdateSeasonParams struct { - Name string - Start pgtype.Timestamp - End pgtype.Timestamp - Current bool - ID int32 -} - -func (q *Queries) UpdateSeason(ctx context.Context, arg UpdateSeasonParams) (Season, error) { - row := q.db.QueryRow(ctx, updateSeason, - arg.Name, - arg.Start, - arg.End, - arg.Current, - arg.ID, - ) - var i Season - err := row.Scan( - &i.ID, - &i.Name, - &i.Start, - &i.End, - &i.Current, - ) - return i, err -} diff --git a/internal/pkg/db/sqlc/tap.sql.go b/internal/pkg/db/sqlc/tap.sql.go deleted file mode 100644 index 56ac2eb..0000000 --- a/internal/pkg/db/sqlc/tap.sql.go +++ /dev/null @@ -1,287 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.27.0 -// source: tap.sql - -package sqlc - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createTap = `-- name: CreateTap :one -INSERT INTO tap (order_id, order_created_at, name, category) -VALUES ($1, $2, $3, $4) -RETURNING id, order_id, order_created_at, name, category, created_at -` - -type CreateTapParams struct { - OrderID int32 - OrderCreatedAt pgtype.Timestamptz - Name string - Category string -} - -func (q *Queries) CreateTap(ctx context.Context, arg CreateTapParams) (Tap, error) { - row := q.db.QueryRow(ctx, createTap, - arg.OrderID, - arg.OrderCreatedAt, - arg.Name, - arg.Category, - ) - var i Tap - err := row.Scan( - &i.ID, - &i.OrderID, - &i.OrderCreatedAt, - &i.Name, - &i.Category, - &i.CreatedAt, - ) - return i, err -} - -const deleteTap = `-- name: DeleteTap :execrows -DELETE FROM tap -WHERE id = $1 -` - -func (q *Queries) DeleteTap(ctx context.Context, id int32) (int64, error) { - result, err := q.db.Exec(ctx, deleteTap, id) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const getAllTaps = `-- name: GetAllTaps :many - -SELECT id, order_id, order_created_at, name, category, created_at -FROM tap -` - -// CRUD -func (q *Queries) GetAllTaps(ctx context.Context) ([]Tap, error) { - rows, err := q.db.Query(ctx, getAllTaps) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Tap - for rows.Next() { - var i Tap - if err := rows.Scan( - &i.ID, - &i.OrderID, - &i.OrderCreatedAt, - &i.Name, - &i.Category, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getLastOrderByOrderID = `-- name: GetLastOrderByOrderID :one -SELECT id, order_id, order_created_at, name, category, created_at -FROM tap -ORDER BY order_id DESC -LIMIT 1 -` - -func (q *Queries) GetLastOrderByOrderID(ctx context.Context) (Tap, error) { - row := q.db.QueryRow(ctx, getLastOrderByOrderID) - var i Tap - err := row.Scan( - &i.ID, - &i.OrderID, - &i.OrderCreatedAt, - &i.Name, - &i.Category, - &i.CreatedAt, - ) - return i, err -} - -const getOrderCount = `-- name: GetOrderCount :many -SELECT category, COUNT(*) -FROM tap -GROUP BY category -` - -type GetOrderCountRow struct { - Category string - Count int64 -} - -func (q *Queries) GetOrderCount(ctx context.Context) ([]GetOrderCountRow, error) { - rows, err := q.db.Query(ctx, getOrderCount) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetOrderCountRow - for rows.Next() { - var i GetOrderCountRow - if err := rows.Scan(&i.Category, &i.Count); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getOrderCountByCategorySinceOrderID = `-- name: GetOrderCountByCategorySinceOrderID :many -SELECT category, COUNT(*), MAX(order_created_at)::TIMESTAMP AS latest_order_created_at -FROM tap -WHERE order_id >= $1 -GROUP BY category -` - -type GetOrderCountByCategorySinceOrderIDRow struct { - Category string - Count int64 - LatestOrderCreatedAt pgtype.Timestamp -} - -func (q *Queries) GetOrderCountByCategorySinceOrderID(ctx context.Context, orderID int32) ([]GetOrderCountByCategorySinceOrderIDRow, error) { - rows, err := q.db.Query(ctx, getOrderCountByCategorySinceOrderID, orderID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetOrderCountByCategorySinceOrderIDRow - for rows.Next() { - var i GetOrderCountByCategorySinceOrderIDRow - if err := rows.Scan(&i.Category, &i.Count, &i.LatestOrderCreatedAt); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getTapByCategory = `-- name: GetTapByCategory :many -SELECT id, order_id, order_created_at, name, category, created_at -FROM tap -WHERE category = $1 -` - -func (q *Queries) GetTapByCategory(ctx context.Context, category string) ([]Tap, error) { - rows, err := q.db.Query(ctx, getTapByCategory, category) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Tap - for rows.Next() { - var i Tap - if err := rows.Scan( - &i.ID, - &i.OrderID, - &i.OrderCreatedAt, - &i.Name, - &i.Category, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getTapByID = `-- name: GetTapByID :one -SELECT id, order_id, order_created_at, name, category, created_at -FROM tap -WHERE id = $1 -` - -func (q *Queries) GetTapByID(ctx context.Context, id int32) (Tap, error) { - row := q.db.QueryRow(ctx, getTapByID, id) - var i Tap - err := row.Scan( - &i.ID, - &i.OrderID, - &i.OrderCreatedAt, - &i.Name, - &i.Category, - &i.CreatedAt, - ) - return i, err -} - -const getTapByOrderID = `-- name: GetTapByOrderID :one - - -SELECT id, order_id, order_created_at, name, category, created_at -FROM tap -WHERE order_id = $1 -` - -// Other -func (q *Queries) GetTapByOrderID(ctx context.Context, orderID int32) (Tap, error) { - row := q.db.QueryRow(ctx, getTapByOrderID, orderID) - var i Tap - err := row.Scan( - &i.ID, - &i.OrderID, - &i.OrderCreatedAt, - &i.Name, - &i.Category, - &i.CreatedAt, - ) - return i, err -} - -const updateTap = `-- name: UpdateTap :one -UPDATE tap -SET order_id = $1, order_created_at = $2, name = $3, category = $4 -WHERE id = $5 -RETURNING id, order_id, order_created_at, name, category, created_at -` - -type UpdateTapParams struct { - OrderID int32 - OrderCreatedAt pgtype.Timestamptz - Name string - Category string - ID int32 -} - -func (q *Queries) UpdateTap(ctx context.Context, arg UpdateTapParams) (Tap, error) { - row := q.db.QueryRow(ctx, updateTap, - arg.OrderID, - arg.OrderCreatedAt, - arg.Name, - arg.Category, - arg.ID, - ) - var i Tap - err := row.Scan( - &i.ID, - &i.OrderID, - &i.OrderCreatedAt, - &i.Name, - &i.Category, - &i.CreatedAt, - ) - return i, err -} diff --git a/internal/pkg/event/api.go b/internal/pkg/event/api.go deleted file mode 100644 index b0b89e2..0000000 --- a/internal/pkg/event/api.go +++ /dev/null @@ -1,117 +0,0 @@ -package event - -import ( - "errors" - "fmt" - "strconv" - "strings" - "time" - - "github.com/gocolly/colly" - "github.com/gofiber/fiber/v2" - "github.com/zeusWPI/scc/internal/pkg/db/dto" - "go.uber.org/zap" -) - -var layout = "Monday 02 January, 15:04 2006" - -func (e *Event) getEvents() ([]dto.Event, error) { - zap.S().Info("Events: Getting all events") - - var events []dto.Event - var errs []error - c := colly.NewCollector() - - c.OnHTML(".event-tile", func(el *colly.HTMLElement) { - event := dto.Event{} - - // Name - event.Name = el.ChildText(".is-size-4-mobile") - - // Date & Location - dateLoc := el.DOM.Find(".event-time-loc").Contents() - dateLocStr := strings.Split(dateLoc.Text(), " ") - - if len(dateLocStr) != 2 { - errs = append(errs, fmt.Errorf("Event: Unable to scrape date and location %s", dateLocStr)) - return - } - - // Location - event.Location = strings.TrimSpace(dateLocStr[1]) - - // Date - date := strings.TrimSpace(dateLocStr[0]) - - yearString := el.Attr("href") - yearParts := strings.Split(yearString, "/") - if len(yearParts) == 5 { - rangeParts := strings.Split(yearParts[2], "-") - if len(rangeParts) == 2 { - partIdx := 0 - if time.Now().Month() < time.September { - partIdx = 1 - } - - dateWithYear := fmt.Sprintf("%s 20%s", date, rangeParts[partIdx]) - parsedDate, err := time.Parse(layout, dateWithYear) - if err == nil { - event.AcademicYear = yearParts[2] - event.Date = parsedDate - } - } - } - // Check for error - if event.Date.IsZero() { - errs = append(errs, fmt.Errorf("Event: Unable to parse date %s %s", date, yearString)) - return - } - - events = append(events, event) - }) - - err := c.Visit(e.website) - if err != nil { - return nil, err - } - - c.Wait() - - return events, errors.Join(errs...) -} - -func (e *Event) getPoster(event *dto.Event) error { - zap.S().Info("Events: Getting poster for ", event.Name) - - yearParts := strings.Split(event.AcademicYear, "-") - if len(yearParts) != 2 { - return fmt.Errorf("Event: Academic year not properly formatted %s", event.AcademicYear) - } - - yearStart, err := strconv.Atoi(yearParts[0]) - if err != nil { - return fmt.Errorf("Event: Unable to convert academic year to int %v", yearParts) - } - yearEnd, err := strconv.Atoi(yearParts[1]) - if err != nil { - return fmt.Errorf("Event: Unable to convert academic year to int %v", yearParts) - } - - year := fmt.Sprintf("20%d-20%d", yearStart, yearEnd) - - url := fmt.Sprintf("%s/%s/%s/scc.png", e.websitePoster, year, event.Name) - - req := fiber.Get(url) - status, body, errs := req.Bytes() - if len(errs) != 0 { - return errors.Join(append(errs, errors.New("Event: Download poster request failed"))...) - } - if status != fiber.StatusOK { - // No poster for event - return nil - } - - event.Poster = body - - return nil -} diff --git a/internal/pkg/event/event.go b/internal/pkg/event/event.go deleted file mode 100644 index 9ccbb5f..0000000 --- a/internal/pkg/event/event.go +++ /dev/null @@ -1,105 +0,0 @@ -// Package event provides all logic regarding the events of the website -package event - -import ( - "context" - "errors" - "slices" - "sync" - - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/db/dto" - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" - "github.com/zeusWPI/scc/pkg/config" -) - -// Event represents a event instance -type Event struct { - db *db.DB - website string - websitePoster string -} - -// New creates a new event instance -func New(db *db.DB) *Event { - return &Event{ - db: db, - website: config.GetDefaultString("backend.event.website", "https://zeus.gent/events/"), - websitePoster: config.GetDefaultString("backend.event.website_poster", "https://git.zeus.gent/ZeusWPI/visueel/raw/branch/master"), - } -} - -// Update gets all events from the website of this academic year -func (e *Event) Update() error { - events, err := e.getEvents() - if err != nil { - return err - } - if len(events) == 0 { - return nil - } - - eventsDBSQL, err := e.db.Queries.GetEventByAcademicYear(context.Background(), events[0].AcademicYear) - if err != nil { - return err - } - - eventsDB := make([]*dto.Event, 0, len(eventsDBSQL)) - - var wg sync.WaitGroup - var errs []error - for _, event := range eventsDBSQL { - wg.Add(1) - - go func(event sqlc.Event) { - defer wg.Done() - - ev := dto.EventDTO(event) - eventsDB = append(eventsDB, ev) - err := e.getPoster(ev) - if err != nil { - errs = append(errs, err) - } - }(event) - } - wg.Wait() - - // Check if there are any new events - equal := true - for _, event := range eventsDB { - found := slices.ContainsFunc(events, func(ev dto.Event) bool { return ev.Equal(*event) }) - if !found { - equal = false - break - } - } - - if len(events) != len(eventsDB) { - equal = false - } - - // Both are equal, nothing to be done - if equal { - return nil - } - - // They differ, remove the old ones and insert the new once - err = e.db.Queries.DeleteEventByAcademicYear(context.Background(), events[0].AcademicYear) - if err != nil { - return err - } - - for _, event := range events { - err = e.getPoster(&event) - if err != nil { - errs = append(errs, err) - // Don't return / continue. We can still enter it without a poster - } - _, err = e.db.Queries.CreateEvent(context.Background(), event.CreateParams()) - if err != nil { - errs = append(errs, err) - } - } - - return errors.Join(errs...) -} diff --git a/internal/pkg/gamification/api.go b/internal/pkg/gamification/api.go deleted file mode 100644 index 6e3f1a3..0000000 --- a/internal/pkg/gamification/api.go +++ /dev/null @@ -1,78 +0,0 @@ -package gamification - -import ( - "errors" - "fmt" - - "github.com/gofiber/fiber/v2" - "github.com/zeusWPI/scc/internal/pkg/db/dto" - "go.uber.org/zap" -) - -type gamificationItem struct { - ID int32 `json:"id"` - Name string `json:"github_name"` - Score int32 `json:"score"` - AvatarURL string `json:"avatar_url"` -} - -func (g *Gamification) getLeaderboard() ([]dto.Gamification, error) { - zap.S().Info("Gamification: Getting leaderboard") - - req := fiber.Get(g.api+"/top4").Set("Accept", "application/json") - - res := new([]gamificationItem) - status, _, errs := req.Struct(res) - if len(errs) > 0 { - return nil, errors.Join(append(errs, errors.New("Gamification: Leaderboard API request failed"))...) - } - if status != fiber.StatusOK { - return nil, fmt.Errorf("Gamification: Leaderboard API request returned bad status code %d", status) - } - - errs = make([]error, 0) - for _, gam := range *res { - if err := dto.Validate.Struct(gam); err != nil { - errs = append(errs, err) - } - } - - if len(errs) != 0 { - return nil, errors.Join(errs...) - } - - gams := make([]dto.Gamification, 0, 4) - for _, item := range *res { - gam, err := downloadAvatar(item) - if err != nil { - errs = append(errs, err) - continue - } - - gams = append(gams, gam) - } - - return gams, errors.Join(errs...) -} - -func downloadAvatar(gam gamificationItem) (dto.Gamification, error) { - zap.S().Info("Gamification: Getting avatar for ", gam.Name) - - req := fiber.Get(gam.AvatarURL) - status, body, errs := req.Bytes() - if len(errs) != 0 { - return dto.Gamification{}, errors.Join(append(errs, errors.New("Gamification: Download avatar request failed"))...) - } - if status != fiber.StatusOK { - return dto.Gamification{}, fmt.Errorf("Gamification: Download avatar returned bad status code %d", status) - } - - g := dto.Gamification{ - ID: gam.ID, - Name: gam.Name, - Score: gam.Score, - Avatar: body, - } - - return g, nil -} diff --git a/internal/pkg/gamification/gamification.go b/internal/pkg/gamification/gamification.go deleted file mode 100644 index aeb2ed4..0000000 --- a/internal/pkg/gamification/gamification.go +++ /dev/null @@ -1,45 +0,0 @@ -// Package gamification provides all gamification related logic -package gamification - -import ( - "context" - "errors" - - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/pkg/config" -) - -// Gamification represents a gamification instance -type Gamification struct { - db *db.DB - api string -} - -// New creates a new gamification instance -func New(db *db.DB) *Gamification { - return &Gamification{ - db: db, - api: config.GetDefaultString("backend.gamification.api", "https://gamification.zeus.gent"), - } -} - -// Update gets the current leaderboard from gamification -func (g *Gamification) Update() error { - if _, err := g.db.Queries.DeleteGamificationAll(context.Background()); err != nil { - return err - } - - leaderboard, err := g.getLeaderboard() - if err != nil { - return err - } - - var errs []error - for _, item := range leaderboard { - if _, err := g.db.Queries.CreateGamification(context.Background(), item.CreateParams()); err != nil { - errs = append(errs, err) - } - } - - return errors.Join(errs...) -} diff --git a/internal/pkg/tap/api.go b/internal/pkg/tap/api.go deleted file mode 100644 index 522b16e..0000000 --- a/internal/pkg/tap/api.go +++ /dev/null @@ -1,37 +0,0 @@ -package tap - -import ( - "errors" - "time" - - "github.com/gofiber/fiber/v2" - "go.uber.org/zap" -) - -type orderResponseItem struct { - OrderID int32 `json:"order_id"` - OrderCreatedAt time.Time `json:"order_created_at"` - ProductName string `json:"product_name"` - ProductCategory string `json:"product_category"` -} - -type orderResponse struct { - Orders []orderResponseItem `json:"orders"` -} - -func (t *Tap) getOrders() ([]orderResponseItem, error) { - zap.S().Info("Tap: Getting orders") - - req := fiber.Get(t.api + "/recent") - - res := new(orderResponse) - status, _, errs := req.Struct(res) - if len(errs) > 0 { - return nil, errors.Join(append(errs, errors.New("Tap: Order API request failed"))...) - } - if status != fiber.StatusOK { - return nil, errors.New("error getting orders") - } - - return res.Orders, nil -} diff --git a/internal/pkg/tap/tap.go b/internal/pkg/tap/tap.go deleted file mode 100644 index d629566..0000000 --- a/internal/pkg/tap/tap.go +++ /dev/null @@ -1,113 +0,0 @@ -// Package tap provides all tap related logic -package tap - -import ( - "context" - "errors" - "slices" - "strings" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" - "github.com/zeusWPI/scc/pkg/config" - "github.com/zeusWPI/scc/pkg/util" -) - -// Tap represents a tap instance -type Tap struct { - db *db.DB - beers []string - api string -} - -var defaultBeers = []string{ - "Schelfaut", - "Duvel", - "Fourchette", - "Jupiler", - "Karmeliet", - "Kriek", - "Chouffe", - "Maes", - "Somersby", - "Sportzot", - "Stella", -} - -// New creates a new tap instance -func New(db *db.DB) *Tap { - return &Tap{ - db: db, - beers: config.GetDefaultStringSlice("backend.tap.beers", defaultBeers), - api: config.GetDefaultString("backend.tap.api", "https://tap.zeus.gent"), - } -} - -// Update gets all new orders from tap -func (t *Tap) Update() error { - // Get latest order - lastOrder, err := t.db.Queries.GetLastOrderByOrderID(context.Background()) - if err != nil { - if err != pgx.ErrNoRows { - return err - } - - lastOrder = sqlc.Tap{OrderID: -1} - } - - // Get all orders - allOrders, err := t.getOrders() - if err != nil { - return err - } - - // Only keep the new orders - orders := util.SliceFilter(allOrders, func(o orderResponseItem) bool { return o.OrderID > lastOrder.OrderID }) - - if len(orders) == 0 { - return nil - } - - // Adjust categories - t.adjustCategories(orders) - - // Insert all new orders - errs := make([]error, 0) - for _, order := range orders { - _, err := t.db.Queries.CreateTap(context.Background(), sqlc.CreateTapParams{ - OrderID: order.OrderID, - OrderCreatedAt: pgtype.Timestamptz{Time: order.OrderCreatedAt, Valid: true}, - Name: order.ProductName, - Category: order.ProductCategory, - }) - if err != nil { - errs = append(errs, err) - } - } - - return errors.Join(errs...) -} - -// adjustCategories changes the categories of the orders to the custom ones -func (t *Tap) adjustCategories(orders []orderResponseItem) { - for i := range orders { - order := &orders[i] // Take a pointer to the struct to modify it directly - switch order.ProductCategory { - case "food": - order.ProductCategory = "Food" - case "other": - order.ProductCategory = "Other" - case "beverages": - // Atm only beverages get special categories - if strings.Contains(order.ProductName, "Mate") || strings.Contains(order.ProductName, "Mio Mio") { - order.ProductCategory = "Mate" - } else if slices.ContainsFunc(t.beers, func(beer string) bool { return strings.Contains(order.ProductName, beer) }) { - order.ProductCategory = "Beer" - } else { - order.ProductCategory = "Soft" - } - } - } -} diff --git a/internal/pkg/zess/api.go b/internal/pkg/zess/api.go deleted file mode 100644 index 3b26ff1..0000000 --- a/internal/pkg/zess/api.go +++ /dev/null @@ -1,58 +0,0 @@ -package zess - -import ( - "errors" - "fmt" - - "github.com/gofiber/fiber/v2" - "github.com/zeusWPI/scc/internal/pkg/db/dto" - "go.uber.org/zap" -) - -func (z *Zess) getSeasons() (*[]*dto.Season, error) { - zap.S().Info("Zess: Getting seasons") - - req := fiber.Get(z.api + "/seasons") - - res := new([]*dto.Season) - status, _, errs := req.Struct(res) - if len(errs) > 0 { - return nil, errors.Join(append([]error{errors.New("Zess: Season API request failed")}, errs...)...) - } - if status != fiber.StatusOK { - return nil, errors.New("error getting seasons") - } - - errs = make([]error, 0) - for _, season := range *res { - if err := dto.Validate.Struct(season); err != nil { - errs = append(errs, err) - } - } - - return res, errors.Join(errs...) -} - -func (z *Zess) getScans() (*[]*dto.Scan, error) { - zap.S().Info("Zess: Getting scans") - - req := fiber.Get(z.api + "/recent_scans") - - res := new([]*dto.Scan) - status, _, errs := req.Struct(res) - if len(errs) > 0 { - return nil, errors.Join(append(errs, errors.New("Zess: Scan API request failed"))...) - } - if status != fiber.StatusOK { - return nil, fmt.Errorf("Zess: Scan API returned bad status code %d", status) - } - - errs = make([]error, 0) - for _, scan := range *res { - if err := dto.Validate.Struct(scan); err != nil { - errs = append(errs, err) - } - } - - return res, errors.Join(errs...) -} diff --git a/internal/pkg/zess/zess.go b/internal/pkg/zess/zess.go deleted file mode 100644 index 469b4c2..0000000 --- a/internal/pkg/zess/zess.go +++ /dev/null @@ -1,94 +0,0 @@ -// Package zess provides all zess related logic -package zess - -import ( - "context" - "errors" - "slices" - - "github.com/jackc/pgx/v5" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/db/dto" - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" - "github.com/zeusWPI/scc/pkg/config" - "github.com/zeusWPI/scc/pkg/util" -) - -// Zess represents a zess instance -type Zess struct { - db *db.DB - api string -} - -// New creates a new zess instance -func New(db *db.DB) *Zess { - return &Zess{ - db: db, - api: config.GetDefaultString("backend.zess.api", "https://zess.zeus.gent/api"), - } -} - -// UpdateSeasons updates the seasons -func (z *Zess) UpdateSeasons() error { - seasons, err := z.db.Queries.GetAllSeasons(context.Background()) - if err != nil && err != pgx.ErrNoRows { - return err - } - - // Get all seasons from zess - zessSeasons, err := z.getSeasons() - if err != nil { - return err - } - - if slices.CompareFunc(util.SliceMap(seasons, dto.SeasonDTO), *zessSeasons, dto.SeasonCmp) == 0 { - return nil - } - - // The seasons differ - // Delete all existing and enter the new ones - if _, err := z.db.Queries.DeleteSeasonAll(context.Background()); err != nil { - return err - } - - var errs []error - for _, season := range *zessSeasons { - if _, err := z.db.Queries.CreateSeason(context.Background(), season.CreateParams()); err != nil { - errs = append(errs, err) - } - } - - return errors.Join(errs...) -} - -// UpdateScans updates the scans -func (z *Zess) UpdateScans() error { - lastScan, err := z.db.Queries.GetLastScan(context.Background()) - if err != nil { - if err != pgx.ErrNoRows { - return err - } - - lastScan = sqlc.Scan{ScanID: -1} - } - - // Get all scans - zessScans, err := z.getScans() - if err != nil { - return err - } - - errs := make([]error, 0) - for _, scan := range *zessScans { - if scan.ScanID <= lastScan.ScanID { - continue - } - - _, err := z.db.Queries.CreateScan(context.Background(), scan.CreateParams()) - if err != nil { - errs = append(errs, err) - } - } - - return errors.Join(errs...) -} diff --git a/internal/server/api/message.go b/internal/server/api/message.go new file mode 100644 index 0000000..69aaa2b --- /dev/null +++ b/internal/server/api/message.go @@ -0,0 +1,45 @@ +package api + +import ( + "github.com/gofiber/fiber/v2" + "github.com/zeusWPI/scc/internal/server/dto" + "github.com/zeusWPI/scc/internal/server/service" +) + +type Message struct { + router fiber.Router + message service.Message +} + +func NewMessage(router fiber.Router, service service.Service) *Message { + api := &Message{ + router: router.Group("/messages"), + message: *service.NewMessage(), + } + + api.createRoutes() + + return api +} + +func (m *Message) createRoutes() { + m.router.Post("/", m.create) +} + +func (m *Message) create(c *fiber.Ctx) error { + var message dto.Message + + if err := c.BodyParser(&message); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + if err := dto.Validate.Struct(message); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + newMessage, err := m.message.Create(c.Context(), message) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated).JSON(newMessage) +} diff --git a/internal/server/api/song.go b/internal/server/api/song.go new file mode 100644 index 0000000..6919731 --- /dev/null +++ b/internal/server/api/song.go @@ -0,0 +1,44 @@ +package api + +import ( + "github.com/gofiber/fiber/v2" + "github.com/zeusWPI/scc/internal/server/dto" + "github.com/zeusWPI/scc/internal/server/service" +) + +type Song struct { + router fiber.Router + song service.Song +} + +func NewSong(router fiber.Router, service service.Service) *Song { + api := &Song{ + router: router.Group("/song"), + song: *service.NewSong(), + } + + api.createRoutes() + + return api +} + +func (s *Song) createRoutes() { + s.router.Post("/", s.new) +} + +func (s *Song) new(c *fiber.Ctx) error { + var song dto.Song + + if err := c.BodyParser(&song); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + if err := dto.Validate.Struct(song); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if err := s.song.New(c.Context(), song); err != nil { + return err + } + + return c.SendStatus(fiber.StatusOK) +} diff --git a/internal/server/dto/dto.go b/internal/server/dto/dto.go new file mode 100644 index 0000000..4d3a29f --- /dev/null +++ b/internal/server/dto/dto.go @@ -0,0 +1,6 @@ +// Package dto forms the bridge between the api data and the internal models +package dto + +import "github.com/go-playground/validator/v10" + +var Validate = validator.New(validator.WithRequiredStructEnabled()) diff --git a/internal/server/dto/message.go b/internal/server/dto/message.go new file mode 100644 index 0000000..521488f --- /dev/null +++ b/internal/server/dto/message.go @@ -0,0 +1,3 @@ +package dto + +type Message struct{} diff --git a/internal/server/dto/song.go b/internal/server/dto/song.go new file mode 100644 index 0000000..031096c --- /dev/null +++ b/internal/server/dto/song.go @@ -0,0 +1,3 @@ +package dto + +type Song struct{} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..adf154d --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,62 @@ +// Package server starts the API server +package server + +import ( + "fmt" + + "github.com/gofiber/contrib/fiberzap" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/jackc/pgx/v5/pgxpool" + routers "github.com/zeusWPI/scc/internal/server/api" + "github.com/zeusWPI/scc/internal/server/service" + "github.com/zeusWPI/scc/pkg/config" + "go.uber.org/zap" +) + +type Server struct { + *fiber.App + Addr string +} + +func NewServer(service service.Service, pool *pgxpool.Pool) *Server { + env := config.GetDefaultString("app.env", "development") + + // Construct app + app := fiber.New(fiber.Config{ + BodyLimit: 16 * 1024 * 1024, + ReadBufferSize: 8096, + }) + + app.Use(fiberzap.New(fiberzap.Config{ + Logger: zap.L(), + })) + if env != "production" { + app.Use(cors.New(cors.Config{ + AllowOrigins: "http://localhost:3000", + AllowHeaders: "Origin, Content-Type, Accept, Access-Control-Allow-Origin", + AllowCredentials: true, + })) + } + + // Register routes + api := app.Group("/api") + + routers.NewMessage(api, service) + routers.NewSong(api, service) + + // Fallback + app.All("/api*", func(c *fiber.Ctx) error { + return c.SendStatus(404) + }) + + port := config.GetDefaultInt("server.port", 4000) + host := config.GetDefaultString("server.host", "0.0.0.0") + + srv := &Server{ + Addr: fmt.Sprintf("%s:%d", host, port), + App: app, + } + + return srv +} diff --git a/internal/server/service/message.go b/internal/server/service/message.go new file mode 100644 index 0000000..a1f989d --- /dev/null +++ b/internal/server/service/message.go @@ -0,0 +1,18 @@ +package service + +import ( + "context" + + "github.com/zeusWPI/scc/internal/server/dto" +) + +type Message struct{} + +func (s *Service) NewMessage() *Message { + return &Message{} +} + +// TODO: fill in +func (m *Message) Create(ctx context.Context, message dto.Message) (dto.Message, error) { + return dto.Message{}, nil +} diff --git a/internal/server/service/service.go b/internal/server/service/service.go new file mode 100644 index 0000000..4fb0663 --- /dev/null +++ b/internal/server/service/service.go @@ -0,0 +1,21 @@ +package service + +import ( + "context" + + "github.com/zeusWPI/scc/internal/database/repository" +) + +type Service struct { + repo repository.Repository +} + +func New(repo repository.Repository) *Service { + return &Service{ + repo: repo, + } +} + +func (s *Service) withRollback(ctx context.Context, fn func(context.Context) error) error { + return s.repo.WithRollback(ctx, fn) +} diff --git a/internal/server/service/song.go b/internal/server/service/song.go new file mode 100644 index 0000000..846394a --- /dev/null +++ b/internal/server/service/song.go @@ -0,0 +1,18 @@ +package service + +import ( + "context" + + "github.com/zeusWPI/scc/internal/server/dto" +) + +type Song struct{} + +func (s *Service) NewSong() *Song { + return &Song{} +} + +// TODO: Fill in +func (s *Song) New(ctx context.Context, song dto.Song) error { + return nil +} diff --git a/internal/pkg/song/account.go b/internal/song/account.go similarity index 100% rename from internal/pkg/song/account.go rename to internal/song/account.go diff --git a/internal/pkg/song/api.go b/internal/song/api.go similarity index 95% rename from internal/pkg/song/api.go rename to internal/song/api.go index 1758e46..e3a30e6 100644 --- a/internal/pkg/song/api.go +++ b/internal/song/api.go @@ -30,7 +30,7 @@ func (s *Song) getTrack(track *dto.Song) error { zap.S().Info("Song: Getting track info for id: ", track.SpotifyID) req := fiber.Get(fmt.Sprintf("%s/%s/%s", s.api, "tracks", track.SpotifyID)). - Set("Authorization", fmt.Sprintf("Bearer %s", s.AccessToken)) + Set("Authorization", "Bearer "+s.AccessToken) res := new(trackResponse) status, _, errs := req.Struct(res) @@ -71,7 +71,7 @@ func (s *Song) getArtist(artist *dto.SongArtist) error { zap.S().Info("Song: Getting artists info for ", artist.SpotifyID) req := fiber.Get(fmt.Sprintf("%s/%s/%s", s.api, "artists", artist.SpotifyID)). - Set("Authorization", fmt.Sprintf("Bearer %s", s.AccessToken)) + Set("Authorization", "Bearer "+s.AccessToken) res := new(artistResponse) status, _, errs := req.Struct(res) @@ -117,7 +117,7 @@ func (s *Song) getLyrics(track *dto.Song) error { params.Set("artist_name", artist.Name) params.Set("track_name", track.Title) params.Set("album_name", track.Album) - params.Set("duration", fmt.Sprintf("%d", track.DurationMS/1000)) + params.Set("duration", strconv.Itoa(int(track.DurationMS/1000))) req := fiber.Get(fmt.Sprintf("%s/get?%s", s.apiLrclib, params.Encode())) diff --git a/internal/pkg/song/song.go b/internal/song/song.go similarity index 64% rename from internal/pkg/song/song.go rename to internal/song/song.go index 5967d51..bd93f86 100644 --- a/internal/pkg/song/song.go +++ b/internal/song/song.go @@ -7,80 +7,82 @@ import ( "time" "github.com/jackc/pgx/v5" - "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/database/repository" "github.com/zeusWPI/scc/internal/pkg/db/dto" "github.com/zeusWPI/scc/internal/pkg/db/sqlc" "github.com/zeusWPI/scc/pkg/config" ) -// Song represents a song instance type Song struct { - db *db.DB - ClientID string - ClientSecret string - AccessToken string - ExpiresTime int64 - - api string - apiAccount string - apiLrclib string + song repository.Song + + clientID string + clientSecret string + accessToken string + expiresTime int64 + + url string + urlAccount string + urlLrclib string } -// New creates a new song instance -func New(db *db.DB) (*Song, error) { +func New(repo repository.Repository) (*Song, error) { clientID := config.GetDefaultString("backend.song.spotify_client_id", "") clientSecret := config.GetDefaultString("backend.song.spotify_client_secret", "") if clientID == "" || clientSecret == "" { - return &Song{}, errors.New("Song: Spotify client id or secret not set") + return &Song{}, errors.New("song: spotify client id or secret not set") } return &Song{ - db: db, - ClientID: clientID, - ClientSecret: clientSecret, - ExpiresTime: 0, - api: config.GetDefaultString("backend.song.spotify_api", "https://api.spotify.com/v1"), - apiAccount: config.GetDefaultString("backend.song.spotify_api_account", "https://accounts.spotify.com/api/token"), - apiLrclib: config.GetDefaultString("backend.song.lrclib_api", "https://lrclib.net/api"), + song: *repo.NewSong(), + clientID: clientID, + clientSecret: clientSecret, + expiresTime: 0, + url: config.GetDefaultString("backend.song.spotify_url", "https://api.spotify.com/v1"), + urlAccount: config.GetDefaultString("backend.song.spotify_url_account", "https://accounts.spotify.com/api/token"), + urlLrclib: config.GetDefaultString("backend.song.lrclib_url", "https://lrclib.net/api"), }, nil } // Track gets information about the current track and stores it in the database -func (s *Song) Track(track *dto.Song) error { +func (s *Song) Track(ctx context.Context, track *dto.Song) error { var errs []error - if s.ClientID == "" || s.ClientSecret == "" { + if s.clientID == "" || s.clientSecret == "" { return errors.New("Song: Spotify client id or secret not set") } // Check if song is already in DB - trackDB, err := s.db.Queries.GetSongBySpotifyID(context.Background(), track.SpotifyID) - if err != nil && err != pgx.ErrNoRows { + trackDB, err := s.song.GetBySpotifyID(ctx, track.SpotifyID) + if err != nil { return err } - if (trackDB != sqlc.Song{}) { + if trackDB != nil { // Already in DB // Add to song history if it's not the latest song - songHistory, err := s.db.Queries.GetLastSongHistory(context.Background()) - if err != nil && err != pgx.ErrNoRows { + songHistory, err := s.song.GetLast(ctx) + if err != nil { return err } - if (songHistory != sqlc.SongHistory{}) && songHistory.SongID == trackDB.ID { + if songHistory != nil && songHistory.ID == trackDB.ID { // Song is already the latest, don't add it again return nil } - _, err = s.db.Queries.CreateSongHistory(context.Background(), trackDB.ID) - return err + if err = s.song.CreateHistory(ctx, trackDB.ID); err != nil { + return err + } + + return nil } // Not in database yet, add it // Refresh token if needed - if s.ExpiresTime <= time.Now().Unix() { + if s.expiresTime <= time.Now().Unix() { err := s.refreshToken() if err != nil { return err @@ -88,17 +90,17 @@ func (s *Song) Track(track *dto.Song) error { } // Get track info - if err = s.getTrack(track); err != nil { + if err = s.getTrack(ctx, track); err != nil { return err } // Get lyrics - if err = s.getLyrics(track); err != nil { + if err = s.getLyrics(ctx, track); err != nil { errs = append(errs, err) } // Store track in DB - trackDB, err = s.db.Queries.CreateSong(context.Background(), *track.CreateSongParams()) + err = s.song.Create(ctx, &track) if err != nil { errs = append(errs, err) return errors.Join(errs...) @@ -107,13 +109,13 @@ func (s *Song) Track(track *dto.Song) error { // Handle artists for i, artist := range track.Artists { - a, err := s.db.Queries.GetSongArtistBySpotifyID(context.Background(), artist.SpotifyID) - if err != nil && err != pgx.ErrNoRows { + a, err := s.song.GetArtistBySpotifyID(ctx, artist.SpotifyID) + if err != nil { errs = append(errs, err) continue } - if (a != sqlc.SongArtist{}) { + if a != nil { // Artist already exists // Add it as an artist for this track track.Artists[i].ID = a.ID @@ -146,7 +148,7 @@ func (s *Song) Track(track *dto.Song) error { // Check if the artists genres are in db for j, genre := range track.Artists[i].Genres { g, err := s.db.Queries.GetSongGenreByName(context.Background(), genre.Genre) - if err != nil && err != pgx.ErrNoRows { + if err != nil && !errors.Is(err, pgx.ErrNoRows) { errs = append(errs, err) continue } @@ -177,5 +179,4 @@ func (s *Song) Track(track *dto.Song) error { } return errors.Join(errs...) - } diff --git a/internal/tap/api.go b/internal/tap/api.go new file mode 100644 index 0000000..81c9009 --- /dev/null +++ b/internal/tap/api.go @@ -0,0 +1,70 @@ +package tap + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/pkg/utils" +) + +type orderResponseItem struct { + OrderID int `json:"order_id"` + OrderCreatedAt time.Time `json:"order_created_at"` + ProductName string `json:"product_name"` + ProductCategory string `json:"product_category"` +} + +type orderResponse struct { + Orders []orderResponseItem `json:"orders"` +} + +func (o orderResponse) ToModel() []model.Tap { + var taps []model.Tap + for _, order := range o.Orders { + var category model.TapCategory = "unknown" + switch order.ProductCategory { + case "soft": + category = model.Soft + case "mate": + category = model.Mate + case "beer": + category = model.Beer + case "food": + category = model.Food + } + + taps = append(taps, model.Tap{ + OrderID: order.OrderID, + CreatedAt: order.OrderCreatedAt, + Name: order.ProductName, + Category: category, + }) + } + + return taps +} + +func (t *Tap) getOrders(ctx context.Context) ([]model.Tap, error) { + resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ + Method: "GET", + URL: fmt.Sprintf("%s/recent", t.url), + }) + if err != nil { + return nil, fmt.Errorf("get all tap orders %w", err) + } + + defer func() { + _ = resp.Body.Close() + }() + + var orders orderResponse + + if err := json.NewDecoder(resp.Body).Decode(&orders); err != nil { + return nil, fmt.Errorf("decode tap order response %w", err) + } + + return orders.ToModel(), nil +} diff --git a/internal/tap/tap.go b/internal/tap/tap.go new file mode 100644 index 0000000..a2bf758 --- /dev/null +++ b/internal/tap/tap.go @@ -0,0 +1,70 @@ +// Package tap provides all tap related logic +package tap + +import ( + "context" + "slices" + + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/internal/database/repository" + "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/pkg/utils" +) + +type Tap struct { + tap repository.Tap + url string + beers []string +} + +var defaultBeers = []string{ + "Schelfaut", + "Duvel", + "Fourchette", + "Jupiler", + "Karmeliet", + "Kriek", + "Chouffe", + "Maes", + "Somersby", + "Sportzot", + "Stella", +} + +func New(repo repository.Repository) *Tap { + return &Tap{ + tap: *repo.NewTap(), + url: config.GetDefaultString("backend.tap.url", "https://tap.zeus.gent"), + beers: config.GetDefaultStringSlice("backend.tap.beers", defaultBeers), + } +} + +// Update gets all new orders from tap +func (t *Tap) Update(ctx context.Context) error { + // Get latest order + lastOrder, err := t.tap.GetLast(ctx) + if err != nil { + return err + } + if lastOrder == nil { + lastOrder = &model.Tap{OrderID: -1} + } + + // Get all orders + allOrders, err := t.getOrders(ctx) + if err != nil { + return err + } + + // Only keep the new orders + orders := utils.SliceFilter(allOrders, func(o model.Tap) bool { return o.OrderID > lastOrder.OrderID }) + slices.SortFunc(orders, func(a, b model.Tap) int { return a.OrderID - b.OrderID }) + + for _, order := range orders { + if err := t.tap.Create(ctx, &order); err != nil { + return err + } + } + + return nil +} diff --git a/internal/zess/api.go b/internal/zess/api.go new file mode 100644 index 0000000..622a5ae --- /dev/null +++ b/internal/zess/api.go @@ -0,0 +1,82 @@ +package zess + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/pkg/date" + "github.com/zeusWPI/scc/pkg/utils" +) + +type seasonAPI struct { + Name string `json:"name"` + Start date.Date `json:"start"` + End date.Date `json:"end"` + Current bool `json:"is_current"` +} + +func (s seasonAPI) toModel() model.Season { + return model.Season{ + Name: s.Name, + Start: s.Start, + End: s.End, + Current: s.Current, + } +} + +func (z *Zess) getSeasons(ctx context.Context) ([]model.Season, error) { + resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ + Method: "GET", + URL: fmt.Sprintf("%s/seasons", z.url), + }) + if err != nil { + return nil, fmt.Errorf("http get all zess seasons %w", err) + } + + defer func() { + _ = resp.Body.Close() + }() + + var seasons []seasonAPI + if err := json.NewDecoder(resp.Body).Decode(&seasons); err != nil { + return nil, fmt.Errorf("decode http zess seasons %w", err) + } + + return utils.SliceMap(seasons, func(s seasonAPI) model.Season { return s.toModel() }), nil +} + +type scanAPI struct { + ScanID int `json:"scan_id"` + ScanTime time.Time `json:"scan_time"` +} + +func (s scanAPI) toModel() model.Scan { + return model.Scan{ + ScanID: s.ScanID, + ScanTime: s.ScanTime, + } +} + +func (z *Zess) getScans(ctx context.Context) ([]model.Scan, error) { + resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ + Method: "GET", + URL: fmt.Sprintf("%s/recent_scans", z.url), + }) + if err != nil { + return nil, fmt.Errorf("http get recent zess scans %w", err) + } + + defer func() { + _ = resp.Body.Close() + }() + + var scans []scanAPI + if err := json.NewDecoder(resp.Body).Decode(&scans); err != nil { + return nil, fmt.Errorf("decode http zess scans %w", err) + } + + return utils.SliceMap(scans, func(s scanAPI) model.Scan { return s.toModel() }), nil +} diff --git a/internal/zess/zess.go b/internal/zess/zess.go new file mode 100644 index 0000000..d22cedc --- /dev/null +++ b/internal/zess/zess.go @@ -0,0 +1,76 @@ +// Package zess provides all zess related logic +package zess + +import ( + "context" + "slices" + + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/internal/database/repository" + "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/pkg/utils" +) + +type Zess struct { + repo repository.Repository + scan repository.Scan + seasons repository.Season + url string +} + +func New(repo repository.Repository) *Zess { + return &Zess{ + repo: repo, + scan: *repo.NewScan(), + seasons: *repo.NewSeason(), + url: config.GetDefaultString("backend.zess.url", "https://zess.zeus.gent/api"), + } +} + +func (z *Zess) UpdateSeasons(ctx context.Context) error { + zessSeasons, err := z.getSeasons(ctx) + if err != nil { + return err + } + + return z.repo.WithRollback(ctx, func(ctx context.Context) error { + if err := z.seasons.DeleteAll(ctx); err != nil { + return err + } + + for _, season := range zessSeasons { + if err := z.seasons.Create(ctx, &season); err != nil { + return err + } + } + + return nil + }) +} + +func (z *Zess) UpdateScans(ctx context.Context) error { + lastScan, err := z.scan.GetLast(ctx) + if err != nil { + return err + } + if lastScan == nil { + lastScan = &model.Scan{ScanID: -1} + } + + // Get all scans + zessScans, err := z.getScans(ctx) + if err != nil { + return err + } + + scans := utils.SliceFilter(zessScans, func(s model.Scan) bool { return s.ScanID > lastScan.ScanID }) + slices.SortFunc(scans, func(a, b model.Scan) int { return a.ScanID - b.ScanID }) + + for _, scan := range scans { + if err := z.scan.Create(ctx, &scan); err != nil { + return err + } + } + + return nil +} diff --git a/makefile b/makefile index cda5bb3..8ffaa2e 100644 --- a/makefile +++ b/makefile @@ -8,13 +8,11 @@ DB_USER := postgres DB_PASSWORD := postgres DB_NAME := scc -# Phony targets -.PHONY: all build build-backed build-tui clean run run-backend run-tui db sqlc create-migration goose migrate watch - -# Default target: build everything all: build -# Build targets +setup: + @go get tool + build: clean build-backend build-tui build-backend: clean-backend @@ -25,48 +23,40 @@ build-tui: clean-tui @echo "Building $(TUI_BIN)..." @go build -o $(TUI_BIN) $(TUI_SRC) -# Run targets -run: run-backend run-tui - run-backend: + @docker compose up -d @[ -f $(BACKEND_BIN) ] || $(MAKE) build-backend @./$(BACKEND_BIN) + @docker compose down run-tui: @[ -f $(TUI_BIN) ] || $(MAKE) build-tui @read -p "Enter screen name: " screen; \ ./$(TUI_BIN) $$screen -# Run db -db: - @docker compose up - -# Clean targets -clean: clean-backend clean-tui - -clean-backend: - @if [ -f "$(BACKEND_BIN)" ]; then \ - echo "Cleaning $(BACKEND_BIN)..."; \ - rm -f "$(BACKEND_BIN)"; \ - fi - -clean-tui: - @if [ -f "$(TUI_BIN)" ]; then \ - echo "Cleaning $(TUI_BIN)..."; \ - rm -f "$(TUI_BIN)"; \ - fi +goose: + @docker compose down + @docker compose up db -d + @docker compose exec db bash -c 'until pg_isready -U postgres; do sleep 1; done' + @read -p "Action: " action; \ + go tool goose -dir ./db/migrations postgres "user=postgres password=postgres host=localhost port=5431 dbname=website sslmode=disable" $$action + @docker compose down db -# SQL and migration targets -sqlc: - sqlc generate +migrate: + @docker compose down + @docker compose up db -d + @docker compose exec db bash -c 'until pg_isready -U postgres; do sleep 1; done' + @go tool goose -dir ./db/migrations postgres "user=postgres password=postgres host=localhost port=5431 dbname=website sslmode=disable" up + @docker compose down db create-migration: @read -p "Enter migration name: " name; \ - goose -dir $(DB_DIR) create $$name sql + go tool goose -dir ./db/migrations create $$name sql -migrate: - @goose -dir $(DB_DIR) postgres "user=$(DB_USER) password=$(DB_PASSWORD) dbname=$(DB_NAME) host=localhost sslmode=disable" up +query: + @go tool sqlc generate -goose: - @read -p "Action: " action; \ - goose -dir $(DB_DIR) postgres "user=$(DB_USER) password=$(DB_PASSWORD) dbname=$(DB_NAME) host=localhost sslmode=disable" $$action +dead: + @go tool deadcode ./... + +.PHONY: all setup build build-backed build-tui run-backend run-tui sqlc create-migration goose migrate dead diff --git a/pkg/config/config.go b/pkg/config/config.go index b8ace59..0432dda 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,4 +1,4 @@ -// Package config provides all configuration related functions +// Package config lets you retrieve config variables package config import ( @@ -11,81 +11,70 @@ import ( func bindEnv(key string) { envName := strings.ToUpper(strings.ReplaceAll(key, ".", "_")) - - _ = viper.BindEnv(key, envName) + // nolint:errcheck // we do not care if it binds + viper.BindEnv(key, envName) } -// Init initializes the configuration func Init() error { if err := godotenv.Load(); err != nil { - return err + fmt.Println("Failed to load .env file", err) } viper.AutomaticEnv() env := GetDefaultString("app.env", "development") - viper.SetConfigName(fmt.Sprintf("%s.yaml", strings.ToLower(env))) - viper.SetConfigType("yaml") + viper.SetConfigName(env + ".toml") + viper.SetConfigType("toml") viper.AddConfigPath("./config") return viper.ReadInConfig() } -// GetString returns the value of the key in string func GetString(key string) string { bindEnv(key) return viper.GetString(key) } -// GetDefaultString returns the value of the key in string or a default value func GetDefaultString(key, defaultValue string) string { viper.SetDefault(key, defaultValue) return GetString(key) } -// GetStringSlice returns the value of the key in string slice func GetStringSlice(key string) []string { bindEnv(key) return viper.GetStringSlice(key) } -// GetDefaultStringSlice returns the value of the key in string slice or a default value func GetDefaultStringSlice(key string, defaultValue []string) []string { viper.SetDefault(key, defaultValue) return GetStringSlice(key) } -// GetInt returns the value of the key in int func GetInt(key string) int { bindEnv(key) return viper.GetInt(key) } -// GetDefaultInt returns the value of the key in int or a default value func GetDefaultInt(key string, defaultVal int) int { viper.SetDefault(key, defaultVal) return GetInt(key) } -// GetUint16 returns the value of the key in uint16 func GetUint16(key string) uint16 { bindEnv(key) return viper.GetUint16(key) } -// GetDefaultUint16 returns the value of the key in uint16 or a default value func GetDefaultUint16(key string, defaultVal uint16) uint16 { viper.SetDefault(key, defaultVal) return GetUint16(key) } -// GetBool returns the value of the key in bool func GetBool(key string) bool { bindEnv(key) return viper.GetBool(key) } -// GetDefaultBool returns the value of the key in bool or a default value func GetDefaultBool(key string, defaultVal bool) bool { viper.SetDefault(key, defaultVal) return GetBool(key) diff --git a/pkg/db/db.go b/pkg/db/db.go new file mode 100644 index 0000000..9f12794 --- /dev/null +++ b/pkg/db/db.go @@ -0,0 +1,15 @@ +// Package db connects with the databank +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/zeusWPI/scc/pkg/sqlc" +) + +type DB interface { + WithRollback(ctx context.Context, fn func(q *sqlc.Queries) error) error + Pool() *pgxpool.Pool + Queries() *sqlc.Queries +} diff --git a/internal/pkg/db/db.go b/pkg/db/psql.go similarity index 51% rename from internal/pkg/db/db.go rename to pkg/db/psql.go index 35b2223..3ab39d6 100644 --- a/internal/pkg/db/db.go +++ b/pkg/db/psql.go @@ -1,22 +1,23 @@ -// Package db provides a database connection package db import ( "context" + "fmt" "github.com/jackc/pgx/v5/pgxpool" - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/pkg/sqlc" ) -// DB represents a database connection -type DB struct { - Pool *pgxpool.Pool - Queries *sqlc.Queries +type psql struct { + pool *pgxpool.Pool + queries *sqlc.Queries } -// New creates a new database connection -func New() (*DB, error) { +// Interface compliance +var _ DB = (*psql)(nil) + +func NewPSQL() (DB, error) { pgConfig, err := pgxpool.ParseConfig("") if err != nil { return nil, err @@ -24,7 +25,7 @@ func New() (*DB, error) { pgConfig.ConnConfig.Host = config.GetDefaultString("db.host", "localhost") pgConfig.ConnConfig.Port = config.GetDefaultUint16("db.port", 5432) - pgConfig.ConnConfig.Database = config.GetDefaultString("db.database", "scc") + pgConfig.ConnConfig.Database = config.GetDefaultString("db.database", "website") pgConfig.ConnConfig.User = config.GetDefaultString("db.user", "postgres") pgConfig.ConnConfig.Password = config.GetDefaultString("db.password", "postgres") @@ -39,5 +40,31 @@ func New() (*DB, error) { queries := sqlc.New(pool) - return &DB{Pool: pool, Queries: queries}, nil + return &psql{pool: pool, queries: queries}, nil +} + +func (p *psql) WithRollback(ctx context.Context, fn func(q *sqlc.Queries) error) error { + tx, err := p.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("failed to start transaction: %w", err) + } + defer func() { + _ = tx.Rollback(ctx) + }() + + queries := sqlc.New(tx) + + if err := fn(queries); err != nil { + return err + } + + return tx.Commit(ctx) +} + +func (p *psql) Pool() *pgxpool.Pool { + return p.pool +} + +func (p *psql) Queries() *sqlc.Queries { + return p.queries } diff --git a/pkg/logger/logger.go b/pkg/logger/zap.go similarity index 52% rename from pkg/logger/logger.go rename to pkg/logger/zap.go index 6dde1cf..6f4790a 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/zap.go @@ -1,4 +1,4 @@ -// Package logger provides a logger instance +// Package logger initiates a zap logger package logger import ( @@ -9,21 +9,10 @@ import ( "go.uber.org/zap" ) -// New returns a new logger instance func New(logFile string, console bool) (*zap.Logger, error) { - // Create logs directory err := os.Mkdir("logs", os.ModePerm) if err != nil && !os.IsExist(err) { - return nil, err - } - - // Create logger - var zapConfig zap.Config - env := config.GetDefaultString("app.env", "development") - if env == "development" { - zapConfig = zap.NewDevelopmentConfig() - } else { - zapConfig = zap.NewProductionConfig() + return nil, fmt.Errorf("create logs directory %w", err) } outputPaths := []string{fmt.Sprintf("logs/%s.log", logFile)} @@ -36,10 +25,25 @@ func New(logFile string, console bool) (*zap.Logger, error) { errorOutputPaths = append(errorOutputPaths, "stderr") } - zapConfig.OutputPaths = outputPaths - zapConfig.ErrorOutputPaths = errorOutputPaths + var logger *zap.Logger + env := config.GetDefaultString("app.env", "development") + + if env != "production" { + cfg := zap.NewDevelopmentConfig() + cfg.OutputPaths = outputPaths + cfg.ErrorOutputPaths = errorOutputPaths + + logger = zap.Must(cfg.Build(zap.AddStacktrace(zap.WarnLevel))) + } else { + cfg := zap.NewProductionConfig() + cfg.Level.SetLevel(zap.WarnLevel) + cfg.OutputPaths = outputPaths + cfg.ErrorOutputPaths = errorOutputPaths + + logger = zap.Must(cfg.Build()) + } - logger := zap.Must(zapConfig.Build()) + logger = logger.With(zap.String("env", env)) return logger, nil } diff --git a/internal/pkg/db/sqlc/db.go b/pkg/sqlc/db.go similarity index 96% rename from internal/pkg/db/sqlc/db.go rename to pkg/sqlc/db.go index b931bc5..2725108 100644 --- a/internal/pkg/db/sqlc/db.go +++ b/pkg/sqlc/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.29.0 package sqlc diff --git a/pkg/sqlc/event.sql.go b/pkg/sqlc/event.sql.go new file mode 100644 index 0000000..09e1751 --- /dev/null +++ b/pkg/sqlc/event.sql.go @@ -0,0 +1,118 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: event.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const eventCreate = `-- name: EventCreate :one +INSERT INTO event (name, date, academic_year, location, poster) +VALUES ($1, $2, $3, $4, $5) +RETURNING id +` + +type EventCreateParams struct { + Name string + Date pgtype.Timestamptz + AcademicYear string + Location string + Poster []byte +} + +func (q *Queries) EventCreate(ctx context.Context, arg EventCreateParams) (int32, error) { + row := q.db.QueryRow(ctx, eventCreate, + arg.Name, + arg.Date, + arg.AcademicYear, + arg.Location, + arg.Poster, + ) + var id int32 + err := row.Scan(&id) + return id, err +} + +const eventDeleteAll = `-- name: EventDeleteAll :exec +DELETE FROM event +` + +func (q *Queries) EventDeleteAll(ctx context.Context) error { + _, err := q.db.Exec(ctx, eventDeleteAll) + return err +} + +const eventGetAllByCurrentYear = `-- name: EventGetAllByCurrentYear :many +SELECT id, name, date, academic_year, location, poster +FROM event +WHERE academic_year = ( + SELECT MAX(academic_year) + FROM event +) +ORDER BY date ASC +` + +func (q *Queries) EventGetAllByCurrentYear(ctx context.Context) ([]Event, error) { + rows, err := q.db.Query(ctx, eventGetAllByCurrentYear) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Event + for rows.Next() { + var i Event + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Date, + &i.AcademicYear, + &i.Location, + &i.Poster, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const eventGetAllByYear = `-- name: EventGetAllByYear :many +SELECT id, name, date, academic_year, location, poster +FROM event +WHERE academic_year = $1 +` + +func (q *Queries) EventGetAllByYear(ctx context.Context, academicYear string) ([]Event, error) { + rows, err := q.db.Query(ctx, eventGetAllByYear, academicYear) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Event + for rows.Next() { + var i Event + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Date, + &i.AcademicYear, + &i.Location, + &i.Poster, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/pkg/sqlc/gamification.sql.go b/pkg/sqlc/gamification.sql.go new file mode 100644 index 0000000..d68b24e --- /dev/null +++ b/pkg/sqlc/gamification.sql.go @@ -0,0 +1,69 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: gamification.sql + +package sqlc + +import ( + "context" +) + +const gamificationCreate = `-- name: GamificationCreate :one +INSERT INTO gamification (name, score, avatar) +VALUES ($1, $2, $3) +RETURNING id +` + +type GamificationCreateParams struct { + Name string + Score int32 + Avatar []byte +} + +func (q *Queries) GamificationCreate(ctx context.Context, arg GamificationCreateParams) (int32, error) { + row := q.db.QueryRow(ctx, gamificationCreate, arg.Name, arg.Score, arg.Avatar) + var id int32 + err := row.Scan(&id) + return id, err +} + +const gamificationDeleteAll = `-- name: GamificationDeleteAll :exec +DELETE FROM gamification +` + +func (q *Queries) GamificationDeleteAll(ctx context.Context) error { + _, err := q.db.Exec(ctx, gamificationDeleteAll) + return err +} + +const gamificationGetAll = `-- name: GamificationGetAll :many +SELECT id, name, score, avatar +FROM gamification +ORDER BY score DESC +` + +func (q *Queries) GamificationGetAll(ctx context.Context) ([]Gamification, error) { + rows, err := q.db.Query(ctx, gamificationGetAll) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Gamification + for rows.Next() { + var i Gamification + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Score, + &i.Avatar, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/pkg/sqlc/message.sql.go b/pkg/sqlc/message.sql.go new file mode 100644 index 0000000..a602e1f --- /dev/null +++ b/pkg/sqlc/message.sql.go @@ -0,0 +1,62 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: message.sql + +package sqlc + +import ( + "context" +) + +const messageCreate = `-- name: MessageCreate :one +INSERT INTO message (name, ip, message) +VALUES ($1, $2, $3) +RETURNING id +` + +type MessageCreateParams struct { + Name string + Ip string + Message string +} + +func (q *Queries) MessageCreate(ctx context.Context, arg MessageCreateParams) (int32, error) { + row := q.db.QueryRow(ctx, messageCreate, arg.Name, arg.Ip, arg.Message) + var id int32 + err := row.Scan(&id) + return id, err +} + +const messageGetSinceID = `-- name: MessageGetSinceID :many +SELECT id, name, ip, message, created_at +FROM message +WHERE id > $1 +ORDER BY created_at ASC +` + +func (q *Queries) MessageGetSinceID(ctx context.Context, id int32) ([]Message, error) { + rows, err := q.db.Query(ctx, messageGetSinceID, id) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Message + for rows.Next() { + var i Message + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Ip, + &i.Message, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/pkg/db/sqlc/models.go b/pkg/sqlc/models.go similarity index 56% rename from internal/pkg/db/sqlc/models.go rename to pkg/sqlc/models.go index f50e025..9f66a3f 100644 --- a/internal/pkg/db/sqlc/models.go +++ b/pkg/sqlc/models.go @@ -1,13 +1,61 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.29.0 package sqlc import ( + "database/sql/driver" + "fmt" + "github.com/jackc/pgx/v5/pgtype" ) +type TapCategory string + +const ( + TapCategorySoft TapCategory = "soft" + TapCategoryMate TapCategory = "mate" + TapCategoryBeer TapCategory = "beer" + TapCategoryFood TapCategory = "food" + TapCategoryUnknown TapCategory = "unknown" +) + +func (e *TapCategory) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = TapCategory(s) + case string: + *e = TapCategory(s) + default: + return fmt.Errorf("unsupported scan type for TapCategory: %T", src) + } + return nil +} + +type NullTapCategory struct { + TapCategory TapCategory + Valid bool // Valid is true if TapCategory is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullTapCategory) Scan(value interface{}) error { + if value == nil { + ns.TapCategory, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.TapCategory.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullTapCategory) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.TapCategory), nil +} + type Event struct { ID int32 Name string @@ -92,6 +140,6 @@ type Tap struct { OrderID int32 OrderCreatedAt pgtype.Timestamptz Name string - Category string CreatedAt pgtype.Timestamptz + Category TapCategory } diff --git a/pkg/sqlc/scan.sql.go b/pkg/sqlc/scan.sql.go new file mode 100644 index 0000000..477baa4 --- /dev/null +++ b/pkg/sqlc/scan.sql.go @@ -0,0 +1,71 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: scan.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const scanCreate = `-- name: ScanCreate :one +INSERT INTO scan (scan_id, scan_time) +VALUES ($1, $2) +RETURNING id +` + +type ScanCreateParams struct { + ScanID int32 + ScanTime pgtype.Timestamptz +} + +func (q *Queries) ScanCreate(ctx context.Context, arg ScanCreateParams) (int32, error) { + row := q.db.QueryRow(ctx, scanCreate, arg.ScanID, arg.ScanTime) + var id int32 + err := row.Scan(&id) + return id, err +} + +const scanGetAllSinceID = `-- name: ScanGetAllSinceID :many +SELECT id, scan_time, scan_id +FROM scan +WHERE id > $1 +ORDER BY scan_id, scan_time ASC +` + +func (q *Queries) ScanGetAllSinceID(ctx context.Context, id int32) ([]Scan, error) { + rows, err := q.db.Query(ctx, scanGetAllSinceID, id) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Scan + for rows.Next() { + var i Scan + if err := rows.Scan(&i.ID, &i.ScanTime, &i.ScanID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const scanGetLast = `-- name: ScanGetLast :one +SELECT id, scan_time, scan_id +FROM scan +ORDER BY id DESC +LIMIT 1 +` + +func (q *Queries) ScanGetLast(ctx context.Context) (Scan, error) { + row := q.db.QueryRow(ctx, scanGetLast) + var i Scan + err := row.Scan(&i.ID, &i.ScanTime, &i.ScanID) + return i, err +} diff --git a/pkg/sqlc/season.sql.go b/pkg/sqlc/season.sql.go new file mode 100644 index 0000000..721e20e --- /dev/null +++ b/pkg/sqlc/season.sql.go @@ -0,0 +1,122 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: season.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const seasonCreate = `-- name: SeasonCreate :one +INSERT INTO season (name, start, "end", current) +VALUES ($1, $2, $3, $4) +RETURNING id +` + +type SeasonCreateParams struct { + Name string + Start pgtype.Timestamp + End pgtype.Timestamp + Current bool +} + +func (q *Queries) SeasonCreate(ctx context.Context, arg SeasonCreateParams) (int32, error) { + row := q.db.QueryRow(ctx, seasonCreate, + arg.Name, + arg.Start, + arg.End, + arg.Current, + ) + var id int32 + err := row.Scan(&id) + return id, err +} + +const seasonDeleteAll = `-- name: SeasonDeleteAll :exec +DELETE FROM season +` + +func (q *Queries) SeasonDeleteAll(ctx context.Context) error { + _, err := q.db.Exec(ctx, seasonDeleteAll) + return err +} + +const seasonGetAll = `-- name: SeasonGetAll :many +SELECT id, name, start, "end", current +FROM season +` + +func (q *Queries) SeasonGetAll(ctx context.Context) ([]Season, error) { + rows, err := q.db.Query(ctx, seasonGetAll) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Season + for rows.Next() { + var i Season + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Start, + &i.End, + &i.Current, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const seasonGetCurrent = `-- name: SeasonGetCurrent :one +SELECT id, name, start, "end", current +FROM season +WHERE current = true +` + +func (q *Queries) SeasonGetCurrent(ctx context.Context) (Season, error) { + row := q.db.QueryRow(ctx, seasonGetCurrent) + var i Season + err := row.Scan( + &i.ID, + &i.Name, + &i.Start, + &i.End, + &i.Current, + ) + return i, err +} + +const seasonUpdate = `-- name: SeasonUpdate :exec +UPDATE season +SET name = $1, start = $2, "end" = $3, current = $4 +WHERE id = $5 +RETURNING id, name, start, "end", current +` + +type SeasonUpdateParams struct { + Name string + Start pgtype.Timestamp + End pgtype.Timestamp + Current bool + ID int32 +} + +func (q *Queries) SeasonUpdate(ctx context.Context, arg SeasonUpdateParams) error { + _, err := q.db.Exec(ctx, seasonUpdate, + arg.Name, + arg.Start, + arg.End, + arg.Current, + arg.ID, + ) + return err +} diff --git a/internal/pkg/db/sqlc/song.sql.go b/pkg/sqlc/song.sql.go similarity index 95% rename from internal/pkg/db/sqlc/song.sql.go rename to pkg/sqlc/song.sql.go index 16ff8de..d248b12 100644 --- a/internal/pkg/db/sqlc/song.sql.go +++ b/pkg/sqlc/song.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.29.0 // source: song.sql package sqlc @@ -15,7 +15,7 @@ const createSong = `-- name: CreateSong :one INSERT INTO song (title, album, spotify_id, duration_ms, lyrics_type, lyrics) VALUES ($1, $2, $3, $4, $5, $6) -RETURNING id, title, spotify_id, duration_ms, album, lyrics_type, lyrics +RETURNING id ` type CreateSongParams struct { @@ -28,7 +28,7 @@ type CreateSongParams struct { } // CRUD -func (q *Queries) CreateSong(ctx context.Context, arg CreateSongParams) (Song, error) { +func (q *Queries) CreateSong(ctx context.Context, arg CreateSongParams) (int32, error) { row := q.db.QueryRow(ctx, createSong, arg.Title, arg.Album, @@ -37,17 +37,9 @@ func (q *Queries) CreateSong(ctx context.Context, arg CreateSongParams) (Song, e arg.LyricsType, arg.Lyrics, ) - var i Song - err := row.Scan( - &i.ID, - &i.Title, - &i.SpotifyID, - &i.DurationMs, - &i.Album, - &i.LyricsType, - &i.Lyrics, - ) - return i, err + var id int32 + err := row.Scan(&id) + return id, err } const createSongArtist = `-- name: CreateSongArtist :one @@ -225,25 +217,6 @@ func (q *Queries) GetLastSongHistory(ctx context.Context) (SongHistory, error) { return i, err } -const getSongArtistByName = `-- name: GetSongArtistByName :one -SELECT id, name, spotify_id, followers, popularity -FROM song_artist -WHERE name = $1 -` - -func (q *Queries) GetSongArtistByName(ctx context.Context, name string) (SongArtist, error) { - row := q.db.QueryRow(ctx, getSongArtistByName, name) - var i SongArtist - err := row.Scan( - &i.ID, - &i.Name, - &i.SpotifyID, - &i.Followers, - &i.Popularity, - ) - return i, err -} - const getSongArtistBySpotifyID = `-- name: GetSongArtistBySpotifyID :one SELECT id, name, spotify_id, followers, popularity FROM song_artist diff --git a/pkg/sqlc/tap.sql.go b/pkg/sqlc/tap.sql.go new file mode 100644 index 0000000..7a7e8e9 --- /dev/null +++ b/pkg/sqlc/tap.sql.go @@ -0,0 +1,89 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: tap.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const tapCreate = `-- name: TapCreate :one +INSERT INTO tap (order_id, order_created_at, name, category) +VALUES ($1, $2, $3, $4) +RETURNING id +` + +type TapCreateParams struct { + OrderID int32 + OrderCreatedAt pgtype.Timestamptz + Name string + Category TapCategory +} + +func (q *Queries) TapCreate(ctx context.Context, arg TapCreateParams) (int32, error) { + row := q.db.QueryRow(ctx, tapCreate, + arg.OrderID, + arg.OrderCreatedAt, + arg.Name, + arg.Category, + ) + var id int32 + err := row.Scan(&id) + return id, err +} + +const tapGetCountByCategory = `-- name: TapGetCountByCategory :many +SELECT category, COUNT(*) +FROM tap +GROUP BY category +` + +type TapGetCountByCategoryRow struct { + Category TapCategory + Count int64 +} + +func (q *Queries) TapGetCountByCategory(ctx context.Context) ([]TapGetCountByCategoryRow, error) { + rows, err := q.db.Query(ctx, tapGetCountByCategory) + if err != nil { + return nil, err + } + defer rows.Close() + var items []TapGetCountByCategoryRow + for rows.Next() { + var i TapGetCountByCategoryRow + if err := rows.Scan(&i.Category, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const tapGetLast = `-- name: TapGetLast :one +SELECT id, order_id, order_created_at, name, created_at, category +FROM tap +ORDER BY order_id DESC +LIMIT 1 +` + +func (q *Queries) TapGetLast(ctx context.Context) (Tap, error) { + row := q.db.QueryRow(ctx, tapGetLast) + var i Tap + err := row.Scan( + &i.ID, + &i.OrderID, + &i.OrderCreatedAt, + &i.Name, + &i.CreatedAt, + &i.Category, + ) + return i, err +} diff --git a/pkg/util/map.go b/pkg/util/map.go deleted file mode 100644 index 5e45f39..0000000 --- a/pkg/util/map.go +++ /dev/null @@ -1,10 +0,0 @@ -package util - -// Keys returns the keys of a map -func Keys[T comparable, U any](m map[T]U) []T { - keys := make([]T, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - return keys -} diff --git a/pkg/util/slice.go b/pkg/util/slice.go deleted file mode 100644 index 1840f51..0000000 --- a/pkg/util/slice.go +++ /dev/null @@ -1,31 +0,0 @@ -// Package util provides utility functions -package util - -// SliceMap maps a slice of type T to a slice of type U -func SliceMap[T any, U any](input []T, mapFunc func(T) U) []U { - v := make([]U, len(input)) - for i, item := range input { - v[i] = mapFunc(item) - } - return v -} - -// SliceStringJoin joins a slice of type T to a string with a separator -func SliceStringJoin[T any](input []T, sep string, mapFunc func(T) string) string { - str := "" - for _, item := range input { - str += mapFunc(item) + sep - } - return str[:len(str)-len(sep)] -} - -// SliceFilter filters a slice of type T based on a filter function -func SliceFilter[T any](input []T, filterFunc func(T) bool) []T { - v := make([]T, 0) - for _, item := range input { - if filterFunc(item) { - v = append(v, item) - } - } - return v -} diff --git a/pkg/utils/http.go b/pkg/utils/http.go new file mode 100644 index 0000000..6c579e2 --- /dev/null +++ b/pkg/utils/http.go @@ -0,0 +1,37 @@ +package utils + +import ( + "context" + "fmt" + "io" + "net/http" +) + +type DoRequestValues struct { + Method string + URL string + Body io.Reader + Headers map[string]string +} + +func DoRequest(ctx context.Context, values DoRequestValues) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, values.Method, values.URL, values.Body) + if err != nil { + return nil, fmt.Errorf("new http request %+v | %w", values, err) + } + + for k, v := range values.Headers { + req.Header.Set(k, v) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("do http request %+v | %w", values, err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("invalid http request response %s | %+v | %w", resp.Status, values, err) + } + + return resp, nil +} diff --git a/pkg/utils/map.go b/pkg/utils/map.go new file mode 100644 index 0000000..12e7fa6 --- /dev/null +++ b/pkg/utils/map.go @@ -0,0 +1,29 @@ +// Package utils contains various util functions +package utils + +import ( + "fmt" +) + +// MapGetKeyAsType retrieves a key from a map and converts it to a given type +func MapGetKeyAsType[T any](key string, m map[string]interface{}) (T, error) { + if valueRaw, found := m[key]; found { + if value, ok := valueRaw.(T); ok { + return value, nil + } + } + + var zero T + return zero, fmt.Errorf("unable to find %s key in %v", key, m) +} + +// MapValues returns a slice of all values +func MapValues[T comparable, U any](input map[T]U) []U { + result := make([]U, 0, len(input)) + + for _, v := range input { + result = append(result, v) + } + + return result +} diff --git a/pkg/utils/slice.go b/pkg/utils/slice.go new file mode 100644 index 0000000..82d52ed --- /dev/null +++ b/pkg/utils/slice.go @@ -0,0 +1,131 @@ +package utils + +// SliceMap maps a slice of type T to a slice of type U +func SliceMap[T any, U any](input []T, mapFunc func(T) U) []U { + result := make([]U, len(input)) + + for i, item := range input { + result[i] = mapFunc(item) + } + + return result +} + +// SliceFilter returns a new slice consisting of elements that passed the filter function +func SliceFilter[T any](input []T, filter func(T) bool) []T { + var result []T + + for _, item := range input { + if filter(item) { + result = append(result, item) + } + } + + return result +} + +// SliceFind returns a pointer to the first item determined by the equal function, nil if not found +// The second argument returns true if found, false otherwise +func SliceFind[T any](input []T, equal func(T) bool) (*T, bool) { + for _, item := range input { + if equal(item) { + return &item, true + } + } + + return nil, false +} + +// SliceUnique filters out all duplicate elements +func SliceUnique[T comparable](input []T) []T { + result := make([]T, 0, len(input)) + seen := make(map[T]struct{}, len(input)) + + for _, item := range input { + if _, ok := seen[item]; !ok { + seen[item] = struct{}{} + result = append(result, item) + } + } + + return result +} + +// SliceReference converts a []T slice to []*T +func SliceReference[T any](input []T) []*T { + result := make([]*T, len(input)) + + for i, item := range input { + result[i] = &item + } + + return result +} + +// SliceDereference converts a []*T slice to []T +func SliceDereference[T any](input []*T) []T { + result := make([]T, len(input)) + + for i, ptr := range input { + result[i] = *ptr + } + + return result +} + +// SliceToMap maps a slice to a map with +// key -> result of toKey function +// value -> slice entry +func SliceToMap[T any, U comparable](input []T, toKey func(T) U) map[U]T { + result := make(map[U]T, len(input)) + + for _, item := range input { + result[toKey(item)] = item + } + + return result +} + +// SliceRepeat constructs a slice of length `count` of element `value` +func SliceRepeat[T any](value T, count int) []T { + result := make([]T, count) + for i := range count { + result[i] = value + } + + return result +} + +// SliceFlatten flattens 2D slices to 1D +func SliceFlatten[T any](slice [][]T) []T { + var result []T + for _, s := range slice { + result = append(result, s...) + } + + return result +} + +// SliceMerge merges multiple slices together +func SliceMerge[T any](slices ...[]T) []T { + var result []T + for _, slice := range slices { + result = append(result, slice...) + } + + return result +} + +// SliceSanitize removes all null values according to T{} +func SliceSanitize[T comparable](slice []T) []T { + sanitized := []T{} + var zero T + + for _, item := range slice { + if item != zero { + sanitized = append(sanitized, item) + } + } + + return sanitized +} diff --git a/sqlc.yml b/sqlc.yml index a4821a6..6810455 100644 --- a/sqlc.yml +++ b/sqlc.yml @@ -7,5 +7,5 @@ sql: gen: go: package: "sqlc" - out: "internal/pkg/db/sqlc" + out: "pkg/sqlc" sql_package: "pgx/v5" diff --git a/tui/view/gamification/gamification.go b/tui/view/gamification/gamification.go index 27bdc42..a287edc 100644 --- a/tui/view/gamification/gamification.go +++ b/tui/view/gamification/gamification.go @@ -4,6 +4,7 @@ package gamification import ( "bytes" "context" + "errors" "fmt" "image" "strconv" @@ -112,7 +113,7 @@ func updateLeaderboard(view view.View) (tea.Msg, error) { gams, err := m.db.Queries.GetAllGamificationByScore(context.Background()) if err != nil { - if err == pgx.ErrNoRows { + if errors.Is(err, pgx.ErrNoRows) { err = nil } return nil, err diff --git a/tui/view/message/message.go b/tui/view/message/message.go index c87ed12..15e60f2 100644 --- a/tui/view/message/message.go +++ b/tui/view/message/message.go @@ -99,7 +99,7 @@ func updateMessages(view view.View) (tea.Msg, error) { messagesDB, err := m.db.Queries.GetMessageSinceID(context.Background(), lastMessageID) if err != nil { - if err == pgx.ErrNoRows { + if errors.Is(err, pgx.ErrNoRows) { err = nil } return nil, err diff --git a/tui/view/song/song.go b/tui/view/song/song.go index 84eb01d..750ab68 100644 --- a/tui/view/song/song.go +++ b/tui/view/song/song.go @@ -264,7 +264,7 @@ func updateCurrentSong(view view.View) (tea.Msg, error) { songs, err := m.db.Queries.GetLastSongFull(context.Background()) if err != nil { - if err == pgx.ErrNoRows { + if errors.Is(err, pgx.ErrNoRows) { err = nil } return nil, err @@ -295,7 +295,7 @@ func updateHistory(view view.View) (tea.Msg, error) { m := view.(*Model) history, err := m.db.Queries.GetSongHistory(context.Background()) - if err != nil && err != pgx.ErrNoRows { + if err != nil && !errors.Is(err, pgx.ErrNoRows) { return nil, err } @@ -312,17 +312,17 @@ func updateMonthlyStats(view view.View) (tea.Msg, error) { m := view.(*Model) songs, err := m.db.Queries.GetTopMonthlySongs(context.Background()) - if err != nil && err != pgx.ErrNoRows { + if err != nil && !errors.Is(err, pgx.ErrNoRows) { return nil, err } genres, err := m.db.Queries.GetTopMonthlyGenres(context.Background()) - if err != nil && err != pgx.ErrNoRows { + if err != nil && !errors.Is(err, pgx.ErrNoRows) { return nil, err } artists, err := m.db.Queries.GetTopMonthlyArtists(context.Background()) - if err != nil && err != pgx.ErrNoRows { + if err != nil && !errors.Is(err, pgx.ErrNoRows) { return nil, err } @@ -357,17 +357,17 @@ func updateStats(view view.View) (tea.Msg, error) { m := view.(*Model) songs, err := m.db.Queries.GetTopSongs(context.Background()) - if err != nil && err != pgx.ErrNoRows { + if err != nil && !errors.Is(err, pgx.ErrNoRows) { return nil, err } genres, err := m.db.Queries.GetTopGenres(context.Background()) - if err != nil && err != pgx.ErrNoRows { + if err != nil && !errors.Is(err, pgx.ErrNoRows) { return nil, err } artists, err := m.db.Queries.GetTopArtists(context.Background()) - if err != nil && err != pgx.ErrNoRows { + if err != nil && !errors.Is(err, pgx.ErrNoRows) { return nil, err } diff --git a/tui/view/song/view.go b/tui/view/song/view.go index 058ddf7..bc0d26f 100644 --- a/tui/view/song/view.go +++ b/tui/view/song/view.go @@ -122,7 +122,7 @@ func (m *Model) viewNotPlaying() string { for i, entry := range m.history.entries { enum := sStatEnum.Render(fmt.Sprintf("%d.", i+1)) body := sStatEntry.Render(entry.name) - amount := sStatAmount.Render(fmt.Sprintf("%d", entry.amount)) + amount := sStatAmount.Render(strconv.Itoa(entry.amount)) items = append(items, lipgloss.JoinHorizontal(lipgloss.Top, enum, body, amount)) } items = append(items, "") // HACK: Avoid the last item shifting to the right @@ -149,7 +149,7 @@ func (m *Model) viewStatPlaying(stat stat, titleOpt ...string) string { enum := sStatEnum.Render(fmt.Sprintf("%d.", i+1)) body := sStatEntry.Render(stat.entries[i].name) - amount := sStatAmount.Render(fmt.Sprintf("%d", stat.entries[i].amount)) + amount := sStatAmount.Render(strconv.Itoa(stat.entries[i].amount)) items = append(items, lipgloss.JoinHorizontal(lipgloss.Top, enum, body, amount)) } diff --git a/tui/view/tap/tap.go b/tui/view/tap/tap.go index 814a33c..19ec405 100644 --- a/tui/view/tap/tap.go +++ b/tui/view/tap/tap.go @@ -142,7 +142,7 @@ func updateOrders(view view.View) (tea.Msg, error) { order, err := m.db.Queries.GetLastOrderByOrderID(context.Background()) if err != nil { - if err == pgx.ErrNoRows { + if errors.Is(err, pgx.ErrNoRows) { err = nil } return nil, err diff --git a/tui/view/zess/style.go b/tui/view/zess/style.go index 790326c..a4cc3f0 100644 --- a/tui/view/zess/style.go +++ b/tui/view/zess/style.go @@ -35,7 +35,7 @@ var ( wStatAmount = 4 // Supports up to 9999 wStatGapMin = 3 // Minimum gap size between the date and amount - sStat = base.BorderStyle(lipgloss.ThickBorder()).BorderForeground(cBorder).BorderLeft(true).Margin(0, 1, 1, 1).PaddingLeft(1) //.Align(lipgloss.Center) + sStat = base.BorderStyle(lipgloss.ThickBorder()).BorderForeground(cBorder).BorderLeft(true).Margin(0, 1, 1, 1).PaddingLeft(1) // .Align(lipgloss.Center) sStatTitle = base.Foreground(cStatsTitle).Bold(true).BorderStyle(lipgloss.NormalBorder()).BorderForeground(cBorder).BorderBottom(true).Align(lipgloss.Center).MarginBottom(1) sStatDate = base.Width(wStatDate) sStatAmount = base.Width(wStatAmount).Align(lipgloss.Right) diff --git a/tui/view/zess/zess.go b/tui/view/zess/zess.go index 1eda17f..d180fa0 100644 --- a/tui/view/zess/zess.go +++ b/tui/view/zess/zess.go @@ -214,7 +214,7 @@ func updateScans(view view.View) (tea.Msg, error) { // Get new scans scans, err := m.db.Queries.GetAllScansSinceID(context.Background(), lastScanID) if err != nil { - if err == pgx.ErrNoRows { + if errors.Is(err, pgx.ErrNoRows) { // No rows shouldn't be considered an error err = nil } @@ -267,7 +267,7 @@ func updateSeason(view view.View) (tea.Msg, error) { season, err := m.db.Queries.GetSeasonCurrent(context.Background()) if err != nil { - if err == pgx.ErrNoRows { + if errors.Is(err, pgx.ErrNoRows) { // No rows shouldn't be considered an error err = nil } From e616dea1d2e12ec384e4a4fd63c036f5f6eb2e4a Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sat, 23 Aug 2025 20:51:57 +0200 Subject: [PATCH 02/15] chore: update readme --- README.md | 97 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 54 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 8efdf8f..d6ed9f0 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,81 @@ # Screen Cammie Chat -Displays the cammie chat along with some other statistics. +A terminal based dashboard for viewing the Cammie chat messages and other Zeus related data. -## Development Setup +## Overview -### Prerequisites +The project has 2 main parts: -1. Go: Check the [.tool-versions](.tool-versions) file for the required Go version. -2. Pre-commit hooks: `git config --local core.hooksPath .githooks/`. -3. Goose (DB migrations): `go install github.com/pressly/goose/v3/cmd/goose@latest`. -4. SQLC (Statically typed queries): `go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest` -5. Make (Optional) +### 1. TUI -### Configuration +A terminal interface built with [bubbletea](https://github.com/charmbracelet/bubbletea). +It displays the Cammie chat messages and other Zeus data in a screen-based layout. -1. Create a `.env` file specifying - - `APP_ENV`. Available options are: - - `development` - - `production` - - `BACKEND_SONG_SPOTIFY_CLIENT_ID` - - `BACKEND_SONG_SPOTIFY_CLIENT_SECRET` -2. Configure the appropriate settings in the corresponding configuration file located in the [config directory](./config). you can either set them as environment variables or inside the configuration file. +- **Views**: Reusable components responsible for rendering a single type of data (e.g. the [TAP](github.com/zeusWPI/tap) statistics). +- **Screens**: A combination of multiple views forming a single terminal screen. -## DB +Each view implements a shared interface, exposing methods for initialization, updating, and rendering. +When a screen is loaded: -This project uses a postgres database. -SQLC is used to generate statically typed queries and goose is responsible for the database migrations. +- Each view’s initial data is fetched automatically. +- Each view’s update loop runs in a goroutine, periodically checking for new data. +- The update loop communicates state changes back to the TUI. -### Usefull commands +This design allows each view to be self-contained and independently refresh its data. -- `make db`: Start the database -- `make migrate`: Run database migrations to update your database schema (watch out, migrations might result in minor data loss). -- `make create-migration`: Create a new migration in the [db/migrations](./db/migrations/) directory. -- `make sqlc`: Generate statically typed queries based on the .sql files in the [db/queries](./db/queries/) directory. Add new queries to this directory as needed. +However, not all data can be retrieved directly from persistent external services and that’s where the backend comes in. -## Backend +### 2. Backend -The backend is responsible for fetching and processing external data, which is then stored in the database. -Data can either received by exposing an API or by actively fetching them. +The backend handles data aggregation and persistence. -### Running the backend +- Exposes an API for incoming data. +- Maintaines websockets. +- Makes use of external API's. -To build and run the backend, use the following commands: +Provides persistence data using Postgres for data that the external services do not retain. -- `make build-backend`: Build the backend binary. -- `make run-backend`: Run the backend. +## Development -### Logs +### Prerequisites -Backend logs are saved to `./logs/backend.log` (created on first start) and written to `stdout`. +1. Download the golang version [.tool-versions](.tool-versions). +2. Install make. +3. Install the go tools: `make setup`. +4. (Optional) Install the pre-commit hooks: `git config --local core.hooksPath .githooks/`. -## TUI +### Configuration -The TUI (Text User Interface) displays data retrieved from the database. This flexibility allows for running multiple instances of the TUI, each displaying different data. +1. Copy `.env.example` to `.env`, set `ENV=development` and populate the remaining keys. +2. (Optional) Edit the [config file](./config/development.yml). The defaults work. -### Running the TUI +### Database -To build and run the TUI, use the following commands: +A Postgres database instance is provided via docker compose and started automatically when needed by the [makefile](./makefile). +To use a custom database, update the config and edit the makefile. -- `make build-tui`: Build the TUI binary. -- `make run-tui`: Run the TUI. -- -The TUI requires one argument: the screen name to display. You can create new screens in the [screens directory](./tui/screen/), and you must add them to the startup command list in [tui.go](./internal/cmd/tui.go). +### Run -A screen is responsible for creating and managing the layout, consisting of various [views](./tui/view/). +1. Migrate the database `make migrate`. +2. Start the backend `make backend`. +3. Start a TUI `make tui` and enter the desired screen name (if you're not using the makefile use the `-screen` flag to specify the screen). ### Logs -TUI logs are written to `./logs/tui.log` and _not_ to `stdout`. +- Backend: written to `./logs/backend.log` and to stdout. +- TUI: only written to `./logs/{screen}.log`. + +### Useful commands + +- `make create-migration`: Create a new migration in the [db/migrations](./db/migrations/) directory. +- `make query`: Generate statically typed queries based on the .sql files in the [db/queries](./db/queries/) directory. Add new queries to this directory as needed. +- `make goose`: Migrate one version up or down. +- `make dead`: Check for unreachable code. + +## Production + +1. Set `ENV=production` in `.env`. +2. Provide a Postgres database. +3. Populate the [production config file](./config/production.yml). +4. Build the binaries `make build`. +5. Run both binaries with the desired flags. From 1654a5dbc190a8360d98450816f6271667157979 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sat, 23 Aug 2025 21:33:23 +0200 Subject: [PATCH 03/15] refactor: remove event fetching from backend --- .../20241127133125_add_events_table.sql | 4 +- .../20241203173952_event_add_poster.sql | 2 +- .../20250823192319_drop_event_table.sql | 16 +++ internal/cmd/event.go | 48 ------- internal/database/model/event.go | 27 ---- internal/database/repository/event.go | 72 ---------- internal/event/api.go | 100 -------------- internal/event/event.go | 47 ------- tui/view/event/event.go | 89 +++--------- tui/view/event/update.go | 127 ++++++++++++++++++ tui/view/event/view.go | 51 ++++--- 11 files changed, 198 insertions(+), 385 deletions(-) create mode 100644 db/migrations/20250823192319_drop_event_table.sql delete mode 100644 internal/cmd/event.go delete mode 100644 internal/database/model/event.go delete mode 100644 internal/database/repository/event.go delete mode 100644 internal/event/api.go delete mode 100644 internal/event/event.go create mode 100644 tui/view/event/update.go diff --git a/db/migrations/20241127133125_add_events_table.sql b/db/migrations/20241127133125_add_events_table.sql index 11369d4..064cde5 100644 --- a/db/migrations/20241127133125_add_events_table.sql +++ b/db/migrations/20241127133125_add_events_table.sql @@ -1,6 +1,6 @@ -- +goose Up -- +goose StatementBegin -CREATE TABLE IF NOT EXISTS event ( +CREATE TABLE IF event ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, date TIMESTAMP WITH TIME ZONE NOT NULL, @@ -11,5 +11,5 @@ CREATE TABLE IF NOT EXISTS event ( -- +goose Down -- +goose StatementBegin -DROP TABLE IF EXISTS event; +DROP TABLE event; -- +goose StatementEnd diff --git a/db/migrations/20241203173952_event_add_poster.sql b/db/migrations/20241203173952_event_add_poster.sql index 3e195e5..77a0532 100644 --- a/db/migrations/20241203173952_event_add_poster.sql +++ b/db/migrations/20241203173952_event_add_poster.sql @@ -1,7 +1,7 @@ -- +goose Up -- +goose StatementBegin ALTER TABLE event -ADD COLUMN poster BYTEA; +ADD COLUMN poster BYTES; -- +goose StatementEnd -- +goose Down diff --git a/db/migrations/20250823192319_drop_event_table.sql b/db/migrations/20250823192319_drop_event_table.sql new file mode 100644 index 0000000..3674923 --- /dev/null +++ b/db/migrations/20250823192319_drop_event_table.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- +goose StatementBegin +DROP TABLE event; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +CREATE TABLE event ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + date TIMESTAMP WITH TIME ZONE NOT NULL, + academic_year TEXT NOT NULL, + location TEXT NOT NULL, + poster BYTES +); +-- +goose StatementEnd diff --git a/internal/cmd/event.go b/internal/cmd/event.go deleted file mode 100644 index 6957ac2..0000000 --- a/internal/cmd/event.go +++ /dev/null @@ -1,48 +0,0 @@ -package cmd - -import ( - "time" - - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/event" - "github.com/zeusWPI/scc/pkg/config" - "go.uber.org/zap" -) - -// Event starts the event instance -func Event(db *db.DB) (*event.Event, chan bool) { - ev := event.New(db) - done := make(chan bool) - interval := config.GetDefaultInt("backend.event.interval_s", 3600) - - go eventPeriodicUpdate(ev, done, interval) - - return ev, done -} - -func eventPeriodicUpdate(ev *event.Event, done chan bool, interval int) { - zap.S().Info("Event: Starting periodic leaderboard update with an interval of ", interval, " seconds") - - ticker := time.NewTimer(time.Duration(interval) * time.Second) - defer ticker.Stop() - - // Run immediatly once - zap.S().Info("Event: Updating events") - if err := ev.Update(); err != nil { - zap.S().Error("Event: Error updating events\n", err) - } - - for { - select { - case <-done: - zap.S().Info("Event: Stopping periodic leaderboard update") - return - case <-ticker.C: - // Update leaderboard - zap.S().Info("Event: Updating events") - if err := ev.Update(); err != nil { - zap.S().Error("Event: Error updating events\n", err) - } - } - } -} diff --git a/internal/database/model/event.go b/internal/database/model/event.go deleted file mode 100644 index c439b62..0000000 --- a/internal/database/model/event.go +++ /dev/null @@ -1,27 +0,0 @@ -package model - -import ( - "time" - - "github.com/zeusWPI/scc/pkg/sqlc" -) - -type Event struct { - ID int - Name string - Date time.Time - AcademicYear string - Location string - Poster []byte -} - -func EventModel(e sqlc.Event) *Event { - return &Event{ - ID: int(e.ID), - Name: e.Name, - Date: e.Date.Time, - AcademicYear: e.AcademicYear, - Location: e.Location, - Poster: e.Poster, - } -} diff --git a/internal/database/repository/event.go b/internal/database/repository/event.go deleted file mode 100644 index 9070910..0000000 --- a/internal/database/repository/event.go +++ /dev/null @@ -1,72 +0,0 @@ -package repository - -import ( - "context" - "database/sql" - "errors" - "fmt" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/zeusWPI/scc/internal/database/model" - "github.com/zeusWPI/scc/pkg/sqlc" - "github.com/zeusWPI/scc/pkg/utils" -) - -type Event struct { - repo Repository -} - -func (r *Repository) NewEvent() *Event { - return &Event{ - repo: *r, - } -} - -func (e *Event) GetByAcademicYear(ctx context.Context, year string) ([]*model.Event, error) { - events, err := e.repo.queries(ctx).EventGetAllByYear(ctx, year) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("get by academic year %s | %w", year, err) - } - return nil, nil - } - - return utils.SliceMap(events, model.EventModel), nil -} - -func (e *Event) GetByCurrentAcademicYear(ctx context.Context) ([]*model.Event, error) { - events, err := e.repo.queries(ctx).EventGetAllByCurrentYear(ctx) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("get by current academic year %w", err) - } - return nil, nil - } - - return utils.SliceMap(events, model.EventModel), nil -} - -func (e *Event) Create(ctx context.Context, event *model.Event) error { - id, err := e.repo.queries(ctx).EventCreate(ctx, sqlc.EventCreateParams{ - Name: event.Name, - Date: pgtype.Timestamptz{Time: event.Date, Valid: !event.Date.IsZero()}, - AcademicYear: event.AcademicYear, - Location: event.Location, - Poster: event.Poster, - }) - if err != nil { - return fmt.Errorf("create event %+v | %w", *event, err) - } - - event.ID = int(id) - - return nil -} - -func (e *Event) DeleteAll(ctx context.Context) error { - if err := e.repo.queries(ctx).EventDeleteAll(ctx); err != nil { - return fmt.Errorf("delete all events %w", err) - } - - return nil -} diff --git a/internal/event/api.go b/internal/event/api.go deleted file mode 100644 index 6d34862..0000000 --- a/internal/event/api.go +++ /dev/null @@ -1,100 +0,0 @@ -package event - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "sync" - "time" - - "github.com/zeusWPI/scc/internal/database/model" - "github.com/zeusWPI/scc/pkg/utils" -) - -type eventAPI struct { - ID int `json:"id"` - Name string `json:"Name"` - Location string `json:"location"` - Start time.Time `json:"start_time"` - End time.Time `json:"end_time"` - YearStart int `json:"year_start"` - YearEnd int `json:"year_end"` - Poster []byte -} - -func (e eventAPI) toModel() model.Event { - return model.Event{ - Name: e.Name, - Date: e.Start, - AcademicYear: fmt.Sprintf("%d-%d", e.YearStart, e.YearEnd), - Location: e.Location, - Poster: e.Poster, - } -} - -func (e *Event) getPoster(ctx context.Context, event *eventAPI) error { - resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ - Method: "GET", - URL: fmt.Sprintf("%s/event/poster/%d?original=true&scc=true", e.url, event.ID), - }) - if err != nil { - return fmt.Errorf("get poster %+v | %w", *event, err) - } - - defer func() { - _ = resp.Body.Close() - }() - - bytes, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("read poster bytes %+v | %w", *event, err) - } - - event.Poster = bytes - - return nil -} - -func (e *Event) getEvents(ctx context.Context) ([]model.Event, error) { - resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ - Method: "GET", - URL: fmt.Sprintf("%s/event", e.url), - }) - if err != nil { - return nil, fmt.Errorf("get events %w", err) - } - - defer func() { - _ = resp.Body.Close() - }() - - var events []eventAPI - if err := json.NewDecoder(resp.Body).Decode(&events); err != nil { - return nil, fmt.Errorf("decode event api %w", err) - } - - var errs []error - - var mu sync.Mutex - var wg sync.WaitGroup - - for _, event := range events { - wg.Go(func() { - if err := e.getPoster(ctx, &event); err != nil { - mu.Lock() - errs = append(errs, err) - mu.Unlock() - } - }) - } - - wg.Wait() - - if errs != nil { - return nil, errors.Join(errs...) - } - - return utils.SliceMap(events, func(e eventAPI) model.Event { return e.toModel() }), nil -} diff --git a/internal/event/event.go b/internal/event/event.go deleted file mode 100644 index 5b36cae..0000000 --- a/internal/event/event.go +++ /dev/null @@ -1,47 +0,0 @@ -// Package event keeps the current event database in sync -package event - -import ( - "context" - - "github.com/zeusWPI/scc/internal/database/repository" - "github.com/zeusWPI/scc/pkg/config" -) - -type Event struct { - repo repository.Repository - event repository.Event - url string -} - -// TODO: Check if colly is removed - -func New(repo repository.Repository) *Event { - return &Event{ - repo: repo, - event: *repo.NewEvent(), - url: config.GetDefaultString("backend.event.url", "https://events.zeus.gent/api/v1"), - } -} - -// Update gets all events from the website of this academic year -func (e *Event) Update(ctx context.Context) error { - events, err := e.getEvents(ctx) - if err != nil { - return err - } - - return e.repo.WithRollback(ctx, func(ctx context.Context) error { - if err := e.event.DeleteAll(ctx); err != nil { - return err - } - - for _, event := range events { - if err := e.event.Create(ctx, &event); err != nil { - return err - } - } - - return nil - }) -} diff --git a/tui/view/event/event.go b/tui/view/event/event.go index ad7e32e..199178b 100644 --- a/tui/view/event/event.go +++ b/tui/view/event/event.go @@ -2,56 +2,52 @@ package event import ( - "context" + "slices" "time" tea "github.com/charmbracelet/bubbletea" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/db/dto" "github.com/zeusWPI/scc/pkg/config" - "github.com/zeusWPI/scc/pkg/util" + "github.com/zeusWPI/scc/pkg/utils" "github.com/zeusWPI/scc/tui/view" ) -var ( - passedAmount = 3 - upcomingAmount = 7 -) - -// Model represents the model for the event view type Model struct { - db *db.DB - passed []dto.Event - upcoming []dto.Event - today *dto.Event + events []event width int height int + + url string // Url of the api } +// Interface compliance +var _ view.View = (*Model)(nil) + // Msg represents the message to update the event view type Msg struct { - upcoming []dto.Event - passed []dto.Event - today *dto.Event + events []event } -// NewModel creates a new event view -func NewModel(db *db.DB) view.View { - return &Model{db: db} +// Interface compliance +var _ tea.Msg = (*Msg)(nil) + +func NewModel() view.View { + return &Model{ + events: nil, + width: 0, + height: 0, + url: config.GetDefaultString("tui.view.event.url", "https://events.zeus.gent/api/v1"), + } } -// Init initializes the event model view func (m *Model) Init() tea.Cmd { return nil } -// Name returns the name of the view func (m *Model) Name() string { return "Events" } -// Update updates the event model view func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { case view.MsgSize: @@ -69,24 +65,20 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { return m, nil case Msg: - m.passed = msg.passed - m.upcoming = msg.upcoming - m.today = msg.today + m.events = msg.events } return m, nil } -// View returns the view for the event model func (m *Model) View() string { - if m.today != nil { + if idx := slices.IndexFunc(m.events, func(e event) bool { return utils.SameDay(e.Start, time.Now()) }); idx != -1 { return m.viewToday() } return m.viewOverview() } -// GetUpdateDatas returns all the update function for the event model func (m *Model) GetUpdateDatas() []view.UpdateData { return []view.UpdateData{ { @@ -97,42 +89,3 @@ func (m *Model) GetUpdateDatas() []view.UpdateData { }, } } - -func updateEvents(view view.View) (tea.Msg, error) { - m := view.(*Model) - - eventsDB, err := m.db.Queries.GetEventsCurrentAcademicYear(context.Background()) - if err != nil { - return nil, err - } - - events := util.SliceMap(eventsDB, dto.EventDTO) - - passed := make([]dto.Event, 0) - upcoming := make([]dto.Event, 0) - var today *dto.Event - - now := time.Now() - for _, event := range events { - if event.Date.Before(now) { - passed = append(passed, *event) - } else { - upcoming = append(upcoming, *event) - } - - if event.Date.Year() == now.Year() && event.Date.YearDay() == now.YearDay() { - today = event - } - } - - // Truncate passed and upcoming slices - if len(passed) > passedAmount { - passed = passed[len(passed)-passedAmount:] - } - - if len(upcoming) > upcomingAmount { - upcoming = upcoming[:upcomingAmount] - } - - return Msg{passed: passed, upcoming: upcoming, today: today}, nil -} diff --git a/tui/view/event/update.go b/tui/view/event/update.go new file mode 100644 index 0000000..b39b3e6 --- /dev/null +++ b/tui/view/event/update.go @@ -0,0 +1,127 @@ +package event + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "slices" + "sync" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/pkg/utils" + "github.com/zeusWPI/scc/tui/view" +) + +type event struct { + ID int `json:"id"` + Name string `json:"name"` + Location string `json:"location"` + Start time.Time `json:"start_time"` + Poster []byte +} + +func (e event) equal(e2 event) bool { + return e.ID == e2.ID && e.Name == e2.Name && e.Location == e2.Location && e.Start.Equal(e2.Start) && bytes.Equal(e.Poster, e2.Poster) +} + +func updateEvents(ctx context.Context, view view.View) (tea.Msg, error) { + m := view.(*Model) + + events, err := getEvents(ctx, m.url) + if err != nil { + return nil, err + } + + if len(events) != len(m.events) { + return Msg{events: events}, nil + } + + for _, ev := range events { + if idx := slices.IndexFunc(m.events, func(e event) bool { return e.equal(ev) }); idx == -1 { + return Msg{events: events}, nil + } + } + + return nil, nil +} + +func getEvents(ctx context.Context, url string) ([]event, error) { + resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ + Method: "GET", + URL: fmt.Sprintf("%s/event", url), + }) + if err != nil { + return nil, fmt.Errorf("get events %w", err) + } + + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("bad response code for get events %s", resp.Status) + } + + var events []event + if err := json.NewDecoder(resp.Body).Decode(&events); err != nil { + return nil, fmt.Errorf("decode event api %w", err) + } + + var errs []error + + var mu sync.Mutex + var wg sync.WaitGroup + + for _, event := range events { + wg.Go(func() { + if err := getPoster(ctx, url, &event); err != nil { + mu.Lock() + errs = append(errs, err) + mu.Unlock() + } + }) + } + + wg.Wait() + + if errs != nil { + return nil, errors.Join(errs...) + } + + return events, nil +} + +func getPoster(ctx context.Context, url string, event *event) error { + resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ + Method: "GET", + URL: fmt.Sprintf("%s/event/poster/%d?original=true&scc=true", url, event.ID), + }) + if err != nil { + return fmt.Errorf("get poster %+v | %w", *event, err) + } + + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != 200 { + if resp.StatusCode == 404 { + // Event doesn't have a poster + return nil + } + return fmt.Errorf("bad response code for event poster %s | %+v", resp.Status, *event) + } + + bytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read poster bytes %+v | %w", *event, err) + } + + event.Poster = bytes + + return nil +} diff --git a/tui/view/event/view.go b/tui/view/event/view.go index c6edcb9..3a4053a 100644 --- a/tui/view/event/view.go +++ b/tui/view/event/view.go @@ -3,24 +3,31 @@ package event import ( "bytes" "image" + "time" "github.com/charmbracelet/lipgloss" + "github.com/zeusWPI/scc/pkg/utils" "github.com/zeusWPI/scc/tui/view" ) func (m *Model) viewToday() string { + today, ok := utils.SliceFind(m.events, func(e event) bool { return utils.SameDay(e.Start, time.Now()) }) + if !ok { + return "" + } + // Render image poster := "" - if m.today.Poster != nil { - i, _, err := image.Decode(bytes.NewReader(m.today.Poster)) + if today.Poster != nil { + i, _, err := image.Decode(bytes.NewReader(today.Poster)) if err == nil { poster = view.ImageToString(i, wTodayPoster, 0) } } - name := sTodayText.Render(m.today.Name) - date := sTodayDate.Render("🕙 " + m.today.Date.Format("15:04")) - location := sTodayeLoc.Render("📍 " + m.today.Location) + name := sTodayText.Render(today.Name) + date := sTodayDate.Render("🕙 " + today.Start.Format("15:04")) + location := sTodayeLoc.Render("📍 " + today.Location) event := lipgloss.JoinVertical(lipgloss.Left, name, date, location) event = sToday.Render(event) @@ -36,18 +43,22 @@ func (m *Model) viewToday() string { return sTodayAll.Render(view) } +// TODO: update rendering func (m *Model) viewOverview() string { + passed := utils.SliceFilter(m.events, func(e event) bool { return e.Start.Before(time.Now()) }) + upcoming := utils.SliceFilter(m.events, func(e event) bool { return e.Start.After(time.Now()) }) + // Poster if present poster := "" - if len(m.upcoming) > 0 && m.upcoming[0].Poster != nil { - i, _, err := image.Decode(bytes.NewReader(m.upcoming[0].Poster)) + if len(upcoming) > 0 && upcoming[0].Poster != nil { + i, _, err := image.Decode(bytes.NewReader(upcoming[0].Poster)) if err == nil { poster = view.ImageToString(i, wOvPoster, 0) } } // Overview - events := m.viewGetEventOverview() + events := m.viewGetEventOverview(passed, upcoming) if lipgloss.Height(poster) > lipgloss.Height(events) { events = sOv.Height(lipgloss.Height(poster)).Render(events) @@ -61,26 +72,26 @@ func (m *Model) viewOverview() string { return sOvAll.Render(view) } -func (m *Model) viewGetEventOverview() string { - events := make([]string, 0, len(m.passed)+len(m.upcoming)+1) +func (m *Model) viewGetEventOverview(passed, upcoming []event) string { + events := make([]string, 0, len(passed)+len(upcoming)+1) title := sOvTitle.Render("Events") events = append(events, title) // Passed - for _, event := range m.passed { - date := sOvPassedDate.Render(event.Date.Format("02/01")) + for _, event := range passed { + date := sOvPassedDate.Render(event.Start.Format("02/01")) name := sOvPassedText.Render(event.Name) text := lipgloss.JoinHorizontal(lipgloss.Top, date, name) events = append(events, text) } - if len(m.upcoming) > 0 { + if len(upcoming) > 0 { // Next - date := sOvNextDate.Render(m.upcoming[0].Date.Format("02/01")) - name := sOvNextText.Render(m.upcoming[0].Name) - location := sOvNextLoc.Render("📍 " + m.upcoming[0].Location) + date := sOvNextDate.Render(upcoming[0].Start.Format("02/01")) + name := sOvNextText.Render(upcoming[0].Name) + location := sOvNextLoc.Render("📍 " + upcoming[0].Location) text := lipgloss.JoinVertical(lipgloss.Left, name, location) text = lipgloss.JoinHorizontal(lipgloss.Top, date, text) @@ -89,12 +100,12 @@ func (m *Model) viewGetEventOverview() string { } // Upcoming - for i := 1; i < len(m.upcoming); i++ { - date := sOvUpcomingDate.Render(m.upcoming[i].Date.Format("02/01")) - name := sOvUpcomingText.Render(m.upcoming[i].Name) + for i := 1; i < len(upcoming); i++ { + date := sOvUpcomingDate.Render(upcoming[i].Start.Format("02/01")) + name := sOvUpcomingText.Render(upcoming[i].Name) text := name if i < 3 { - location := sOvNextLoc.Render("📍 " + m.upcoming[i].Location) + location := sOvNextLoc.Render("📍 " + upcoming[i].Location) text = lipgloss.JoinVertical(lipgloss.Left, name, location) } From 1ac0f01d4323acd82486bee309e00c569d430542 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sat, 23 Aug 2025 23:22:41 +0200 Subject: [PATCH 04/15] refactor: remove gamification fetching from backend --- config/{development.yaml => development.yml} | 13 +- .../20241125113707_add_gamification_table.sql | 4 +- .../20241203173952_event_add_poster.sql | 2 +- ...20250823211949_drop_gamification_table.sql | 15 +++ internal/cmd/gamification.go | 48 ------- internal/database/model/gamification.go | 19 --- internal/database/repository/gamification.go | 57 --------- internal/gamification/api.go | 96 -------------- internal/gamification/gamification.go | 44 ------- tui/view/gamification/gamification.go | 90 ++++---------- tui/view/gamification/update.go | 117 ++++++++++++++++++ 11 files changed, 165 insertions(+), 340 deletions(-) rename config/{development.yaml => development.yml} (90%) create mode 100644 db/migrations/20250823211949_drop_gamification_table.sql delete mode 100644 internal/cmd/gamification.go delete mode 100644 internal/database/model/gamification.go delete mode 100644 internal/database/repository/gamification.go delete mode 100644 internal/gamification/api.go delete mode 100644 internal/gamification/gamification.go create mode 100644 tui/view/gamification/update.go diff --git a/config/development.yaml b/config/development.yml similarity index 90% rename from config/development.yaml rename to config/development.yml index a7ce00d..5e917a5 100644 --- a/config/development.yaml +++ b/config/development.yml @@ -7,6 +7,7 @@ db: port: 5432 user: "postgres" password: "postgres" + database: "scc" backend: buzzer: @@ -48,14 +49,6 @@ backend: - "-l100" - "-d0" - event: - url: "https://events.zeus.gent/api/v1" - interval_s: 3600 - - gamification: - url: "https://gamification.zeus.gent" - interval_s: 3600 - song: spotify_url: "https://api.spotify.com/v1" spotify_url_account: "https://accounts.spotify.com/api/token" @@ -89,9 +82,11 @@ tui: view: event: - interval_s: 3600 + url: "https://events.zeus.gent/api/v1" + interval_s: 86400 gamification: + url: "https://gamification.zeus.gent" interval_s: 3600 message: diff --git a/db/migrations/20241125113707_add_gamification_table.sql b/db/migrations/20241125113707_add_gamification_table.sql index 2da01bf..080a8a0 100644 --- a/db/migrations/20241125113707_add_gamification_table.sql +++ b/db/migrations/20241125113707_add_gamification_table.sql @@ -1,6 +1,6 @@ -- +goose Up -- +goose StatementBegin -CREATE TABLE IF NOT EXISTS gamification ( +CREATE TABLE gamification ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, score INTEGER NOT NULL, @@ -10,5 +10,5 @@ CREATE TABLE IF NOT EXISTS gamification ( -- +goose Down -- +goose StatementBegin -DROP TABLE IF EXISTS gamification; +DROP TABLE gamification; -- +goose StatementEnd diff --git a/db/migrations/20241203173952_event_add_poster.sql b/db/migrations/20241203173952_event_add_poster.sql index 77a0532..3e195e5 100644 --- a/db/migrations/20241203173952_event_add_poster.sql +++ b/db/migrations/20241203173952_event_add_poster.sql @@ -1,7 +1,7 @@ -- +goose Up -- +goose StatementBegin ALTER TABLE event -ADD COLUMN poster BYTES; +ADD COLUMN poster BYTEA; -- +goose StatementEnd -- +goose Down diff --git a/db/migrations/20250823211949_drop_gamification_table.sql b/db/migrations/20250823211949_drop_gamification_table.sql new file mode 100644 index 0000000..9cf6b83 --- /dev/null +++ b/db/migrations/20250823211949_drop_gamification_table.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- +goose StatementBegin +DROP TABLE gamification; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +CREATE TABLE gamification ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + score INTEGER NOT NULL, + avatar BYTEA +); + +-- +goose StatementEnd diff --git a/internal/cmd/gamification.go b/internal/cmd/gamification.go deleted file mode 100644 index 8561625..0000000 --- a/internal/cmd/gamification.go +++ /dev/null @@ -1,48 +0,0 @@ -package cmd - -import ( - "time" - - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/gamification" - "github.com/zeusWPI/scc/pkg/config" - "go.uber.org/zap" -) - -// Gamification starts the gamification instance -func Gamification(db *db.DB) (*gamification.Gamification, chan bool) { - gam := gamification.New(db) - done := make(chan bool) - interval := config.GetDefaultInt("backend.gamification.interval_s", 3600) - - go gamificationPeriodicUpdate(gam, done, interval) - - return gam, done -} - -func gamificationPeriodicUpdate(gam *gamification.Gamification, done chan bool, interval int) { - zap.S().Info("Gamification: Starting periodic leaderboard update with an interval of ", interval, " seconds") - - ticker := time.NewTicker(time.Duration(interval) * time.Second) - defer ticker.Stop() - - // Run immediatly once - zap.S().Info("Gamification: Updating leaderboard") - if err := gam.Update(); err != nil { - zap.S().Error("gamification: Error updating leaderboard\n", err) - } - - for { - select { - case <-done: - zap.S().Info("Gamification: Stopping periodic leaderboard update") - return - case <-ticker.C: - // Update leaderboard - zap.S().Info("Gamification: Updating leaderboard") - if err := gam.Update(); err != nil { - zap.S().Error("gamification: Error updating leaderboard\n", err) - } - } - } -} diff --git a/internal/database/model/gamification.go b/internal/database/model/gamification.go deleted file mode 100644 index 4e6b567..0000000 --- a/internal/database/model/gamification.go +++ /dev/null @@ -1,19 +0,0 @@ -package model - -import "github.com/zeusWPI/scc/pkg/sqlc" - -type Gamification struct { - ID int - Name string - Score int - Avatar []byte -} - -func GamificationModel(g sqlc.Gamification) *Gamification { - return &Gamification{ - ID: int(g.ID), - Name: g.Name, - Score: int(g.Score), - Avatar: g.Avatar, - } -} diff --git a/internal/database/repository/gamification.go b/internal/database/repository/gamification.go deleted file mode 100644 index a734ffa..0000000 --- a/internal/database/repository/gamification.go +++ /dev/null @@ -1,57 +0,0 @@ -package repository - -import ( - "context" - "database/sql" - "errors" - "fmt" - - "github.com/zeusWPI/scc/internal/database/model" - "github.com/zeusWPI/scc/pkg/sqlc" - "github.com/zeusWPI/scc/pkg/utils" -) - -type Gamification struct { - repo Repository -} - -func (r *Repository) NewGamification() *Gamification { - return &Gamification{ - repo: *r, - } -} - -func (g *Gamification) GetAll(ctx context.Context) ([]*model.Gamification, error) { - gams, err := g.repo.queries(ctx).GamificationGetAll(ctx) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("get all gamification %w", err) - } - return nil, nil - } - - return utils.SliceMap(gams, model.GamificationModel), nil -} - -func (g *Gamification) Create(ctx context.Context, gam *model.Gamification) error { - id, err := g.repo.queries(ctx).GamificationCreate(ctx, sqlc.GamificationCreateParams{ - Name: gam.Name, - Score: int32(gam.Score), - Avatar: gam.Avatar, - }) - if err != nil { - return fmt.Errorf("create gamification %+v | %w", *gam, err) - } - - gam.ID = int(id) - - return nil -} - -func (g *Gamification) DeleteAll(ctx context.Context) error { - if err := g.repo.queries(ctx).GamificationDeleteAll(ctx); err != nil { - return fmt.Errorf("delete all gamifications %w", err) - } - - return nil -} diff --git a/internal/gamification/api.go b/internal/gamification/api.go deleted file mode 100644 index 98bd1e3..0000000 --- a/internal/gamification/api.go +++ /dev/null @@ -1,96 +0,0 @@ -package gamification - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "sync" - - "github.com/zeusWPI/scc/internal/database/model" - "github.com/zeusWPI/scc/pkg/utils" -) - -type gamificationAPI struct { - Name string `json:"github_name"` - Score int `json:"score"` - AvatarURL string `json:"avatar_url"` - Avatar []byte -} - -func (g gamificationAPI) toModel() model.Gamification { - return model.Gamification{ - Name: g.Name, - Score: g.Score, - Avatar: g.Avatar, - } -} - -func (g *Gamification) getAvatar(ctx context.Context, gam *gamificationAPI) error { - resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ - Method: "GET", - URL: gam.AvatarURL, - }) - if err != nil { - return fmt.Errorf("get avatar url %+v | %w", *gam, err) - } - - defer func() { - _ = resp.Body.Close() - }() - - bytes, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("read avatar bytes %+v | %w", *gam, err) - } - - gam.Avatar = bytes - - return nil -} - -func (g *Gamification) getLeaderboard(ctx context.Context) ([]model.Gamification, error) { - resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ - Method: "GET", - URL: fmt.Sprintf("%s/top4", g.url), - Headers: map[string]string{ - "Accept": "application/json", - }, - }) - if err != nil { - return nil, fmt.Errorf("get top 4 gamification %w", err) - } - - defer func() { - _ = resp.Body.Close() - }() - - var gams []gamificationAPI - if err := json.NewDecoder(resp.Body).Decode(&gams); err != nil { - return nil, fmt.Errorf("decode gamification response %w", err) - } - - var errs []error - - var mu sync.Mutex - var wg sync.WaitGroup - - for _, gam := range gams { - wg.Go(func() { - if err := g.getAvatar(ctx, &gam); err != nil { - mu.Lock() - errs = append(errs, err) - mu.Unlock() - } - }) - } - - wg.Wait() - - if errs != nil { - return nil, errors.Join(errs...) - } - - return utils.SliceMap(gams, func(g gamificationAPI) model.Gamification { return g.toModel() }), nil -} diff --git a/internal/gamification/gamification.go b/internal/gamification/gamification.go deleted file mode 100644 index b55ed8e..0000000 --- a/internal/gamification/gamification.go +++ /dev/null @@ -1,44 +0,0 @@ -// Package gamification provides all gamification related logic -package gamification - -import ( - "context" - - "github.com/zeusWPI/scc/internal/database/repository" - "github.com/zeusWPI/scc/pkg/config" -) - -type Gamification struct { - repo repository.Repository - gam repository.Gamification - url string -} - -func New(repo repository.Repository) *Gamification { - return &Gamification{ - repo: repo, - gam: *repo.NewGamification(), - url: config.GetDefaultString("backend.gamification.url", "https://gamification.zeus.gent"), - } -} - -func (g *Gamification) Update(ctx context.Context) error { - leaderboard, err := g.getLeaderboard(ctx) - if err != nil { - return err - } - - return g.repo.WithRollback(ctx, func(ctx context.Context) error { - if err := g.gam.DeleteAll(ctx); err != nil { - return err - } - - for _, l := range leaderboard { - if err := g.gam.Create(ctx, &l); err != nil { - return err - } - } - - return nil - }) -} diff --git a/tui/view/gamification/gamification.go b/tui/view/gamification/gamification.go index a287edc..63bc3dd 100644 --- a/tui/view/gamification/gamification.go +++ b/tui/view/gamification/gamification.go @@ -2,57 +2,60 @@ package gamification import ( - "bytes" - "context" - "errors" "fmt" "image" "strconv" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/jackc/pgx/v5" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/db/dto" "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/tui/view" ) -// Model represents the view model for gamification type Model struct { - db *db.DB - leaderboard []gamificationItem + leaderboard []gamification width int height int -} -type gamificationItem struct { - image image.Image - item dto.Gamification + url string // API url for gamification } +// Interface compliance +var _ view.View = (*Model)(nil) + // Msg contains the data to update the gamification model type Msg struct { - leaderboard []gamificationItem + leaderboard []gamification +} + +// Interface Compliance +var _ tea.Msg = (*Msg)(nil) + +type gamification struct { + Name string `json:"github_name"` + Score int `json:"score"` + AvatarURL string `json:"avartar_url"` + avatar image.Image } -// NewModel initializes a new gamification model -func NewModel(db *db.DB) view.View { - return &Model{db: db, leaderboard: []gamificationItem{}} +func NewModel() view.View { + return &Model{ + leaderboard: nil, + width: 0, + height: 0, + url: config.GetDefaultString("tui.view.gamification.url", "https://gamification.zeus.gent"), + } } -// Init starts the gamification view func (m *Model) Init() tea.Cmd { return nil } -// Name returns the name of the view func (m *Model) Name() string { return "Gamification" } -// Update updates the gamification view func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { case view.MsgSize: @@ -76,16 +79,15 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { return m, nil } -// View draws the gamification view func (m *Model) View() string { columns := make([]string, 0, len(m.leaderboard)) for i, item := range m.leaderboard { user := lipgloss.JoinVertical(lipgloss.Left, - positions[i].Inherit(sName).Render(fmt.Sprintf("%d. %s", i+1, item.item.Name)), - sScore.Render(strconv.Itoa(int(item.item.Score))), + positions[i].Inherit(sName).Render(fmt.Sprintf("%d. %s", i+1, item.Name)), + sScore.Render(strconv.Itoa(int(item.Score))), ) - im := sAvatar.Render(view.ImageToString(item.image, wColumn, sAll.GetHeight()-lipgloss.Height(user))) + im := sAvatar.Render(view.ImageToString(item.avatar, wColumn, sAll.GetHeight()-lipgloss.Height(user))) column := lipgloss.JoinVertical(lipgloss.Left, im, user) columns = append(columns, sColumn.Render(column)) @@ -107,43 +109,3 @@ func (m *Model) GetUpdateDatas() []view.UpdateData { }, } } - -func updateLeaderboard(view view.View) (tea.Msg, error) { - m := view.(*Model) - - gams, err := m.db.Queries.GetAllGamificationByScore(context.Background()) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - err = nil - } - return nil, err - } - - // Check if both leaderboards are equal - equal := false - if len(m.leaderboard) == len(gams) { - equal = true - for i, l := range m.leaderboard { - if !l.item.Equal(*dto.GamificationDTO(gams[i])) { - equal = false - break - } - } - } - - if equal { - return nil, nil - } - - msg := Msg{leaderboard: []gamificationItem{}} - for _, gam := range gams { - im, _, err := image.Decode(bytes.NewReader(gam.Avatar)) - if err != nil { - return nil, err - } - - msg.leaderboard = append(msg.leaderboard, gamificationItem{image: im, item: *dto.GamificationDTO(gam)}) - } - - return msg, nil -} diff --git a/tui/view/gamification/update.go b/tui/view/gamification/update.go new file mode 100644 index 0000000..2df1866 --- /dev/null +++ b/tui/view/gamification/update.go @@ -0,0 +1,117 @@ +package gamification + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "image" + "io" + "slices" + "sync" + + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/pkg/utils" + "github.com/zeusWPI/scc/tui/view" +) + +func (g gamification) equal(g2 gamification) bool { + return g.Name == g2.Name && g.Score == g2.Score && g.AvatarURL == g2.AvatarURL +} + +func updateLeaderboard(ctx context.Context, view view.View) (tea.Msg, error) { + m := view.(*Model) + + leaderboard, err := getLeaderboard(ctx, m.url) + if err != nil { + return nil, err + } + + slices.SortFunc(leaderboard, func(a, b gamification) int { return a.Score - b.Score }) + + if len(leaderboard) != len(m.leaderboard) { + return Msg{leaderboard: leaderboard}, nil + } + + for idx, l := range leaderboard { + if !m.leaderboard[idx].equal(l) { + return Msg{leaderboard: leaderboard}, nil + } + } + + return nil, nil +} + +func getLeaderboard(ctx context.Context, url string) ([]gamification, error) { + resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ + Method: "GET", + URL: fmt.Sprintf("%s/top4", url), + Headers: map[string]string{ + "Accept": "application/json", + }, + }) + if err != nil { + return nil, fmt.Errorf("get top 4 gamification %w", err) + } + + defer func() { + _ = resp.Body.Close() + }() + + var gams []gamification + if err := json.NewDecoder(resp.Body).Decode(&gams); err != nil { + return nil, fmt.Errorf("decode gamification response %w", err) + } + + var errs []error + + var mu sync.Mutex + var wg sync.WaitGroup + + for _, gam := range gams { + wg.Go(func() { + if err := getAvatar(ctx, &gam); err != nil { + mu.Lock() + errs = append(errs, err) + mu.Unlock() + } + }) + } + + wg.Wait() + + if errs != nil { + return nil, errors.Join(errs...) + } + + return gams, nil +} + +func getAvatar(ctx context.Context, gam *gamification) error { + resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ + Method: "GET", + URL: gam.AvatarURL, + }) + if err != nil { + return fmt.Errorf("get avatar url %+v | %w", *gam, err) + } + + defer func() { + _ = resp.Body.Close() + }() + + avatarBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read avatar bytes %+v | %w", *gam, err) + } + + img, _, err := image.Decode(bytes.NewReader(avatarBytes)) + if err != nil { + return fmt.Errorf("decode gamification avatar %+v | %w", *gam, err) + } + + gam.avatar = img + + return nil +} From 91a0f5e990b9a2f707e30d5861fbe2c9a6f8735e Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sat, 23 Aug 2025 23:58:04 +0200 Subject: [PATCH 05/15] refactor: bring up to date --- cmd/backend/backend.go | 20 ++- cmd/tui/tui.go | 18 ++- config/{production.yaml => production.yml} | 0 .../20241127133125_add_events_table.sql | 2 +- db/queries/event.sql | 22 --- db/queries/gamification.sql | 13 -- db/queries/tap.sql | 2 +- internal/cmd/api.go | 41 ------ internal/cmd/tap.go | 14 +- internal/cmd/tui.go | 24 ++-- internal/cmd/zess.go | 19 +-- internal/database/model/tap.go | 15 +- internal/database/repository/tap.go | 10 +- internal/server/server.go | 2 +- makefile | 20 ++- pkg/config/config.go | 4 +- pkg/sqlc/event.sql.go | 118 --------------- pkg/sqlc/gamification.sql.go | 69 --------- pkg/sqlc/models.go | 16 --- pkg/sqlc/song.sql.go | 10 +- pkg/sqlc/tap.sql.go | 9 +- pkg/utils/http.go | 5 +- pkg/utils/image.go | 29 ++++ pkg/utils/time.go | 7 + tui/components/bar/bar.go | 8 +- tui/components/stopwatch/stopwatch.go | 11 +- tui/screen/cammie/cammie.go | 68 +++++---- tui/screen/screen.go | 3 +- tui/screen/song/song.go | 11 +- tui/tui.go | 2 +- tui/view/event/event.go | 14 +- tui/view/event/update.go | 23 ++- tui/view/event/view.go | 16 +-- tui/view/gamification/gamification.go | 6 +- tui/view/message/message.go | 57 ++++---- tui/view/song/song.go | 1 + tui/view/tap/tap.go | 134 ++++-------------- tui/view/tap/update.go | 33 +++++ tui/view/tap/view.go | 14 +- tui/view/util.go | 2 +- tui/view/view.go | 4 +- tui/view/zess/zess.go | 2 + 42 files changed, 330 insertions(+), 568 deletions(-) rename config/{production.yaml => production.yml} (100%) delete mode 100644 db/queries/event.sql delete mode 100644 db/queries/gamification.sql delete mode 100644 internal/cmd/api.go delete mode 100644 pkg/sqlc/event.sql.go delete mode 100644 pkg/sqlc/gamification.sql.go create mode 100644 pkg/utils/image.go create mode 100644 pkg/utils/time.go create mode 100644 tui/view/tap/update.go diff --git a/cmd/backend/backend.go b/cmd/backend/backend.go index ddeea6f..05ea4e1 100644 --- a/cmd/backend/backend.go +++ b/cmd/backend/backend.go @@ -3,6 +3,9 @@ package main import ( "github.com/zeusWPI/scc/internal/cmd" + "github.com/zeusWPI/scc/internal/database/repository" + "github.com/zeusWPI/scc/internal/server" + "github.com/zeusWPI/scc/internal/server/service" "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/pkg/db" "github.com/zeusWPI/scc/pkg/logger" @@ -31,18 +34,15 @@ func main() { zap.S().Fatal("DB: Fatal error\n", err) } + // Repository + repo := repository.New(db) + // Tap _, _ = cmd.Tap(db) // Zess _, _, _ = cmd.Zess(db) - // Gamification - _, _ = cmd.Gamification(db) - - // Event - _, _ = cmd.Event(db) - // Spotify spotify, err := cmd.Song(db) if err != nil { @@ -50,5 +50,11 @@ func main() { } // API - cmd.API(db, spotify) + service := service.New(*repo) + api := server.New(*service, db.Pool()) + + zap.S().Infof("Server is running on %s", api.Addr) + if err := api.Listen(api.Addr); err != nil { + zap.S().Fatalf("Failure while running the server %v", err) + } } diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go index ea2604f..16ea219 100644 --- a/cmd/tui/tui.go +++ b/cmd/tui/tui.go @@ -2,7 +2,11 @@ package main import ( + "flag" + "fmt" + "github.com/zeusWPI/scc/internal/cmd" + "github.com/zeusWPI/scc/internal/database/repository" "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/pkg/db" "github.com/zeusWPI/scc/pkg/logger" @@ -16,8 +20,16 @@ func main() { panic(err) } + screen := flag.String("screen", "", "TUI screen to start") + flag.Parse() + + if *screen == "" { + flag.PrintDefaults() + return + } + // Logger - zapLogger, err := logger.New("tui", false) + zapLogger, err := logger.New(fmt.Sprintf("%s.log", *screen), false) if err != nil { panic(err) } @@ -31,8 +43,10 @@ func main() { zap.S().Fatal("DB: Fatal error\n", err) } + repo := repository.New(db) + // TUI - err = cmd.TUI(db) + err = cmd.TUI(*repo, *screen) if err != nil { zap.S().Fatal("TUI: Fatal error\n", err) } diff --git a/config/production.yaml b/config/production.yml similarity index 100% rename from config/production.yaml rename to config/production.yml diff --git a/db/migrations/20241127133125_add_events_table.sql b/db/migrations/20241127133125_add_events_table.sql index 064cde5..af16f11 100644 --- a/db/migrations/20241127133125_add_events_table.sql +++ b/db/migrations/20241127133125_add_events_table.sql @@ -1,6 +1,6 @@ -- +goose Up -- +goose StatementBegin -CREATE TABLE IF event ( +CREATE TABLE event ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, date TIMESTAMP WITH TIME ZONE NOT NULL, diff --git a/db/queries/event.sql b/db/queries/event.sql deleted file mode 100644 index f05f898..0000000 --- a/db/queries/event.sql +++ /dev/null @@ -1,22 +0,0 @@ --- name: EventGetAllByYear :many -SELECT * -FROM event -WHERE academic_year = $1; - --- name: EventGetAllByCurrentYear :many -SELECT * -FROM event -WHERE academic_year = ( - SELECT MAX(academic_year) - FROM event -) -ORDER BY date ASC; - --- name: EventCreate :one -INSERT INTO event (name, date, academic_year, location, poster) -VALUES ($1, $2, $3, $4, $5) -RETURNING id; - - --- name: EventDeleteAll :exec -DELETE FROM event; diff --git a/db/queries/gamification.sql b/db/queries/gamification.sql deleted file mode 100644 index a5fe85b..0000000 --- a/db/queries/gamification.sql +++ /dev/null @@ -1,13 +0,0 @@ --- name: GamificationGetAll :many -SELECT * -FROM gamification -ORDER BY score DESC; - --- name: GamificationCreate :one -INSERT INTO gamification (name, score, avatar) -VALUES ($1, $2, $3) -RETURNING id; - --- name: GamificationDeleteAll :exec -DELETE FROM gamification; - diff --git a/db/queries/tap.sql b/db/queries/tap.sql index 92bd755..ef73a03 100644 --- a/db/queries/tap.sql +++ b/db/queries/tap.sql @@ -5,7 +5,7 @@ ORDER BY order_id DESC LIMIT 1; -- name: TapGetCountByCategory :many -SELECT category, COUNT(*) +SELECT category, COUNT(*), MAX(order_created_at)::TIMESTAMP AS latest_order_created_at FROM tap GROUP BY category; diff --git a/internal/cmd/api.go b/internal/cmd/api.go deleted file mode 100644 index 52f2803..0000000 --- a/internal/cmd/api.go +++ /dev/null @@ -1,41 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/gofiber/contrib/fiberzap" - "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/cors" - "github.com/zeusWPI/scc/internal/api" - "github.com/zeusWPI/scc/internal/pkg/buzzer" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/song" - "github.com/zeusWPI/scc/pkg/config" - "go.uber.org/zap" -) - -// API starts the API server -func API(db *db.DB, song *song.Song) { - app := fiber.New(fiber.Config{ - BodyLimit: 1024 * 1024 * 1024, - }) - app.Use( - fiberzap.New(fiberzap.Config{ - Logger: zap.L(), - }), - cors.New(cors.Config{ - AllowOrigins: "*", - AllowHeaders: "Origin, Content-Type, Accept, Access-Control-Allow-Origin", - }), - ) - - buzz := buzzer.New() - - apiGroup := app.Group("/api") - api.New(apiGroup, db, buzz, song) - - host := config.GetDefaultString("server.host", "localhost") - port := config.GetDefaultInt("server.port", 3000) - - zap.S().Fatal("API: Fatal server error", app.Listen(fmt.Sprintf("%s:%d", host, port))) -} diff --git a/internal/cmd/tap.go b/internal/cmd/tap.go index 00e97f5..e160f67 100644 --- a/internal/cmd/tap.go +++ b/internal/cmd/tap.go @@ -1,17 +1,18 @@ package cmd import ( + "context" "time" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/tap" + "github.com/zeusWPI/scc/internal/database/repository" + "github.com/zeusWPI/scc/internal/tap" "github.com/zeusWPI/scc/pkg/config" "go.uber.org/zap" ) // Tap starts the tap instance -func Tap(db *db.DB) (*tap.Tap, chan bool) { - tap := tap.New(db) +func Tap(repo repository.Repository) (*tap.Tap, chan bool) { + tap := tap.New(repo) done := make(chan bool) interval := config.GetDefaultInt("backend.tap.interval_s", 60) @@ -20,6 +21,7 @@ func Tap(db *db.DB) (*tap.Tap, chan bool) { return tap, done } +// TODO: Figure out the context situation func tapPeriodicUpdate(tap *tap.Tap, done chan bool, interval int) { zap.S().Info("Tap: Starting periodic update with an interval of ", interval, " seconds") @@ -28,7 +30,7 @@ func tapPeriodicUpdate(tap *tap.Tap, done chan bool, interval int) { // Run immediatly once zap.S().Info("Tap: Updating tap") - if err := tap.Update(); err != nil { + if err := tap.Update(context.Background()); err != nil { zap.S().Error("Tap: Error updating tap\n", err) } @@ -40,7 +42,7 @@ func tapPeriodicUpdate(tap *tap.Tap, done chan bool, interval int) { case <-ticker.C: // Update tap zap.S().Info("Tap: Updating tap") - if err := tap.Update(); err != nil { + if err := tap.Update(context.Background()); err != nil { zap.S().Error("Tap: Error updating tap\n", err) } } diff --git a/internal/cmd/tui.go b/internal/cmd/tui.go index ea097d5..98486e7 100644 --- a/internal/cmd/tui.go +++ b/internal/cmd/tui.go @@ -2,13 +2,14 @@ package cmd import ( + "context" "fmt" - "os" + "maps" "time" tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/internal/database/repository" "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/pkg/util" "github.com/zeusWPI/scc/tui" "github.com/zeusWPI/scc/tui/screen" "github.com/zeusWPI/scc/tui/screen/cammie" @@ -23,20 +24,13 @@ var screens = map[string]func(*db.DB) screen.Screen{ } // TUI starts the terminal user interface -func TUI(db *db.DB) error { - args := os.Args - if len(args) < 2 { - return fmt.Errorf("No screen specified. Options are %v", util.Keys(screens)) - } - - selectedScreen := args[1] - - val, ok := screens[selectedScreen] +func TUI(repo repository.Repository, screenName string) error { + val, ok := screens[screenName] if !ok { - return fmt.Errorf("Screen %s not found. Options are %v", selectedScreen, util.Keys(screens)) + return fmt.Errorf("Screen %s not found. Options are %v", screenName, maps.Keys(screens)) } - screen := val(db) + screen := val(repo) tui := tui.New(screen) p := tea.NewProgram(tui, tea.WithAltScreen()) @@ -63,7 +57,7 @@ func tuiPeriodicUpdates(p *tea.Program, updateData view.UpdateData, done chan bo defer ticker.Stop() // Immediatly update once - msg, err := updateData.Update(updateData.View) + msg, err := updateData.Update(context.Background(), updateData.View) if err != nil { zap.S().Error("TUI: Error updating ", updateData.Name, "\n", err) } @@ -79,7 +73,7 @@ func tuiPeriodicUpdates(p *tea.Program, updateData view.UpdateData, done chan bo return case <-ticker.C: // Update - msg, err := updateData.Update(updateData.View) + msg, err := updateData.Update(context.Background(), updateData.View) if err != nil { zap.S().Error("TUI: Error updating ", updateData.Name, "\n", err) } diff --git a/internal/cmd/zess.go b/internal/cmd/zess.go index 03e3888..7acc791 100644 --- a/internal/cmd/zess.go +++ b/internal/cmd/zess.go @@ -1,17 +1,18 @@ package cmd import ( + "context" "time" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/zess" + "github.com/zeusWPI/scc/internal/database/repository" + "github.com/zeusWPI/scc/internal/zess" "github.com/zeusWPI/scc/pkg/config" "go.uber.org/zap" ) // Zess starts the zess instance -func Zess(db *db.DB) (*zess.Zess, chan bool, chan bool) { - zess := zess.New(db) +func Zess(repo repository.Repository) (*zess.Zess, chan bool, chan bool) { + zess := zess.New(repo) doneSeason := make(chan bool) intervalSeason := config.GetDefaultInt("backend.zess.interval_season_s", 300) @@ -25,6 +26,8 @@ func Zess(db *db.DB) (*zess.Zess, chan bool, chan bool) { return zess, doneSeason, doneScan } +// TODO: Figure out the context situation + func zessPeriodicSeasonUpdate(zess *zess.Zess, done chan bool, interval int) { zap.S().Info("Zess: Starting periodic season update with an interval of ", interval, " seconds") @@ -33,7 +36,7 @@ func zessPeriodicSeasonUpdate(zess *zess.Zess, done chan bool, interval int) { // Run immediatly once zap.S().Info("Zess: Updating seasons") - if err := zess.UpdateSeasons(); err != nil { + if err := zess.UpdateSeasons(context.Background()); err != nil { zap.S().Error("Zess: Error updating seasons\n", err) } @@ -45,7 +48,7 @@ func zessPeriodicSeasonUpdate(zess *zess.Zess, done chan bool, interval int) { case <-ticker.C: // Update seasons zap.S().Info("Zess: Updating seasons") - if err := zess.UpdateSeasons(); err != nil { + if err := zess.UpdateSeasons(context.Background()); err != nil { zap.S().Error("Zess: Error updating seasons\n", err) } } @@ -60,7 +63,7 @@ func zessPeriodicScanUpdate(zess *zess.Zess, done chan bool, interval int) { // Run immediatly once zap.S().Info("Zess: Updating scans") - if err := zess.UpdateScans(); err != nil { + if err := zess.UpdateScans(context.Background()); err != nil { zap.S().Error("Zess: Error updating scans\n", err) } @@ -72,7 +75,7 @@ func zessPeriodicScanUpdate(zess *zess.Zess, done chan bool, interval int) { case <-ticker.C: // Update scans zap.S().Info("Zess: Updating scans") - if err := zess.UpdateScans(); err != nil { + if err := zess.UpdateScans(context.Background()); err != nil { zap.S().Error("Zess: Error updating scans\n", err) } } diff --git a/internal/database/model/tap.go b/internal/database/model/tap.go index 61cc651..641a6c1 100644 --- a/internal/database/model/tap.go +++ b/internal/database/model/tap.go @@ -34,4 +34,17 @@ func TapModel(t sqlc.Tap) *Tap { } } -type TapCount map[TapCategory]int +type TapCount struct { + Category TapCategory + LastOrder time.Time + + Count int +} + +func TapCountModel(t sqlc.TapGetCountByCategoryRow) *TapCount { + return &TapCount{ + Category: TapCategory(t.Category), + LastOrder: t.LatestOrderCreatedAt.Time, + Count: int(t.Count), + } +} diff --git a/internal/database/repository/tap.go b/internal/database/repository/tap.go index 11adc4b..8eb30df 100644 --- a/internal/database/repository/tap.go +++ b/internal/database/repository/tap.go @@ -9,6 +9,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/zeusWPI/scc/internal/database/model" "github.com/zeusWPI/scc/pkg/sqlc" + "github.com/zeusWPI/scc/pkg/utils" ) type Tap struct { @@ -33,7 +34,7 @@ func (t *Tap) GetLast(ctx context.Context) (*model.Tap, error) { return model.TapModel(tap), nil } -func (t *Tap) GetCountByCategory(ctx context.Context) (model.TapCount, error) { +func (t *Tap) GetCountByCategory(ctx context.Context) ([]*model.TapCount, error) { counts, err := t.repo.queries(ctx).TapGetCountByCategory(ctx) if err != nil { if !errors.Is(err, sql.ErrNoRows) { @@ -42,12 +43,7 @@ func (t *Tap) GetCountByCategory(ctx context.Context) (model.TapCount, error) { return nil, nil } - tapCount := model.TapCount{} - for _, count := range counts { - tapCount[model.TapCategory(count.Category)] = int(count.Count) - } - - return tapCount, nil + return utils.SliceMap(counts, model.TapCountModel), nil } func (t *Tap) Create(ctx context.Context, tap *model.Tap) error { diff --git a/internal/server/server.go b/internal/server/server.go index adf154d..d087759 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -19,7 +19,7 @@ type Server struct { Addr string } -func NewServer(service service.Service, pool *pgxpool.Pool) *Server { +func New(service service.Service, pool *pgxpool.Pool) *Server { env := config.GetDefaultString("app.env", "development") // Construct app diff --git a/makefile b/makefile index 8ffaa2e..89bed3e 100644 --- a/makefile +++ b/makefile @@ -23,13 +23,27 @@ build-tui: clean-tui @echo "Building $(TUI_BIN)..." @go build -o $(TUI_BIN) $(TUI_SRC) -run-backend: +clean: clean-backend clean-tui + +clean-backend: + @if [ -f "$(BACKEND_BIN)" ]; then \ + echo "Cleaning $(BACKEND_BIN)..."; \ + rm -f "$(BACKEND_BIN)"; \ + fi + +clean-tui: + @if [ -f "$(TUI_BIN)" ]; then \ + echo "Cleaning $(TUI_BIN)..."; \ + rm -f "$(TUI_BIN)"; \ + fi + +backend: @docker compose up -d @[ -f $(BACKEND_BIN) ] || $(MAKE) build-backend @./$(BACKEND_BIN) @docker compose down -run-tui: +tui: @[ -f $(TUI_BIN) ] || $(MAKE) build-tui @read -p "Enter screen name: " screen; \ ./$(TUI_BIN) $$screen @@ -59,4 +73,4 @@ query: dead: @go tool deadcode ./... -.PHONY: all setup build build-backed build-tui run-backend run-tui sqlc create-migration goose migrate dead +.PHONY: all setup build build-backed build-tui clean clean-backend clean-tui backend tui create-migration goose query dead diff --git a/pkg/config/config.go b/pkg/config/config.go index 0432dda..f438c11 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -23,8 +23,8 @@ func Init() error { viper.AutomaticEnv() env := GetDefaultString("app.env", "development") - viper.SetConfigName(env + ".toml") - viper.SetConfigType("toml") + viper.SetConfigName(fmt.Sprintf("%s.yml", strings.ToLower(env))) + viper.SetConfigType("yaml") viper.AddConfigPath("./config") return viper.ReadInConfig() diff --git a/pkg/sqlc/event.sql.go b/pkg/sqlc/event.sql.go deleted file mode 100644 index 09e1751..0000000 --- a/pkg/sqlc/event.sql.go +++ /dev/null @@ -1,118 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 -// source: event.sql - -package sqlc - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const eventCreate = `-- name: EventCreate :one -INSERT INTO event (name, date, academic_year, location, poster) -VALUES ($1, $2, $3, $4, $5) -RETURNING id -` - -type EventCreateParams struct { - Name string - Date pgtype.Timestamptz - AcademicYear string - Location string - Poster []byte -} - -func (q *Queries) EventCreate(ctx context.Context, arg EventCreateParams) (int32, error) { - row := q.db.QueryRow(ctx, eventCreate, - arg.Name, - arg.Date, - arg.AcademicYear, - arg.Location, - arg.Poster, - ) - var id int32 - err := row.Scan(&id) - return id, err -} - -const eventDeleteAll = `-- name: EventDeleteAll :exec -DELETE FROM event -` - -func (q *Queries) EventDeleteAll(ctx context.Context) error { - _, err := q.db.Exec(ctx, eventDeleteAll) - return err -} - -const eventGetAllByCurrentYear = `-- name: EventGetAllByCurrentYear :many -SELECT id, name, date, academic_year, location, poster -FROM event -WHERE academic_year = ( - SELECT MAX(academic_year) - FROM event -) -ORDER BY date ASC -` - -func (q *Queries) EventGetAllByCurrentYear(ctx context.Context) ([]Event, error) { - rows, err := q.db.Query(ctx, eventGetAllByCurrentYear) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Event - for rows.Next() { - var i Event - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Date, - &i.AcademicYear, - &i.Location, - &i.Poster, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const eventGetAllByYear = `-- name: EventGetAllByYear :many -SELECT id, name, date, academic_year, location, poster -FROM event -WHERE academic_year = $1 -` - -func (q *Queries) EventGetAllByYear(ctx context.Context, academicYear string) ([]Event, error) { - rows, err := q.db.Query(ctx, eventGetAllByYear, academicYear) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Event - for rows.Next() { - var i Event - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Date, - &i.AcademicYear, - &i.Location, - &i.Poster, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/pkg/sqlc/gamification.sql.go b/pkg/sqlc/gamification.sql.go deleted file mode 100644 index d68b24e..0000000 --- a/pkg/sqlc/gamification.sql.go +++ /dev/null @@ -1,69 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 -// source: gamification.sql - -package sqlc - -import ( - "context" -) - -const gamificationCreate = `-- name: GamificationCreate :one -INSERT INTO gamification (name, score, avatar) -VALUES ($1, $2, $3) -RETURNING id -` - -type GamificationCreateParams struct { - Name string - Score int32 - Avatar []byte -} - -func (q *Queries) GamificationCreate(ctx context.Context, arg GamificationCreateParams) (int32, error) { - row := q.db.QueryRow(ctx, gamificationCreate, arg.Name, arg.Score, arg.Avatar) - var id int32 - err := row.Scan(&id) - return id, err -} - -const gamificationDeleteAll = `-- name: GamificationDeleteAll :exec -DELETE FROM gamification -` - -func (q *Queries) GamificationDeleteAll(ctx context.Context) error { - _, err := q.db.Exec(ctx, gamificationDeleteAll) - return err -} - -const gamificationGetAll = `-- name: GamificationGetAll :many -SELECT id, name, score, avatar -FROM gamification -ORDER BY score DESC -` - -func (q *Queries) GamificationGetAll(ctx context.Context) ([]Gamification, error) { - rows, err := q.db.Query(ctx, gamificationGetAll) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Gamification - for rows.Next() { - var i Gamification - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Score, - &i.Avatar, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/pkg/sqlc/models.go b/pkg/sqlc/models.go index 9f66a3f..d9b99c7 100644 --- a/pkg/sqlc/models.go +++ b/pkg/sqlc/models.go @@ -56,22 +56,6 @@ func (ns NullTapCategory) Value() (driver.Value, error) { return string(ns.TapCategory), nil } -type Event struct { - ID int32 - Name string - Date pgtype.Timestamptz - AcademicYear string - Location string - Poster []byte -} - -type Gamification struct { - ID int32 - Name string - Score int32 - Avatar []byte -} - type Message struct { ID int32 Name string diff --git a/pkg/sqlc/song.sql.go b/pkg/sqlc/song.sql.go index d248b12..440b07b 100644 --- a/pkg/sqlc/song.sql.go +++ b/pkg/sqlc/song.sql.go @@ -94,7 +94,7 @@ func (q *Queries) CreateSongArtistGenre(ctx context.Context, arg CreateSongArtis const createSongArtistSong = `-- name: CreateSongArtistSong :one INSERT INTO song_artist_song (artist_id, song_id) VALUES ($1, $2) -RETURNING id, artist_id, song_id +RETURNING id ` type CreateSongArtistSongParams struct { @@ -102,11 +102,11 @@ type CreateSongArtistSongParams struct { SongID int32 } -func (q *Queries) CreateSongArtistSong(ctx context.Context, arg CreateSongArtistSongParams) (SongArtistSong, error) { +func (q *Queries) CreateSongArtistSong(ctx context.Context, arg CreateSongArtistSongParams) (int32, error) { row := q.db.QueryRow(ctx, createSongArtistSong, arg.ArtistID, arg.SongID) - var i SongArtistSong - err := row.Scan(&i.ID, &i.ArtistID, &i.SongID) - return i, err + var id int32 + err := row.Scan(&id) + return id, err } const createSongGenre = `-- name: CreateSongGenre :one diff --git a/pkg/sqlc/tap.sql.go b/pkg/sqlc/tap.sql.go index 7a7e8e9..365d818 100644 --- a/pkg/sqlc/tap.sql.go +++ b/pkg/sqlc/tap.sql.go @@ -37,14 +37,15 @@ func (q *Queries) TapCreate(ctx context.Context, arg TapCreateParams) (int32, er } const tapGetCountByCategory = `-- name: TapGetCountByCategory :many -SELECT category, COUNT(*) +SELECT category, COUNT(*), MAX(order_created_at)::TIMESTAMP AS latest_order_created_at FROM tap GROUP BY category ` type TapGetCountByCategoryRow struct { - Category TapCategory - Count int64 + Category TapCategory + Count int64 + LatestOrderCreatedAt pgtype.Timestamp } func (q *Queries) TapGetCountByCategory(ctx context.Context) ([]TapGetCountByCategoryRow, error) { @@ -56,7 +57,7 @@ func (q *Queries) TapGetCountByCategory(ctx context.Context) ([]TapGetCountByCat var items []TapGetCountByCategoryRow for rows.Next() { var i TapGetCountByCategoryRow - if err := rows.Scan(&i.Category, &i.Count); err != nil { + if err := rows.Scan(&i.Category, &i.Count, &i.LatestOrderCreatedAt); err != nil { return nil, err } items = append(items, i) diff --git a/pkg/utils/http.go b/pkg/utils/http.go index 6c579e2..36df81c 100644 --- a/pkg/utils/http.go +++ b/pkg/utils/http.go @@ -29,9 +29,6 @@ func DoRequest(ctx context.Context, values DoRequestValues) (*http.Response, err return nil, fmt.Errorf("do http request %+v | %w", values, err) } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("invalid http request response %s | %+v | %w", resp.Status, values, err) - } - + // TODO: Update all references now it doesnt check the response code return resp, nil } diff --git a/pkg/utils/image.go b/pkg/utils/image.go new file mode 100644 index 0000000..d22d2fc --- /dev/null +++ b/pkg/utils/image.go @@ -0,0 +1,29 @@ +package utils + +import ( + "image" + "image/color" +) + +func ImageEqual(img1, img2 image.Image) bool { + if !img1.Bounds().Eq(img2.Bounds()) { + return false + } + + bounds := img1.Bounds() + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + if !colorsEqual(img1.At(x, y), img2.At(x, y)) { + return false + } + } + } + + return true +} + +func colorsEqual(c1, c2 color.Color) bool { + r1, g1, b1, a1 := c1.RGBA() + r2, g2, b2, a2 := c2.RGBA() + return r1 == r2 && g1 == g2 && b1 == b2 && a1 == a2 +} diff --git a/pkg/utils/time.go b/pkg/utils/time.go new file mode 100644 index 0000000..7babcc1 --- /dev/null +++ b/pkg/utils/time.go @@ -0,0 +1,7 @@ +package utils + +import "time" + +func SameDay(t1, t2 time.Time) bool { + return t1.Year() == t2.Year() && t1.Month() == t2.Month() && t1.Day() == t2.Day() +} diff --git a/tui/components/bar/bar.go b/tui/components/bar/bar.go index 66d1821..004348e 100644 --- a/tui/components/bar/bar.go +++ b/tui/components/bar/bar.go @@ -37,12 +37,13 @@ type Model struct { style lipgloss.Style } -// New creates a new progress +// Interface compliance +var _ tea.Model = (*Model)(nil) + func New(style lipgloss.Style) Model { return Model{id: nextID(), style: style} } -// Init initializes the progress component func (m Model) Init() tea.Cmd { return nil } @@ -61,7 +62,7 @@ func (m Model) Start(width int, runningTime time.Duration, duration time.Duratio } // Update handles the progress frame tick -func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case FrameMsg: if msg.id != m.id { @@ -88,7 +89,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { return m, nil } -// View of the progress bar component func (m Model) View() string { b := strings.Repeat("▄", m.width/2) if m.width%2 == 1 { diff --git a/tui/components/stopwatch/stopwatch.go b/tui/components/stopwatch/stopwatch.go index 0d47840..5cb90e9 100644 --- a/tui/components/stopwatch/stopwatch.go +++ b/tui/components/stopwatch/stopwatch.go @@ -29,8 +29,7 @@ type StartStopMsg struct { } // ResetMsg is a message that resets the stopwatch -type ResetMsg struct { -} +type ResetMsg struct{} // Model for the stopwatch component type Model struct { @@ -39,7 +38,9 @@ type Model struct { running bool } -// New creates a new stopwatch with a given interval +// Interface compliance +var _ tea.Model = (*Model)(nil) + func New() Model { return Model{ id: nextID(), @@ -48,7 +49,6 @@ func New() Model { } } -// Init initializes the stopwatch component func (m Model) Init() tea.Cmd { return nil } @@ -75,7 +75,7 @@ func (m Model) Reset() tea.Cmd { } // Update handles the stopwatch tick -func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case TickMsg: if msg.id != m.id || !m.running { @@ -106,7 +106,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { return m, nil } -// View of the stopwatch component func (m Model) View() string { duration := m.duration.Round(time.Second) diff --git a/tui/screen/cammie/cammie.go b/tui/screen/cammie/cammie.go index dd7fa35..5584f43 100644 --- a/tui/screen/cammie/cammie.go +++ b/tui/screen/cammie/cammie.go @@ -7,7 +7,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/database/repository" "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/tui/screen" "github.com/zeusWPI/scc/tui/view" @@ -18,33 +18,49 @@ import ( "github.com/zeusWPI/scc/tui/view/zess" ) -// Cammie represents the cammie screen type Cammie struct { - db *db.DB messages view.View top []view.View bottom view.View - indexTop int - width int - height int + + topIdx int // Index of the cycled top views + width int + height int } -// Message to update the bottomIndex -type msgIndex struct { - indexBottom int +// Interface compliance +var _ screen.Screen = (*Cammie)(nil) + +// Msg is the message to update the topIndex +type Msg struct { + topIdx int } -// New creates a new cammie screen -func New(db *db.DB) screen.Screen { - messages := message.NewModel(db) - top := event.NewModel(db) - bottom := []view.View{gamification.NewModel(db), tap.NewModel(db), zess.NewModel(db)} - return &Cammie{db: db, messages: messages, bottom: top, top: bottom, indexTop: 0, width: 0, height: 0} +// Interface compliance +var _ tea.Msg = (*Msg)(nil) + +func New(repo repository.Repository) screen.Screen { + messages := message.NewModel(repo) + bottom := event.NewModel() + top := []view.View{ + gamification.NewModel(), + tap.NewModel(repo), + zess.NewModel(repo), + } + + return &Cammie{ + messages: messages, + top: top, + bottom: bottom, + + topIdx: 0, + width: 0, + height: 0, + } } -// Init initializes the cammie screen func (c *Cammie) Init() tea.Cmd { - cmds := []tea.Cmd{updateBottomIndex(*c), c.messages.Init(), c.bottom.Init()} + cmds := []tea.Cmd{updateTopIndex(*c), c.messages.Init(), c.bottom.Init()} for _, view := range c.top { cmds = append(cmds, view.Init()) } @@ -52,7 +68,6 @@ func (c *Cammie) Init() tea.Cmd { return tea.Batch(cmds...) } -// Update updates the cammie screen func (c *Cammie) Update(msg tea.Msg) (screen.Screen, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -64,10 +79,10 @@ func (c *Cammie) Update(msg tea.Msg) (screen.Screen, tea.Cmd) { sBottom = sBottom.Width(c.width/2 - sBottom.GetHorizontalFrameSize()).Height(c.height/2 - sBottom.GetVerticalFrameSize()) return c, c.GetSizeMsg - case msgIndex: - c.indexTop = msg.indexBottom + case Msg: + c.topIdx = msg.topIdx - return c, updateBottomIndex(*c) + return c, updateTopIndex(*c) } cmds := make([]tea.Cmd, 0) @@ -87,7 +102,6 @@ func (c *Cammie) Update(msg tea.Msg) (screen.Screen, tea.Cmd) { return c, tea.Batch(cmds...) } -// View returns the cammie screen view func (c *Cammie) View() string { if c.width == 0 || c.height == 0 { return "Initialzing..." @@ -100,7 +114,7 @@ func (c *Cammie) View() string { // Render tabs var tabs []string for i, view := range c.top { - if i == c.indexTop { + if i == c.topIdx { tabs = append(tabs, sActiveTab.Render(view.Name())) } else { tabs = append(tabs, sTabNormal.Render(view.Name())) @@ -111,7 +125,7 @@ func (c *Cammie) View() string { tab = lipgloss.JoinHorizontal(lipgloss.Bottom, tab, tabLine) // Render top view - top := lipgloss.JoinVertical(lipgloss.Left, tab, c.top[c.indexTop].View()) + top := lipgloss.JoinVertical(lipgloss.Left, tab, c.top[c.topIdx].View()) top = sTop.Render(top) // Render bottom @@ -154,11 +168,11 @@ func (c *Cammie) GetSizeMsg() tea.Msg { return view.MsgSize{Sizes: sizes} } -func updateBottomIndex(cammie Cammie) tea.Cmd { +func updateTopIndex(cammie Cammie) tea.Cmd { timeout := time.Duration(config.GetDefaultInt("tui.screen.cammie.interval_s", 300) * int(time.Second)) return tea.Tick(timeout, func(_ time.Time) tea.Msg { - newIndex := (cammie.indexTop + 1) % len(cammie.top) + newIndex := (cammie.topIdx + 1) % len(cammie.top) - return msgIndex{indexBottom: newIndex} + return Msg{topIdx: newIndex} }) } diff --git a/tui/screen/screen.go b/tui/screen/screen.go index b6c0a37..cb555c6 100644 --- a/tui/screen/screen.go +++ b/tui/screen/screen.go @@ -1,4 +1,4 @@ -// Package screen provides difference screens for the tui +// Package screen contains the interface for a screen package screen import ( @@ -6,7 +6,6 @@ import ( "github.com/zeusWPI/scc/tui/view" ) -// Screen represents a screen type Screen interface { Init() tea.Cmd Update(tea.Msg) (Screen, tea.Cmd) diff --git a/tui/screen/song/song.go b/tui/screen/song/song.go index 8e6608b..b75c353 100644 --- a/tui/screen/song/song.go +++ b/tui/screen/song/song.go @@ -3,7 +3,7 @@ package song import ( tea "github.com/charmbracelet/bubbletea" - "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/database/repository" "github.com/zeusWPI/scc/tui/screen" "github.com/zeusWPI/scc/tui/view" "github.com/zeusWPI/scc/tui/view/song" @@ -11,7 +11,6 @@ import ( // Song represents the song screen type Song struct { - db *db.DB song view.View width int @@ -19,8 +18,12 @@ type Song struct { } // New creates a new song screen -func New(db *db.DB) screen.Screen { - return &Song{db: db, song: song.New(db), width: 0, height: 0} +func New(repo repository.Repository) screen.Screen { + return &Song{ + song: song.New(repo), + width: 0, + height: 0, + } } // Init initializes the song screen diff --git a/tui/tui.go b/tui/tui.go index eb7008e..048cf40 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -38,7 +38,7 @@ func (t *TUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyCtrlC: zap.S().Info("Exiting") - // cmds = append(cmds, tea.ExitAltScreen) + cmds = append(cmds, tea.ExitAltScreen) cmds = append(cmds, tea.Quit) } } diff --git a/tui/view/event/event.go b/tui/view/event/event.go index 199178b..4b6a973 100644 --- a/tui/view/event/event.go +++ b/tui/view/event/event.go @@ -2,6 +2,7 @@ package event import ( + "image" "slices" "time" @@ -28,8 +29,14 @@ type Msg struct { events []event } -// Interface compliance -var _ tea.Msg = (*Msg)(nil) +// event is the internal representation of a zeus event +type event struct { + ID int `json:"id"` + Name string `json:"name"` + Location string `json:"location"` + Start time.Time `json:"start_time"` + poster image.Image +} func NewModel() view.View { return &Model{ @@ -53,8 +60,7 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { case view.MsgSize: // Size update! // Check if it's relevant for this view - entry, ok := msg.Sizes[m.Name()] - if ok { + if entry, ok := msg.Sizes[m.Name()]; ok { // Update all dependent styles m.width = entry.Width m.height = entry.Height diff --git a/tui/view/event/update.go b/tui/view/event/update.go index b39b3e6..2e87403 100644 --- a/tui/view/event/update.go +++ b/tui/view/event/update.go @@ -6,26 +6,18 @@ import ( "encoding/json" "errors" "fmt" + "image" "io" "slices" "sync" - "time" tea "github.com/charmbracelet/bubbletea" "github.com/zeusWPI/scc/pkg/utils" "github.com/zeusWPI/scc/tui/view" ) -type event struct { - ID int `json:"id"` - Name string `json:"name"` - Location string `json:"location"` - Start time.Time `json:"start_time"` - Poster []byte -} - func (e event) equal(e2 event) bool { - return e.ID == e2.ID && e.Name == e2.Name && e.Location == e2.Location && e.Start.Equal(e2.Start) && bytes.Equal(e.Poster, e2.Poster) + return e.ID == e2.ID && e.Name == e2.Name && e.Location == e2.Location && e.Start.Equal(e2.Start) && utils.ImageEqual(e.poster, e2.poster) } func updateEvents(ctx context.Context, view view.View) (tea.Msg, error) { @@ -36,6 +28,8 @@ func updateEvents(ctx context.Context, view view.View) (tea.Msg, error) { return nil, err } + slices.SortFunc(events, func(a, b event) int { return a.Start.Compare(b.Start) }) + if len(events) != len(m.events) { return Msg{events: events}, nil } @@ -116,12 +110,17 @@ func getPoster(ctx context.Context, url string, event *event) error { return fmt.Errorf("bad response code for event poster %s | %+v", resp.Status, *event) } - bytes, err := io.ReadAll(resp.Body) + posterBytes, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("read poster bytes %+v | %w", *event, err) } - event.Poster = bytes + poster, _, err := image.Decode(bytes.NewReader(posterBytes)) + if err != nil { + return fmt.Errorf("decode poster for event %+v | %w", *event, err) + } + + event.poster = poster return nil } diff --git a/tui/view/event/view.go b/tui/view/event/view.go index 3a4053a..7b5cc60 100644 --- a/tui/view/event/view.go +++ b/tui/view/event/view.go @@ -1,8 +1,6 @@ package event import ( - "bytes" - "image" "time" "github.com/charmbracelet/lipgloss" @@ -18,11 +16,8 @@ func (m *Model) viewToday() string { // Render image poster := "" - if today.Poster != nil { - i, _, err := image.Decode(bytes.NewReader(today.Poster)) - if err == nil { - poster = view.ImageToString(i, wTodayPoster, 0) - } + if today.poster != nil { + poster = view.ImageToString(today.poster, wTodayPoster, 0) } name := sTodayText.Render(today.Name) @@ -50,11 +45,8 @@ func (m *Model) viewOverview() string { // Poster if present poster := "" - if len(upcoming) > 0 && upcoming[0].Poster != nil { - i, _, err := image.Decode(bytes.NewReader(upcoming[0].Poster)) - if err == nil { - poster = view.ImageToString(i, wOvPoster, 0) - } + if len(upcoming) > 0 && upcoming[0].poster != nil { + poster = view.ImageToString(upcoming[0].poster, wOvPoster, 0) } // Overview diff --git a/tui/view/gamification/gamification.go b/tui/view/gamification/gamification.go index 63bc3dd..cd566b7 100644 --- a/tui/view/gamification/gamification.go +++ b/tui/view/gamification/gamification.go @@ -29,9 +29,6 @@ type Msg struct { leaderboard []gamification } -// Interface Compliance -var _ tea.Msg = (*Msg)(nil) - type gamification struct { Name string `json:"github_name"` Score int `json:"score"` @@ -61,8 +58,7 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { case view.MsgSize: // Size update! // Check if it's relevant for this view - entry, ok := msg.Sizes[m.Name()] - if ok { + if entry, ok := msg.Sizes[m.Name()]; ok { // Update all dependent styles m.width = entry.Width m.height = entry.Height diff --git a/tui/view/message/message.go b/tui/view/message/message.go index 15e60f2..052a2b2 100644 --- a/tui/view/message/message.go +++ b/tui/view/message/message.go @@ -7,20 +7,28 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" - "github.com/jackc/pgx/v5" - "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/database/repository" "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/tui/view" ) // Model represents the model for the message view type Model struct { - db *db.DB - lastMessageID int32 - messages []message + repo repository.Message + messages []message + + lastMessageID int + width int + height int +} + +// Interface compliance +var _ view.View = (*Model)(nil) - width int - height int +// Msg represents the message to update the message view +type Msg struct { + lastMessageID int + messages []message } type message struct { @@ -30,33 +38,31 @@ type message struct { date time.Time } -// Msg represents the message to update the message view -type Msg struct { - lastMessageID int32 - messages []message -} - -// NewModel creates a new message model view -func NewModel(db *db.DB) view.View { - return &Model{db: db, lastMessageID: -1, messages: []message{}} +func NewModel(repo repository.Repository) view.View { + return &Model{ + repo: *repo.NewMessage(), + messages: nil, + lastMessageID: -1, + width: 0, + height: 0, + } } -// Init initializes the message model view func (m *Model) Init() tea.Cmd { return nil } -// Name returns the name of the view func (m *Model) Name() string { return "Cammie Messages" } -// Update updates the message model view func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { case view.MsgSize: - entry, ok := msg.Sizes[m.Name()] - if ok { + // Size update! + // Check if it's relevant for this view + if entry, ok := msg.Sizes[m.Name()]; ok { + // Update all dependent styles m.width = entry.Width m.height = entry.Height } @@ -93,15 +99,12 @@ func (m *Model) GetUpdateDatas() []view.UpdateData { } } -func updateMessages(view view.View) (tea.Msg, error) { +func updateMessages(ctx context.Context, view view.View) (tea.Msg, error) { m := view.(*Model) lastMessageID := m.lastMessageID - messagesDB, err := m.db.Queries.GetMessageSinceID(context.Background(), lastMessageID) + messagesDB, err := m.repo.GetSinceID(ctx, lastMessageID) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - err = nil - } return nil, err } @@ -120,7 +123,7 @@ func updateMessages(view view.View) (tea.Msg, error) { sender: m.Name, message: m.Message, color: hashColor(m.Name), - date: m.CreatedAt.Time, + date: m.CreatedAt, }) } diff --git a/tui/view/song/song.go b/tui/view/song/song.go index 750ab68..90c5823 100644 --- a/tui/view/song/song.go +++ b/tui/view/song/song.go @@ -3,6 +3,7 @@ package song import ( "context" + "errors" "time" tea "github.com/charmbracelet/bubbletea" diff --git a/tui/view/tap/tap.go b/tui/view/tap/tap.go index 19ec405..4a7049f 100644 --- a/tui/view/tap/tap.go +++ b/tui/view/tap/tap.go @@ -2,79 +2,63 @@ package tap import ( - "context" - "slices" - "time" - tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/jackc/pgx/v5" - "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/internal/database/repository" "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/tui/view" ) -type category string - -const ( - mate category = "Mate" - soft category = "Soft" - beer category = "Beer" - food category = "Food" -) - -var categoryToStyle = map[category]lipgloss.Style{ - mate: sMate, - soft: sSoft, - beer: sBeer, - food: sFood, +var categoryToStyle = map[model.TapCategory]lipgloss.Style{ + model.Mate: sMate, + model.Soft: sSoft, + model.Beer: sBeer, + model.Food: sFood, } -// Model represents the tap model type Model struct { - db *db.DB - lastOrderID int32 - items []tapItem + repo repository.Tap + lastOrderID int + items []model.TapCount width int height int } +// Interface compliance +var _ view.View = (*Model)(nil) + // Msg represents a tap message type Msg struct { - lastOrderID int32 - items []tapItem -} - -type tapItem struct { - category category - amount int - last time.Time + lastOrderID int + items []model.TapCount } -// NewModel creates a new tap model -func NewModel(db *db.DB) view.View { - return &Model{db: db, lastOrderID: -1} +func NewModel(repo repository.Repository) view.View { + return &Model{ + repo: *repo.NewTap(), + lastOrderID: -1, + items: nil, + width: 0, + height: 0, + } } -// Init initializes the tap model func (m *Model) Init() tea.Cmd { return nil } -// Name returns the name of the view func (m *Model) Name() string { return "Tap" } -// Update updates the tap model func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { case view.MsgSize: // Size update! // Check if it's relevant for this view - entry, ok := msg.Sizes[m.Name()] - if ok { + if entry, ok := msg.Sizes[m.Name()]; ok { // Update all dependent styles m.width = entry.Width m.height = entry.Height @@ -86,29 +70,7 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { case Msg: m.lastOrderID = msg.lastOrderID - - for _, msgItem := range msg.items { - found := false - for i, item := range m.items { - if item.category == msgItem.category { - m.items[i].amount += msgItem.amount - m.items[i].last = msgItem.last - found = true - break - } - } - - if !found { - m.items = append(m.items, msgItem) - } - } - - // Sort to display bars in order - slices.SortFunc(m.items, func(i, j tapItem) int { - return j.amount - i.amount - }) - - return m, nil + m.items = msg.items } return m, nil @@ -135,49 +97,3 @@ func (m *Model) GetUpdateDatas() []view.UpdateData { }, } } - -func updateOrders(view view.View) (tea.Msg, error) { - m := view.(*Model) - lastOrderID := m.lastOrderID - - order, err := m.db.Queries.GetLastOrderByOrderID(context.Background()) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - err = nil - } - return nil, err - } - - if order.OrderID <= lastOrderID { - return nil, nil - } - - orders, err := m.db.Queries.GetOrderCountByCategorySinceOrderID(context.Background(), lastOrderID) - if err != nil { - return nil, err - } - - counts := make(map[category]tapItem) - - for _, order := range orders { - if entry, ok := counts[category(order.Category)]; ok { - entry.amount += int(order.Count) - counts[category(order.Category)] = entry - continue - } - - counts[category(order.Category)] = tapItem{ - category: category(order.Category), - amount: int(order.Count), - last: order.LatestOrderCreatedAt.Time, - } - } - - items := make([]tapItem, 0, len(counts)) - - for _, v := range counts { - items = append(items, v) - } - - return Msg{lastOrderID: order.OrderID, items: items}, nil -} diff --git a/tui/view/tap/update.go b/tui/view/tap/update.go new file mode 100644 index 0000000..1c0d51c --- /dev/null +++ b/tui/view/tap/update.go @@ -0,0 +1,33 @@ +package tap + +import ( + "context" + + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/pkg/utils" + "github.com/zeusWPI/scc/tui/view" +) + +func updateOrders(ctx context.Context, view view.View) (tea.Msg, error) { + m := view.(*Model) + + lastOrder, err := m.repo.GetLast(ctx) + if err != nil { + return nil, err + } + if lastOrder == nil { + lastOrder = &model.Tap{OrderID: -1} + } + + if lastOrder.OrderID == m.lastOrderID { + return nil, nil + } + + counts, err := m.repo.GetCountByCategory(ctx) + if err != nil { + return nil, nil + } + + return Msg{items: utils.SliceDereference(counts)}, nil +} diff --git a/tui/view/tap/view.go b/tui/view/tap/view.go index d5236f8..2d47fe8 100644 --- a/tui/view/tap/view.go +++ b/tui/view/tap/view.go @@ -12,16 +12,16 @@ func (m *Model) viewChart() string { bars := make([]barchart.BarData, 0, len(m.items)) for _, item := range m.items { - style, ok := categoryToStyle[item.category] + style, ok := categoryToStyle[item.Category] if !ok { continue } bars = append(bars, barchart.BarData{ - Label: sBarLabel.Render(string(item.category)), + Label: sBarLabel.Render(string(item.Category)), Values: []barchart.BarValue{{ - Name: string(item.category), - Value: float64(item.amount), + Name: string(item.Category), + Value: float64(item.Count), Style: style.Inherit(sBarOne), }}, }) @@ -37,9 +37,9 @@ func (m *Model) viewStats() string { rows := make([]string, 0, len(m.items)) for _, item := range m.items { - amount := sStatAmount.Render(strconv.Itoa(item.amount)) - category := sStatCategory.Inherit(categoryToStyle[item.category]).Render(string(item.category)) - last := sStatLast.Render(item.last.Format("15:04 02/01")) + amount := sStatAmount.Render(strconv.Itoa(item.Count)) + category := sStatCategory.Inherit(categoryToStyle[item.Category]).Render(string(item.Category)) + last := sStatLast.Render(item.LastOrder.Format("15:04 02/01")) text := lipgloss.JoinHorizontal(lipgloss.Top, amount, category, last) rows = append(rows, text) diff --git a/tui/view/util.go b/tui/view/util.go index 3b78a04..fc0431b 100644 --- a/tui/view/util.go +++ b/tui/view/util.go @@ -35,7 +35,7 @@ func imageToString(img image.Image) string { str.WriteString(" ") } - for x := 0; x < imageWidth; x++ { + for x := range imageWidth { c1, _ := colorful.MakeColor(img.At(x, heightCounter)) color1 := lipgloss.Color(c1.Hex()) c2, _ := colorful.MakeColor(img.At(x, heightCounter+1)) diff --git a/tui/view/view.go b/tui/view/view.go index fa57bcc..322d3fc 100644 --- a/tui/view/view.go +++ b/tui/view/view.go @@ -2,6 +2,8 @@ package view import ( + "context" + tea "github.com/charmbracelet/bubbletea" ) @@ -9,7 +11,7 @@ import ( type UpdateData struct { Name string View View - Update func(view View) (tea.Msg, error) + Update func(ctx context.Context, view View) (tea.Msg, error) Interval int } diff --git a/tui/view/zess/zess.go b/tui/view/zess/zess.go index d180fa0..42a22b9 100644 --- a/tui/view/zess/zess.go +++ b/tui/view/zess/zess.go @@ -3,6 +3,7 @@ package zess import ( "context" + "errors" "math/rand/v2" tea "github.com/charmbracelet/bubbletea" @@ -288,6 +289,7 @@ func updateSeason(view view.View) (tea.Msg, error) { func (z *yearWeek) equal(z2 yearWeek) bool { return z.week == z2.week && z.year == z2.year } + func (z *yearWeek) after(z2 yearWeek) bool { if z.year > z2.year { return true From c7b586923191a98bb2ec1f5fcac27d90726bbd78 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sun, 24 Aug 2025 12:20:44 +0200 Subject: [PATCH 06/15] refactor(zess): simplify calculations --- config/development.yml | 3 +- db/queries/scan.sql | 7 + internal/database/repository/scan.go | 13 ++ pkg/sqlc/scan.sql.go | 28 +++ pkg/utils/slice.go | 10 ++ tui/view/zess/update.go | 81 +++++++++ tui/view/zess/view.go | 33 ++-- tui/view/zess/zess.go | 249 +++------------------------ 8 files changed, 184 insertions(+), 240 deletions(-) create mode 100644 tui/view/zess/update.go diff --git a/config/development.yml b/config/development.yml index 5e917a5..949f95e 100644 --- a/config/development.yml +++ b/config/development.yml @@ -103,5 +103,4 @@ tui: zess: weeks: 10 - interval_scan_s: 60 - interval_season_s: 3600 + interval_s: 60 diff --git a/db/queries/scan.sql b/db/queries/scan.sql index c91e92f..3a37aa9 100644 --- a/db/queries/scan.sql +++ b/db/queries/scan.sql @@ -10,6 +10,13 @@ FROM scan WHERE id > $1 ORDER BY scan_id, scan_time ASC; +-- name: ScanGetInSeason :many +SELECT sc.* +FROM scan sc +LEFT JOIN season se ON se.start <= sc.scan_time AND sc.scan_time < se.end +WHERE se.id = $1 +ORDER BY sc.scan_time ASC; + -- name: ScanCreate :one INSERT INTO scan (scan_id, scan_time) VALUES ($1, $2) diff --git a/internal/database/repository/scan.go b/internal/database/repository/scan.go index 4deb12d..d27e5f8 100644 --- a/internal/database/repository/scan.go +++ b/internal/database/repository/scan.go @@ -22,6 +22,7 @@ func (r *Repository) NewScan() *Scan { } } +// TODO: I think unused. Veryify with g + r and make dead func (s *Scan) GetLast(ctx context.Context) (*model.Scan, error) { scan, err := s.repo.queries(ctx).ScanGetLast(ctx) if err != nil { @@ -46,6 +47,18 @@ func (s *Scan) GetAllSinceID(ctx context.Context, id int) ([]*model.Scan, error) return utils.SliceMap(scans, model.ScanModel), nil } +func (s *Scan) GetInSeason(ctx context.Context, season model.Season) ([]*model.Scan, error) { + scans, err := s.repo.queries(ctx).ScanGetInSeason(ctx, int32(season.ID)) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get scans in season %+v | %w", season, err) + } + return nil, nil + } + + return utils.SliceMap(scans, model.ScanModel), nil +} + func (s *Scan) Create(ctx context.Context, scan *model.Scan) error { id, err := s.repo.queries(ctx).ScanCreate(ctx, sqlc.ScanCreateParams{ ScanID: int32(scan.ScanID), diff --git a/pkg/sqlc/scan.sql.go b/pkg/sqlc/scan.sql.go index 477baa4..71d96f9 100644 --- a/pkg/sqlc/scan.sql.go +++ b/pkg/sqlc/scan.sql.go @@ -56,6 +56,34 @@ func (q *Queries) ScanGetAllSinceID(ctx context.Context, id int32) ([]Scan, erro return items, nil } +const scanGetInSeason = `-- name: ScanGetInSeason :many +SELECT sc.id, sc.scan_time, sc.scan_id +FROM scan sc +LEFT JOIN season se ON se.start <= sc.scan_time AND sc.scan_time < se.end +WHERE se.id = $1 +ORDER BY sc.scan_time ASC +` + +func (q *Queries) ScanGetInSeason(ctx context.Context, id int32) ([]Scan, error) { + rows, err := q.db.Query(ctx, scanGetInSeason, id) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Scan + for rows.Next() { + var i Scan + if err := rows.Scan(&i.ID, &i.ScanTime, &i.ScanID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const scanGetLast = `-- name: ScanGetLast :one SELECT id, scan_time, scan_id FROM scan diff --git a/pkg/utils/slice.go b/pkg/utils/slice.go index 82d52ed..4887dc5 100644 --- a/pkg/utils/slice.go +++ b/pkg/utils/slice.go @@ -129,3 +129,13 @@ func SliceSanitize[T comparable](slice []T) []T { return sanitized } + +// Reduce applies a combining function to each element of a slice, +// accumulating a single result. +func Reduce[T any, U any](slice []T, combine func(U, T) U) U { + var accum U + for _, v := range slice { + accum = combine(accum, v) + } + return accum +} diff --git a/tui/view/zess/update.go b/tui/view/zess/update.go new file mode 100644 index 0000000..a69d4ab --- /dev/null +++ b/tui/view/zess/update.go @@ -0,0 +1,81 @@ +package zess + +import ( + "context" + "slices" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/pkg/utils" + "github.com/zeusWPI/scc/tui/view" +) + +func (w week) equal(w2 week) bool { + return w.scans == w2.scans && w.start.Equal(w2.start) +} + +func updateWeeks(ctx context.Context, view view.View) (tea.Msg, error) { + m := view.(*Model) + + season, err := m.repoSeason.GetCurrent(ctx) + if err != nil { + return nil, err + } + if season == nil { + return nil, nil + } + + scans, err := m.repoScan.GetInSeason(ctx, *season) + if err != nil { + return nil, err + } + if scans == nil { + return nil, nil + } + slices.SortFunc(scans, func(a, b *model.Scan) int { return a.ScanTime.Compare(b.ScanTime) }) + + weekMap := make(map[time.Time]week) + + for _, scan := range scans { + start := getStartOfWeek(scan.ScanTime) + + entry, ok := weekMap[start] + if !ok { + entry = week{ + start: start, + scans: 0, + } + } + + entry.scans++ + weekMap[start] = entry + } + + weeks := utils.MapValues(weekMap) + + if len(weeks) != len(m.weeks) { + return Msg{weeks: weeks}, nil + } + + for idx, week := range weeks { + if !week.equal(m.weeks[idx]) { + return Msg{weeks: weeks}, nil + } + } + + return nil, nil +} + +func getStartOfWeek(t time.Time) time.Time { + weekDay := int(t.Weekday()) + + if weekDay == 0 { + weekDay = 7 + } + + return time.Date( + t.Year(), t.Month(), t.Day()-weekDay+1, + 0, 0, 0, 0, t.Location(), + ) +} diff --git a/tui/view/zess/view.go b/tui/view/zess/view.go index 5b2f811..523f5fc 100644 --- a/tui/view/zess/view.go +++ b/tui/view/zess/view.go @@ -2,22 +2,26 @@ package zess import ( "fmt" + "slices" "strconv" "github.com/NimbleMarkets/ntcharts/barchart" "github.com/charmbracelet/lipgloss" + "github.com/zeusWPI/scc/pkg/utils" ) func (m *Model) viewChart() string { chart := barchart.New(sBar.GetWidth(), sBar.GetHeight(), barchart.WithNoAutoBarWidth(), barchart.WithBarGap(wBarGap), barchart.WithBarWidth(wBar)) - for _, scan := range m.scans { + for _, week := range m.weeks { + weekNumber, _ := week.start.ISOWeek() + bar := barchart.BarData{ - Label: sBarLabel.Render(fmt.Sprintf("W%d", scan.time.week)), + Label: sBarLabel.Render(fmt.Sprintf("W%02d", weekNumber)), Values: []barchart.BarValue{{ - Name: scan.start, - Value: float64(scan.amount), - Style: sBarOne.Foreground(lipgloss.Color(scan.color)), + Name: week.start.String(), + Value: float64(week.scans), + Style: sBarOne.Foreground(lipgloss.Color(randomColor())), }}, } @@ -31,19 +35,22 @@ func (m *Model) viewChart() string { func (m *Model) viewStats() string { // Overview of each week - rows := make([]string, 0, len(m.scans)) + rows := make([]string, 0, len(m.weeks)) + + maxScans := slices.MaxFunc(m.weeks, func(a, b week) int { return a.scans - b.scans }) - for _, scan := range m.scans { - week := sStatDate.Render(fmt.Sprintf("W%02d - %s", scan.time.week, scan.start)) + for _, week := range m.weeks { + weekNumber, _ := week.start.ISOWeek() + weekStr := sStatDate.Render(fmt.Sprintf("W%02d - %s", weekNumber, week.start.Format("01/02"))) var amount string - if scan.amount == m.maxWeekScans { - amount = sStatMax.Inherit(sStatAmount).Render(strconv.Itoa(int(scan.amount))) + if week.scans == maxScans.scans { + amount = sStatMax.Inherit(sStatAmount).Render(strconv.Itoa(int(week.scans))) } else { - amount = sStatAmount.Render(strconv.Itoa(int(scan.amount))) + amount = sStatAmount.Render(strconv.Itoa(int(week.scans))) } - text := lipgloss.JoinHorizontal(lipgloss.Top, week, amount) + text := lipgloss.JoinHorizontal(lipgloss.Top, weekStr, amount) rows = append(rows, text) } @@ -54,7 +61,7 @@ func (m *Model) viewStats() string { // Total scans total := sStatTotalTitle.Render("Total") - amount := sStatTotalAmount.Render(strconv.Itoa(int(m.seasonScans))) + amount := sStatTotalAmount.Render(strconv.Itoa(utils.Reduce(m.weeks, func(accum int, w week) int { return accum + w.scans }))) total = lipgloss.JoinHorizontal(lipgloss.Top, total, amount) total = sStatTotal.Render(total) diff --git a/tui/view/zess/zess.go b/tui/view/zess/zess.go index 42a22b9..eabf1de 100644 --- a/tui/view/zess/zess.go +++ b/tui/view/zess/zess.go @@ -2,104 +2,58 @@ package zess import ( - "context" - "errors" "math/rand/v2" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/jackc/pgx/v5" - "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/database/repository" "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/tui/view" - "go.uber.org/zap" ) -// yearWeek is used to represent a date by it's year and week -type yearWeek struct { - year int - week int -} - -type weekScan struct { - time yearWeek - amount int64 - start string // The date when the week starts - color string -} - -// Model represents the Model for the zess view type Model struct { - db *db.DB - lastScanID int32 - scans []weekScan // Scans per week - showWeeks int // Amount of weeks to show - maxWeekScans int64 - currentSeason yearWeek // Start week of the season - seasonScans int64 + repoSeason repository.Season + repoScan repository.Scan + weeks []week width int height int } -// Msg is the base message to indicate that something changed in the zess view -type Msg struct{} +// Interface compliance +var _ view.View = (*Model)(nil) -// scanMsg is used to indicate that the zess view should be updated with new scans -type scanMsg struct { - Msg - lastScanID int32 - scans []weekScan +type Msg struct { + weeks []week } -// seasonMsg is used to indicate that the current season changed. -type seasonMsg struct { - Msg - start yearWeek +type week struct { + start time.Time // Start of the week + + scans int } -// NewModel creates a new zess model view -func NewModel(db *db.DB) view.View { +func NewModel(repo repository.Repository) view.View { m := &Model{ - db: db, - lastScanID: -1, - scans: make([]weekScan, 0), - showWeeks: config.GetDefaultInt("tui.view.zess.weeks", 10), - maxWeekScans: -1, - currentSeason: yearWeek{year: -1, week: -1}, - seasonScans: 0, - } - - // Populate with data - // The order in which this is called is important! - msgScans, err := updateScans(m) - if err != nil { - zap.S().Error("TUI: Unable to update zess scans\n", err) - return m + repoSeason: *repo.NewSeason(), + repoScan: *repo.NewScan(), + weeks: nil, + width: 0, + height: 0, } - _, _ = m.Update(msgScans) - - msgSeason, err := updateSeason(m) - if err != nil { - zap.S().Error("TUI: Unable to update zess seasons\n", err) - return m - } - _, _ = m.Update(msgSeason) return m } -// Init created a new zess model func (m *Model) Init() tea.Cmd { return nil } -// Name returns the name of the view func (m *Model) Name() string { return "Zess" } -// Update updates the zess model func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { case view.MsgSize: @@ -116,64 +70,8 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { return m, nil - // New scan(s) - case scanMsg: - m.lastScanID = msg.lastScanID - // Add new scans - for _, newScan := range msg.scans { - found := false - for i, modelScan := range m.scans { - if newScan.time.equal(modelScan.time) { - m.scans[i].amount++ - // Check for maxWeekScans - if m.scans[i].amount > m.maxWeekScans { - m.maxWeekScans = modelScan.amount - } - - found = true - break - } - } - - if !found { - m.scans = append(m.scans, newScan) - // Check for maxWeekScans - if newScan.amount > m.maxWeekScans { - m.maxWeekScans = newScan.amount - } - // Make sure the array doesn't get too big - if len(m.scans) > m.showWeeks { - m.scans = m.scans[:1] - } - } - - // Update seasonScans - m.seasonScans += newScan.amount - } - - // New season! - // Update variables accordinly - case seasonMsg: - m.currentSeason = msg.start - m.seasonScans = 0 - m.maxWeekScans = 0 - - validScans := make([]weekScan, 0, len(m.scans)) - - for _, scan := range m.scans { - // Add scans if they happend after (or in the same week of) the season start - if scan.time.equal(m.currentSeason) || scan.time.after(m.currentSeason) { - validScans = append(validScans, scan) - - if scan.amount > m.maxWeekScans { - m.maxWeekScans = scan.amount - } - - m.seasonScans += scan.amount - } - } - - m.scans = validScans + case Msg: + m.weeks = msg.weeks } return m, nil @@ -193,111 +91,12 @@ func (m *Model) View() string { func (m *Model) GetUpdateDatas() []view.UpdateData { return []view.UpdateData{ { - Name: "zess scans", + Name: "zess weeks", View: m, - Update: updateScans, - Interval: config.GetDefaultInt("tui.view.zess.interval_scan_s", 60), + Update: updateWeeks, + Interval: config.GetDefaultInt("tui.view.zess.interval_s", 60), }, - { - Name: "zess season", - View: m, - Update: updateSeason, - Interval: config.GetDefaultInt("tui.view.zess.interval_season_s", 3600), - }, - } -} - -// Check for any new scans -func updateScans(view view.View) (tea.Msg, error) { - m := view.(*Model) - lastScanID := m.lastScanID - - // Get new scans - scans, err := m.db.Queries.GetAllScansSinceID(context.Background(), lastScanID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - // No rows shouldn't be considered an error - err = nil - } - return nil, err - } - - // No new scans - if len(scans) == 0 { - return nil, nil - } - - zessScanMsg := scanMsg{lastScanID: lastScanID, scans: make([]weekScan, 0)} - - // Add new scans to scan msg - for _, newScan := range scans { - yearNumber, weekNumber := newScan.ScanTime.Time.ISOWeek() - newTime := yearWeek{year: yearNumber, week: weekNumber} - - found := false - for i, scan := range zessScanMsg.scans { - if scan.time.equal(newTime) { - zessScanMsg.scans[i].amount++ - found = true - break - } - } - - if !found { - zessScanMsg.scans = append(zessScanMsg.scans, weekScan{ - time: newTime, - amount: 1, - start: newScan.ScanTime.Time.Format("02/01"), - color: randomColor(), - }) - } - - // Update scan ID - // Not necessarly the first or last entry in the scans slice - if newScan.ID > zessScanMsg.lastScanID { - zessScanMsg.lastScanID = newScan.ID - } } - - return zessScanMsg, nil -} - -// Check if a new season started -func updateSeason(view view.View) (tea.Msg, error) { - m := view.(*Model) - - season, err := m.db.Queries.GetSeasonCurrent(context.Background()) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - // No rows shouldn't be considered an error - err = nil - } - return nil, err - } - - // Check if we have a new season - yearNumber, weekNumber := season.Start.Time.ISOWeek() - seasonStart := yearWeek{year: yearNumber, week: weekNumber} - if m.currentSeason.equal(seasonStart) { - // Same season - return nil, nil - } - - return seasonMsg{start: seasonStart}, nil -} - -func (z *yearWeek) equal(z2 yearWeek) bool { - return z.week == z2.week && z.year == z2.year -} - -func (z *yearWeek) after(z2 yearWeek) bool { - if z.year > z2.year { - return true - } else if z.year < z2.year { - return false - } - - return z.week > z2.week } func randomColor() string { From da1969310e72ebbbe7cd04d27918b9885715e112 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Fri, 29 Aug 2025 20:25:22 +0200 Subject: [PATCH 07/15] refactor(song): yeet --- cmd/backend/backend.go | 20 +- cmd/tui/tui.go | 3 +- config/development.yml | 5 - .../20250829172236_drop_song_table.sql | 57 ++ db/queries/song.sql | 140 ----- internal/cmd/song.go | 13 - internal/cmd/tap.go | 39 +- internal/cmd/tui.go | 59 +- internal/cmd/zess.go | 84 +-- internal/database/model/message.go | 2 +- internal/database/model/scan.go | 2 +- internal/database/model/season.go | 2 +- internal/database/model/song.go | 115 ---- internal/database/model/tap.go | 2 +- internal/database/repository/message.go | 2 +- internal/database/repository/repository.go | 2 +- internal/database/repository/scan.go | 2 +- internal/database/repository/season.go | 2 +- internal/database/repository/song.go | 97 ---- internal/database/repository/tap.go | 2 +- {pkg => internal/database}/sqlc/db.go | 0 .../database}/sqlc/message.sql.go | 0 {pkg => internal/database}/sqlc/models.go | 41 -- {pkg => internal/database}/sqlc/scan.sql.go | 0 {pkg => internal/database}/sqlc/season.sql.go | 0 {pkg => internal/database}/sqlc/tap.sql.go | 0 internal/lyrics/instrumental.go | 318 ----------- internal/lyrics/lrc.go | 127 ----- internal/lyrics/lyrics.go | 45 -- internal/lyrics/missing.go | 55 -- internal/lyrics/plain.go | 54 -- internal/server/server.go | 3 +- internal/server/service/message.go | 4 +- internal/server/service/service.go | 6 - internal/server/service/song.go | 4 +- internal/song/account.go | 38 -- internal/song/api.go | 158 ------ internal/song/song.go | 182 ------ internal/tap/api.go | 5 +- internal/zess/api.go | 4 +- pkg/config/config.go | 2 +- pkg/db/db.go | 2 +- pkg/db/psql.go | 2 +- pkg/sqlc/song.sql.go | 534 ------------------ pkg/utils/periodic.go | 40 ++ sqlc.yml | 2 +- tui/components/stopwatch/stopwatch.go | 6 +- tui/screen/song/song.go | 7 +- tui/tui.go | 5 + tui/view/event/update.go | 2 +- tui/view/gamification/gamification.go | 2 +- tui/view/gamification/update.go | 2 +- tui/view/song/song.go | 405 +------------ tui/view/song/style.go | 106 ---- tui/view/song/util.go | 13 - tui/view/song/view.go | 162 ------ tui/view/zess/view.go | 4 +- 57 files changed, 216 insertions(+), 2774 deletions(-) create mode 100644 db/migrations/20250829172236_drop_song_table.sql delete mode 100644 db/queries/song.sql delete mode 100644 internal/cmd/song.go delete mode 100644 internal/database/model/song.go delete mode 100644 internal/database/repository/song.go rename {pkg => internal/database}/sqlc/db.go (100%) rename {pkg => internal/database}/sqlc/message.sql.go (100%) rename {pkg => internal/database}/sqlc/models.go (74%) rename {pkg => internal/database}/sqlc/scan.sql.go (100%) rename {pkg => internal/database}/sqlc/season.sql.go (100%) rename {pkg => internal/database}/sqlc/tap.sql.go (100%) delete mode 100644 internal/lyrics/instrumental.go delete mode 100644 internal/lyrics/lrc.go delete mode 100644 internal/lyrics/lyrics.go delete mode 100644 internal/lyrics/missing.go delete mode 100644 internal/lyrics/plain.go delete mode 100644 internal/song/account.go delete mode 100644 internal/song/api.go delete mode 100644 internal/song/song.go delete mode 100644 pkg/sqlc/song.sql.go create mode 100644 pkg/utils/periodic.go delete mode 100644 tui/view/song/style.go delete mode 100644 tui/view/song/util.go delete mode 100644 tui/view/song/view.go diff --git a/cmd/backend/backend.go b/cmd/backend/backend.go index 05ea4e1..2ccf725 100644 --- a/cmd/backend/backend.go +++ b/cmd/backend/backend.go @@ -37,24 +37,26 @@ func main() { // Repository repo := repository.New(db) + var dones []chan bool + // Tap - _, _ = cmd.Tap(db) + _, done := cmd.Tap(*repo) + dones = append(dones, done) // Zess - _, _, _ = cmd.Zess(db) - - // Spotify - spotify, err := cmd.Song(db) - if err != nil { - zap.S().Error("Spotify: Initiating error, integration will not work.\n", err) - } + _, done = cmd.Zess(*repo) + dones = append(dones, done) // API service := service.New(*repo) - api := server.New(*service, db.Pool()) + api := server.New(*service) zap.S().Infof("Server is running on %s", api.Addr) if err := api.Listen(api.Addr); err != nil { zap.S().Fatalf("Failure while running the server %v", err) } + + for _, done := range dones { + done <- true + } } diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go index 16ea219..17bd457 100644 --- a/cmd/tui/tui.go +++ b/cmd/tui/tui.go @@ -3,7 +3,6 @@ package main import ( "flag" - "fmt" "github.com/zeusWPI/scc/internal/cmd" "github.com/zeusWPI/scc/internal/database/repository" @@ -29,7 +28,7 @@ func main() { } // Logger - zapLogger, err := logger.New(fmt.Sprintf("%s.log", *screen), false) + zapLogger, err := logger.New(*screen+".log", false) if err != nil { panic(err) } diff --git a/config/development.yml b/config/development.yml index 949f95e..417c376 100644 --- a/config/development.yml +++ b/config/development.yml @@ -49,11 +49,6 @@ backend: - "-l100" - "-d0" - song: - spotify_url: "https://api.spotify.com/v1" - spotify_url_account: "https://accounts.spotify.com/api/token" - lrclib_url: "https://lrclib.net/api" - tap: url: "https://tap.zeus.gent" beers: diff --git a/db/migrations/20250829172236_drop_song_table.sql b/db/migrations/20250829172236_drop_song_table.sql new file mode 100644 index 0000000..0803cc6 --- /dev/null +++ b/db/migrations/20250829172236_drop_song_table.sql @@ -0,0 +1,57 @@ +-- +goose Up +-- +goose StatementBegin +DROP TABLE song_history; +DROP TABLE song_artist; +DROP TABLE song_genre; +DROP TABLE song_artist_song; +DROP TABLE song_artist_genre; +DROP TABLE song; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +CREATE TABLE song ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + spotify_id TEXT NOT NULL, + duration_ms INTEGER NOT NULL, + album TEXT NOT NULL + lyrics_type TEXT, + lyrics TEXT +); + +CREATE TABLE song_history ( + id SERIAL PRIMARY KEY, + song_id INTEGER NOT NULL REFERENCES song (id), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT (NOW() - INTERVAL '1 second') +); + +CREATE TABLE song_genre ( + id SERIAL PRIMARY KEY, + genre TEXT NOT NULL +); + +CREATE TABLE song_artist ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + spotify_id TEXT NOT NULL, + followers INTEGER NOT NULL, + popularity INTEGER NOT NULL +); + +CREATE TABLE song_artist_song ( + id SERIAL PRIMARY KEY, + artist_id INTEGER NOT NULL, + song_id INTEGER NOT NULL, + FOREIGN KEY(artist_id) REFERENCES song_artist(id) ON DELETE CASCADE, + FOREIGN KEY(song_id) REFERENCES song(id) ON DELETE CASCADE +); + +CREATE TABLE song_artist_genre ( + id SERIAL PRIMARY KEY, + artist_id INTEGER NOT NULL, + genre_id INTEGER NOT NULL, + FOREIGN KEY(artist_id) REFERENCES song_artist(id) ON DELETE CASCADE, + FOREIGN KEY(genre_id) REFERENCES song_genre(id) ON DELETE CASCADE +); +-- +goose StatementEnd diff --git a/db/queries/song.sql b/db/queries/song.sql deleted file mode 100644 index c8a100c..0000000 --- a/db/queries/song.sql +++ /dev/null @@ -1,140 +0,0 @@ --- CRUD - --- name: CreateSong :one -INSERT INTO song (title, album, spotify_id, duration_ms, lyrics_type, lyrics) -VALUES ($1, $2, $3, $4, $5, $6) -RETURNING id; - --- name: CreateSongHistory :one -INSERT INTO song_history (song_id) -VALUES ($1) -RETURNING *; - --- name: CreateSongGenre :one -INSERT INTO song_genre (genre) -VALUES ($1) -RETURNING *; - --- name: CreateSongArtist :one -INSERT INTO song_artist (name, spotify_id, followers, popularity) -VALUES ($1, $2, $3, $4) -RETURNING *; - --- name: CreateSongArtistSong :one -INSERT INTO song_artist_song (artist_id, song_id) -VALUES ($1, $2) -RETURNING id; - --- name: CreateSongArtistGenre :one -INSERT INTO song_artist_genre (artist_id, genre_id) -VALUES ($1, $2) -RETURNING *; - - --- Other - --- name: GetSongBySpotifyID :one -SELECT * -FROM song -WHERE spotify_id = $1; - --- name: GetSongArtistBySpotifyID :one -SELECT * -FROM song_artist -WHERE spotify_id = $1; - --- name: GetLastSongHistory :one -SELECT * -FROM song_history -ORDER BY created_at DESC -LIMIT 1; - --- name: GetSongGenreByName :one -SELECT * -FROM song_genre -WHERE genre = $1; - --- name: GetLastSongFull :many -SELECT s.id, s.title AS song_title, s.spotify_id, s.album, s.duration_ms, s.lyrics_type, s.lyrics, sh.created_at, a.id AS artist_id, a.name AS artist_name, a.spotify_id AS artist_spotify_id, a.followers AS artist_followers, a.popularity AS artist_popularity, g.id AS genre_id, g.genre AS genre, sh.created_at -FROM song_history sh -JOIN song s ON sh.song_id = s.id -LEFT JOIN song_artist_song sa ON s.id = sa.song_id -LEFT JOIN song_artist a ON sa.artist_id = a.id -LEFT JOIN song_artist_genre ag ON ag.artist_id = a.id -LEFT JOIN song_genre g ON ag.genre_id = g.id -WHERE sh.created_at = (SELECT MAX(created_at) FROM song_history) -ORDER BY a.name, g.genre; - --- name: GetSongHistory :many -SELECT s.title, play_count, aggregated.created_at -FROM ( - SELECT sh.song_id, MAX(sh.created_at) AS created_at, COUNT(sh.song_id) AS play_count - FROM song_history sh - GROUP BY sh.song_id -) aggregated -JOIN song s ON aggregated.song_id = s.id -ORDER BY aggregated.created_at DESC -LIMIT 50; - --- name: GetTopSongs :many -SELECT s.id AS song_id, s.title, COUNT(sh.id) AS play_count -FROM song_history sh -JOIN song s ON sh.song_id = s.id -GROUP BY s.id, s.title -ORDER BY play_count DESC -LIMIT 10; - --- name: GetTopArtists :many -SELECT sa.id AS artist_id, sa.name AS artist_name, COUNT(sh.id) AS total_plays -FROM song_history sh -JOIN song s ON sh.song_id = s.id -JOIN song_artist_song sas ON s.id = sas.song_id -JOIN song_artist sa ON sas.artist_id = sa.id -GROUP BY sa.id, sa.name -ORDER BY total_plays DESC -LIMIT 10; - --- name: GetTopGenres :many -SELECT g.genre AS genre_name, COUNT(sh.id) AS total_plays -FROM song_history sh -JOIN song s ON sh.song_id = s.id -JOIN song_artist_song sas ON s.id = sas.song_id -JOIN song_artist sa ON sas.artist_id = sa.id -JOIN song_artist_genre sag ON sa.id = sag.artist_id -JOIN song_genre g ON sag.genre_id = g.id -GROUP BY g.genre -ORDER BY total_plays DESC -LIMIT 10; - --- name: GetTopMonthlySongs :many -SELECT s.id AS song_id, s.title, COUNT(sh.id) AS play_count -FROM song_history sh -JOIN song s ON sh.song_id = s.id -WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' -GROUP BY s.id, s.title -ORDER BY play_count DESC -LIMIT 10; - --- name: GetTopMonthlyArtists :many -SELECT sa.id AS artist_id, sa.name AS artist_name, COUNT(sh.id) AS total_plays -FROM song_history sh -JOIN song s ON sh.song_id = s.id -JOIN song_artist_song sas ON s.id = sas.song_id -JOIN song_artist sa ON sas.artist_id = sa.id -WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' -GROUP BY sa.id, sa.name -ORDER BY total_plays DESC -LIMIT 10; - --- name: GetTopMonthlyGenres :many -SELECT g.genre AS genre_name, COUNT(sh.id) AS total_plays -FROM song_history sh -JOIN song s ON sh.song_id = s.id -JOIN song_artist_song sas ON s.id = sas.song_id -JOIN song_artist sa ON sas.artist_id = sa.id -JOIN song_artist_genre sag ON sa.id = sag.artist_id -JOIN song_genre g ON sag.genre_id = g.id -WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' -GROUP BY g.genre -ORDER BY total_plays DESC -LIMIT 10; diff --git a/internal/cmd/song.go b/internal/cmd/song.go deleted file mode 100644 index 198d84c..0000000 --- a/internal/cmd/song.go +++ /dev/null @@ -1,13 +0,0 @@ -package cmd - -import ( - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/song" -) - -// Song starts the Song integration -func Song(db *db.DB) (*song.Song, error) { - song, err := song.New(db) - - return song, err -} diff --git a/internal/cmd/tap.go b/internal/cmd/tap.go index e160f67..f476f7b 100644 --- a/internal/cmd/tap.go +++ b/internal/cmd/tap.go @@ -1,50 +1,27 @@ package cmd import ( - "context" "time" "github.com/zeusWPI/scc/internal/database/repository" "github.com/zeusWPI/scc/internal/tap" "github.com/zeusWPI/scc/pkg/config" - "go.uber.org/zap" + "github.com/zeusWPI/scc/pkg/utils" ) // Tap starts the tap instance func Tap(repo repository.Repository) (*tap.Tap, chan bool) { tap := tap.New(repo) + done := make(chan bool) interval := config.GetDefaultInt("backend.tap.interval_s", 60) - go tapPeriodicUpdate(tap, done, interval) + go utils.Periodic( + "Tap", + time.Duration(interval)*time.Second, + tap.Update, + done, + ) return tap, done } - -// TODO: Figure out the context situation -func tapPeriodicUpdate(tap *tap.Tap, done chan bool, interval int) { - zap.S().Info("Tap: Starting periodic update with an interval of ", interval, " seconds") - - ticker := time.NewTicker(time.Duration(interval) * time.Second) - defer ticker.Stop() - - // Run immediatly once - zap.S().Info("Tap: Updating tap") - if err := tap.Update(context.Background()); err != nil { - zap.S().Error("Tap: Error updating tap\n", err) - } - - for { - select { - case <-done: - zap.S().Info("Tap: Stopping periodic update") - return - case <-ticker.C: - // Update tap - zap.S().Info("Tap: Updating tap") - if err := tap.Update(context.Background()); err != nil { - zap.S().Error("Tap: Error updating tap\n", err) - } - } - } -} diff --git a/internal/cmd/tui.go b/internal/cmd/tui.go index 98486e7..065542d 100644 --- a/internal/cmd/tui.go +++ b/internal/cmd/tui.go @@ -9,25 +9,23 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/zeusWPI/scc/internal/database/repository" - "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/pkg/utils" "github.com/zeusWPI/scc/tui" "github.com/zeusWPI/scc/tui/screen" "github.com/zeusWPI/scc/tui/screen/cammie" songScreen "github.com/zeusWPI/scc/tui/screen/song" "github.com/zeusWPI/scc/tui/view" - "go.uber.org/zap" ) -var screens = map[string]func(*db.DB) screen.Screen{ +var screens = map[string]func(repo repository.Repository) screen.Screen{ "cammie": cammie.New, "song": songScreen.New, } -// TUI starts the terminal user interface func TUI(repo repository.Repository, screenName string) error { val, ok := screens[screenName] if !ok { - return fmt.Errorf("Screen %s not found. Options are %v", screenName, maps.Keys(screens)) + return fmt.Errorf("screen %s not found. Options are %v", screenName, maps.Keys(screens)) } screen := val(repo) @@ -35,10 +33,8 @@ func TUI(repo repository.Repository, screenName string) error { p := tea.NewProgram(tui, tea.WithAltScreen()) dones := make([]chan bool, 0, len(screen.GetUpdateViews())) - for _, updateData := range screen.GetUpdateViews() { - done := make(chan bool) - dones = append(dones, done) - go tuiPeriodicUpdates(p, updateData, done) + for _, data := range screen.GetUpdateViews() { + dones = append(dones, periodicUpdate(p, data)) } _, err := p.Run() @@ -50,37 +46,28 @@ func TUI(repo repository.Repository, screenName string) error { return err } -func tuiPeriodicUpdates(p *tea.Program, updateData view.UpdateData, done chan bool) { - zap.S().Info("TUI: Starting periodic update for ", updateData.Name, " with an interval of ", updateData.Interval, " seconds") +func periodicUpdate(p *tea.Program, data view.UpdateData) chan bool { + done := make(chan bool) - ticker := time.NewTicker(time.Duration(updateData.Interval) * time.Second) - defer ticker.Stop() + update := func(ctx context.Context) error { + msg, err := data.Update(ctx, data.View) + if err != nil { + return err + } - // Immediatly update once - msg, err := updateData.Update(context.Background(), updateData.View) - if err != nil { - zap.S().Error("TUI: Error updating ", updateData.Name, "\n", err) - } + if msg != nil { + p.Send(msg) + } - if msg != nil { - p.Send(msg) + return nil } - for { - select { - case <-done: - zap.S().Info("TUI: Stopping periodic update for ", updateData.Name) - return - case <-ticker.C: - // Update - msg, err := updateData.Update(context.Background(), updateData.View) - if err != nil { - zap.S().Error("TUI: Error updating ", updateData.Name, "\n", err) - } + go utils.Periodic( + data.Name, + time.Duration(data.Interval)*time.Second, + update, + done, + ) - if msg != nil { - p.Send(msg) - } - } - } + return done } diff --git a/internal/cmd/zess.go b/internal/cmd/zess.go index 7acc791..be751e3 100644 --- a/internal/cmd/zess.go +++ b/internal/cmd/zess.go @@ -1,83 +1,35 @@ package cmd import ( - "context" "time" "github.com/zeusWPI/scc/internal/database/repository" "github.com/zeusWPI/scc/internal/zess" "github.com/zeusWPI/scc/pkg/config" - "go.uber.org/zap" + "github.com/zeusWPI/scc/pkg/utils" ) // Zess starts the zess instance -func Zess(repo repository.Repository) (*zess.Zess, chan bool, chan bool) { +func Zess(repo repository.Repository) (*zess.Zess, chan bool) { zess := zess.New(repo) - doneSeason := make(chan bool) + done := make(chan bool) intervalSeason := config.GetDefaultInt("backend.zess.interval_season_s", 300) - - doneScan := make(chan bool) intervalScan := config.GetDefaultInt("backend.zess.interval_scan_s", 60) - go zessPeriodicSeasonUpdate(zess, doneSeason, intervalSeason) - go zessPeriodicScanUpdate(zess, doneScan, intervalScan) - - return zess, doneSeason, doneScan -} - -// TODO: Figure out the context situation - -func zessPeriodicSeasonUpdate(zess *zess.Zess, done chan bool, interval int) { - zap.S().Info("Zess: Starting periodic season update with an interval of ", interval, " seconds") - - ticker := time.NewTicker(time.Duration(interval) * time.Second) - defer ticker.Stop() - - // Run immediatly once - zap.S().Info("Zess: Updating seasons") - if err := zess.UpdateSeasons(context.Background()); err != nil { - zap.S().Error("Zess: Error updating seasons\n", err) - } - - for { - select { - case <-done: - zap.S().Info("Zess: Stopping periodic season update") - return - case <-ticker.C: - // Update seasons - zap.S().Info("Zess: Updating seasons") - if err := zess.UpdateSeasons(context.Background()); err != nil { - zap.S().Error("Zess: Error updating seasons\n", err) - } - } - } -} - -func zessPeriodicScanUpdate(zess *zess.Zess, done chan bool, interval int) { - zap.S().Info("Zess: Starting periodic scan update with an interval of ", interval, " seconds") - - ticker := time.NewTicker(time.Duration(interval) * time.Second) - defer ticker.Stop() - - // Run immediatly once - zap.S().Info("Zess: Updating scans") - if err := zess.UpdateScans(context.Background()); err != nil { - zap.S().Error("Zess: Error updating scans\n", err) - } - - for { - select { - case <-done: - zap.S().Info("Zess: Stopping periodic scan update") - return - case <-ticker.C: - // Update scans - zap.S().Info("Zess: Updating scans") - if err := zess.UpdateScans(context.Background()); err != nil { - zap.S().Error("Zess: Error updating scans\n", err) - } - } - } + go utils.Periodic( + "Zess season", + time.Duration(intervalSeason)*time.Second, + zess.UpdateSeasons, + done, + ) + + go utils.Periodic( + "Zess scans", + time.Duration(intervalScan)*time.Second, + zess.UpdateScans, + done, + ) + + return zess, done } diff --git a/internal/database/model/message.go b/internal/database/model/message.go index a1a13d2..0831544 100644 --- a/internal/database/model/message.go +++ b/internal/database/model/message.go @@ -3,7 +3,7 @@ package model import ( "time" - "github.com/zeusWPI/scc/pkg/sqlc" + "github.com/zeusWPI/scc/internal/database/sqlc" ) type Message struct { diff --git a/internal/database/model/scan.go b/internal/database/model/scan.go index 51e83b5..b916f73 100644 --- a/internal/database/model/scan.go +++ b/internal/database/model/scan.go @@ -3,7 +3,7 @@ package model import ( "time" - "github.com/zeusWPI/scc/pkg/sqlc" + "github.com/zeusWPI/scc/internal/database/sqlc" ) type Scan struct { diff --git a/internal/database/model/season.go b/internal/database/model/season.go index 0f0482d..d3ae248 100644 --- a/internal/database/model/season.go +++ b/internal/database/model/season.go @@ -1,8 +1,8 @@ package model import ( + "github.com/zeusWPI/scc/internal/database/sqlc" "github.com/zeusWPI/scc/pkg/date" - "github.com/zeusWPI/scc/pkg/sqlc" ) type Season struct { diff --git a/internal/database/model/song.go b/internal/database/model/song.go deleted file mode 100644 index 66df55d..0000000 --- a/internal/database/model/song.go +++ /dev/null @@ -1,115 +0,0 @@ -package model - -import ( - "time" - - "github.com/zeusWPI/scc/pkg/sqlc" -) - -type SongGenre struct { - ID int - Genre string -} - -type SongArtist struct { - ID int - Name string - SpotifyID string - Followers int - Popularity int - Genres []SongGenre -} - -type Song struct { - ID int - Title string - Album string - SpotifyID string - DurationMS int - LyricsType string - Lyrics string - CreatedAt time.Time - Artists []SongArtist -} - -func SongModel(s sqlc.Song) *Song { - var lyricsType string - if s.LyricsType.Valid { - lyricsType = s.Lyrics.String - } - - var lyrics string - if s.Lyrics.Valid { - lyrics = s.Lyrics.String - } - - return &Song{ - ID: int(s.ID), - Title: s.Title, - Album: s.Album, - SpotifyID: s.SpotifyID, - DurationMS: int(s.DurationMs), - LyricsType: lyricsType, - Lyrics: lyrics, - } -} - -func SongModelHistory(songs []sqlc.GetLastSongFullRow) *Song { - if len(songs) == 0 { - return nil - } - - var lyricsType string - if songs[0].LyricsType.Valid { - lyricsType = songs[0].LyricsType.String - } - var lyrics string - if songs[0].Lyrics.Valid { - lyrics = songs[0].Lyrics.String - } - - artistsMap := make(map[int32]SongArtist) - for _, song := range songs { - if !song.ArtistID.Valid { - continue - } - - // Get artist - artist, ok := artistsMap[song.ArtistID.Int32] - if !ok { - // Artist doesn't exist yet, add him - artist = SongArtist{ - ID: int(song.ArtistID.Int32), - Name: song.ArtistName.String, - SpotifyID: song.ArtistSpotifyID.String, - Followers: int(song.ArtistFollowers.Int32), - Popularity: int(song.ArtistPopularity.Int32), - Genres: make([]SongGenre, 0), - } - artistsMap[song.ArtistID.Int32] = artist - } - - // Add genre - artist.Genres = append(artist.Genres, SongGenre{ - ID: int(song.GenreID.Int32), - Genre: song.Genre.String, - }) - } - - artists := make([]SongArtist, 0, len(artistsMap)) - for _, artist := range artistsMap { - artists = append(artists, artist) - } - - return &Song{ - ID: int(songs[0].ID), - Title: songs[0].SongTitle, - Album: songs[0].Album, - SpotifyID: songs[0].SpotifyID, - DurationMS: int(songs[0].DurationMs), - LyricsType: lyricsType, - Lyrics: lyrics, - CreatedAt: songs[0].CreatedAt.Time, - Artists: artists, - } -} diff --git a/internal/database/model/tap.go b/internal/database/model/tap.go index 641a6c1..54961c8 100644 --- a/internal/database/model/tap.go +++ b/internal/database/model/tap.go @@ -3,7 +3,7 @@ package model import ( "time" - "github.com/zeusWPI/scc/pkg/sqlc" + "github.com/zeusWPI/scc/internal/database/sqlc" ) type TapCategory string diff --git a/internal/database/repository/message.go b/internal/database/repository/message.go index a3c6fad..0104b5e 100644 --- a/internal/database/repository/message.go +++ b/internal/database/repository/message.go @@ -7,7 +7,7 @@ import ( "fmt" "github.com/zeusWPI/scc/internal/database/model" - "github.com/zeusWPI/scc/pkg/sqlc" + "github.com/zeusWPI/scc/internal/database/sqlc" "github.com/zeusWPI/scc/pkg/utils" ) diff --git a/internal/database/repository/repository.go b/internal/database/repository/repository.go index c45ce9f..7fa02d8 100644 --- a/internal/database/repository/repository.go +++ b/internal/database/repository/repository.go @@ -4,8 +4,8 @@ package repository import ( "context" + "github.com/zeusWPI/scc/internal/database/sqlc" "github.com/zeusWPI/scc/pkg/db" - "github.com/zeusWPI/scc/pkg/sqlc" ) type Repository struct { diff --git a/internal/database/repository/scan.go b/internal/database/repository/scan.go index d27e5f8..2315dd0 100644 --- a/internal/database/repository/scan.go +++ b/internal/database/repository/scan.go @@ -8,7 +8,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/zeusWPI/scc/internal/database/model" - "github.com/zeusWPI/scc/pkg/sqlc" + "github.com/zeusWPI/scc/internal/database/sqlc" "github.com/zeusWPI/scc/pkg/utils" ) diff --git a/internal/database/repository/season.go b/internal/database/repository/season.go index 14eacee..3eb5c71 100644 --- a/internal/database/repository/season.go +++ b/internal/database/repository/season.go @@ -9,7 +9,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/zeusWPI/scc/internal/database/model" - "github.com/zeusWPI/scc/pkg/sqlc" + "github.com/zeusWPI/scc/internal/database/sqlc" "github.com/zeusWPI/scc/pkg/utils" ) diff --git a/internal/database/repository/song.go b/internal/database/repository/song.go deleted file mode 100644 index dd080ac..0000000 --- a/internal/database/repository/song.go +++ /dev/null @@ -1,97 +0,0 @@ -package repository - -import ( - "context" - "database/sql" - "errors" - "fmt" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/zeusWPI/scc/internal/database/model" - "github.com/zeusWPI/scc/pkg/sqlc" -) - -type Song struct { - repo Repository -} - -func (r *Repository) NewSong() *Song { - return &Song{ - repo: *r, - } -} - -func (s *Song) GetBySpotifyID(ctx context.Context, id string) (*model.Song, error) { - song, err := s.repo.queries(ctx).GetSongBySpotifyID(ctx, id) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("get song by spotify id %s | %w", id, err) - } - return nil, nil - } - - return model.SongModel(song), nil -} - -func (s *Song) GetLast(ctx context.Context) (*model.Song, error) { - song, err := s.repo.queries(ctx).GetLastSongFull(ctx) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("get last song %w", err) - } - return nil, nil - } - - return model.SongModelHistory(song), nil -} - -func (s *Song) GetArtistBySpotifyID(ctx context.Context, id string) (*model.SongArtist, error) { - artist, err := s.repo.queries(ctx).GetSongArtistBySpotifyID(ctx, id) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - return nil, fmt.Errorf("get artist by spotify id %s | %w", id, err) - } - - return &model.SongArtist{ - ID: int(artist.ID), - Name: artist.Name, - SpotifyID: artist.SpotifyID, - Followers: int(artist.Followers), - Popularity: int(artist.Popularity), - }, nil -} - -func (s *Song) Create(ctx context.Context, song *model.Song) error { - id, err := s.repo.queries(ctx).CreateSong(ctx, sqlc.CreateSongParams{ - Title: song.Title, - Album: song.Album, - SpotifyID: song.SpotifyID, - DurationMs: int32(song.DurationMS), - LyricsType: pgtype.Text{String: song.LyricsType, Valid: song.LyricsType != ""}, - Lyrics: pgtype.Text{String: song.Lyrics, Valid: song.Lyrics != ""}, - }) - if err != nil { - return fmt.Errorf("create song %+v | %w", *song, err) - } - - song.ID = int(id) - - return nil -} - -func (s *Song) CreateHistory(ctx context.Context, id int) error { - if _, err := s.repo.queries(ctx).CreateSongHistory(ctx, int32(id)); err != nil { - return fmt.Errorf("create song history %d %w", id, err) - } - - return nil -} - -func (s *Song) CreateArtist(ctx context.Context, ) error { - id, err := s.repo.queries(ctx).CreateSongArtistSong(ctx, sqlc.CreateSongArtistSongParams{ - ArtistID: artist.SpotifyID, - SongID: , - }) -} diff --git a/internal/database/repository/tap.go b/internal/database/repository/tap.go index 8eb30df..f4cc02f 100644 --- a/internal/database/repository/tap.go +++ b/internal/database/repository/tap.go @@ -8,7 +8,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/zeusWPI/scc/internal/database/model" - "github.com/zeusWPI/scc/pkg/sqlc" + "github.com/zeusWPI/scc/internal/database/sqlc" "github.com/zeusWPI/scc/pkg/utils" ) diff --git a/pkg/sqlc/db.go b/internal/database/sqlc/db.go similarity index 100% rename from pkg/sqlc/db.go rename to internal/database/sqlc/db.go diff --git a/pkg/sqlc/message.sql.go b/internal/database/sqlc/message.sql.go similarity index 100% rename from pkg/sqlc/message.sql.go rename to internal/database/sqlc/message.sql.go diff --git a/pkg/sqlc/models.go b/internal/database/sqlc/models.go similarity index 74% rename from pkg/sqlc/models.go rename to internal/database/sqlc/models.go index d9b99c7..e4d9c83 100644 --- a/pkg/sqlc/models.go +++ b/internal/database/sqlc/models.go @@ -78,47 +78,6 @@ type Season struct { Current bool } -type Song struct { - ID int32 - Title string - SpotifyID string - DurationMs int32 - Album string - LyricsType pgtype.Text - Lyrics pgtype.Text -} - -type SongArtist struct { - ID int32 - Name string - SpotifyID string - Followers int32 - Popularity int32 -} - -type SongArtistGenre struct { - ID int32 - ArtistID int32 - GenreID int32 -} - -type SongArtistSong struct { - ID int32 - ArtistID int32 - SongID int32 -} - -type SongGenre struct { - ID int32 - Genre string -} - -type SongHistory struct { - ID int32 - SongID int32 - CreatedAt pgtype.Timestamptz -} - type Tap struct { ID int32 OrderID int32 diff --git a/pkg/sqlc/scan.sql.go b/internal/database/sqlc/scan.sql.go similarity index 100% rename from pkg/sqlc/scan.sql.go rename to internal/database/sqlc/scan.sql.go diff --git a/pkg/sqlc/season.sql.go b/internal/database/sqlc/season.sql.go similarity index 100% rename from pkg/sqlc/season.sql.go rename to internal/database/sqlc/season.sql.go diff --git a/pkg/sqlc/tap.sql.go b/internal/database/sqlc/tap.sql.go similarity index 100% rename from pkg/sqlc/tap.sql.go rename to internal/database/sqlc/tap.sql.go diff --git a/internal/lyrics/instrumental.go b/internal/lyrics/instrumental.go deleted file mode 100644 index 3803309..0000000 --- a/internal/lyrics/instrumental.go +++ /dev/null @@ -1,318 +0,0 @@ -package lyrics - -import ( - "fmt" - "math/rand/v2" - "time" - - "github.com/zeusWPI/scc/internal/database/model" -) - -// Instrumental represents the lyrics for an instrumental song -type Instrumental struct { - song model.Song - lyrics []Lyric - i int -} - -func newInstrumental(song model.Song) Lyrics { - return &Instrumental{song: song, lyrics: generateInstrumental(time.Duration(song.DurationMS) * time.Millisecond), i: 0} -} - -// GetSong returns the song associated to the lyrics -func (i *Instrumental) GetSong() model.Song { - return i.song -} - -// Previous provides the previous `amount` of lyrics without affecting the current lyric -func (i *Instrumental) Previous(amount int) []Lyric { - lyrics := make([]Lyric, 0, amount) - - for j := 1; j <= amount; j++ { - if i.i-j-1 < 0 { - break - } - - lyrics = append([]Lyric{i.lyrics[i.i-j-1]}, lyrics...) - } - - return lyrics -} - -// Current provides the current lyric if any. -func (i *Instrumental) Current() (Lyric, bool) { - if i.i >= len(i.lyrics) { - return Lyric{}, false - } - - return i.lyrics[i.i], true -} - -// Next provides the next lyric. -// In this case it's always nothing -func (i *Instrumental) Next() (Lyric, bool) { - if i.i+1 >= len(i.lyrics) { - return Lyric{}, false - } - - i.i++ - return i.lyrics[i.i-1], true -} - -// Upcoming provides the next `amount` lyrics without affecting the current lyric -// In this case it's always empty -func (i *Instrumental) Upcoming(amount int) []Lyric { - lyrics := make([]Lyric, 0, amount) - - for j := 0; j < amount; j++ { - if i.i+j >= len(i.lyrics) { - break - } - - lyrics = append(lyrics, i.lyrics[i.i+j]) - } - - return lyrics -} - -// Progress shows the fraction of lyrics that have been used. -func (i *Instrumental) Progress() float64 { - return float64(i.i) / float64(len(i.lyrics)) -} - -func generateInstrumental(dur time.Duration) []Lyric { - // Get all instruments with their frequency - freqs := []instrument{} - for _, instr := range instruments { - for range instr.frequency { - freqs = append(freqs, instr) - } - } - - lyrics := []Lyric{} - // Split up song in segments between 5 and 15 seconds - currentDur := time.Duration(0) - for currentDur < dur { - // Get a random instrument - instr := freqs[rand.IntN(len(freqs))] - - // Get a random duration - randomDur := time.Duration(rand.IntN(10)+5) * time.Second - currentDur += randomDur - if currentDur >= dur { - randomDur -= (currentDur - dur) - } - - // Get the lyrics - lyrics = append(lyrics, instr.generate(randomDur)...) - } - - return lyrics -} - -// Instruments - -type instrument struct { - frequency int // Odds of it occuring - name string // Name of the instrument - sounds []string // Different ways it could sound -} - -// generate creates lyrics for a specific instrument -func (i instrument) generate(dur time.Duration) []Lyric { - lyrics := []Lyric{} - - // Same logic as in `generateInstrumental` except that the segments are between 1 and 4 seconds - - // Add the start lyric - text := fmt.Sprintf(openings[rand.IntN(len(openings))], i.name) - randomDur := time.Duration(1) * time.Second - lyrics = append(lyrics, Lyric{Text: text, Duration: randomDur}) - - currentDur := randomDur - for currentDur < dur { - // Get a random text - textLength := rand.IntN(5) + 1 - var text string - for range textLength { - text += i.sounds[rand.IntN(len(i.sounds))] + " " - } - - // Get a random duration - randomDur := time.Duration(rand.IntN(3)+1) * time.Second - currentDur += randomDur - if currentDur >= dur { - // Last lyric, add a newline - text += "\n" - randomDur -= (currentDur - dur) - } - - lyrics = append(lyrics, Lyric{Text: text, Duration: randomDur}) - } - - return lyrics -} - -// All instruments to choose from -var instruments = []instrument{ - {frequency: 9, name: "Piano", sounds: []string{"plink", "plonk", "pling", "clink", "clang"}}, - {frequency: 7, name: "Drums", sounds: []string{"boom", "ba-dum", "thwack", "tshh", "bop"}}, - {frequency: 5, name: "Electric Guitar", sounds: []string{"wah", "zzzzzz", "twang", "vrrr", "brrraang"}}, - {frequency: 3, name: "Theremin", sounds: []string{"wooOOOooo", "weeeee", "ooooo", "waaAAaah", "hummmm"}}, - {frequency: 6, name: "Flute", sounds: []string{"toot", "fweee", "trillll", "pip", "peep"}}, - {frequency: 4, name: "Accordion", sounds: []string{"wheeze", "honk", "phwoo", "eep", "squawk"}}, - {frequency: 8, name: "Violin", sounds: []string{"screee", "swish", "zing", "vwee", "mreee"}}, - {frequency: 5, name: "Saxophone", sounds: []string{"saxxy", "bwoop", "dooo", "reebop", "honka"}}, - {frequency: 2, name: "Kazoo", sounds: []string{"bzzzzz", "zwip", "vwoo", "brrr", "zzzzrrt"}}, - {frequency: 9, name: "Trumpet", sounds: []string{"brrraaap", "toot", "doo-doo", "wah-wah", "parp"}}, - {frequency: 3, name: "Cowbell", sounds: []string{"clang", "clong", "ding", "donk", "bonk"}}, - {frequency: 2, name: "Bagpipes", sounds: []string{"drone", "hrooo", "whine", "skree", "rrrrrrr"}}, - {frequency: 6, name: "Triangle", sounds: []string{"ting", "tang", "ding", "dling", "plink"}}, - {frequency: 1, name: "Didgeridoo", sounds: []string{"whooooo", "womp", "drrrrrr", "brrrrr", "hummmmm"}}, - {frequency: 4, name: "Bongos", sounds: []string{"pop", "tap", "dum", "ba-dum", "bop"}}, - {frequency: 7, name: "Harp", sounds: []string{"plink", "tinkle", "zling", "glint", "ding"}}, - {frequency: 5, name: "Maracas", sounds: []string{"sh-sh-sh", "shaka-shaka", "chick", "rattle", "tktktk"}}, - {frequency: 3, name: "Tuba", sounds: []string{"oompah", "bruhm", "whoom", "booo", "phrum"}}, - {frequency: 1, name: "Banjo", sounds: []string{"twang", "plink", "brrrring", "plunk", "doink"}}, - {frequency: 2, name: "Synthesizer", sounds: []string{"beep-boop", "vwee-vwee", "zorp", "waah", "ding"}}, - {frequency: 6, name: "Xylophone", sounds: []string{"ding", "dunk", "plink-plonk", "tok", "tink"}}, - {frequency: 2, name: "Hurdy-Gurdy", sounds: []string{"whirr", "drone", "skreee", "buzz", "rrrrrng"}}, - {frequency: 4, name: "Harmonica", sounds: []string{"wheeze", "toot", "hoo", "blow", "brrrr"}}, - {frequency: 3, name: "Slide Whistle", sounds: []string{"whoooop", "wheeee", "wooo", "boooo", "zwip"}}, - {frequency: 5, name: "Tambourine", sounds: []string{"jingle", "shake-shake", "tshh", "tinkle", "ting-ting"}}, - {frequency: 2, name: "Ocarina", sounds: []string{"woo", "fweee", "doodle", "pip-pip", "toot"}}, - {frequency: 8, name: "Acoustic Guitar", sounds: []string{"strum", "plang", "twang", "zing", "thrum"}}, - {frequency: 1, name: "Sousaphone", sounds: []string{"toot", "boop", "pah-pah", "oompah", "pwaaah"}}, - {frequency: 3, name: "Castanets", sounds: []string{"clack", "click", "clap", "tick", "tack"}}, - {frequency: 7, name: "Synth Drum", sounds: []string{"pshh", "bzzt", "bip", "tsh", "zorp"}}, - {frequency: 2, name: "Bag of Gravel", sounds: []string{"crunch", "scrape", "sh-sh", "clatter", "grrnk"}}, - {frequency: 5, name: "Steel Drum", sounds: []string{"pong", "ding", "donk", "bop", "ting"}}, - {frequency: 4, name: "Mouth Harp", sounds: []string{"boing", "thwong", "zzzt", "doyoyoy", "wobble"}}, - {frequency: 2, name: "Rainstick", sounds: []string{"shhhhh", "rrrrrr", "drip-drop", "fwssh", "ssss"}}, - {frequency: 1, name: "Toy Piano", sounds: []string{"plink", "tink-tink", "chime", "plinkity", "dink"}}, - {frequency: 3, name: "Jaw Harp", sounds: []string{"twang", "boing", "doink", "womp", "zzzrrrt"}}, - {frequency: 4, name: "Bicycle Horn", sounds: []string{"honk", "meeep", "awoooga", "brrrt", "bop-bop"}}, - {frequency: 2, name: "Glass Harp", sounds: []string{"wheee", "zing", "woo", "glint", "oooo"}}, - {frequency: 6, name: "Claves", sounds: []string{"clack", "click", "clonk", "tak", "tok"}}, - {frequency: 3, name: "Rubber Band", sounds: []string{"twang", "ping", "boing", "zing", "snap"}}, - {frequency: 2, name: "Paper Comb", sounds: []string{"buzz", "brrr", "wobble", "zzzt", "drone"}}, - {frequency: 1, name: "Duck Call", sounds: []string{"quack", "wak-wak", "honk", "waak", "weeek"}}, - {frequency: 5, name: "Handbells", sounds: []string{"ding", "dong", "chime", "tinkle", "bong"}}, - {frequency: 4, name: "Foghorn", sounds: []string{"MOOOO", "hoooonk", "BWAAAA", "WOOOO", "brrrmmm"}}, - {frequency: 7, name: "Cello", sounds: []string{"mmmm", "vmmm", "vroom", "dronnn", "zoomm"}}, - {frequency: 6, name: "Clarinet", sounds: []string{"toot", "wooo", "hmmm", "dee-dee", "reeee"}}, - {frequency: 8, name: "Oboe", sounds: []string{"hweee", "hee", "whee", "ooooo", "reee"}}, - {frequency: 5, name: "French Horn", sounds: []string{"vooom", "phoo", "bwoo", "vuuum", "whooo"}}, - {frequency: 6, name: "Bassoon", sounds: []string{"boo", "brrrr", "phrum", "wuuu", "vrrr"}}, - {frequency: 8, name: "Timpani", sounds: []string{"boom", "dum", "rumble", "thud", "pum"}}, - {frequency: 7, name: "Double Bass", sounds: []string{"vrumm", "dumm", "boooom", "grumm", "zzzooom"}}, - {frequency: 9, name: "Trumpet", sounds: []string{"brrrmp", "doo-doo", "toot", "baap", "dah-dah"}}, - {frequency: 6, name: "Trombone", sounds: []string{"wah-wah", "dooo", "wooo", "bwaaah", "vroom"}}, - {frequency: 4, name: "Harp", sounds: []string{"plink", "strum", "zinnnng", "twang", "gliss"}}, - {frequency: 6, name: "Piccolo", sounds: []string{"peep", "tweet", "fweep", "weeet", "pweep"}}, - {frequency: 7, name: "Bass Drum", sounds: []string{"boom", "thud", "pum", "dum", "bomp"}}, - {frequency: 5, name: "Snare Drum", sounds: []string{"rat-a-tat", "tsh", "tktktk", "snap", "crack"}}, - {frequency: 7, name: "Tuba", sounds: []string{"pah-pah", "brumm", "booom", "ooooh", "vrooo"}}, - {frequency: 6, name: "Viola", sounds: []string{"mmmmm", "zoooo", "veee", "whooo", "vrreee"}}, - {frequency: 5, name: "Glockenspiel", sounds: []string{"ding", "tinkle", "ping", "plink", "chime"}}, - {frequency: 7, name: "Organ", sounds: []string{"hummmm", "ooooo", "voooom", "drone", "wooo"}}, - {frequency: 4, name: "Bass Clarinet", sounds: []string{"mmmm", "brooo", "bwooo", "rooo", "vrmmm"}}, - {frequency: 6, name: "English Horn", sounds: []string{"hooo", "wheee", "woooo", "phmmm", "breee"}}, - {frequency: 8, name: "Concert Bass Drum", sounds: []string{"BOOM", "rumble", "dum", "doom", "pum"}}, - {frequency: 5, name: "Cymbals", sounds: []string{"crash", "clang", "clash", "shing", "chhhh"}}, - {frequency: 6, name: "Recorder", sounds: []string{"tweet", "toot", "peep", "reep", "fweee"}}, - {frequency: 5, name: "Baritone Saxophone", sounds: []string{"vrooo", "booo", "bop", "grmmm", "vrooom"}}, - {frequency: 7, name: "Marimba", sounds: []string{"tok", "tonk", "dunk", "dong", "bong"}}, -} - -var openings = []string{ - "The sound of %s fills the air", - "Everyone listens as %s takes over", - "A melody rises, played by %s", - "The stage belongs to %s now", - "You can hear %s in the distance", - "All eyes are on %s as it begins", - "The music swells, led by %s", - "A soft hum emerges from %s", - "Powerful notes erupt from %s", - "The rhythm shifts, thanks to %s", - "From the corner, %s adds its voice", - "The harmony is completed by %s", - "Suddenly, %s makes its presence known", - "In the mix, %s finds its place", - "A delicate tune floats out of %s", - "The energy builds, driven by %s", - "A resonant sound comes from %s", - "The silence is broken by %s", - "An unmistakable sound flows from %s", - "Everything changes when %s joins in", - "The audience is captivated by %s", - "The backdrop hums with the sound of %s", - "A new tone emerges, thanks to %s", - "The piece takes flight with %s", - "A rich sound emanates from %s", - "The music deepens as %s plays", - "Out of nowhere, %s begins to play", - "The atmosphere transforms with %s", - "The melody comes alive with %s", - "A wave of sound builds around %s", - "The air is electrified by %s", - "The composition breathes through %s", - "A bright tone emerges from %s", - "The song's heartbeat is driven by %s", - "In the chaos, %s finds its voice", - "The layers of sound are enriched by %s", - "A subtle rhythm flows from %s", - "The crowd stirs as %s joins the fray", - "The lead shifts to %s for a moment", - "The balance is perfected by %s", - "The soul of the piece resonates with %s", - "A cascade of notes falls from %s", - "The performance peaks with %s", - "Each note feels alive with %s playing", - "The essence of the tune shines through %s", - "A haunting sound drifts from %s", - "The soundscape expands with %s", - "The magic unfolds around %s", - "The rhythm breathes new life through %s", - "From the shadows, %s contributes a tone", - "The journey continues with %s", - "A bold entrance by %s turns heads", - "The crescendo builds, led by %s", - "The quiet is punctuated by %s", - "The song finds its pulse in %s", - "The atmosphere shimmers with %s", - "A tender phrase is born from %s", - "The mood shifts under the spell of %s", - "%s brings a new layer to the melody", - "%s fills the space with its sound", - "%s adds depth to the composition", - "%s carries the tune to new heights", - "%s weaves through the harmony effortlessly", - "%s resonates with a rich and vibrant tone", - "%s shapes the rhythm with precision", - "%s colors the soundscape beautifully", - "%s takes the lead with bold notes", - "%s softens the mood with its melody", - "%s breathes life into the music", - "%s anchors the harmony with steady tones", - "%s dances through the melody with ease", - "%s punctuates the silence with clarity", - "%s soars above the other instruments", - "%s enriches the atmosphere with its presence", - "%s blends seamlessly into the symphony", - "%s echoes the spirit of the piece", - "%s shines as the centerpiece of the sound", - "%s threads its voice into the composition", - "%s carries the weight of the rhythm", - "%s bursts forth with dynamic energy", - "%s hums softly, anchoring the melody", - "%s paints vivid colors with its notes", - "%s rises and falls with graceful precision", - "%s whispers a delicate phrase into the mix", - "%s transforms the tune with its entrance", - "%s gives the piece a fresh perspective", - "%s stirs emotions with every note", - "%s intertwines with the harmony effortlessly", - "%s drives the pulse of the music forward", -} diff --git a/internal/lyrics/lrc.go b/internal/lyrics/lrc.go deleted file mode 100644 index c37882c..0000000 --- a/internal/lyrics/lrc.go +++ /dev/null @@ -1,127 +0,0 @@ -package lyrics - -import ( - "regexp" - "strconv" - "strings" - "time" - - "github.com/zeusWPI/scc/internal/database/model" -) - -var re = regexp.MustCompile(`^\[(\d{2}):(\d{2})\.(\d{2})\]`) - -// LRC represents synced lyrics -type LRC struct { - song model.Song - lyrics []Lyric - i int -} - -func newLRC(song model.Song) Lyrics { - return &LRC{song: song, lyrics: parseLRC(song.Lyrics, time.Duration(song.DurationMS)), i: 0} -} - -// GetSong returns the song associated to the lyrics -func (l *LRC) GetSong() model.Song { - return l.song -} - -// Previous provides the previous `amount` of lyrics without affecting the current lyric -func (l *LRC) Previous(amount int) []Lyric { - lyrics := make([]Lyric, 0, amount) - - for i := 1; i <= amount; i++ { - if l.i-i-1 < 0 { - break - } - - lyrics = append([]Lyric{l.lyrics[l.i-i-1]}, lyrics...) - } - - return lyrics -} - -// Current provides the current lyric if any. -// If the song is finished the boolean is set to false -func (l *LRC) Current() (Lyric, bool) { - if l.i >= len(l.lyrics) { - return Lyric{}, false - } - - return l.lyrics[l.i], true -} - -// Next provides the next lyric if any. -// If the song is finished the boolean is set to false -func (l *LRC) Next() (Lyric, bool) { - if l.i+1 >= len(l.lyrics) { - return Lyric{}, false - } - - l.i++ - return l.lyrics[l.i-1], true -} - -// Upcoming provides the next `amount` lyrics without affecting the current lyric -func (l *LRC) Upcoming(amount int) []Lyric { - lyrics := make([]Lyric, 0, amount) - - for i := 0; i < amount; i++ { - if l.i+i >= len(l.lyrics) { - break - } - - lyrics = append(lyrics, l.lyrics[l.i+i]) - } - - return lyrics -} - -// Progress shows the fraction of lyrics that have been used. -func (l *LRC) Progress() float64 { - return float64(l.i) / float64(len(l.lyrics)) -} - -func parseLRC(text string, totalDuration time.Duration) []Lyric { - lines := strings.Split(text, "\n") - - if len(lines) == 0 { - return []Lyric{} - } - - lyrics := make([]Lyric, 0, len(lines)+1) // + 1 for a start empty lyric - var previousTimestamp time.Duration - - // Add first lyric (no text) - lyrics = append(lyrics, Lyric{Text: ""}) - previousTimestamp = time.Duration(0) - - for i, line := range lines { - parts := strings.SplitN(line, " ", 2) - if len(parts) != 2 { - continue - } - - // Duration part - timeParts := re.FindStringSubmatch(parts[0]) - minutes, _ := strconv.Atoi(timeParts[1]) - seconds, _ := strconv.Atoi(timeParts[2]) - hundredths, _ := strconv.Atoi(timeParts[3]) - timestamp := time.Duration(minutes)*time.Minute + - time.Duration(seconds)*time.Second + - time.Duration(hundredths)*10*time.Millisecond - - // Actual lyric - lyric := parts[1] - - lyrics = append(lyrics, Lyric{Text: lyric}) - lyrics[i].Duration = timestamp - previousTimestamp - previousTimestamp = timestamp - } - - // Set duration of last lyric - lyrics[len(lyrics)-1].Duration = totalDuration - previousTimestamp - - return lyrics -} diff --git a/internal/lyrics/lyrics.go b/internal/lyrics/lyrics.go deleted file mode 100644 index c5812c1..0000000 --- a/internal/lyrics/lyrics.go +++ /dev/null @@ -1,45 +0,0 @@ -// Package lyrics provides a way to work with both synced and plain lyrics -package lyrics - -import ( - "time" - - "github.com/zeusWPI/scc/internal/database/model" -) - -// Lyrics is the common interface for different lyric types -type Lyrics interface { - GetSong() model.Song - Previous(int) []Lyric - Current() (Lyric, bool) - Next() (Lyric, bool) - Upcoming(int) []Lyric - Progress() float64 -} - -// Lyric represents a single lyric line. -type Lyric struct { - Text string - Duration time.Duration -} - -// New returns a new object that implements the Lyrics interface -func New(song model.Song) Lyrics { - // Basic sync - if song.LyricsType == "synced" { - return newLRC(song) - } - - // Plain - if song.LyricsType == "plain" { - return newPlain(song) - } - - // Instrumental - if song.LyricsType == "instrumental" { - return newInstrumental(song) - } - - // No lyrics found - return newMissing(song) -} diff --git a/internal/lyrics/missing.go b/internal/lyrics/missing.go deleted file mode 100644 index d0ae335..0000000 --- a/internal/lyrics/missing.go +++ /dev/null @@ -1,55 +0,0 @@ -package lyrics - -import ( - "time" - - "github.com/zeusWPI/scc/internal/database/model" -) - -// Missing represents lyrics that are absent -type Missing struct { - song model.Song - lyrics Lyric -} - -func newMissing(song model.Song) Lyrics { - lyric := Lyric{ - Text: "Missing lyrics\n\nHelp the open source community by adding them to\nhttps://lrclib.net/", - Duration: time.Duration(song.DurationMS) * time.Millisecond, - } - - return &Missing{song: song, lyrics: lyric} -} - -// GetSong returns the song associated to the lyrics -func (m *Missing) GetSong() model.Song { - return m.song -} - -// Previous provides the previous `amount` of lyrics without affecting the current lyric -// In this case it's alway nothing -func (m *Missing) Previous(_ int) []Lyric { - return []Lyric{} -} - -// Current provides the current lyric if any. -func (m *Missing) Current() (Lyric, bool) { - return m.lyrics, true -} - -// Next provides the next lyric. -// In this case it's always nothing -func (m *Missing) Next() (Lyric, bool) { - return Lyric{}, false -} - -// Upcoming provides the next `amount` lyrics without affecting the current lyric -// In this case it's always empty -func (m *Missing) Upcoming(_ int) []Lyric { - return []Lyric{} -} - -// Progress shows the fraction of lyrics that have been used. -func (m *Missing) Progress() float64 { - return 1 -} diff --git a/internal/lyrics/plain.go b/internal/lyrics/plain.go deleted file mode 100644 index e04e2a7..0000000 --- a/internal/lyrics/plain.go +++ /dev/null @@ -1,54 +0,0 @@ -package lyrics - -import ( - "time" - - "github.com/zeusWPI/scc/internal/database/model" -) - -// Plain represents lyrics that don't have timestamps or songs without lyrics -type Plain struct { - song model.Song - lyrics Lyric -} - -func newPlain(song model.Song) Lyrics { - lyric := Lyric{ - Text: song.Lyrics, - Duration: time.Duration(song.DurationMS) * time.Millisecond, - } - return &Plain{song: song, lyrics: lyric} -} - -// GetSong returns the song associated to the lyrics -func (p *Plain) GetSong() model.Song { - return p.song -} - -// Previous provides the previous `amount` of lyrics without affecting the current lyric -// In this case it's always nothing -func (p *Plain) Previous(_ int) []Lyric { - return []Lyric{} -} - -// Current provides the current lyric if any. -func (p *Plain) Current() (Lyric, bool) { - return p.lyrics, true -} - -// Next provides the next lyric. -// In this case it's alway nothing -func (p *Plain) Next() (Lyric, bool) { - return Lyric{}, false -} - -// Upcoming provides the next `amount` lyrics without affecting the current lyric -// In this case it's always empty -func (p *Plain) Upcoming(_ int) []Lyric { - return []Lyric{} -} - -// Progress shows the fraction of lyrics that have been used. -func (p *Plain) Progress() float64 { - return 1 -} diff --git a/internal/server/server.go b/internal/server/server.go index d087759..e04f19d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -7,7 +7,6 @@ import ( "github.com/gofiber/contrib/fiberzap" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" - "github.com/jackc/pgx/v5/pgxpool" routers "github.com/zeusWPI/scc/internal/server/api" "github.com/zeusWPI/scc/internal/server/service" "github.com/zeusWPI/scc/pkg/config" @@ -19,7 +18,7 @@ type Server struct { Addr string } -func New(service service.Service, pool *pgxpool.Pool) *Server { +func New(service service.Service) *Server { env := config.GetDefaultString("app.env", "development") // Construct app diff --git a/internal/server/service/message.go b/internal/server/service/message.go index a1f989d..2f3cc6d 100644 --- a/internal/server/service/message.go +++ b/internal/server/service/message.go @@ -12,7 +12,7 @@ func (s *Service) NewMessage() *Message { return &Message{} } -// TODO: fill in -func (m *Message) Create(ctx context.Context, message dto.Message) (dto.Message, error) { +func (m *Message) Create(_ context.Context, _ dto.Message) (dto.Message, error) { + // TODO: fill in return dto.Message{}, nil } diff --git a/internal/server/service/service.go b/internal/server/service/service.go index 4fb0663..ef091f6 100644 --- a/internal/server/service/service.go +++ b/internal/server/service/service.go @@ -1,8 +1,6 @@ package service import ( - "context" - "github.com/zeusWPI/scc/internal/database/repository" ) @@ -15,7 +13,3 @@ func New(repo repository.Repository) *Service { repo: repo, } } - -func (s *Service) withRollback(ctx context.Context, fn func(context.Context) error) error { - return s.repo.WithRollback(ctx, fn) -} diff --git a/internal/server/service/song.go b/internal/server/service/song.go index 846394a..a9f8036 100644 --- a/internal/server/service/song.go +++ b/internal/server/service/song.go @@ -12,7 +12,7 @@ func (s *Service) NewSong() *Song { return &Song{} } -// TODO: Fill in -func (s *Song) New(ctx context.Context, song dto.Song) error { +func (s *Song) New(_ context.Context, _ dto.Song) error { + // TODO: Fill in return nil } diff --git a/internal/song/account.go b/internal/song/account.go deleted file mode 100644 index 774953a..0000000 --- a/internal/song/account.go +++ /dev/null @@ -1,38 +0,0 @@ -package song - -import ( - "errors" - "time" - - "github.com/gofiber/fiber/v2" - "go.uber.org/zap" -) - -type accountResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int64 `json:"expires_in"` -} - -func (s *Song) refreshToken() error { - zap.S().Info("Song: Refreshing spotify access token") - - form := &fiber.Args{} - form.Add("grant_type", "client_credentials") - - req := fiber.Post(s.apiAccount).Form(form).BasicAuth(s.ClientID, s.ClientSecret) - - res := new(accountResponse) - status, _, errs := req.Struct(res) - if len(errs) > 0 { - return errors.Join(append([]error{errors.New("Song: Spotify token refresh request failed")}, errs...)...) - } - if status != fiber.StatusOK { - return errors.New("Song: Error getting access token") - } - - s.AccessToken = res.AccessToken - s.ExpiresTime = time.Now().Unix() + res.ExpiresIn - - return nil -} diff --git a/internal/song/api.go b/internal/song/api.go deleted file mode 100644 index e3a30e6..0000000 --- a/internal/song/api.go +++ /dev/null @@ -1,158 +0,0 @@ -package song - -import ( - "errors" - "fmt" - "net/url" - - "github.com/gofiber/fiber/v2" - "github.com/zeusWPI/scc/internal/pkg/db/dto" - "go.uber.org/zap" -) - -type trackArtist struct { - ID string `json:"id"` - Name string `json:"name"` -} - -type trackAlbum struct { - Name string `json:"name"` -} - -type trackResponse struct { - Name string `json:"name"` - Album trackAlbum `json:"album"` - Artists []trackArtist `json:"artists"` - DurationMS int32 `json:"duration_ms"` -} - -func (s *Song) getTrack(track *dto.Song) error { - zap.S().Info("Song: Getting track info for id: ", track.SpotifyID) - - req := fiber.Get(fmt.Sprintf("%s/%s/%s", s.api, "tracks", track.SpotifyID)). - Set("Authorization", "Bearer "+s.AccessToken) - - res := new(trackResponse) - status, _, errs := req.Struct(res) - if len(errs) > 0 { - return errors.Join(append([]error{errors.New("Song: Track request failed")}, errs...)...) - } - if status != fiber.StatusOK { - return fmt.Errorf("Song: Track request wrong status code %d", status) - } - - track.Title = res.Name - track.Album = res.Album.Name - track.DurationMS = res.DurationMS - - for _, a := range res.Artists { - track.Artists = append(track.Artists, dto.SongArtist{ - Name: a.Name, - SpotifyID: a.ID, - }) - } - - return nil -} - -type artistFollowers struct { - Total int `json:"total"` -} - -type artistResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Genres []string `json:"genres"` - Popularity int `json:"popularity"` - Followers artistFollowers `json:"followers"` -} - -func (s *Song) getArtist(artist *dto.SongArtist) error { - zap.S().Info("Song: Getting artists info for ", artist.SpotifyID) - - req := fiber.Get(fmt.Sprintf("%s/%s/%s", s.api, "artists", artist.SpotifyID)). - Set("Authorization", "Bearer "+s.AccessToken) - - res := new(artistResponse) - status, _, errs := req.Struct(res) - if len(errs) > 0 { - return errors.Join(append([]error{errors.New("Song: Artist request failed")}, errs...)...) - } - if status != fiber.StatusOK { - return fmt.Errorf("Song: Artist request wrong status code %d", status) - } - - artist.Popularity = int32(res.Popularity) - artist.Followers = int32(res.Followers.Total) - - for _, genre := range res.Genres { - artist.Genres = append(artist.Genres, dto.SongGenre{Genre: genre}) - } - - return nil -} - -type lyricsResponse struct { - Instrumental bool `json:"instrumental"` - PlainLyrics string `json:"plainLyrics"` - SyncedLyrics string `json:"SyncedLyrics"` -} - -func (s *Song) getLyrics(track *dto.Song) error { - zap.S().Info("Song: Getting lyrics for ", track.Title) - - // Get most popular artist - if len(track.Artists) == 0 { - return fmt.Errorf("Song: No artists for track: %v", track) - } - artist := track.Artists[0] - for _, a := range track.Artists { - if a.Followers > artist.Followers { - artist = a - } - } - - // Construct url - params := url.Values{} - params.Set("artist_name", artist.Name) - params.Set("track_name", track.Title) - params.Set("album_name", track.Album) - params.Set("duration", strconv.Itoa(int(track.DurationMS/1000))) - - req := fiber.Get(fmt.Sprintf("%s/get?%s", s.apiLrclib, params.Encode())) - - res := new(lyricsResponse) - status, _, errs := req.Struct(res) - if len(errs) > 0 { - return errors.Join(append([]error{errors.New("Song: Lyrics request failed")}, errs...)...) - } - if status != fiber.StatusOK { - if status == fiber.StatusNotFound { - // Lyrics not found - return nil - } - - return fmt.Errorf("Song: Lyrics request wrong status code %d", status) - } - if (res == &lyricsResponse{}) { - return errors.New("Song: Lyrics request returned empty struct") - } - - zap.S().Info(res) - - if res.SyncedLyrics != "" { - // Synced lyrics ? - track.LyricsType = "synced" - track.Lyrics = res.SyncedLyrics - } else if res.PlainLyrics != "" { - // Plain lyrics ? - track.LyricsType = "plain" - track.Lyrics = res.PlainLyrics - } else if res.Instrumental { - // Instrumental ? - track.LyricsType = "instrumental" - track.Lyrics = "" - } - - return nil -} diff --git a/internal/song/song.go b/internal/song/song.go deleted file mode 100644 index bd93f86..0000000 --- a/internal/song/song.go +++ /dev/null @@ -1,182 +0,0 @@ -// Package song provides all song related logic -package song - -import ( - "context" - "errors" - "time" - - "github.com/jackc/pgx/v5" - "github.com/zeusWPI/scc/internal/database/repository" - "github.com/zeusWPI/scc/internal/pkg/db/dto" - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" - "github.com/zeusWPI/scc/pkg/config" -) - -type Song struct { - song repository.Song - - clientID string - clientSecret string - accessToken string - expiresTime int64 - - url string - urlAccount string - urlLrclib string -} - -func New(repo repository.Repository) (*Song, error) { - clientID := config.GetDefaultString("backend.song.spotify_client_id", "") - clientSecret := config.GetDefaultString("backend.song.spotify_client_secret", "") - - if clientID == "" || clientSecret == "" { - return &Song{}, errors.New("song: spotify client id or secret not set") - } - - return &Song{ - song: *repo.NewSong(), - clientID: clientID, - clientSecret: clientSecret, - expiresTime: 0, - url: config.GetDefaultString("backend.song.spotify_url", "https://api.spotify.com/v1"), - urlAccount: config.GetDefaultString("backend.song.spotify_url_account", "https://accounts.spotify.com/api/token"), - urlLrclib: config.GetDefaultString("backend.song.lrclib_url", "https://lrclib.net/api"), - }, nil -} - -// Track gets information about the current track and stores it in the database -func (s *Song) Track(ctx context.Context, track *dto.Song) error { - var errs []error - - if s.clientID == "" || s.clientSecret == "" { - return errors.New("Song: Spotify client id or secret not set") - } - - // Check if song is already in DB - trackDB, err := s.song.GetBySpotifyID(ctx, track.SpotifyID) - if err != nil { - return err - } - - if trackDB != nil { - // Already in DB - // Add to song history if it's not the latest song - songHistory, err := s.song.GetLast(ctx) - if err != nil { - return err - } - - if songHistory != nil && songHistory.ID == trackDB.ID { - // Song is already the latest, don't add it again - return nil - } - - if err = s.song.CreateHistory(ctx, trackDB.ID); err != nil { - return err - } - - return nil - } - - // Not in database yet, add it - - // Refresh token if needed - if s.expiresTime <= time.Now().Unix() { - err := s.refreshToken() - if err != nil { - return err - } - } - - // Get track info - if err = s.getTrack(ctx, track); err != nil { - return err - } - - // Get lyrics - if err = s.getLyrics(ctx, track); err != nil { - errs = append(errs, err) - } - - // Store track in DB - err = s.song.Create(ctx, &track) - if err != nil { - errs = append(errs, err) - return errors.Join(errs...) - } - track.ID = trackDB.ID - - // Handle artists - for i, artist := range track.Artists { - a, err := s.song.GetArtistBySpotifyID(ctx, artist.SpotifyID) - if err != nil { - errs = append(errs, err) - continue - } - - if a != nil { - // Artist already exists - // Add it as an artist for this track - track.Artists[i].ID = a.ID - if _, err := s.db.Queries.CreateSongArtistSong(context.Background(), *track.CreateSongArtistSongParams(i)); err != nil { - errs = append(errs, err) - } - continue - } - - // Get artist data - if err := s.getArtist(&track.Artists[i]); err != nil { - errs = append(errs, err) - continue - } - - // Insert artist in DB - a, err = s.db.Queries.CreateSongArtist(context.Background(), *track.CreateSongArtistParams(i)) - if err != nil { - errs = append(errs, err) - continue - } - track.Artists[i].ID = a.ID - - // Add artist as an artist for this song - if _, err := s.db.Queries.CreateSongArtistSong(context.Background(), *track.CreateSongArtistSongParams(i)); err != nil { - errs = append(errs, err) - continue - } - - // Check if the artists genres are in db - for j, genre := range track.Artists[i].Genres { - g, err := s.db.Queries.GetSongGenreByName(context.Background(), genre.Genre) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - errs = append(errs, err) - continue - } - - if (g != sqlc.SongGenre{}) { - // Genre already exists - continue - } - - // Insert genre in DB - g, err = s.db.Queries.CreateSongGenre(context.Background(), track.CreateSongGenreParams(i, j)) - if err != nil { - errs = append(errs, err) - continue - } - track.Artists[i].Genres[j].ID = g.ID - - // Add genre as a genre for this artist - if _, err := s.db.Queries.CreateSongArtistGenre(context.Background(), *track.CreateSongArtistGenreParamas(i, j)); err != nil { - errs = append(errs, err) - } - } - } - - // Add to song history - if _, err = s.db.Queries.CreateSongHistory(context.Background(), trackDB.ID); err != nil { - errs = append(errs, err) - } - - return errors.Join(errs...) -} diff --git a/internal/tap/api.go b/internal/tap/api.go index 81c9009..00ca72f 100644 --- a/internal/tap/api.go +++ b/internal/tap/api.go @@ -22,7 +22,8 @@ type orderResponse struct { } func (o orderResponse) ToModel() []model.Tap { - var taps []model.Tap + taps := make([]model.Tap, 0, len(o.Orders)) + for _, order := range o.Orders { var category model.TapCategory = "unknown" switch order.ProductCategory { @@ -50,7 +51,7 @@ func (o orderResponse) ToModel() []model.Tap { func (t *Tap) getOrders(ctx context.Context) ([]model.Tap, error) { resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ Method: "GET", - URL: fmt.Sprintf("%s/recent", t.url), + URL: t.url + "/recent", }) if err != nil { return nil, fmt.Errorf("get all tap orders %w", err) diff --git a/internal/zess/api.go b/internal/zess/api.go index 622a5ae..473c42b 100644 --- a/internal/zess/api.go +++ b/internal/zess/api.go @@ -30,7 +30,7 @@ func (s seasonAPI) toModel() model.Season { func (z *Zess) getSeasons(ctx context.Context) ([]model.Season, error) { resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ Method: "GET", - URL: fmt.Sprintf("%s/seasons", z.url), + URL: z.url + "/seasons", }) if err != nil { return nil, fmt.Errorf("http get all zess seasons %w", err) @@ -63,7 +63,7 @@ func (s scanAPI) toModel() model.Scan { func (z *Zess) getScans(ctx context.Context) ([]model.Scan, error) { resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ Method: "GET", - URL: fmt.Sprintf("%s/recent_scans", z.url), + URL: z.url + "/recent_scans", }) if err != nil { return nil, fmt.Errorf("http get recent zess scans %w", err) diff --git a/pkg/config/config.go b/pkg/config/config.go index f438c11..19ae653 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -23,7 +23,7 @@ func Init() error { viper.AutomaticEnv() env := GetDefaultString("app.env", "development") - viper.SetConfigName(fmt.Sprintf("%s.yml", strings.ToLower(env))) + viper.SetConfigName(strings.ToLower(env) + ".yml") viper.SetConfigType("yaml") viper.AddConfigPath("./config") diff --git a/pkg/db/db.go b/pkg/db/db.go index 9f12794..d4e7e03 100644 --- a/pkg/db/db.go +++ b/pkg/db/db.go @@ -5,7 +5,7 @@ import ( "context" "github.com/jackc/pgx/v5/pgxpool" - "github.com/zeusWPI/scc/pkg/sqlc" + "github.com/zeusWPI/scc/internal/database/sqlc" ) type DB interface { diff --git a/pkg/db/psql.go b/pkg/db/psql.go index 3ab39d6..83e9cc8 100644 --- a/pkg/db/psql.go +++ b/pkg/db/psql.go @@ -5,8 +5,8 @@ import ( "fmt" "github.com/jackc/pgx/v5/pgxpool" + "github.com/zeusWPI/scc/internal/database/sqlc" "github.com/zeusWPI/scc/pkg/config" - "github.com/zeusWPI/scc/pkg/sqlc" ) type psql struct { diff --git a/pkg/sqlc/song.sql.go b/pkg/sqlc/song.sql.go deleted file mode 100644 index 440b07b..0000000 --- a/pkg/sqlc/song.sql.go +++ /dev/null @@ -1,534 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 -// source: song.sql - -package sqlc - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createSong = `-- name: CreateSong :one - -INSERT INTO song (title, album, spotify_id, duration_ms, lyrics_type, lyrics) -VALUES ($1, $2, $3, $4, $5, $6) -RETURNING id -` - -type CreateSongParams struct { - Title string - Album string - SpotifyID string - DurationMs int32 - LyricsType pgtype.Text - Lyrics pgtype.Text -} - -// CRUD -func (q *Queries) CreateSong(ctx context.Context, arg CreateSongParams) (int32, error) { - row := q.db.QueryRow(ctx, createSong, - arg.Title, - arg.Album, - arg.SpotifyID, - arg.DurationMs, - arg.LyricsType, - arg.Lyrics, - ) - var id int32 - err := row.Scan(&id) - return id, err -} - -const createSongArtist = `-- name: CreateSongArtist :one -INSERT INTO song_artist (name, spotify_id, followers, popularity) -VALUES ($1, $2, $3, $4) -RETURNING id, name, spotify_id, followers, popularity -` - -type CreateSongArtistParams struct { - Name string - SpotifyID string - Followers int32 - Popularity int32 -} - -func (q *Queries) CreateSongArtist(ctx context.Context, arg CreateSongArtistParams) (SongArtist, error) { - row := q.db.QueryRow(ctx, createSongArtist, - arg.Name, - arg.SpotifyID, - arg.Followers, - arg.Popularity, - ) - var i SongArtist - err := row.Scan( - &i.ID, - &i.Name, - &i.SpotifyID, - &i.Followers, - &i.Popularity, - ) - return i, err -} - -const createSongArtistGenre = `-- name: CreateSongArtistGenre :one -INSERT INTO song_artist_genre (artist_id, genre_id) -VALUES ($1, $2) -RETURNING id, artist_id, genre_id -` - -type CreateSongArtistGenreParams struct { - ArtistID int32 - GenreID int32 -} - -func (q *Queries) CreateSongArtistGenre(ctx context.Context, arg CreateSongArtistGenreParams) (SongArtistGenre, error) { - row := q.db.QueryRow(ctx, createSongArtistGenre, arg.ArtistID, arg.GenreID) - var i SongArtistGenre - err := row.Scan(&i.ID, &i.ArtistID, &i.GenreID) - return i, err -} - -const createSongArtistSong = `-- name: CreateSongArtistSong :one -INSERT INTO song_artist_song (artist_id, song_id) -VALUES ($1, $2) -RETURNING id -` - -type CreateSongArtistSongParams struct { - ArtistID int32 - SongID int32 -} - -func (q *Queries) CreateSongArtistSong(ctx context.Context, arg CreateSongArtistSongParams) (int32, error) { - row := q.db.QueryRow(ctx, createSongArtistSong, arg.ArtistID, arg.SongID) - var id int32 - err := row.Scan(&id) - return id, err -} - -const createSongGenre = `-- name: CreateSongGenre :one -INSERT INTO song_genre (genre) -VALUES ($1) -RETURNING id, genre -` - -func (q *Queries) CreateSongGenre(ctx context.Context, genre string) (SongGenre, error) { - row := q.db.QueryRow(ctx, createSongGenre, genre) - var i SongGenre - err := row.Scan(&i.ID, &i.Genre) - return i, err -} - -const createSongHistory = `-- name: CreateSongHistory :one -INSERT INTO song_history (song_id) -VALUES ($1) -RETURNING id, song_id, created_at -` - -func (q *Queries) CreateSongHistory(ctx context.Context, songID int32) (SongHistory, error) { - row := q.db.QueryRow(ctx, createSongHistory, songID) - var i SongHistory - err := row.Scan(&i.ID, &i.SongID, &i.CreatedAt) - return i, err -} - -const getLastSongFull = `-- name: GetLastSongFull :many -SELECT s.id, s.title AS song_title, s.spotify_id, s.album, s.duration_ms, s.lyrics_type, s.lyrics, sh.created_at, a.id AS artist_id, a.name AS artist_name, a.spotify_id AS artist_spotify_id, a.followers AS artist_followers, a.popularity AS artist_popularity, g.id AS genre_id, g.genre AS genre, sh.created_at -FROM song_history sh -JOIN song s ON sh.song_id = s.id -LEFT JOIN song_artist_song sa ON s.id = sa.song_id -LEFT JOIN song_artist a ON sa.artist_id = a.id -LEFT JOIN song_artist_genre ag ON ag.artist_id = a.id -LEFT JOIN song_genre g ON ag.genre_id = g.id -WHERE sh.created_at = (SELECT MAX(created_at) FROM song_history) -ORDER BY a.name, g.genre -` - -type GetLastSongFullRow struct { - ID int32 - SongTitle string - SpotifyID string - Album string - DurationMs int32 - LyricsType pgtype.Text - Lyrics pgtype.Text - CreatedAt pgtype.Timestamptz - ArtistID pgtype.Int4 - ArtistName pgtype.Text - ArtistSpotifyID pgtype.Text - ArtistFollowers pgtype.Int4 - ArtistPopularity pgtype.Int4 - GenreID pgtype.Int4 - Genre pgtype.Text - CreatedAt_2 pgtype.Timestamptz -} - -func (q *Queries) GetLastSongFull(ctx context.Context) ([]GetLastSongFullRow, error) { - rows, err := q.db.Query(ctx, getLastSongFull) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetLastSongFullRow - for rows.Next() { - var i GetLastSongFullRow - if err := rows.Scan( - &i.ID, - &i.SongTitle, - &i.SpotifyID, - &i.Album, - &i.DurationMs, - &i.LyricsType, - &i.Lyrics, - &i.CreatedAt, - &i.ArtistID, - &i.ArtistName, - &i.ArtistSpotifyID, - &i.ArtistFollowers, - &i.ArtistPopularity, - &i.GenreID, - &i.Genre, - &i.CreatedAt_2, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getLastSongHistory = `-- name: GetLastSongHistory :one -SELECT id, song_id, created_at -FROM song_history -ORDER BY created_at DESC -LIMIT 1 -` - -func (q *Queries) GetLastSongHistory(ctx context.Context) (SongHistory, error) { - row := q.db.QueryRow(ctx, getLastSongHistory) - var i SongHistory - err := row.Scan(&i.ID, &i.SongID, &i.CreatedAt) - return i, err -} - -const getSongArtistBySpotifyID = `-- name: GetSongArtistBySpotifyID :one -SELECT id, name, spotify_id, followers, popularity -FROM song_artist -WHERE spotify_id = $1 -` - -func (q *Queries) GetSongArtistBySpotifyID(ctx context.Context, spotifyID string) (SongArtist, error) { - row := q.db.QueryRow(ctx, getSongArtistBySpotifyID, spotifyID) - var i SongArtist - err := row.Scan( - &i.ID, - &i.Name, - &i.SpotifyID, - &i.Followers, - &i.Popularity, - ) - return i, err -} - -const getSongBySpotifyID = `-- name: GetSongBySpotifyID :one - -SELECT id, title, spotify_id, duration_ms, album, lyrics_type, lyrics -FROM song -WHERE spotify_id = $1 -` - -// Other -func (q *Queries) GetSongBySpotifyID(ctx context.Context, spotifyID string) (Song, error) { - row := q.db.QueryRow(ctx, getSongBySpotifyID, spotifyID) - var i Song - err := row.Scan( - &i.ID, - &i.Title, - &i.SpotifyID, - &i.DurationMs, - &i.Album, - &i.LyricsType, - &i.Lyrics, - ) - return i, err -} - -const getSongGenreByName = `-- name: GetSongGenreByName :one -SELECT id, genre -FROM song_genre -WHERE genre = $1 -` - -func (q *Queries) GetSongGenreByName(ctx context.Context, genre string) (SongGenre, error) { - row := q.db.QueryRow(ctx, getSongGenreByName, genre) - var i SongGenre - err := row.Scan(&i.ID, &i.Genre) - return i, err -} - -const getSongHistory = `-- name: GetSongHistory :many -SELECT s.title, play_count, aggregated.created_at -FROM ( - SELECT sh.song_id, MAX(sh.created_at) AS created_at, COUNT(sh.song_id) AS play_count - FROM song_history sh - GROUP BY sh.song_id -) aggregated -JOIN song s ON aggregated.song_id = s.id -ORDER BY aggregated.created_at DESC -LIMIT 50 -` - -type GetSongHistoryRow struct { - Title string - PlayCount int64 - CreatedAt interface{} -} - -func (q *Queries) GetSongHistory(ctx context.Context) ([]GetSongHistoryRow, error) { - rows, err := q.db.Query(ctx, getSongHistory) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetSongHistoryRow - for rows.Next() { - var i GetSongHistoryRow - if err := rows.Scan(&i.Title, &i.PlayCount, &i.CreatedAt); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getTopArtists = `-- name: GetTopArtists :many -SELECT sa.id AS artist_id, sa.name AS artist_name, COUNT(sh.id) AS total_plays -FROM song_history sh -JOIN song s ON sh.song_id = s.id -JOIN song_artist_song sas ON s.id = sas.song_id -JOIN song_artist sa ON sas.artist_id = sa.id -GROUP BY sa.id, sa.name -ORDER BY total_plays DESC -LIMIT 10 -` - -type GetTopArtistsRow struct { - ArtistID int32 - ArtistName string - TotalPlays int64 -} - -func (q *Queries) GetTopArtists(ctx context.Context) ([]GetTopArtistsRow, error) { - rows, err := q.db.Query(ctx, getTopArtists) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetTopArtistsRow - for rows.Next() { - var i GetTopArtistsRow - if err := rows.Scan(&i.ArtistID, &i.ArtistName, &i.TotalPlays); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getTopGenres = `-- name: GetTopGenres :many -SELECT g.genre AS genre_name, COUNT(sh.id) AS total_plays -FROM song_history sh -JOIN song s ON sh.song_id = s.id -JOIN song_artist_song sas ON s.id = sas.song_id -JOIN song_artist sa ON sas.artist_id = sa.id -JOIN song_artist_genre sag ON sa.id = sag.artist_id -JOIN song_genre g ON sag.genre_id = g.id -GROUP BY g.genre -ORDER BY total_plays DESC -LIMIT 10 -` - -type GetTopGenresRow struct { - GenreName string - TotalPlays int64 -} - -func (q *Queries) GetTopGenres(ctx context.Context) ([]GetTopGenresRow, error) { - rows, err := q.db.Query(ctx, getTopGenres) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetTopGenresRow - for rows.Next() { - var i GetTopGenresRow - if err := rows.Scan(&i.GenreName, &i.TotalPlays); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getTopMonthlyArtists = `-- name: GetTopMonthlyArtists :many -SELECT sa.id AS artist_id, sa.name AS artist_name, COUNT(sh.id) AS total_plays -FROM song_history sh -JOIN song s ON sh.song_id = s.id -JOIN song_artist_song sas ON s.id = sas.song_id -JOIN song_artist sa ON sas.artist_id = sa.id -WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' -GROUP BY sa.id, sa.name -ORDER BY total_plays DESC -LIMIT 10 -` - -type GetTopMonthlyArtistsRow struct { - ArtistID int32 - ArtistName string - TotalPlays int64 -} - -func (q *Queries) GetTopMonthlyArtists(ctx context.Context) ([]GetTopMonthlyArtistsRow, error) { - rows, err := q.db.Query(ctx, getTopMonthlyArtists) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetTopMonthlyArtistsRow - for rows.Next() { - var i GetTopMonthlyArtistsRow - if err := rows.Scan(&i.ArtistID, &i.ArtistName, &i.TotalPlays); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getTopMonthlyGenres = `-- name: GetTopMonthlyGenres :many -SELECT g.genre AS genre_name, COUNT(sh.id) AS total_plays -FROM song_history sh -JOIN song s ON sh.song_id = s.id -JOIN song_artist_song sas ON s.id = sas.song_id -JOIN song_artist sa ON sas.artist_id = sa.id -JOIN song_artist_genre sag ON sa.id = sag.artist_id -JOIN song_genre g ON sag.genre_id = g.id -WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' -GROUP BY g.genre -ORDER BY total_plays DESC -LIMIT 10 -` - -type GetTopMonthlyGenresRow struct { - GenreName string - TotalPlays int64 -} - -func (q *Queries) GetTopMonthlyGenres(ctx context.Context) ([]GetTopMonthlyGenresRow, error) { - rows, err := q.db.Query(ctx, getTopMonthlyGenres) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetTopMonthlyGenresRow - for rows.Next() { - var i GetTopMonthlyGenresRow - if err := rows.Scan(&i.GenreName, &i.TotalPlays); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getTopMonthlySongs = `-- name: GetTopMonthlySongs :many -SELECT s.id AS song_id, s.title, COUNT(sh.id) AS play_count -FROM song_history sh -JOIN song s ON sh.song_id = s.id -WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' -GROUP BY s.id, s.title -ORDER BY play_count DESC -LIMIT 10 -` - -type GetTopMonthlySongsRow struct { - SongID int32 - Title string - PlayCount int64 -} - -func (q *Queries) GetTopMonthlySongs(ctx context.Context) ([]GetTopMonthlySongsRow, error) { - rows, err := q.db.Query(ctx, getTopMonthlySongs) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetTopMonthlySongsRow - for rows.Next() { - var i GetTopMonthlySongsRow - if err := rows.Scan(&i.SongID, &i.Title, &i.PlayCount); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getTopSongs = `-- name: GetTopSongs :many -SELECT s.id AS song_id, s.title, COUNT(sh.id) AS play_count -FROM song_history sh -JOIN song s ON sh.song_id = s.id -GROUP BY s.id, s.title -ORDER BY play_count DESC -LIMIT 10 -` - -type GetTopSongsRow struct { - SongID int32 - Title string - PlayCount int64 -} - -func (q *Queries) GetTopSongs(ctx context.Context) ([]GetTopSongsRow, error) { - rows, err := q.db.Query(ctx, getTopSongs) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetTopSongsRow - for rows.Next() { - var i GetTopSongsRow - if err := rows.Scan(&i.SongID, &i.Title, &i.PlayCount); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/pkg/utils/periodic.go b/pkg/utils/periodic.go new file mode 100644 index 0000000..3365930 --- /dev/null +++ b/pkg/utils/periodic.go @@ -0,0 +1,40 @@ +package utils + +import ( + "context" + "time" + + "go.uber.org/zap" +) + +func Periodic(name string, interval time.Duration, fn func(ctx context.Context) error, done chan bool) { + zap.S().Infof("Starting periodic task for %s", name) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + update := func() { + zap.S().Infof("Running %s", name) + if err := fn(ctx); err != nil { + zap.S().Errorf("Error %s | %w", name, err) + } + } + + // Run immediatly once + update() + +loop: + for { + select { + case <-done: + break loop + case <-ticker.C: + update() + } + } + + zap.S().Infof("Stopping periodic task for %s", name) +} diff --git a/sqlc.yml b/sqlc.yml index 6810455..d451e44 100644 --- a/sqlc.yml +++ b/sqlc.yml @@ -7,5 +7,5 @@ sql: gen: go: package: "sqlc" - out: "pkg/sqlc" + out: "internal/database/sqlc" sql_package: "pgx/v5" diff --git a/tui/components/stopwatch/stopwatch.go b/tui/components/stopwatch/stopwatch.go index 5cb90e9..46f67e8 100644 --- a/tui/components/stopwatch/stopwatch.go +++ b/tui/components/stopwatch/stopwatch.go @@ -109,10 +109,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) View() string { duration := m.duration.Round(time.Second) - min := int(duration / time.Minute) - sec := int((duration % time.Minute) / time.Second) + minutes := int(duration / time.Minute) + seconds := int((duration % time.Minute) / time.Second) - return fmt.Sprintf("%02d:%02d", min, sec) + return fmt.Sprintf("%02d:%02d", minutes, seconds) } func tick(id int64) tea.Cmd { diff --git a/tui/screen/song/song.go b/tui/screen/song/song.go index b75c353..ca48a8f 100644 --- a/tui/screen/song/song.go +++ b/tui/screen/song/song.go @@ -18,9 +18,9 @@ type Song struct { } // New creates a new song screen -func New(repo repository.Repository) screen.Screen { +func New(_ repository.Repository) screen.Screen { return &Song{ - song: song.New(repo), + song: song.New(), width: 0, height: 0, } @@ -41,6 +41,9 @@ func (s *Song) Update(msg tea.Msg) (screen.Screen, tea.Cmd) { sSong = sSong.Width(s.width - view.GetOuterWidth(sSong)).Height(s.height - sSong.GetVerticalFrameSize() - sSong.GetVerticalPadding()) return s, s.GetSizeMsg + + default: + break } cmds := make([]tea.Cmd, 0) diff --git a/tui/tui.go b/tui/tui.go index 048cf40..5432088 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -40,7 +40,12 @@ func (t *TUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { zap.S().Info("Exiting") cmds = append(cmds, tea.ExitAltScreen) cmds = append(cmds, tea.Quit) + + default: + break } + default: + break } return t, tea.Batch(cmds...) diff --git a/tui/view/event/update.go b/tui/view/event/update.go index 2e87403..edf2f56 100644 --- a/tui/view/event/update.go +++ b/tui/view/event/update.go @@ -46,7 +46,7 @@ func updateEvents(ctx context.Context, view view.View) (tea.Msg, error) { func getEvents(ctx context.Context, url string) ([]event, error) { resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ Method: "GET", - URL: fmt.Sprintf("%s/event", url), + URL: url + "/event", }) if err != nil { return nil, fmt.Errorf("get events %w", err) diff --git a/tui/view/gamification/gamification.go b/tui/view/gamification/gamification.go index cd566b7..530de1a 100644 --- a/tui/view/gamification/gamification.go +++ b/tui/view/gamification/gamification.go @@ -81,7 +81,7 @@ func (m *Model) View() string { for i, item := range m.leaderboard { user := lipgloss.JoinVertical(lipgloss.Left, positions[i].Inherit(sName).Render(fmt.Sprintf("%d. %s", i+1, item.Name)), - sScore.Render(strconv.Itoa(int(item.Score))), + sScore.Render(strconv.Itoa(item.Score)), ) im := sAvatar.Render(view.ImageToString(item.avatar, wColumn, sAll.GetHeight()-lipgloss.Height(user))) diff --git a/tui/view/gamification/update.go b/tui/view/gamification/update.go index 2df1866..5a7f170 100644 --- a/tui/view/gamification/update.go +++ b/tui/view/gamification/update.go @@ -46,7 +46,7 @@ func updateLeaderboard(ctx context.Context, view view.View) (tea.Msg, error) { func getLeaderboard(ctx context.Context, url string) ([]gamification, error) { resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ Method: "GET", - URL: fmt.Sprintf("%s/top4", url), + URL: url + "/top4", Headers: map[string]string{ "Accept": "application/json", }, diff --git a/tui/view/song/song.go b/tui/view/song/song.go index 90c5823..9591774 100644 --- a/tui/view/song/song.go +++ b/tui/view/song/song.go @@ -2,434 +2,63 @@ package song import ( - "context" - "errors" - "time" - tea "github.com/charmbracelet/bubbletea" - "github.com/jackc/pgx/v5" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/db/dto" - "github.com/zeusWPI/scc/internal/pkg/lyrics" - "github.com/zeusWPI/scc/pkg/config" - "github.com/zeusWPI/scc/tui/components/bar" - "github.com/zeusWPI/scc/tui/components/stopwatch" "github.com/zeusWPI/scc/tui/view" - "go.uber.org/zap" -) - -var ( - previousAmount = 5 // Amount of passed lyrics to show - upcomingAmount = 12 // Amount of upcoming lyrics to show ) -type stat struct { - title string - entries []statEntry -} - -type statEntry struct { - name string - amount int -} - -type playing struct { - song dto.Song - playing bool - lyrics lyrics.Lyrics - previous []string // Lyrics already sang - current string // Current lyric - upcoming []string // Lyrics that are coming up -} - -type progression struct { - stopwatch stopwatch.Model - bar bar.Model -} - -// Model represents the view model for song type Model struct { - db *db.DB - current playing - progress progression - history stat - stats []stat - statsMonthly []stat - width int - height int + width int + height int } -// Msg triggers a song data update -// Required for the view interface -type Msg struct{} - -type msgHistory struct { - history stat -} +// Interface compliance +var _ view.View = (*Model)(nil) -type msgStats struct { - monthly bool - stats []stat -} - -type msgPlaying struct { - song dto.Song - lyrics lyrics.Lyrics -} - -type msgLyrics struct { - song dto.Song - playing bool - previous []string - current string - upcoming []string - startNext time.Time -} +// Msg contains the data to update the gamification model +type Msg struct{} // New initializes a new song model -func New(db *db.DB) view.View { +func New() view.View { return &Model{ - db: db, - current: playing{}, - progress: progression{stopwatch: stopwatch.New(), bar: bar.New(sStatusBar)}, - stats: make([]stat, 4), - statsMonthly: make([]stat, 4), + width: 0, + height: 0, } } -// Init starts the song view func (m *Model) Init() tea.Cmd { - return tea.Batch( - m.progress.stopwatch.Init(), - m.progress.bar.Init(), - ) + return nil } -// Name returns the name of the view func (m *Model) Name() string { - return "Songs" + return "Song" } -// Update updates the song view func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { case view.MsgSize: // Size update! // Check if it's relevant for this view - entry, ok := msg.Sizes[m.Name()] - if ok { + if entry, ok := msg.Sizes[m.Name()]; ok { // Update all dependent styles m.width = entry.Width m.height = entry.Height - - m.updateStyles() - } - - return m, nil - - case msgPlaying: - // We're playing a song - // Initialize the variables - m.current.song = msg.song - m.current.playing = true - m.current.lyrics = msg.lyrics - m.current.current = "" - m.current.previous = []string{""} - m.current.upcoming = []string{""} - - // The song might already been playing for some time - // Let's go through the lyrics until we get to the current one - lyric, ok := m.current.lyrics.Current() - if !ok { - // Shouldn't happen - zap.S().Error("song: Unable to get current lyric in initialization phase: ", m.current.song.Title) - m.current.playing = false - return m, nil - } - - startTime := m.current.song.CreatedAt.Add(lyric.Duration) // Start time of the next lyric - for startTime.Before(time.Now()) { - // This lyric is already finished, onto the next! - lyric, ok := m.current.lyrics.Next() - if !ok { - // No more lyrics to display, the song is already finished - m.current.playing = false - return m, m.progress.stopwatch.Reset() - } - startTime = startTime.Add(lyric.Duration) - } - - // We have the right lyric, let's get the previous and upcoming lyrics - m.current.current = lyric.Text - m.current.previous = lyricsToString(m.current.lyrics.Previous(previousAmount)) - m.current.upcoming = lyricsToString(m.current.lyrics.Upcoming(upcomingAmount)) - - // Start the update loop - return m, tea.Batch( - updateLyrics(m.current, startTime), - m.progress.stopwatch.Start(time.Since(m.current.song.CreatedAt)), - m.progress.bar.Start(view.GetWidth(sStatusBar), time.Since(m.current.song.CreatedAt), time.Duration(m.current.song.DurationMS)*time.Millisecond), - ) - - case msgHistory: - m.history = msg.history - - return m, nil - - case msgStats: - if msg.monthly { - // Monthly stats - m.statsMonthly = msg.stats - return m, nil } - m.stats = msg.stats return m, nil - case msgLyrics: - // Check if it's still relevant - if msg.song.ID != m.current.song.ID { - // We already switched to a new song - return m, nil - } - - m.current.playing = msg.playing - if !m.current.playing { - // Song has finished. Reset variables - return m, m.progress.stopwatch.Reset() - } - - m.current.previous = msg.previous - m.current.current = msg.current - m.current.upcoming = msg.upcoming - - // Start the cmd to update the lyrics - return m, updateLyrics(m.current, msg.startNext) + default: + break } - // Maybe a stopwatch message? - var cmd tea.Cmd - m.progress.stopwatch, cmd = m.progress.stopwatch.Update(msg) - if cmd != nil { - return m, cmd - } - - // Apparently not, lets try the bar! - m.progress.bar, cmd = m.progress.bar.Update(msg) - - return m, cmd + return m, nil } // View draws the song view func (m *Model) View() string { - if m.current.playing { - return m.viewPlaying() - } - - return m.viewNotPlaying() + return "Not implemented" } // GetUpdateDatas gets all update functions for the song view func (m *Model) GetUpdateDatas() []view.UpdateData { - return []view.UpdateData{ - { - Name: "update current song", - View: m, - Update: updateCurrentSong, - Interval: config.GetDefaultInt("tui.view.song.interval_current_s", 5), - }, - { - Name: "update history", - View: m, - Update: updateHistory, - Interval: config.GetDefaultInt("tui.view.song.interval_history_s", 5), - }, - { - Name: "monthly stats", - View: m, - Update: updateMonthlyStats, - Interval: config.GetDefaultInt("tui.view.song.interval_monthly_stats_s", 300), - }, - { - Name: "all time stats", - View: m, - Update: updateStats, - Interval: config.GetDefaultInt("tui.view.song.interval_stats_s", 3600), - }, - } -} - -// updateCurrentSong checks if there's currently a song playing -func updateCurrentSong(view view.View) (tea.Msg, error) { - m := view.(*Model) - - songs, err := m.db.Queries.GetLastSongFull(context.Background()) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - err = nil - } - return nil, err - } - if len(songs) == 0 { - return nil, nil - } - - // Check if song is still playing - if songs[0].CreatedAt.Time.Add(time.Duration(songs[0].DurationMs) * time.Millisecond).Before(time.Now()) { - // Song is finished - return nil, nil - } - - if m.current.playing && songs[0].ID == m.current.song.ID { - // Song is already set to current - return nil, nil - } - - // Convert sqlc song to a dto song - song := *dto.SongDTOHistory(songs) - - return msgPlaying{song: song, lyrics: lyrics.New(song)}, nil -} - -// updateHistory updates the recently played list -func updateHistory(view view.View) (tea.Msg, error) { - m := view.(*Model) - - history, err := m.db.Queries.GetSongHistory(context.Background()) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - return nil, err - } - - stat := stat{title: tStatHistory, entries: []statEntry{}} - for _, h := range history { - stat.entries = append(stat.entries, statEntry{name: h.Title, amount: int(h.PlayCount)}) - } - - return msgHistory{history: stat}, nil -} - -// Update all monthly stats -func updateMonthlyStats(view view.View) (tea.Msg, error) { - m := view.(*Model) - - songs, err := m.db.Queries.GetTopMonthlySongs(context.Background()) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - return nil, err - } - - genres, err := m.db.Queries.GetTopMonthlyGenres(context.Background()) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - return nil, err - } - - artists, err := m.db.Queries.GetTopMonthlyArtists(context.Background()) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - return nil, err - } - - msg := msgStats{monthly: true, stats: []stat{}} - - // Songs - s := stat{title: tStatSong, entries: []statEntry{}} - for _, song := range songs { - s.entries = append(s.entries, statEntry{name: song.Title, amount: int(song.PlayCount)}) - } - msg.stats = append(msg.stats, s) - - // Genres - s = stat{title: tStatGenre, entries: []statEntry{}} - for _, genre := range genres { - s.entries = append(s.entries, statEntry{name: genre.GenreName, amount: int(genre.TotalPlays)}) - } - msg.stats = append(msg.stats, s) - - // Artists - s = stat{title: tStatArtist, entries: []statEntry{}} - for _, artist := range artists { - s.entries = append(s.entries, statEntry{name: artist.ArtistName, amount: int(artist.TotalPlays)}) - } - msg.stats = append(msg.stats, s) - - return msg, nil -} - -// Update all stats -func updateStats(view view.View) (tea.Msg, error) { - m := view.(*Model) - - songs, err := m.db.Queries.GetTopSongs(context.Background()) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - return nil, err - } - - genres, err := m.db.Queries.GetTopGenres(context.Background()) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - return nil, err - } - - artists, err := m.db.Queries.GetTopArtists(context.Background()) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - return nil, err - } - - // Don't bother checking if anything has changed - // A single extra refresh won't matter - - msg := msgStats{monthly: false, stats: []stat{}} - - // Songs - s := stat{title: tStatSong, entries: []statEntry{}} - for _, song := range songs { - s.entries = append(s.entries, statEntry{name: song.Title, amount: int(song.PlayCount)}) - } - msg.stats = append(msg.stats, s) - - // Genres - s = stat{title: tStatGenre, entries: []statEntry{}} - for _, genre := range genres { - s.entries = append(s.entries, statEntry{name: genre.GenreName, amount: int(genre.TotalPlays)}) - } - msg.stats = append(msg.stats, s) - - // Artists - s = stat{title: tStatArtist, entries: []statEntry{}} - for _, artist := range artists { - s.entries = append(s.entries, statEntry{name: artist.ArtistName, amount: int(artist.TotalPlays)}) - } - msg.stats = append(msg.stats, s) - - return msg, nil -} - -// Update the current lyric -func updateLyrics(song playing, start time.Time) tea.Cmd { - // How long do we need to wait until we can update the lyric? - timeout := time.Duration(0) - now := time.Now() - if start.After(now) { - timeout = start.Sub(now) - } - - return tea.Tick(timeout, func(_ time.Time) tea.Msg { - // Next lyric - lyric, ok := song.lyrics.Next() - if !ok { - // Song finished - return msgLyrics{song: song.song, playing: false} // Values in the other fields are not looked at when the song is finished - } - - previous := song.lyrics.Previous(previousAmount) - upcoming := song.lyrics.Upcoming(upcomingAmount) - - end := start.Add(lyric.Duration) - - return msgLyrics{ - song: song.song, - playing: true, - previous: lyricsToString(previous), - current: lyric.Text, - upcoming: lyricsToString(upcoming), - startNext: end, - } - }) + return nil } diff --git a/tui/view/song/style.go b/tui/view/song/style.go deleted file mode 100644 index 062d8f2..0000000 --- a/tui/view/song/style.go +++ /dev/null @@ -1,106 +0,0 @@ -package song - -import ( - "math" - - "github.com/charmbracelet/lipgloss" - "github.com/zeusWPI/scc/tui/view" -) - -// Title for statistics -const ( - tStatHistory = "Recently Played" - tStatSong = "Top Songs" - tStatGenre = "Top Genres" - tStatArtist = "Top Artists" -) - -// Colors -var ( - cZeus = lipgloss.Color("#FF7F00") - cSpotify = lipgloss.Color("#1DB954") - cBorder = lipgloss.Color("#383838") -) - -// Base style -var base = lipgloss.NewStyle() - -// Styles for the stats -var ( - // Widths - wStatEnum = 3 - wStatAmount = 4 // Supports up to 1000 - wStatEntryMax = 35 - - // Styles - sStat = base.Align(lipgloss.Center).BorderStyle(lipgloss.NormalBorder()).BorderTop(true).BorderForeground(cBorder).PaddingTop(1) - sStatOne = base.Margin(0, 1) - sStatTitle = base.Foreground(cZeus).Align(lipgloss.Center).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(cSpotify) - sStatEnum = base.Foreground(cSpotify).Width(wStatEnum).Align(lipgloss.Left) - sStatEntry = base.Align(lipgloss.Left) - sStatAmount = base.Foreground(cZeus).Width(wStatAmount).Align(lipgloss.Right) - - // Specific styles for when no song is playing - sStatCategory = base.Align(lipgloss.Center) - sStatCategoryTitle = base.Foreground(cZeus).Align(lipgloss.Center).Border(lipgloss.NormalBorder(), true, false).BorderForeground(cBorder) - sStatHistory = base.MarginRight(1).PaddingRight(2).Border(lipgloss.ThickBorder(), false, true, false, false).BorderForeground(cBorder) -) - -// Styles for the lyrics -var ( - wLyricsF = 0.8 // Fraction of width - - sLyric = base.AlignVertical(lipgloss.Center).Align(lipgloss.Center) - sLyricPrevious = base.Foreground(cZeus).Bold(true).Align(lipgloss.Center).Faint(true) - sLyricCurrent = base.Foreground(cZeus).Bold(true).Align(lipgloss.Center) - sLyricUpcoming = base.Foreground(cSpotify).Bold(true).Align(lipgloss.Center) -) - -// Styles for the status -var ( - sStatus = base.MarginTop(1) - sStatusSong = base.Align(lipgloss.Center) - sStatusStopwatch = base.Faint(true) - sStatusBar = base.Foreground(cZeus).Align(lipgloss.Left) -) - -// Style for everything -var ( - sAll = base.Align(lipgloss.Center).AlignVertical(lipgloss.Center) -) - -// updateStyles updates all the affected styles when a size update message is received -func (m *Model) updateStyles() { - // Adjust stats styles - sStat = sStat.Width(m.width) - - wStatEntry := int(math.Min(float64(wStatEntryMax), float64(m.width/4)-float64(view.GetOuterWidth(sStatOne)+wStatEnum+wStatAmount))) - sStatEntry = sStatEntry.Width(wStatEntry) - sStatOne = sStatOne.Width(wStatEnum + wStatAmount + wStatEntry) - sStatTitle = sStatTitle.Width(wStatEnum + wStatAmount + wStatEntry) - if wStatEntry == wStatEntryMax { - // We're full screen - sStatOne = sStatOne.Margin(0, 3) - } - sStatCategory = sStatCategory.Width(2 * (sStatOne.GetWidth() + view.GetOuterWidth(sStatOne))) - sStatCategoryTitle = sStatCategoryTitle.Width(2*sStatOne.GetWidth() + view.GetOuterWidth(sStatOne)) - - // Adjust lyrics styles - sLyric = sLyric.Width(m.width) - - wLyrics := int(float64(m.width) * wLyricsF) - sLyricPrevious = sLyricPrevious.Width(wLyrics) - sLyricCurrent = sLyricCurrent.Width(wLyrics) - sLyricUpcoming = sLyricUpcoming.Width(wLyrics) - - // Adjust status styles - - sStatusSong = sStatusSong.Width(m.width - view.GetOuterWidth(sStatusSong)) - sStatusBar = sStatusBar.Width(m.width - view.GetOuterWidth(sStatusBar)) - - // Adjust the all styles - sAll = sAll.Height(m.height - view.GetOuterHeight(sAll)). - MaxHeight(m.height - view.GetOuterHeight(sAll)). - Width(m.width - view.GetOuterWidth(sAll)). - MaxWidth(m.width - view.GetOuterWidth(sAll)) -} diff --git a/tui/view/song/util.go b/tui/view/song/util.go deleted file mode 100644 index c3808ac..0000000 --- a/tui/view/song/util.go +++ /dev/null @@ -1,13 +0,0 @@ -package song - -import ( - "github.com/zeusWPI/scc/internal/pkg/lyrics" -) - -func lyricsToString(lyrics []lyrics.Lyric) []string { - text := make([]string, 0, len(lyrics)) - for _, lyric := range lyrics { - text = append(text, lyric.Text) - } - return text -} diff --git a/tui/view/song/view.go b/tui/view/song/view.go deleted file mode 100644 index bc0d26f..0000000 --- a/tui/view/song/view.go +++ /dev/null @@ -1,162 +0,0 @@ -package song - -import ( - "fmt" - "math" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -func (m *Model) viewPlaying() string { - status := m.viewPlayingStatus() - status = sStatus.Render(status) - - stats := m.viewPlayingStats() - stats = sStat.Render(stats) - - lyrics := m.viewPlayingLyrics() - lyrics = sLyric.Height(sAll.GetHeight() - lipgloss.Height(status) - lipgloss.Height(stats)). - MaxHeight(sAll.GetHeight() - lipgloss.Height(status) - lipgloss.Height(stats)). - Render(lyrics) - - view := lipgloss.JoinVertical(lipgloss.Left, status, lyrics, stats) - - return sAll.Render(view) -} - -func (m *Model) viewPlayingStatus() string { - // Stopwatch - durationS := int(math.Round(float64(m.current.song.DurationMS) / 1000)) - stopwatch := fmt.Sprintf("\t%s / %02d:%02d", m.progress.stopwatch.View(), durationS/60, durationS%60) - stopwatch = sStatusStopwatch.Render(stopwatch) - - // Song name - var artists strings.Builder - for _, artist := range m.current.song.Artists { - artists.WriteString(artist.Name + " & ") - } - artist := artists.String() - if len(artist) > 0 { - artist = artist[:len(artist)-3] - } - - song := sStatusSong.Width(sStatusSong.GetWidth() - lipgloss.Width(stopwatch)).Render(fmt.Sprintf("%s | %s", m.current.song.Title, artist)) - - // Progress bar - progress := m.progress.bar.View() - progress = sStatusBar.Render(progress) - - view := lipgloss.JoinHorizontal(lipgloss.Top, song, stopwatch) - view = lipgloss.JoinVertical(lipgloss.Left, view, progress) - - return view -} - -func (m *Model) viewPlayingLyrics() string { - var previousB strings.Builder - for i, lyric := range m.current.previous { - previousB.WriteString(lyric) - if i != len(m.current.previous)-1 { - previousB.WriteString("\n") - } - } - previous := sLyricPrevious.Render(previousB.String()) - - current := sLyricCurrent.Render(m.current.current) - - var upcomingB strings.Builder - for _, lyric := range m.current.upcoming { - upcomingB.WriteString(lyric) - upcomingB.WriteString("\n") - } - upcoming := sLyricUpcoming.Render(upcomingB.String()) - - return sLyric.Render(lipgloss.JoinVertical(lipgloss.Left, previous, current, upcoming)) -} - -func (m *Model) viewPlayingStats() string { - columns := make([]string, 0, 4) - - columns = append(columns, m.viewStatPlaying(m.history)) - columns = append(columns, m.viewStatPlaying(m.statsMonthly[0])) - columns = append(columns, m.viewStatPlaying(m.statsMonthly[1])) - columns = append(columns, m.viewStatPlaying(m.statsMonthly[2])) - - return lipgloss.JoinHorizontal(lipgloss.Top, columns...) -} - -func (m *Model) viewNotPlaying() string { - // Render stats - rows := make([][]string, 0, 3) - for i := 0; i < 3; i++ { - rows = append(rows, make([]string, 0, 2)) - } - - rows[0] = append(rows[0], m.viewStatPlaying(m.statsMonthly[0], "Monthly")) - rows[0] = append(rows[0], m.viewStatPlaying(m.stats[0], "All Time")) - rows[1] = append(rows[1], m.viewStatPlaying(m.statsMonthly[1], "Monthly")) - rows[1] = append(rows[1], m.viewStatPlaying(m.stats[1], "All Time")) - rows[2] = append(rows[2], m.viewStatPlaying(m.statsMonthly[2], "Monthly")) - rows[2] = append(rows[2], m.viewStatPlaying(m.stats[2], "All Time")) - - renderedRows := make([]string, 0, 3) - var title string - for i, row := range rows { - r := lipgloss.JoinHorizontal(lipgloss.Top, row...) - title = sStatCategory.Render(sStatCategoryTitle.Render(m.stats[i].title)) // HACK: Make border same size as 2 stats next to each other - renderedRows = append(renderedRows, lipgloss.JoinVertical(lipgloss.Left, title, r)) - } - - v := lipgloss.JoinVertical(lipgloss.Left, renderedRows...) - - // Render history - items := make([]string, 0, len(m.history.entries)) - - // Push it down - for range lipgloss.Height(title) { - items = append(items, "") - } - items = append(items, sStatTitle.Render(m.history.title)) - - for i, entry := range m.history.entries { - enum := sStatEnum.Render(fmt.Sprintf("%d.", i+1)) - body := sStatEntry.Render(entry.name) - amount := sStatAmount.Render(strconv.Itoa(entry.amount)) - items = append(items, lipgloss.JoinHorizontal(lipgloss.Top, enum, body, amount)) - } - items = append(items, "") // HACK: Avoid the last item shifting to the right - list := lipgloss.JoinVertical(lipgloss.Left, items...) - // title := sStatTitle.Render(m.history.title) - history := sStatHistory.Height(lipgloss.Height(v) - 1).MaxHeight(lipgloss.Height(v) - 1).Render(list) // - 1 to compensate for the hack newline at the end - - v = lipgloss.JoinHorizontal(lipgloss.Top, history, v) - - return sAll.Render(v) -} - -func (m *Model) viewStatPlaying(stat stat, titleOpt ...string) string { - title := stat.title - if len(titleOpt) > 0 { - title = titleOpt[0] - } - - items := make([]string, 0, len(stat.entries)) - for i := range stat.entries { - if i >= 10 { - break - } - - enum := sStatEnum.Render(fmt.Sprintf("%d.", i+1)) - body := sStatEntry.Render(stat.entries[i].name) - amount := sStatAmount.Render(strconv.Itoa(stat.entries[i].amount)) - - items = append(items, lipgloss.JoinHorizontal(lipgloss.Top, enum, body, amount)) - } - items = append(items, "") // HACK: Avoid the last item shifting to the right - l := lipgloss.JoinVertical(lipgloss.Left, items...) - - t := sStatTitle.Render(title) - - return sStatOne.Render(lipgloss.JoinVertical(lipgloss.Left, t, l)) -} diff --git a/tui/view/zess/view.go b/tui/view/zess/view.go index 523f5fc..48a9f24 100644 --- a/tui/view/zess/view.go +++ b/tui/view/zess/view.go @@ -45,9 +45,9 @@ func (m *Model) viewStats() string { var amount string if week.scans == maxScans.scans { - amount = sStatMax.Inherit(sStatAmount).Render(strconv.Itoa(int(week.scans))) + amount = sStatMax.Inherit(sStatAmount).Render(strconv.Itoa(week.scans)) } else { - amount = sStatAmount.Render(strconv.Itoa(int(week.scans))) + amount = sStatAmount.Render(strconv.Itoa(week.scans)) } text := lipgloss.JoinHorizontal(lipgloss.Top, weekStr, amount) From bcf5e17b9b4fccb3376eb8b345ff2a9fe1b64147 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 9 Sep 2025 15:26:28 +0200 Subject: [PATCH 08/15] refactor: idk it's been too long --- cmd/tui/tui.go | 2 +- config/development.yml | 3 ++- makefile | 6 ++---- pkg/utils/periodic.go | 2 +- tui/theme/color.go | 14 +++++++++++++ tui/view/event/style.go | 29 ++++++++++++++------------- tui/view/event/update.go | 4 ++-- tui/view/event/view.go | 5 +++++ tui/view/gamification/gamification.go | 2 +- tui/view/gamification/styles.go | 17 +++++----------- tui/view/gamification/update.go | 19 ++++++++---------- tui/view/util.go | 4 ++++ 12 files changed, 60 insertions(+), 47 deletions(-) create mode 100644 tui/theme/color.go diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go index 17bd457..7ef87b8 100644 --- a/cmd/tui/tui.go +++ b/cmd/tui/tui.go @@ -28,7 +28,7 @@ func main() { } // Logger - zapLogger, err := logger.New(*screen+".log", false) + zapLogger, err := logger.New(*screen, false) if err != nil { panic(err) } diff --git a/config/development.yml b/config/development.yml index 417c376..9c56e9c 100644 --- a/config/development.yml +++ b/config/development.yml @@ -85,7 +85,8 @@ tui: interval_s: 3600 message: - interval_s: 1 + # TODO: Change back to 1 + interval_s: 300 song: interval_current_s: 5 diff --git a/makefile b/makefile index 89bed3e..5626c0a 100644 --- a/makefile +++ b/makefile @@ -39,14 +39,12 @@ clean-tui: backend: @docker compose up -d - @[ -f $(BACKEND_BIN) ] || $(MAKE) build-backend - @./$(BACKEND_BIN) + @go run $(BACKEND_SRC) @docker compose down tui: - @[ -f $(TUI_BIN) ] || $(MAKE) build-tui @read -p "Enter screen name: " screen; \ - ./$(TUI_BIN) $$screen + go run $(TUI_SRC) -screen $$screen goose: @docker compose down diff --git a/pkg/utils/periodic.go b/pkg/utils/periodic.go index 3365930..3b7370f 100644 --- a/pkg/utils/periodic.go +++ b/pkg/utils/periodic.go @@ -19,7 +19,7 @@ func Periodic(name string, interval time.Duration, fn func(ctx context.Context) update := func() { zap.S().Infof("Running %s", name) if err := fn(ctx); err != nil { - zap.S().Errorf("Error %s | %w", name, err) + zap.S().Errorf("Error %s | %v", name, err) } } diff --git a/tui/theme/color.go b/tui/theme/color.go new file mode 100644 index 0000000..ce55229 --- /dev/null +++ b/tui/theme/color.go @@ -0,0 +1,14 @@ +// Package theme contains the tui theme variables +package theme + +import "github.com/charmbracelet/lipgloss" + +var ( + Zeus = lipgloss.Color("#FF7F00") + + Gold = lipgloss.Color("#FFBF00") + Bronze = lipgloss.Color("#CD7F32") + Red = lipgloss.Color("#EE4B2B") + + Border = lipgloss.Color("#383838") +) diff --git a/tui/view/event/style.go b/tui/view/event/style.go index 87a0f71..b626b4a 100644 --- a/tui/view/event/style.go +++ b/tui/view/event/style.go @@ -2,17 +2,10 @@ package event import ( "github.com/charmbracelet/lipgloss" + "github.com/zeusWPI/scc/tui/theme" "github.com/zeusWPI/scc/tui/view" ) -// Color -var ( - cZeus = lipgloss.Color("#FF7F00") - cWarning = lipgloss.Color("#EE4B2B") - cBorder = lipgloss.Color("#383838") - cUpcoming = lipgloss.Color("#FFBF00") -) - // Base style var base = lipgloss.NewStyle() @@ -26,20 +19,20 @@ var ( sOvAll = base.Padding(0, 1) // Style for the overview and the poster sOvPoster = base.AlignVertical(lipgloss.Center) sOv = base.AlignVertical(lipgloss.Center).MarginRight(wOvGap) // Style for the overview of the events - sOvTitle = base.Bold(true).Foreground(cWarning).Align(lipgloss.Center).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(cBorder) + sOvTitle = base.Bold(true).Foreground(theme.Red).Align(lipgloss.Center).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(theme.Border) // Styles for passed events sOvPassedDate = base.Width(wOvDate).Faint(true) - sOvPassedText = base.Foreground(cZeus).Faint(true) + sOvPassedText = base.Foreground(theme.Zeus).Faint(true) // Styles for next event sOvNextDate = base.Width(wOvDate).Bold(true) - sOvNextText = base.Bold(true).Foreground(cZeus) + sOvNextText = base.Bold(true).Foreground(theme.Zeus) sOvNextLoc = base.Italic(true) // Styles for the upcoming envets sOvUpcomingDate = base.Width(wOvDate).Faint(true) - sOvUpcomingText = base.Foreground(cUpcoming) + sOvUpcomingText = base.Foreground(theme.Gold) sOvUpcomingLoc = base.Italic(true).Faint(true) ) @@ -54,10 +47,16 @@ var ( sToday = base.AlignVertical(lipgloss.Center).MarginLeft(wOvGap).Padding(1, 0).Border(lipgloss.DoubleBorder(), true, false) // Style for the event sTodayDate = base.Align(lipgloss.Center) - sTodayText = base.Align(lipgloss.Center).Bold(true).Foreground(cZeus).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(cBorder) + sTodayText = base.Align(lipgloss.Center).Bold(true).Foreground(theme.Zeus).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(theme.Border) sTodayeLoc = base.Align(lipgloss.Center).Italic(true).Faint(true) ) +// Styles for no events +var ( + sNoTitle = base.Bold(true).Foreground(theme.Red).Align(lipgloss.Center).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(theme.Border) + sNoEvents = base.Align(lipgloss.Center) +) + func (m *Model) updateStyles() { // Adjust the styles for the overview wOvPoster = (m.width - wOvGap - view.GetOuterWidth(sOvAll)) / 2 @@ -89,4 +88,6 @@ func (m *Model) updateStyles() { sTodayDate = sTodayDate.Width(wTodayEv) sTodayText = sTodayText.Width(wTodayEv) sTodayeLoc = sTodayeLoc.Width(wTodayEv) -} + + // Adjust the styles for no events + diff --git a/tui/view/event/update.go b/tui/view/event/update.go index edf2f56..7d8710a 100644 --- a/tui/view/event/update.go +++ b/tui/view/event/update.go @@ -70,9 +70,9 @@ func getEvents(ctx context.Context, url string) ([]event, error) { var mu sync.Mutex var wg sync.WaitGroup - for _, event := range events { + for i := range events { wg.Go(func() { - if err := getPoster(ctx, url, &event); err != nil { + if err := getPoster(ctx, url, &events[i]); err != nil { mu.Lock() errs = append(errs, err) mu.Unlock() diff --git a/tui/view/event/view.go b/tui/view/event/view.go index 7b5cc60..d14ae7b 100644 --- a/tui/view/event/view.go +++ b/tui/view/event/view.go @@ -110,3 +110,8 @@ func (m *Model) viewGetEventOverview(passed, upcoming []event) string { return sOv.Render(view) } + +func (m *Model) viewNoEvents() string { + title := sOvTitle.Render("Events") + noEvents := +} diff --git a/tui/view/gamification/gamification.go b/tui/view/gamification/gamification.go index 530de1a..90866f6 100644 --- a/tui/view/gamification/gamification.go +++ b/tui/view/gamification/gamification.go @@ -32,7 +32,7 @@ type Msg struct { type gamification struct { Name string `json:"github_name"` Score int `json:"score"` - AvatarURL string `json:"avartar_url"` + AvatarURL string `json:"avatar_url"` avatar image.Image } diff --git a/tui/view/gamification/styles.go b/tui/view/gamification/styles.go index 0f7bcf0..70f4d00 100644 --- a/tui/view/gamification/styles.go +++ b/tui/view/gamification/styles.go @@ -2,17 +2,10 @@ package gamification import ( "github.com/charmbracelet/lipgloss" + "github.com/zeusWPI/scc/tui/theme" "github.com/zeusWPI/scc/tui/view" ) -// Colors -var ( - cGold = lipgloss.Color("#FFD700") - cZeus = lipgloss.Color("#FF7F00") - cBronze = lipgloss.Color("#CD7F32") - cBorder = lipgloss.Color("#383838") -) - // Base style var base = lipgloss.NewStyle() @@ -32,9 +25,9 @@ var ( // Styles for the positions var positions = []lipgloss.Style{ - base.Foreground(cGold), - base.Foreground(cZeus), - base.Foreground(cBronze), + base.Foreground(theme.Gold), + base.Foreground(theme.Zeus), + base.Foreground(theme.Bronze), base, } @@ -45,7 +38,7 @@ func (m *Model) updateStyles() { // Adjust styles wColumn = (sAll.GetWidth() - view.GetOuterWidth(sAll) - view.GetOuterWidth(sColumn)*wAmount) / wAmount - sName = sName.Width(wColumn).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(cBorder) + sName = sName.Width(wColumn).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(theme.Border) sScore = sScore.Width(wColumn) sAvatar = sAvatar.Width(wColumn) } diff --git a/tui/view/gamification/update.go b/tui/view/gamification/update.go index 5a7f170..4dd23e4 100644 --- a/tui/view/gamification/update.go +++ b/tui/view/gamification/update.go @@ -1,13 +1,11 @@ package gamification import ( - "bytes" "context" "encoding/json" "errors" "fmt" "image" - "io" "slices" "sync" @@ -28,7 +26,7 @@ func updateLeaderboard(ctx context.Context, view view.View) (tea.Msg, error) { return nil, err } - slices.SortFunc(leaderboard, func(a, b gamification) int { return a.Score - b.Score }) + slices.SortFunc(leaderboard, func(a, b gamification) int { return b.Score - a.Score }) if len(leaderboard) != len(m.leaderboard) { return Msg{leaderboard: leaderboard}, nil @@ -69,9 +67,9 @@ func getLeaderboard(ctx context.Context, url string) ([]gamification, error) { var mu sync.Mutex var wg sync.WaitGroup - for _, gam := range gams { + for i := range gams { wg.Go(func() { - if err := getAvatar(ctx, &gam); err != nil { + if err := getAvatar(ctx, &gams[i]); err != nil { mu.Lock() errs = append(errs, err) mu.Unlock() @@ -89,6 +87,10 @@ func getLeaderboard(ctx context.Context, url string) ([]gamification, error) { } func getAvatar(ctx context.Context, gam *gamification) error { + if gam.AvatarURL == "" { + return nil + } + resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ Method: "GET", URL: gam.AvatarURL, @@ -101,12 +103,7 @@ func getAvatar(ctx context.Context, gam *gamification) error { _ = resp.Body.Close() }() - avatarBytes, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("read avatar bytes %+v | %w", *gam, err) - } - - img, _, err := image.Decode(bytes.NewReader(avatarBytes)) + img, _, err := image.Decode(resp.Body) if err != nil { return fmt.Errorf("decode gamification avatar %+v | %w", *gam, err) } diff --git a/tui/view/util.go b/tui/view/util.go index fc0431b..d2a63b0 100644 --- a/tui/view/util.go +++ b/tui/view/util.go @@ -12,6 +12,10 @@ import ( // ImageToString converts an image to a string // If either widht or height is 0 then the aspect ratio is kept func ImageToString(img image.Image, width, height int) string { + if img == nil { + return "" + } + if width == 0 || height == 0 { return imageToString(imaging.Resize(img, width, height, imaging.Lanczos)) } From 8e914852027425480e113e6b5edfc2a0b3a3af82 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 29 Oct 2025 14:36:33 +0100 Subject: [PATCH 09/15] feat(cammie): add buzzer sound back --- config/development.yml | 3 +- go.mod | 13 --- go.sum | 137 ++++++++++--------------- internal/buzzer/buzzer.go | 15 ++- internal/database/repository/season.go | 14 --- internal/server/dto/message.go | 24 ++++- internal/server/service/message.go | 28 ++++- pkg/utils/slice.go | 12 +++ tui/view/event/style.go | 8 +- tui/view/event/view.go | 15 ++- 10 files changed, 130 insertions(+), 139 deletions(-) diff --git a/config/development.yml b/config/development.yml index 9c56e9c..417c376 100644 --- a/config/development.yml +++ b/config/development.yml @@ -85,8 +85,7 @@ tui: interval_s: 3600 message: - # TODO: Change back to 1 - interval_s: 300 + interval_s: 1 song: interval_current_s: 5 diff --git a/go.mod b/go.mod index eeaa094..409164f 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/disintegration/imaging v1.6.2 github.com/go-playground/validator/v10 v10.27.0 - github.com/gocolly/colly v1.2.0 github.com/gofiber/contrib/fiberzap v1.0.2 github.com/gofiber/fiber/v2 v2.52.9 github.com/jackc/pgx/v5 v5.7.5 @@ -23,12 +22,7 @@ require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/ClickHouse/ch-go v0.65.1 // indirect github.com/ClickHouse/clickhouse-go/v2 v2.34.0 // indirect - github.com/PuerkitoBio/goquery v1.10.3 // indirect github.com/andybalholm/brotli v1.2.0 // indirect - github.com/andybalholm/cascadia v1.3.3 // indirect - github.com/antchfx/htmlquery v1.3.4 // indirect - github.com/antchfx/xmlquery v1.4.4 // indirect - github.com/antchfx/xpath v1.3.5 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/bubbles v0.21.0 // indirect @@ -52,12 +46,9 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sql-driver/mysql v1.9.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/gobwas/glob v0.2.3 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect - github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect - github.com/golang/protobuf v1.5.4 // indirect github.com/google/cel-go v0.24.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -66,7 +57,6 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect - github.com/kennygrant/sanitize v1.2.4 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lrstanley/bubblezone v1.0.0 // indirect @@ -95,7 +85,6 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/riza-io/grpc-go v0.2.0 // indirect github.com/sagikazarmark/locafero v0.10.0 // indirect - github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect @@ -107,7 +96,6 @@ require ( github.com/sqlc-dev/sqlc v1.29.0 // indirect github.com/stoewer/go-strcase v1.2.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/temoto/robotstxt v1.1.2 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect @@ -133,7 +121,6 @@ require ( golang.org/x/telemetry v0.0.0-20250807160809-1a19826ec488 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/tools v0.36.0 // indirect - google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect google.golang.org/grpc v1.71.1 // indirect diff --git a/go.sum b/go.sum index 8b37c49..a8d3a7f 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,18 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 h1:U2rTu3Ef+7w9FHKIAXM6ZyqF3UOWJZ12zIm8zECAFfg= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/ClickHouse/ch-go v0.65.1 h1:SLuxmLl5Mjj44/XbINsK2HFvzqup0s6rwKLFH347ZhU= github.com/ClickHouse/ch-go v0.65.1/go.mod h1:bsodgURwmrkvkBe5jw1qnGDgyITsYErfONKAHn05nv4= @@ -11,19 +23,8 @@ github.com/ClickHouse/clickhouse-go/v2 v2.34.0 h1:Y4rqkdrRHgExvC4o/NTbLdY5LFQ3LH github.com/ClickHouse/clickhouse-go/v2 v2.34.0/go.mod h1:yioSINoRLVZkLyDzdMXPLRIqhDvel8iLBlwh6Iefso8= github.com/NimbleMarkets/ntcharts v0.3.1 h1:EH4O80RMy5rqDmZM7aWjTbCSuRDDJ5fXOv/qAzdwOjk= github.com/NimbleMarkets/ntcharts v0.3.1/go.mod h1:zVeRqYkh2n59YPe1bflaSL4O2aD2ZemNmrbdEqZ70hk= -github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= -github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= -github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= -github.com/antchfx/htmlquery v1.3.4 h1:Isd0srPkni2iNTWCwVj/72t7uCphFeor5Q8nCzj1jdQ= -github.com/antchfx/htmlquery v1.3.4/go.mod h1:K9os0BwIEmLAvTqaNSua8tXLWRWZpocZIH73OzWQbwM= -github.com/antchfx/xmlquery v1.4.4 h1:mxMEkdYP3pjKSftxss4nUHfjBhnMk4imGoR96FRY2dg= -github.com/antchfx/xmlquery v1.4.4/go.mod h1:AEPEEPYE9GnA2mj5Ur2L5Q5/2PycJ0N9Fusrx9b12fc= -github.com/antchfx/xpath v1.3.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= -github.com/antchfx/xpath v1.3.5 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ= -github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= @@ -92,6 +93,10 @@ github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -104,10 +109,6 @@ github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRj github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= -github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/gocolly/colly v1.2.0 h1:qRz9YAn8FIH0qzgNUw+HT9UN7wm1oF9OBAilwEWpyrI= -github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA= github.com/gofiber/contrib/fiberzap v1.0.2 h1:EQwhggtszVfIdBeXxN9Xrmld71es34Ufs+ef8VMqZxc= github.com/gofiber/contrib/fiberzap v1.0.2/go.mod h1:jGO8BHU4gRI9U0JtM6zj2CIhYfgVmW5JxziN8NTgVwE= github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= @@ -115,14 +116,13 @@ github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPArei github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -150,8 +150,10 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -174,8 +176,6 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= -github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o= -github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= @@ -188,6 +188,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA= @@ -235,6 +237,8 @@ github.com/pingcap/log v1.1.0 h1:ELiPxACz7vdo1qAvvaWJg1NrYFoY6gqAh/+Uo6aXdD8= github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4= github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 h1:W3rpAI3bubR6VWOcwxDIG0Gz9G5rl5b3SL116T0vBt0= github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0/go.mod h1:+8feuexTKcXHZF/dkDfvCwEyBAmgb4paFc3/WeYV2eE= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -245,6 +249,8 @@ github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1: github.com/prometheus/procfs v0.0.0-20190425082905-87a4384529e0/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rekby/fixenv v0.6.1 h1:jUFiSPpajT4WY2cYuc++7Y1zWrnCxnovGCIX72PZniM= +github.com/rekby/fixenv v0.6.1/go.mod h1:/b5LRc06BYJtslRtHKxsPWFT/ySpHV+rWvzTg+XWk4c= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -253,13 +259,11 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ= github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc= github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw= -github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA= -github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= @@ -293,8 +297,6 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg= -github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo= github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= @@ -324,12 +326,19 @@ github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1/go.mod h1:l5sSv153E18VvYcsmr51hok github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= @@ -341,6 +350,8 @@ go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0 go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -351,12 +362,7 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -371,11 +377,6 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -388,15 +389,7 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -408,12 +401,6 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -427,38 +414,16 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20250807160809-1a19826ec488 h1:3doPGa+Gg4snce233aCWnbZVFsyFMo/dR40KK/6skyE= golang.org/x/telemetry v0.0.0-20250807160809-1a19826ec488/go.mod h1:fGb/2+tgXXjhjHsTNdVEEMZNWA0quBnfrO+AfoDSAKw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -471,10 +436,6 @@ golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -483,8 +444,6 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= -google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= @@ -539,11 +498,27 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA= +modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc= +modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY= +modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= +modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y= modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/buzzer/buzzer.go b/internal/buzzer/buzzer.go index 2a20b6b..57755ee 100644 --- a/internal/buzzer/buzzer.go +++ b/internal/buzzer/buzzer.go @@ -8,8 +8,8 @@ import ( "go.uber.org/zap" ) -// Buzzer represents a buzzer -type Buzzer struct { +// Client represents a buzzer +type Client struct { Song []string } @@ -26,19 +26,18 @@ var defaultSong = []string{ } // New returns a new buzzer instance -func New() *Buzzer { - return &Buzzer{ +func New() *Client { + return &Client{ Song: config.GetDefaultStringSlice("backend.buzzer.song", defaultSong), } } // Play plays the buzzer -func (b *Buzzer) Play() { +func (c *Client) Play() { // See `man beep` for more information - cmd := exec.Command("beep", b.Song...) + cmd := exec.Command("beep", c.Song...) err := cmd.Run() - if err != nil { - zap.L().Error("Error running command 'beep'", zap.Error(err)) + zap.S().Error("Error running command 'beep' %v", err) } } diff --git a/internal/database/repository/season.go b/internal/database/repository/season.go index 3eb5c71..92cc2cb 100644 --- a/internal/database/repository/season.go +++ b/internal/database/repository/season.go @@ -10,7 +10,6 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/zeusWPI/scc/internal/database/model" "github.com/zeusWPI/scc/internal/database/sqlc" - "github.com/zeusWPI/scc/pkg/utils" ) type Season struct { @@ -23,19 +22,6 @@ func (r *Repository) NewSeason() *Season { } } -// TODO: Check still used -func (s *Season) GetAll(ctx context.Context) ([]*model.Season, error) { - seasons, err := s.repo.queries(ctx).SeasonGetAll(ctx) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("season get all %w", err) - } - return nil, nil - } - - return utils.SliceMap(seasons, model.SeasonModel), nil -} - func (s *Season) GetCurrent(ctx context.Context) (*model.Season, error) { season, err := s.repo.queries(ctx).SeasonGetCurrent(ctx) if err != nil { diff --git a/internal/server/dto/message.go b/internal/server/dto/message.go index 521488f..9ea1a84 100644 --- a/internal/server/dto/message.go +++ b/internal/server/dto/message.go @@ -1,3 +1,25 @@ package dto -type Message struct{} +import "github.com/zeusWPI/scc/internal/database/model" + +type Message struct { + Name string `json:"name" validate:"required"` + IP string `json:"ip" validate:"required"` + Message string `json:"message" validate:"required"` +} + +func MessageDTO(msg *model.Message) Message { + return Message{ + Name: msg.Name, + IP: msg.IP, + Message: msg.Message, + } +} + +func (m *Message) ToModel() *model.Message { + return &model.Message{ + Name: m.Name, + IP: m.IP, + Message: m.Message, + } +} diff --git a/internal/server/service/message.go b/internal/server/service/message.go index 2f3cc6d..e4fb62e 100644 --- a/internal/server/service/message.go +++ b/internal/server/service/message.go @@ -3,16 +3,34 @@ package service import ( "context" + "github.com/gofiber/fiber/v2" + "github.com/zeusWPI/scc/internal/buzzer" + "github.com/zeusWPI/scc/internal/database/repository" "github.com/zeusWPI/scc/internal/server/dto" + "go.uber.org/zap" ) -type Message struct{} +type Message struct { + message repository.Message + + buzzer buzzer.Client +} func (s *Service) NewMessage() *Message { - return &Message{} + return &Message{ + message: *s.repo.NewMessage(), + buzzer: *buzzer.New(), + } } -func (m *Message) Create(_ context.Context, _ dto.Message) (dto.Message, error) { - // TODO: fill in - return dto.Message{}, nil +func (m *Message) Create(ctx context.Context, msgSave dto.Message) (dto.Message, error) { + msg := msgSave.ToModel() + if err := m.message.Create(ctx, msg); err != nil { + zap.S().Error(err) + return dto.Message{}, fiber.ErrInternalServerError + } + + m.buzzer.Play() + + return dto.MessageDTO(msg), nil } diff --git a/pkg/utils/slice.go b/pkg/utils/slice.go index 4887dc5..7011c33 100644 --- a/pkg/utils/slice.go +++ b/pkg/utils/slice.go @@ -139,3 +139,15 @@ func Reduce[T any, U any](slice []T, combine func(U, T) U) U { } return accum } + +// SliceGet returns the first x items +// If the slice doesn't have enough items then it returns +// all items +func SliceGet[T any](slice []T, amount int) []T { + result := make([]T, 0, amount) + for i := range min(amount, len(slice)) { + result = append(result, slice[i]) + } + + return result +} diff --git a/tui/view/event/style.go b/tui/view/event/style.go index b626b4a..989ed78 100644 --- a/tui/view/event/style.go +++ b/tui/view/event/style.go @@ -51,12 +51,6 @@ var ( sTodayeLoc = base.Align(lipgloss.Center).Italic(true).Faint(true) ) -// Styles for no events -var ( - sNoTitle = base.Bold(true).Foreground(theme.Red).Align(lipgloss.Center).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(theme.Border) - sNoEvents = base.Align(lipgloss.Center) -) - func (m *Model) updateStyles() { // Adjust the styles for the overview wOvPoster = (m.width - wOvGap - view.GetOuterWidth(sOvAll)) / 2 @@ -90,4 +84,4 @@ func (m *Model) updateStyles() { sTodayeLoc = sTodayeLoc.Width(wTodayEv) // Adjust the styles for no events - +} diff --git a/tui/view/event/view.go b/tui/view/event/view.go index d14ae7b..0ab0ce9 100644 --- a/tui/view/event/view.go +++ b/tui/view/event/view.go @@ -8,6 +8,11 @@ import ( "github.com/zeusWPI/scc/tui/view" ) +const ( + amountOfPassed = 1 + amountOfFuture = 3 +) + func (m *Model) viewToday() string { today, ok := utils.SliceFind(m.events, func(e event) bool { return utils.SameDay(e.Start, time.Now()) }) if !ok { @@ -38,10 +43,9 @@ func (m *Model) viewToday() string { return sTodayAll.Render(view) } -// TODO: update rendering func (m *Model) viewOverview() string { - passed := utils.SliceFilter(m.events, func(e event) bool { return e.Start.Before(time.Now()) }) - upcoming := utils.SliceFilter(m.events, func(e event) bool { return e.Start.After(time.Now()) }) + passed := utils.SliceGet(utils.SliceFilter(m.events, func(e event) bool { return e.Start.Before(time.Now()) }), amountOfPassed) + upcoming := utils.SliceGet(utils.SliceFilter(m.events, func(e event) bool { return e.Start.After(time.Now()) }), amountOfFuture) // Poster if present poster := "" @@ -110,8 +114,3 @@ func (m *Model) viewGetEventOverview(passed, upcoming []event) string { return sOv.Render(view) } - -func (m *Model) viewNoEvents() string { - title := sOvTitle.Render("Events") - noEvents := -} From 06a25a340b3f804c2f40512ee0207f790733a441 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 29 Oct 2025 14:38:58 +0100 Subject: [PATCH 10/15] chore(http): check for status codes --- internal/database/repository/scan.go | 1 - internal/tap/api.go | 4 ++++ internal/zess/api.go | 8 ++++++++ pkg/utils/http.go | 1 - tui/view/gamification/update.go | 8 ++++++++ 5 files changed, 20 insertions(+), 2 deletions(-) diff --git a/internal/database/repository/scan.go b/internal/database/repository/scan.go index 2315dd0..55d5531 100644 --- a/internal/database/repository/scan.go +++ b/internal/database/repository/scan.go @@ -22,7 +22,6 @@ func (r *Repository) NewScan() *Scan { } } -// TODO: I think unused. Veryify with g + r and make dead func (s *Scan) GetLast(ctx context.Context) (*model.Scan, error) { scan, err := s.repo.queries(ctx).ScanGetLast(ctx) if err != nil { diff --git a/internal/tap/api.go b/internal/tap/api.go index 00ca72f..88b5679 100644 --- a/internal/tap/api.go +++ b/internal/tap/api.go @@ -61,6 +61,10 @@ func (t *Tap) getOrders(ctx context.Context) ([]model.Tap, error) { _ = resp.Body.Close() }() + if resp.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code %s", resp.Status) + } + var orders orderResponse if err := json.NewDecoder(resp.Body).Decode(&orders); err != nil { diff --git a/internal/zess/api.go b/internal/zess/api.go index 473c42b..64146ca 100644 --- a/internal/zess/api.go +++ b/internal/zess/api.go @@ -40,6 +40,10 @@ func (z *Zess) getSeasons(ctx context.Context) ([]model.Season, error) { _ = resp.Body.Close() }() + if resp.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code %s", resp.Status) + } + var seasons []seasonAPI if err := json.NewDecoder(resp.Body).Decode(&seasons); err != nil { return nil, fmt.Errorf("decode http zess seasons %w", err) @@ -73,6 +77,10 @@ func (z *Zess) getScans(ctx context.Context) ([]model.Scan, error) { _ = resp.Body.Close() }() + if resp.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code %s", resp.Status) + } + var scans []scanAPI if err := json.NewDecoder(resp.Body).Decode(&scans); err != nil { return nil, fmt.Errorf("decode http zess scans %w", err) diff --git a/pkg/utils/http.go b/pkg/utils/http.go index 36df81c..8a86176 100644 --- a/pkg/utils/http.go +++ b/pkg/utils/http.go @@ -29,6 +29,5 @@ func DoRequest(ctx context.Context, values DoRequestValues) (*http.Response, err return nil, fmt.Errorf("do http request %+v | %w", values, err) } - // TODO: Update all references now it doesnt check the response code return resp, nil } diff --git a/tui/view/gamification/update.go b/tui/view/gamification/update.go index 4dd23e4..2aac4ef 100644 --- a/tui/view/gamification/update.go +++ b/tui/view/gamification/update.go @@ -57,6 +57,10 @@ func getLeaderboard(ctx context.Context, url string) ([]gamification, error) { _ = resp.Body.Close() }() + if resp.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code %s", resp.Status) + } + var gams []gamification if err := json.NewDecoder(resp.Body).Decode(&gams); err != nil { return nil, fmt.Errorf("decode gamification response %w", err) @@ -103,6 +107,10 @@ func getAvatar(ctx context.Context, gam *gamification) error { _ = resp.Body.Close() }() + if resp.StatusCode != 200 { + return fmt.Errorf("unexpected status code %s", resp.Status) + } + img, _, err := image.Decode(resp.Body) if err != nil { return fmt.Errorf("decode gamification avatar %+v | %w", *gam, err) From 46e5c83bc0a1e606513ecac4874a997674b6bc9d Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 30 Oct 2025 11:51:19 +0100 Subject: [PATCH 11/15] fix: minor generic fixes --- .../20250829172236_drop_song_table.sql | 57 ------------------- internal/buzzer/buzzer.go | 22 ++++++- internal/tap/api.go | 21 ++++--- makefile | 4 +- tui/view/tap/update.go | 4 ++ tui/view/zess/view.go | 9 ++- 6 files changed, 44 insertions(+), 73 deletions(-) delete mode 100644 db/migrations/20250829172236_drop_song_table.sql diff --git a/db/migrations/20250829172236_drop_song_table.sql b/db/migrations/20250829172236_drop_song_table.sql deleted file mode 100644 index 0803cc6..0000000 --- a/db/migrations/20250829172236_drop_song_table.sql +++ /dev/null @@ -1,57 +0,0 @@ --- +goose Up --- +goose StatementBegin -DROP TABLE song_history; -DROP TABLE song_artist; -DROP TABLE song_genre; -DROP TABLE song_artist_song; -DROP TABLE song_artist_genre; -DROP TABLE song; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -CREATE TABLE song ( - id SERIAL PRIMARY KEY, - title TEXT NOT NULL, - spotify_id TEXT NOT NULL, - duration_ms INTEGER NOT NULL, - album TEXT NOT NULL - lyrics_type TEXT, - lyrics TEXT -); - -CREATE TABLE song_history ( - id SERIAL PRIMARY KEY, - song_id INTEGER NOT NULL REFERENCES song (id), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT (NOW() - INTERVAL '1 second') -); - -CREATE TABLE song_genre ( - id SERIAL PRIMARY KEY, - genre TEXT NOT NULL -); - -CREATE TABLE song_artist ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - spotify_id TEXT NOT NULL, - followers INTEGER NOT NULL, - popularity INTEGER NOT NULL -); - -CREATE TABLE song_artist_song ( - id SERIAL PRIMARY KEY, - artist_id INTEGER NOT NULL, - song_id INTEGER NOT NULL, - FOREIGN KEY(artist_id) REFERENCES song_artist(id) ON DELETE CASCADE, - FOREIGN KEY(song_id) REFERENCES song(id) ON DELETE CASCADE -); - -CREATE TABLE song_artist_genre ( - id SERIAL PRIMARY KEY, - artist_id INTEGER NOT NULL, - genre_id INTEGER NOT NULL, - FOREIGN KEY(artist_id) REFERENCES song_artist(id) ON DELETE CASCADE, - FOREIGN KEY(genre_id) REFERENCES song_genre(id) ON DELETE CASCADE -); --- +goose StatementEnd diff --git a/internal/buzzer/buzzer.go b/internal/buzzer/buzzer.go index 57755ee..73a0cda 100644 --- a/internal/buzzer/buzzer.go +++ b/internal/buzzer/buzzer.go @@ -10,7 +10,8 @@ import ( // Client represents a buzzer type Client struct { - Song []string + hasBuzzer bool + song []string } var defaultSong = []string{ @@ -27,15 +28,30 @@ var defaultSong = []string{ // New returns a new buzzer instance func New() *Client { + hasBuzzer := false + if _, err := exec.LookPath("beep"); err == nil { + hasBuzzer = true + } + + if !hasBuzzer { + zap.S().Debug("No beep executable found.\nMock messages will be used instead") + } + return &Client{ - Song: config.GetDefaultStringSlice("backend.buzzer.song", defaultSong), + hasBuzzer: hasBuzzer, + song: config.GetDefaultStringSlice("backend.buzzer.song", defaultSong), } } // Play plays the buzzer func (c *Client) Play() { + if !c.hasBuzzer { + zap.S().Info("BEEEEEEEP") + return + } + // See `man beep` for more information - cmd := exec.Command("beep", c.Song...) + cmd := exec.Command("beep", c.song...) err := cmd.Run() if err != nil { zap.S().Error("Error running command 'beep' %v", err) diff --git a/internal/tap/api.go b/internal/tap/api.go index 88b5679..bf575af 100644 --- a/internal/tap/api.go +++ b/internal/tap/api.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "slices" + "strings" "time" "github.com/zeusWPI/scc/internal/database/model" @@ -21,20 +23,23 @@ type orderResponse struct { Orders []orderResponseItem `json:"orders"` } -func (o orderResponse) ToModel() []model.Tap { +func (o orderResponse) ToModel(beers []string) []model.Tap { taps := make([]model.Tap, 0, len(o.Orders)) for _, order := range o.Orders { var category model.TapCategory = "unknown" switch order.ProductCategory { - case "soft": - category = model.Soft - case "mate": - category = model.Mate - case "beer": - category = model.Beer case "food": category = model.Food + case "beverages": + switch { + case strings.Contains(order.ProductName, "Mate") || strings.Contains(order.ProductName, "Mio Mio"): + category = model.Mate + case slices.ContainsFunc(beers, func(beer string) bool { return strings.Contains(order.ProductName, beer) }): + category = model.Beer + default: + category = model.Soft + } } taps = append(taps, model.Tap{ @@ -71,5 +76,5 @@ func (t *Tap) getOrders(ctx context.Context) ([]model.Tap, error) { return nil, fmt.Errorf("decode tap order response %w", err) } - return orders.ToModel(), nil + return orders.ToModel(t.beers), nil } diff --git a/makefile b/makefile index 5626c0a..d39c449 100644 --- a/makefile +++ b/makefile @@ -51,14 +51,14 @@ goose: @docker compose up db -d @docker compose exec db bash -c 'until pg_isready -U postgres; do sleep 1; done' @read -p "Action: " action; \ - go tool goose -dir ./db/migrations postgres "user=postgres password=postgres host=localhost port=5431 dbname=website sslmode=disable" $$action + go tool goose -dir ./db/migrations postgres "user=postgres password=postgres host=localhost port=5432 dbname=scc sslmode=disable" $$action @docker compose down db migrate: @docker compose down @docker compose up db -d @docker compose exec db bash -c 'until pg_isready -U postgres; do sleep 1; done' - @go tool goose -dir ./db/migrations postgres "user=postgres password=postgres host=localhost port=5431 dbname=website sslmode=disable" up + @go tool goose -dir ./db/migrations postgres "user=postgres password=postgres host=localhost port=5432 dbname=scc sslmode=disable" up @docker compose down db create-migration: diff --git a/tui/view/tap/update.go b/tui/view/tap/update.go index 1c0d51c..2f69cd0 100644 --- a/tui/view/tap/update.go +++ b/tui/view/tap/update.go @@ -2,6 +2,7 @@ package tap import ( "context" + "slices" tea "github.com/charmbracelet/bubbletea" "github.com/zeusWPI/scc/internal/database/model" @@ -29,5 +30,8 @@ func updateOrders(ctx context.Context, view view.View) (tea.Msg, error) { return nil, nil } + slices.SortFunc(counts, func(a, b *model.TapCount) int { return b.Count - a.Count }) + counts = utils.SliceFilter(counts, func(c *model.TapCount) bool { return c.Category != model.Unknown }) + return Msg{items: utils.SliceDereference(counts)}, nil } diff --git a/tui/view/zess/view.go b/tui/view/zess/view.go index 48a9f24..3b00c88 100644 --- a/tui/view/zess/view.go +++ b/tui/view/zess/view.go @@ -37,14 +37,17 @@ func (m *Model) viewStats() string { // Overview of each week rows := make([]string, 0, len(m.weeks)) - maxScans := slices.MaxFunc(m.weeks, func(a, b week) int { return a.scans - b.scans }) + maxScans := 0 + if len(m.weeks) != 0 { + maxScans = slices.MaxFunc(m.weeks, func(a, b week) int { return a.scans - b.scans }).scans + } for _, week := range m.weeks { - weekNumber, _ := week.start.ISOWeek() + _, weekNumber := week.start.ISOWeek() weekStr := sStatDate.Render(fmt.Sprintf("W%02d - %s", weekNumber, week.start.Format("01/02"))) var amount string - if week.scans == maxScans.scans { + if week.scans == maxScans { amount = sStatMax.Inherit(sStatAmount).Render(strconv.Itoa(week.scans)) } else { amount = sStatAmount.Render(strconv.Itoa(week.scans)) From 6c5776c0f4d2e1c48e620a775113ee3da423925e Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 30 Oct 2025 12:27:23 +0100 Subject: [PATCH 12/15] chore(lyric): readd --- internal/lyrics/instrumental.go | 309 ++++++++++++++++++++++++++++++++ internal/lyrics/lrc.go | 118 ++++++++++++ internal/lyrics/lyrics.go | 38 ++++ internal/lyrics/missing.go | 45 +++++ internal/lyrics/plain.go | 44 +++++ 5 files changed, 554 insertions(+) create mode 100644 internal/lyrics/instrumental.go create mode 100644 internal/lyrics/lrc.go create mode 100644 internal/lyrics/lyrics.go create mode 100644 internal/lyrics/missing.go create mode 100644 internal/lyrics/plain.go diff --git a/internal/lyrics/instrumental.go b/internal/lyrics/instrumental.go new file mode 100644 index 0000000..f2ca075 --- /dev/null +++ b/internal/lyrics/instrumental.go @@ -0,0 +1,309 @@ +package lyrics + +import ( + "fmt" + "math/rand/v2" + "time" + + "github.com/zeusWPI/scc/internal/database/model" +) + +type Instrumental struct { + song model.Song + lyrics []Lyric + i int +} + +func newInstrumental(song model.Song) Lyrics { + return &Instrumental{song: song, lyrics: generateInstrumental(time.Duration(song.DurationMS) * time.Millisecond), i: 0} +} + +func (i *Instrumental) GetSong() model.Song { + return i.song +} + +func (i *Instrumental) Previous(amount int) []Lyric { + lyrics := make([]Lyric, 0, amount) + + for j := 1; j <= amount; j++ { + if i.i-j-1 < 0 { + break + } + + lyrics = append([]Lyric{i.lyrics[i.i-j-1]}, lyrics...) + } + + return lyrics +} + +func (i *Instrumental) Current() (Lyric, bool) { + if i.i >= len(i.lyrics) { + return Lyric{}, false + } + + return i.lyrics[i.i], true +} + +func (i *Instrumental) Next() (Lyric, bool) { + if i.i+1 >= len(i.lyrics) { + return Lyric{}, false + } + + i.i++ + return i.lyrics[i.i-1], true +} + +func (i *Instrumental) Upcoming(amount int) []Lyric { + lyrics := make([]Lyric, 0, amount) + + for j := range amount { + if i.i+j >= len(i.lyrics) { + break + } + + lyrics = append(lyrics, i.lyrics[i.i+j]) + } + + return lyrics +} + +func (i *Instrumental) Progress() float64 { + return float64(i.i) / float64(len(i.lyrics)) +} + +func generateInstrumental(dur time.Duration) []Lyric { + // Get all instruments with their frequency + freqs := []instrument{} + for _, instr := range instruments { + for range instr.frequency { + freqs = append(freqs, instr) + } + } + + lyrics := []Lyric{} + // Split up song in segments between 5 and 15 seconds + currentDur := time.Duration(0) + for currentDur < dur { + // Get a random instrument + instr := freqs[rand.IntN(len(freqs))] + + // Get a random duration + randomDur := time.Duration(rand.IntN(10)+5) * time.Second + currentDur += randomDur + if currentDur >= dur { + randomDur -= (currentDur - dur) + } + + // Get the lyrics + lyrics = append(lyrics, instr.generate(randomDur)...) + } + + return lyrics +} + +// Instruments + +type instrument struct { + frequency int // Odds of it occuring + name string // Name of the instrument + sounds []string // Different ways it could sound +} + +// generate creates lyrics for a specific instrument +func (i instrument) generate(dur time.Duration) []Lyric { + lyrics := []Lyric{} + + // Same logic as in `generateInstrumental` except that the segments are between 1 and 4 seconds + + // Add the start lyric + text := fmt.Sprintf(openings[rand.IntN(len(openings))], i.name) + randomDur := time.Duration(1) * time.Second + lyrics = append(lyrics, Lyric{Text: text, Duration: randomDur}) + + currentDur := randomDur + for currentDur < dur { + // Get a random text + textLength := rand.IntN(5) + 1 + var text string + for range textLength { + text += i.sounds[rand.IntN(len(i.sounds))] + " " + } + + // Get a random duration + randomDur := time.Duration(rand.IntN(3)+1) * time.Second + currentDur += randomDur + if currentDur >= dur { + // Last lyric, add a newline + text += "\n" + randomDur -= (currentDur - dur) + } + + lyrics = append(lyrics, Lyric{Text: text, Duration: randomDur}) + } + + return lyrics +} + +// All instruments to choose from +var instruments = []instrument{ + {frequency: 9, name: "Piano", sounds: []string{"plink", "plonk", "pling", "clink", "clang"}}, + {frequency: 7, name: "Drums", sounds: []string{"boom", "ba-dum", "thwack", "tshh", "bop"}}, + {frequency: 5, name: "Electric Guitar", sounds: []string{"wah", "zzzzzz", "twang", "vrrr", "brrraang"}}, + {frequency: 3, name: "Theremin", sounds: []string{"wooOOOooo", "weeeee", "ooooo", "waaAAaah", "hummmm"}}, + {frequency: 6, name: "Flute", sounds: []string{"toot", "fweee", "trillll", "pip", "peep"}}, + {frequency: 4, name: "Accordion", sounds: []string{"wheeze", "honk", "phwoo", "eep", "squawk"}}, + {frequency: 8, name: "Violin", sounds: []string{"screee", "swish", "zing", "vwee", "mreee"}}, + {frequency: 5, name: "Saxophone", sounds: []string{"saxxy", "bwoop", "dooo", "reebop", "honka"}}, + {frequency: 2, name: "Kazoo", sounds: []string{"bzzzzz", "zwip", "vwoo", "brrr", "zzzzrrt"}}, + {frequency: 9, name: "Trumpet", sounds: []string{"brrraaap", "toot", "doo-doo", "wah-wah", "parp"}}, + {frequency: 3, name: "Cowbell", sounds: []string{"clang", "clong", "ding", "donk", "bonk"}}, + {frequency: 2, name: "Bagpipes", sounds: []string{"drone", "hrooo", "whine", "skree", "rrrrrrr"}}, + {frequency: 6, name: "Triangle", sounds: []string{"ting", "tang", "ding", "dling", "plink"}}, + {frequency: 1, name: "Didgeridoo", sounds: []string{"whooooo", "womp", "drrrrrr", "brrrrr", "hummmmm"}}, + {frequency: 4, name: "Bongos", sounds: []string{"pop", "tap", "dum", "ba-dum", "bop"}}, + {frequency: 7, name: "Harp", sounds: []string{"plink", "tinkle", "zling", "glint", "ding"}}, + {frequency: 5, name: "Maracas", sounds: []string{"sh-sh-sh", "shaka-shaka", "chick", "rattle", "tktktk"}}, + {frequency: 3, name: "Tuba", sounds: []string{"oompah", "bruhm", "whoom", "booo", "phrum"}}, + {frequency: 1, name: "Banjo", sounds: []string{"twang", "plink", "brrrring", "plunk", "doink"}}, + {frequency: 2, name: "Synthesizer", sounds: []string{"beep-boop", "vwee-vwee", "zorp", "waah", "ding"}}, + {frequency: 6, name: "Xylophone", sounds: []string{"ding", "dunk", "plink-plonk", "tok", "tink"}}, + {frequency: 2, name: "Hurdy-Gurdy", sounds: []string{"whirr", "drone", "skreee", "buzz", "rrrrrng"}}, + {frequency: 4, name: "Harmonica", sounds: []string{"wheeze", "toot", "hoo", "blow", "brrrr"}}, + {frequency: 3, name: "Slide Whistle", sounds: []string{"whoooop", "wheeee", "wooo", "boooo", "zwip"}}, + {frequency: 5, name: "Tambourine", sounds: []string{"jingle", "shake-shake", "tshh", "tinkle", "ting-ting"}}, + {frequency: 2, name: "Ocarina", sounds: []string{"woo", "fweee", "doodle", "pip-pip", "toot"}}, + {frequency: 8, name: "Acoustic Guitar", sounds: []string{"strum", "plang", "twang", "zing", "thrum"}}, + {frequency: 1, name: "Sousaphone", sounds: []string{"toot", "boop", "pah-pah", "oompah", "pwaaah"}}, + {frequency: 3, name: "Castanets", sounds: []string{"clack", "click", "clap", "tick", "tack"}}, + {frequency: 7, name: "Synth Drum", sounds: []string{"pshh", "bzzt", "bip", "tsh", "zorp"}}, + {frequency: 2, name: "Bag of Gravel", sounds: []string{"crunch", "scrape", "sh-sh", "clatter", "grrnk"}}, + {frequency: 5, name: "Steel Drum", sounds: []string{"pong", "ding", "donk", "bop", "ting"}}, + {frequency: 4, name: "Mouth Harp", sounds: []string{"boing", "thwong", "zzzt", "doyoyoy", "wobble"}}, + {frequency: 2, name: "Rainstick", sounds: []string{"shhhhh", "rrrrrr", "drip-drop", "fwssh", "ssss"}}, + {frequency: 1, name: "Toy Piano", sounds: []string{"plink", "tink-tink", "chime", "plinkity", "dink"}}, + {frequency: 3, name: "Jaw Harp", sounds: []string{"twang", "boing", "doink", "womp", "zzzrrrt"}}, + {frequency: 4, name: "Bicycle Horn", sounds: []string{"honk", "meeep", "awoooga", "brrrt", "bop-bop"}}, + {frequency: 2, name: "Glass Harp", sounds: []string{"wheee", "zing", "woo", "glint", "oooo"}}, + {frequency: 6, name: "Claves", sounds: []string{"clack", "click", "clonk", "tak", "tok"}}, + {frequency: 3, name: "Rubber Band", sounds: []string{"twang", "ping", "boing", "zing", "snap"}}, + {frequency: 2, name: "Paper Comb", sounds: []string{"buzz", "brrr", "wobble", "zzzt", "drone"}}, + {frequency: 1, name: "Duck Call", sounds: []string{"quack", "wak-wak", "honk", "waak", "weeek"}}, + {frequency: 5, name: "Handbells", sounds: []string{"ding", "dong", "chime", "tinkle", "bong"}}, + {frequency: 4, name: "Foghorn", sounds: []string{"MOOOO", "hoooonk", "BWAAAA", "WOOOO", "brrrmmm"}}, + {frequency: 7, name: "Cello", sounds: []string{"mmmm", "vmmm", "vroom", "dronnn", "zoomm"}}, + {frequency: 6, name: "Clarinet", sounds: []string{"toot", "wooo", "hmmm", "dee-dee", "reeee"}}, + {frequency: 8, name: "Oboe", sounds: []string{"hweee", "hee", "whee", "ooooo", "reee"}}, + {frequency: 5, name: "French Horn", sounds: []string{"vooom", "phoo", "bwoo", "vuuum", "whooo"}}, + {frequency: 6, name: "Bassoon", sounds: []string{"boo", "brrrr", "phrum", "wuuu", "vrrr"}}, + {frequency: 8, name: "Timpani", sounds: []string{"boom", "dum", "rumble", "thud", "pum"}}, + {frequency: 7, name: "Double Bass", sounds: []string{"vrumm", "dumm", "boooom", "grumm", "zzzooom"}}, + {frequency: 9, name: "Trumpet", sounds: []string{"brrrmp", "doo-doo", "toot", "baap", "dah-dah"}}, + {frequency: 6, name: "Trombone", sounds: []string{"wah-wah", "dooo", "wooo", "bwaaah", "vroom"}}, + {frequency: 4, name: "Harp", sounds: []string{"plink", "strum", "zinnnng", "twang", "gliss"}}, + {frequency: 6, name: "Piccolo", sounds: []string{"peep", "tweet", "fweep", "weeet", "pweep"}}, + {frequency: 7, name: "Bass Drum", sounds: []string{"boom", "thud", "pum", "dum", "bomp"}}, + {frequency: 5, name: "Snare Drum", sounds: []string{"rat-a-tat", "tsh", "tktktk", "snap", "crack"}}, + {frequency: 7, name: "Tuba", sounds: []string{"pah-pah", "brumm", "booom", "ooooh", "vrooo"}}, + {frequency: 6, name: "Viola", sounds: []string{"mmmmm", "zoooo", "veee", "whooo", "vrreee"}}, + {frequency: 5, name: "Glockenspiel", sounds: []string{"ding", "tinkle", "ping", "plink", "chime"}}, + {frequency: 7, name: "Organ", sounds: []string{"hummmm", "ooooo", "voooom", "drone", "wooo"}}, + {frequency: 4, name: "Bass Clarinet", sounds: []string{"mmmm", "brooo", "bwooo", "rooo", "vrmmm"}}, + {frequency: 6, name: "English Horn", sounds: []string{"hooo", "wheee", "woooo", "phmmm", "breee"}}, + {frequency: 8, name: "Concert Bass Drum", sounds: []string{"BOOM", "rumble", "dum", "doom", "pum"}}, + {frequency: 5, name: "Cymbals", sounds: []string{"crash", "clang", "clash", "shing", "chhhh"}}, + {frequency: 6, name: "Recorder", sounds: []string{"tweet", "toot", "peep", "reep", "fweee"}}, + {frequency: 5, name: "Baritone Saxophone", sounds: []string{"vrooo", "booo", "bop", "grmmm", "vrooom"}}, + {frequency: 7, name: "Marimba", sounds: []string{"tok", "tonk", "dunk", "dong", "bong"}}, +} + +var openings = []string{ + "The sound of %s fills the air", + "Everyone listens as %s takes over", + "A melody rises, played by %s", + "The stage belongs to %s now", + "You can hear %s in the distance", + "All eyes are on %s as it begins", + "The music swells, led by %s", + "A soft hum emerges from %s", + "Powerful notes erupt from %s", + "The rhythm shifts, thanks to %s", + "From the corner, %s adds its voice", + "The harmony is completed by %s", + "Suddenly, %s makes its presence known", + "In the mix, %s finds its place", + "A delicate tune floats out of %s", + "The energy builds, driven by %s", + "A resonant sound comes from %s", + "The silence is broken by %s", + "An unmistakable sound flows from %s", + "Everything changes when %s joins in", + "The audience is captivated by %s", + "The backdrop hums with the sound of %s", + "A new tone emerges, thanks to %s", + "The piece takes flight with %s", + "A rich sound emanates from %s", + "The music deepens as %s plays", + "Out of nowhere, %s begins to play", + "The atmosphere transforms with %s", + "The melody comes alive with %s", + "A wave of sound builds around %s", + "The air is electrified by %s", + "The composition breathes through %s", + "A bright tone emerges from %s", + "The song's heartbeat is driven by %s", + "In the chaos, %s finds its voice", + "The layers of sound are enriched by %s", + "A subtle rhythm flows from %s", + "The crowd stirs as %s joins the fray", + "The lead shifts to %s for a moment", + "The balance is perfected by %s", + "The soul of the piece resonates with %s", + "A cascade of notes falls from %s", + "The performance peaks with %s", + "Each note feels alive with %s playing", + "The essence of the tune shines through %s", + "A haunting sound drifts from %s", + "The soundscape expands with %s", + "The magic unfolds around %s", + "The rhythm breathes new life through %s", + "From the shadows, %s contributes a tone", + "The journey continues with %s", + "A bold entrance by %s turns heads", + "The crescendo builds, led by %s", + "The quiet is punctuated by %s", + "The song finds its pulse in %s", + "The atmosphere shimmers with %s", + "A tender phrase is born from %s", + "The mood shifts under the spell of %s", + "%s brings a new layer to the melody", + "%s fills the space with its sound", + "%s adds depth to the composition", + "%s carries the tune to new heights", + "%s weaves through the harmony effortlessly", + "%s resonates with a rich and vibrant tone", + "%s shapes the rhythm with precision", + "%s colors the soundscape beautifully", + "%s takes the lead with bold notes", + "%s softens the mood with its melody", + "%s breathes life into the music", + "%s anchors the harmony with steady tones", + "%s dances through the melody with ease", + "%s punctuates the silence with clarity", + "%s soars above the other instruments", + "%s enriches the atmosphere with its presence", + "%s blends seamlessly into the symphony", + "%s echoes the spirit of the piece", + "%s shines as the centerpiece of the sound", + "%s threads its voice into the composition", + "%s carries the weight of the rhythm", + "%s bursts forth with dynamic energy", + "%s hums softly, anchoring the melody", + "%s paints vivid colors with its notes", + "%s rises and falls with graceful precision", + "%s whispers a delicate phrase into the mix", + "%s transforms the tune with its entrance", + "%s gives the piece a fresh perspective", + "%s stirs emotions with every note", + "%s intertwines with the harmony effortlessly", + "%s drives the pulse of the music forward", +} diff --git a/internal/lyrics/lrc.go b/internal/lyrics/lrc.go new file mode 100644 index 0000000..14f5c34 --- /dev/null +++ b/internal/lyrics/lrc.go @@ -0,0 +1,118 @@ +package lyrics + +import ( + "regexp" + "strconv" + "strings" + "time" + + "github.com/zeusWPI/scc/internal/database/model" +) + +var re = regexp.MustCompile(`^\[(\d{2}):(\d{2})\.(\d{2})\]`) + +type LRC struct { + song model.Song + lyrics []Lyric + i int +} + +func newLRC(song model.Song) Lyrics { + return &LRC{song: song, lyrics: parseLRC(song.Lyrics, time.Duration(song.DurationMS)), i: 0} +} + +func (l *LRC) GetSong() model.Song { + return l.song +} + +func (l *LRC) Previous(amount int) []Lyric { + lyrics := make([]Lyric, 0, amount) + + for i := 1; i <= amount; i++ { + if l.i-i-1 < 0 { + break + } + + lyrics = append([]Lyric{l.lyrics[l.i-i-1]}, lyrics...) + } + + return lyrics +} + +func (l *LRC) Current() (Lyric, bool) { + if l.i >= len(l.lyrics) { + return Lyric{}, false + } + + return l.lyrics[l.i], true +} + +func (l *LRC) Next() (Lyric, bool) { + if l.i+1 >= len(l.lyrics) { + return Lyric{}, false + } + + l.i++ + return l.lyrics[l.i-1], true +} + +func (l *LRC) Upcoming(amount int) []Lyric { + lyrics := make([]Lyric, 0, amount) + + for i := 0; i < amount; i++ { + if l.i+i >= len(l.lyrics) { + break + } + + lyrics = append(lyrics, l.lyrics[l.i+i]) + } + + return lyrics +} + +func (l *LRC) Progress() float64 { + return float64(l.i) / float64(len(l.lyrics)) +} + +func parseLRC(text string, totalDuration time.Duration) []Lyric { + lines := strings.Split(text, "\n") + + if len(lines) == 0 { + return []Lyric{} + } + + lyrics := make([]Lyric, 0, len(lines)+1) // + 1 for a start empty lyric + var previousTimestamp time.Duration + + // Add first lyric (no text) + lyrics = append(lyrics, Lyric{Text: ""}) + previousTimestamp = time.Duration(0) + + for i, line := range lines { + parts := strings.SplitN(line, " ", 2) + if len(parts) != 2 { + continue + } + + // Duration part + timeParts := re.FindStringSubmatch(parts[0]) + minutes, _ := strconv.Atoi(timeParts[1]) + seconds, _ := strconv.Atoi(timeParts[2]) + hundredths, _ := strconv.Atoi(timeParts[3]) + timestamp := time.Duration(minutes)*time.Minute + + time.Duration(seconds)*time.Second + + time.Duration(hundredths)*10*time.Millisecond + + // Actual lyric + lyric := parts[1] + + lyrics = append(lyrics, Lyric{Text: lyric}) + lyrics[i].Duration = timestamp - previousTimestamp + previousTimestamp = timestamp + } + + // Set duration of last lyric + lyrics[len(lyrics)-1].Duration = totalDuration - previousTimestamp + + return lyrics +} diff --git a/internal/lyrics/lyrics.go b/internal/lyrics/lyrics.go new file mode 100644 index 0000000..b5bcddc --- /dev/null +++ b/internal/lyrics/lyrics.go @@ -0,0 +1,38 @@ +// Package lyrics provides a way to work with both synced and plain lyrics +package lyrics + +import ( + "time" + + "github.com/zeusWPI/scc/internal/database/model" +) + +// Lyrics is the common interface for different lyric types +type Lyrics interface { + GetSong() model.Song // GetSong returns the song associated to the lyrics + Previous(int) []Lyric // Previous returns the previous 'int amount' of lyrics without affecting the current lyric + Current() (Lyric, bool) // Current provides the current lyric and a bool indicating if there is one + Next() (Lyric, bool) // Next returns the next lyric and a bool if there are any + Upcoming(int) []Lyric // Upcoming returns the next `int amount` of lyrics without affecting the current lyric + Progress() float64 // Progress returns the fraction of lyrics that have been used +} + +// Lyric represents a single lyric line. +type Lyric struct { + Text string + Duration time.Duration +} + +// New returns a new object that implements the Lyrics interface +func New(song model.Song) Lyrics { + switch song.LyricsType { + case model.LyricsSynced: + return newLRC(song) + case model.LyricsInstrumental: + return newInstrumental(song) + case model.LyricsPlain: + return newPlain(song) + default: + return newMissing(song) + } +} diff --git a/internal/lyrics/missing.go b/internal/lyrics/missing.go new file mode 100644 index 0000000..b31dd6c --- /dev/null +++ b/internal/lyrics/missing.go @@ -0,0 +1,45 @@ +package lyrics + +import ( + "time" + + "github.com/zeusWPI/scc/internal/database/model" +) + +type Missing struct { + song model.Song + lyrics Lyric +} + +func newMissing(song model.Song) Lyrics { + lyric := Lyric{ + Text: "Missing lyrics\n\nHelp the open source community by adding them to\nhttps://lrclib.net/", + Duration: time.Duration(song.DurationMS) * time.Millisecond, + } + + return &Missing{song: song, lyrics: lyric} +} + +func (m *Missing) GetSong() model.Song { + return m.song +} + +func (m *Missing) Previous(_ int) []Lyric { + return []Lyric{} +} + +func (m *Missing) Current() (Lyric, bool) { + return m.lyrics, true +} + +func (m *Missing) Next() (Lyric, bool) { + return Lyric{}, false +} + +func (m *Missing) Upcoming(_ int) []Lyric { + return []Lyric{} +} + +func (m *Missing) Progress() float64 { + return 1 +} diff --git a/internal/lyrics/plain.go b/internal/lyrics/plain.go new file mode 100644 index 0000000..d5d098f --- /dev/null +++ b/internal/lyrics/plain.go @@ -0,0 +1,44 @@ +package lyrics + +import ( + "time" + + "github.com/zeusWPI/scc/internal/database/model" +) + +type Plain struct { + song model.Song + lyrics Lyric +} + +func newPlain(song model.Song) Lyrics { + lyric := Lyric{ + Text: song.Lyrics, + Duration: time.Duration(song.DurationMS) * time.Millisecond, + } + return &Plain{song: song, lyrics: lyric} +} + +func (p *Plain) GetSong() model.Song { + return p.song +} + +func (p *Plain) Previous(_ int) []Lyric { + return []Lyric{} +} + +func (p *Plain) Current() (Lyric, bool) { + return p.lyrics, true +} + +func (p *Plain) Next() (Lyric, bool) { + return Lyric{}, false +} + +func (p *Plain) Upcoming(_ int) []Lyric { + return []Lyric{} +} + +func (p *Plain) Progress() float64 { + return 1 +} From 8b72bd7dfb63c0e0bf3bd53b348d050546a34fc4 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 30 Oct 2025 14:23:19 +0100 Subject: [PATCH 13/15] chore(song): readd to the backend --- cmd/backend/backend.go | 7 +- .../20251030105953_refactor_song.sql | 49 +++++ db/queries/song.sql | 44 ++++ internal/cmd/song.go | 9 + internal/database/model/song.go | 72 ++++++ internal/database/repository/song.go | 163 ++++++++++++++ internal/database/sqlc/models.go | 83 +++++++ internal/database/sqlc/song.sql.go | 206 ++++++++++++++++++ internal/server/dto/song.go | 12 +- internal/server/service/song.go | 60 ++++- internal/song/lrc.go | 70 ++++++ internal/song/song.go | 74 +++++++ internal/song/spotify.go | 113 ++++++++++ {internal => pkg}/lyrics/instrumental.go | 0 {internal => pkg}/lyrics/lrc.go | 0 {internal => pkg}/lyrics/lyrics.go | 0 {internal => pkg}/lyrics/missing.go | 0 {internal => pkg}/lyrics/plain.go | 0 18 files changed, 956 insertions(+), 6 deletions(-) create mode 100644 db/migrations/20251030105953_refactor_song.sql create mode 100644 db/queries/song.sql create mode 100644 internal/cmd/song.go create mode 100644 internal/database/model/song.go create mode 100644 internal/database/repository/song.go create mode 100644 internal/database/sqlc/song.sql.go create mode 100644 internal/song/lrc.go create mode 100644 internal/song/song.go create mode 100644 internal/song/spotify.go rename {internal => pkg}/lyrics/instrumental.go (100%) rename {internal => pkg}/lyrics/lrc.go (100%) rename {internal => pkg}/lyrics/lyrics.go (100%) rename {internal => pkg}/lyrics/missing.go (100%) rename {internal => pkg}/lyrics/plain.go (100%) diff --git a/cmd/backend/backend.go b/cmd/backend/backend.go index 2ccf725..825d537 100644 --- a/cmd/backend/backend.go +++ b/cmd/backend/backend.go @@ -31,7 +31,7 @@ func main() { // Database db, err := db.NewPSQL() if err != nil { - zap.S().Fatal("DB: Fatal error\n", err) + zap.S().Fatalf("DB: Fatal error %v", err) } // Repository @@ -47,6 +47,11 @@ func main() { _, done = cmd.Zess(*repo) dones = append(dones, done) + // Song + if err := cmd.Song(); err != nil { + zap.S().Fatalf("Initialize song %v", err) + } + // API service := service.New(*repo) api := server.New(*service) diff --git a/db/migrations/20251030105953_refactor_song.sql b/db/migrations/20251030105953_refactor_song.sql new file mode 100644 index 0000000..9d3e623 --- /dev/null +++ b/db/migrations/20251030105953_refactor_song.sql @@ -0,0 +1,49 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE song_artist +DROP COLUMN followers; + +ALTER TABLE song_artist +DROP COLUMN popularity; + +CREATE TYPE lyrics_type_enum AS ENUM ('plain', 'synced', 'instrumental', 'missing'); + +UPDATE song +SET lyrics_type = 'missing' +WHERE lyrics_type = ''; + +ALTER TABLE song +ADD COLUMN lyrics_type_new lyrics_type_enum NOT NULL DEFAULT 'missing'; + +UPDATE song +SET lyrics_type_new = lyrics_type::lyrics_type_enum; + +ALTER TABLE song +DROP COLUMN lyrics_type; + +ALTER TABLE song +RENAME COLUMN lyrics_type_new TO lyrics_type; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE song +ADD COLUMN lyrics_type_text TEXT; + +UPDATE song +SET lyrics_type_text = lyrics_type::text; + +ALTER TABLE song +DROP COLUMN lyrics_type; + +ALTER TABLE song +RENAME COLUMN lyrics_type_text TO lyrics_type; + +DROP TYPE lyrics_type_enum; + +ALTER TABLE song_artist +ADD COLUMN popularity INT; + +ALTER TABLE song_artist +ADD COLUMN followers INT; +-- +goose StatementEnd diff --git a/db/queries/song.sql b/db/queries/song.sql new file mode 100644 index 0000000..f4b8b48 --- /dev/null +++ b/db/queries/song.sql @@ -0,0 +1,44 @@ +-- name: SongGetBySpotify :one +SELECT * +FROM song +WHERE spotify_id = $1; + +-- name: SongArtistGetBySpotify :one +SELECT * +FROM song_artist +WHERE spotify_id = $1; + +-- name: SongGenreGetByGenre :one +SELECT * +FROM song_genre +WHERE genre = $1; + +-- name: SongCreate :one +INSERT INTO song (title, album, spotify_id, duration_ms, lyrics_type, lyrics) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id; + +-- name: SongArtistCreate :one +INSERT INTO song_artist (name, spotify_id) +VALUES ($1, $2) +RETURNING id; + +-- name: SongArtistSongCreate :one +INSERT INTO song_artist_song (artist_id, song_id) +VALUES ($1, $2) +RETURNING id; + +-- name: SongGenreCreate :one +INSERT INTO song_genre (genre) +VALUES ($1) +RETURNING id; + +-- name: SongArtistGenreCreate :one +INSERT INTO song_artist_genre (artist_id, genre_id) +VALUES ($1, $2) +RETURNING id; + +-- name: SongHistoryCreate :one +INSERT INTO song_history (song_id) +VALUES ($1) +RETURNING id; diff --git a/internal/cmd/song.go b/internal/cmd/song.go new file mode 100644 index 0000000..ef22c2a --- /dev/null +++ b/internal/cmd/song.go @@ -0,0 +1,9 @@ +package cmd + +import ( + "github.com/zeusWPI/scc/internal/song" +) + +func Song() error { + return song.Init() +} diff --git a/internal/database/model/song.go b/internal/database/model/song.go new file mode 100644 index 0000000..80aa5fa --- /dev/null +++ b/internal/database/model/song.go @@ -0,0 +1,72 @@ +package model + +import ( + "time" + + "github.com/zeusWPI/scc/internal/database/sqlc" +) + +type LyricsType string + +const ( + LyricsPlain LyricsType = "plain" + LyricsSynced LyricsType = "synced" + LyricsInstrumental LyricsType = "instrumental" + LyricsMissing LyricsType = "missing" +) + +type Song struct { + ID int + Title string + Album string + SpotifyID string + DurationMS int + LyricsType LyricsType + Lyrics string + PlayedAt time.Time + Artists []Artist +} + +type Artist struct { + ID int + Name string + SpotifyID string + Genres []Genre +} + +type Genre struct { + ID int + Genre string +} + +func SongModel(s sqlc.Song) *Song { + lyrics := "" + if s.Lyrics.Valid { + lyrics = s.Lyrics.String + } + + return &Song{ + ID: int(s.ID), + Title: s.Title, + Album: s.Album, + SpotifyID: s.SpotifyID, + DurationMS: int(s.DurationMs), + LyricsType: LyricsType(s.LyricsType), + Lyrics: lyrics, + } +} + +func ArtistModel(a sqlc.SongArtist) *Artist { + return &Artist{ + ID: int(a.ID), + Name: a.Name, + SpotifyID: a.SpotifyID, + } +} + +func GenreModel(g sqlc.SongGenre) *Genre { + return &Genre{ + ID: int(g.ID), + Genre: g.Genre, + } +} diff --git a/internal/database/repository/song.go b/internal/database/repository/song.go new file mode 100644 index 0000000..4b35ecf --- /dev/null +++ b/internal/database/repository/song.go @@ -0,0 +1,163 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/internal/database/sqlc" + "github.com/zeusWPI/scc/pkg/utils" +) + +type Song struct { + repo Repository +} + +func (r *Repository) NewSong() *Song { + return &Song{ + repo: *r, + } +} + +func (s *Song) GetBySpotify(ctx context.Context, spotifyID string) (*model.Song, error) { + song, err := s.repo.queries(ctx).SongGetBySpotify(ctx, spotifyID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get song by spotify id %s | %w", spotifyID, err) + } + + return model.SongModel(song), nil +} + +func (s *Song) GetArtistBySpotify(ctx context.Context, spotifyID string) (*model.Artist, error) { + artist, err := s.repo.queries(ctx).SongArtistGetBySpotify(ctx, spotifyID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get song artist by spotify id %s | %w", spotifyID, err) + } + + return model.ArtistModel(artist), nil +} + +func (s *Song) GetGenreByGenre(ctx context.Context, genre string) (*model.Genre, error) { + genreDB, err := s.repo.queries(ctx).SongGenreGetByGenre(ctx, genre) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get song genre by genre %s | %w", genre, err) + } + + return model.GenreModel(genreDB), nil +} + +func (s *Song) GetArtistsBySpotify(ctx context.Context, artists []model.Artist) ([]*model.Artist, error) { + artistsDB, err := s.repo.queries(ctx).SongArtistGetBySpotifyIds(ctx, utils.SliceMap(artists, func(a model.Artist) string { return a.SpotifyID })) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get artists by ids %+v | %w", artists, err) + } + + artistMap := make(map[int]*model.Artist) + for _, artist := range artistsDB { + a, ok := artistMap[int(artist.SongArtist.ID)] + if !ok { + a = model.ArtistModel(artist.SongArtist) + artistMap[a.ID] = a + } + + a.Genres = append(a.Genres, *model.GenreModel(artist.SongGenre)) + } + + return utils.MapValues(artistMap), nil +} + +func (s *Song) Create(ctx context.Context, song *model.Song) error { + return s.repo.WithRollback(ctx, func(ctx context.Context) error { + id, err := s.repo.queries(ctx).SongCreate(ctx, sqlc.SongCreateParams{ + Title: song.Title, + Album: song.Album, + SpotifyID: song.SpotifyID, + DurationMs: int32(song.DurationMS), + LyricsType: sqlc.LyricsTypeEnum(song.LyricsType), + Lyrics: pgtype.Text{String: song.Lyrics, Valid: song.Lyrics != ""}, + }) + if err != nil { + return fmt.Errorf("create song %+v | %w", *song, err) + } + + song.ID = int(id) + + for i, artist := range song.Artists { + artistDB, err := s.GetArtistBySpotify(ctx, artist.SpotifyID) + if err != nil { + return err + } + + if artistDB != nil { + song.Artists[i] = *artistDB + } else { + id, err := s.repo.queries(ctx).SongArtistCreate(ctx, sqlc.SongArtistCreateParams{ + Name: artist.Name, + SpotifyID: artist.SpotifyID, + }) + if err != nil { + return fmt.Errorf("create song artist %+v | %+v | %w", artist, *song, err) + } + + song.Artists[i].ID = int(id) + } + + if _, err := s.repo.queries(ctx).SongArtistSongCreate(ctx, sqlc.SongArtistSongCreateParams{ + ArtistID: int32(song.Artists[i].ID), + SongID: int32(song.ID), + }); err != nil { + return fmt.Errorf("create song artist song %+v | %+v | %w", artist, *song, err) + } + + for j, genre := range artist.Genres { + genreDB, err := s.GetGenreByGenre(ctx, genre.Genre) + if err != nil { + return err + } + + if genreDB != nil { + song.Artists[i].Genres[j] = *genreDB + } else { + id, err := s.repo.queries(ctx).SongGenreCreate(ctx, genre.Genre) + if err != nil { + return fmt.Errorf("create song genre %+v | %+v | %+v | %w", genre, artist, *song, err) + } + + song.Artists[i].Genres[j].ID = int(id) + } + + if _, err := s.repo.queries(ctx).SongArtistGenreCreate(ctx, sqlc.SongArtistGenreCreateParams{ + ArtistID: int32(song.Artists[i].ID), + GenreID: int32(song.Artists[i].Genres[j].ID), + }); err != nil { + return fmt.Errorf("create song artist genre %+v | %+v | %+v | %w", genre, artist, *song, err) + } + } + } + + return nil + }) +} + +func (s *Song) CreateHistory(ctx context.Context, song model.Song) error { + if _, err := s.repo.queries(ctx).SongHistoryCreate(ctx, int32(song.ID)); err != nil { + return fmt.Errorf("create song history %+v | %w", song, err) + } + + return nil +} diff --git a/internal/database/sqlc/models.go b/internal/database/sqlc/models.go index e4d9c83..e1f5dc9 100644 --- a/internal/database/sqlc/models.go +++ b/internal/database/sqlc/models.go @@ -11,6 +11,50 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type LyricsTypeEnum string + +const ( + LyricsTypeEnumPlain LyricsTypeEnum = "plain" + LyricsTypeEnumSynced LyricsTypeEnum = "synced" + LyricsTypeEnumInstrumental LyricsTypeEnum = "instrumental" + LyricsTypeEnumMissing LyricsTypeEnum = "missing" +) + +func (e *LyricsTypeEnum) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = LyricsTypeEnum(s) + case string: + *e = LyricsTypeEnum(s) + default: + return fmt.Errorf("unsupported scan type for LyricsTypeEnum: %T", src) + } + return nil +} + +type NullLyricsTypeEnum struct { + LyricsTypeEnum LyricsTypeEnum + Valid bool // Valid is true if LyricsTypeEnum is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullLyricsTypeEnum) Scan(value interface{}) error { + if value == nil { + ns.LyricsTypeEnum, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.LyricsTypeEnum.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullLyricsTypeEnum) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.LyricsTypeEnum), nil +} + type TapCategory string const ( @@ -78,6 +122,45 @@ type Season struct { Current bool } +type Song struct { + ID int32 + Title string + SpotifyID string + DurationMs int32 + Album string + Lyrics pgtype.Text + LyricsType LyricsTypeEnum +} + +type SongArtist struct { + ID int32 + Name string + SpotifyID string +} + +type SongArtistGenre struct { + ID int32 + ArtistID int32 + GenreID int32 +} + +type SongArtistSong struct { + ID int32 + ArtistID int32 + SongID int32 +} + +type SongGenre struct { + ID int32 + Genre string +} + +type SongHistory struct { + ID int32 + SongID int32 + CreatedAt pgtype.Timestamptz +} + type Tap struct { ID int32 OrderID int32 diff --git a/internal/database/sqlc/song.sql.go b/internal/database/sqlc/song.sql.go new file mode 100644 index 0000000..c6c3c2a --- /dev/null +++ b/internal/database/sqlc/song.sql.go @@ -0,0 +1,206 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: song.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const songArtistCreate = `-- name: SongArtistCreate :one +INSERT INTO song_artist (name, spotify_id) +VALUES ($1, $2) +RETURNING id +` + +type SongArtistCreateParams struct { + Name string + SpotifyID string +} + +func (q *Queries) SongArtistCreate(ctx context.Context, arg SongArtistCreateParams) (int32, error) { + row := q.db.QueryRow(ctx, songArtistCreate, arg.Name, arg.SpotifyID) + var id int32 + err := row.Scan(&id) + return id, err +} + +const songArtistGenreCreate = `-- name: SongArtistGenreCreate :one +INSERT INTO song_artist_genre (artist_id, genre_id) +VALUES ($1, $2) +RETURNING id +` + +type SongArtistGenreCreateParams struct { + ArtistID int32 + GenreID int32 +} + +func (q *Queries) SongArtistGenreCreate(ctx context.Context, arg SongArtistGenreCreateParams) (int32, error) { + row := q.db.QueryRow(ctx, songArtistGenreCreate, arg.ArtistID, arg.GenreID) + var id int32 + err := row.Scan(&id) + return id, err +} + +const songArtistGetBySpotify = `-- name: SongArtistGetBySpotify :one +SELECT id, name, spotify_id +FROM song_artist +WHERE spotify_id = $1 +` + +func (q *Queries) SongArtistGetBySpotify(ctx context.Context, spotifyID string) (SongArtist, error) { + row := q.db.QueryRow(ctx, songArtistGetBySpotify, spotifyID) + var i SongArtist + err := row.Scan(&i.ID, &i.Name, &i.SpotifyID) + return i, err +} + +const songArtistGetBySpotifyIds = `-- name: SongArtistGetBySpotifyIds :many +SELECT a.id, a.name, a.spotify_id, g.id, g.genre +FROM song_artist a +LEFT JOIN song_genre g ON a.id = g.artist_id +WHERE a.spotify_id = ANY($1::text[]) +` + +type SongArtistGetBySpotifyIdsRow struct { + SongArtist SongArtist + SongGenre SongGenre +} + +func (q *Queries) SongArtistGetBySpotifyIds(ctx context.Context, dollar_1 []string) ([]SongArtistGetBySpotifyIdsRow, error) { + rows, err := q.db.Query(ctx, songArtistGetBySpotifyIds, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SongArtistGetBySpotifyIdsRow + for rows.Next() { + var i SongArtistGetBySpotifyIdsRow + if err := rows.Scan( + &i.SongArtist.ID, + &i.SongArtist.Name, + &i.SongArtist.SpotifyID, + &i.SongGenre.ID, + &i.SongGenre.Genre, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const songArtistSongCreate = `-- name: SongArtistSongCreate :one +INSERT INTO song_artist_song (artist_id, song_id) +VALUES ($1, $2) +RETURNING id +` + +type SongArtistSongCreateParams struct { + ArtistID int32 + SongID int32 +} + +func (q *Queries) SongArtistSongCreate(ctx context.Context, arg SongArtistSongCreateParams) (int32, error) { + row := q.db.QueryRow(ctx, songArtistSongCreate, arg.ArtistID, arg.SongID) + var id int32 + err := row.Scan(&id) + return id, err +} + +const songCreate = `-- name: SongCreate :one +INSERT INTO song (title, album, spotify_id, duration_ms, lyrics_type, lyrics) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id +` + +type SongCreateParams struct { + Title string + Album string + SpotifyID string + DurationMs int32 + LyricsType LyricsTypeEnum + Lyrics pgtype.Text +} + +func (q *Queries) SongCreate(ctx context.Context, arg SongCreateParams) (int32, error) { + row := q.db.QueryRow(ctx, songCreate, + arg.Title, + arg.Album, + arg.SpotifyID, + arg.DurationMs, + arg.LyricsType, + arg.Lyrics, + ) + var id int32 + err := row.Scan(&id) + return id, err +} + +const songGenreCreate = `-- name: SongGenreCreate :one +INSERT INTO song_genre (genre) +VALUES ($1) +RETURNING id +` + +func (q *Queries) SongGenreCreate(ctx context.Context, genre string) (int32, error) { + row := q.db.QueryRow(ctx, songGenreCreate, genre) + var id int32 + err := row.Scan(&id) + return id, err +} + +const songGenreGetByGenre = `-- name: SongGenreGetByGenre :one +SELECT id, genre +FROM song_genre +WHERE genre = $1 +` + +func (q *Queries) SongGenreGetByGenre(ctx context.Context, genre string) (SongGenre, error) { + row := q.db.QueryRow(ctx, songGenreGetByGenre, genre) + var i SongGenre + err := row.Scan(&i.ID, &i.Genre) + return i, err +} + +const songGetBySpotify = `-- name: SongGetBySpotify :one +SELECT id, title, spotify_id, duration_ms, album, lyrics, lyrics_type +FROM song +WHERE spotify_id = $1 +` + +func (q *Queries) SongGetBySpotify(ctx context.Context, spotifyID string) (Song, error) { + row := q.db.QueryRow(ctx, songGetBySpotify, spotifyID) + var i Song + err := row.Scan( + &i.ID, + &i.Title, + &i.SpotifyID, + &i.DurationMs, + &i.Album, + &i.Lyrics, + &i.LyricsType, + ) + return i, err +} + +const songHistoryCreate = `-- name: SongHistoryCreate :one +INSERT INTO song_history (song_id) +VALUES ($1) +RETURNING id +` + +func (q *Queries) SongHistoryCreate(ctx context.Context, songID int32) (int32, error) { + row := q.db.QueryRow(ctx, songHistoryCreate, songID) + var id int32 + err := row.Scan(&id) + return id, err +} diff --git a/internal/server/dto/song.go b/internal/server/dto/song.go index 031096c..c150fcc 100644 --- a/internal/server/dto/song.go +++ b/internal/server/dto/song.go @@ -1,3 +1,13 @@ package dto -type Song struct{} +import "github.com/zeusWPI/scc/internal/database/model" + +type Song struct { + SpotifyID string `json:"spotify_id"` +} + +func (s *Song) ToModel() *model.Song { + return &model.Song{ + SpotifyID: s.SpotifyID, + } +} diff --git a/internal/server/service/song.go b/internal/server/service/song.go index a9f8036..4804542 100644 --- a/internal/server/service/song.go +++ b/internal/server/service/song.go @@ -3,16 +3,68 @@ package service import ( "context" + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/internal/database/repository" "github.com/zeusWPI/scc/internal/server/dto" + songClient "github.com/zeusWPI/scc/internal/song" + "go.uber.org/zap" ) -type Song struct{} +type Song struct { + song repository.Song +} func (s *Service) NewSong() *Song { - return &Song{} + return &Song{ + song: *s.repo.NewSong(), + } +} + +func (s *Song) New(_ context.Context, songSave dto.Song) error { + song := songSave.ToModel() + + // Run in the background as it can take some time + go func(ctx context.Context, song *model.Song) { + if err := s.save(ctx, song); err != nil { + zap.S().Error(err) + return + } + + if err := s.saveHistory(ctx, *song); err != nil { + zap.S().Error(err) + return + } + }(context.Background(), song) + + return nil } -func (s *Song) New(_ context.Context, _ dto.Song) error { - // TODO: Fill in +func (s *Song) save(ctx context.Context, song *model.Song) error { + songDB, err := s.song.GetBySpotify(ctx, song.SpotifyID) + if err != nil { + return err + } + if songDB != nil { + // Song is already in the database + *song = *songDB + return nil + } + + if err := songClient.C.Populate(song); err != nil { + return err + } + + if err := s.song.Create(ctx, song); err != nil { + return err + } + + return nil +} + +func (s *Song) saveHistory(ctx context.Context, song model.Song) error { + if err := s.song.CreateHistory(ctx, song); err != nil { + return err + } + return nil } diff --git a/internal/song/lrc.go b/internal/song/lrc.go new file mode 100644 index 0000000..acaed6c --- /dev/null +++ b/internal/song/lrc.go @@ -0,0 +1,70 @@ +package song + +import ( + "errors" + "fmt" + "net/url" + "strconv" + + "github.com/gofiber/fiber/v2" + "github.com/zeusWPI/scc/internal/database/model" + "go.uber.org/zap" +) + +type lyricsResponse struct { + Instrumental bool `json:"instrumental"` + PlainLyrics string `json:"plainLyrics"` + SyncedLyrics string `json:"SyncedLyrics"` +} + +func (c *client) getLyrics(song *model.Song) error { + zap.S().Info("Song: Getting lyrics for ", song.Title) + + // Get most popular artist + if len(song.Artists) == 0 { + return fmt.Errorf("no artists for track: %v", song) + } + artist := song.Artists[0] + + // Construct url + params := url.Values{} + params.Set("artist_name", artist.Name) + params.Set("track_name", song.Title) + params.Set("album_name", song.Album) + params.Set("duration", strconv.Itoa(song.DurationMS/1000)) + + req := fiber.Get(fmt.Sprintf("%s/get?%s", apiLrc, params.Encode())) + + res := new(lyricsResponse) + status, _, errs := req.Struct(res) + if len(errs) > 0 { + return errors.Join(append([]error{errors.New("lyrics request failed")}, errs...)...) + } + if status != fiber.StatusOK { + if status == fiber.StatusNotFound { + // Lyrics not found + return nil + } + + return fmt.Errorf("lyrics request wrong status code %d", status) + } + if (res == &lyricsResponse{}) { + return errors.New("lyrics request returned empty struct") + } + + switch { + case res.SyncedLyrics != "": + song.LyricsType = model.LyricsSynced + song.Lyrics = res.SyncedLyrics + case res.PlainLyrics != "": + song.LyricsType = model.LyricsPlain + song.Lyrics = res.PlainLyrics + case res.Instrumental: + song.LyricsType = model.LyricsInstrumental + song.Lyrics = "" + default: + song.LyricsType = model.LyricsMissing + } + + return nil +} diff --git a/internal/song/song.go b/internal/song/song.go new file mode 100644 index 0000000..00030cf --- /dev/null +++ b/internal/song/song.go @@ -0,0 +1,74 @@ +package song + +import ( + "errors" + "time" + + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/pkg/config" + "go.uber.org/zap" +) + +const ( + apiSpotify = "https://api.spotify.com/v1" + apiAccount = "https://accounts.spotify.com/api/token" + apiLrc = "https://lrclib.net/api" +) + +type client struct { + clientID string + clientSecret string + + accessToken string + expiresTime int64 +} + +var C *client + +func Init() error { + clientID := config.GetDefaultString("backend.song.spotify_client_id", "") + clientSecret := config.GetDefaultString("backend.song.spotify_client_secret", "") + + zap.S().Info(clientID) + + if clientID == "" || clientSecret == "" { + return errors.New("spotify client id or secret not set") + } + + C = &client{ + clientID: clientID, + clientSecret: clientSecret, + expiresTime: 0, + } + + return nil +} + +func (c *client) Populate(song *model.Song) error { + zap.S().Info("Populating song") + + if c.expiresTime <= time.Now().Unix() { + err := c.refreshToken() + if err != nil { + return err + } + } + + if err := c.populateSong(song); err != nil { + return err + } + + for i := range song.Artists { + if err := c.populateArtist(&song.Artists[i]); err != nil { + return err + } + } + + if err := c.getLyrics(song); err != nil { + return err + } + + zap.S().Info("Populated song") + + return nil +} diff --git a/internal/song/spotify.go b/internal/song/spotify.go new file mode 100644 index 0000000..7c4a02a --- /dev/null +++ b/internal/song/spotify.go @@ -0,0 +1,113 @@ +package song + +import ( + "errors" + "fmt" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/zeusWPI/scc/internal/database/model" + "go.uber.org/zap" +) + +type accountResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` +} + +func (c *client) refreshToken() error { + zap.S().Info("Song: Refreshing spotify access token") + + form := &fiber.Args{} + form.Add("grant_type", "client_credentials") + + req := fiber.Post(apiAccount).Form(form).BasicAuth(c.clientID, c.clientSecret) + + res := new(accountResponse) + status, _, errs := req.Struct(res) + if len(errs) > 0 { + return errors.Join(append([]error{errors.New("spotify token refresh request failed")}, errs...)...) + } + if status != fiber.StatusOK { + return errors.New("getting spotify account access token") + } + + c.accessToken = res.AccessToken + c.expiresTime = time.Now().Unix() + res.ExpiresIn + + return nil +} + +type trackArtist struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type trackAlbum struct { + Name string `json:"name"` +} + +type trackResponse struct { + Name string `json:"name"` + Album trackAlbum `json:"album"` + Artists []trackArtist `json:"artists"` + DurationMS int32 `json:"duration_ms"` +} + +func (c *client) populateSong(song *model.Song) error { + zap.S().Info("Song: Getting track info for id: ", song.SpotifyID) + + req := fiber.Get(fmt.Sprintf("%s/%s/%s", apiSpotify, "tracks", song.SpotifyID)). + Set("Authorization", "Bearer "+c.accessToken) + + res := new(trackResponse) + status, _, errs := req.Struct(res) + if len(errs) > 0 { + return errors.Join(append([]error{errors.New("track request failed")}, errs...)...) + } + if status != fiber.StatusOK { + return fmt.Errorf("track request wrong status code %d", status) + } + + song.Title = res.Name + song.Album = res.Album.Name + song.DurationMS = int(res.DurationMS) + + for _, a := range res.Artists { + song.Artists = append(song.Artists, model.Artist{ + Name: a.Name, + SpotifyID: a.ID, + }) + } + + return nil +} + +type artistResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Genres []string `json:"genres"` +} + +func (c *client) populateArtist(artist *model.Artist) error { + zap.S().Info("Song: Getting artists info for ", artist.SpotifyID) + + req := fiber.Get(fmt.Sprintf("%s/%s/%s", apiSpotify, "artists", artist.SpotifyID)). + Set("Authorization", "Bearer "+c.accessToken) + + res := new(artistResponse) + status, _, errs := req.Struct(res) + if len(errs) > 0 { + return errors.Join(append([]error{errors.New("artist request failed")}, errs...)...) + } + if status != fiber.StatusOK { + return fmt.Errorf("artist request wrong status code %d", status) + } + + for _, genre := range res.Genres { + artist.Genres = append(artist.Genres, model.Genre{Genre: genre}) + } + + return nil +} diff --git a/internal/lyrics/instrumental.go b/pkg/lyrics/instrumental.go similarity index 100% rename from internal/lyrics/instrumental.go rename to pkg/lyrics/instrumental.go diff --git a/internal/lyrics/lrc.go b/pkg/lyrics/lrc.go similarity index 100% rename from internal/lyrics/lrc.go rename to pkg/lyrics/lrc.go diff --git a/internal/lyrics/lyrics.go b/pkg/lyrics/lyrics.go similarity index 100% rename from internal/lyrics/lyrics.go rename to pkg/lyrics/lyrics.go diff --git a/internal/lyrics/missing.go b/pkg/lyrics/missing.go similarity index 100% rename from internal/lyrics/missing.go rename to pkg/lyrics/missing.go diff --git a/internal/lyrics/plain.go b/pkg/lyrics/plain.go similarity index 100% rename from internal/lyrics/plain.go rename to pkg/lyrics/plain.go From 698140585b4809276e7f8c66f9dacd3680465b74 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 30 Oct 2025 17:08:54 +0100 Subject: [PATCH 14/15] chore(song): readd to the tui --- db/queries/song.sql | 83 +++++++ internal/database/model/song.go | 11 +- internal/database/repository/song.go | 183 +++++++++++++-- internal/database/sqlc/song.sql.go | 331 +++++++++++++++++++++++++-- tui/screen/song/song.go | 4 +- tui/view/song/song.go | 219 +++++++++++++++++- tui/view/song/style.go | 106 +++++++++ tui/view/song/update.go | 187 +++++++++++++++ tui/view/song/util.go | 11 + tui/view/song/view.go | 163 +++++++++++++ 10 files changed, 1250 insertions(+), 48 deletions(-) create mode 100644 tui/view/song/style.go create mode 100644 tui/view/song/update.go create mode 100644 tui/view/song/util.go create mode 100644 tui/view/song/view.go diff --git a/db/queries/song.sql b/db/queries/song.sql index f4b8b48..37d1b8f 100644 --- a/db/queries/song.sql +++ b/db/queries/song.sql @@ -1,3 +1,86 @@ +-- name: SongGetLastPopulated :many +SELECT sqlc.embed(h), sqlc.embed(s), sqlc.embed(a) +FROM song_history h +JOIN song s ON s.id = h.song_id +LEFT JOIN song_artist_song sa ON sa.song_id = s.id +LEFT JOIN song_artist a ON a.id = sa.artist_id +WHERE h.created_at = (SELECT MAX(created_at) FROM song_history) +ORDER BY a.name; + +-- name: SongGetLast50 :many +SELECT sqlc.embed(s), play_count +FROM ( + SELECT sh.song_id, MAX(sh.created_at) AS created_at, COUNT(sh.song_id) AS play_count + FROM song_history sh + GROUP BY sh.song_id +) aggregated +JOIN song s ON s.id = aggregated.song_id +ORDER BY aggregated.created_at DESC +LIMIT 50; + +-- name: SongGetTop50 :many +SELECT sqlc.embed(s), COUNT(sh.id) AS play_count +FROM song_history sh +JOIN song s ON sh.song_id = s.id +GROUP BY s.id, s.title +ORDER BY play_count DESC +LIMIT 50; + +-- name: SongGetTop50Monthly :many +SELECT sqlc.embed(s), COUNT(sh.id) AS play_count +FROM song_history sh +JOIN song s ON sh.song_id = s.id +WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' +GROUP BY s.id, s.title +ORDER BY play_count DESC +LIMIT 50; + +-- name: SongArtistGetTop50 :many +SELECT sqlc.embed(a), COUNT(sh.id) AS play_count +FROM song_history sh +JOIN song s ON sh.song_id = s.id +JOIN song_artist_song sas ON s.id = sas.song_id +JOIN song_artist a ON sas.artist_id = a.id +GROUP BY a.id, a.name +ORDER BY play_count DESC +LIMIT 50; + +-- name: SongArtistGetTop50Monthly :many +SELECT sqlc.embed(a), COUNT(sh.id) AS play_count +FROM song_history sh +JOIN song s ON sh.song_id = s.id +JOIN song_artist_song sas ON s.id = sas.song_id +JOIN song_artist a ON sas.artist_id = a.id +WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' +GROUP BY a.id, a.name +ORDER BY play_count DESC +LIMIT 50; + +-- name: SongGenreGetTop50 :many +SELECT sqlc.embed(g), COUNT(sh.id) AS play_count +FROM song_history sh +JOIN song s ON sh.song_id = s.id +JOIN song_artist_song sas ON s.id = sas.song_id +JOIN song_artist sa ON sas.artist_id = sa.id +JOIN song_artist_genre sag ON sa.id = sag.artist_id +JOIN song_genre g ON sag.genre_id = g.id +GROUP BY g.genre, g.id +ORDER BY play_count DESC +LIMIT 50; + +-- name: SongGenreGetTop50Monthly :many +SELECT sqlc.embed(g), COUNT(sh.id) AS play_count +FROM song_history sh +JOIN song s ON sh.song_id = s.id +JOIN song_artist_song sas ON s.id = sas.song_id +JOIN song_artist sa ON sas.artist_id = sa.id +JOIN song_artist_genre sag ON sa.id = sag.artist_id +JOIN song_genre g ON sag.genre_id = g.id +WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' +GROUP BY g.genre, g.id +ORDER BY play_count DESC +LIMIT 50; + -- name: SongGetBySpotify :one SELECT * FROM song diff --git a/internal/database/model/song.go b/internal/database/model/song.go index 80aa5fa..532ae6e 100644 --- a/internal/database/model/song.go +++ b/internal/database/model/song.go @@ -23,8 +23,11 @@ type Song struct { DurationMS int LyricsType LyricsType Lyrics string - PlayedAt time.Time Artists []Artist + + // History fields + PlayedAt time.Time + PlayCount int } type Artist struct { @@ -32,11 +35,17 @@ type Artist struct { Name string SpotifyID string Genres []Genre + + // History fields + PlayCount int } type Genre struct { ID int Genre string + + // History fields + PlayCount int } func SongModel(s sqlc.Song) *Song { diff --git a/internal/database/repository/song.go b/internal/database/repository/song.go index 4b35ecf..1d4bdda 100644 --- a/internal/database/repository/song.go +++ b/internal/database/repository/song.go @@ -9,7 +9,6 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/zeusWPI/scc/internal/database/model" "github.com/zeusWPI/scc/internal/database/sqlc" - "github.com/zeusWPI/scc/pkg/utils" ) type Song struct { @@ -22,63 +21,199 @@ func (r *Repository) NewSong() *Song { } } -func (s *Song) GetBySpotify(ctx context.Context, spotifyID string) (*model.Song, error) { - song, err := s.repo.queries(ctx).SongGetBySpotify(ctx, spotifyID) +func (s *Song) GetLastPopulated(ctx context.Context) (*model.Song, error) { + last, err := s.repo.queries(ctx).SongGetLastPopulated(ctx) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } - return nil, fmt.Errorf("get song by spotify id %s | %w", spotifyID, err) + return nil, fmt.Errorf("get last song populated %w", err) } - return model.SongModel(song), nil + song := model.SongModel(last[0].Song) + song.PlayedAt = last[0].SongHistory.CreatedAt.Time + + for _, s := range last { + song.Artists = append(song.Artists, *model.ArtistModel(s.SongArtist)) + } + + return song, nil } -func (s *Song) GetArtistBySpotify(ctx context.Context, spotifyID string) (*model.Artist, error) { - artist, err := s.repo.queries(ctx).SongArtistGetBySpotify(ctx, spotifyID) +func (s *Song) GetLast50(ctx context.Context) ([]*model.Song, error) { + lasts, err := s.repo.queries(ctx).SongGetLast50(ctx) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } - return nil, fmt.Errorf("get song artist by spotify id %s | %w", spotifyID, err) + return nil, fmt.Errorf("get last 50 songs %w", err) } - return model.ArtistModel(artist), nil + songs := make([]*model.Song, 0, len(lasts)) + for _, last := range lasts { + song := model.SongModel(last.Song) + song.PlayCount = int(last.PlayCount) + + songs = append(songs, song) + } + + return songs, nil } -func (s *Song) GetGenreByGenre(ctx context.Context, genre string) (*model.Genre, error) { - genreDB, err := s.repo.queries(ctx).SongGenreGetByGenre(ctx, genre) +func (s *Song) GetTopSongs(ctx context.Context) ([]*model.Song, error) { + tops, err := s.repo.queries(ctx).SongGetTop50(ctx) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } - return nil, fmt.Errorf("get song genre by genre %s | %w", genre, err) + return nil, fmt.Errorf("get top songs %w", err) } - return model.GenreModel(genreDB), nil + songs := make([]*model.Song, 0, len(tops)) + for _, top := range tops { + song := model.SongModel(top.Song) + song.PlayCount = int(top.PlayCount) + + songs = append(songs, song) + } + + return songs, nil +} + +func (s *Song) GetTopArtists(ctx context.Context) ([]*model.Artist, error) { + tops, err := s.repo.queries(ctx).SongArtistGetTop50(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get top artists %w", err) + } + + artists := make([]*model.Artist, 0, len(tops)) + for _, top := range tops { + artist := model.ArtistModel(top.SongArtist) + artist.PlayCount = int(top.PlayCount) + + artists = append(artists, artist) + } + + return artists, nil +} + +func (s *Song) GetTopGenres(ctx context.Context) ([]*model.Genre, error) { + tops, err := s.repo.queries(ctx).SongGenreGetTop50(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get top genres %w", err) + } + + genres := make([]*model.Genre, 0, len(tops)) + for _, top := range tops { + genre := model.GenreModel(top.SongGenre) + genre.PlayCount = int(top.PlayCount) + + genres = append(genres, genre) + } + + return genres, nil +} + +func (s *Song) GetTopSongsMonthly(ctx context.Context) ([]*model.Song, error) { + tops, err := s.repo.queries(ctx).SongGetTop50Monthly(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get top songs monthly %w", err) + } + + songs := make([]*model.Song, 0, len(tops)) + for _, top := range tops { + song := model.SongModel(top.Song) + song.PlayCount = int(top.PlayCount) + + songs = append(songs, song) + } + + return songs, nil +} + +func (s *Song) GetTopArtistsMonthly(ctx context.Context) ([]*model.Artist, error) { + tops, err := s.repo.queries(ctx).SongArtistGetTop50Monthly(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get top artists montlhy %w", err) + } + + artists := make([]*model.Artist, 0, len(tops)) + for _, top := range tops { + artist := model.ArtistModel(top.SongArtist) + artist.PlayCount = int(top.PlayCount) + + artists = append(artists, artist) + } + + return artists, nil +} + +func (s *Song) GetTopGenresMonthly(ctx context.Context) ([]*model.Genre, error) { + tops, err := s.repo.queries(ctx).SongGenreGetTop50Monthly(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get top genres monthly %w", err) + } + + genres := make([]*model.Genre, 0, len(tops)) + for _, top := range tops { + genre := model.GenreModel(top.SongGenre) + genre.PlayCount = int(top.PlayCount) + + genres = append(genres, genre) + } + + return genres, nil } -func (s *Song) GetArtistsBySpotify(ctx context.Context, artists []model.Artist) ([]*model.Artist, error) { - artistsDB, err := s.repo.queries(ctx).SongArtistGetBySpotifyIds(ctx, utils.SliceMap(artists, func(a model.Artist) string { return a.SpotifyID })) +func (s *Song) GetBySpotify(ctx context.Context, spotifyID string) (*model.Song, error) { + song, err := s.repo.queries(ctx).SongGetBySpotify(ctx, spotifyID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } - return nil, fmt.Errorf("get artists by ids %+v | %w", artists, err) + return nil, fmt.Errorf("get song by spotify id %s | %w", spotifyID, err) } - artistMap := make(map[int]*model.Artist) - for _, artist := range artistsDB { - a, ok := artistMap[int(artist.SongArtist.ID)] - if !ok { - a = model.ArtistModel(artist.SongArtist) - artistMap[a.ID] = a + return model.SongModel(song), nil +} + +func (s *Song) GetArtistBySpotify(ctx context.Context, spotifyID string) (*model.Artist, error) { + artist, err := s.repo.queries(ctx).SongArtistGetBySpotify(ctx, spotifyID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil } + return nil, fmt.Errorf("get song artist by spotify id %s | %w", spotifyID, err) + } + + return model.ArtistModel(artist), nil +} - a.Genres = append(a.Genres, *model.GenreModel(artist.SongGenre)) +func (s *Song) GetGenreByGenre(ctx context.Context, genre string) (*model.Genre, error) { + genreDB, err := s.repo.queries(ctx).SongGenreGetByGenre(ctx, genre) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get song genre by genre %s | %w", genre, err) } - return utils.MapValues(artistMap), nil + return model.GenreModel(genreDB), nil } func (s *Song) Create(ctx context.Context, song *model.Song) error { diff --git a/internal/database/sqlc/song.sql.go b/internal/database/sqlc/song.sql.go index c6c3c2a..15a97c8 100644 --- a/internal/database/sqlc/song.sql.go +++ b/internal/database/sqlc/song.sql.go @@ -60,33 +60,78 @@ func (q *Queries) SongArtistGetBySpotify(ctx context.Context, spotifyID string) return i, err } -const songArtistGetBySpotifyIds = `-- name: SongArtistGetBySpotifyIds :many -SELECT a.id, a.name, a.spotify_id, g.id, g.genre -FROM song_artist a -LEFT JOIN song_genre g ON a.id = g.artist_id -WHERE a.spotify_id = ANY($1::text[]) +const songArtistGetTop50 = `-- name: SongArtistGetTop50 :many +SELECT a.id, a.name, a.spotify_id, COUNT(sh.id) AS play_count +FROM song_history sh +JOIN song s ON sh.song_id = s.id +JOIN song_artist_song sas ON s.id = sas.song_id +JOIN song_artist a ON sas.artist_id = a.id +GROUP BY a.id, a.name +ORDER BY play_count DESC +LIMIT 50 ` -type SongArtistGetBySpotifyIdsRow struct { +type SongArtistGetTop50Row struct { SongArtist SongArtist - SongGenre SongGenre + PlayCount int64 } -func (q *Queries) SongArtistGetBySpotifyIds(ctx context.Context, dollar_1 []string) ([]SongArtistGetBySpotifyIdsRow, error) { - rows, err := q.db.Query(ctx, songArtistGetBySpotifyIds, dollar_1) +func (q *Queries) SongArtistGetTop50(ctx context.Context) ([]SongArtistGetTop50Row, error) { + rows, err := q.db.Query(ctx, songArtistGetTop50) if err != nil { return nil, err } defer rows.Close() - var items []SongArtistGetBySpotifyIdsRow + var items []SongArtistGetTop50Row for rows.Next() { - var i SongArtistGetBySpotifyIdsRow + var i SongArtistGetTop50Row if err := rows.Scan( &i.SongArtist.ID, &i.SongArtist.Name, &i.SongArtist.SpotifyID, - &i.SongGenre.ID, - &i.SongGenre.Genre, + &i.PlayCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const songArtistGetTop50Monthly = `-- name: SongArtistGetTop50Monthly :many +SELECT a.id, a.name, a.spotify_id, COUNT(sh.id) AS play_count +FROM song_history sh +JOIN song s ON sh.song_id = s.id +JOIN song_artist_song sas ON s.id = sas.song_id +JOIN song_artist a ON sas.artist_id = a.id +WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' +GROUP BY a.id, a.name +ORDER BY play_count DESC +LIMIT 50 +` + +type SongArtistGetTop50MonthlyRow struct { + SongArtist SongArtist + PlayCount int64 +} + +func (q *Queries) SongArtistGetTop50Monthly(ctx context.Context) ([]SongArtistGetTop50MonthlyRow, error) { + rows, err := q.db.Query(ctx, songArtistGetTop50Monthly) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SongArtistGetTop50MonthlyRow + for rows.Next() { + var i SongArtistGetTop50MonthlyRow + if err := rows.Scan( + &i.SongArtist.ID, + &i.SongArtist.Name, + &i.SongArtist.SpotifyID, + &i.PlayCount, ); err != nil { return nil, err } @@ -171,6 +216,83 @@ func (q *Queries) SongGenreGetByGenre(ctx context.Context, genre string) (SongGe return i, err } +const songGenreGetTop50 = `-- name: SongGenreGetTop50 :many +SELECT g.id, g.genre, COUNT(sh.id) AS play_count +FROM song_history sh +JOIN song s ON sh.song_id = s.id +JOIN song_artist_song sas ON s.id = sas.song_id +JOIN song_artist sa ON sas.artist_id = sa.id +JOIN song_artist_genre sag ON sa.id = sag.artist_id +JOIN song_genre g ON sag.genre_id = g.id +GROUP BY g.genre, g.id +ORDER BY play_count DESC +LIMIT 50 +` + +type SongGenreGetTop50Row struct { + SongGenre SongGenre + PlayCount int64 +} + +func (q *Queries) SongGenreGetTop50(ctx context.Context) ([]SongGenreGetTop50Row, error) { + rows, err := q.db.Query(ctx, songGenreGetTop50) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SongGenreGetTop50Row + for rows.Next() { + var i SongGenreGetTop50Row + if err := rows.Scan(&i.SongGenre.ID, &i.SongGenre.Genre, &i.PlayCount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const songGenreGetTop50Monthly = `-- name: SongGenreGetTop50Monthly :many +SELECT g.id, g.genre, COUNT(sh.id) AS play_count +FROM song_history sh +JOIN song s ON sh.song_id = s.id +JOIN song_artist_song sas ON s.id = sas.song_id +JOIN song_artist sa ON sas.artist_id = sa.id +JOIN song_artist_genre sag ON sa.id = sag.artist_id +JOIN song_genre g ON sag.genre_id = g.id +WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' +GROUP BY g.genre, g.id +ORDER BY play_count DESC +LIMIT 50 +` + +type SongGenreGetTop50MonthlyRow struct { + SongGenre SongGenre + PlayCount int64 +} + +func (q *Queries) SongGenreGetTop50Monthly(ctx context.Context) ([]SongGenreGetTop50MonthlyRow, error) { + rows, err := q.db.Query(ctx, songGenreGetTop50Monthly) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SongGenreGetTop50MonthlyRow + for rows.Next() { + var i SongGenreGetTop50MonthlyRow + if err := rows.Scan(&i.SongGenre.ID, &i.SongGenre.Genre, &i.PlayCount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const songGetBySpotify = `-- name: SongGetBySpotify :one SELECT id, title, spotify_id, duration_ms, album, lyrics, lyrics_type FROM song @@ -192,6 +314,189 @@ func (q *Queries) SongGetBySpotify(ctx context.Context, spotifyID string) (Song, return i, err } +const songGetLast50 = `-- name: SongGetLast50 :many +SELECT s.id, s.title, s.spotify_id, s.duration_ms, s.album, s.lyrics, s.lyrics_type, play_count +FROM ( + SELECT sh.song_id, MAX(sh.created_at) AS created_at, COUNT(sh.song_id) AS play_count + FROM song_history sh + GROUP BY sh.song_id +) aggregated +JOIN song s ON s.id = aggregated.song_id +ORDER BY aggregated.created_at DESC +LIMIT 50 +` + +type SongGetLast50Row struct { + Song Song + PlayCount int64 +} + +func (q *Queries) SongGetLast50(ctx context.Context) ([]SongGetLast50Row, error) { + rows, err := q.db.Query(ctx, songGetLast50) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SongGetLast50Row + for rows.Next() { + var i SongGetLast50Row + if err := rows.Scan( + &i.Song.ID, + &i.Song.Title, + &i.Song.SpotifyID, + &i.Song.DurationMs, + &i.Song.Album, + &i.Song.Lyrics, + &i.Song.LyricsType, + &i.PlayCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const songGetLastPopulated = `-- name: SongGetLastPopulated :many +SELECT h.id, h.song_id, h.created_at, s.id, s.title, s.spotify_id, s.duration_ms, s.album, s.lyrics, s.lyrics_type, a.id, a.name, a.spotify_id +FROM song_history h +JOIN song s ON s.id = h.song_id +LEFT JOIN song_artist_song sa ON sa.song_id = s.id +LEFT JOIN song_artist a ON a.id = sa.artist_id +WHERE h.created_at = (SELECT MAX(created_at) FROM song_history) +ORDER BY a.name +` + +type SongGetLastPopulatedRow struct { + SongHistory SongHistory + Song Song + SongArtist SongArtist +} + +func (q *Queries) SongGetLastPopulated(ctx context.Context) ([]SongGetLastPopulatedRow, error) { + rows, err := q.db.Query(ctx, songGetLastPopulated) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SongGetLastPopulatedRow + for rows.Next() { + var i SongGetLastPopulatedRow + if err := rows.Scan( + &i.SongHistory.ID, + &i.SongHistory.SongID, + &i.SongHistory.CreatedAt, + &i.Song.ID, + &i.Song.Title, + &i.Song.SpotifyID, + &i.Song.DurationMs, + &i.Song.Album, + &i.Song.Lyrics, + &i.Song.LyricsType, + &i.SongArtist.ID, + &i.SongArtist.Name, + &i.SongArtist.SpotifyID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const songGetTop50 = `-- name: SongGetTop50 :many +SELECT s.id, s.title, s.spotify_id, s.duration_ms, s.album, s.lyrics, s.lyrics_type, COUNT(sh.id) AS play_count +FROM song_history sh +JOIN song s ON sh.song_id = s.id +GROUP BY s.id, s.title +ORDER BY play_count DESC +LIMIT 50 +` + +type SongGetTop50Row struct { + Song Song + PlayCount int64 +} + +func (q *Queries) SongGetTop50(ctx context.Context) ([]SongGetTop50Row, error) { + rows, err := q.db.Query(ctx, songGetTop50) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SongGetTop50Row + for rows.Next() { + var i SongGetTop50Row + if err := rows.Scan( + &i.Song.ID, + &i.Song.Title, + &i.Song.SpotifyID, + &i.Song.DurationMs, + &i.Song.Album, + &i.Song.Lyrics, + &i.Song.LyricsType, + &i.PlayCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const songGetTop50Monthly = `-- name: SongGetTop50Monthly :many +SELECT s.id, s.title, s.spotify_id, s.duration_ms, s.album, s.lyrics, s.lyrics_type, COUNT(sh.id) AS play_count +FROM song_history sh +JOIN song s ON sh.song_id = s.id +WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' +GROUP BY s.id, s.title +ORDER BY play_count DESC +LIMIT 50 +` + +type SongGetTop50MonthlyRow struct { + Song Song + PlayCount int64 +} + +func (q *Queries) SongGetTop50Monthly(ctx context.Context) ([]SongGetTop50MonthlyRow, error) { + rows, err := q.db.Query(ctx, songGetTop50Monthly) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SongGetTop50MonthlyRow + for rows.Next() { + var i SongGetTop50MonthlyRow + if err := rows.Scan( + &i.Song.ID, + &i.Song.Title, + &i.Song.SpotifyID, + &i.Song.DurationMs, + &i.Song.Album, + &i.Song.Lyrics, + &i.Song.LyricsType, + &i.PlayCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const songHistoryCreate = `-- name: SongHistoryCreate :one INSERT INTO song_history (song_id) VALUES ($1) diff --git a/tui/screen/song/song.go b/tui/screen/song/song.go index ca48a8f..1460d59 100644 --- a/tui/screen/song/song.go +++ b/tui/screen/song/song.go @@ -18,9 +18,9 @@ type Song struct { } // New creates a new song screen -func New(_ repository.Repository) screen.Screen { +func New(repo repository.Repository) screen.Screen { return &Song{ - song: song.New(), + song: song.New(repo), width: 0, height: 0, } diff --git a/tui/view/song/song.go b/tui/view/song/song.go index 9591774..c37eefb 100644 --- a/tui/view/song/song.go +++ b/tui/view/song/song.go @@ -2,11 +2,59 @@ package song import ( + "time" + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/internal/database/repository" + "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/pkg/lyrics" + "github.com/zeusWPI/scc/tui/components/bar" + "github.com/zeusWPI/scc/tui/components/stopwatch" "github.com/zeusWPI/scc/tui/view" + "go.uber.org/zap" +) + +const ( + previousAmount = 2 + upcomingAmount = 4 + + statsAmount = 3 ) +type stat struct { + title string + entries []statEntry +} + +type statEntry struct { + name string + amount int +} + +type playing struct { + song model.Song + playing bool + lyrics lyrics.Lyrics + previous []string // Lyrics already sang + current string // Current lyric + upcoming []string // Lyrics that are coming up +} + +type progression struct { + stopwatch stopwatch.Model + bar bar.Model +} + type Model struct { + repo repository.Song + current playing + progress progression + + history stat + stats []stat + statsMonthly []stat + width int height int } @@ -17,16 +65,49 @@ var _ view.View = (*Model)(nil) // Msg contains the data to update the gamification model type Msg struct{} +type msgHistory struct { + history stat +} + +type msgStats struct { + monthly bool + stats []stat +} + +type msgPlaying struct { + song model.Song + lyrics lyrics.Lyrics +} + +type msgLyrics struct { + song model.Song + playing bool + previous []string + current string + upcoming []string + startNext time.Time +} + // New initializes a new song model -func New() view.View { +func New(repo repository.Repository) view.View { return &Model{ - width: 0, - height: 0, + repo: *repo.NewSong(), + progress: progression{ + stopwatch: stopwatch.New(), + bar: bar.New(sStatusBar), + }, + stats: make([]stat, 4), + statsMonthly: make([]stat, 4), + width: 0, + height: 0, } } func (m *Model) Init() tea.Cmd { - return nil + return tea.Batch( + m.progress.stopwatch.Init(), + m.progress.bar.Init(), + ) } func (m *Model) Name() string { @@ -42,12 +123,105 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { // Update all dependent styles m.width = entry.Width m.height = entry.Height + + m.updateStyles() } return m, nil - default: - break + case msgPlaying: + // We're playing a song + // Initialize the variables + m.current.song = msg.song + m.current.playing = true + m.current.lyrics = msg.lyrics + m.current.current = "" + m.current.previous = []string{""} + m.current.upcoming = []string{""} + + // The song might already been playing for some time + // Let's go through the lyrics until we get to the current one + lyric, ok := m.current.lyrics.Current() + if !ok { + // Shouldn't happen + zap.S().Error("song: Unable to get current lyric in initialization phase: ", m.current.song.Title) + m.current.playing = false + return m, nil + } + + startTime := m.current.song.PlayedAt.Add(lyric.Duration) // Start time of the next lyric + for startTime.Before(time.Now()) { + // This lyric is already finished, onto the next! + lyric, ok := m.current.lyrics.Next() + if !ok { + // No more lyrics to display, the song is already finished + m.current.playing = false + return m, m.progress.stopwatch.Reset() + } + startTime = startTime.Add(lyric.Duration) + } + + // We have the right lyric, let's get the previous and upcoming lyrics + m.current.current = lyric.Text + m.current.previous = lyricsToString(m.current.lyrics.Previous(previousAmount)) + m.current.upcoming = lyricsToString(m.current.lyrics.Upcoming(upcomingAmount)) + + // Start the update loop + return m, tea.Batch( + updateLyrics(m.current, startTime), + m.progress.stopwatch.Start(time.Since(m.current.song.PlayedAt)), + m.progress.bar.Start(view.GetWidth(sStatusBar), time.Since(m.current.song.PlayedAt), time.Duration(m.current.song.DurationMS)*time.Millisecond), + ) + + case msgHistory: + m.history = msg.history + + return m, nil + + case msgStats: + if msg.monthly { + // Monthly stats + m.statsMonthly = msg.stats + return m, nil + } + + m.stats = msg.stats + return m, nil + + case msgLyrics: + // Check if it's still relevant + if msg.song.ID != m.current.song.ID { + // We already switched to a new song + return m, nil + } + + m.current.playing = msg.playing + if !m.current.playing { + // Song has finished. Reset variables + return m, m.progress.stopwatch.Reset() + } + + m.current.previous = msg.previous + m.current.current = msg.current + m.current.upcoming = msg.upcoming + + // Start the cmd to update the lyrics + return m, updateLyrics(m.current, msg.startNext) + } + + // Maybe a stopwatch message? + var cmd tea.Cmd + stopwatchNew, cmd := m.progress.stopwatch.Update(msg) + m.progress.stopwatch = stopwatchNew.(stopwatch.Model) + if cmd != nil { + return m, cmd + } + + // Apparently not, lets try the bar! + barNew, cmd := m.progress.bar.Update(msg) + m.progress.bar = barNew.(bar.Model) + if cmd != nil { + return m, cmd } return m, nil @@ -55,10 +229,39 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { // View draws the song view func (m *Model) View() string { - return "Not implemented" + if m.current.playing { + return m.viewPlaying() + } + + return m.viewNotPlaying() } // GetUpdateDatas gets all update functions for the song view func (m *Model) GetUpdateDatas() []view.UpdateData { - return nil + return []view.UpdateData{ + { + Name: "update current song", + View: m, + Update: updateCurrentSong, + Interval: config.GetDefaultInt("tui.view.song.interval_current_s", 5), + }, + { + Name: "update history", + View: m, + Update: updateHistory, + Interval: config.GetDefaultInt("tui.view.song.interval_history_s", 5), + }, + { + Name: "monthly stats", + View: m, + Update: updateMonthlyStats, + Interval: config.GetDefaultInt("tui.view.song.interval_monthly_stats_s", 300), + }, + { + Name: "all time stats", + View: m, + Update: updateStats, + Interval: config.GetDefaultInt("tui.view.song.interval_stats_s", 3600), + }, + } } diff --git a/tui/view/song/style.go b/tui/view/song/style.go new file mode 100644 index 0000000..062d8f2 --- /dev/null +++ b/tui/view/song/style.go @@ -0,0 +1,106 @@ +package song + +import ( + "math" + + "github.com/charmbracelet/lipgloss" + "github.com/zeusWPI/scc/tui/view" +) + +// Title for statistics +const ( + tStatHistory = "Recently Played" + tStatSong = "Top Songs" + tStatGenre = "Top Genres" + tStatArtist = "Top Artists" +) + +// Colors +var ( + cZeus = lipgloss.Color("#FF7F00") + cSpotify = lipgloss.Color("#1DB954") + cBorder = lipgloss.Color("#383838") +) + +// Base style +var base = lipgloss.NewStyle() + +// Styles for the stats +var ( + // Widths + wStatEnum = 3 + wStatAmount = 4 // Supports up to 1000 + wStatEntryMax = 35 + + // Styles + sStat = base.Align(lipgloss.Center).BorderStyle(lipgloss.NormalBorder()).BorderTop(true).BorderForeground(cBorder).PaddingTop(1) + sStatOne = base.Margin(0, 1) + sStatTitle = base.Foreground(cZeus).Align(lipgloss.Center).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(cSpotify) + sStatEnum = base.Foreground(cSpotify).Width(wStatEnum).Align(lipgloss.Left) + sStatEntry = base.Align(lipgloss.Left) + sStatAmount = base.Foreground(cZeus).Width(wStatAmount).Align(lipgloss.Right) + + // Specific styles for when no song is playing + sStatCategory = base.Align(lipgloss.Center) + sStatCategoryTitle = base.Foreground(cZeus).Align(lipgloss.Center).Border(lipgloss.NormalBorder(), true, false).BorderForeground(cBorder) + sStatHistory = base.MarginRight(1).PaddingRight(2).Border(lipgloss.ThickBorder(), false, true, false, false).BorderForeground(cBorder) +) + +// Styles for the lyrics +var ( + wLyricsF = 0.8 // Fraction of width + + sLyric = base.AlignVertical(lipgloss.Center).Align(lipgloss.Center) + sLyricPrevious = base.Foreground(cZeus).Bold(true).Align(lipgloss.Center).Faint(true) + sLyricCurrent = base.Foreground(cZeus).Bold(true).Align(lipgloss.Center) + sLyricUpcoming = base.Foreground(cSpotify).Bold(true).Align(lipgloss.Center) +) + +// Styles for the status +var ( + sStatus = base.MarginTop(1) + sStatusSong = base.Align(lipgloss.Center) + sStatusStopwatch = base.Faint(true) + sStatusBar = base.Foreground(cZeus).Align(lipgloss.Left) +) + +// Style for everything +var ( + sAll = base.Align(lipgloss.Center).AlignVertical(lipgloss.Center) +) + +// updateStyles updates all the affected styles when a size update message is received +func (m *Model) updateStyles() { + // Adjust stats styles + sStat = sStat.Width(m.width) + + wStatEntry := int(math.Min(float64(wStatEntryMax), float64(m.width/4)-float64(view.GetOuterWidth(sStatOne)+wStatEnum+wStatAmount))) + sStatEntry = sStatEntry.Width(wStatEntry) + sStatOne = sStatOne.Width(wStatEnum + wStatAmount + wStatEntry) + sStatTitle = sStatTitle.Width(wStatEnum + wStatAmount + wStatEntry) + if wStatEntry == wStatEntryMax { + // We're full screen + sStatOne = sStatOne.Margin(0, 3) + } + sStatCategory = sStatCategory.Width(2 * (sStatOne.GetWidth() + view.GetOuterWidth(sStatOne))) + sStatCategoryTitle = sStatCategoryTitle.Width(2*sStatOne.GetWidth() + view.GetOuterWidth(sStatOne)) + + // Adjust lyrics styles + sLyric = sLyric.Width(m.width) + + wLyrics := int(float64(m.width) * wLyricsF) + sLyricPrevious = sLyricPrevious.Width(wLyrics) + sLyricCurrent = sLyricCurrent.Width(wLyrics) + sLyricUpcoming = sLyricUpcoming.Width(wLyrics) + + // Adjust status styles + + sStatusSong = sStatusSong.Width(m.width - view.GetOuterWidth(sStatusSong)) + sStatusBar = sStatusBar.Width(m.width - view.GetOuterWidth(sStatusBar)) + + // Adjust the all styles + sAll = sAll.Height(m.height - view.GetOuterHeight(sAll)). + MaxHeight(m.height - view.GetOuterHeight(sAll)). + Width(m.width - view.GetOuterWidth(sAll)). + MaxWidth(m.width - view.GetOuterWidth(sAll)) +} diff --git a/tui/view/song/update.go b/tui/view/song/update.go new file mode 100644 index 0000000..2ebcc52 --- /dev/null +++ b/tui/view/song/update.go @@ -0,0 +1,187 @@ +package song + +import ( + "context" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/pkg/lyrics" + "github.com/zeusWPI/scc/pkg/utils" + "github.com/zeusWPI/scc/tui/view" +) + +// updateCurrentSong checks if there's currently a song playing +func updateCurrentSong(ctx context.Context, view view.View) (tea.Msg, error) { + m := view.(*Model) + + song, err := m.repo.GetLastPopulated(ctx) + if err != nil { + return nil, err + } + if song == nil { + return nil, nil + } + + // Check if song is still playing + if song.PlayedAt.Add(time.Duration(song.DurationMS) * time.Millisecond).Before(time.Now()) { + // Song is finished + return nil, nil + } + + if m.current.playing && song.ID == m.current.song.ID { + // Song is already set to current + return nil, nil + } + + return msgPlaying{song: *song, lyrics: lyrics.New(*song)}, nil +} + +// updateHistory updates the recently played list +func updateHistory(ctx context.Context, view view.View) (tea.Msg, error) { + m := view.(*Model) + + histories, err := m.repo.GetLast50(ctx) + if err != nil { + return nil, err + } + histories = utils.SliceGet(histories, statsAmount) + + stat := stat{title: tStatHistory, entries: []statEntry{}} + for _, h := range histories { + stat.entries = append(stat.entries, statEntry{name: h.Title, amount: h.PlayCount}) + } + + return msgHistory{history: stat}, nil +} + +// Update all monthly stats +func updateMonthlyStats(ctx context.Context, view view.View) (tea.Msg, error) { + m := view.(*Model) + + songs, err := m.repo.GetTopSongsMonthly(ctx) + if err != nil { + return nil, err + } + songs = utils.SliceGet(songs, statsAmount) + + genres, err := m.repo.GetTopGenresMonthly(ctx) + if err != nil { + return nil, err + } + genres = utils.SliceGet(genres, statsAmount) + + artists, err := m.repo.GetTopArtistsMonthly(ctx) + if err != nil { + return nil, err + } + artists = utils.SliceGet(artists, statsAmount) + + msg := msgStats{monthly: true, stats: []stat{}} + + // Songs + s := stat{title: tStatSong, entries: []statEntry{}} + for _, song := range songs { + s.entries = append(s.entries, statEntry{name: song.Title, amount: song.PlayCount}) + } + msg.stats = append(msg.stats, s) + + // Genres + s = stat{title: tStatGenre, entries: []statEntry{}} + for _, genre := range genres { + s.entries = append(s.entries, statEntry{name: genre.Genre, amount: genre.PlayCount}) + } + msg.stats = append(msg.stats, s) + + // Artists + s = stat{title: tStatArtist, entries: []statEntry{}} + for _, artist := range artists { + s.entries = append(s.entries, statEntry{name: artist.Name, amount: artist.PlayCount}) + } + msg.stats = append(msg.stats, s) + + return msg, nil +} + +// Update all stats +func updateStats(ctx context.Context, view view.View) (tea.Msg, error) { + m := view.(*Model) + + songs, err := m.repo.GetTopSongs(ctx) + if err != nil { + return nil, err + } + songs = utils.SliceGet(songs, statsAmount) + + genres, err := m.repo.GetTopGenres(ctx) + if err != nil { + return nil, err + } + genres = utils.SliceGet(genres, statsAmount) + + artists, err := m.repo.GetTopArtists(ctx) + if err != nil { + return nil, err + } + artists = utils.SliceGet(artists, statsAmount) + + // Don't bother checking if anything has changed + // A single extra refresh won't matter + + msg := msgStats{monthly: false, stats: []stat{}} + + // Songs + s := stat{title: tStatSong, entries: []statEntry{}} + for _, song := range songs { + s.entries = append(s.entries, statEntry{name: song.Title, amount: song.PlayCount}) + } + msg.stats = append(msg.stats, s) + + // Genres + s = stat{title: tStatGenre, entries: []statEntry{}} + for _, genre := range genres { + s.entries = append(s.entries, statEntry{name: genre.Genre, amount: genre.PlayCount}) + } + msg.stats = append(msg.stats, s) + + // Artists + s = stat{title: tStatArtist, entries: []statEntry{}} + for _, artist := range artists { + s.entries = append(s.entries, statEntry{name: artist.Name, amount: artist.PlayCount}) + } + msg.stats = append(msg.stats, s) + + return msg, nil +} + +// Update the current lyric +func updateLyrics(song playing, start time.Time) tea.Cmd { + // How long do we need to wait until we can update the lyric? + timeout := time.Duration(0) + now := time.Now() + if start.After(now) { + timeout = start.Sub(now) + } + + return tea.Tick(timeout, func(_ time.Time) tea.Msg { + // Next lyric + lyric, ok := song.lyrics.Next() + if !ok { + // Song finished + return msgLyrics{song: song.song, playing: false} // Values in the other fields are not looked at when the song is finished + } + + previous := song.lyrics.Previous(previousAmount) + upcoming := song.lyrics.Upcoming(upcomingAmount) + + end := start.Add(lyric.Duration) + + return msgLyrics{ + song: song.song, + playing: true, + previous: lyricsToString(previous), + current: lyric.Text, + upcoming: lyricsToString(upcoming), + startNext: end, + } + }) +} diff --git a/tui/view/song/util.go b/tui/view/song/util.go new file mode 100644 index 0000000..fcd216a --- /dev/null +++ b/tui/view/song/util.go @@ -0,0 +1,11 @@ +package song + +import "github.com/zeusWPI/scc/pkg/lyrics" + +func lyricsToString(lyrics []lyrics.Lyric) []string { + text := make([]string, 0, len(lyrics)) + for _, lyric := range lyrics { + text = append(text, lyric.Text) + } + return text +} diff --git a/tui/view/song/view.go b/tui/view/song/view.go new file mode 100644 index 0000000..2e112c2 --- /dev/null +++ b/tui/view/song/view.go @@ -0,0 +1,163 @@ +package song + +import ( + "fmt" + "math" + "strconv" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +func (m *Model) viewPlaying() string { + status := m.viewPlayingStatus() + status = sStatus.Render(status) + + stats := m.viewPlayingStats() + stats = sStat.Render(stats) + + lyrics := m.viewPlayingLyrics() + lyrics = sLyric.Height(sAll.GetHeight() - lipgloss.Height(status) - lipgloss.Height(stats)). + MaxHeight(sAll.GetHeight() - lipgloss.Height(status) - lipgloss.Height(stats)). + Render(lyrics) + + view := lipgloss.JoinVertical(lipgloss.Left, status, lyrics, stats) + + return sAll.Render(view) +} + +func (m *Model) viewPlayingStatus() string { + // Stopwatch + durationS := int(math.Round(float64(m.current.song.DurationMS) / 1000)) + stopwatch := fmt.Sprintf("\t%s / %02d:%02d", m.progress.stopwatch.View(), durationS/60, durationS%60) + stopwatch = sStatusStopwatch.Render(stopwatch) + + // Song name + var artists strings.Builder + for _, artist := range m.current.song.Artists { + artists.WriteString(artist.Name + " & ") + } + artist := artists.String() + if len(artist) > 0 { + artist = artist[:len(artist)-3] + } + + song := sStatusSong.Width(sStatusSong.GetWidth() - lipgloss.Width(stopwatch)).Render(fmt.Sprintf("%s | %s", m.current.song.Title, artist)) + + // Progress bar + progress := m.progress.bar.View() + progress = sStatusBar.Render(progress) + + view := lipgloss.JoinHorizontal(lipgloss.Top, song, stopwatch) + view = lipgloss.JoinVertical(lipgloss.Left, view, progress) + + return view +} + +func (m *Model) viewPlayingLyrics() string { + var previousB strings.Builder + for i, lyric := range m.current.previous { + previousB.WriteString(lyric) + if i != len(m.current.previous)-1 { + previousB.WriteString("\n") + } + } + previous := sLyricPrevious.Render(previousB.String()) + + current := sLyricCurrent.Render(m.current.current) + + var upcomingB strings.Builder + for _, lyric := range m.current.upcoming { + upcomingB.WriteString(lyric) + upcomingB.WriteString("\n") + } + upcoming := sLyricUpcoming.Render(upcomingB.String()) + + return sLyric.Render(lipgloss.JoinVertical(lipgloss.Left, previous, current, upcoming)) +} + +func (m *Model) viewPlayingStats() string { + columns := make([]string, 0, 4) + + columns = append(columns, m.viewStatPlaying(m.history)) + columns = append(columns, m.viewStatPlaying(m.statsMonthly[0])) + columns = append(columns, m.viewStatPlaying(m.statsMonthly[1])) + columns = append(columns, m.viewStatPlaying(m.statsMonthly[2])) + + return lipgloss.JoinHorizontal(lipgloss.Top, columns...) +} + +func (m *Model) viewNotPlaying() string { + // Render stats + rows := make([][]string, 0, 3) + for range 3 { + rows = append(rows, make([]string, 0, 2)) + } + + rows[0] = append(rows[0], m.viewStatPlaying(m.statsMonthly[0], "Monthly")) + rows[0] = append(rows[0], m.viewStatPlaying(m.stats[0], "All Time")) + rows[1] = append(rows[1], m.viewStatPlaying(m.statsMonthly[1], "Monthly")) + rows[1] = append(rows[1], m.viewStatPlaying(m.stats[1], "All Time")) + rows[2] = append(rows[2], m.viewStatPlaying(m.statsMonthly[2], "Monthly")) + rows[2] = append(rows[2], m.viewStatPlaying(m.stats[2], "All Time")) + + renderedRows := make([]string, 0, 3) + var title string + for i, row := range rows { + r := lipgloss.JoinHorizontal(lipgloss.Top, row...) + title = sStatCategory.Render(sStatCategoryTitle.Render(m.stats[i].title)) // HACK: Make border same size as 2 stats next to each other + renderedRows = append(renderedRows, lipgloss.JoinVertical(lipgloss.Left, title, r)) + } + + v := lipgloss.JoinVertical(lipgloss.Left, renderedRows...) + + // Render history + items := make([]string, 0, len(m.history.entries)) + + // Push it down + for range lipgloss.Height(title) { + items = append(items, "") + } + items = append(items, sStatTitle.Render(m.history.title)) + + for i, entry := range m.history.entries { + enum := sStatEnum.Render(fmt.Sprintf("%d.", i+1)) + body := sStatEntry.Render(entry.name) + amount := sStatAmount.Render(strconv.Itoa(entry.amount)) + items = append(items, lipgloss.JoinHorizontal(lipgloss.Top, enum, body, amount)) + } + items = append(items, "") // HACK: Avoid the last item shifting to the right + list := lipgloss.JoinVertical(lipgloss.Left, items...) + // title := sStatTitle.Render(m.history.title) + history := sStatHistory.Height(lipgloss.Height(v) - 1).MaxHeight(lipgloss.Height(v) - 1).Render(list) // - 1 to compensate for the hack newline at the end + + v = lipgloss.JoinHorizontal(lipgloss.Top, history, v) + + return sAll.Render(v) +} + +func (m *Model) viewStatPlaying(stat stat, titleOpt ...string) string { + title := stat.title + if len(titleOpt) > 0 { + title = titleOpt[0] + } + + items := make([]string, 0, len(stat.entries)) + for i := range stat.entries { + if i >= 10 { + break + } + + enum := sStatEnum.Render(fmt.Sprintf("%d.", i+1)) + body := sStatEntry.Render(stat.entries[i].name) + amount := sStatAmount.Render(strconv.Itoa(stat.entries[i].amount)) + + items = append(items, lipgloss.JoinHorizontal(lipgloss.Top, enum, body, amount)) + } + items = append(items, "") // HACK: Avoid the last item shifting to the right + l := lipgloss.JoinVertical(lipgloss.Left, items...) + + t := sStatTitle.Render(title) + + return sStatOne.Render(lipgloss.JoinVertical(lipgloss.Left, t, l)) +} From 0f042a86fba51c6398bc8edd8b822fd080eb7d6a Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 30 Oct 2025 17:34:08 +0100 Subject: [PATCH 15/15] chore(song): move amount of stats to the config --- config/development.yml | 1 + internal/song/lrc.go | 1 + tui/view/song/song.go | 4 ++-- tui/view/song/update.go | 13 ++++++------- tui/view/song/view.go | 8 ++++++-- 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/config/development.yml b/config/development.yml index 417c376..16e3bd4 100644 --- a/config/development.yml +++ b/config/development.yml @@ -92,6 +92,7 @@ tui: interval_history_s: 5 interval_monthly_stats_s: 300 interval_stats_s: 3600 + stat_amount: 3 tap: interval_s: 60 diff --git a/internal/song/lrc.go b/internal/song/lrc.go index acaed6c..52b0462 100644 --- a/internal/song/lrc.go +++ b/internal/song/lrc.go @@ -43,6 +43,7 @@ func (c *client) getLyrics(song *model.Song) error { if status != fiber.StatusOK { if status == fiber.StatusNotFound { // Lyrics not found + song.LyricsType = model.LyricsMissing return nil } diff --git a/tui/view/song/song.go b/tui/view/song/song.go index c37eefb..cac2202 100644 --- a/tui/view/song/song.go +++ b/tui/view/song/song.go @@ -18,8 +18,6 @@ import ( const ( previousAmount = 2 upcomingAmount = 4 - - statsAmount = 3 ) type stat struct { @@ -54,6 +52,7 @@ type Model struct { history stat stats []stat statsMonthly []stat + statAmount int width int height int @@ -98,6 +97,7 @@ func New(repo repository.Repository) view.View { }, stats: make([]stat, 4), statsMonthly: make([]stat, 4), + statAmount: config.GetDefaultInt("tui.view.song.stat_amount", 3), width: 0, height: 0, } diff --git a/tui/view/song/update.go b/tui/view/song/update.go index 2ebcc52..7cc97c5 100644 --- a/tui/view/song/update.go +++ b/tui/view/song/update.go @@ -44,7 +44,6 @@ func updateHistory(ctx context.Context, view view.View) (tea.Msg, error) { if err != nil { return nil, err } - histories = utils.SliceGet(histories, statsAmount) stat := stat{title: tStatHistory, entries: []statEntry{}} for _, h := range histories { @@ -62,19 +61,19 @@ func updateMonthlyStats(ctx context.Context, view view.View) (tea.Msg, error) { if err != nil { return nil, err } - songs = utils.SliceGet(songs, statsAmount) + songs = utils.SliceGet(songs, m.statAmount) genres, err := m.repo.GetTopGenresMonthly(ctx) if err != nil { return nil, err } - genres = utils.SliceGet(genres, statsAmount) + genres = utils.SliceGet(genres, m.statAmount) artists, err := m.repo.GetTopArtistsMonthly(ctx) if err != nil { return nil, err } - artists = utils.SliceGet(artists, statsAmount) + artists = utils.SliceGet(artists, m.statAmount) msg := msgStats{monthly: true, stats: []stat{}} @@ -110,19 +109,19 @@ func updateStats(ctx context.Context, view view.View) (tea.Msg, error) { if err != nil { return nil, err } - songs = utils.SliceGet(songs, statsAmount) + songs = utils.SliceGet(songs, m.statAmount) genres, err := m.repo.GetTopGenres(ctx) if err != nil { return nil, err } - genres = utils.SliceGet(genres, statsAmount) + genres = utils.SliceGet(genres, m.statAmount) artists, err := m.repo.GetTopArtists(ctx) if err != nil { return nil, err } - artists = utils.SliceGet(artists, statsAmount) + artists = utils.SliceGet(artists, m.statAmount) // Don't bother checking if anything has changed // A single extra refresh won't matter diff --git a/tui/view/song/view.go b/tui/view/song/view.go index 2e112c2..ca47876 100644 --- a/tui/view/song/view.go +++ b/tui/view/song/view.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + "github.com/zeusWPI/scc/pkg/utils" ) func (m *Model) viewPlaying() string { @@ -79,7 +80,10 @@ func (m *Model) viewPlayingLyrics() string { func (m *Model) viewPlayingStats() string { columns := make([]string, 0, 4) - columns = append(columns, m.viewStatPlaying(m.history)) + history := m.history + history.entries = utils.SliceGet(history.entries, m.statAmount) + + columns = append(columns, m.viewStatPlaying(history)) columns = append(columns, m.viewStatPlaying(m.statsMonthly[0])) columns = append(columns, m.viewStatPlaying(m.statsMonthly[1])) columns = append(columns, m.viewStatPlaying(m.statsMonthly[2])) @@ -120,7 +124,7 @@ func (m *Model) viewNotPlaying() string { } items = append(items, sStatTitle.Render(m.history.title)) - for i, entry := range m.history.entries { + for i, entry := range utils.SliceGet(m.history.entries, m.statAmount*3+(4*2)) { enum := sStatEnum.Render(fmt.Sprintf("%d.", i+1)) body := sStatEntry.Render(entry.name) amount := sStatAmount.Render(strconv.Itoa(entry.amount))