mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-07 21:48:18 -08:00
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 <jesper@posteo.de> * 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 <jesper@posteo.de> * fix: rebase errors --------- Co-authored-by: pet <128837728+againstpetra@users.noreply.github.com> Co-authored-by: mlandry <mike.landry@gmail.com> Co-authored-by: m0d3rnX <jesper@posteo.de>
This commit is contained in:
parent
bf0ec68cfe
commit
36f984a1a2
56 changed files with 1887 additions and 906 deletions
89
.github/workflows/docker.yml
vendored
89
.github/workflows/docker.yml
vendored
|
|
@ -13,6 +13,8 @@ on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
|
@ -21,42 +23,37 @@ jobs:
|
||||||
name: Go Test
|
name: Go Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Install libvips
|
- name: Install libvips
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libvips-dev
|
sudo apt-get install -y libvips-dev
|
||||||
|
|
||||||
- name: Verify libvips install
|
- name: Verify libvips install
|
||||||
run: vips --version
|
run: vips --version
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: go build -v ./...
|
run: go build -v ./...
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
uses: robherley/go-test-action@v0
|
uses: robherley/go-test-action@v0
|
||||||
|
|
||||||
push_to_registry:
|
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
|
needs: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
contents: read
|
|
||||||
attestations: write
|
|
||||||
id-token: write
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
@ -64,19 +61,12 @@ jobs:
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
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
|
- name: Extract tag version
|
||||||
id: extract_version
|
|
||||||
run: echo "KOITO_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
run: echo "KOITO_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push release image
|
||||||
id: push
|
id: push
|
||||||
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
|
@ -88,9 +78,32 @@ jobs:
|
||||||
KOITO_VERSION=${{ env.KOITO_VERSION }}
|
KOITO_VERSION=${{ env.KOITO_VERSION }}
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
||||||
- name: Generate artifact attestation
|
push_dev:
|
||||||
uses: actions/attest-build-provenance@v2
|
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:
|
with:
|
||||||
subject-name: index.docker.io/gabehf/koito
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
subject-digest: ${{ steps.push.outputs.digest }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
push-to-registry: true
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and push dev image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
gabehf/koito:dev
|
||||||
|
gabehf/koito:dev-${{ github.sha }}
|
||||||
|
build-args: |
|
||||||
|
KOITO_VERSION=dev
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
|
||||||
|
|
@ -1,327 +1,419 @@
|
||||||
interface getItemsArgs {
|
interface getItemsArgs {
|
||||||
limit: number,
|
limit: number;
|
||||||
period: string,
|
period: string;
|
||||||
page: number,
|
page: number;
|
||||||
artist_id?: number,
|
artist_id?: number;
|
||||||
album_id?: number,
|
album_id?: number;
|
||||||
track_id?: number
|
track_id?: number;
|
||||||
}
|
}
|
||||||
interface getActivityArgs {
|
interface getActivityArgs {
|
||||||
step: string
|
step: string;
|
||||||
range: number
|
range: number;
|
||||||
month: number
|
month: number;
|
||||||
year: number
|
year: number;
|
||||||
artist_id: number
|
artist_id: number;
|
||||||
album_id: number
|
album_id: number;
|
||||||
track_id: number
|
track_id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLastListens(args: getItemsArgs): Promise<PaginatedResponse<Listen>> {
|
function getLastListens(
|
||||||
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<PaginatedResponse<Listen>>)
|
args: getItemsArgs
|
||||||
|
): Promise<PaginatedResponse<Listen>> {
|
||||||
|
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<PaginatedResponse<Listen>>);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTopTracks(args: getItemsArgs): Promise<PaginatedResponse<Track>> {
|
function getTopTracks(args: getItemsArgs): Promise<PaginatedResponse<Track>> {
|
||||||
if (args.artist_id) {
|
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<PaginatedResponse<Track>>)
|
return fetch(
|
||||||
} else if (args.album_id) {
|
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}`
|
||||||
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<PaginatedResponse<Track>>)
|
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
|
||||||
} else {
|
} else if (args.album_id) {
|
||||||
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
|
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<PaginatedResponse<Track>>);
|
||||||
|
} else {
|
||||||
|
return fetch(
|
||||||
|
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`
|
||||||
|
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTopAlbums(args: getItemsArgs): Promise<PaginatedResponse<Album>> {
|
function getTopAlbums(args: getItemsArgs): Promise<PaginatedResponse<Album>> {
|
||||||
const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`
|
const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`;
|
||||||
if (args.artist_id) {
|
if (args.artist_id) {
|
||||||
return fetch(baseUri+`&artist_id=${args.artist_id}`).then(r => r.json() as Promise<PaginatedResponse<Album>>)
|
return fetch(baseUri + `&artist_id=${args.artist_id}`).then(
|
||||||
} else {
|
(r) => r.json() as Promise<PaginatedResponse<Album>>
|
||||||
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Album>>)
|
);
|
||||||
}
|
} else {
|
||||||
|
return fetch(baseUri).then(
|
||||||
|
(r) => r.json() as Promise<PaginatedResponse<Album>>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> {
|
function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> {
|
||||||
const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`
|
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<PaginatedResponse<Artist>>)
|
return fetch(baseUri).then(
|
||||||
|
(r) => r.json() as Promise<PaginatedResponse<Artist>>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> {
|
function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> {
|
||||||
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<ListenActivityItem[]>)
|
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<ListenActivityItem[]>);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStats(period: string): Promise<Stats> {
|
function getStats(period: string): Promise<Stats> {
|
||||||
return fetch(`/apis/web/v1/stats?period=${period}`).then(r => r.json() as Promise<Stats>)
|
return fetch(`/apis/web/v1/stats?period=${period}`).then(
|
||||||
|
(r) => r.json() as Promise<Stats>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function search(q: string): Promise<SearchResponse> {
|
function search(q: string): Promise<SearchResponse> {
|
||||||
q = encodeURIComponent(q)
|
q = encodeURIComponent(q);
|
||||||
return fetch(`/apis/web/v1/search?q=${q}`).then(r => r.json() as Promise<SearchResponse>)
|
return fetch(`/apis/web/v1/search?q=${q}`).then(
|
||||||
|
(r) => r.json() as Promise<SearchResponse>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageUrl(id: string, size: string) {
|
function imageUrl(id: string, size: string) {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
id = 'default'
|
id = "default";
|
||||||
}
|
}
|
||||||
return `/images/${size}/${id}`
|
return `/images/${size}/${id}`;
|
||||||
}
|
}
|
||||||
function replaceImage(form: FormData): Promise<Response> {
|
function replaceImage(form: FormData): Promise<Response> {
|
||||||
return fetch(`/apis/web/v1/replace-image`, {
|
return fetch(`/apis/web/v1/replace-image`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: form,
|
body: form,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeTracks(from: number, to: number): Promise<Response> {
|
function mergeTracks(from: number, to: number): Promise<Response> {
|
||||||
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
|
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
function mergeAlbums(from: number, to: number, replaceImage: boolean): Promise<Response> {
|
function mergeAlbums(
|
||||||
return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, {
|
from: number,
|
||||||
method: "POST",
|
to: number,
|
||||||
})
|
replaceImage: boolean
|
||||||
|
): Promise<Response> {
|
||||||
|
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<Response> {
|
function mergeArtists(
|
||||||
return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, {
|
from: number,
|
||||||
method: "POST",
|
to: number,
|
||||||
})
|
replaceImage: boolean
|
||||||
|
): Promise<Response> {
|
||||||
|
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<Response> {
|
function login(
|
||||||
const form = new URLSearchParams
|
username: string,
|
||||||
form.append('username', username)
|
password: string,
|
||||||
form.append('password', password)
|
remember: boolean
|
||||||
form.append('remember_me', String(remember))
|
): Promise<Response> {
|
||||||
return fetch(`/apis/web/v1/login`, {
|
const form = new URLSearchParams();
|
||||||
method: "POST",
|
form.append("username", username);
|
||||||
body: form,
|
form.append("password", password);
|
||||||
})
|
form.append("remember_me", String(remember));
|
||||||
|
return fetch(`/apis/web/v1/login`, {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function logout(): Promise<Response> {
|
function logout(): Promise<Response> {
|
||||||
return fetch(`/apis/web/v1/logout`, {
|
return fetch(`/apis/web/v1/logout`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
})
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCfg(): Promise<Config> {
|
||||||
|
return fetch(`/apis/web/v1/config`).then((r) => r.json() as Promise<Config>);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitListen(id: string, ts: Date): Promise<Response> {
|
||||||
|
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<ApiKey[]> {
|
function getApiKeys(): Promise<ApiKey[]> {
|
||||||
return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise<ApiKey[]>)
|
return fetch(`/apis/web/v1/user/apikeys`).then(
|
||||||
|
(r) => r.json() as Promise<ApiKey[]>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const createApiKey = async (label: string): Promise<ApiKey> => {
|
const createApiKey = async (label: string): Promise<ApiKey> => {
|
||||||
const form = new URLSearchParams
|
const form = new URLSearchParams();
|
||||||
form.append('label', label)
|
form.append("label", label);
|
||||||
const r = await fetch(`/apis/web/v1/user/apikeys`, {
|
const r = await fetch(`/apis/web/v1/user/apikeys`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: form,
|
body: form,
|
||||||
});
|
});
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
let errorMessage = `error: ${r.status}`;
|
let errorMessage = `error: ${r.status}`;
|
||||||
try {
|
try {
|
||||||
const errorData: ApiError = await r.json();
|
const errorData: ApiError = await r.json();
|
||||||
if (errorData && typeof errorData.error === 'string') {
|
if (errorData && typeof errorData.error === "string") {
|
||||||
errorMessage = errorData.error;
|
errorMessage = errorData.error;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("unexpected api error:", e);
|
console.error("unexpected api error:", e);
|
||||||
}
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
}
|
||||||
const data: ApiKey = await r.json();
|
throw new Error(errorMessage);
|
||||||
return data;
|
}
|
||||||
|
const data: ApiKey = await r.json();
|
||||||
|
return data;
|
||||||
};
|
};
|
||||||
function deleteApiKey(id: number): Promise<Response> {
|
function deleteApiKey(id: number): Promise<Response> {
|
||||||
return fetch(`/apis/web/v1/user/apikeys?id=${id}`, {
|
return fetch(`/apis/web/v1/user/apikeys?id=${id}`, {
|
||||||
method: "DELETE"
|
method: "DELETE",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
function updateApiKeyLabel(id: number, label: string): Promise<Response> {
|
function updateApiKeyLabel(id: number, label: string): Promise<Response> {
|
||||||
const form = new URLSearchParams
|
const form = new URLSearchParams();
|
||||||
form.append('id', String(id))
|
form.append("id", String(id));
|
||||||
form.append('label', label)
|
form.append("label", label);
|
||||||
return fetch(`/apis/web/v1/user/apikeys`, {
|
return fetch(`/apis/web/v1/user/apikeys`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: form,
|
body: form,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteItem(itemType: string, id: number): Promise<Response> {
|
function deleteItem(itemType: string, id: number): Promise<Response> {
|
||||||
return fetch(`/apis/web/v1/${itemType}?id=${id}`, {
|
return fetch(`/apis/web/v1/${itemType}?id=${id}`, {
|
||||||
method: "DELETE"
|
method: "DELETE",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
function updateUser(username: string, password: string) {
|
function updateUser(username: string, password: string) {
|
||||||
const form = new URLSearchParams
|
const form = new URLSearchParams();
|
||||||
form.append('username', username)
|
form.append("username", username);
|
||||||
form.append('password', password)
|
form.append("password", password);
|
||||||
return fetch(`/apis/web/v1/user`, {
|
return fetch(`/apis/web/v1/user`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: form,
|
body: form,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
function getAliases(type: string, id: number): Promise<Alias[]> {
|
function getAliases(type: string, id: number): Promise<Alias[]> {
|
||||||
return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(r => r.json() as Promise<Alias[]>)
|
return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(
|
||||||
|
(r) => r.json() as Promise<Alias[]>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
function createAlias(type: string, id: number, alias: string): Promise<Response> {
|
function createAlias(
|
||||||
const form = new URLSearchParams
|
type: string,
|
||||||
form.append(`${type}_id`, String(id))
|
id: number,
|
||||||
form.append('alias', alias)
|
alias: string
|
||||||
return fetch(`/apis/web/v1/aliases`, {
|
): Promise<Response> {
|
||||||
method: 'POST',
|
const form = new URLSearchParams();
|
||||||
body: form,
|
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<Response> {
|
function deleteAlias(
|
||||||
const form = new URLSearchParams
|
type: string,
|
||||||
form.append(`${type}_id`, String(id))
|
id: number,
|
||||||
form.append('alias', alias)
|
alias: string
|
||||||
return fetch(`/apis/web/v1/aliases/delete`, {
|
): Promise<Response> {
|
||||||
method: "POST",
|
const form = new URLSearchParams();
|
||||||
body: form,
|
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<Response> {
|
function setPrimaryAlias(
|
||||||
const form = new URLSearchParams
|
type: string,
|
||||||
form.append(`${type}_id`, String(id))
|
id: number,
|
||||||
form.append('alias', alias)
|
alias: string
|
||||||
return fetch(`/apis/web/v1/aliases/primary`, {
|
): Promise<Response> {
|
||||||
method: "POST",
|
const form = new URLSearchParams();
|
||||||
body: form,
|
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<Album> {
|
function getAlbum(id: number): Promise<Album> {
|
||||||
return fetch(`/apis/web/v1/album?id=${id}`).then(r => r.json() as Promise<Album>)
|
return fetch(`/apis/web/v1/album?id=${id}`).then(
|
||||||
|
(r) => r.json() as Promise<Album>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteListen(listen: Listen): Promise<Response> {
|
function deleteListen(listen: Listen): Promise<Response> {
|
||||||
const ms = new Date(listen.time).getTime()
|
const ms = new Date(listen.time).getTime();
|
||||||
const unix= Math.floor(ms / 1000);
|
const unix = Math.floor(ms / 1000);
|
||||||
return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, {
|
return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, {
|
||||||
method: "DELETE"
|
method: "DELETE",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
function getExport() {
|
function getExport() {}
|
||||||
|
|
||||||
|
function getNowPlaying(): Promise<NowPlaying> {
|
||||||
|
return fetch("/apis/web/v1/now-playing").then((r) => r.json());
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getLastListens,
|
getLastListens,
|
||||||
getTopTracks,
|
getTopTracks,
|
||||||
getTopAlbums,
|
getTopAlbums,
|
||||||
getTopArtists,
|
getTopArtists,
|
||||||
getActivity,
|
getActivity,
|
||||||
getStats,
|
getStats,
|
||||||
search,
|
search,
|
||||||
replaceImage,
|
replaceImage,
|
||||||
mergeTracks,
|
mergeTracks,
|
||||||
mergeAlbums,
|
mergeAlbums,
|
||||||
mergeArtists,
|
mergeArtists,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
deleteItem,
|
getCfg,
|
||||||
updateUser,
|
deleteItem,
|
||||||
getAliases,
|
updateUser,
|
||||||
createAlias,
|
getAliases,
|
||||||
deleteAlias,
|
createAlias,
|
||||||
setPrimaryAlias,
|
deleteAlias,
|
||||||
getApiKeys,
|
setPrimaryAlias,
|
||||||
createApiKey,
|
getApiKeys,
|
||||||
deleteApiKey,
|
createApiKey,
|
||||||
updateApiKeyLabel,
|
deleteApiKey,
|
||||||
deleteListen,
|
updateApiKeyLabel,
|
||||||
getAlbum,
|
deleteListen,
|
||||||
getExport,
|
getAlbum,
|
||||||
}
|
getExport,
|
||||||
|
submitListen,
|
||||||
|
getNowPlaying,
|
||||||
|
};
|
||||||
type Track = {
|
type Track = {
|
||||||
id: number
|
id: number;
|
||||||
title: string
|
title: string;
|
||||||
artists: SimpleArtists[]
|
artists: SimpleArtists[];
|
||||||
listen_count: number
|
listen_count: number;
|
||||||
image: string
|
image: string;
|
||||||
album_id: number
|
album_id: number;
|
||||||
musicbrainz_id: string
|
musicbrainz_id: string;
|
||||||
time_listened: number
|
time_listened: number;
|
||||||
}
|
first_listen: number;
|
||||||
|
};
|
||||||
type Artist = {
|
type Artist = {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
image: string,
|
image: string;
|
||||||
aliases: string[]
|
aliases: string[];
|
||||||
listen_count: number
|
listen_count: number;
|
||||||
musicbrainz_id: string
|
musicbrainz_id: string;
|
||||||
time_listened: number
|
time_listened: number;
|
||||||
is_primary: boolean
|
first_listen: number;
|
||||||
}
|
is_primary: boolean;
|
||||||
|
};
|
||||||
type Album = {
|
type Album = {
|
||||||
id: number,
|
id: number;
|
||||||
title: string
|
title: string;
|
||||||
image: string
|
image: string;
|
||||||
listen_count: number
|
listen_count: number;
|
||||||
is_various_artists: boolean
|
is_various_artists: boolean;
|
||||||
artists: SimpleArtists[]
|
artists: SimpleArtists[];
|
||||||
musicbrainz_id: string
|
musicbrainz_id: string;
|
||||||
time_listened: number
|
time_listened: number;
|
||||||
}
|
first_listen: number;
|
||||||
|
};
|
||||||
type Alias = {
|
type Alias = {
|
||||||
id: number
|
id: number;
|
||||||
alias: string
|
alias: string;
|
||||||
source: string
|
source: string;
|
||||||
is_primary: boolean
|
is_primary: boolean;
|
||||||
}
|
};
|
||||||
type Listen = {
|
type Listen = {
|
||||||
time: string,
|
time: string;
|
||||||
track: Track,
|
track: Track;
|
||||||
}
|
};
|
||||||
type PaginatedResponse<T> = {
|
type PaginatedResponse<T> = {
|
||||||
items: T[],
|
items: T[];
|
||||||
total_record_count: number,
|
total_record_count: number;
|
||||||
has_next_page: boolean,
|
has_next_page: boolean;
|
||||||
current_page: number,
|
current_page: number;
|
||||||
items_per_page: number,
|
items_per_page: number;
|
||||||
}
|
};
|
||||||
type ListenActivityItem = {
|
type ListenActivityItem = {
|
||||||
start_time: Date,
|
start_time: Date;
|
||||||
listens: number
|
listens: number;
|
||||||
}
|
};
|
||||||
type SimpleArtists = {
|
type SimpleArtists = {
|
||||||
name: string
|
name: string;
|
||||||
id: number
|
id: number;
|
||||||
}
|
};
|
||||||
type Stats = {
|
type Stats = {
|
||||||
listen_count: number
|
listen_count: number;
|
||||||
track_count: number
|
track_count: number;
|
||||||
album_count: number
|
album_count: number;
|
||||||
artist_count: number
|
artist_count: number;
|
||||||
minutes_listened: number
|
minutes_listened: number;
|
||||||
}
|
};
|
||||||
type SearchResponse = {
|
type SearchResponse = {
|
||||||
albums: Album[]
|
albums: Album[];
|
||||||
artists: Artist[]
|
artists: Artist[];
|
||||||
tracks: Track[]
|
tracks: Track[];
|
||||||
}
|
};
|
||||||
type User = {
|
type User = {
|
||||||
id: number
|
id: number;
|
||||||
username: string
|
username: string;
|
||||||
role: 'user' | 'admin'
|
role: "user" | "admin";
|
||||||
}
|
};
|
||||||
type ApiKey = {
|
type ApiKey = {
|
||||||
id: number
|
id: number;
|
||||||
key: string
|
key: string;
|
||||||
label: string
|
label: string;
|
||||||
created_at: Date
|
created_at: Date;
|
||||||
}
|
};
|
||||||
type ApiError = {
|
type ApiError = {
|
||||||
error: string
|
error: string;
|
||||||
}
|
};
|
||||||
|
type Config = {
|
||||||
|
default_theme: string;
|
||||||
|
};
|
||||||
|
type NowPlaying = {
|
||||||
|
currently_playing: boolean;
|
||||||
|
track: Track;
|
||||||
|
};
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
getItemsArgs,
|
getItemsArgs,
|
||||||
getActivityArgs,
|
getActivityArgs,
|
||||||
Track,
|
Track,
|
||||||
Artist,
|
Artist,
|
||||||
Album,
|
Album,
|
||||||
Listen,
|
Listen,
|
||||||
SearchResponse,
|
SearchResponse,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
ListenActivityItem,
|
ListenActivityItem,
|
||||||
User,
|
User,
|
||||||
Alias,
|
Alias,
|
||||||
ApiKey,
|
ApiKey,
|
||||||
ApiError
|
ApiError,
|
||||||
}
|
Config,
|
||||||
|
NowPlaying,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,200 +1,197 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getActivity, type getActivityArgs, type ListenActivityItem } from "api/api"
|
import {
|
||||||
import Popup from "./Popup"
|
getActivity,
|
||||||
import { useEffect, useState } from "react"
|
type getActivityArgs,
|
||||||
import { useTheme } from "~/hooks/useTheme"
|
type ListenActivityItem,
|
||||||
import ActivityOptsSelector from "./ActivityOptsSelector"
|
} 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(): string {
|
function getPrimaryColor(theme: Theme): string {
|
||||||
const value = getComputedStyle(document.documentElement)
|
const value = theme.primary;
|
||||||
.getPropertyValue('--color-primary')
|
const rgbMatch = value.match(
|
||||||
.trim();
|
/^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("");
|
||||||
|
}
|
||||||
|
|
||||||
const rgbMatch = value.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/);
|
return value;
|
||||||
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 {
|
interface Props {
|
||||||
step?: string
|
step?: string;
|
||||||
range?: number
|
range?: number;
|
||||||
month?: number
|
month?: number;
|
||||||
year?: number
|
year?: number;
|
||||||
artistId?: number
|
artistId?: number;
|
||||||
albumId?: number
|
albumId?: number;
|
||||||
trackId?: number
|
trackId?: number;
|
||||||
configurable?: boolean
|
configurable?: boolean;
|
||||||
autoAdjust?: boolean
|
autoAdjust?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ActivityGrid({
|
export default function ActivityGrid({
|
||||||
step = 'day',
|
step = "day",
|
||||||
range = 182,
|
range = 182,
|
||||||
month = 0,
|
month = 0,
|
||||||
year = 0,
|
year = 0,
|
||||||
artistId = 0,
|
artistId = 0,
|
||||||
albumId = 0,
|
albumId = 0,
|
||||||
trackId = 0,
|
trackId = 0,
|
||||||
configurable = false,
|
configurable = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const [stepState, setStep] = useState(step);
|
||||||
|
const [rangeState, setRange] = useState(range);
|
||||||
|
|
||||||
const [color, setColor] = useState(getPrimaryColor())
|
const { isPending, isError, data, error } = useQuery({
|
||||||
const [stepState, setStep] = useState(step)
|
queryKey: [
|
||||||
const [rangeState, setRange] = useState(range)
|
"listen-activity",
|
||||||
|
{
|
||||||
const { isPending, isError, data, error } = useQuery({
|
step: stepState,
|
||||||
queryKey: [
|
range: rangeState,
|
||||||
'listen-activity',
|
month: month,
|
||||||
{
|
year: year,
|
||||||
step: stepState,
|
artist_id: artistId,
|
||||||
range: rangeState,
|
album_id: albumId,
|
||||||
month: month,
|
track_id: trackId,
|
||||||
year: year,
|
},
|
||||||
artist_id: artistId,
|
],
|
||||||
album_id: albumId,
|
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
|
||||||
track_id: trackId
|
});
|
||||||
},
|
|
||||||
],
|
|
||||||
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const { theme, themeName } = useTheme();
|
||||||
|
const color = getPrimaryColor(theme);
|
||||||
|
|
||||||
const { theme } = useTheme();
|
if (isPending) {
|
||||||
useEffect(() => {
|
|
||||||
const raf = requestAnimationFrame(() => {
|
|
||||||
const color = getPrimaryColor()
|
|
||||||
setColor(color);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => cancelAnimationFrame(raf);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="w-[500px]">
|
|
||||||
<h2>Activity</h2>
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (isError) return <p className="error">Error:{error.message}</p>
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="flex flex-col items-start">
|
<div className="w-[500px]">
|
||||||
<h2>Activity</h2>
|
<h2>Activity</h2>
|
||||||
{configurable ? (
|
<p>Loading...</p>
|
||||||
<ActivityOptsSelector
|
</div>
|
||||||
rangeSetter={setRange}
|
|
||||||
currentRange={rangeState}
|
|
||||||
stepSetter={setStep}
|
|
||||||
currentStep={stepState}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{chunks.map((chunk, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="w-auto grid grid-flow-col grid-rows-7 gap-[3px] md:gap-[5px] mb-4"
|
|
||||||
>
|
|
||||||
{chunk.map((item) => (
|
|
||||||
<div
|
|
||||||
key={new Date(item.start_time).toString()}
|
|
||||||
className="w-[10px] sm:w-[12px] h-[10px] sm:h-[12px]"
|
|
||||||
>
|
|
||||||
<Popup
|
|
||||||
position="top"
|
|
||||||
space={12}
|
|
||||||
extraClasses="left-2"
|
|
||||||
inner={`${new Date(item.start_time).toLocaleDateString()} ${item.listens} plays`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
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)'
|
|
||||||
}`}
|
|
||||||
></div>
|
|
||||||
</Popup>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (isError) return <p className="error">Error:{error.message}</p>;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<h2>Activity</h2>
|
||||||
|
{configurable ? (
|
||||||
|
<ActivityOptsSelector
|
||||||
|
rangeSetter={setRange}
|
||||||
|
currentRange={rangeState}
|
||||||
|
stepSetter={setStep}
|
||||||
|
currentStep={stepState}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{chunks.map((chunk, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="w-auto grid grid-flow-col grid-rows-7 gap-[3px] md:gap-[5px] mb-4"
|
||||||
|
>
|
||||||
|
{chunk.map((item) => (
|
||||||
|
<div
|
||||||
|
key={new Date(item.start_time).toString()}
|
||||||
|
className="w-[10px] sm:w-[12px] h-[10px] sm:h-[12px]"
|
||||||
|
>
|
||||||
|
<Popup
|
||||||
|
position="top"
|
||||||
|
space={12}
|
||||||
|
extraClasses="left-2"
|
||||||
|
inner={`${new Date(item.start_time).toLocaleDateString()} ${
|
||||||
|
item.listens
|
||||||
|
} plays`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
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)"
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
</Popup>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,8 @@ export default function ActivityOptsSelector({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!disableCache) {
|
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);
|
if (cachedRange) rangeSetter(cachedRange);
|
||||||
const cachedStep = localStorage.getItem('activity_step_' + window.location.pathname.split('/')[1]);
|
const cachedStep = localStorage.getItem('activity_step_' + window.location.pathname.split('/')[1]);
|
||||||
if (cachedStep) stepSetter(cachedStep);
|
if (cachedStep) stepSetter(cachedStep);
|
||||||
|
|
|
||||||
|
|
@ -1,109 +1,150 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { timeSince } from "~/utils/utils"
|
import { timeSince } from "~/utils/utils";
|
||||||
import ArtistLinks from "./ArtistLinks"
|
import ArtistLinks from "./ArtistLinks";
|
||||||
import { deleteListen, getLastListens, type getItemsArgs, type Listen } from "api/api"
|
import {
|
||||||
import { Link } from "react-router"
|
deleteListen,
|
||||||
import { useAppContext } from "~/providers/AppProvider"
|
getLastListens,
|
||||||
|
getNowPlaying,
|
||||||
|
type getItemsArgs,
|
||||||
|
type Listen,
|
||||||
|
type Track,
|
||||||
|
} from "api/api";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { useAppContext } from "~/providers/AppProvider";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
limit: number
|
limit: number;
|
||||||
artistId?: Number
|
artistId?: Number;
|
||||||
albumId?: Number
|
albumId?: Number;
|
||||||
trackId?: number
|
trackId?: number;
|
||||||
hideArtists?: boolean
|
hideArtists?: boolean;
|
||||||
|
showNowPlaying?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LastPlays(props: Props) {
|
export default function LastPlays(props: Props) {
|
||||||
const { user } = useAppContext()
|
const { user } = useAppContext();
|
||||||
const { isPending, isError, data, error } = useQuery({
|
const { isPending, isError, data, error } = useQuery({
|
||||||
queryKey: ['last-listens', {
|
queryKey: [
|
||||||
limit: props.limit,
|
"last-listens",
|
||||||
period: 'all_time',
|
{
|
||||||
artist_id: props.artistId,
|
limit: props.limit,
|
||||||
album_id: props.albumId,
|
period: "all_time",
|
||||||
track_id: props.trackId
|
artist_id: props.artistId,
|
||||||
}],
|
album_id: props.albumId,
|
||||||
queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs),
|
track_id: props.trackId,
|
||||||
})
|
},
|
||||||
|
],
|
||||||
|
queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs),
|
||||||
|
});
|
||||||
|
const { data: npData } = useQuery({
|
||||||
|
queryKey: ["now-playing"],
|
||||||
|
queryFn: () => getNowPlaying(),
|
||||||
|
});
|
||||||
|
|
||||||
const [items, setItems] = useState<Listen[] | null>(null)
|
const [items, setItems] = useState<Listen[] | null>(null);
|
||||||
|
|
||||||
const handleDelete = async (listen: Listen) => {
|
const handleDelete = async (listen: Listen) => {
|
||||||
if (!data) return
|
if (!data) return;
|
||||||
try {
|
try {
|
||||||
const res = await deleteListen(listen)
|
const res = await deleteListen(listen);
|
||||||
if (res.ok || (res.status >= 200 && res.status < 300)) {
|
if (res.ok || (res.status >= 200 && res.status < 300)) {
|
||||||
setItems((prev) => (prev ?? data.items).filter((i) => i.time !== listen.time))
|
setItems((prev) =>
|
||||||
} else {
|
(prev ?? data.items).filter((i) => i.time !== listen.time)
|
||||||
console.error("Failed to delete listen:", res.status)
|
);
|
||||||
}
|
} else {
|
||||||
} catch (err) {
|
console.error("Failed to delete listen:", res.status);
|
||||||
console.error("Error deleting listen:", err)
|
}
|
||||||
}
|
} catch (err) {
|
||||||
|
console.error("Error deleting listen:", err);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
|
||||||
<div className="w-[300px] sm:w-[500px]">
|
|
||||||
<h2>Last Played</h2>
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (isError) {
|
|
||||||
return <p className="error">Error: {error.message}</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
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}` : ''
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-sm sm:text-[16px]">
|
<div className="w-[300px] sm:w-[500px]">
|
||||||
<h2 className="hover:underline">
|
<h2>Last Played</h2>
|
||||||
<Link to={`/listens?period=all_time${params}`}>Last Played</Link>
|
<p>Loading...</p>
|
||||||
</h2>
|
</div>
|
||||||
<table className="-ml-4">
|
);
|
||||||
<tbody>
|
}
|
||||||
{listens.map((item) => (
|
if (isError) {
|
||||||
<tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]">
|
return <p className="error">Error: {error.message}</p>;
|
||||||
<td className="w-[18px] pr-2 align-middle" >
|
}
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(item)}
|
const listens = items ?? data.items;
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
|
|
||||||
aria-label="Delete"
|
let params = "";
|
||||||
hidden={user === null || user === undefined}
|
params += props.artistId ? `&artist_id=${props.artistId}` : "";
|
||||||
>
|
params += props.albumId ? `&album_id=${props.albumId}` : "";
|
||||||
×
|
params += props.trackId ? `&track_id=${props.trackId}` : "";
|
||||||
</button>
|
|
||||||
</td>
|
return (
|
||||||
<td
|
<div className="text-sm sm:text-[16px]">
|
||||||
className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0"
|
<h2 className="hover:underline">
|
||||||
title={new Date(item.time).toString()}
|
<Link to={`/listens?period=all_time${params}`}>Last Played</Link>
|
||||||
>
|
</h2>
|
||||||
{timeSince(new Date(item.time))}
|
<table className="-ml-4">
|
||||||
</td>
|
<tbody>
|
||||||
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
|
{props.showNowPlaying && npData && npData.currently_playing && (
|
||||||
{props.hideArtists ? null : (
|
<tr className="group hover:bg-[--color-bg-secondary]">
|
||||||
<>
|
<td className="w-[18px] pr-2 align-middle"></td>
|
||||||
<ArtistLinks artists={item.track.artists} /> –{' '}
|
<td className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0">
|
||||||
</>
|
Now Playing
|
||||||
)}
|
</td>
|
||||||
<Link
|
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
|
||||||
className="hover:text-[--color-fg-secondary]"
|
{props.hideArtists ? null : (
|
||||||
to={`/track/${item.track.id}`}
|
<>
|
||||||
>
|
<ArtistLinks artists={npData.track.artists} /> –{" "}
|
||||||
{item.track.title}
|
</>
|
||||||
</Link>
|
)}
|
||||||
</td>
|
<Link
|
||||||
</tr>
|
className="hover:text-[--color-fg-secondary]"
|
||||||
))}
|
to={`/track/${npData.track.id}`}
|
||||||
</tbody>
|
>
|
||||||
</table>
|
{npData.track.title}
|
||||||
</div>
|
</Link>
|
||||||
)
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{listens.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={`last_listen_${item.time}`}
|
||||||
|
className="group hover:bg-[--color-bg-secondary]"
|
||||||
|
>
|
||||||
|
<td className="w-[18px] pr-2 align-middle">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(item)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
|
||||||
|
aria-label="Delete"
|
||||||
|
hidden={user === null || user === undefined}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0"
|
||||||
|
title={new Date(item.time).toString()}
|
||||||
|
>
|
||||||
|
{timeSince(new Date(item.time))}
|
||||||
|
</td>
|
||||||
|
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
|
||||||
|
{props.hideArtists ? null : (
|
||||||
|
<>
|
||||||
|
<ArtistLinks artists={item.track.artists} /> –{" "}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
className="hover:text-[--color-fg-secondary]"
|
||||||
|
to={`/track/${item.track.id}`}
|
||||||
|
>
|
||||||
|
{item.track.title}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
57
client/app/components/modals/AddListenModal.tsx
Normal file
57
client/app/components/modals/AddListenModal.tsx
Normal file
|
|
@ -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<Date>(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 (
|
||||||
|
<Modal isOpen={open} onClose={close}>
|
||||||
|
<h2>Add Listen</h2>
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className="w-full mx-auto fg bg rounded p-2"
|
||||||
|
value={formatForDatetimeLocal(ts)}
|
||||||
|
onChange={(e) => setTS(new Date(e.target.value))}
|
||||||
|
/>
|
||||||
|
<AsyncButton loading={loading} onClick={submit}>Submit</AsyncButton>
|
||||||
|
<p className="error">{error}</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,20 @@
|
||||||
import type { Theme } from "~/providers/ThemeProvider";
|
import type { Theme } from "~/styles/themes.css";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
theme: Theme
|
theme: Theme
|
||||||
|
themeName: string
|
||||||
setTheme: Function
|
setTheme: Function
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ThemeOption({ theme, setTheme }: Props) {
|
export default function ThemeOption({ theme, themeName, setTheme }: Props) {
|
||||||
|
|
||||||
const capitalizeFirstLetter = (s: string) => {
|
const capitalizeFirstLetter = (s: string) => {
|
||||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div onClick={() => 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}}>
|
<div onClick={() => 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}}>
|
||||||
<div className="text-xs sm:text-sm">{capitalizeFirstLetter(theme.name)}</div>
|
<div className="text-xs sm:text-sm">{capitalizeFirstLetter(themeName)}</div>
|
||||||
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.bgSecondary}}></div>
|
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.bgSecondary}}></div>
|
||||||
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.fgSecondary}}></div>
|
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.fgSecondary}}></div>
|
||||||
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.primary}}></div>
|
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.primary}}></div>
|
||||||
|
|
|
||||||
|
|
@ -1,69 +1,78 @@
|
||||||
// ThemeSwitcher.tsx
|
import { useState } from "react";
|
||||||
import { useEffect, useState } from 'react';
|
import { useTheme } from "../../hooks/useTheme";
|
||||||
import { useTheme } from '../../hooks/useTheme';
|
import themes from "~/styles/themes.css";
|
||||||
import themes from '~/styles/themes.css';
|
import ThemeOption from "./ThemeOption";
|
||||||
import ThemeOption from './ThemeOption';
|
import { AsyncButton } from "../AsyncButton";
|
||||||
import { AsyncButton } from '../AsyncButton';
|
|
||||||
|
|
||||||
export function ThemeSwitcher() {
|
export function ThemeSwitcher() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
const initialTheme = {
|
const initialTheme = {
|
||||||
bg: "#1e1816",
|
bg: "#1e1816",
|
||||||
bgSecondary: "#2f2623",
|
bgSecondary: "#2f2623",
|
||||||
bgTertiary: "#453733",
|
bgTertiary: "#453733",
|
||||||
fg: "#f8f3ec",
|
fg: "#f8f3ec",
|
||||||
fgSecondary: "#d6ccc2",
|
fgSecondary: "#d6ccc2",
|
||||||
fgTertiary: "#b4a89c",
|
fgTertiary: "#b4a89c",
|
||||||
primary: "#f5a97f",
|
primary: "#f5a97f",
|
||||||
primaryDim: "#d88b65",
|
primaryDim: "#d88b65",
|
||||||
accent: "#f9db6d",
|
accent: "#f9db6d",
|
||||||
accentDim: "#d9bc55",
|
accentDim: "#d9bc55",
|
||||||
error: "#e26c6a",
|
error: "#e26c6a",
|
||||||
warning: "#f5b851",
|
warning: "#f5b851",
|
||||||
success: "#8fc48f",
|
success: "#8fc48f",
|
||||||
info: "#87b8dd",
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
const { setCustomTheme, getCustomTheme, resetTheme } = useTheme();
|
||||||
if (theme) {
|
const [custom, setCustom] = useState(
|
||||||
setTheme(theme)
|
JSON.stringify(getCustomTheme() ?? initialTheme, null, " ")
|
||||||
}
|
);
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
return (
|
const handleCustomTheme = () => {
|
||||||
<div className='flex flex-col gap-10'>
|
console.log(custom);
|
||||||
<div>
|
try {
|
||||||
<h2>Select Theme</h2>
|
const themeData = JSON.parse(custom);
|
||||||
<div className="grid grid-cols-2 items-center gap-2">
|
setCustomTheme(themeData);
|
||||||
{themes.map((t) => (
|
setCustom(JSON.stringify(themeData, null, " "));
|
||||||
<ThemeOption setTheme={setTheme} key={t.name} theme={t} />
|
console.log(themeData);
|
||||||
))}
|
} catch (err) {
|
||||||
</div>
|
console.log(err);
|
||||||
</div>
|
}
|
||||||
<div>
|
};
|
||||||
<h2>Use Custom Theme</h2>
|
|
||||||
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
|
return (
|
||||||
<textarea name="custom-theme" onChange={(e) => setCustom(e.target.value)} id="custom-theme-input" className="bg-(--color-bg) h-[450px] w-[300px] p-5 rounded-md" value={custom} />
|
<div className="flex flex-col gap-10">
|
||||||
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
|
<div>
|
||||||
</div>
|
<div className="flex items-center gap-3">
|
||||||
</div>
|
<h2>Select Theme</h2>
|
||||||
|
<div className="mb-3">
|
||||||
|
<AsyncButton onClick={resetTheme}>Reset</AsyncButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className="grid grid-cols-2 items-center gap-2">
|
||||||
|
{Object.entries(themes).map(([name, themeData]) => (
|
||||||
|
<ThemeOption
|
||||||
|
setTheme={setTheme}
|
||||||
|
key={name}
|
||||||
|
theme={themeData}
|
||||||
|
themeName={name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Use Custom Theme</h2>
|
||||||
|
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
|
||||||
|
<textarea
|
||||||
|
name="custom-theme"
|
||||||
|
onChange={(e) => setCustom(e.target.value)}
|
||||||
|
id="custom-theme-input"
|
||||||
|
className="bg-(--color-bg) h-[450px] w-[300px] p-5 rounded-md"
|
||||||
|
value={custom}
|
||||||
|
/>
|
||||||
|
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import type { User } from "api/api";
|
import { getCfg, type User } from "api/api";
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
interface AppContextType {
|
interface AppContextType {
|
||||||
user: User | null | undefined;
|
user: User | null | undefined;
|
||||||
configurableHomeActivity: boolean;
|
configurableHomeActivity: boolean;
|
||||||
homeItems: number;
|
homeItems: number;
|
||||||
|
defaultTheme: string;
|
||||||
setConfigurableHomeActivity: (value: boolean) => void;
|
setConfigurableHomeActivity: (value: boolean) => void;
|
||||||
setHomeItems: (value: number) => void;
|
setHomeItems: (value: number) => void;
|
||||||
setUsername: (value: string) => void;
|
setUsername: (value: string) => void;
|
||||||
|
|
@ -22,15 +23,19 @@ export const useAppContext = () => {
|
||||||
|
|
||||||
export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
const [user, setUser] = useState<User | null | undefined>(undefined);
|
const [user, setUser] = useState<User | null | undefined>(undefined);
|
||||||
const [configurableHomeActivity, setConfigurableHomeActivity] = useState<boolean>(false);
|
const [defaultTheme, setDefaultTheme] = useState<string | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const [configurableHomeActivity, setConfigurableHomeActivity] =
|
||||||
|
useState<boolean>(false);
|
||||||
const [homeItems, setHomeItems] = useState<number>(0);
|
const [homeItems, setHomeItems] = useState<number>(0);
|
||||||
|
|
||||||
const setUsername = (value: string) => {
|
const setUsername = (value: string) => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setUser({...user, username: value})
|
setUser({ ...user, username: value });
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/apis/web/v1/user/me")
|
fetch("/apis/web/v1/user/me")
|
||||||
|
|
@ -42,9 +47,19 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
|
||||||
setConfigurableHomeActivity(true);
|
setConfigurableHomeActivity(true);
|
||||||
setHomeItems(12);
|
setHomeItems(12);
|
||||||
|
|
||||||
|
getCfg().then((cfg) => {
|
||||||
|
console.log(cfg);
|
||||||
|
if (cfg.default_theme !== "") {
|
||||||
|
setDefaultTheme(cfg.default_theme);
|
||||||
|
} else {
|
||||||
|
setDefaultTheme("yuu");
|
||||||
|
}
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (user === undefined) {
|
// Block rendering the app until config is loaded
|
||||||
|
if (user === undefined || defaultTheme === undefined) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,10 +67,13 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
user,
|
user,
|
||||||
configurableHomeActivity,
|
configurableHomeActivity,
|
||||||
homeItems,
|
homeItems,
|
||||||
|
defaultTheme,
|
||||||
setConfigurableHomeActivity,
|
setConfigurableHomeActivity,
|
||||||
setHomeItems,
|
setHomeItems,
|
||||||
setUsername,
|
setUsername,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>;
|
return (
|
||||||
};
|
<AppContext.Provider value={contextValue}>{children}</AppContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,95 +1,131 @@
|
||||||
import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react';
|
import {
|
||||||
import { type Theme } from '~/styles/themes.css';
|
createContext,
|
||||||
import { themeVars } from '~/styles/vars.css';
|
useEffect,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { type Theme, themes } from "~/styles/themes.css";
|
||||||
|
import { themeVars } from "~/styles/vars.css";
|
||||||
|
import { useAppContext } from "./AppProvider";
|
||||||
|
|
||||||
interface ThemeContextValue {
|
interface ThemeContextValue {
|
||||||
theme: string;
|
themeName: string;
|
||||||
setTheme: (theme: string) => void;
|
theme: Theme;
|
||||||
setCustomTheme: (theme: Theme) => void;
|
setTheme: (theme: string) => void;
|
||||||
getCustomTheme: () => Theme | undefined;
|
resetTheme: () => void;
|
||||||
|
setCustomTheme: (theme: Theme) => void;
|
||||||
|
getCustomTheme: () => Theme | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||||
|
|
||||||
function toKebabCase(str: string) {
|
function toKebabCase(str: string) {
|
||||||
return str.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
|
return str.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyCustomThemeVars(theme: Theme) {
|
function applyCustomThemeVars(theme: Theme) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
for (const [key, value] of Object.entries(theme)) {
|
for (const [key, value] of Object.entries(theme)) {
|
||||||
if (key === 'name') continue;
|
if (key === "name") continue;
|
||||||
root.style.setProperty(`--color-${toKebabCase(key)}`, value);
|
root.style.setProperty(`--color-${toKebabCase(key)}`, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearCustomThemeVars() {
|
function clearCustomThemeVars() {
|
||||||
for (const cssVar of Object.values(themeVars)) {
|
for (const cssVar of Object.values(themeVars)) {
|
||||||
document.documentElement.style.removeProperty(cssVar);
|
document.documentElement.style.removeProperty(cssVar);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeProvider({
|
function getStoredCustomTheme(): Theme | undefined {
|
||||||
theme: initialTheme,
|
const themeStr = localStorage.getItem("custom-theme");
|
||||||
children,
|
if (!themeStr) return undefined;
|
||||||
}: {
|
try {
|
||||||
theme: string;
|
const parsed = JSON.parse(themeStr);
|
||||||
children: ReactNode;
|
const { name, ...theme } = parsed;
|
||||||
}) {
|
return theme as Theme;
|
||||||
const [theme, setThemeName] = useState(initialTheme);
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const setTheme = (theme: string) => {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
setThemeName(theme)
|
let defaultTheme = useAppContext().defaultTheme;
|
||||||
|
let initialTheme = localStorage.getItem("theme") ?? defaultTheme;
|
||||||
|
const [themeName, setThemeName] = useState(initialTheme);
|
||||||
|
const [currentTheme, setCurrentTheme] = useState<Theme>(() => {
|
||||||
|
if (initialTheme === "custom") {
|
||||||
|
const customTheme = getStoredCustomTheme();
|
||||||
|
return customTheme || themes[defaultTheme];
|
||||||
}
|
}
|
||||||
|
return themes[initialTheme] || themes[defaultTheme];
|
||||||
|
});
|
||||||
|
|
||||||
const setCustomTheme = useCallback((customTheme: Theme) => {
|
const setTheme = (newThemeName: string) => {
|
||||||
localStorage.setItem('custom-theme', JSON.stringify(customTheme));
|
setThemeName(newThemeName);
|
||||||
applyCustomThemeVars(customTheme);
|
if (newThemeName === "custom") {
|
||||||
setTheme('custom');
|
const customTheme = getStoredCustomTheme();
|
||||||
}, []);
|
if (customTheme) {
|
||||||
|
setCurrentTheme(customTheme);
|
||||||
const getCustomTheme = (): Theme | undefined => {
|
} else {
|
||||||
const themeStr = localStorage.getItem('custom-theme');
|
// Fallback to default theme if no custom theme found
|
||||||
if (!themeStr) {
|
setThemeName(defaultTheme);
|
||||||
return undefined
|
setCurrentTheme(themes[defaultTheme]);
|
||||||
}
|
}
|
||||||
try {
|
} else {
|
||||||
let theme = JSON.parse(themeStr) as Theme
|
const foundTheme = themes[newThemeName];
|
||||||
return theme
|
if (foundTheme) {
|
||||||
} catch (err) {
|
localStorage.setItem("theme", newThemeName);
|
||||||
return undefined
|
setCurrentTheme(foundTheme);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const resetTheme = () => {
|
||||||
const root = document.documentElement;
|
setThemeName(defaultTheme);
|
||||||
|
localStorage.removeItem("theme");
|
||||||
|
setCurrentTheme(themes[defaultTheme]);
|
||||||
|
};
|
||||||
|
|
||||||
root.setAttribute('data-theme', theme);
|
const setCustomTheme = useCallback((customTheme: Theme) => {
|
||||||
localStorage.setItem('theme', theme)
|
localStorage.setItem("custom-theme", JSON.stringify(customTheme));
|
||||||
console.log(theme)
|
applyCustomThemeVars(customTheme);
|
||||||
|
setThemeName("custom");
|
||||||
|
localStorage.setItem("theme", "custom");
|
||||||
|
setCurrentTheme(customTheme);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (theme === 'custom') {
|
const getCustomTheme = (): Theme | undefined => {
|
||||||
const saved = localStorage.getItem('custom-theme');
|
return getStoredCustomTheme();
|
||||||
if (saved) {
|
};
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(saved) as Theme;
|
|
||||||
applyCustomThemeVars(parsed);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Invalid custom theme in localStorage', err);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setTheme('yuu')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
clearCustomThemeVars()
|
|
||||||
}
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<ThemeContext.Provider value={{ theme, setTheme, setCustomTheme, getCustomTheme }}>
|
const root = document.documentElement;
|
||||||
{children}
|
|
||||||
</ThemeContext.Provider>
|
root.setAttribute("data-theme", themeName);
|
||||||
);
|
|
||||||
|
if (themeName === "custom") {
|
||||||
|
applyCustomThemeVars(currentTheme);
|
||||||
|
} else {
|
||||||
|
clearCustomThemeVars();
|
||||||
|
}
|
||||||
|
}, [themeName, currentTheme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider
|
||||||
|
value={{
|
||||||
|
themeName,
|
||||||
|
theme: currentTheme,
|
||||||
|
setTheme,
|
||||||
|
resetTheme,
|
||||||
|
setCustomTheme,
|
||||||
|
getCustomTheme,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ThemeContext };
|
export { ThemeContext };
|
||||||
|
|
|
||||||
|
|
@ -58,12 +58,10 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
let theme = localStorage.getItem('theme') ?? 'yuu'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppProvider>
|
<AppProvider>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<div className="flex-col flex sm:flex-row">
|
<div className="flex-col flex sm:flex-row">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
|
@ -99,18 +97,12 @@ export function ErrorBoundary() {
|
||||||
stack = error.stack;
|
stack = error.stack;
|
||||||
}
|
}
|
||||||
|
|
||||||
let theme = 'yuu'
|
|
||||||
try {
|
|
||||||
theme = localStorage.getItem('theme') ?? theme
|
|
||||||
} catch(err) {
|
|
||||||
console.log(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = `${message} - Koito`
|
const title = `${message} - Koito`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppProvider>
|
<AppProvider>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider>
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ export default function Home() {
|
||||||
<TopArtists period={period} limit={homeItems} />
|
<TopArtists period={period} limit={homeItems} />
|
||||||
<TopAlbums period={period} limit={homeItems} />
|
<TopAlbums period={period} limit={homeItems} />
|
||||||
<TopTracks period={period} limit={homeItems} />
|
<TopTracks period={period} limit={homeItems} />
|
||||||
<LastPlays limit={Math.floor(homeItems * 2.7)} />
|
<LastPlays showNowPlaying={true} limit={Math.floor(homeItems * 2.7)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export default function Album() {
|
||||||
subContent={<div className="flex flex-col gap-2 items-start">
|
subContent={<div className="flex flex-col gap-2 items-start">
|
||||||
{album.listen_count && <p>{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}</p>}
|
{album.listen_count && <p>{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}</p>}
|
||||||
{<p title={Math.floor(album.time_listened / 60) + " minutes"}>{timeListenedString(album.time_listened)}</p>}
|
{<p title={Math.floor(album.time_listened / 60) + " minutes"}>{timeListenedString(album.time_listened)}</p>}
|
||||||
|
{<p title={new Date(album.first_listen * 1000).toLocaleString()}>Listening since {new Date(album.first_listen * 1000).toLocaleDateString()}</p>}
|
||||||
</div>}
|
</div>}
|
||||||
>
|
>
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ export default function Artist() {
|
||||||
subContent={<div className="flex flex-col gap-2 items-start">
|
subContent={<div className="flex flex-col gap-2 items-start">
|
||||||
{artist.listen_count && <p>{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}</p>}
|
{artist.listen_count && <p>{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}</p>}
|
||||||
{<p title={Math.floor(artist.time_listened / 60) + " minutes"}>{timeListenedString(artist.time_listened)}</p>}
|
{<p title={Math.floor(artist.time_listened / 60) + " minutes"}>{timeListenedString(artist.time_listened)}</p>}
|
||||||
|
{<p title={new Date(artist.first_listen * 1000).toLocaleString()}>Listening since {new Date(artist.first_listen * 1000).toLocaleDateString()}</p>}
|
||||||
</div>}
|
</div>}
|
||||||
>
|
>
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@ import React, { useEffect, useState } from "react";
|
||||||
import { average } from "color.js";
|
import { average } from "color.js";
|
||||||
import { imageUrl, type SearchResponse } from "api/api";
|
import { imageUrl, type SearchResponse } from "api/api";
|
||||||
import ImageDropHandler from "~/components/ImageDropHandler";
|
import ImageDropHandler from "~/components/ImageDropHandler";
|
||||||
import { Edit, ImageIcon, Merge, Trash } from "lucide-react";
|
import { Edit, ImageIcon, Merge, Plus, Trash } from "lucide-react";
|
||||||
import { useAppContext } from "~/providers/AppProvider";
|
import { useAppContext } from "~/providers/AppProvider";
|
||||||
import MergeModal from "~/components/modals/MergeModal";
|
import MergeModal from "~/components/modals/MergeModal";
|
||||||
import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
|
import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
|
||||||
import DeleteModal from "~/components/modals/DeleteModal";
|
import DeleteModal from "~/components/modals/DeleteModal";
|
||||||
import RenameModal from "~/components/modals/EditModal/EditModal";
|
import RenameModal from "~/components/modals/EditModal/EditModal";
|
||||||
import EditModal from "~/components/modals/EditModal/EditModal";
|
import EditModal from "~/components/modals/EditModal/EditModal";
|
||||||
|
import AddListenModal from "~/components/modals/AddListenModal";
|
||||||
|
|
||||||
export type MergeFunc = (from: number, to: number, replaceImage: boolean) => Promise<Response>
|
export type MergeFunc = (from: number, to: number, replaceImage: boolean) => Promise<Response>
|
||||||
export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
|
export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
|
||||||
|
|
@ -32,6 +33,7 @@ export default function MediaLayout(props: Props) {
|
||||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||||
const [imageModalOpen, setImageModalOpen] = useState(false);
|
const [imageModalOpen, setImageModalOpen] = useState(false);
|
||||||
const [renameModalOpen, setRenameModalOpen] = useState(false);
|
const [renameModalOpen, setRenameModalOpen] = useState(false);
|
||||||
|
const [addListenModalOpen, setAddListenModalOpen] = useState(false);
|
||||||
const { user } = useAppContext();
|
const { user } = useAppContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -80,6 +82,12 @@ export default function MediaLayout(props: Props) {
|
||||||
</div>
|
</div>
|
||||||
{ user &&
|
{ user &&
|
||||||
<div className="absolute left-1 sm:right-1 sm:left-auto -top-9 sm:top-1 flex gap-3 items-center">
|
<div className="absolute left-1 sm:right-1 sm:left-auto -top-9 sm:top-1 flex gap-3 items-center">
|
||||||
|
{ props.type === "Track" &&
|
||||||
|
<>
|
||||||
|
<button title="Add Listen" className="hover:cursor-pointer" onClick={() => setAddListenModalOpen(true)}><Plus size={iconSize} /></button>
|
||||||
|
<AddListenModal open={addListenModalOpen} setOpen={setAddListenModalOpen} trackid={props.id} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
<button title="Edit Item" className="hover:cursor-pointer" onClick={() => setRenameModalOpen(true)}><Edit size={iconSize} /></button>
|
<button title="Edit Item" className="hover:cursor-pointer" onClick={() => setRenameModalOpen(true)}><Edit size={iconSize} /></button>
|
||||||
<button title="Replace Image" className="hover:cursor-pointer" onClick={() => setImageModalOpen(true)}><ImageIcon size={iconSize} /></button>
|
<button title="Replace Image" className="hover:cursor-pointer" onClick={() => setImageModalOpen(true)}><ImageIcon size={iconSize} /></button>
|
||||||
<button title="Merge Items" className="hover:cursor-pointer" onClick={() => setMergeModalOpen(true)}><Merge size={iconSize} /></button>
|
<button title="Merge Items" className="hover:cursor-pointer" onClick={() => setMergeModalOpen(true)}><Merge size={iconSize} /></button>
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,8 @@ export default function Track() {
|
||||||
subContent={<div className="flex flex-col gap-2 items-start">
|
subContent={<div className="flex flex-col gap-2 items-start">
|
||||||
<Link to={`/album/${track.album_id}`}>appears on {album.title}</Link>
|
<Link to={`/album/${track.album_id}`}>appears on {album.title}</Link>
|
||||||
{track.listen_count && <p>{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}</p>}
|
{track.listen_count && <p>{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}</p>}
|
||||||
{<p title={Math.floor(track.time_listened / 60) + " minutes"}>{timeListenedString(track.time_listened)}</p>}
|
{<p title={Math.floor(track.time_listened / 60) + " minutes"}>{timeListenedString(track.time_listened)}</p>}
|
||||||
|
{<p title={new Date(track.first_listen * 1000).toLocaleString()}>Listening since {new Date(track.first_listen * 1000).toLocaleDateString()}</p>}
|
||||||
</div>}
|
</div>}
|
||||||
>
|
>
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import { themes, type Theme } from "~/styles/themes.css"
|
||||||
|
|
||||||
export default function ThemeHelper() {
|
export default function ThemeHelper() {
|
||||||
const initialTheme = {
|
const initialTheme = {
|
||||||
name: "custom",
|
|
||||||
bg: "#1e1816",
|
bg: "#1e1816",
|
||||||
bgSecondary: "#2f2623",
|
bgSecondary: "#2f2623",
|
||||||
bgTertiary: "#453733",
|
bgTertiary: "#453733",
|
||||||
|
|
@ -36,9 +35,6 @@ export default function ThemeHelper() {
|
||||||
console.log(custom)
|
console.log(custom)
|
||||||
try {
|
try {
|
||||||
const theme = JSON.parse(custom) as Theme
|
const theme = JSON.parse(custom) as Theme
|
||||||
if (theme.name !== "custom") {
|
|
||||||
throw new Error("theme name must be 'custom'")
|
|
||||||
}
|
|
||||||
console.log(theme)
|
console.log(theme)
|
||||||
setCustomTheme(theme)
|
setCustomTheme(theme)
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,10 @@ import { globalStyle } from "@vanilla-extract/css"
|
||||||
import { themeVars } from "./vars.css"
|
import { themeVars } from "./vars.css"
|
||||||
|
|
||||||
export type Theme = {
|
export type Theme = {
|
||||||
name: string,
|
bg: string
|
||||||
bg: string
|
bgSecondary: string
|
||||||
bgSecondary: string
|
|
||||||
bgTertiary: string
|
bgTertiary: string
|
||||||
fg: string
|
fg: string
|
||||||
fgSecondary: string
|
fgSecondary: string
|
||||||
fgTertiary: string
|
fgTertiary: string
|
||||||
primary: string
|
primary: string
|
||||||
|
|
@ -23,9 +22,8 @@ export const THEME_KEYS = [
|
||||||
'--color'
|
'--color'
|
||||||
]
|
]
|
||||||
|
|
||||||
export const themes: Theme[] = [
|
export const themes: Record<string, Theme> = {
|
||||||
{
|
yuu: {
|
||||||
name: "yuu",
|
|
||||||
bg: "#1e1816",
|
bg: "#1e1816",
|
||||||
bgSecondary: "#2f2623",
|
bgSecondary: "#2f2623",
|
||||||
bgTertiary: "#453733",
|
bgTertiary: "#453733",
|
||||||
|
|
@ -41,8 +39,7 @@ export const themes: Theme[] = [
|
||||||
success: "#8fc48f",
|
success: "#8fc48f",
|
||||||
info: "#87b8dd",
|
info: "#87b8dd",
|
||||||
},
|
},
|
||||||
{
|
varia: {
|
||||||
name: "varia",
|
|
||||||
bg: "rgb(25, 25, 29)",
|
bg: "rgb(25, 25, 29)",
|
||||||
bgSecondary: "#222222",
|
bgSecondary: "#222222",
|
||||||
bgTertiary: "#333333",
|
bgTertiary: "#333333",
|
||||||
|
|
@ -58,8 +55,7 @@ export const themes: Theme[] = [
|
||||||
success: "#4caf50",
|
success: "#4caf50",
|
||||||
info: "#2196f3",
|
info: "#2196f3",
|
||||||
},
|
},
|
||||||
{
|
midnight: {
|
||||||
name: "midnight",
|
|
||||||
bg: "rgb(8, 15, 24)",
|
bg: "rgb(8, 15, 24)",
|
||||||
bgSecondary: "rgb(15, 27, 46)",
|
bgSecondary: "rgb(15, 27, 46)",
|
||||||
bgTertiary: "rgb(15, 41, 70)",
|
bgTertiary: "rgb(15, 41, 70)",
|
||||||
|
|
@ -75,8 +71,7 @@ export const themes: Theme[] = [
|
||||||
success: "#4caf50",
|
success: "#4caf50",
|
||||||
info: "#2196f3",
|
info: "#2196f3",
|
||||||
},
|
},
|
||||||
{
|
catppuccin: {
|
||||||
name: "catppuccin",
|
|
||||||
bg: "#1e1e2e",
|
bg: "#1e1e2e",
|
||||||
bgSecondary: "#181825",
|
bgSecondary: "#181825",
|
||||||
bgTertiary: "#11111b",
|
bgTertiary: "#11111b",
|
||||||
|
|
@ -92,8 +87,7 @@ export const themes: Theme[] = [
|
||||||
success: "#a6e3a1",
|
success: "#a6e3a1",
|
||||||
info: "#89dceb",
|
info: "#89dceb",
|
||||||
},
|
},
|
||||||
{
|
autumn: {
|
||||||
name: "autumn",
|
|
||||||
bg: "rgb(44, 25, 18)",
|
bg: "rgb(44, 25, 18)",
|
||||||
bgSecondary: "rgb(70, 40, 18)",
|
bgSecondary: "rgb(70, 40, 18)",
|
||||||
bgTertiary: "#4b2f1c",
|
bgTertiary: "#4b2f1c",
|
||||||
|
|
@ -109,8 +103,7 @@ export const themes: Theme[] = [
|
||||||
success: "#6b8e23",
|
success: "#6b8e23",
|
||||||
info: "#c084fc",
|
info: "#c084fc",
|
||||||
},
|
},
|
||||||
{
|
black: {
|
||||||
name: "black",
|
|
||||||
bg: "#000000",
|
bg: "#000000",
|
||||||
bgSecondary: "#1a1a1a",
|
bgSecondary: "#1a1a1a",
|
||||||
bgTertiary: "#2a2a2a",
|
bgTertiary: "#2a2a2a",
|
||||||
|
|
@ -126,8 +119,7 @@ export const themes: Theme[] = [
|
||||||
success: "#4caf50",
|
success: "#4caf50",
|
||||||
info: "#2196f3",
|
info: "#2196f3",
|
||||||
},
|
},
|
||||||
{
|
wine: {
|
||||||
name: "wine",
|
|
||||||
bg: "#23181E",
|
bg: "#23181E",
|
||||||
bgSecondary: "#2C1C25",
|
bgSecondary: "#2C1C25",
|
||||||
bgTertiary: "#422A37",
|
bgTertiary: "#422A37",
|
||||||
|
|
@ -143,97 +135,92 @@ export const themes: Theme[] = [
|
||||||
success: "#bbf7d0",
|
success: "#bbf7d0",
|
||||||
info: "#bae6fd",
|
info: "#bae6fd",
|
||||||
},
|
},
|
||||||
{
|
pearl: {
|
||||||
name: "pearl",
|
bg: "#FFFFFF",
|
||||||
bg: "#FFFFFF",
|
bgSecondary: "#EEEEEE",
|
||||||
bgSecondary: "#EEEEEE",
|
bgTertiary: "#E0E0E0",
|
||||||
bgTertiary: "#E0E0E0",
|
fg: "#333333",
|
||||||
fg: "#333333",
|
fgSecondary: "#555555",
|
||||||
fgSecondary: "#555555",
|
|
||||||
fgTertiary: "#777777",
|
fgTertiary: "#777777",
|
||||||
primary: "#007BFF",
|
primary: "#007BFF",
|
||||||
primaryDim: "#0056B3",
|
primaryDim: "#0056B3",
|
||||||
accent: "#28A745",
|
accent: "#28A745",
|
||||||
accentDim: "#1E7E34",
|
accentDim: "#1E7E34",
|
||||||
error: "#DC3545",
|
error: "#DC3545",
|
||||||
warning: "#FFC107",
|
warning: "#FFC107",
|
||||||
success: "#28A745",
|
success: "#28A745",
|
||||||
info: "#17A2B8",
|
info: "#17A2B8",
|
||||||
},
|
},
|
||||||
{
|
asuka: {
|
||||||
name: "asuka",
|
bg: "#3B1212",
|
||||||
bg: "#3B1212",
|
bgSecondary: "#471B1B",
|
||||||
bgSecondary: "#471B1B",
|
bgTertiary: "#020202",
|
||||||
bgTertiary: "#020202",
|
fg: "#F1E9E6",
|
||||||
fg: "#F1E9E6",
|
fgSecondary: "#CCB6AE",
|
||||||
fgSecondary: "#CCB6AE",
|
|
||||||
fgTertiary: "#9F8176",
|
fgTertiary: "#9F8176",
|
||||||
primary: "#F1E9E6",
|
primary: "#F1E9E6",
|
||||||
primaryDim: "#CCB6AE",
|
primaryDim: "#CCB6AE",
|
||||||
accent: "#41CE41",
|
accent: "#41CE41",
|
||||||
accentDim: "#3BA03B",
|
accentDim: "#3BA03B",
|
||||||
error: "#DC143C",
|
error: "#DC143C",
|
||||||
warning: "#FFD700",
|
warning: "#FFD700",
|
||||||
success: "#32CD32",
|
success: "#32CD32",
|
||||||
info: "#1E90FF",
|
info: "#1E90FF",
|
||||||
},
|
},
|
||||||
{
|
urim: {
|
||||||
name: "urim",
|
bg: "#101713",
|
||||||
bg: "#101713",
|
bgSecondary: "#1B2921",
|
||||||
bgSecondary: "#1B2921",
|
bgTertiary: "#273B30",
|
||||||
bgTertiary: "#273B30",
|
fg: "#D2E79E",
|
||||||
fg: "#D2E79E",
|
fgSecondary: "#B4DA55",
|
||||||
fgSecondary: "#B4DA55",
|
|
||||||
fgTertiary: "#7E9F2A",
|
fgTertiary: "#7E9F2A",
|
||||||
primary: "#ead500",
|
primary: "#ead500",
|
||||||
primaryDim: "#C1B210",
|
primaryDim: "#C1B210",
|
||||||
accent: "#28A745",
|
accent: "#28A745",
|
||||||
accentDim: "#1E7E34",
|
accentDim: "#1E7E34",
|
||||||
error: "#EE5237",
|
error: "#EE5237",
|
||||||
warning: "#FFC107",
|
warning: "#FFC107",
|
||||||
success: "#28A745",
|
success: "#28A745",
|
||||||
info: "#17A2B8",
|
info: "#17A2B8",
|
||||||
},
|
},
|
||||||
{
|
match: {
|
||||||
name: "match",
|
bg: "#071014",
|
||||||
bg: "#071014",
|
bgSecondary: "#0A181E",
|
||||||
bgSecondary: "#0A181E",
|
bgTertiary: "#112A34",
|
||||||
bgTertiary: "#112A34",
|
fg: "#ebeaeb",
|
||||||
fg: "#ebeaeb",
|
fgSecondary: "#BDBDBD",
|
||||||
fgSecondary: "#BDBDBD",
|
|
||||||
fgTertiary: "#A2A2A2",
|
fgTertiary: "#A2A2A2",
|
||||||
primary: "#fda827",
|
primary: "#fda827",
|
||||||
primaryDim: "#C78420",
|
primaryDim: "#C78420",
|
||||||
accent: "#277CFD",
|
accent: "#277CFD",
|
||||||
accentDim: "#1F60C1",
|
accentDim: "#1F60C1",
|
||||||
error: "#F14426",
|
error: "#F14426",
|
||||||
warning: "#FFC107",
|
warning: "#FFC107",
|
||||||
success: "#28A745",
|
success: "#28A745",
|
||||||
info: "#17A2B8",
|
info: "#17A2B8",
|
||||||
},
|
},
|
||||||
{
|
lemon: {
|
||||||
name: "lemon",
|
bg: "#1a171a",
|
||||||
bg: "#1a171a",
|
bgSecondary: "#2E272E",
|
||||||
bgSecondary: "#2E272E",
|
bgTertiary: "#443844",
|
||||||
bgTertiary: "#443844",
|
fg: "#E6E2DC",
|
||||||
fg: "#E6E2DC",
|
fgSecondary: "#B2ACA1",
|
||||||
fgSecondary: "#B2ACA1",
|
|
||||||
fgTertiary: "#968F82",
|
fgTertiary: "#968F82",
|
||||||
primary: "#f5c737",
|
primary: "#f5c737",
|
||||||
primaryDim: "#C29D2F",
|
primaryDim: "#C29D2F",
|
||||||
accent: "#277CFD",
|
accent: "#277CFD",
|
||||||
accentDim: "#1F60C1",
|
accentDim: "#1F60C1",
|
||||||
error: "#F14426",
|
error: "#F14426",
|
||||||
warning: "#FFC107",
|
warning: "#FFC107",
|
||||||
success: "#28A745",
|
success: "#28A745",
|
||||||
info: "#17A2B8",
|
info: "#17A2B8",
|
||||||
}
|
}
|
||||||
];
|
};
|
||||||
|
|
||||||
export default themes
|
export default themes
|
||||||
|
|
||||||
themes.forEach((theme) => {
|
Object.entries(themes).forEach(([name, theme]) => {
|
||||||
const selector = `[data-theme="${theme.name}"]`
|
const selector = `[data-theme="${name}"]`
|
||||||
|
|
||||||
globalStyle(selector, {
|
globalStyle(selector, {
|
||||||
vars: {
|
vars: {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "MyWebSite",
|
"name": "Koito",
|
||||||
"short_name": "MySite",
|
"short_name": "Koito",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/web-app-manifest-192x192.png",
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,16 @@ WHERE at.artist_id = $5
|
||||||
ORDER BY l.listened_at DESC
|
ORDER BY l.listened_at DESC
|
||||||
LIMIT $3 OFFSET $4;
|
LIMIT $3 OFFSET $4;
|
||||||
|
|
||||||
|
-- name: GetFirstListenFromArtist :one
|
||||||
|
SELECT
|
||||||
|
l.*
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks_with_title t ON l.track_id = t.id
|
||||||
|
JOIN artist_tracks at ON t.id = at.track_id
|
||||||
|
WHERE at.artist_id = $1
|
||||||
|
ORDER BY l.listened_at ASC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: GetLastListensFromReleasePaginated :many
|
-- name: GetLastListensFromReleasePaginated :many
|
||||||
SELECT
|
SELECT
|
||||||
l.*,
|
l.*,
|
||||||
|
|
@ -42,6 +52,15 @@ WHERE l.listened_at BETWEEN $1 AND $2
|
||||||
ORDER BY l.listened_at DESC
|
ORDER BY l.listened_at DESC
|
||||||
LIMIT $3 OFFSET $4;
|
LIMIT $3 OFFSET $4;
|
||||||
|
|
||||||
|
-- name: GetFirstListenFromRelease :one
|
||||||
|
SELECT
|
||||||
|
l.*
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks t ON l.track_id = t.id
|
||||||
|
WHERE t.release_id = $1
|
||||||
|
ORDER BY l.listened_at ASC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: GetLastListensFromTrackPaginated :many
|
-- name: GetLastListensFromTrackPaginated :many
|
||||||
SELECT
|
SELECT
|
||||||
l.*,
|
l.*,
|
||||||
|
|
@ -55,6 +74,15 @@ WHERE l.listened_at BETWEEN $1 AND $2
|
||||||
ORDER BY l.listened_at DESC
|
ORDER BY l.listened_at DESC
|
||||||
LIMIT $3 OFFSET $4;
|
LIMIT $3 OFFSET $4;
|
||||||
|
|
||||||
|
-- name: GetFirstListenFromTrack :one
|
||||||
|
SELECT
|
||||||
|
l.*
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks t ON l.track_id = t.id
|
||||||
|
WHERE t.id = $1
|
||||||
|
ORDER BY l.listened_at ASC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: CountListens :one
|
-- name: CountListens :one
|
||||||
SELECT COUNT(*) AS total_count
|
SELECT COUNT(*) AS total_count
|
||||||
FROM listens l
|
FROM listens l
|
||||||
|
|
|
||||||
|
|
@ -74,8 +74,8 @@ JOIN artist_releases ar ON r.id = ar.release_id
|
||||||
WHERE ar.artist_id = $1;
|
WHERE ar.artist_id = $1;
|
||||||
|
|
||||||
-- name: AssociateArtistToRelease :exec
|
-- name: AssociateArtistToRelease :exec
|
||||||
INSERT INTO artist_releases (artist_id, release_id)
|
INSERT INTO artist_releases (artist_id, release_id, is_primary)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- name: GetReleasesWithoutImages :many
|
-- name: GetReleasesWithoutImages :many
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ VALUES ($1, $2, $3)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: AssociateArtistToTrack :exec
|
-- name: AssociateArtistToTrack :exec
|
||||||
INSERT INTO artist_tracks (artist_id, track_id)
|
INSERT INTO artist_tracks (artist_id, track_id, is_primary)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- name: GetTrack :one
|
-- name: GetTrack :one
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ If the environment variable is defined without **and** with the suffix at the sa
|
||||||
##### KOITO_DEFAULT_PASSWORD
|
##### KOITO_DEFAULT_PASSWORD
|
||||||
- Default: `changeme`
|
- Default: `changeme`
|
||||||
- Description: The password for the user that is created on first startup. Only applies when running Koito for the first time.
|
- Description: The password for the user that is created on first startup. Only applies when running Koito for the first time.
|
||||||
|
##### KOITO_DEFAULT_THEME
|
||||||
|
- Default: `yuu`
|
||||||
|
- Description: The lowercase name of the default theme to be used by the client. Overridden if a user picks a theme in the theme switcher.
|
||||||
##### KOITO_BIND_ADDR
|
##### KOITO_BIND_ADDR
|
||||||
- Description: The address to bind to. The default blank value is equivalent to `0.0.0.0`.
|
- Description: The address to bind to. The default blank value is equivalent to `0.0.0.0`.
|
||||||
##### KOITO_LISTEN_PORT
|
##### KOITO_LISTEN_PORT
|
||||||
|
|
@ -37,6 +40,9 @@ If the environment variable is defined without **and** with the suffix at the sa
|
||||||
##### KOITO_LOG_LEVEL
|
##### KOITO_LOG_LEVEL
|
||||||
- Default: `info`
|
- Default: `info`
|
||||||
- Description: One of `debug | info | warn | error | fatal`
|
- Description: One of `debug | info | warn | error | fatal`
|
||||||
|
##### KOITO_ARTIST_SEPARATORS_REGEX
|
||||||
|
- Default: `\s+·\s+`
|
||||||
|
- Description: The list of regex patterns Koito will use to separate artist strings, separated by two semicolons (`;;`).
|
||||||
##### KOITO_MUSICBRAINZ_URL
|
##### KOITO_MUSICBRAINZ_URL
|
||||||
- Default: `https://musicbrainz.org`
|
- Default: `https://musicbrainz.org`
|
||||||
- Description: The URL Koito will use to contact MusicBrainz. Replace this value if you have your own MusicBrainz mirror.
|
- Description: The URL Koito will use to contact MusicBrainz. Replace this value if you have your own MusicBrainz mirror.
|
||||||
|
|
@ -63,6 +69,12 @@ If the environment variable is defined without **and** with the suffix at the sa
|
||||||
- Description: Disables Cover Art Archive as a source for finding album images.
|
- Description: Disables Cover Art Archive as a source for finding album images.
|
||||||
##### KOITO_DISABLE_MUSICBRAINZ
|
##### KOITO_DISABLE_MUSICBRAINZ
|
||||||
- Default: `false`
|
- Default: `false`
|
||||||
|
##### KOITO_SUBSONIC_URL
|
||||||
|
- Required: `true` if KOITO_SUBSONIC_PARAMS is set
|
||||||
|
- Description: The URL of your subsonic compatible music server. For example, `https://navidrome.mydomain.com`.
|
||||||
|
##### KOITO_SUBSONIC_PARAMS
|
||||||
|
- Required: `true` if KOITO_SUBSONIC_URL is set
|
||||||
|
- Description: The `u`, `t`, and `s` authentication parameters to use for authenticated requests to your subsonic server, in the format `u=XXX&t=XXX&s=XXX`. An easy way to find them is to open the network tab in the developer tools of your browser of choice and copy them from a request.
|
||||||
##### KOITO_SKIP_IMPORT
|
##### KOITO_SKIP_IMPORT
|
||||||
- Default: `false`
|
- Default: `false`
|
||||||
- Description: Skips running the importer on startup.
|
- Description: Skips running the importer on startup.
|
||||||
|
|
@ -81,4 +93,4 @@ If the environment variable is defined without **and** with the suffix at the sa
|
||||||
- Description: When true, images will be downloaded and cached during imports.
|
- Description: When true, images will be downloaded and cached during imports.
|
||||||
##### KOITO_CORS_ALLOWED_ORIGINS
|
##### KOITO_CORS_ALLOWED_ORIGINS
|
||||||
- Default: No CORS policy
|
- Default: No CORS policy
|
||||||
- Description: A comma separated list of origins to allow CORS requests from. The special value `*` allows CORS requests from all origins.
|
- Description: A comma separated list of origins to allow CORS requests from. The special value `*` allows CORS requests from all origins.
|
||||||
|
|
|
||||||
|
|
@ -107,9 +107,10 @@ func Run(
|
||||||
|
|
||||||
l.Debug().Msg("Engine: Initializing image sources")
|
l.Debug().Msg("Engine: Initializing image sources")
|
||||||
images.Initialize(images.ImageSourceOpts{
|
images.Initialize(images.ImageSourceOpts{
|
||||||
UserAgent: cfg.UserAgent(),
|
UserAgent: cfg.UserAgent(),
|
||||||
EnableCAA: !cfg.CoverArtArchiveDisabled(),
|
EnableCAA: !cfg.CoverArtArchiveDisabled(),
|
||||||
EnableDeezer: !cfg.DeezerDisabled(),
|
EnableDeezer: !cfg.DeezerDisabled(),
|
||||||
|
EnableSubsonic: cfg.SubsonicEnabled(),
|
||||||
})
|
})
|
||||||
l.Info().Msg("Engine: Image sources initialized")
|
l.Info().Msg("Engine: Image sources initialized")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -211,10 +211,8 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http
|
||||||
Time: listenedAt,
|
Time: listenedAt,
|
||||||
UserID: u.ID,
|
UserID: u.ID,
|
||||||
Client: client,
|
Client: client,
|
||||||
}
|
IsNowPlaying: req.ListenType == ListenTypePlayingNow,
|
||||||
|
SkipSaveListen: req.ListenType == ListenTypePlayingNow,
|
||||||
if req.ListenType == ListenTypePlayingNow {
|
|
||||||
opts.SkipSaveListen = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err, shared := sfGroup.Do(buildCaolescingKey(payload), func() (interface{}, error) {
|
_, err, shared := sfGroup.Do(buildCaolescingKey(payload), func() (interface{}, error) {
|
||||||
|
|
|
||||||
77
engine/handlers/manual_scrobble.go
Normal file
77
engine/handlers/manual_scrobble.go
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gabehf/koito/engine/middleware"
|
||||||
|
"github.com/gabehf/koito/internal/db"
|
||||||
|
"github.com/gabehf/koito/internal/logger"
|
||||||
|
"github.com/gabehf/koito/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SubmitListenWithIDHandler(store db.DB) http.HandlerFunc {
|
||||||
|
|
||||||
|
var defaultClientStr = "Koito Web UI"
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
l := logger.FromContext(ctx)
|
||||||
|
|
||||||
|
l.Debug().Msg("SubmitListenWithIDHandler: Got request")
|
||||||
|
|
||||||
|
u := middleware.GetUserFromContext(ctx)
|
||||||
|
if u == nil {
|
||||||
|
l.Debug().Msg("SubmitListenWithIDHandler: Unauthorized request (user context is nil)")
|
||||||
|
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
l.Debug().Msg("SubmitListenWithIDHandler: Failed to parse form")
|
||||||
|
utils.WriteError(w, "form is invalid", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
trackIDStr := r.FormValue("track_id")
|
||||||
|
timestampStr := r.FormValue("unix")
|
||||||
|
client := r.FormValue("client")
|
||||||
|
if client == "" {
|
||||||
|
client = defaultClientStr
|
||||||
|
}
|
||||||
|
|
||||||
|
if trackIDStr == "" || timestampStr == "" {
|
||||||
|
l.Debug().Msg("SubmitListenWithIDHandler: Request is missing required parameters")
|
||||||
|
utils.WriteError(w, "track_id and unix (timestamp) must be provided", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trackID, err := strconv.Atoi(trackIDStr)
|
||||||
|
if err != nil {
|
||||||
|
l.Debug().AnErr("error", err).Msg("SubmitListenWithIDHandler: Invalid track id")
|
||||||
|
utils.WriteError(w, "invalid track_id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unix, err := strconv.ParseInt(timestampStr, 10, 64)
|
||||||
|
if err != nil || time.Now().Unix() < unix {
|
||||||
|
l.Debug().AnErr("error", err).Msg("SubmitListenWithIDHandler: Invalid unix timestamp")
|
||||||
|
utils.WriteError(w, "invalid timestamp", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := time.Unix(unix, 0)
|
||||||
|
err = store.SaveListen(ctx, db.SaveListenOpts{
|
||||||
|
TrackID: int32(trackID),
|
||||||
|
Time: ts,
|
||||||
|
UserID: u.ID,
|
||||||
|
Client: client,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
l.Err(err).Msg("SubmitListenWithIDHandler: Failed to submit listen")
|
||||||
|
utils.WriteError(w, "failed to submit listen", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
engine/handlers/now_playing.go
Normal file
41
engine/handlers/now_playing.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gabehf/koito/internal/db"
|
||||||
|
"github.com/gabehf/koito/internal/logger"
|
||||||
|
"github.com/gabehf/koito/internal/memkv"
|
||||||
|
"github.com/gabehf/koito/internal/models"
|
||||||
|
"github.com/gabehf/koito/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NowPlayingResponse struct {
|
||||||
|
CurrentlyPlaying bool `json:"currently_playing"`
|
||||||
|
Track models.Track `json:"track"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NowPlayingHandler(store db.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
l := logger.FromContext(ctx)
|
||||||
|
|
||||||
|
l.Debug().Msg("NowPlayingHandler: Got request")
|
||||||
|
|
||||||
|
// Hardcoded user id as 1. Not great but it works until (if) multi-user is supported.
|
||||||
|
if trackIdI, ok := memkv.Store.Get("1"); !ok {
|
||||||
|
utils.WriteJSON(w, http.StatusOK, NowPlayingResponse{CurrentlyPlaying: false})
|
||||||
|
} else if trackId, ok := trackIdI.(int32); !ok {
|
||||||
|
l.Debug().Msg("NowPlayingHandler: Failed type assertion for trackIdI")
|
||||||
|
utils.WriteError(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
} else {
|
||||||
|
track, err := store.GetTrack(ctx, db.GetTrackOpts{ID: trackId})
|
||||||
|
if err != nil {
|
||||||
|
l.Error().Err(err).Msg("NowPlayingHandler: Failed to get track from database")
|
||||||
|
utils.WriteError(w, "failed to fetch currently playing track from database", http.StatusInternalServerError)
|
||||||
|
} else {
|
||||||
|
utils.WriteJSON(w, http.StatusOK, NowPlayingResponse{CurrentlyPlaying: true, Track: *track})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
engine/handlers/server_cfg.go
Normal file
18
engine/handlers/server_cfg.go
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gabehf/koito/internal/cfg"
|
||||||
|
"github.com/gabehf/koito/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
DefaultTheme string `json:"default_theme"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCfgHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
utils.WriteJSON(w, http.StatusOK, ServerConfig{DefaultTheme: cfg.DefaultTheme()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -890,3 +891,86 @@ func TestSetPrimaryArtist(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.EqualValues(t, 1, count, "expected only one primary artist for track")
|
assert.EqualValues(t, 1, count, "expected only one primary artist for track")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestManualListen(t *testing.T) {
|
||||||
|
|
||||||
|
t.Run("Submit Listens", doSubmitListens)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// happy
|
||||||
|
formdata := url.Values{}
|
||||||
|
formdata.Set("track_id", "1")
|
||||||
|
formdata.Set("unix", strconv.FormatInt(time.Now().Unix()-60, 10))
|
||||||
|
body := formdata.Encode()
|
||||||
|
resp, err := makeAuthRequest(t, session, "POST", "/apis/web/v1/listen", strings.NewReader(body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
count, _ := store.Count(ctx, `SELECT COUNT(*) FROM listens WHERE track_id = $1`, 1)
|
||||||
|
assert.Equal(t, 2, count)
|
||||||
|
|
||||||
|
// 400
|
||||||
|
formdata.Set("track_id", "1")
|
||||||
|
formdata.Set("unix", strconv.FormatInt(time.Now().Unix()+60, 10))
|
||||||
|
body = formdata.Encode()
|
||||||
|
resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/listen", strings.NewReader(body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNowPlaying(t *testing.T) {
|
||||||
|
|
||||||
|
t.Run("Submit Listens", doSubmitListens)
|
||||||
|
|
||||||
|
// no playing
|
||||||
|
resp, err := http.DefaultClient.Get(host() + "/apis/web/v1/now-playing")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var result handlers.NowPlayingResponse
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||||
|
require.False(t, result.CurrentlyPlaying)
|
||||||
|
|
||||||
|
body := `{
|
||||||
|
"listen_type": "playing_now",
|
||||||
|
"payload": [
|
||||||
|
{
|
||||||
|
"track_metadata": {
|
||||||
|
"additional_info": {
|
||||||
|
"artist_mbids": [
|
||||||
|
"efc787f0-046f-4a60-beff-77b398c8cdf4"
|
||||||
|
],
|
||||||
|
"artist_names": [
|
||||||
|
"さユり"
|
||||||
|
],
|
||||||
|
"duration_ms": 275960,
|
||||||
|
"recording_mbid": "21524d55-b1f8-45d1-b172-976cba447199",
|
||||||
|
"release_group_mbid": "3281e0d9-fa44-4337-a8ce-6f264beeae16",
|
||||||
|
"release_mbid": "eb790e90-0065-4852-b47d-bbeede4aa9fc",
|
||||||
|
"submission_client": "navidrome",
|
||||||
|
"submission_client_version": "0.56.1 (fa2cf362)"
|
||||||
|
},
|
||||||
|
"artist_name": "さユり",
|
||||||
|
"release_name": "酸欠少女",
|
||||||
|
"track_name": "花の塔"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", host()+"/apis/listenbrainz/1/submit-listens", strings.NewReader(body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Add("Authorization", fmt.Sprintf("Token %s", apikey))
|
||||||
|
resp, err = http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
respBytes, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, `{"status": "ok"}`, string(respBytes))
|
||||||
|
|
||||||
|
// yes playing
|
||||||
|
resp, err = http.DefaultClient.Get(host() + "/apis/web/v1/now-playing")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||||
|
require.True(t, result.CurrentlyPlaying)
|
||||||
|
require.Equal(t, "花の塔", result.Track.Title)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ func bindRoutes(
|
||||||
Get("/images/{size}/{filename}", handlers.ImageHandler(db))
|
Get("/images/{size}/{filename}", handlers.ImageHandler(db))
|
||||||
|
|
||||||
r.Route("/apis/web/v1", func(r chi.Router) {
|
r.Route("/apis/web/v1", func(r chi.Router) {
|
||||||
|
r.Get("/config", handlers.GetCfgHandler())
|
||||||
r.Get("/artist", handlers.GetArtistHandler(db))
|
r.Get("/artist", handlers.GetArtistHandler(db))
|
||||||
r.Get("/artists", handlers.GetArtistsForItemHandler(db))
|
r.Get("/artists", handlers.GetArtistsForItemHandler(db))
|
||||||
r.Get("/album", handlers.GetAlbumHandler(db))
|
r.Get("/album", handlers.GetAlbumHandler(db))
|
||||||
|
|
@ -44,6 +45,7 @@ func bindRoutes(
|
||||||
r.Get("/top-artists", handlers.GetTopArtistsHandler(db))
|
r.Get("/top-artists", handlers.GetTopArtistsHandler(db))
|
||||||
r.Get("/listens", handlers.GetListensHandler(db))
|
r.Get("/listens", handlers.GetListensHandler(db))
|
||||||
r.Get("/listen-activity", handlers.GetListenActivityHandler(db))
|
r.Get("/listen-activity", handlers.GetListenActivityHandler(db))
|
||||||
|
r.Get("/now-playing", handlers.NowPlayingHandler(db))
|
||||||
r.Get("/stats", handlers.StatsHandler(db))
|
r.Get("/stats", handlers.StatsHandler(db))
|
||||||
r.Get("/search", handlers.SearchHandler(db))
|
r.Get("/search", handlers.SearchHandler(db))
|
||||||
r.Get("/aliases", handlers.GetAliasesHandler(db))
|
r.Get("/aliases", handlers.GetAliasesHandler(db))
|
||||||
|
|
@ -80,6 +82,7 @@ func bindRoutes(
|
||||||
r.Post("/artists/primary", handlers.SetPrimaryArtistHandler(db))
|
r.Post("/artists/primary", handlers.SetPrimaryArtistHandler(db))
|
||||||
r.Delete("/album", handlers.DeleteAlbumHandler(db))
|
r.Delete("/album", handlers.DeleteAlbumHandler(db))
|
||||||
r.Delete("/track", handlers.DeleteTrackHandler(db))
|
r.Delete("/track", handlers.DeleteTrackHandler(db))
|
||||||
|
r.Post("/listen", handlers.SubmitListenWithIDHandler(db))
|
||||||
r.Delete("/listen", handlers.DeleteListenHandler(db))
|
r.Delete("/listen", handlers.DeleteListenHandler(db))
|
||||||
r.Post("/aliases", handlers.CreateAliasHandler(db))
|
r.Post("/aliases", handlers.CreateAliasHandler(db))
|
||||||
r.Post("/aliases/delete", handlers.DeleteAliasHandler(db))
|
r.Post("/aliases/delete", handlers.DeleteAliasHandler(db))
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) (
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result) < 1 {
|
if len(result) < 1 {
|
||||||
allArtists := slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle))
|
allArtists := slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle, cfg.ArtistSeparators()))
|
||||||
l.Debug().Msgf("Associating artists by artist name(s) %v and track title '%s'", allArtists, opts.TrackTitle)
|
l.Debug().Msgf("Associating artists by artist name(s) %v and track title '%s'", allArtists, opts.TrackTitle)
|
||||||
fallbackMatches, err := matchArtistsByNames(ctx, allArtists, nil, d, opts)
|
fallbackMatches, err := matchArtistsByNames(ctx, allArtists, nil, d, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -180,7 +180,7 @@ func matchArtistsByMBID(ctx context.Context, d db.DB, opts AssociateArtistsOpts,
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(opts.ArtistNames) < 1 {
|
if len(opts.ArtistNames) < 1 {
|
||||||
opts.ArtistNames = slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle))
|
opts.ArtistNames = slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle, cfg.ArtistSeparators()))
|
||||||
}
|
}
|
||||||
|
|
||||||
a, err = resolveAliasOrCreateArtist(ctx, id, opts.ArtistNames, d, opts)
|
a, err = resolveAliasOrCreateArtist(ctx, id, opts.ArtistNames, d, opts)
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,14 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gabehf/koito/internal/db"
|
"github.com/gabehf/koito/internal/db"
|
||||||
"github.com/gabehf/koito/internal/logger"
|
"github.com/gabehf/koito/internal/logger"
|
||||||
"github.com/gabehf/koito/internal/mbz"
|
"github.com/gabehf/koito/internal/mbz"
|
||||||
|
"github.com/gabehf/koito/internal/memkv"
|
||||||
"github.com/gabehf/koito/internal/models"
|
"github.com/gabehf/koito/internal/models"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
@ -56,8 +58,9 @@ type SubmitListenOpts struct {
|
||||||
ReleaseGroupMbzID uuid.UUID
|
ReleaseGroupMbzID uuid.UUID
|
||||||
Time time.Time
|
Time time.Time
|
||||||
|
|
||||||
UserID int32
|
UserID int32
|
||||||
Client string
|
Client string
|
||||||
|
IsNowPlaying bool
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -165,6 +168,14 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.IsNowPlaying {
|
||||||
|
if track.Duration == 0 {
|
||||||
|
memkv.Store.Set(strconv.Itoa(int(opts.UserID)), track.ID)
|
||||||
|
} else {
|
||||||
|
memkv.Store.Set(strconv.Itoa(int(opts.UserID)), track.ID, time.Duration(track.Duration)*time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if opts.SkipSaveListen {
|
if opts.SkipSaveListen {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -190,21 +201,18 @@ func buildArtistStr(artists []*models.Artist) string {
|
||||||
var (
|
var (
|
||||||
// Bracketed feat patterns
|
// Bracketed feat patterns
|
||||||
bracketFeatPatterns = []*regexp.Regexp{
|
bracketFeatPatterns = []*regexp.Regexp{
|
||||||
regexp.MustCompile(`(?i)\(feat\. ([^)]*)\)`),
|
regexp.MustCompile(`(?i)\([fF]eat\. ([^)]*)\)`),
|
||||||
regexp.MustCompile(`(?i)\[feat\. ([^\]]*)\]`),
|
regexp.MustCompile(`(?i)\[[fF]eat\. ([^\]]*)\]`),
|
||||||
}
|
}
|
||||||
// Inline feat (not in brackets)
|
// Inline feat (not in brackets)
|
||||||
inlineFeatPattern = regexp.MustCompile(`(?i)feat\. ([^()\[\]]+)$`)
|
inlineFeatPattern = regexp.MustCompile(`(?i)[fF]eat\. ([^()\[\]]+)$`)
|
||||||
|
|
||||||
// Delimiters only used inside feat. sections
|
// Delimiters only used inside feat. sections
|
||||||
featSplitDelimiters = regexp.MustCompile(`(?i)\s*(?:,|&|and|·)\s*`)
|
featSplitDelimiters = regexp.MustCompile(`(?i)\s*(?:,|&|and|·)\s*`)
|
||||||
|
|
||||||
// Delimiter for separating artists in main string (rare but real usage)
|
|
||||||
mainArtistDotSplitter = regexp.MustCompile(`\s+·\s+`)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParseArtists extracts all contributing artist names from the artist and title strings
|
// ParseArtists extracts all contributing artist names from the artist and title strings
|
||||||
func ParseArtists(artist string, title string) []string {
|
func ParseArtists(artist string, title string, addlSeparators []*regexp.Regexp) []string {
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
var out []string
|
var out []string
|
||||||
|
|
||||||
|
|
@ -219,12 +227,9 @@ func ParseArtists(artist string, title string) []string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foundFeat := false
|
|
||||||
|
|
||||||
// Extract bracketed features from artist
|
// Extract bracketed features from artist
|
||||||
for _, re := range bracketFeatPatterns {
|
for _, re := range bracketFeatPatterns {
|
||||||
if matches := re.FindStringSubmatch(artist); matches != nil {
|
if matches := re.FindStringSubmatch(artist); matches != nil {
|
||||||
foundFeat = true
|
|
||||||
artist = strings.Replace(artist, matches[0], "", 1)
|
artist = strings.Replace(artist, matches[0], "", 1)
|
||||||
for _, name := range featSplitDelimiters.Split(matches[1], -1) {
|
for _, name := range featSplitDelimiters.Split(matches[1], -1) {
|
||||||
add(name)
|
add(name)
|
||||||
|
|
@ -233,7 +238,6 @@ func ParseArtists(artist string, title string) []string {
|
||||||
}
|
}
|
||||||
// Extract inline feat. from artist
|
// Extract inline feat. from artist
|
||||||
if matches := inlineFeatPattern.FindStringSubmatch(artist); matches != nil {
|
if matches := inlineFeatPattern.FindStringSubmatch(artist); matches != nil {
|
||||||
foundFeat = true
|
|
||||||
artist = strings.Replace(artist, matches[0], "", 1)
|
artist = strings.Replace(artist, matches[0], "", 1)
|
||||||
for _, name := range featSplitDelimiters.Split(matches[1], -1) {
|
for _, name := range featSplitDelimiters.Split(matches[1], -1) {
|
||||||
add(name)
|
add(name)
|
||||||
|
|
@ -241,14 +245,19 @@ func ParseArtists(artist string, title string) []string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add base artist(s)
|
// Add base artist(s)
|
||||||
if foundFeat {
|
l1 := len(out)
|
||||||
add(strings.TrimSpace(artist))
|
for _, re := range addlSeparators {
|
||||||
} else {
|
for _, name := range re.Split(artist, -1) {
|
||||||
// Only split on " · " in base artist string
|
if name == artist {
|
||||||
for _, name := range mainArtistDotSplitter.Split(artist, -1) {
|
continue
|
||||||
|
}
|
||||||
add(name)
|
add(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Only add the full artist string if no splitters were matched
|
||||||
|
if l1 == len(out) {
|
||||||
|
add(artist)
|
||||||
|
}
|
||||||
|
|
||||||
// Extract features from title
|
// Extract features from title
|
||||||
for _, re := range bracketFeatPatterns {
|
for _, re := range bracketFeatPatterns {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -167,15 +168,15 @@ func getTestGetenv(resource *dockertest.Resource) func(string) string {
|
||||||
|
|
||||||
func truncateTestData(t *testing.T) {
|
func truncateTestData(t *testing.T) {
|
||||||
err := store.Exec(context.Background(),
|
err := store.Exec(context.Background(),
|
||||||
`TRUNCATE
|
`TRUNCATE
|
||||||
artists,
|
artists,
|
||||||
artist_aliases,
|
artist_aliases,
|
||||||
tracks,
|
tracks,
|
||||||
artist_tracks,
|
artist_tracks,
|
||||||
releases,
|
releases,
|
||||||
artist_releases,
|
artist_releases,
|
||||||
release_aliases,
|
release_aliases,
|
||||||
listens
|
listens
|
||||||
RESTART IDENTITY CASCADE`)
|
RESTART IDENTITY CASCADE`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
@ -184,23 +185,23 @@ func setupTestDataWithMbzIDs(t *testing.T) {
|
||||||
truncateTestData(t)
|
truncateTestData(t)
|
||||||
|
|
||||||
err := store.Exec(context.Background(),
|
err := store.Exec(context.Background(),
|
||||||
`INSERT INTO artists (musicbrainz_id)
|
`INSERT INTO artists (musicbrainz_id)
|
||||||
VALUES ('00000000-0000-0000-0000-000000000001')`)
|
VALUES ('00000000-0000-0000-0000-000000000001')`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = store.Exec(context.Background(),
|
err = store.Exec(context.Background(),
|
||||||
`INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
|
`INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
|
||||||
VALUES (1, 'ATARASHII GAKKO!', 'Testing', true)`)
|
VALUES (1, 'ATARASHII GAKKO!', 'Testing', true)`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = store.Exec(context.Background(),
|
err = store.Exec(context.Background(),
|
||||||
`INSERT INTO releases (musicbrainz_id)
|
`INSERT INTO releases (musicbrainz_id)
|
||||||
VALUES ('00000000-0000-0000-0000-000000000101')`)
|
VALUES ('00000000-0000-0000-0000-000000000101')`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = store.Exec(context.Background(),
|
err = store.Exec(context.Background(),
|
||||||
`INSERT INTO release_aliases (release_id, alias, source, is_primary)
|
`INSERT INTO release_aliases (release_id, alias, source, is_primary)
|
||||||
VALUES (1, 'AG! Calling', 'Testing', true)`)
|
VALUES (1, 'AG! Calling', 'Testing', true)`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = store.Exec(context.Background(),
|
err = store.Exec(context.Background(),
|
||||||
`INSERT INTO artist_releases (artist_id, release_id)
|
`INSERT INTO artist_releases (artist_id, release_id)
|
||||||
VALUES (1, 1)`)
|
VALUES (1, 1)`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = store.Exec(context.Background(),
|
err = store.Exec(context.Background(),
|
||||||
|
|
@ -221,23 +222,23 @@ func setupTestDataSansMbzIDs(t *testing.T) {
|
||||||
truncateTestData(t)
|
truncateTestData(t)
|
||||||
|
|
||||||
err := store.Exec(context.Background(),
|
err := store.Exec(context.Background(),
|
||||||
`INSERT INTO artists (musicbrainz_id)
|
`INSERT INTO artists (musicbrainz_id)
|
||||||
VALUES (NULL)`)
|
VALUES (NULL)`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = store.Exec(context.Background(),
|
err = store.Exec(context.Background(),
|
||||||
`INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
|
`INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
|
||||||
VALUES (1, 'ATARASHII GAKKO!', 'Testing', true)`)
|
VALUES (1, 'ATARASHII GAKKO!', 'Testing', true)`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = store.Exec(context.Background(),
|
err = store.Exec(context.Background(),
|
||||||
`INSERT INTO releases (musicbrainz_id)
|
`INSERT INTO releases (musicbrainz_id)
|
||||||
VALUES (NULL)`)
|
VALUES (NULL)`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = store.Exec(context.Background(),
|
err = store.Exec(context.Background(),
|
||||||
`INSERT INTO release_aliases (release_id, alias, source, is_primary)
|
`INSERT INTO release_aliases (release_id, alias, source, is_primary)
|
||||||
VALUES (1, 'AG! Calling', 'Testing', true)`)
|
VALUES (1, 'AG! Calling', 'Testing', true)`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = store.Exec(context.Background(),
|
err = store.Exec(context.Background(),
|
||||||
`INSERT INTO artist_releases (artist_id, release_id)
|
`INSERT INTO artist_releases (artist_id, release_id)
|
||||||
VALUES (1, 1)`)
|
VALUES (1, 1)`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = store.Exec(context.Background(),
|
err = store.Exec(context.Background(),
|
||||||
|
|
@ -358,10 +359,16 @@ func TestArtistStringParse(t *testing.T) {
|
||||||
// artists in both
|
// artists in both
|
||||||
{"Daft Punk feat. Julian Casablancas", "Instant Crush (feat. Julian Casablancas)"}: {"Daft Punk", "Julian Casablancas"},
|
{"Daft Punk feat. Julian Casablancas", "Instant Crush (feat. Julian Casablancas)"}: {"Daft Punk", "Julian Casablancas"},
|
||||||
{"Paramore (feat. Joy Williams)", "Hate to See Your Heart Break feat. Joy Williams"}: {"Paramore", "Joy Williams"},
|
{"Paramore (feat. Joy Williams)", "Hate to See Your Heart Break feat. Joy Williams"}: {"Paramore", "Joy Williams"},
|
||||||
|
{"MINSU", "오해 금지 (Feat. BIG Naughty)"}: {"MINSU", "BIG Naughty"},
|
||||||
|
{"MINSU", "오해 금지 [Feat. BIG Naughty]"}: {"MINSU", "BIG Naughty"},
|
||||||
|
{"MINSU", "오해 금지 Feat. BIG Naughty"}: {"MINSU", "BIG Naughty"},
|
||||||
|
|
||||||
|
// custom separator
|
||||||
|
{"MIMiNARI//楠木ともり", "眠れない"}: {"MIMiNARI", "楠木ともり"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for in, out := range cases {
|
for in, out := range cases {
|
||||||
artists := catalog.ParseArtists(in.Name, in.Title)
|
artists := catalog.ParseArtists(in.Name, in.Title, []*regexp.Regexp{regexp.MustCompile(`\s*//\s*`), regexp.MustCompile(`\s+·\s+`)})
|
||||||
assert.ElementsMatch(t, out, artists)
|
assert.ElementsMatch(t, out, artists)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,22 @@ func TestSubmitListen_CreateAllNoMbzIDsNoArtistNamesNoReleaseTitle(t *testing.T)
|
||||||
)`, "Madeline Kenney")
|
)`, "Madeline Kenney")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, exists, "expected featured artist to be created")
|
assert.True(t, exists, "expected featured artist to be created")
|
||||||
|
|
||||||
|
// assert that Rat Tally is the primary artist
|
||||||
|
exists, err = store.RowExists(ctx, `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM artist_tracks
|
||||||
|
WHERE artist_id = $1 AND is_primary = $2
|
||||||
|
)`, 1, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists, "expected primary artist to be marked as primary for track")
|
||||||
|
exists, err = store.RowExists(ctx, `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM artist_releases
|
||||||
|
WHERE artist_id = $1 AND is_primary = $2
|
||||||
|
)`, 1, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists, "expected primary artist to be marked as primary for release")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSubmitListen_MatchAllMbzIDs(t *testing.T) {
|
func TestSubmitListen_MatchAllMbzIDs(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package cfg
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -31,9 +32,12 @@ const (
|
||||||
CONFIG_DIR_ENV = "KOITO_CONFIG_DIR"
|
CONFIG_DIR_ENV = "KOITO_CONFIG_DIR"
|
||||||
DEFAULT_USERNAME_ENV = "KOITO_DEFAULT_USERNAME"
|
DEFAULT_USERNAME_ENV = "KOITO_DEFAULT_USERNAME"
|
||||||
DEFAULT_PASSWORD_ENV = "KOITO_DEFAULT_PASSWORD"
|
DEFAULT_PASSWORD_ENV = "KOITO_DEFAULT_PASSWORD"
|
||||||
|
DEFAULT_THEME_ENV = "KOITO_DEFAULT_THEME"
|
||||||
DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER"
|
DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER"
|
||||||
DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE"
|
DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE"
|
||||||
DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ"
|
DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ"
|
||||||
|
SUBSONIC_URL_ENV = "KOITO_SUBSONIC_URL"
|
||||||
|
SUBSONIC_PARAMS_ENV = "KOITO_SUBSONIC_PARAMS"
|
||||||
SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT"
|
SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT"
|
||||||
ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS"
|
ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS"
|
||||||
CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS"
|
CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS"
|
||||||
|
|
@ -42,6 +46,7 @@ const (
|
||||||
IMPORT_BEFORE_UNIX_ENV = "KOITO_IMPORT_BEFORE_UNIX"
|
IMPORT_BEFORE_UNIX_ENV = "KOITO_IMPORT_BEFORE_UNIX"
|
||||||
IMPORT_AFTER_UNIX_ENV = "KOITO_IMPORT_AFTER_UNIX"
|
IMPORT_AFTER_UNIX_ENV = "KOITO_IMPORT_AFTER_UNIX"
|
||||||
FETCH_IMAGES_DURING_IMPORT_ENV = "KOITO_FETCH_IMAGES_DURING_IMPORT"
|
FETCH_IMAGES_DURING_IMPORT_ENV = "KOITO_FETCH_IMAGES_DURING_IMPORT"
|
||||||
|
ARTIST_SEPARATORS_ENV = "KOITO_ARTIST_SEPARATORS_REGEX"
|
||||||
)
|
)
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
|
|
@ -60,9 +65,13 @@ type config struct {
|
||||||
lbzRelayToken string
|
lbzRelayToken string
|
||||||
defaultPw string
|
defaultPw string
|
||||||
defaultUsername string
|
defaultUsername string
|
||||||
|
defaultTheme string
|
||||||
disableDeezer bool
|
disableDeezer bool
|
||||||
disableCAA bool
|
disableCAA bool
|
||||||
disableMusicBrainz bool
|
disableMusicBrainz bool
|
||||||
|
subsonicUrl string
|
||||||
|
subsonicParams string
|
||||||
|
subsonicEnabled bool
|
||||||
skipImport bool
|
skipImport bool
|
||||||
fetchImageDuringImport bool
|
fetchImageDuringImport bool
|
||||||
allowedHosts []string
|
allowedHosts []string
|
||||||
|
|
@ -73,6 +82,7 @@ type config struct {
|
||||||
userAgent string
|
userAgent string
|
||||||
importBefore time.Time
|
importBefore time.Time
|
||||||
importAfter time.Time
|
importAfter time.Time
|
||||||
|
artistSeparators []*regexp.Regexp
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -147,6 +157,12 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
|
||||||
cfg.disableDeezer = parseBool(getenv(DISABLE_DEEZER_ENV))
|
cfg.disableDeezer = parseBool(getenv(DISABLE_DEEZER_ENV))
|
||||||
cfg.disableCAA = parseBool(getenv(DISABLE_COVER_ART_ARCHIVE_ENV))
|
cfg.disableCAA = parseBool(getenv(DISABLE_COVER_ART_ARCHIVE_ENV))
|
||||||
cfg.disableMusicBrainz = parseBool(getenv(DISABLE_MUSICBRAINZ_ENV))
|
cfg.disableMusicBrainz = parseBool(getenv(DISABLE_MUSICBRAINZ_ENV))
|
||||||
|
cfg.subsonicUrl = getenv(SUBSONIC_URL_ENV)
|
||||||
|
cfg.subsonicParams = getenv(SUBSONIC_PARAMS_ENV)
|
||||||
|
cfg.subsonicEnabled = cfg.subsonicUrl != "" && cfg.subsonicParams != ""
|
||||||
|
if cfg.subsonicEnabled && (cfg.subsonicUrl == "" || cfg.subsonicParams == "") {
|
||||||
|
return nil, fmt.Errorf("loadConfig: invalid configuration: both %s and %s must be set in order to use subsonic image fetching", SUBSONIC_URL_ENV, SUBSONIC_PARAMS_ENV)
|
||||||
|
}
|
||||||
cfg.skipImport = parseBool(getenv(SKIP_IMPORT_ENV))
|
cfg.skipImport = parseBool(getenv(SKIP_IMPORT_ENV))
|
||||||
|
|
||||||
cfg.userAgent = fmt.Sprintf("Koito %s (contact@koito.io)", version)
|
cfg.userAgent = fmt.Sprintf("Koito %s (contact@koito.io)", version)
|
||||||
|
|
@ -162,6 +178,8 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
|
||||||
cfg.defaultPw = getenv(DEFAULT_PASSWORD_ENV)
|
cfg.defaultPw = getenv(DEFAULT_PASSWORD_ENV)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg.defaultTheme = getenv(DEFAULT_THEME_ENV)
|
||||||
|
|
||||||
cfg.configDir = getenv(CONFIG_DIR_ENV)
|
cfg.configDir = getenv(CONFIG_DIR_ENV)
|
||||||
if cfg.configDir == "" {
|
if cfg.configDir == "" {
|
||||||
cfg.configDir = "/etc/koito"
|
cfg.configDir = "/etc/koito"
|
||||||
|
|
@ -174,6 +192,18 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
|
||||||
rawCors := getenv(CORS_ORIGINS_ENV)
|
rawCors := getenv(CORS_ORIGINS_ENV)
|
||||||
cfg.allowedOrigins = strings.Split(rawCors, ",")
|
cfg.allowedOrigins = strings.Split(rawCors, ",")
|
||||||
|
|
||||||
|
if getenv(ARTIST_SEPARATORS_ENV) != "" {
|
||||||
|
for pattern := range strings.SplitSeq(getenv(ARTIST_SEPARATORS_ENV), ";;") {
|
||||||
|
regex, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to compile regex pattern %s", pattern)
|
||||||
|
}
|
||||||
|
cfg.artistSeparators = append(cfg.artistSeparators, regex)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cfg.artistSeparators = []*regexp.Regexp{regexp.MustCompile(`\s+·\s+`)}
|
||||||
|
}
|
||||||
|
|
||||||
switch strings.ToLower(getenv(LOG_LEVEL_ENV)) {
|
switch strings.ToLower(getenv(LOG_LEVEL_ENV)) {
|
||||||
case "debug":
|
case "debug":
|
||||||
cfg.logLevel = 0
|
cfg.logLevel = 0
|
||||||
|
|
@ -277,6 +307,12 @@ func DefaultUsername() string {
|
||||||
return globalConfig.defaultUsername
|
return globalConfig.defaultUsername
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DefaultTheme() string {
|
||||||
|
lock.RLock()
|
||||||
|
defer lock.RUnlock()
|
||||||
|
return globalConfig.defaultTheme
|
||||||
|
}
|
||||||
|
|
||||||
func FullImageCacheEnabled() bool {
|
func FullImageCacheEnabled() bool {
|
||||||
lock.RLock()
|
lock.RLock()
|
||||||
defer lock.RUnlock()
|
defer lock.RUnlock()
|
||||||
|
|
@ -301,6 +337,24 @@ func MusicBrainzDisabled() bool {
|
||||||
return globalConfig.disableMusicBrainz
|
return globalConfig.disableMusicBrainz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SubsonicEnabled() bool {
|
||||||
|
lock.RLock()
|
||||||
|
defer lock.RUnlock()
|
||||||
|
return globalConfig.subsonicEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func SubsonicUrl() string {
|
||||||
|
lock.RLock()
|
||||||
|
defer lock.RUnlock()
|
||||||
|
return globalConfig.subsonicUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
func SubsonicParams() string {
|
||||||
|
lock.RLock()
|
||||||
|
defer lock.RUnlock()
|
||||||
|
return globalConfig.subsonicParams
|
||||||
|
}
|
||||||
|
|
||||||
func SkipImport() bool {
|
func SkipImport() bool {
|
||||||
lock.RLock()
|
lock.RLock()
|
||||||
defer lock.RUnlock()
|
defer lock.RUnlock()
|
||||||
|
|
@ -349,3 +403,9 @@ func FetchImagesDuringImport() bool {
|
||||||
defer lock.RUnlock()
|
defer lock.RUnlock()
|
||||||
return globalConfig.fetchImageDuringImport
|
return globalConfig.fetchImageDuringImport
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ArtistSeparators() []*regexp.Regexp {
|
||||||
|
lock.RLock()
|
||||||
|
defer lock.RUnlock()
|
||||||
|
return globalConfig.artistSeparators
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,8 +98,14 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu
|
||||||
return nil, fmt.Errorf("GetAlbum: CountTimeListenedToItem: %w", err)
|
return nil, fmt.Errorf("GetAlbum: CountTimeListenedToItem: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
firstListen, err := d.q.GetFirstListenFromRelease(ctx, ret.ID)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromRelease: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
ret.ListenCount = count
|
ret.ListenCount = count
|
||||||
ret.TimeListened = seconds
|
ret.TimeListened = seconds
|
||||||
|
ret.FirstListen = firstListen.ListenedAt.Unix()
|
||||||
|
|
||||||
return ret, nil
|
return ret, nil
|
||||||
}
|
}
|
||||||
|
|
@ -144,6 +150,7 @@ func (d *Psql) SaveAlbum(ctx context.Context, opts db.SaveAlbumOpts) (*models.Al
|
||||||
err = qtx.AssociateArtistToRelease(ctx, repository.AssociateArtistToReleaseParams{
|
err = qtx.AssociateArtistToRelease(ctx, repository.AssociateArtistToReleaseParams{
|
||||||
ArtistID: artistId,
|
ArtistID: artistId,
|
||||||
ReleaseID: r.ID,
|
ReleaseID: r.ID,
|
||||||
|
IsPrimary: opts.ArtistIDs[0] == artistId,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SaveAlbum: AssociateArtistToRelease: %w", err)
|
return nil, fmt.Errorf("SaveAlbum: AssociateArtistToRelease: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,10 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
|
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
|
||||||
}
|
}
|
||||||
|
firstListen, err := d.q.GetFirstListenFromArtist(ctx, row.ID)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromArtist: %w", err)
|
||||||
|
}
|
||||||
return &models.Artist{
|
return &models.Artist{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
MbzID: row.MusicBrainzID,
|
MbzID: row.MusicBrainzID,
|
||||||
|
|
@ -49,6 +53,7 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
|
||||||
Image: row.Image,
|
Image: row.Image,
|
||||||
ListenCount: count,
|
ListenCount: count,
|
||||||
TimeListened: seconds,
|
TimeListened: seconds,
|
||||||
|
FirstListen: firstListen.ListenedAt.Unix(),
|
||||||
}, nil
|
}, nil
|
||||||
} else if opts.MusicBrainzID != uuid.Nil {
|
} else if opts.MusicBrainzID != uuid.Nil {
|
||||||
l.Debug().Msgf("Fetching artist from DB with MusicBrainz ID %s", opts.MusicBrainzID)
|
l.Debug().Msgf("Fetching artist from DB with MusicBrainz ID %s", opts.MusicBrainzID)
|
||||||
|
|
@ -71,14 +76,19 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
|
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
|
||||||
}
|
}
|
||||||
|
firstListen, err := d.q.GetFirstListenFromArtist(ctx, row.ID)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromArtist: %w", err)
|
||||||
|
}
|
||||||
return &models.Artist{
|
return &models.Artist{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
MbzID: row.MusicBrainzID,
|
MbzID: row.MusicBrainzID,
|
||||||
Name: row.Name,
|
Name: row.Name,
|
||||||
Aliases: row.Aliases,
|
Aliases: row.Aliases,
|
||||||
Image: row.Image,
|
Image: row.Image,
|
||||||
TimeListened: seconds,
|
|
||||||
ListenCount: count,
|
ListenCount: count,
|
||||||
|
TimeListened: seconds,
|
||||||
|
FirstListen: firstListen.ListenedAt.Unix(),
|
||||||
}, nil
|
}, nil
|
||||||
} else if opts.Name != "" {
|
} else if opts.Name != "" {
|
||||||
l.Debug().Msgf("Fetching artist from DB with name '%s'", opts.Name)
|
l.Debug().Msgf("Fetching artist from DB with name '%s'", opts.Name)
|
||||||
|
|
@ -101,6 +111,10 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
|
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
|
||||||
}
|
}
|
||||||
|
firstListen, err := d.q.GetFirstListenFromArtist(ctx, row.ID)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromArtist: %w", err)
|
||||||
|
}
|
||||||
return &models.Artist{
|
return &models.Artist{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
MbzID: row.MusicBrainzID,
|
MbzID: row.MusicBrainzID,
|
||||||
|
|
@ -109,6 +123,7 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
|
||||||
Image: row.Image,
|
Image: row.Image,
|
||||||
ListenCount: count,
|
ListenCount: count,
|
||||||
TimeListened: seconds,
|
TimeListened: seconds,
|
||||||
|
FirstListen: firstListen.ListenedAt.Unix(),
|
||||||
}, nil
|
}, nil
|
||||||
} else {
|
} else {
|
||||||
return nil, errors.New("insufficient information to get artist")
|
return nil, errors.New("insufficient information to get artist")
|
||||||
|
|
|
||||||
|
|
@ -89,8 +89,14 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
|
||||||
return nil, fmt.Errorf("GetTrack: CountTimeListenedToItem: %w", err)
|
return nil, fmt.Errorf("GetTrack: CountTimeListenedToItem: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
firstListen, err := d.q.GetFirstListenFromTrack(ctx, track.ID)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromRelease: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
track.ListenCount = count
|
track.ListenCount = count
|
||||||
track.TimeListened = seconds
|
track.TimeListened = seconds
|
||||||
|
track.FirstListen = firstListen.ListenedAt.Unix()
|
||||||
|
|
||||||
return &track, nil
|
return &track, nil
|
||||||
}
|
}
|
||||||
|
|
@ -132,8 +138,9 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr
|
||||||
// insert associated artists
|
// insert associated artists
|
||||||
for _, aid := range opts.ArtistIDs {
|
for _, aid := range opts.ArtistIDs {
|
||||||
err = qtx.AssociateArtistToTrack(ctx, repository.AssociateArtistToTrackParams{
|
err = qtx.AssociateArtistToTrack(ctx, repository.AssociateArtistToTrackParams{
|
||||||
ArtistID: aid,
|
ArtistID: aid,
|
||||||
TrackID: trackRow.ID,
|
TrackID: trackRow.ID,
|
||||||
|
IsPrimary: opts.ArtistIDs[0] == aid,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SaveTrack: AssociateArtistToTrack: %w", err)
|
return nil, fmt.Errorf("SaveTrack: AssociateArtistToTrack: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,17 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type ImageSource struct {
|
type ImageSource struct {
|
||||||
deezerEnabled bool
|
deezerEnabled bool
|
||||||
deezerC *DeezerClient
|
deezerC *DeezerClient
|
||||||
caaEnabled bool
|
subsonicEnabled bool
|
||||||
|
subsonicC *SubsonicClient
|
||||||
|
caaEnabled bool
|
||||||
}
|
}
|
||||||
type ImageSourceOpts struct {
|
type ImageSourceOpts struct {
|
||||||
UserAgent string
|
UserAgent string
|
||||||
EnableCAA bool
|
EnableCAA bool
|
||||||
EnableDeezer bool
|
EnableDeezer bool
|
||||||
|
EnableSubsonic bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var once sync.Once
|
var once sync.Once
|
||||||
|
|
@ -48,6 +51,10 @@ func Initialize(opts ImageSourceOpts) {
|
||||||
imgsrc.deezerEnabled = true
|
imgsrc.deezerEnabled = true
|
||||||
imgsrc.deezerC = NewDeezerClient()
|
imgsrc.deezerC = NewDeezerClient()
|
||||||
}
|
}
|
||||||
|
if opts.EnableSubsonic {
|
||||||
|
imgsrc.subsonicEnabled = true
|
||||||
|
imgsrc.subsonicC = NewSubsonicClient()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,6 +64,16 @@ func Shutdown() {
|
||||||
|
|
||||||
func GetArtistImage(ctx context.Context, opts ArtistImageOpts) (string, error) {
|
func GetArtistImage(ctx context.Context, opts ArtistImageOpts) (string, error) {
|
||||||
l := logger.FromContext(ctx)
|
l := logger.FromContext(ctx)
|
||||||
|
if imgsrc.subsonicEnabled {
|
||||||
|
img, err := imgsrc.subsonicC.GetArtistImage(ctx, opts.Aliases[0])
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if img != "" {
|
||||||
|
return img, nil
|
||||||
|
}
|
||||||
|
l.Debug().Msg("Could not find artist image from Subsonic")
|
||||||
|
}
|
||||||
if imgsrc.deezerC != nil {
|
if imgsrc.deezerC != nil {
|
||||||
img, err := imgsrc.deezerC.GetArtistImages(ctx, opts.Aliases)
|
img, err := imgsrc.deezerC.GetArtistImages(ctx, opts.Aliases)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -69,6 +86,16 @@ func GetArtistImage(ctx context.Context, opts ArtistImageOpts) (string, error) {
|
||||||
}
|
}
|
||||||
func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) {
|
func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) {
|
||||||
l := logger.FromContext(ctx)
|
l := logger.FromContext(ctx)
|
||||||
|
if imgsrc.subsonicEnabled {
|
||||||
|
img, err := imgsrc.subsonicC.GetAlbumImage(ctx, opts.Artists[0], opts.Album)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if img != "" {
|
||||||
|
return img, nil
|
||||||
|
}
|
||||||
|
l.Debug().Msg("Could not find album cover from Subsonic")
|
||||||
|
}
|
||||||
if imgsrc.caaEnabled {
|
if imgsrc.caaEnabled {
|
||||||
l.Debug().Msg("Attempting to find album image from CoverArtArchive")
|
l.Debug().Msg("Attempting to find album image from CoverArtArchive")
|
||||||
if opts.ReleaseMbzID != nil && *opts.ReleaseMbzID != uuid.Nil {
|
if opts.ReleaseMbzID != nil && *opts.ReleaseMbzID != uuid.Nil {
|
||||||
|
|
|
||||||
137
internal/images/subsonic.go
Normal file
137
internal/images/subsonic.go
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
package images
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/gabehf/koito/internal/cfg"
|
||||||
|
"github.com/gabehf/koito/internal/logger"
|
||||||
|
"github.com/gabehf/koito/queue"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SubsonicClient struct {
|
||||||
|
url string
|
||||||
|
userAgent string
|
||||||
|
authParams string
|
||||||
|
requestQueue *queue.RequestQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubsonicAlbumResponse struct {
|
||||||
|
SubsonicResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
SearchResult3 struct {
|
||||||
|
Album []struct {
|
||||||
|
CoverArt string `json:"coverArt"`
|
||||||
|
} `json:"album"`
|
||||||
|
} `json:"searchResult3"`
|
||||||
|
} `json:"subsonic-response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubsonicArtistResponse struct {
|
||||||
|
SubsonicResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
SearchResult3 struct {
|
||||||
|
Artist []struct {
|
||||||
|
ArtistImageUrl string `json:"artistImageUrl"`
|
||||||
|
} `json:"artist"`
|
||||||
|
} `json:"searchResult3"`
|
||||||
|
} `json:"subsonic-response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
subsonicAlbumSearchFmtStr = "/rest/search3?%s&f=json&query=%s&v=1.13.0&c=koito&artistCount=0&songCount=0&albumCount=1"
|
||||||
|
subsonicArtistSearchFmtStr = "/rest/search3?%s&f=json&query=%s&v=1.13.0&c=koito&artistCount=1&songCount=0&albumCount=0"
|
||||||
|
subsonicCoverArtFmtStr = "/rest/getCoverArt?%s&id=%s&v=1.13.0&c=koito"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewSubsonicClient() *SubsonicClient {
|
||||||
|
ret := new(SubsonicClient)
|
||||||
|
ret.url = cfg.SubsonicUrl()
|
||||||
|
ret.userAgent = cfg.UserAgent()
|
||||||
|
ret.authParams = cfg.SubsonicParams()
|
||||||
|
ret.requestQueue = queue.NewRequestQueue(5, 5)
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SubsonicClient) queue(ctx context.Context, req *http.Request) ([]byte, error) {
|
||||||
|
l := logger.FromContext(ctx)
|
||||||
|
req.Header.Set("User-Agent", c.userAgent)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resultChan := c.requestQueue.Enqueue(func(client *http.Client, done chan<- queue.RequestResult) {
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
l.Debug().Err(err).Str("url", req.RequestURI).Msg("Failed to contact ImageSrc")
|
||||||
|
done <- queue.RequestResult{Err: err}
|
||||||
|
return
|
||||||
|
} else if resp.StatusCode >= 300 || resp.StatusCode < 200 {
|
||||||
|
err = fmt.Errorf("recieved non-ok status from Subsonic: %s", resp.Status)
|
||||||
|
done <- queue.RequestResult{Body: nil, Err: err}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
done <- queue.RequestResult{Body: body, Err: err}
|
||||||
|
})
|
||||||
|
|
||||||
|
result := <-resultChan
|
||||||
|
return result.Body, result.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SubsonicClient) getEntity(ctx context.Context, endpoint string, result any) error {
|
||||||
|
l := logger.FromContext(ctx)
|
||||||
|
url := c.url + endpoint
|
||||||
|
l.Debug().Msgf("Sending request to ImageSrc: GET %s", url)
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getEntity: %w", err)
|
||||||
|
}
|
||||||
|
l.Debug().Msg("Adding ImageSrc request to queue")
|
||||||
|
body, err := c.queue(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
l.Err(err).Msg("Subsonic request failed")
|
||||||
|
return fmt.Errorf("getEntity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(body, result)
|
||||||
|
if err != nil {
|
||||||
|
l.Err(err).Msg("Failed to unmarshal Subsonic response")
|
||||||
|
return fmt.Errorf("getEntity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SubsonicClient) GetAlbumImage(ctx context.Context, artist, album string) (string, error) {
|
||||||
|
l := logger.FromContext(ctx)
|
||||||
|
resp := new(SubsonicAlbumResponse)
|
||||||
|
l.Debug().Msgf("Finding album image for %s from artist %s", album, artist)
|
||||||
|
err := c.getEntity(ctx, fmt.Sprintf(subsonicAlbumSearchFmtStr, c.authParams, url.QueryEscape(artist+" "+album)), resp)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("GetAlbumImage: %v", err)
|
||||||
|
}
|
||||||
|
l.Debug().Any("subsonic_response", resp).Send()
|
||||||
|
if len(resp.SubsonicResponse.SearchResult3.Album) < 1 || resp.SubsonicResponse.SearchResult3.Album[0].CoverArt == "" {
|
||||||
|
return "", fmt.Errorf("GetAlbumImage: failed to get album art")
|
||||||
|
}
|
||||||
|
return cfg.SubsonicUrl() + fmt.Sprintf(subsonicCoverArtFmtStr, c.authParams, url.QueryEscape(resp.SubsonicResponse.SearchResult3.Album[0].CoverArt)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SubsonicClient) GetArtistImage(ctx context.Context, artist string) (string, error) {
|
||||||
|
l := logger.FromContext(ctx)
|
||||||
|
resp := new(SubsonicArtistResponse)
|
||||||
|
l.Debug().Msgf("Finding artist image for %s", artist)
|
||||||
|
err := c.getEntity(ctx, fmt.Sprintf(subsonicArtistSearchFmtStr, c.authParams, url.QueryEscape(artist)), resp)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("GetArtistImage: %v", err)
|
||||||
|
}
|
||||||
|
l.Debug().Any("subsonic_response", resp).Send()
|
||||||
|
if len(resp.SubsonicResponse.SearchResult3.Artist) < 1 || resp.SubsonicResponse.SearchResult3.Artist[0].ArtistImageUrl == "" {
|
||||||
|
return "", fmt.Errorf("GetArtistImage: failed to get artist art")
|
||||||
|
}
|
||||||
|
return resp.SubsonicResponse.SearchResult3.Artist[0].ArtistImageUrl, nil
|
||||||
|
}
|
||||||
110
internal/memkv/memkv.go
Normal file
110
internal/memkv/memkv.go
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
package memkv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type item struct {
|
||||||
|
value interface{}
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type InMemoryStore struct {
|
||||||
|
data map[string]item
|
||||||
|
defaultExpiration time.Duration
|
||||||
|
mu sync.RWMutex
|
||||||
|
stopJanitor chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var Store *InMemoryStore
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Store = NewStore(10 * time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(defaultExpiration time.Duration) *InMemoryStore {
|
||||||
|
s := &InMemoryStore{
|
||||||
|
data: make(map[string]item),
|
||||||
|
defaultExpiration: defaultExpiration,
|
||||||
|
stopJanitor: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
go s.janitor(1 * time.Minute)
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InMemoryStore) Set(key string, value interface{}, expiration ...time.Duration) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
exp := s.defaultExpiration
|
||||||
|
if len(expiration) > 0 {
|
||||||
|
exp = expiration[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresAt time.Time
|
||||||
|
if exp > 0 {
|
||||||
|
expiresAt = time.Now().Add(exp)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.data[key] = item{
|
||||||
|
value: value,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InMemoryStore) Get(key string) (interface{}, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
it, found := s.data[key]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !it.expiresAt.IsZero() && time.Now().After(it.expiresAt) {
|
||||||
|
s.Delete(key)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return it.value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InMemoryStore) Delete(key string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
delete(s.data, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InMemoryStore) janitor(interval time.Duration) {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
s.cleanup()
|
||||||
|
case <-s.stopJanitor:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InMemoryStore) cleanup() {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
for k, it := range s.data {
|
||||||
|
if !it.expiresAt.IsZero() && now.After(it.expiresAt) {
|
||||||
|
delete(s.data, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InMemoryStore) Close() {
|
||||||
|
close(s.stopJanitor)
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ type Album struct {
|
||||||
VariousArtists bool `json:"is_various_artists"`
|
VariousArtists bool `json:"is_various_artists"`
|
||||||
ListenCount int64 `json:"listen_count"`
|
ListenCount int64 `json:"listen_count"`
|
||||||
TimeListened int64 `json:"time_listened"`
|
TimeListened int64 `json:"time_listened"`
|
||||||
|
FirstListen int64 `json:"first_listen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// type SimpleAlbum struct {
|
// type SimpleAlbum struct {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ type Artist struct {
|
||||||
Image *uuid.UUID `json:"image"`
|
Image *uuid.UUID `json:"image"`
|
||||||
ListenCount int64 `json:"listen_count"`
|
ListenCount int64 `json:"listen_count"`
|
||||||
TimeListened int64 `json:"time_listened"`
|
TimeListened int64 `json:"time_listened"`
|
||||||
|
FirstListen int64 `json:"first_listen"`
|
||||||
IsPrimary bool `json:"is_primary,omitempty"`
|
IsPrimary bool `json:"is_primary,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,5 +28,6 @@ type ArtistWithFullAliases struct {
|
||||||
ImageSource string `json:"image_source,omitempty"`
|
ImageSource string `json:"image_source,omitempty"`
|
||||||
ListenCount int64 `json:"listen_count"`
|
ListenCount int64 `json:"listen_count"`
|
||||||
TimeListened int64 `json:"time_listened"`
|
TimeListened int64 `json:"time_listened"`
|
||||||
|
FirstListen int64 `json:"first_listen"`
|
||||||
IsPrimary bool `json:"is_primary,omitempty"`
|
IsPrimary bool `json:"is_primary,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,4 +12,5 @@ type Track struct {
|
||||||
Image *uuid.UUID `json:"image"`
|
Image *uuid.UUID `json:"image"`
|
||||||
AlbumID int32 `json:"album_id"`
|
AlbumID int32 `json:"album_id"`
|
||||||
TimeListened int64 `json:"time_listened"`
|
TimeListened int64 `json:"time_listened"`
|
||||||
|
FirstListen int64 `json:"first_listen"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.29.0
|
// sqlc v1.30.0
|
||||||
// source: alias.sql
|
// source: alias.sql
|
||||||
|
|
||||||
package repository
|
package repository
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.29.0
|
// sqlc v1.30.0
|
||||||
// source: artist.sql
|
// source: artist.sql
|
||||||
|
|
||||||
package repository
|
package repository
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.29.0
|
// sqlc v1.30.0
|
||||||
|
|
||||||
package repository
|
package repository
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.29.0
|
// sqlc v1.30.0
|
||||||
// source: etc.sql
|
// source: etc.sql
|
||||||
|
|
||||||
package repository
|
package repository
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.29.0
|
// sqlc v1.30.0
|
||||||
// source: listen.sql
|
// source: listen.sql
|
||||||
|
|
||||||
package repository
|
package repository
|
||||||
|
|
@ -190,6 +190,73 @@ func (q *Queries) DeleteListen(ctx context.Context, arg DeleteListenParams) erro
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getFirstListenFromArtist = `-- name: GetFirstListenFromArtist :one
|
||||||
|
SELECT
|
||||||
|
l.track_id, l.listened_at, l.client, l.user_id
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks_with_title t ON l.track_id = t.id
|
||||||
|
JOIN artist_tracks at ON t.id = at.track_id
|
||||||
|
WHERE at.artist_id = $1
|
||||||
|
ORDER BY l.listened_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetFirstListenFromArtist(ctx context.Context, artistID int32) (Listen, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getFirstListenFromArtist, artistID)
|
||||||
|
var i Listen
|
||||||
|
err := row.Scan(
|
||||||
|
&i.TrackID,
|
||||||
|
&i.ListenedAt,
|
||||||
|
&i.Client,
|
||||||
|
&i.UserID,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFirstListenFromRelease = `-- name: GetFirstListenFromRelease :one
|
||||||
|
SELECT
|
||||||
|
l.track_id, l.listened_at, l.client, l.user_id
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks t ON l.track_id = t.id
|
||||||
|
WHERE t.release_id = $1
|
||||||
|
ORDER BY l.listened_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetFirstListenFromRelease(ctx context.Context, releaseID int32) (Listen, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getFirstListenFromRelease, releaseID)
|
||||||
|
var i Listen
|
||||||
|
err := row.Scan(
|
||||||
|
&i.TrackID,
|
||||||
|
&i.ListenedAt,
|
||||||
|
&i.Client,
|
||||||
|
&i.UserID,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFirstListenFromTrack = `-- name: GetFirstListenFromTrack :one
|
||||||
|
SELECT
|
||||||
|
l.track_id, l.listened_at, l.client, l.user_id
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks t ON l.track_id = t.id
|
||||||
|
WHERE t.id = $1
|
||||||
|
ORDER BY l.listened_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetFirstListenFromTrack(ctx context.Context, id int32) (Listen, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getFirstListenFromTrack, id)
|
||||||
|
var i Listen
|
||||||
|
err := row.Scan(
|
||||||
|
&i.TrackID,
|
||||||
|
&i.ListenedAt,
|
||||||
|
&i.Client,
|
||||||
|
&i.UserID,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const getLastListensFromArtistPaginated = `-- name: GetLastListensFromArtistPaginated :many
|
const getLastListensFromArtistPaginated = `-- name: GetLastListensFromArtistPaginated :many
|
||||||
SELECT
|
SELECT
|
||||||
l.track_id, l.listened_at, l.client, l.user_id,
|
l.track_id, l.listened_at, l.client, l.user_id,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.29.0
|
// sqlc v1.30.0
|
||||||
|
|
||||||
package repository
|
package repository
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.29.0
|
// sqlc v1.30.0
|
||||||
// source: release.sql
|
// source: release.sql
|
||||||
|
|
||||||
package repository
|
package repository
|
||||||
|
|
@ -14,18 +14,19 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const associateArtistToRelease = `-- name: AssociateArtistToRelease :exec
|
const associateArtistToRelease = `-- name: AssociateArtistToRelease :exec
|
||||||
INSERT INTO artist_releases (artist_id, release_id)
|
INSERT INTO artist_releases (artist_id, release_id, is_primary)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
`
|
`
|
||||||
|
|
||||||
type AssociateArtistToReleaseParams struct {
|
type AssociateArtistToReleaseParams struct {
|
||||||
ArtistID int32
|
ArtistID int32
|
||||||
ReleaseID int32
|
ReleaseID int32
|
||||||
|
IsPrimary bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) AssociateArtistToRelease(ctx context.Context, arg AssociateArtistToReleaseParams) error {
|
func (q *Queries) AssociateArtistToRelease(ctx context.Context, arg AssociateArtistToReleaseParams) error {
|
||||||
_, err := q.db.Exec(ctx, associateArtistToRelease, arg.ArtistID, arg.ReleaseID)
|
_, err := q.db.Exec(ctx, associateArtistToRelease, arg.ArtistID, arg.ReleaseID, arg.IsPrimary)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.29.0
|
// sqlc v1.30.0
|
||||||
// source: search.sql
|
// source: search.sql
|
||||||
|
|
||||||
package repository
|
package repository
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.29.0
|
// sqlc v1.30.0
|
||||||
// source: sessions.sql
|
// source: sessions.sql
|
||||||
|
|
||||||
package repository
|
package repository
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.29.0
|
// sqlc v1.30.0
|
||||||
// source: track.sql
|
// source: track.sql
|
||||||
|
|
||||||
package repository
|
package repository
|
||||||
|
|
@ -13,18 +13,19 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const associateArtistToTrack = `-- name: AssociateArtistToTrack :exec
|
const associateArtistToTrack = `-- name: AssociateArtistToTrack :exec
|
||||||
INSERT INTO artist_tracks (artist_id, track_id)
|
INSERT INTO artist_tracks (artist_id, track_id, is_primary)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
`
|
`
|
||||||
|
|
||||||
type AssociateArtistToTrackParams struct {
|
type AssociateArtistToTrackParams struct {
|
||||||
ArtistID int32
|
ArtistID int32
|
||||||
TrackID int32
|
TrackID int32
|
||||||
|
IsPrimary bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) AssociateArtistToTrack(ctx context.Context, arg AssociateArtistToTrackParams) error {
|
func (q *Queries) AssociateArtistToTrack(ctx context.Context, arg AssociateArtistToTrackParams) error {
|
||||||
_, err := q.db.Exec(ctx, associateArtistToTrack, arg.ArtistID, arg.TrackID)
|
_, err := q.db.Exec(ctx, associateArtistToTrack, arg.ArtistID, arg.TrackID, arg.IsPrimary)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.29.0
|
// sqlc v1.30.0
|
||||||
// source: users.sql
|
// source: users.sql
|
||||||
|
|
||||||
package repository
|
package repository
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.29.0
|
// sqlc v1.30.0
|
||||||
// source: year.sql
|
// source: year.sql
|
||||||
|
|
||||||
package repository
|
package repository
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue