From 36f984a1a2f17190e18093e64e43b4daf963faeb Mon Sep 17 00:00:00 2001 From: Gabe Farrell <90876006+gabehf@users.noreply.github.com> Date: Wed, 19 Nov 2025 20:26:56 -0500 Subject: [PATCH] Pre-release version v0.0.14 (#96) * add dev branch container to workflow * correctly set the default range of ActivityGrid * fix: set name/short_name to koito (#61) * fix dev container push workflow * fix: race condition with using getComputedStyle primary color for dynamic activity grid darkening (#76) * Fix race condition with using getComputedStyle primary color for dynamic activity grid darkening Instead just use the color from the current theme directly. Tested works on initial load and theme changes. Fixes https://github.com/gabehf/Koito/issues/75 * Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name Split name out of the Theme struct to simplify custom theme saving/reading * fix: set first artist listed as primary by default (#81) * feat: add server-side configuration with default theme (#90) * docs: add example for usage of the main listenbrainz instance (#71) * docs: add example for usage of the main listenbrainz instance * Update scrobbler.md --------- Co-authored-by: Gabe Farrell <90876006+gabehf@users.noreply.github.com> * feat: add server-side cfg and default theme * fix: repair custom theme --------- Co-authored-by: m0d3rnX * docs: add default theme cfg option to docs * feat: add ability to manually scrobble track (#91) * feat: add button to manually scrobble from ui * fix: ensure timestamp is in the past, log fix * test: add integration test * feat: add first listened to dates for media items (#92) * fix: ensure error checks for ErrNoRows * feat: add now playing endpoint and ui (#93) * wip * feat: add now playing * fix: set default theme when config is not set * feat: fetch images from subsonic server (#94) * fix: useQuery instead of useEffect for now playing * feat: custom artist separator regex (#95) * Fix race condition with using getComputedStyle primary color for dynamic activity grid darkening Instead just use the color from the current theme directly. Tested works on initial load and theme changes. Fixes https://github.com/gabehf/Koito/issues/75 * Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name Split name out of the Theme struct to simplify custom theme saving/reading * feat: add server-side configuration with default theme (#90) * docs: add example for usage of the main listenbrainz instance (#71) * docs: add example for usage of the main listenbrainz instance * Update scrobbler.md --------- Co-authored-by: Gabe Farrell <90876006+gabehf@users.noreply.github.com> * feat: add server-side cfg and default theme * fix: repair custom theme --------- Co-authored-by: m0d3rnX * fix: rebase errors --------- Co-authored-by: pet <128837728+againstpetra@users.noreply.github.com> Co-authored-by: mlandry Co-authored-by: m0d3rnX --- .github/workflows/docker.yml | 89 +-- client/api/api.ts | 596 ++++++++++-------- client/app/components/ActivityGrid.tsx | 371 ++++++----- .../app/components/ActivityOptsSelector.tsx | 3 +- client/app/components/LastPlays.tsx | 233 ++++--- .../app/components/modals/AddListenModal.tsx | 57 ++ .../components/themeSwitcher/ThemeOption.tsx | 9 +- .../themeSwitcher/ThemeSwitcher.tsx | 135 ++-- client/app/providers/AppProvider.tsx | 34 +- client/app/providers/ThemeProvider.tsx | 182 +++--- client/app/root.tsx | 12 +- client/app/routes/Home.tsx | 2 +- client/app/routes/MediaItems/Album.tsx | 1 + client/app/routes/MediaItems/Artist.tsx | 1 + client/app/routes/MediaItems/MediaLayout.tsx | 10 +- client/app/routes/MediaItems/Track.tsx | 3 +- client/app/routes/ThemeHelper.tsx | 4 - client/app/styles/themes.css.ts | 171 +++-- client/public/site.webmanifest | 4 +- db/queries/listen.sql | 28 + db/queries/release.sql | 4 +- db/queries/track.sql | 4 +- .../content/docs/reference/configuration.md | 14 +- engine/engine.go | 7 +- engine/handlers/lbz_submit_listen.go | 6 +- engine/handlers/manual_scrobble.go | 77 +++ engine/handlers/now_playing.go | 41 ++ engine/handlers/server_cfg.go | 18 + engine/long_test.go | 84 +++ engine/routes.go | 3 + internal/catalog/associate_artists.go | 4 +- internal/catalog/catalog.go | 45 +- internal/catalog/catalog_test.go | 43 +- internal/catalog/submit_listen_test.go | 16 + internal/cfg/cfg.go | 60 ++ internal/db/psql/album.go | 7 + internal/db/psql/artist.go | 17 +- internal/db/psql/track.go | 11 +- internal/images/imagesrc.go | 39 +- internal/images/subsonic.go | 137 ++++ internal/memkv/memkv.go | 110 ++++ internal/models/album.go | 1 + internal/models/artist.go | 2 + internal/models/track.go | 1 + internal/repository/alias.sql.go | 2 +- internal/repository/artist.sql.go | 2 +- internal/repository/db.go | 2 +- internal/repository/etc.sql.go | 2 +- internal/repository/listen.sql.go | 69 +- internal/repository/models.go | 2 +- internal/repository/release.sql.go | 9 +- internal/repository/search.sql.go | 2 +- internal/repository/sessions.sql.go | 2 +- internal/repository/track.sql.go | 13 +- internal/repository/users.sql.go | 2 +- internal/repository/year.sql.go | 2 +- 56 files changed, 1893 insertions(+), 912 deletions(-) create mode 100644 client/app/components/modals/AddListenModal.tsx create mode 100644 engine/handlers/manual_scrobble.go create mode 100644 engine/handlers/now_playing.go create mode 100644 engine/handlers/server_cfg.go create mode 100644 internal/images/subsonic.go create mode 100644 internal/memkv/memkv.go diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index eac475d..63736ac 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -13,6 +13,8 @@ on: push: tags: - 'v*' + branches: + - dev workflow_dispatch: @@ -21,42 +23,37 @@ jobs: name: Go Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod - - name: Install libvips - run: | - sudo apt-get update - sudo apt-get install -y libvips-dev + - name: Install libvips + run: | + sudo apt-get update + sudo apt-get install -y libvips-dev - - name: Verify libvips install - run: vips --version + - name: Verify libvips install + run: vips --version - - name: Build - run: go build -v ./... + - name: Build + run: go build -v ./... - - name: Test - uses: robherley/go-test-action@v0 + - name: Test + uses: robherley/go-test-action@v0 push_to_registry: - name: Push Docker image to Docker Hub + name: Push Docker image to Docker Hub (release) + if: startsWith(github.ref, 'refs/tags/') needs: test runs-on: ubuntu-latest - permissions: - packages: write - contents: read - attestations: write - id-token: write steps: - - name: Check out the repo - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - name: Log in to Docker Hub - uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} @@ -64,19 +61,12 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 - with: - images: gabehf/koito - - name: Extract tag version - id: extract_version run: echo "KOITO_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV - - name: Build and push Docker image + - name: Build and push release image id: push - uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 + uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile @@ -88,9 +78,32 @@ jobs: KOITO_VERSION=${{ env.KOITO_VERSION }} platforms: linux/amd64,linux/arm64 - - name: Generate artifact attestation - uses: actions/attest-build-provenance@v2 + push_dev: + name: Push Docker image (dev branch) + if: github.ref == 'refs/heads/dev' + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push dev image + uses: docker/build-push-action@v6 with: - subject-name: index.docker.io/gabehf/koito - subject-digest: ${{ steps.push.outputs.digest }} - push-to-registry: true + context: . + file: ./Dockerfile + push: true + tags: | + gabehf/koito:dev + gabehf/koito:dev-${{ github.sha }} + build-args: | + KOITO_VERSION=dev + platforms: linux/amd64,linux/arm64 diff --git a/client/api/api.ts b/client/api/api.ts index b744a05..c7e0b96 100644 --- a/client/api/api.ts +++ b/client/api/api.ts @@ -1,327 +1,419 @@ interface getItemsArgs { - limit: number, - period: string, - page: number, - artist_id?: number, - album_id?: number, - track_id?: number + limit: number; + period: string; + page: number; + artist_id?: number; + album_id?: number; + track_id?: number; } interface getActivityArgs { - step: string - range: number - month: number - year: number - artist_id: number - album_id: number - track_id: number + step: string; + range: number; + month: number; + year: number; + artist_id: number; + album_id: number; + track_id: number; } -function getLastListens(args: getItemsArgs): Promise> { - return fetch(`/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}`).then(r => r.json() as Promise>) +function getLastListens( + args: getItemsArgs +): Promise> { + return fetch( + `/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}` + ).then((r) => r.json() as Promise>); } function getTopTracks(args: getItemsArgs): Promise> { - if (args.artist_id) { - return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}`).then(r => r.json() as Promise>) - } else if (args.album_id) { - return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&album_id=${args.album_id}&page=${args.page}`).then(r => r.json() as Promise>) - } else { - return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`).then(r => r.json() as Promise>) - } + if (args.artist_id) { + return fetch( + `/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}` + ).then((r) => r.json() as Promise>); + } else if (args.album_id) { + return fetch( + `/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&album_id=${args.album_id}&page=${args.page}` + ).then((r) => r.json() as Promise>); + } else { + return fetch( + `/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}` + ).then((r) => r.json() as Promise>); + } } function getTopAlbums(args: getItemsArgs): Promise> { - const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}` - if (args.artist_id) { - return fetch(baseUri+`&artist_id=${args.artist_id}`).then(r => r.json() as Promise>) - } else { - return fetch(baseUri).then(r => r.json() as Promise>) - } + const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`; + if (args.artist_id) { + return fetch(baseUri + `&artist_id=${args.artist_id}`).then( + (r) => r.json() as Promise> + ); + } else { + return fetch(baseUri).then( + (r) => r.json() as Promise> + ); + } } function getTopArtists(args: getItemsArgs): Promise> { - const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}` - return fetch(baseUri).then(r => r.json() as Promise>) + const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`; + return fetch(baseUri).then( + (r) => r.json() as Promise> + ); } function getActivity(args: getActivityArgs): Promise { - return fetch(`/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`).then(r => r.json() as Promise) + return fetch( + `/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}` + ).then((r) => r.json() as Promise); } function getStats(period: string): Promise { - return fetch(`/apis/web/v1/stats?period=${period}`).then(r => r.json() as Promise) + return fetch(`/apis/web/v1/stats?period=${period}`).then( + (r) => r.json() as Promise + ); } function search(q: string): Promise { - q = encodeURIComponent(q) - return fetch(`/apis/web/v1/search?q=${q}`).then(r => r.json() as Promise) + q = encodeURIComponent(q); + return fetch(`/apis/web/v1/search?q=${q}`).then( + (r) => r.json() as Promise + ); } function imageUrl(id: string, size: string) { - if (!id) { - id = 'default' - } - return `/images/${size}/${id}` + if (!id) { + id = "default"; + } + return `/images/${size}/${id}`; } function replaceImage(form: FormData): Promise { - return fetch(`/apis/web/v1/replace-image`, { - method: "POST", - body: form, - }) + return fetch(`/apis/web/v1/replace-image`, { + method: "POST", + body: form, + }); } function mergeTracks(from: number, to: number): Promise { - return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, { - method: "POST", - }) -} -function mergeAlbums(from: number, to: number, replaceImage: boolean): Promise { - return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, { - method: "POST", - }) + return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, { + method: "POST", + }); +} +function mergeAlbums( + from: number, + to: number, + replaceImage: boolean +): Promise { + return fetch( + `/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, + { + method: "POST", + } + ); +} +function mergeArtists( + from: number, + to: number, + replaceImage: boolean +): Promise { + return fetch( + `/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, + { + method: "POST", + } + ); +} +function login( + username: string, + password: string, + remember: boolean +): Promise { + const form = new URLSearchParams(); + form.append("username", username); + form.append("password", password); + form.append("remember_me", String(remember)); + return fetch(`/apis/web/v1/login`, { + method: "POST", + body: form, + }); } -function mergeArtists(from: number, to: number, replaceImage: boolean): Promise { - return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, { - method: "POST", - }) +function logout(): Promise { + return fetch(`/apis/web/v1/logout`, { + method: "POST", + }); } -function login(username: string, password: string, remember: boolean): Promise { - const form = new URLSearchParams - form.append('username', username) - form.append('password', password) - form.append('remember_me', String(remember)) - return fetch(`/apis/web/v1/login`, { - method: "POST", - body: form, - }) + +function getCfg(): Promise { + return fetch(`/apis/web/v1/config`).then((r) => r.json() as Promise); } -function logout(): Promise { - return fetch(`/apis/web/v1/logout`, { - method: "POST", - }) + +function submitListen(id: string, ts: Date): Promise { + const form = new URLSearchParams(); + form.append("track_id", id); + const ms = new Date(ts).getTime(); + const unix = Math.floor(ms / 1000); + form.append("unix", unix.toString()); + return fetch(`/apis/web/v1/listen`, { + method: "POST", + body: form, + }); } function getApiKeys(): Promise { - return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise) + return fetch(`/apis/web/v1/user/apikeys`).then( + (r) => r.json() as Promise + ); } const createApiKey = async (label: string): Promise => { - const form = new URLSearchParams - form.append('label', label) - const r = await fetch(`/apis/web/v1/user/apikeys`, { - method: "POST", - body: form, - }); - if (!r.ok) { - let errorMessage = `error: ${r.status}`; - try { - const errorData: ApiError = await r.json(); - if (errorData && typeof errorData.error === 'string') { - errorMessage = errorData.error; - } - } catch (e) { - console.error("unexpected api error:", e); - } - throw new Error(errorMessage); + const form = new URLSearchParams(); + form.append("label", label); + const r = await fetch(`/apis/web/v1/user/apikeys`, { + method: "POST", + body: form, + }); + if (!r.ok) { + let errorMessage = `error: ${r.status}`; + try { + const errorData: ApiError = await r.json(); + if (errorData && typeof errorData.error === "string") { + errorMessage = errorData.error; + } + } catch (e) { + console.error("unexpected api error:", e); } - const data: ApiKey = await r.json(); - return data; + throw new Error(errorMessage); + } + const data: ApiKey = await r.json(); + return data; }; function deleteApiKey(id: number): Promise { - return fetch(`/apis/web/v1/user/apikeys?id=${id}`, { - method: "DELETE" - }) + return fetch(`/apis/web/v1/user/apikeys?id=${id}`, { + method: "DELETE", + }); } function updateApiKeyLabel(id: number, label: string): Promise { - const form = new URLSearchParams - form.append('id', String(id)) - form.append('label', label) - return fetch(`/apis/web/v1/user/apikeys`, { - method: "PATCH", - body: form, - }) + const form = new URLSearchParams(); + form.append("id", String(id)); + form.append("label", label); + return fetch(`/apis/web/v1/user/apikeys`, { + method: "PATCH", + body: form, + }); } function deleteItem(itemType: string, id: number): Promise { - return fetch(`/apis/web/v1/${itemType}?id=${id}`, { - method: "DELETE" - }) + return fetch(`/apis/web/v1/${itemType}?id=${id}`, { + method: "DELETE", + }); } function updateUser(username: string, password: string) { - const form = new URLSearchParams - form.append('username', username) - form.append('password', password) - return fetch(`/apis/web/v1/user`, { - method: "PATCH", - body: form, - }) + const form = new URLSearchParams(); + form.append("username", username); + form.append("password", password); + return fetch(`/apis/web/v1/user`, { + method: "PATCH", + body: form, + }); } function getAliases(type: string, id: number): Promise { - return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(r => r.json() as Promise) -} -function createAlias(type: string, id: number, alias: string): Promise { - const form = new URLSearchParams - form.append(`${type}_id`, String(id)) - form.append('alias', alias) - return fetch(`/apis/web/v1/aliases`, { - method: 'POST', - body: form, - }) -} -function deleteAlias(type: string, id: number, alias: string): Promise { - const form = new URLSearchParams - form.append(`${type}_id`, String(id)) - form.append('alias', alias) - return fetch(`/apis/web/v1/aliases/delete`, { - method: "POST", - body: form, - }) -} -function setPrimaryAlias(type: string, id: number, alias: string): Promise { - const form = new URLSearchParams - form.append(`${type}_id`, String(id)) - form.append('alias', alias) - return fetch(`/apis/web/v1/aliases/primary`, { - method: "POST", - body: form, - }) + return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then( + (r) => r.json() as Promise + ); +} +function createAlias( + type: string, + id: number, + alias: string +): Promise { + const form = new URLSearchParams(); + form.append(`${type}_id`, String(id)); + form.append("alias", alias); + return fetch(`/apis/web/v1/aliases`, { + method: "POST", + body: form, + }); +} +function deleteAlias( + type: string, + id: number, + alias: string +): Promise { + const form = new URLSearchParams(); + form.append(`${type}_id`, String(id)); + form.append("alias", alias); + return fetch(`/apis/web/v1/aliases/delete`, { + method: "POST", + body: form, + }); +} +function setPrimaryAlias( + type: string, + id: number, + alias: string +): Promise { + const form = new URLSearchParams(); + form.append(`${type}_id`, String(id)); + form.append("alias", alias); + return fetch(`/apis/web/v1/aliases/primary`, { + method: "POST", + body: form, + }); } function getAlbum(id: number): Promise { - return fetch(`/apis/web/v1/album?id=${id}`).then(r => r.json() as Promise) + return fetch(`/apis/web/v1/album?id=${id}`).then( + (r) => r.json() as Promise + ); } function deleteListen(listen: Listen): Promise { - const ms = new Date(listen.time).getTime() - const unix= Math.floor(ms / 1000); - return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, { - method: "DELETE" - }) + const ms = new Date(listen.time).getTime(); + const unix = Math.floor(ms / 1000); + return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, { + method: "DELETE", + }); } -function getExport() { +function getExport() {} + +function getNowPlaying(): Promise { + return fetch("/apis/web/v1/now-playing").then((r) => r.json()); } export { - getLastListens, - getTopTracks, - getTopAlbums, - getTopArtists, - getActivity, - getStats, - search, - replaceImage, - mergeTracks, - mergeAlbums, - mergeArtists, - imageUrl, - login, - logout, - deleteItem, - updateUser, - getAliases, - createAlias, - deleteAlias, - setPrimaryAlias, - getApiKeys, - createApiKey, - deleteApiKey, - updateApiKeyLabel, - deleteListen, - getAlbum, - getExport, -} + getLastListens, + getTopTracks, + getTopAlbums, + getTopArtists, + getActivity, + getStats, + search, + replaceImage, + mergeTracks, + mergeAlbums, + mergeArtists, + imageUrl, + login, + logout, + getCfg, + deleteItem, + updateUser, + getAliases, + createAlias, + deleteAlias, + setPrimaryAlias, + getApiKeys, + createApiKey, + deleteApiKey, + updateApiKeyLabel, + deleteListen, + getAlbum, + getExport, + submitListen, + getNowPlaying, +}; type Track = { - id: number - title: string - artists: SimpleArtists[] - listen_count: number - image: string - album_id: number - musicbrainz_id: string - time_listened: number -} + id: number; + title: string; + artists: SimpleArtists[]; + listen_count: number; + image: string; + album_id: number; + musicbrainz_id: string; + time_listened: number; + first_listen: number; +}; type Artist = { - id: number - name: string - image: string, - aliases: string[] - listen_count: number - musicbrainz_id: string - time_listened: number - is_primary: boolean -} + id: number; + name: string; + image: string; + aliases: string[]; + listen_count: number; + musicbrainz_id: string; + time_listened: number; + first_listen: number; + is_primary: boolean; +}; type Album = { - id: number, - title: string - image: string - listen_count: number - is_various_artists: boolean - artists: SimpleArtists[] - musicbrainz_id: string - time_listened: number -} + id: number; + title: string; + image: string; + listen_count: number; + is_various_artists: boolean; + artists: SimpleArtists[]; + musicbrainz_id: string; + time_listened: number; + first_listen: number; +}; type Alias = { - id: number - alias: string - source: string - is_primary: boolean -} + id: number; + alias: string; + source: string; + is_primary: boolean; +}; type Listen = { - time: string, - track: Track, -} + time: string; + track: Track; +}; type PaginatedResponse = { - items: T[], - total_record_count: number, - has_next_page: boolean, - current_page: number, - items_per_page: number, -} + items: T[]; + total_record_count: number; + has_next_page: boolean; + current_page: number; + items_per_page: number; +}; type ListenActivityItem = { - start_time: Date, - listens: number -} + start_time: Date; + listens: number; +}; type SimpleArtists = { - name: string - id: number -} + name: string; + id: number; +}; type Stats = { - listen_count: number - track_count: number - album_count: number - artist_count: number - minutes_listened: number -} + listen_count: number; + track_count: number; + album_count: number; + artist_count: number; + minutes_listened: number; +}; type SearchResponse = { - albums: Album[] - artists: Artist[] - tracks: Track[] -} + albums: Album[]; + artists: Artist[]; + tracks: Track[]; +}; type User = { - id: number - username: string - role: 'user' | 'admin' -} + id: number; + username: string; + role: "user" | "admin"; +}; type ApiKey = { - id: number - key: string - label: string - created_at: Date -} + id: number; + key: string; + label: string; + created_at: Date; +}; type ApiError = { - error: string -} + error: string; +}; +type Config = { + default_theme: string; +}; +type NowPlaying = { + currently_playing: boolean; + track: Track; +}; export type { - getItemsArgs, - getActivityArgs, - Track, - Artist, - Album, - Listen, - SearchResponse, - PaginatedResponse, - ListenActivityItem, - User, - Alias, - ApiKey, - ApiError -} + getItemsArgs, + getActivityArgs, + Track, + Artist, + Album, + Listen, + SearchResponse, + PaginatedResponse, + ListenActivityItem, + User, + Alias, + ApiKey, + ApiError, + Config, + NowPlaying, +}; diff --git a/client/app/components/ActivityGrid.tsx b/client/app/components/ActivityGrid.tsx index 16953df..9628df6 100644 --- a/client/app/components/ActivityGrid.tsx +++ b/client/app/components/ActivityGrid.tsx @@ -1,200 +1,197 @@ -import { useQuery } from "@tanstack/react-query" -import { getActivity, type getActivityArgs, type ListenActivityItem } from "api/api" -import Popup from "./Popup" -import { useEffect, useState } from "react" -import { useTheme } from "~/hooks/useTheme" -import ActivityOptsSelector from "./ActivityOptsSelector" - -function getPrimaryColor(): string { - const value = getComputedStyle(document.documentElement) - .getPropertyValue('--color-primary') - .trim(); - - const rgbMatch = value.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/); - if (rgbMatch) { - const [, r, g, b] = rgbMatch.map(Number); - return ( - '#' + - [r, g, b] - .map((n) => n.toString(16).padStart(2, '0')) - .join('') - ); - } - - return value; +import { useQuery } from "@tanstack/react-query"; +import { + getActivity, + type getActivityArgs, + type ListenActivityItem, +} from "api/api"; +import Popup from "./Popup"; +import { useState } from "react"; +import { useTheme } from "~/hooks/useTheme"; +import ActivityOptsSelector from "./ActivityOptsSelector"; +import type { Theme } from "~/styles/themes.css"; + +function getPrimaryColor(theme: Theme): string { + const value = theme.primary; + const rgbMatch = value.match( + /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/ + ); + if (rgbMatch) { + const [, r, g, b] = rgbMatch.map(Number); + return "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0")).join(""); + } + + return value; } - interface Props { - step?: string - range?: number - month?: number - year?: number - artistId?: number - albumId?: number - trackId?: number - configurable?: boolean - autoAdjust?: boolean + step?: string; + range?: number; + month?: number; + year?: number; + artistId?: number; + albumId?: number; + trackId?: number; + configurable?: boolean; + autoAdjust?: boolean; } export default function ActivityGrid({ - step = 'day', - range = 182, - month = 0, - year = 0, - artistId = 0, - albumId = 0, - trackId = 0, - configurable = false, - }: Props) { - - const [color, setColor] = useState(getPrimaryColor()) - const [stepState, setStep] = useState(step) - const [rangeState, setRange] = useState(range) - - const { isPending, isError, data, error } = useQuery({ - queryKey: [ - 'listen-activity', - { - step: stepState, - range: rangeState, - month: month, - year: year, - artist_id: artistId, - album_id: albumId, - track_id: trackId - }, - ], - queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs), - }); - - - const { theme } = useTheme(); - useEffect(() => { - const raf = requestAnimationFrame(() => { - const color = getPrimaryColor() - setColor(color); - }); - - return () => cancelAnimationFrame(raf); - }, [theme]); - - if (isPending) { - return ( -
-

Activity

-

Loading...

-
- ) + step = "day", + range = 182, + month = 0, + year = 0, + artistId = 0, + albumId = 0, + trackId = 0, + configurable = false, +}: Props) { + const [stepState, setStep] = useState(step); + const [rangeState, setRange] = useState(range); + + const { isPending, isError, data, error } = useQuery({ + queryKey: [ + "listen-activity", + { + step: stepState, + range: rangeState, + month: month, + year: year, + artist_id: artistId, + album_id: albumId, + track_id: trackId, + }, + ], + queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs), + }); + + const { theme, themeName } = useTheme(); + const color = getPrimaryColor(theme); + + if (isPending) { + return ( +
+

Activity

+

Loading...

+
+ ); + } + if (isError) return

Error:{error.message}

; + + // from https://css-tricks.com/snippets/javascript/lighten-darken-color/ + function LightenDarkenColor(hex: string, lum: number) { + // validate hex string + hex = String(hex).replace(/[^0-9a-f]/gi, ""); + if (hex.length < 6) { + hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; } - if (isError) return

Error:{error.message}

- - // from https://css-tricks.com/snippets/javascript/lighten-darken-color/ - function LightenDarkenColor(hex: string, lum: number) { - // validate hex string - hex = String(hex).replace(/[^0-9a-f]/gi, ''); - if (hex.length < 6) { - hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2]; - } - lum = lum || 0; - - // convert to decimal and change luminosity - var rgb = "#", c, i; - for (i = 0; i < 3; i++) { - c = parseInt(hex.substring(i*2,(i*2)+2), 16); - c = Math.round(Math.min(Math.max(0, c + (c * lum)), 255)).toString(16); - rgb += ("00"+c).substring(c.length); - } - - return rgb; + lum = lum || 0; + + // convert to decimal and change luminosity + var rgb = "#", + c, + i; + for (i = 0; i < 3; i++) { + c = parseInt(hex.substring(i * 2, i * 2 + 2), 16); + c = Math.round(Math.min(Math.max(0, c + c * lum), 255)).toString(16); + rgb += ("00" + c).substring(c.length); } - const getDarkenAmount = (v: number, t: number): number => { - - // really ugly way to just check if this is for all items and not a specific item. - // is it jsut better to just pass the target in as a var? probably. - const adjustment = artistId == albumId && albumId == trackId && trackId == 0 ? 10 : 1 - - // automatically adjust the target value based on step - // the smartest way to do this would be to have the api return the - // highest value in the range. too bad im not smart - switch (stepState) { - case 'day': - t = 10 * adjustment - break; - case 'week': - t = 20 * adjustment - break; - case 'month': - t = 50 * adjustment - break; - case 'year': - t = 100 * adjustment - break; - } - - v = Math.min(v, t) - if (theme === "pearl") { - // special case for the only light theme lol - // could be generalized by pragmatically comparing the - // lightness of the bg vs the primary but eh - return ((t-v) / t) - } else { - return ((v-t) / t) * .8 - } + return rgb; + } + + const getDarkenAmount = (v: number, t: number): number => { + // really ugly way to just check if this is for all items and not a specific item. + // is it jsut better to just pass the target in as a var? probably. + const adjustment = + artistId == albumId && albumId == trackId && trackId == 0 ? 10 : 1; + + // automatically adjust the target value based on step + // the smartest way to do this would be to have the api return the + // highest value in the range. too bad im not smart + switch (stepState) { + case "day": + t = 10 * adjustment; + break; + case "week": + t = 20 * adjustment; + break; + case "month": + t = 50 * adjustment; + break; + case "year": + t = 100 * adjustment; + break; } - const CHUNK_SIZE = 26 * 7; - const chunks = []; - - for (let i = 0; i < data.length; i += CHUNK_SIZE) { - chunks.push(data.slice(i, i + CHUNK_SIZE)); + v = Math.min(v, t); + if (themeName === "pearl") { + // special case for the only light theme lol + // could be generalized by pragmatically comparing the + // lightness of the bg vs the primary but eh + return (t - v) / t; + } else { + return ((v - t) / t) * 0.8; } - - return ( -
-

Activity

- {configurable ? ( - - ) : null} - - {chunks.map((chunk, index) => ( + }; + + const CHUNK_SIZE = 26 * 7; + const chunks = []; + + for (let i = 0; i < data.length; i += CHUNK_SIZE) { + chunks.push(data.slice(i, i + CHUNK_SIZE)); + } + + return ( +
+

Activity

+ {configurable ? ( + + ) : null} + + {chunks.map((chunk, index) => ( +
+ {chunk.map((item) => ( +
+
- {chunk.map((item) => ( -
- -
0 - ? LightenDarkenColor(color, getDarkenAmount(item.listens, 100)) - : 'var(--color-bg-secondary)', - }} - className={`w-[10px] sm:w-[12px] h-[10px] sm:h-[12px] rounded-[2px] md:rounded-[3px] ${ - item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)' - }`} - >
-
-
- ))} -
- ))} + style={{ + display: "inline-block", + background: + item.listens > 0 + ? LightenDarkenColor( + color, + getDarkenAmount(item.listens, 100) + ) + : "var(--color-bg-secondary)", + }} + className={`w-[10px] sm:w-[12px] h-[10px] sm:h-[12px] rounded-[2px] md:rounded-[3px] ${ + item.listens > 0 + ? "" + : "border-[0.5px] border-(--color-bg-tertiary)" + }`} + >
+ +
+ ))}
- ); -} + ))} +
+ ); +} diff --git a/client/app/components/ActivityOptsSelector.tsx b/client/app/components/ActivityOptsSelector.tsx index 803cb0d..26d1357 100644 --- a/client/app/components/ActivityOptsSelector.tsx +++ b/client/app/components/ActivityOptsSelector.tsx @@ -43,7 +43,8 @@ export default function ActivityOptsSelector({ useEffect(() => { if (!disableCache) { - const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '35'); + // TODO: the '182' here overwrites the default range as configured in the ActivityGrid. This is bad. Only one of these should determine the default. + const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '182'); if (cachedRange) rangeSetter(cachedRange); const cachedStep = localStorage.getItem('activity_step_' + window.location.pathname.split('/')[1]); if (cachedStep) stepSetter(cachedStep); diff --git a/client/app/components/LastPlays.tsx b/client/app/components/LastPlays.tsx index 9463245..c6687b4 100644 --- a/client/app/components/LastPlays.tsx +++ b/client/app/components/LastPlays.tsx @@ -1,109 +1,150 @@ -import { useState } from "react" -import { useQuery } from "@tanstack/react-query" -import { timeSince } from "~/utils/utils" -import ArtistLinks from "./ArtistLinks" -import { deleteListen, getLastListens, type getItemsArgs, type Listen } from "api/api" -import { Link } from "react-router" -import { useAppContext } from "~/providers/AppProvider" +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { timeSince } from "~/utils/utils"; +import ArtistLinks from "./ArtistLinks"; +import { + deleteListen, + getLastListens, + getNowPlaying, + type getItemsArgs, + type Listen, + type Track, +} from "api/api"; +import { Link } from "react-router"; +import { useAppContext } from "~/providers/AppProvider"; interface Props { - limit: number - artistId?: Number - albumId?: Number - trackId?: number - hideArtists?: boolean + limit: number; + artistId?: Number; + albumId?: Number; + trackId?: number; + hideArtists?: boolean; + showNowPlaying?: boolean; } export default function LastPlays(props: Props) { - const { user } = useAppContext() - const { isPending, isError, data, error } = useQuery({ - queryKey: ['last-listens', { - limit: props.limit, - period: 'all_time', - artist_id: props.artistId, - album_id: props.albumId, - track_id: props.trackId - }], - queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs), - }) + const { user } = useAppContext(); + const { isPending, isError, data, error } = useQuery({ + queryKey: [ + "last-listens", + { + limit: props.limit, + period: "all_time", + artist_id: props.artistId, + album_id: props.albumId, + track_id: props.trackId, + }, + ], + queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs), + }); + const { data: npData } = useQuery({ + queryKey: ["now-playing"], + queryFn: () => getNowPlaying(), + }); - const [items, setItems] = useState(null) + const [items, setItems] = useState(null); - const handleDelete = async (listen: Listen) => { - if (!data) return - try { - const res = await deleteListen(listen) - if (res.ok || (res.status >= 200 && res.status < 300)) { - setItems((prev) => (prev ?? data.items).filter((i) => i.time !== listen.time)) - } else { - console.error("Failed to delete listen:", res.status) - } - } catch (err) { - console.error("Error deleting listen:", err) - } + const handleDelete = async (listen: Listen) => { + if (!data) return; + try { + const res = await deleteListen(listen); + if (res.ok || (res.status >= 200 && res.status < 300)) { + setItems((prev) => + (prev ?? data.items).filter((i) => i.time !== listen.time) + ); + } else { + console.error("Failed to delete listen:", res.status); + } + } catch (err) { + console.error("Error deleting listen:", err); } + }; - if (isPending) { - return ( -
-

Last Played

-

Loading...

-
- ) - } - if (isError) { - return

Error: {error.message}

- } + if (isPending) { + return ( +
+

Last Played

+

Loading...

+
+ ); + } + if (isError) { + return

Error: {error.message}

; + } - const listens = items ?? data.items + const listens = items ?? data.items; - let params = '' - params += props.artistId ? `&artist_id=${props.artistId}` : '' - params += props.albumId ? `&album_id=${props.albumId}` : '' - params += props.trackId ? `&track_id=${props.trackId}` : '' + let params = ""; + params += props.artistId ? `&artist_id=${props.artistId}` : ""; + params += props.albumId ? `&album_id=${props.albumId}` : ""; + params += props.trackId ? `&track_id=${props.trackId}` : ""; - return ( -
-

- Last Played -

- - - {listens.map((item) => ( - - - - - - ))} - -
- - - {timeSince(new Date(item.time))} - - {props.hideArtists ? null : ( - <> - –{' '} - - )} - - {item.track.title} - -
-
- ) + return ( +
+

+ Last Played +

+ + + {props.showNowPlaying && npData && npData.currently_playing && ( + + + + + + )} + {listens.map((item) => ( + + + + + + ))} + +
+ Now Playing + + {props.hideArtists ? null : ( + <> + –{" "} + + )} + + {npData.track.title} + +
+ + + {timeSince(new Date(item.time))} + + {props.hideArtists ? null : ( + <> + –{" "} + + )} + + {item.track.title} + +
+
+ ); } diff --git a/client/app/components/modals/AddListenModal.tsx b/client/app/components/modals/AddListenModal.tsx new file mode 100644 index 0000000..2776d3e --- /dev/null +++ b/client/app/components/modals/AddListenModal.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import { Modal } from "./Modal"; +import { AsyncButton } from "../AsyncButton"; +import { submitListen } from "api/api"; +import { useNavigate } from "react-router"; + +interface Props { + open: boolean + setOpen: Function + trackid: number +} + +export default function AddListenModal({ open, setOpen, trackid }: Props) { + const [ts, setTS] = useState(new Date); + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const navigate = useNavigate() + + const close = () => { + setOpen(false) + } + + const submit = () => { + setLoading(true) + submitListen(trackid.toString(), ts) + .then(r => { + if(r.ok) { + setLoading(false) + navigate(0) + } else { + r.json().then(r => setError(r.error)) + setLoading(false) + } + }) + } + + const formatForDatetimeLocal = (d: Date) => { + const pad = (n: number) => n.toString().padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + }; + + return ( + +

Add Listen

+
+ setTS(new Date(e.target.value))} + /> + Submit +

{error}

+
+
+ ) +} diff --git a/client/app/components/themeSwitcher/ThemeOption.tsx b/client/app/components/themeSwitcher/ThemeOption.tsx index 224fcce..51b9acf 100644 --- a/client/app/components/themeSwitcher/ThemeOption.tsx +++ b/client/app/components/themeSwitcher/ThemeOption.tsx @@ -1,19 +1,20 @@ -import type { Theme } from "~/providers/ThemeProvider"; +import type { Theme } from "~/styles/themes.css"; interface Props { theme: Theme + themeName: string setTheme: Function } -export default function ThemeOption({ theme, setTheme }: Props) { +export default function ThemeOption({ theme, themeName, setTheme }: Props) { const capitalizeFirstLetter = (s: string) => { return s.charAt(0).toUpperCase() + s.slice(1); } return ( -
setTheme(theme.name)} className="rounded-md p-3 sm:p-5 hover:cursor-pointer flex gap-4 items-center border-2" style={{background: theme.bg, color: theme.fg, borderColor: theme.bgSecondary}}> -
{capitalizeFirstLetter(theme.name)}
+
setTheme(themeName)} className="rounded-md p-3 sm:p-5 hover:cursor-pointer flex gap-4 items-center border-2" style={{background: theme.bg, color: theme.fg, borderColor: theme.bgSecondary}}> +
{capitalizeFirstLetter(themeName)}
diff --git a/client/app/components/themeSwitcher/ThemeSwitcher.tsx b/client/app/components/themeSwitcher/ThemeSwitcher.tsx index 14eda1e..25670b2 100644 --- a/client/app/components/themeSwitcher/ThemeSwitcher.tsx +++ b/client/app/components/themeSwitcher/ThemeSwitcher.tsx @@ -1,69 +1,78 @@ -// ThemeSwitcher.tsx -import { useEffect, useState } from 'react'; -import { useTheme } from '../../hooks/useTheme'; -import themes from '~/styles/themes.css'; -import ThemeOption from './ThemeOption'; -import { AsyncButton } from '../AsyncButton'; +import { useState } from "react"; +import { useTheme } from "../../hooks/useTheme"; +import themes from "~/styles/themes.css"; +import ThemeOption from "./ThemeOption"; +import { AsyncButton } from "../AsyncButton"; export function ThemeSwitcher() { - const { theme, setTheme } = useTheme(); - const initialTheme = { - bg: "#1e1816", - bgSecondary: "#2f2623", - bgTertiary: "#453733", - fg: "#f8f3ec", - fgSecondary: "#d6ccc2", - fgTertiary: "#b4a89c", - primary: "#f5a97f", - primaryDim: "#d88b65", - accent: "#f9db6d", - accentDim: "#d9bc55", - error: "#e26c6a", - warning: "#f5b851", - success: "#8fc48f", - info: "#87b8dd", - } - - const { setCustomTheme, getCustomTheme } = useTheme() - const [custom, setCustom] = useState(JSON.stringify(getCustomTheme() ?? initialTheme, null, " ")) - - const handleCustomTheme = () => { - console.log(custom) - try { - const theme = JSON.parse(custom) - theme.name = "custom" - setCustomTheme(theme) - delete theme.name - setCustom(JSON.stringify(theme, null, " ")) - console.log(theme) - } catch(err) { - console.log(err) - } - } + const { setTheme } = useTheme(); + const initialTheme = { + bg: "#1e1816", + bgSecondary: "#2f2623", + bgTertiary: "#453733", + fg: "#f8f3ec", + fgSecondary: "#d6ccc2", + fgTertiary: "#b4a89c", + primary: "#f5a97f", + primaryDim: "#d88b65", + accent: "#f9db6d", + accentDim: "#d9bc55", + error: "#e26c6a", + warning: "#f5b851", + success: "#8fc48f", + info: "#87b8dd", + }; - useEffect(() => { - if (theme) { - setTheme(theme) - } - }, [theme]); + const { setCustomTheme, getCustomTheme, resetTheme } = useTheme(); + const [custom, setCustom] = useState( + JSON.stringify(getCustomTheme() ?? initialTheme, null, " ") + ); - return ( -
-
-

Select Theme

-
- {themes.map((t) => ( - - ))} -
-
-
-

Use Custom Theme

-
-