├── .dockerignore
├── .env.sample
├── .eslintrc.js
├── .github
└── workflows
│ ├── ci.yaml
│ ├── helm-repo-index.yaml
│ └── release.yaml
├── .gitignore
├── .prettierignore
├── .releaserc.yml
├── CHANGELOG.md
├── Dockerfile
├── LICENSE
├── README.md
├── assets
└── logo.svg
├── charts
└── listory
│ ├── .helmignore
│ ├── Chart.yaml
│ ├── templates
│ ├── NOTES.txt
│ ├── _helpers.tpl
│ ├── deployment.yaml
│ ├── ingress.yaml
│ ├── secrets-external-db.yaml
│ ├── secrets.yaml
│ ├── service.yaml
│ └── serviceaccount.yaml
│ └── values.yaml
├── docker-compose.prod.yml
├── docker-compose.yml
├── docs
├── listens-report.png
├── recent-listens.png
└── top-genres.png
├── frontend
├── .env.production
├── .gitignore
├── components.json
├── index.html
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
│ ├── favicon.png
│ ├── favicon.svg
│ ├── manifest.json
│ └── robots.txt
├── src
│ ├── App.tsx
│ ├── api
│ │ ├── api.ts
│ │ ├── auth-api.ts
│ │ └── entities
│ │ │ ├── album.ts
│ │ │ ├── api-token.ts
│ │ │ ├── artist.ts
│ │ │ ├── extended-streaming-history-status.ts
│ │ │ ├── genre.ts
│ │ │ ├── listen-report-item.ts
│ │ │ ├── listen-report-options.ts
│ │ │ ├── listen.ts
│ │ │ ├── pagination-options.ts
│ │ │ ├── pagination.ts
│ │ │ ├── refresh-token-response.ts
│ │ │ ├── spotify-extended-streaming-history-item.ts
│ │ │ ├── spotify-info.ts
│ │ │ ├── time-options.ts
│ │ │ ├── time-preset.enum.ts
│ │ │ ├── top-albums-item.ts
│ │ │ ├── top-albums-options.ts
│ │ │ ├── top-artists-item.ts
│ │ │ ├── top-artists-options.ts
│ │ │ ├── top-genres-item.ts
│ │ │ ├── top-genres-options.ts
│ │ │ ├── top-tracks-item.ts
│ │ │ ├── top-tracks-options.ts
│ │ │ ├── track.ts
│ │ │ └── user.ts
│ ├── components
│ │ ├── AuthApiTokens.tsx
│ │ ├── Footer.tsx
│ │ ├── ImportListens.tsx
│ │ ├── LoginFailure.tsx
│ │ ├── LoginLoading.tsx
│ │ ├── NavBar.tsx
│ │ ├── ThemeProvider.tsx
│ │ ├── inputs
│ │ │ └── DateSelect.tsx
│ │ ├── reports
│ │ │ ├── RecentListens.tsx
│ │ │ ├── ReportListens.tsx
│ │ │ ├── ReportTimeOptions.tsx
│ │ │ ├── ReportTopAlbums.tsx
│ │ │ ├── ReportTopArtists.tsx
│ │ │ ├── ReportTopGenres.tsx
│ │ │ ├── ReportTopTracks.tsx
│ │ │ └── TopListItem.tsx
│ │ └── ui
│ │ │ ├── Spinner.tsx
│ │ │ ├── avatar.tsx
│ │ │ ├── badge.tsx
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ ├── code.tsx
│ │ │ ├── dropdown-menu.tsx
│ │ │ ├── label.tsx
│ │ │ ├── navigation-menu.tsx
│ │ │ ├── select.tsx
│ │ │ └── table.tsx
│ ├── hooks
│ │ ├── use-api-client.tsx
│ │ ├── use-api.tsx
│ │ ├── use-async.tsx
│ │ ├── use-auth.tsx
│ │ └── use-query.tsx
│ ├── icons
│ │ ├── Cogwheel.tsx
│ │ ├── Error.tsx
│ │ ├── Import.tsx
│ │ ├── Reload.tsx
│ │ ├── Spinner.tsx
│ │ ├── Spotify.tsx
│ │ └── Trashcan.tsx
│ ├── index.css
│ ├── index.tsx
│ ├── lib
│ │ └── utils.ts
│ ├── react-app-env.d.ts
│ ├── react-files.d.ts
│ ├── setupTests.ts
│ └── util
│ │ ├── capitalizeString.ts
│ │ ├── getMaxCount.ts
│ │ ├── getPaginationItems.ts
│ │ ├── numberToPercent.ts
│ │ └── queryString.ts
├── tailwind.config.js
├── tests
│ └── setup.js
├── tsconfig.json
└── vite.config.js
├── hack
└── build-docker-image.sh
├── nest-cli.json
├── observability
├── grafana
│ └── provisioning
│ │ ├── dashboards
│ │ └── dashboard.yml
│ │ └── datasources
│ │ └── datasource.yml
├── loki
│ └── loki.yaml
├── prometheus
│ └── prometheus.yml
├── promtail
│ └── promtail.yaml
└── tempo
│ └── tempo.yaml
├── package-lock.json
├── package.json
├── renovate.json
├── src
├── app.module.ts
├── auth
│ ├── api-token.entity.ts
│ ├── api-token.repository.ts
│ ├── auth-session.entity.ts
│ ├── auth-session.repository.ts
│ ├── auth.controller.spec.ts
│ ├── auth.controller.ts
│ ├── auth.module.ts
│ ├── auth.service.spec.ts
│ ├── auth.service.ts
│ ├── constants.ts
│ ├── decorators
│ │ ├── auth-access-token.decorator.ts
│ │ └── req-user.decorator.ts
│ ├── dto
│ │ ├── api-token.dto.ts
│ │ ├── create-api-token-request.dto.ts
│ │ ├── login.dto.ts
│ │ ├── new-api-token.dto.ts
│ │ ├── refresh-access-token-response.dto.ts
│ │ └── revoke-api-token-request.dto.ts
│ ├── guards
│ │ └── auth-strategies.guard.ts
│ ├── spotify.filter.ts
│ └── strategies
│ │ ├── access-token.strategy.ts
│ │ ├── api-token.strategy.ts
│ │ ├── refresh-token.strategy.spec.ts
│ │ ├── refresh-token.strategy.ts
│ │ ├── spotify.strategy.ts
│ │ └── strategies.enum.ts
├── config
│ └── config.module.ts
├── console.ts
├── cookie-parser
│ ├── cookie-parser.middleware.ts
│ └── index.ts
├── database
│ ├── database.module.ts
│ ├── entity-repository
│ │ ├── README.md
│ │ ├── entity-repository.decorator.ts
│ │ ├── index.ts
│ │ └── typeorm-repository.module.ts
│ ├── error-codes.ts
│ └── migrations
│ │ ├── 01-CreateUsersTable.ts
│ │ ├── 02-CreateLibraryTables.ts
│ │ ├── 03-CreateListensTable.ts
│ │ ├── 04-CreateAuthSessionsTable.ts
│ │ ├── 05-CreateGenreTables.ts
│ │ ├── 06-AddUpdatedAtColumns.ts
│ │ ├── 07-CreateApiTokenTable.ts
│ │ ├── 08-OptimizeDBIndices.ts
│ │ └── 09-CreateSpotifyImportTables.ts
├── health-check
│ ├── health-check.controller.ts
│ └── health-check.module.ts
├── job-queue
│ └── job-queue.module.ts
├── listens
│ ├── dto
│ │ ├── create-listen.dto.ts
│ │ └── get-listens.dto.ts
│ ├── listen.entity.ts
│ ├── listen.repository.ts
│ ├── listens.controller.spec.ts
│ ├── listens.controller.ts
│ ├── listens.module.ts
│ ├── listens.service.spec.ts
│ └── listens.service.ts
├── logger
│ ├── logger.module.ts
│ └── logger.ts
├── main.ts
├── music-library
│ ├── album.entity.ts
│ ├── album.repository.ts
│ ├── artist.entity.ts
│ ├── artist.repository.ts
│ ├── dto
│ │ ├── create-album.dto.ts
│ │ ├── create-artist.dto.ts
│ │ ├── create-genre.dto.ts
│ │ ├── create-track.dto.ts
│ │ ├── find-album.dto.ts
│ │ ├── find-artist.dto.ts
│ │ ├── find-genre.dto.ts
│ │ ├── find-track.dto.ts
│ │ └── update-artist.dto.ts
│ ├── genre.entity.ts
│ ├── genre.repository.ts
│ ├── music-library.module.ts
│ ├── music-library.service.spec.ts
│ ├── music-library.service.ts
│ ├── track.entity.ts
│ └── track.repository.ts
├── open-telemetry
│ ├── open-telemetry.module.ts
│ ├── sdk.ts
│ ├── url-value-parser.service.spec.ts
│ └── url-value-parser.service.ts
├── reports
│ ├── dto
│ │ ├── get-listen-report.dto.ts
│ │ ├── get-top-albums-report.dto.ts
│ │ ├── get-top-artists-report.dto.ts
│ │ ├── get-top-genres-report.dto.ts
│ │ ├── get-top-tracks-report.dto.ts
│ │ ├── listen-report.dto.ts
│ │ ├── report-time.dto.ts
│ │ ├── top-albums-report.dto.ts
│ │ ├── top-artists-report.dto.ts
│ │ ├── top-genres-report.dto.ts
│ │ └── top-tracks-report.dto.ts
│ ├── interval.d.ts
│ ├── reports.controller.spec.ts
│ ├── reports.controller.ts
│ ├── reports.module.ts
│ ├── reports.service.spec.ts
│ ├── reports.service.ts
│ ├── timePreset.enum.ts
│ └── timeframe.enum.ts
├── sources
│ ├── jobs.ts
│ ├── scheduler.service.ts
│ ├── sources.module.ts
│ └── spotify
│ │ ├── import-extended-streaming-history
│ │ ├── dto
│ │ │ ├── extended-streaming-history-status.dto.ts
│ │ │ ├── import-extended-streaming-history.dto.ts
│ │ │ └── spotify-extended-streaming-history-item.dto.ts
│ │ ├── import.controller.ts
│ │ ├── import.service.ts
│ │ ├── index.ts
│ │ ├── jobs.ts
│ │ ├── listen.entity.ts
│ │ └── listen.repository.ts
│ │ ├── spotify-api
│ │ ├── entities
│ │ │ ├── album-object.ts
│ │ │ ├── artist-object.ts
│ │ │ ├── context-object.ts
│ │ │ ├── external-url-object.ts
│ │ │ ├── paging-object.ts
│ │ │ ├── play-history-object.ts
│ │ │ ├── simplified-album-object.ts
│ │ │ ├── simplified-artist-object.ts
│ │ │ ├── simplified-track-object.ts
│ │ │ └── track-object.ts
│ │ ├── metrics.axios-interceptor.ts
│ │ ├── spotify-api.module.ts
│ │ ├── spotify-api.service.spec.ts
│ │ └── spotify-api.service.ts
│ │ ├── spotify-auth
│ │ ├── spotify-auth.module.ts
│ │ └── spotify-auth.service.ts
│ │ ├── spotify-connection.entity.ts
│ │ ├── spotify-library-details.entity.ts
│ │ ├── spotify.module.ts
│ │ ├── spotify.service.spec.ts
│ │ └── spotify.service.ts
└── users
│ ├── dto
│ └── create-or-update.dto.ts
│ ├── user.entity.ts
│ ├── user.repository.ts
│ ├── users.controller.spec.ts
│ ├── users.controller.ts
│ ├── users.module.ts
│ ├── users.service.spec.ts
│ └── users.service.ts
├── test
├── app.e2e-spec.ts
└── jest-e2e.json
├── tsconfig.build.json
└── tsconfig.json
/.dockerignore:
--------------------------------------------------------------------------------
1 | # Ignore all and then selectivly allow
2 | **/*
3 |
4 | # api
5 | !package.json
6 | !package-lock.json
7 | !nest-cli.json
8 | !tsconfig.build.json
9 | !tsconfig.json
10 |
11 | !src/**/*
12 |
13 | # frontend
14 | !frontend/.env.production
15 | !frontend/package.json
16 | !frontend/package-lock.json
17 | !frontend/postcss.config.js
18 | !frontend/tailwind.config.js
19 | !frontend/tsconfig.json
20 | !frontend/vite.config.js
21 | !frontend/index.html
22 | !frontend/*.d.ts
23 | !frontend/src/**/*
24 | !frontend/public/**/*
25 |
--------------------------------------------------------------------------------
/.env.sample:
--------------------------------------------------------------------------------
1 | SPOTIFY_CLIENT_ID=
2 | SPOTIFY_CLIENT_SECRET=
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ["airbnb-typescript", "prettier"],
3 | parser: "@typescript-eslint/parser",
4 | parserOptions: {
5 | project: "tsconfig.json",
6 | },
7 | plugins: [
8 | "eslint-plugin-import",
9 | "eslint-plugin-jsdoc",
10 | "eslint-plugin-prefer-arrow",
11 | "eslint-plugin-react",
12 | "@typescript-eslint",
13 | ],
14 | rules: {
15 | "import/prefer-default-export": "off",
16 | "class-methods-use-this": "off",
17 | "@typescript-eslint/lines-between-class-members": [
18 | "error",
19 | "always",
20 | { exceptAfterSingleLine: true },
21 | ],
22 | "@typescript-eslint/return-await": "off",
23 | "import/no-cycle": "off",
24 | "no-restricted-syntax": [
25 | "error",
26 | {
27 | selector: "ForInStatement",
28 | message:
29 | "for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array.",
30 | },
31 | {
32 | selector: "LabeledStatement",
33 | message:
34 | "Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand.",
35 | },
36 | {
37 | selector: "WithStatement",
38 | message:
39 | "`with` is disallowed in strict mode because it makes code impossible to predict and optimize.",
40 | },
41 | ],
42 | },
43 | };
44 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yaml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | workflow_call:
6 |
7 | jobs:
8 | api:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Checkout
12 | uses: actions/checkout@v4
13 |
14 | - name: Setup Node.js
15 | uses: actions/setup-node@v4
16 | with:
17 | node-version: 20
18 |
19 | - run: npm ci
20 | - run: npm run build
21 | - run: npm run lint:api
22 | - run: npm run test:cov
23 |
24 | - name: Upload coverage results to codecov
25 | uses: codecov/codecov-action@v3
26 | with:
27 | flags: unittests,api
28 |
29 | frontend:
30 | runs-on: ubuntu-latest
31 | steps:
32 | - name: Checkout
33 | uses: actions/checkout@v4
34 |
35 | - name: Setup Node.js
36 | uses: actions/setup-node@v4
37 | with:
38 | node-version: 20
39 |
40 | - run: npm ci
41 |
42 | - run: npm ci
43 | working-directory: frontend
44 |
45 | - run: npm run lint:frontend
46 |
47 | - run: npm run build
48 | working-directory: frontend
49 |
50 | - name: Archive code coverage results
51 | uses: actions/upload-artifact@v3
52 | with:
53 | name: code-coverage-report
54 | path: coverage
55 |
--------------------------------------------------------------------------------
/.github/workflows/helm-repo-index.yaml:
--------------------------------------------------------------------------------
1 | name: Helm repo index
2 | on: release
3 | jobs:
4 | publish:
5 | name: Publish helm repo index to gh-pages
6 | runs-on: ubuntu-latest
7 | steps:
8 | - name: Checkout
9 | uses: actions/checkout@v4
10 |
11 | - name: Install chart-releaser
12 | env:
13 | VERSION: 1.0.0-beta.1
14 | run: |
15 | mkdir -p $GITHUB_WORKSPACE/bin
16 |
17 | curl -Lo $GITHUB_WORKSPACE/cr.tar.gz https://github.com/helm/chart-releaser/releases/download/v${VERSION}/chart-releaser_${VERSION}_linux_amd64.tar.gz
18 | tar -xzf $GITHUB_WORKSPACE/cr.tar.gz -C $GITHUB_WORKSPACE/bin cr
19 | chmod +x $GITHUB_WORKSPACE/bin/cr
20 | echo "::add-path::$GITHUB_WORKSPACE/bin"
21 |
22 | - name: Generate index.yaml
23 | run: |
24 | mkdir .helm-index
25 | cr index \
26 | --charts-repo https://apricote.github.io/Listory
27 | --package-path charts
28 | --owner owner
29 | --git-repo Listory
30 | --index-path .helm-index/index.yaml
31 |
32 | - name: Publish to gh-pages
33 | uses: JamesIves/github-pages-deploy-action@releases/v3
34 | with:
35 | ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36 | BRANCH: gh-pages # The branch the action should deploy to.
37 | FOLDER: .helm-index # The folder the action should deploy.
38 |
--------------------------------------------------------------------------------
/.github/workflows/release.yaml:
--------------------------------------------------------------------------------
1 | name: Release
2 | on:
3 | push:
4 | branches:
5 | - main
6 | - alpha
7 | jobs:
8 | tests:
9 | uses: ./.github/workflows/ci.yaml
10 |
11 | release:
12 | name: Release
13 | runs-on: ubuntu-latest
14 | environment: release
15 | needs: tests
16 | steps:
17 | - name: Checkout
18 | uses: actions/checkout@v4
19 | with:
20 | persist-credentials: false
21 |
22 | - name: Setup Docker Buildx
23 | uses: docker/setup-buildx-action@v3
24 | with:
25 | version: "v0.11.2"
26 |
27 | - name: Setup Node.js
28 | uses: actions/setup-node@v4
29 | with:
30 | node-version: 20
31 |
32 | - name: Install Helm
33 | uses: azure/setup-helm@v3
34 | with:
35 | version: "v3.11.1"
36 |
37 | - name: Login to Docker Hub
38 | uses: docker/login-action@v3
39 | with:
40 | username: ${{ secrets.DOCKER_USERNAME }}
41 | password: ${{ secrets.DOCKER_PASSWORD }}
42 |
43 | - name: Install semantic-release
44 | run: npm install -g --legacy-peer-deps semantic-release @semantic-release/git @semantic-release/changelog @semantic-release/exec
45 |
46 | - name: Release
47 | env:
48 | GH_TOKEN: ${{ secrets.GH_TOKEN }}
49 | run: semantic-release
50 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # compiled output
2 | /dist
3 | /build
4 | /node_modules
5 |
6 |
7 | # Logs
8 | logs
9 | *.log
10 | npm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 | lerna-debug.log*
14 |
15 | # OS
16 | .DS_Store
17 |
18 | # Tests
19 | /coverage
20 | /.nyc_output
21 |
22 | # IDEs and editors
23 | /.idea
24 | .project
25 | .classpath
26 | .c9/
27 | *.launch
28 | .settings/
29 | *.sublime-workspace
30 |
31 | # IDE - VSCode
32 | .vscode/*
33 | !.vscode/settings.json
34 | !.vscode/tasks.json
35 | !.vscode/launch.json
36 | !.vscode/extensions.json
37 |
38 | # Credentials
39 | .env
40 |
41 | # Tailwind
42 | frontend/src/tailwind.generated.css
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Prettier will break the helm templating language (`{{ .Value }}` -> `{ { .Value } }`)
2 | charts/listory/templates
--------------------------------------------------------------------------------
/.releaserc.yml:
--------------------------------------------------------------------------------
1 | repositoryUrl: "https://github.com/apricote/Listory"
2 | branches:
3 | - "main"
4 | - { name: "beta", prerelease: true }
5 | - { name: "alpha", prerelease: true }
6 | plugins:
7 | - "@semantic-release/commit-analyzer"
8 | - "@semantic-release/release-notes-generator"
9 | - "@semantic-release/changelog"
10 | - - "@semantic-release/npm"
11 | - npmPublish: false
12 | - - "@semantic-release/exec"
13 | - prepareCmd: |
14 | # Update version in Helm Chart
15 | CHART_FILE=charts/listory/Chart.yaml
16 |
17 | sed -i \
18 | -e "s/version: .*/version: ${nextRelease.version}/g" \
19 | -e "s/appVersion: .*/appVersion: ${nextRelease.version}/g" \
20 | $CHART_FILE
21 |
22 | # Package Helm Chart
23 | mkdir .helm-chart
24 | helm package charts/listory --destination .helm-charts
25 |
26 | # Update version in prod docker compose
27 | sed -i \
28 | -e "s/apricote\/listory:.*/apricote\/listory:${nextRelease.version}/g" \
29 | docker-compose.prod.yml
30 |
31 | # Build docker image after updating all versions, to make sure that we
32 | # don't bust the cache between prepare & publish
33 | hack/build-docker-image.sh prepare ${nextRelease.version}
34 | publishCmd: |
35 | hack/build-docker-image.sh publish ${nextRelease.version}
36 |
37 | - - "@semantic-release/git"
38 | - assets:
39 | - CHANGELOG.md
40 | - package.json
41 | - package-lock.json
42 | - charts/listory/Chart.yaml
43 | - docker-compose.prod.yml
44 | - - "@semantic-release/github"
45 | - assets:
46 | - path: .helm-charts/listory-*.tgz
47 | label: Helm Chart
48 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # syntax=docker/dockerfile:1.12
2 |
3 | ##################
4 | ## common
5 | ##################
6 | FROM --platform=$BUILDPLATFORM node:20-alpine as common
7 |
8 | WORKDIR /app
9 |
10 | ##################
11 | ## build-api
12 | ##################
13 | FROM common as build-api
14 | LABEL stage="build-api"
15 |
16 | COPY *.json /app/
17 | RUN --mount=type=cache,target=/home/root/.npm \
18 | npm set cache /usr/src/app/.npm && \
19 | npm ci
20 |
21 | COPY src/ /app/src/
22 | RUN NODE_ENV=production npm run build
23 |
24 | ##################
25 | ## build-frontend
26 | ##################
27 | FROM common as build-frontend
28 | LABEL stage="build-frontend"
29 |
30 | ARG VERSION=unknown
31 |
32 | WORKDIR /app/frontend
33 |
34 | COPY frontend/package*.json /app/frontend/
35 | RUN --mount=type=cache,target=/home/root/.npm \
36 | npm set cache /usr/src/app/.npm && \
37 | npm ci
38 |
39 |
40 | COPY frontend/ /app/frontend/
41 | RUN NODE_ENV=production npm run build
42 |
43 | ##################
44 | ## app
45 | ##################
46 | FROM node:20-alpine as app
47 |
48 | ARG VERSION=unknown
49 | ARG GIT_COMMIT=unknown
50 |
51 | LABEL org.opencontainers.image.title="listory" \
52 | org.opencontainers.image.version=$VERSION \
53 | org.opencontainers.image.revision=$GIT_COMMIT \
54 | stage="common"
55 |
56 | WORKDIR /app
57 |
58 | ENV NODE_ENV=production
59 |
60 | COPY package.json /app/
61 | COPY package-lock.json /app/
62 | RUN --mount=type=cache,target=/home/root/.npm \
63 | npm set cache /usr/src/app/.npm && \
64 | npm ci --omit=dev
65 |
66 | COPY --from=build-api /app/dist/ /app/dist/
67 | COPY --from=build-frontend /app/frontend/build /app/static/
68 |
69 | CMD ["dist/main"]
70 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Julian Tölle
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/charts/listory/.helmignore:
--------------------------------------------------------------------------------
1 | # Patterns to ignore when building packages.
2 | # This supports shell glob matching, relative path matching, and
3 | # negation (prefixed with !). Only one pattern per line.
4 | .DS_Store
5 | # Common VCS dirs
6 | .git/
7 | .gitignore
8 | .bzr/
9 | .bzrignore
10 | .hg/
11 | .hgignore
12 | .svn/
13 | # Common backup files
14 | *.swp
15 | *.bak
16 | *.tmp
17 | *.orig
18 | *~
19 | # Various IDEs
20 | .project
21 | .idea/
22 | *.tmproj
23 | .vscode/
24 |
--------------------------------------------------------------------------------
/charts/listory/Chart.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v2
2 | name: listory
3 | description: Track your Spotify Listen History
4 |
5 | # A chart can be either an 'application' or a 'library' chart.
6 | #
7 | # Application charts are a collection of templates that can be packaged into versioned archives
8 | # to be deployed.
9 | #
10 | # Library charts provide useful utilities or functions for the chart developer. They're included as
11 | # a dependency of application charts to inject those utilities and functions into the rendering
12 | # pipeline. Library charts do not define any templates and therefore cannot be deployed.
13 | type: application
14 |
15 | # This is the chart version. This version number should be incremented each time you make changes
16 | # to the chart and its templates, including the app version.
17 | version: 1.31.0
18 |
19 | # This is the version number of the application being deployed. This version number should be
20 | # incremented each time you make changes to the application.
21 | appVersion: 1.31.0
22 |
--------------------------------------------------------------------------------
/charts/listory/templates/NOTES.txt:
--------------------------------------------------------------------------------
1 | 1. Get the application URL by running these commands:
2 | {{- if .Values.ingress.enabled }}
3 | {{- range $host := .Values.ingress.hosts }}
4 | {{- range .paths }}
5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ . }}
6 | {{- end }}
7 | {{- end }}
8 | {{- else if contains "NodePort" .Values.service.type }}
9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "listory.fullname" . }})
10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
11 | echo http://$NODE_IP:$NODE_PORT
12 | {{- else if contains "LoadBalancer" .Values.service.type }}
13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available.
14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "listory.fullname" . }}'
15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "listory.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
16 | echo http://$SERVICE_IP:{{ .Values.service.port }}
17 | {{- else if contains "ClusterIP" .Values.service.type }}
18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "listory.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
19 | echo "Visit http://127.0.0.1:8080 to use your application"
20 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:80
21 | {{- end }}
22 |
--------------------------------------------------------------------------------
/charts/listory/templates/_helpers.tpl:
--------------------------------------------------------------------------------
1 | {{/* vim: set filetype=mustache: */}}
2 | {{/*
3 | Expand the name of the chart.
4 | */}}
5 | {{- define "listory.name" -}}
6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
7 | {{- end -}}
8 |
9 | {{/*
10 | Create a default fully qualified app name.
11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
12 | If release name contains chart name it will be used as a full name.
13 | */}}
14 | {{- define "listory.fullname" -}}
15 | {{- if .Values.fullnameOverride -}}
16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
17 | {{- else -}}
18 | {{- $name := default .Chart.Name .Values.nameOverride -}}
19 | {{- if contains $name .Release.Name -}}
20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}}
21 | {{- else -}}
22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
23 | {{- end -}}
24 | {{- end -}}
25 | {{- end -}}
26 |
27 | {{/*
28 | Create chart name and version as used by the chart label.
29 | */}}
30 | {{- define "listory.chart" -}}
31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
32 | {{- end -}}
33 |
34 | {{/*
35 | Common labels
36 | */}}
37 | {{- define "listory.labels" -}}
38 | helm.sh/chart: {{ include "listory.chart" . }}
39 | {{ include "listory.selectorLabels" . }}
40 | {{- if .Chart.AppVersion }}
41 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
42 | {{- end }}
43 | app.kubernetes.io/managed-by: {{ .Release.Service }}
44 | {{- end -}}
45 |
46 | {{/*
47 | Selector labels
48 | */}}
49 | {{- define "listory.selectorLabels" -}}
50 | app.kubernetes.io/name: {{ include "listory.name" . }}
51 | app.kubernetes.io/instance: {{ .Release.Name }}
52 | {{- end -}}
53 |
54 | {{/*
55 | Create the name of the service account to use
56 | */}}
57 | {{- define "listory.serviceAccountName" -}}
58 | {{- if .Values.serviceAccount.create -}}
59 | {{ default (include "listory.fullname" .) .Values.serviceAccount.name }}
60 | {{- else -}}
61 | {{ default "default" .Values.serviceAccount.name }}
62 | {{- end -}}
63 | {{- end -}}
64 |
--------------------------------------------------------------------------------
/charts/listory/templates/ingress.yaml:
--------------------------------------------------------------------------------
1 | {{- if .Values.ingress.enabled -}}
2 | {{- $fullName := include "listory.fullname" . -}}
3 | {{- $svcPort := .Values.service.port -}}
4 | {{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
5 | apiVersion: networking.k8s.io/v1
6 | {{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
7 | apiVersion: networking.k8s.io/v1beta1
8 | {{- else -}}
9 | apiVersion: extensions/v1beta1
10 | {{- end }}
11 | kind: Ingress
12 | metadata:
13 | name: {{ $fullName }}
14 | labels:
15 | {{- include "listory.labels" . | nindent 4 }}
16 | {{- with .Values.ingress.annotations }}
17 | annotations:
18 | {{- toYaml . | nindent 4 }}
19 | {{- end }}
20 | spec:
21 | {{- if ne .Values.ingress.ingressClassName "" }}
22 | ingressClassName: {{ .Values.ingress.ingressClassName }}
23 | {{- end }}
24 | {{- if .Values.ingress.tls }}
25 | tls:
26 | {{- range .Values.ingress.tls }}
27 | - hosts:
28 | {{- range .hosts }}
29 | - {{ . | quote }}
30 | {{- end }}
31 | secretName: {{ .secretName }}
32 | {{- end }}
33 | {{- end }}
34 | {{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion }}
35 | rules:
36 | - host: {{ .Values.ingress.host | quote }}
37 | http:
38 | paths:
39 | - pathType: Prefix
40 | path: /
41 | backend:
42 | service:
43 | name: {{ $fullName }}
44 | port:
45 | number: {{ $svcPort }}
46 | {{- else }}
47 | rules:
48 | - host: {{ .Values.ingress.host | quote }}
49 | http:
50 | paths:
51 | - path: /
52 | backend:
53 | serviceName: {{ $fullName }}
54 | servicePort: {{ $svcPort }}
55 | {{- end }}
56 | {{- end }}
57 |
--------------------------------------------------------------------------------
/charts/listory/templates/secrets-external-db.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Secret
3 | metadata:
4 | name: {{ include "listory.fullname" . }}-external-db
5 | labels:
6 | {{- include "listory.labels" . | nindent 4 }}
7 | type: Opaque
8 | data:
9 | {{- if .Values.externalDatabase.password }}
10 | postgres-password: {{ .Values.externalDatabase.password | b64enc | quote }}
11 | {{- end }}
12 |
--------------------------------------------------------------------------------
/charts/listory/templates/secrets.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Secret
3 | metadata:
4 | name: {{ include "listory.fullname" . }}
5 | labels:
6 | {{- include "listory.labels" . | nindent 4 }}
7 | type: Opaque
8 | data:
9 | spotify-client-secret: {{ .Values.spotify.clientSecret | b64enc | quote }}
10 | jwt-secret: {{ .Values.auth.jwtSecret | b64enc | quote }}
11 |
--------------------------------------------------------------------------------
/charts/listory/templates/service.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: {{ include "listory.fullname" . }}
5 | labels:
6 | {{- include "listory.labels" . | nindent 4 }}
7 | spec:
8 | type: {{ .Values.service.type }}
9 | ports:
10 | - port: {{ .Values.service.port }}
11 | targetPort: http
12 | protocol: TCP
13 | name: http
14 | {{- if .Values.opentelemetry.metrics.enabled }}
15 | - port: {{ .Values.opentelemetry.metrics.port }}
16 | targetPort: metrics
17 | protocol: TCP
18 | name: metrics
19 | {{- end }}
20 | selector:
21 | {{- include "listory.selectorLabels" . | nindent 4 }}
22 |
--------------------------------------------------------------------------------
/charts/listory/templates/serviceaccount.yaml:
--------------------------------------------------------------------------------
1 | {{- if .Values.serviceAccount.create -}}
2 | apiVersion: v1
3 | kind: ServiceAccount
4 | metadata:
5 | name: {{ include "listory.serviceAccountName" . }}
6 | labels:
7 | {{- include "listory.labels" . | nindent 4 }}
8 | {{- with .Values.serviceAccount.annotations }}
9 | annotations:
10 | {{- toYaml . | nindent 4 }}
11 | {{- end }}
12 | {{- end -}}
13 |
--------------------------------------------------------------------------------
/charts/listory/values.yaml:
--------------------------------------------------------------------------------
1 | # Default values for listory.
2 | # This is a YAML-formatted file.
3 | # Declare variables to be passed into your templates.
4 |
5 | replicaCount: 1
6 |
7 | image:
8 | repository: apricote/listory
9 | pullPolicy: IfNotPresent
10 |
11 | imagePullSecrets: []
12 | nameOverride: ""
13 | fullnameOverride: ""
14 |
15 | serviceAccount:
16 | # Specifies whether a service account should be created
17 | create: true
18 | # Annotations to add to the service account
19 | annotations: {}
20 | # The name of the service account to use.
21 | # If not set and create is true, a name is generated using the fullname template
22 | name:
23 |
24 | podSecurityContext:
25 | {}
26 | # fsGroup: 2000
27 |
28 | securityContext:
29 | {}
30 | # capabilities:
31 | # drop:
32 | # - ALL
33 | # readOnlyRootFilesystem: true
34 | # runAsNonRoot: true
35 | # runAsUser: 1000
36 |
37 | service:
38 | type: ClusterIP
39 | port: 3000
40 |
41 | ingress:
42 | enabled: false
43 | host: ""
44 | ingressClassName: ""
45 | annotations:
46 | {}
47 | # kubernetes.io/ingress.class: nginx
48 | # kubernetes.io/tls-acme: "true"
49 | tls: []
50 | # - secretName: chart-example-tls
51 | # hosts:
52 | # - chart-example.local
53 |
54 | resources:
55 | {}
56 | # We usually recommend not to specify default resources and to leave this as a conscious
57 | # choice for the user. This also increases chances charts run on environments with little
58 | # resources, such as Minikube. If you do want to specify resources, uncomment the following
59 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'.
60 | # limits:
61 | # cpu: 100m
62 | # memory: 128Mi
63 | # requests:
64 | # cpu: 100m
65 | # memory: 128Mi
66 |
67 | nodeSelector: {}
68 |
69 | tolerations: []
70 |
71 | affinity: {}
72 |
73 | externalDatabase:
74 | host:
75 | user:
76 | password:
77 | database:
78 |
79 | database:
80 | poolMax: 50
81 |
82 | spotify:
83 | clientId:
84 | clientSecret:
85 | userFilter:
86 |
87 | auth:
88 | jwtSecret:
89 |
90 | sentry:
91 | enabled: false
92 | dsn: ""
93 |
94 | opentelemetry:
95 | metrics:
96 | enabled: false
97 | port: 9464
98 | traces:
99 | enabled: false
100 | otlpEndpoint: ""
101 |
--------------------------------------------------------------------------------
/docker-compose.prod.yml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 |
3 | services:
4 | #####
5 | ## Required services for listory
6 | #####
7 |
8 | db:
9 | image: postgres:16.6
10 | restart: unless-stopped
11 | environment:
12 | POSTGRES_PASSWORD: listory
13 | POSTGRES_USER: listory
14 | POSTGRES_DB: listory
15 | volumes:
16 | - db:/var/lib/postgresql/data
17 | networks:
18 | - db
19 |
20 | api:
21 | image: apricote/listory:1.31.0
22 | restart: unless-stopped
23 | environment:
24 | DB_HOST: db
25 | DB_USERNAME: listory
26 | DB_PASSWORD: listory
27 | DB_DATABASE: listory
28 | JWT_SECRET: listory
29 | APP_URL: "http://localhost:3000"
30 |
31 | # You can add any configuration from the README.md here or in .env,
32 | # make sure to restart the container if you made any changes.
33 | env_file: .env
34 | ports:
35 | - "3000:3000" # API
36 | networks:
37 | - web
38 | - db
39 |
40 | volumes:
41 | db: {}
42 |
43 | networks:
44 | db: {}
45 | web: {}
46 |
--------------------------------------------------------------------------------
/docs/listens-report.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apricote/Listory/eae4df7d0647f86c436a9b4b78232814132d35c6/docs/listens-report.png
--------------------------------------------------------------------------------
/docs/recent-listens.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apricote/Listory/eae4df7d0647f86c436a9b4b78232814132d35c6/docs/recent-listens.png
--------------------------------------------------------------------------------
/docs/top-genres.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apricote/Listory/eae4df7d0647f86c436a9b4b78232814132d35c6/docs/top-genres.png
--------------------------------------------------------------------------------
/frontend/.env.production:
--------------------------------------------------------------------------------
1 | VITE_VERSION=$VERSION
2 |
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/frontend/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "./src/index.css",
9 | "baseColor": "gray",
10 | "cssVariables": false
11 | },
12 | "aliases": {
13 | "components": "src/components",
14 | "utils": "src/lib/utils"
15 | }
16 | }
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Listory - Spotify Listen History
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@listory/frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "author": {
6 | "name": "Julian Tölle",
7 | "email": "julian.toelle97@gmail.com"
8 | },
9 | "license": "MIT",
10 | "dependencies": {
11 | "@radix-ui/react-avatar": "1.1.2",
12 | "@radix-ui/react-dropdown-menu": "2.1.3",
13 | "@radix-ui/react-label": "2.1.1",
14 | "@radix-ui/react-navigation-menu": "1.2.2",
15 | "@radix-ui/react-select": "2.1.3",
16 | "@radix-ui/react-slot": "1.1.1",
17 | "@testing-library/jest-dom": "6.6.3",
18 | "@testing-library/react": "16.1.0",
19 | "@testing-library/user-event": "14.5.2",
20 | "@types/jest": "29.5.14",
21 | "@types/node": "20.17.16",
22 | "@types/react": "18.3.18",
23 | "@types/react-dom": "18.3.5",
24 | "@types/react-router-dom": "5.3.3",
25 | "@types/recharts": "1.8.29",
26 | "@vitejs/plugin-react": "4.3.4",
27 | "autoprefixer": "10.4.20",
28 | "axios": "1.7.9",
29 | "class-variance-authority": "0.7.1",
30 | "clsx": "2.1.1",
31 | "date-fns": "2.30.0",
32 | "eslint-config-react-app": "7.0.1",
33 | "jsdom": "22.1.0",
34 | "lucide-react": "0.468.0",
35 | "npm-run-all": "4.1.5",
36 | "postcss": "8.4.49",
37 | "prettier": "3.4.2",
38 | "react": "18.3.1",
39 | "react-dom": "18.3.1",
40 | "react-files": "3.0.3",
41 | "react-router-dom": "6.28.0",
42 | "recharts": "2.15.0",
43 | "tailwind-merge": "1.14.0",
44 | "tailwindcss": "3.4.16",
45 | "tailwindcss-animate": "1.0.7",
46 | "typescript": "5.7.2",
47 | "vite": "5.4.12",
48 | "vitest": "1.6.0"
49 | },
50 | "scripts": {
51 | "format": "prettier --write \"./*.js\" \"src/**/*.(tsx|ts|css)\"",
52 | "start": "vite",
53 | "build": "vite build",
54 | "serve": "vite preview",
55 | "test": "vitest --passWithNoTests"
56 | },
57 | "eslintConfig": {
58 | "extends": [
59 | "react-app",
60 | "react-app/jest"
61 | ]
62 | },
63 | "browserslist": {
64 | "production": [
65 | ">0.2%",
66 | "not dead",
67 | "not op_mini all"
68 | ],
69 | "development": [
70 | "last 1 chrome version",
71 | "last 1 firefox version",
72 | "last 1 safari version"
73 | ]
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/frontend/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/frontend/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apricote/Listory/eae4df7d0647f86c436a9b4b78232814132d35c6/frontend/public/favicon.png
--------------------------------------------------------------------------------
/frontend/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
35 |
--------------------------------------------------------------------------------
/frontend/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Listory",
3 | "name": "Listory - Track your Spotify Listens",
4 | "icons": [
5 | { "src": "favicon.svg", "sizes": "256x256" },
6 | { "src": "favicon.png", "sizes": "64x64" }
7 | ],
8 | "start_url": ".",
9 | "display": "standalone",
10 | "theme_color": "#48bb78",
11 | "background_color": "#ffffff",
12 | "orientation": "portrait-primary",
13 | "scope": "/"
14 | }
15 |
--------------------------------------------------------------------------------
/frontend/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/frontend/src/api/auth-api.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * These calls are seperate from the others because they are only
3 | * used in the useAuth hook which is used before the useApiClient hook.
4 | *
5 | * They do not use the apiClient/axios.
6 | */
7 |
8 | import { UnauthenticatedError } from "./api";
9 | import { RefreshTokenResponse } from "./entities/refresh-token-response";
10 | import { User } from "./entities/user";
11 |
12 | export const getUsersMe = async (accessToken: string): Promise => {
13 | const res = await fetch(`/api/v1/users/me`, {
14 | headers: new Headers({
15 | "Content-Type": "application/json",
16 | Authorization: `Bearer ${accessToken}`,
17 | }),
18 | });
19 |
20 | switch (res.status) {
21 | case 200: {
22 | break;
23 | }
24 | case 401: {
25 | throw new UnauthenticatedError(`No token or token expired`);
26 | }
27 | default: {
28 | throw new Error(`Unable to getUsersMe: ${res.status}`);
29 | }
30 | }
31 |
32 | const user: User = await res.json();
33 | return user;
34 | };
35 |
36 | export const postAuthTokenRefresh = async (): Promise => {
37 | const res = await fetch(`/api/v1/auth/token/refresh`, {
38 | method: "POST",
39 | headers: new Headers({ "Content-Type": "application/json" }),
40 | });
41 |
42 | switch (res.status) {
43 | case 201: {
44 | break;
45 | }
46 | case 401: {
47 | throw new UnauthenticatedError(`No Refresh Token or token expired`);
48 | }
49 | default: {
50 | throw new Error(`Unable to postAuthTokenRefresh: ${res.status}`);
51 | }
52 | }
53 |
54 | const refreshTokenResponse: RefreshTokenResponse = await res.json();
55 | return refreshTokenResponse;
56 | };
57 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/album.ts:
--------------------------------------------------------------------------------
1 | import { Artist } from "./artist";
2 | import { SpotifyInfo } from "./spotify-info";
3 |
4 | export interface Album {
5 | id: string;
6 | name: string;
7 | spotify?: SpotifyInfo;
8 | artists?: Artist[];
9 | }
10 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/api-token.ts:
--------------------------------------------------------------------------------
1 | export interface ApiToken {
2 | id: string;
3 | description: string;
4 | prefix: string;
5 | createdAt: string;
6 | revokedAt: string | null;
7 | }
8 |
9 | export interface NewApiToken {
10 | id: string;
11 | description: string;
12 | token: string;
13 | createdAt: string;
14 | }
15 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/artist.ts:
--------------------------------------------------------------------------------
1 | import { SpotifyInfo } from "./spotify-info";
2 |
3 | export interface Artist {
4 | id: string;
5 | name: string;
6 | spotify?: SpotifyInfo;
7 | }
8 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/extended-streaming-history-status.ts:
--------------------------------------------------------------------------------
1 | export interface ExtendedStreamingHistoryStatus {
2 | total: number;
3 | imported: number;
4 | }
5 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/genre.ts:
--------------------------------------------------------------------------------
1 | export interface Genre {
2 | id: string;
3 | name: string;
4 | }
5 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/listen-report-item.ts:
--------------------------------------------------------------------------------
1 | export interface ListenReportItem {
2 | date: Date;
3 | count: number;
4 | }
5 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/listen-report-options.ts:
--------------------------------------------------------------------------------
1 | import { TimeOptions } from "./time-options";
2 |
3 | export interface ListenReportOptions {
4 | timeFrame: "day" | "week" | "month" | "year";
5 | time: TimeOptions;
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/listen.ts:
--------------------------------------------------------------------------------
1 | import { Track } from "./track";
2 |
3 | export interface Listen {
4 | id: string;
5 | playedAt: string;
6 | track: Track;
7 | }
8 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/pagination-options.ts:
--------------------------------------------------------------------------------
1 | export interface PaginationOptions {
2 | limit: number;
3 | page: number;
4 | }
5 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/pagination.ts:
--------------------------------------------------------------------------------
1 | export interface Pagination {
2 | /**
3 | * a list of items to be returned
4 | */
5 | items: PaginationObject[];
6 | /**
7 | * associated meta information (e.g., counts)
8 | */
9 | meta: PaginationMeta;
10 | }
11 |
12 | export interface PaginationMeta {
13 | /**
14 | * the amount of items on this specific page
15 | */
16 | itemCount: number;
17 | /**
18 | * the total amount of items
19 | */
20 | totalItems: number;
21 | /**
22 | * the amount of items that were requested per page
23 | */
24 | itemsPerPage: number;
25 | /**
26 | * the total amount of pages in this paginator
27 | */
28 | totalPages: number;
29 | /**
30 | * the current page this paginator "points" to
31 | */
32 | currentPage: number;
33 | }
34 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/refresh-token-response.ts:
--------------------------------------------------------------------------------
1 | export interface RefreshTokenResponse {
2 | accessToken: string;
3 | }
4 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/spotify-extended-streaming-history-item.ts:
--------------------------------------------------------------------------------
1 | export interface SpotifyExtendedStreamingHistoryItem {
2 | ts: string;
3 | spotify_track_uri: string;
4 | }
5 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/spotify-info.ts:
--------------------------------------------------------------------------------
1 | export interface SpotifyInfo {
2 | id: string;
3 | uri: string;
4 | type: string;
5 | href: string;
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/time-options.ts:
--------------------------------------------------------------------------------
1 | import { TimePreset } from "./time-preset.enum";
2 |
3 | export interface TimeOptions {
4 | timePreset: TimePreset;
5 | customTimeStart: Date;
6 | customTimeEnd: Date;
7 | }
8 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/time-preset.enum.ts:
--------------------------------------------------------------------------------
1 | export enum TimePreset {
2 | LAST_7_DAYS = "last_7_days",
3 | LAST_30_DAYS = "last_30_days",
4 | LAST_90_DAYS = "last_90_days",
5 | LAST_180_DAYS = "last_180_days",
6 | LAST_365_DAYS = "last_365_days",
7 | ALL_TIME = "all_time",
8 | CUSTOM = "custom",
9 | }
10 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/top-albums-item.ts:
--------------------------------------------------------------------------------
1 | import { Album } from "./album";
2 |
3 | export interface TopAlbumsItem {
4 | album: Album;
5 | count: number;
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/top-albums-options.ts:
--------------------------------------------------------------------------------
1 | import { TimeOptions } from "./time-options";
2 |
3 | export interface TopAlbumsOptions {
4 | time: TimeOptions;
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/top-artists-item.ts:
--------------------------------------------------------------------------------
1 | import { Artist } from "./artist";
2 |
3 | export interface TopArtistsItem {
4 | artist: Artist;
5 | count: number;
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/top-artists-options.ts:
--------------------------------------------------------------------------------
1 | import { TimeOptions } from "./time-options";
2 |
3 | export interface TopArtistsOptions {
4 | time: TimeOptions;
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/top-genres-item.ts:
--------------------------------------------------------------------------------
1 | import { Genre } from "./genre";
2 | import { TopArtistsItem } from "./top-artists-item";
3 |
4 | export interface TopGenresItem {
5 | genre: Genre;
6 | artists: TopArtistsItem[];
7 | count: number;
8 | }
9 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/top-genres-options.ts:
--------------------------------------------------------------------------------
1 | import { TimeOptions } from "./time-options";
2 |
3 | export interface TopGenresOptions {
4 | time: TimeOptions;
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/top-tracks-item.ts:
--------------------------------------------------------------------------------
1 | import { Track } from "./track";
2 |
3 | export interface TopTracksItem {
4 | track: Track;
5 | count: number;
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/top-tracks-options.ts:
--------------------------------------------------------------------------------
1 | import { TimeOptions } from "./time-options";
2 |
3 | export interface TopTracksOptions {
4 | time: TimeOptions;
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/track.ts:
--------------------------------------------------------------------------------
1 | import { Album } from "./album";
2 | import { Artist } from "./artist";
3 | import { SpotifyInfo } from "./spotify-info";
4 |
5 | export interface Track {
6 | id: string;
7 | name: string;
8 | album: Album;
9 | artists: Artist[];
10 | spotify?: SpotifyInfo;
11 | }
12 |
--------------------------------------------------------------------------------
/frontend/src/api/entities/user.ts:
--------------------------------------------------------------------------------
1 | export interface User {
2 | id: string;
3 | displayName: string;
4 | photo?: string;
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/src/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const REPO_URL = "https://github.com/apricote/Listory";
4 | const CHANGELOG_URL = `${REPO_URL}/blob/main/CHANGELOG.md`;
5 |
6 | const VERSION = import.meta.env.VITE_VERSION || "Unknown";
7 |
8 | export const Footer: React.FC = () => {
9 | return (
10 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/frontend/src/components/LoginFailure.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useQuery } from "../hooks/use-query";
3 |
4 | export const LoginFailure: React.FC = () => {
5 | const query = useQuery();
6 |
7 | const source = query.get("source");
8 | const reason = query.get("reason");
9 |
10 | return (
11 |
15 |
Login Failure
16 |
Something not ideal might be happening.
17 |
18 |
19 | - Source: {source}
20 | - Reason: {reason}
21 |
22 |
23 |
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/frontend/src/components/LoginLoading.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Spinner } from "./ui/Spinner";
3 |
4 | export const LoginLoading: React.FC = () => (
5 |
6 |
7 |
8 | Listory
9 |
10 |
11 | Logging in
12 |
13 |
14 |
15 |
16 | );
17 |
18 | ;
19 |
--------------------------------------------------------------------------------
/frontend/src/components/ThemeProvider.tsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useContext, useEffect, useState } from "react";
2 |
3 | type Theme = "dark" | "light" | "system";
4 |
5 | type ThemeProviderProps = {
6 | children: React.ReactNode;
7 | defaultTheme?: Theme;
8 | storageKey?: string;
9 | };
10 |
11 | type ThemeProviderState = {
12 | theme: Theme;
13 | setTheme: (theme: Theme) => void;
14 | };
15 |
16 | const initialState: ThemeProviderState = {
17 | theme: "system",
18 | setTheme: () => null,
19 | };
20 |
21 | const ThemeProviderContext = createContext(initialState);
22 |
23 | export function ThemeProvider({
24 | children,
25 | defaultTheme = "system",
26 | storageKey = "vite-ui-theme",
27 | ...props
28 | }: ThemeProviderProps) {
29 | const [theme, setTheme] = useState(
30 | () => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
31 | );
32 |
33 | useEffect(() => {
34 | const root = window.document.documentElement;
35 |
36 | root.classList.remove("light", "dark");
37 |
38 | if (theme === "system") {
39 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
40 | .matches
41 | ? "dark"
42 | : "light";
43 |
44 | root.classList.add(systemTheme);
45 | return;
46 | }
47 |
48 | root.classList.add(theme);
49 | }, [theme]);
50 |
51 | const value = {
52 | theme,
53 | setTheme: (theme: Theme) => {
54 | localStorage.setItem(storageKey, theme);
55 | setTheme(theme);
56 | },
57 | };
58 |
59 | return (
60 |
61 | {children}
62 |
63 | );
64 | }
65 |
66 | export const useTheme = () => {
67 | const context = useContext(ThemeProviderContext);
68 |
69 | if (context === undefined)
70 | throw new Error("useTheme must be used within a ThemeProvider");
71 |
72 | return context;
73 | };
74 |
--------------------------------------------------------------------------------
/frontend/src/components/inputs/DateSelect.tsx:
--------------------------------------------------------------------------------
1 | import { format, parse } from "date-fns";
2 | import React from "react";
3 |
4 | const parseDateFromDateInput = (input: string) =>
5 | parse(input, "yyyy-MM-dd", new Date());
6 |
7 | const formatDateForDateInput = (date: Date) => format(date, "yyyy-MM-dd");
8 |
9 | interface DateSelectProps {
10 | label: string;
11 | value: Date;
12 | onChange: (date: Date) => void;
13 | }
14 |
15 | export const DateSelect: React.FC = ({
16 | label,
17 | value,
18 | onChange,
19 | }) => {
20 | return (
21 |
22 |
23 | {
28 | if (e.target.value === "") {
29 | // Firefox includes "reset" buttons in date inputs, which set the value to "",
30 | // is does not make sense to clear the value in our case.
31 | e.target.value = formatDateForDateInput(value);
32 | e.preventDefault();
33 | return;
34 | }
35 | onChange(parseDateFromDateInput(e.target.value));
36 | }}
37 | />
38 |
39 | );
40 | };
41 |
--------------------------------------------------------------------------------
/frontend/src/components/reports/ReportTopAlbums.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo, useState } from "react";
2 | import { Album } from "../../api/entities/album";
3 | import { TimeOptions } from "../../api/entities/time-options";
4 | import { TimePreset } from "../../api/entities/time-preset.enum";
5 | import { useTopAlbums } from "../../hooks/use-api";
6 | import { getMaxCount } from "../../util/getMaxCount";
7 | import { ReportTimeOptions } from "./ReportTimeOptions";
8 | import { Spinner } from "../ui/Spinner";
9 | import { TopListItem } from "./TopListItem";
10 |
11 | export const ReportTopAlbums: React.FC = () => {
12 | const [timeOptions, setTimeOptions] = useState({
13 | timePreset: TimePreset.LAST_90_DAYS,
14 | customTimeStart: new Date("2020"),
15 | customTimeEnd: new Date(),
16 | });
17 |
18 | const options = useMemo(
19 | () => ({
20 | time: timeOptions,
21 | }),
22 | [timeOptions],
23 | );
24 |
25 | const { topAlbums, isLoading } = useTopAlbums(options);
26 |
27 | const reportHasItems = topAlbums.length !== 0;
28 | const maxCount = getMaxCount(topAlbums);
29 |
30 | return (
31 | <>
32 |
33 |
34 | Top Albums
35 |
36 |
37 |
38 |
42 | {isLoading &&
}
43 | {!reportHasItems && !isLoading && (
44 |
45 |
Report is emtpy! :(
46 |
47 | )}
48 | {reportHasItems &&
49 | topAlbums.map(({ album, count }) => (
50 |
56 | ))}
57 |
58 | >
59 | );
60 | };
61 |
62 | const ReportItem: React.FC<{
63 | album: Album;
64 | count: number;
65 | maxCount: number;
66 | }> = ({ album, count, maxCount }) => {
67 | const artists = album.artists?.map((artist) => artist.name).join(", ") || "";
68 |
69 | return (
70 |
77 | );
78 | };
79 |
--------------------------------------------------------------------------------
/frontend/src/components/reports/ReportTopArtists.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo, useState } from "react";
2 | import { TimeOptions } from "../../api/entities/time-options";
3 | import { TimePreset } from "../../api/entities/time-preset.enum";
4 | import { useTopArtists } from "../../hooks/use-api";
5 | import { getMaxCount } from "../../util/getMaxCount";
6 | import { ReportTimeOptions } from "./ReportTimeOptions";
7 | import { Spinner } from "../ui/Spinner";
8 | import { TopListItem } from "./TopListItem";
9 |
10 | export const ReportTopArtists: React.FC = () => {
11 | const [timeOptions, setTimeOptions] = useState({
12 | timePreset: TimePreset.LAST_90_DAYS,
13 | customTimeStart: new Date("2020"),
14 | customTimeEnd: new Date(),
15 | });
16 |
17 | const options = useMemo(
18 | () => ({
19 | time: timeOptions,
20 | }),
21 | [timeOptions],
22 | );
23 |
24 | const { topArtists, isLoading } = useTopArtists(options);
25 |
26 | const reportHasItems = topArtists.length !== 0;
27 | const maxCount = getMaxCount(topArtists);
28 |
29 | return (
30 | <>
31 |
32 |
33 | Top Artists
34 |
35 |
36 |
37 |
41 | {isLoading &&
}
42 |
43 | {!reportHasItems && !isLoading && (
44 |
45 |
Report is emtpy! :(
46 |
47 | )}
48 | {reportHasItems &&
49 | topArtists.map(({ artist, count }) => (
50 |
56 | ))}
57 |
58 | >
59 | );
60 | };
61 |
--------------------------------------------------------------------------------
/frontend/src/components/reports/ReportTopTracks.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo, useState } from "react";
2 | import { TimeOptions } from "../../api/entities/time-options";
3 | import { TimePreset } from "../../api/entities/time-preset.enum";
4 | import { Track } from "../../api/entities/track";
5 | import { useTopTracks } from "../../hooks/use-api";
6 | import { getMaxCount } from "../../util/getMaxCount";
7 | import { ReportTimeOptions } from "./ReportTimeOptions";
8 | import { Spinner } from "../ui/Spinner";
9 | import { TopListItem } from "./TopListItem";
10 |
11 | export const ReportTopTracks: React.FC = () => {
12 | const [timeOptions, setTimeOptions] = useState({
13 | timePreset: TimePreset.LAST_90_DAYS,
14 | customTimeStart: new Date("2020"),
15 | customTimeEnd: new Date(),
16 | });
17 |
18 | const options = useMemo(
19 | () => ({
20 | time: timeOptions,
21 | }),
22 | [timeOptions],
23 | );
24 |
25 | const { topTracks, isLoading } = useTopTracks(options);
26 |
27 | const reportHasItems = topTracks.length !== 0;
28 |
29 | const maxCount = getMaxCount(topTracks);
30 |
31 | return (
32 | <>
33 |
34 |
35 | Top Tracks
36 |
37 |
38 |
39 |
43 | {isLoading &&
}
44 | {!reportHasItems && !isLoading && (
45 |
46 |
Report is emtpy! :(
47 |
48 | )}
49 | {reportHasItems &&
50 | topTracks.map(({ track, count }) => (
51 |
57 | ))}
58 |
59 | >
60 | );
61 | };
62 |
63 | const ReportItem: React.FC<{
64 | track: Track;
65 | count: number;
66 | maxCount: number;
67 | }> = ({ track, count, maxCount }) => {
68 | const artists = track.artists?.map((artist) => artist.name).join(", ") || "";
69 |
70 | return (
71 |
77 | );
78 | };
79 |
--------------------------------------------------------------------------------
/frontend/src/components/reports/TopListItem.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { numberToPercent } from "../../util/numberToPercent";
3 |
4 | export interface TopListItemProps {
5 | title: string;
6 | subTitle?: string | React.ReactNode;
7 | count: number;
8 |
9 | /**
10 | * Highest Number that is displayed in the top list. Used to display a "progress bar".
11 | */
12 | maxCount?: number;
13 | }
14 |
15 | export const TopListItem: React.FC = ({
16 | title,
17 | subTitle,
18 | count,
19 | maxCount,
20 | }) => {
21 | return (
22 |
23 |
24 |
25 |
26 | {title}
27 |
28 | {subTitle &&
{subTitle}
}
29 |
30 |
{count}
31 |
32 | {maxCount && isMaxCountValid(maxCount) && (
33 |
39 | )}
40 |
41 | );
42 | };
43 |
44 | const isMaxCountValid = (maxCount: number) =>
45 | !(Number.isNaN(maxCount) || maxCount === 0);
46 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/Spinner.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { SpinnerIcon } from "../../icons/Spinner";
3 |
4 | interface SpinnerProps {
5 | className?: string;
6 | iconClassName?: string;
7 | }
8 |
9 | export const Spinner: React.FC = ({
10 | className = "",
11 | iconClassName = "h-16 w-16",
12 | }) => (
13 |
14 |
17 |
18 | );
19 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
3 |
4 | import { cn } from "src/lib/utils"
5 |
6 | const Avatar = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 | ))
19 | Avatar.displayName = AvatarPrimitive.Root.displayName
20 |
21 | const AvatarImage = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, ...props }, ref) => (
25 |
30 | ))
31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
32 |
33 | const AvatarFallback = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, ...props }, ref) => (
37 |
45 | ))
46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
47 |
48 | export { Avatar, AvatarImage, AvatarFallback }
49 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { cva, type VariantProps } from "class-variance-authority";
3 |
4 | import { cn } from "src/lib/utils";
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-full border border-gray-200 px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 dark:border-gray-800 dark:focus:ring-gray-300",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-gray-900 text-gray-50 hover:bg-gray-900/80 dark:bg-gray-50 dark:text-gray-900 dark:hover:bg-gray-50/80",
13 | secondary:
14 | "border-transparent bg-gray-100 text-gray-900 hover:bg-gray-100/80 dark:bg-gray-800 dark:text-gray-50 dark:hover:bg-gray-800/80",
15 | destructive:
16 | "border-transparent bg-red-500 text-gray-50 hover:bg-red-500/80 dark:bg-red-900 dark:text-gray-50 dark:hover:bg-red-900/80",
17 | outline: "text-gray-900 dark:text-gray-50",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | },
24 | );
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | );
34 | }
35 |
36 | export { Badge, badgeVariants };
37 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "src/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-gray-900 dark:focus-visible:ring-gray-300",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-gray-900 text-gray-50 hover:bg-gray-900/90 dark:bg-gray-50 dark:text-gray-900 dark:hover:bg-gray-50/90",
14 | destructive:
15 | "bg-red-500 text-gray-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-gray-50 dark:hover:bg-red-900/90",
16 | outline:
17 | "border border-gray-200 bg-white hover:bg-gray-100 hover:text-gray-900 dark:border-gray-800 dark:bg-gray-900 dark:hover:bg-gray-800 dark:hover:text-gray-50",
18 | secondary:
19 | "bg-gray-100 text-gray-900 hover:bg-gray-100/80 dark:bg-gray-800 dark:text-gray-50 dark:hover:bg-gray-800/80",
20 | ghost:
21 | "hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-800 dark:hover:text-gray-50",
22 | link: "text-gray-900 underline-offset-4 hover:underline dark:text-gray-50",
23 | },
24 | size: {
25 | default: "h-10 px-4 py-2",
26 | sm: "h-9 rounded-md px-3",
27 | lg: "h-11 rounded-md px-8",
28 | icon: "h-10 w-10",
29 | },
30 | },
31 | defaultVariants: {
32 | variant: "default",
33 | size: "default",
34 | },
35 | },
36 | );
37 |
38 | export interface ButtonProps
39 | extends React.ButtonHTMLAttributes,
40 | VariantProps {
41 | asChild?: boolean;
42 | }
43 |
44 | const Button = React.forwardRef(
45 | ({ className, variant, size, asChild = false, ...props }, ref) => {
46 | const Comp = asChild ? Slot : "button";
47 | return (
48 |
53 | );
54 | },
55 | );
56 | Button.displayName = "Button";
57 |
58 | export { Button, buttonVariants };
59 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "src/lib/utils";
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ));
18 | Card.displayName = "Card";
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ));
30 | CardHeader.displayName = "CardHeader";
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ));
45 | CardTitle.displayName = "CardTitle";
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ));
57 | CardDescription.displayName = "CardDescription";
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ));
65 | CardContent.displayName = "CardContent";
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ));
77 | CardFooter.displayName = "CardFooter";
78 |
79 | export {
80 | Card,
81 | CardHeader,
82 | CardFooter,
83 | CardTitle,
84 | CardDescription,
85 | CardContent,
86 | };
87 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/code.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren } from "react";
2 |
3 | export const Code: React.FC = ({ children }) => {
4 | return (
5 |
6 | {children}
7 |
8 | );
9 | };
10 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "src/lib/utils"
6 |
7 | const labelVariants = cva(
8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
9 | )
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef &
14 | VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ))
22 | Label.displayName = LabelPrimitive.Root.displayName
23 |
24 | export { Label }
25 |
--------------------------------------------------------------------------------
/frontend/src/hooks/use-async.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState, useTransition } from "react";
2 |
3 | type UseAsync = (
4 | asyncFunction: () => Promise,
5 | initialValue: T,
6 | ) => {
7 | pending: boolean;
8 | value: T;
9 | error: Error | null;
10 | reload: () => Promise;
11 | };
12 |
13 | export const useAsync: UseAsync = (
14 | asyncFunction: () => Promise,
15 | initialValue: T,
16 | ) => {
17 | const [pending, setPending] = useState(false);
18 | const [value, setValue] = useState(initialValue);
19 | const [error, setError] = useState(null);
20 | const [, startTransition] = useTransition();
21 |
22 | // The execute function wraps asyncFunction and
23 | // handles setting state for pending, value, and error.
24 | // useCallback ensures the below useEffect is not called
25 | // on every render, but only if asyncFunction changes.
26 | const execute = useCallback(() => {
27 | startTransition(() => {
28 | setPending(true);
29 | setError(null);
30 | });
31 |
32 | return asyncFunction()
33 | .then((response) => startTransition(() => setValue(response)))
34 | .catch((err) => startTransition(() => setError(err)))
35 | .finally(() => startTransition(() => setPending(false)));
36 | }, [asyncFunction]);
37 |
38 | // Call execute if we want to fire it right away.
39 | // Otherwise execute can be called later, such as
40 | // in an onClick handler.
41 | useEffect(() => {
42 | execute();
43 | }, [execute]);
44 |
45 | return { reload: execute, pending, value, error };
46 | };
47 |
--------------------------------------------------------------------------------
/frontend/src/hooks/use-auth.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | createContext,
3 | useCallback,
4 | useContext,
5 | useEffect,
6 | useState,
7 | } from "react";
8 | import { getUsersMe, postAuthTokenRefresh } from "../api/auth-api";
9 | import { User } from "../api/entities/user";
10 |
11 | interface AuthContext {
12 | isLoaded: boolean;
13 | user: User | null;
14 | accessToken: string;
15 | error: Error | null;
16 | refreshAccessToken: () => Promise;
17 | loginWithSpotifyProps: () => { href: string };
18 | }
19 |
20 | const authContext = createContext(undefined as any as AuthContext);
21 |
22 | export const ProvideAuth: React.FC<{ children: React.ReactNode }> = ({
23 | children,
24 | }) => {
25 | const auth = useProvideAuth();
26 |
27 | return {children};
28 | };
29 |
30 | export function useAuth() {
31 | return useContext(authContext);
32 | }
33 |
34 | function useProvideAuth(): AuthContext {
35 | const [isLoaded, setIsLoaded] = useState(false);
36 | const [user, setUser] = useState(null);
37 | const [accessToken, setAccessToken] = useState("");
38 | const [error, setError] = useState(null);
39 |
40 | const loginWithSpotifyProps = () => ({ href: "/api/v1/auth/spotify" });
41 |
42 | const refreshAccessToken = useCallback(async () => {
43 | try {
44 | const { accessToken: newAccessToken } = await postAuthTokenRefresh();
45 | setAccessToken(newAccessToken);
46 | return newAccessToken;
47 | } catch (err) {
48 | setAccessToken("");
49 | setUser(null);
50 | setIsLoaded(true);
51 | setError(err as Error);
52 |
53 | throw err;
54 | }
55 | }, []);
56 |
57 | useEffect(() => {
58 | refreshAccessToken().catch(() => {
59 | // eslint-disable-next-line no-console
60 | console.log("Unable to refresh access token");
61 | });
62 | }, [refreshAccessToken]);
63 |
64 | useEffect(() => {
65 | if (!accessToken) {
66 | return;
67 | }
68 | async function getUser(token: string) {
69 | const newUser = await getUsersMe(token);
70 | setUser(newUser);
71 | setIsLoaded(true);
72 | }
73 |
74 | getUser(accessToken);
75 | }, [accessToken]);
76 |
77 | return {
78 | isLoaded,
79 | user,
80 | accessToken,
81 | error,
82 | refreshAccessToken,
83 | loginWithSpotifyProps,
84 | };
85 | }
86 |
--------------------------------------------------------------------------------
/frontend/src/hooks/use-query.tsx:
--------------------------------------------------------------------------------
1 | import { useLocation } from "react-router-dom";
2 |
3 | export function useQuery(): URLSearchParams {
4 | return new URLSearchParams(useLocation().search);
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/src/icons/Cogwheel.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export const CogwheelIcon: React.FC> = (
4 | props,
5 | ) => {
6 | return (
7 |
20 | );
21 | };
22 |
--------------------------------------------------------------------------------
/frontend/src/icons/Error.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export const ErrorIcon: React.FC> = (props) => {
4 | return (
5 |
13 | );
14 | };
15 |
--------------------------------------------------------------------------------
/frontend/src/icons/Import.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | export const ImportIcon: React.FC> = (props) => (
3 |
12 | );
13 |
--------------------------------------------------------------------------------
/frontend/src/icons/Reload.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export const ReloadIcon: React.FC> = (props) => {
4 | return (
5 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/frontend/src/icons/Spinner.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export const SpinnerIcon: React.FC> = (props) => {
4 | return (
5 |
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/frontend/src/icons/Spotify.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export const SpotifyLogo: React.FC> = (props) => {
4 | return (
5 |
18 | );
19 | };
20 |
--------------------------------------------------------------------------------
/frontend/src/icons/Trashcan.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export const TrashcanIcon: React.FC> = (
4 | props,
5 | ) => {
6 | return (
7 |
20 | );
21 | };
22 |
23 | export default TrashcanIcon;
24 |
--------------------------------------------------------------------------------
/frontend/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/frontend/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { BrowserRouter } from "react-router-dom";
3 | import { createRoot } from "react-dom/client";
4 | import { App } from "./App";
5 | import { ProvideApiClient } from "./hooks/use-api-client";
6 | import { ProvideAuth } from "./hooks/use-auth";
7 | import "./index.css";
8 | import { ThemeProvider } from "./components/ThemeProvider";
9 |
10 | const root = createRoot(document.getElementById("root")!);
11 |
12 | root.render(
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | ,
24 | );
25 |
--------------------------------------------------------------------------------
/frontend/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/frontend/src/react-files.d.ts:
--------------------------------------------------------------------------------
1 | ////
2 |
3 | declare module "react-files" {
4 | declare const Files: React.FC<
5 | Partial<{
6 | accepts: string[];
7 | children: React.ReactNode;
8 | className: string;
9 | clickable: boolean;
10 | dragActiveClassName: string;
11 | inputProps: unknown;
12 | multiple: boolean;
13 | maxFiles: number;
14 | maxFileSize: number;
15 | minFileSize: number;
16 | name: string;
17 | onChange: (files: ReactFile[]) => void;
18 | onDragEnter: () => void;
19 | onDragLeave: () => void;
20 | onError: (
21 | error: { code: number; message: string },
22 | file: ReactFile
23 | ) => void;
24 | style: object;
25 | }>
26 | >;
27 |
28 | export type ReactFile = File & {
29 | id: string;
30 | extension: string;
31 | sizeReadable: string;
32 | preview: { type: "image"; url: string } | { type: "file" };
33 | };
34 |
35 | export default Files;
36 | }
37 |
--------------------------------------------------------------------------------
/frontend/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import "@testing-library/jest-dom/extend-expect";
6 |
--------------------------------------------------------------------------------
/frontend/src/util/capitalizeString.ts:
--------------------------------------------------------------------------------
1 | export const capitalizeString = (str: string): string => {
2 | const arr = str.split(" ");
3 |
4 | for (var i = 0; i < arr.length; i++) {
5 | arr[i] = arr[i].charAt(0).toUpperCase() + arr[i].slice(1);
6 | }
7 | return arr.join(" ");
8 | };
9 |
--------------------------------------------------------------------------------
/frontend/src/util/getMaxCount.ts:
--------------------------------------------------------------------------------
1 | interface TopListItemEntity {
2 | count: number;
3 | }
4 |
5 | /**
6 | * Get max count for top list. Returns at least 1 to make sure we do not run into issues
7 | * with empty list (would normally return -Infinity) or 0 (could cause divide by zero error).
8 | */
9 | export function getMaxCount(items: TopListItemEntity[]): number {
10 | return Math.max(1, ...items.map(({ count }) => count));
11 | }
12 |
--------------------------------------------------------------------------------
/frontend/src/util/getPaginationItems.ts:
--------------------------------------------------------------------------------
1 | export const getPaginationItems = (
2 | currentPage: number,
3 | totalPages: number,
4 | delta: number = 1,
5 | ): (number | null)[] => {
6 | const left = currentPage - delta;
7 | const right = currentPage + delta;
8 |
9 | const range = Array.from(Array(totalPages))
10 | .map((e, i) => i + 1)
11 | .filter((i) => i === 1 || i === totalPages || (i >= left && i <= right))
12 | .reduce((pages: (number | null)[], page, i) => {
13 | if (pages.length !== 0) {
14 | const prevPage = pages[pages.length - 1];
15 | if (prevPage !== null) {
16 | const diff = page - prevPage;
17 | if (diff > 1) {
18 | pages.push(null);
19 | }
20 | }
21 | }
22 |
23 | pages.push(page);
24 |
25 | return pages;
26 | }, []);
27 |
28 | return range;
29 | };
30 |
--------------------------------------------------------------------------------
/frontend/src/util/numberToPercent.ts:
--------------------------------------------------------------------------------
1 | export const numberToPercent = (ratio: number) =>
2 | ratio.toLocaleString(undefined, {
3 | style: "percent",
4 | minimumFractionDigits: 2,
5 | });
6 |
--------------------------------------------------------------------------------
/frontend/src/util/queryString.ts:
--------------------------------------------------------------------------------
1 | interface QueryParameters {
2 | [x: string]: string;
3 | }
4 | export const qs = (parameters: QueryParameters): string => {
5 | const queryParams = new URLSearchParams();
6 |
7 | Object.entries(parameters).forEach(([key, value]) =>
8 | queryParams.append(key, value),
9 | );
10 |
11 | return queryParams.toString();
12 | };
13 |
--------------------------------------------------------------------------------
/frontend/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const colors = require("tailwindcss/colors");
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | module.exports = {
5 | darkMode: ["class"],
6 | content: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"],
7 | theme: {
8 | colors: {
9 | transparent: "transparent",
10 | current: "currentColor",
11 |
12 | black: "#000",
13 | white: "#fff",
14 |
15 | // Tailwind v1 Colors
16 | gray: {
17 | 50: "#ffffff",
18 | 100: "#f7fafc",
19 | 200: "#edf2f7",
20 | 300: "#e2e8f0",
21 | 400: "#cbd5e0",
22 | 500: "#a0aec0",
23 | 600: "#718096",
24 | 700: "#4a5568",
25 | 800: "#2d3748",
26 | 900: "#1a202c",
27 | 950: "#0C0F12",
28 | },
29 |
30 | green: {
31 | 50: "#FFFFFF",
32 | 100: "#f0fff4",
33 | 200: "#c6f6d5",
34 | 300: "#9ae6b4",
35 | 400: "#68d391",
36 | 500: "#48bb78",
37 | 600: "#38a169",
38 | 700: "#2f855a",
39 | 800: "#276749",
40 | 900: "#22543d",
41 | 950: "#1C4A2F",
42 | },
43 |
44 | yellow: colors.yellow,
45 | teal: colors.teal,
46 | violet: colors.violet,
47 | amber: colors.amber,
48 | },
49 | container: {
50 | center: true,
51 | padding: "2rem",
52 | screens: {
53 | "2xl": "1400px",
54 | },
55 | },
56 | extend: {
57 | keyframes: {
58 | "accordion-down": {
59 | from: { height: 0 },
60 | to: { height: "var(--radix-accordion-content-height)" },
61 | },
62 | "accordion-up": {
63 | from: { height: "var(--radix-accordion-content-height)" },
64 | to: { height: 0 },
65 | },
66 | },
67 | animation: {
68 | "accordion-down": "accordion-down 0.2s ease-out",
69 | "accordion-up": "accordion-up 0.2s ease-out",
70 | },
71 | },
72 | },
73 | plugins: [require("tailwindcss-animate")],
74 | };
75 |
--------------------------------------------------------------------------------
/frontend/tests/setup.js:
--------------------------------------------------------------------------------
1 | import { expect, afterEach } from "vitest";
2 | import { cleanup } from "@testing-library/react";
3 | import matchers from "@testing-library/jest-dom/matchers";
4 |
5 | // extends Vitest's expect method with methods from react-testing-library
6 | expect.extend(matchers);
7 |
8 | // runs a cleanup after each test case (e.g. clearing jsdom)
9 | afterEach(() => {
10 | cleanup();
11 | });
12 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "esModuleInterop": true,
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "noEmit": true,
16 | "jsx": "react"
17 | },
18 | "include": [
19 | "src"
20 | ],
21 | "paths": {
22 | "src/*": ["./src/*"]
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/frontend/vite.config.js:
--------------------------------------------------------------------------------
1 | import path from "path";
2 | import react from "@vitejs/plugin-react";
3 | import { defineConfig } from "vite";
4 |
5 | export default defineConfig(() => {
6 | return {
7 | build: {
8 | outDir: "build",
9 | },
10 | plugins: [react()],
11 | resolve: {
12 | alias: {
13 | src: path.resolve(__dirname, "./src"),
14 | },
15 | },
16 | test: {
17 | globals: true,
18 | environment: "jsdom",
19 | setupFiles: "./tests/setup.js",
20 | },
21 | };
22 | });
23 |
--------------------------------------------------------------------------------
/hack/build-docker-image.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -o pipefail
4 | set -e
5 |
6 | PREPARE_OR_PUBLISH="$1"
7 | VERSION="$2"
8 |
9 | REPO="apricote/listory"
10 |
11 | PLATFORMS="--platform=linux/amd64,linux/arm64"
12 | TAGS="--tag ${REPO}:${VERSION} --tag ${REPO}:latest"
13 | ARGS="--build-arg VERSION=${VERSION} --build-arg GIT_COMMIT=`git rev-parse HEAD`"
14 | CACHE="--cache-from=type=registry,ref=${REPO}:buildcache --cache-to=type=registry,ref=${REPO}:buildcache,mode=max"
15 |
16 |
17 | # We "build" the image twice, once in "prepare" and then again in "publish" stage:
18 | # - Prepare makes sure that the image is buildable
19 | # - Publish utilizes the local cache from prepare stage and pushes the image
20 | PUSH=""
21 | if [ "$PREPARE_OR_PUBLISH" = "publish" ]; then
22 | PUSH="--push"
23 | fi
24 |
25 | docker buildx build $PLATFORMS $TAGS $ARGS $CACHE $PUSH .
26 |
--------------------------------------------------------------------------------
/nest-cli.json:
--------------------------------------------------------------------------------
1 | {
2 | "collection": "@nestjs/schematics",
3 | "sourceRoot": "src"
4 | }
5 |
--------------------------------------------------------------------------------
/observability/grafana/provisioning/dashboards/dashboard.yml:
--------------------------------------------------------------------------------
1 | apiVersion: 1
2 |
3 | providers:
4 | - name: "Prometheus"
5 | orgId: 1
6 | folder: ""
7 | type: file
8 | disableDeletion: false
9 | editable: true
10 | allowUiUpdates: true
11 | options:
12 | path: /etc/grafana/provisioning/dashboards
13 |
--------------------------------------------------------------------------------
/observability/grafana/provisioning/datasources/datasource.yml:
--------------------------------------------------------------------------------
1 | apiVersion: 1
2 |
3 | datasources:
4 | - name: Prometheus
5 | type: prometheus
6 | access: proxy
7 | orgId: 1
8 | url: http://prometheus:9090
9 | basicAuth: false
10 | isDefault: false
11 | version: 1
12 | editable: false
13 |
14 | - name: Tempo
15 | type: tempo
16 | access: proxy
17 | orgId: 1
18 | url: http://tempo:3101
19 | basicAuth: false
20 | isDefault: false
21 | version: 1
22 | editable: true
23 | apiVersion: 1
24 | uid: tempo
25 |
26 | - name: Loki
27 | type: loki
28 | access: proxy
29 | orgId: 1
30 | url: http://loki:3100
31 | basicAuth: false
32 | isDefault: false
33 | version: 1
34 | editable: false
35 | apiVersion: 1
36 | jsonData:
37 | derivedFields:
38 | - datasourceUid: tempo
39 | matcherRegex: '"traceId":"([A-Za-z0-9]+)"'
40 | name: TraceID
41 | url: $${__value.raw}
42 |
--------------------------------------------------------------------------------
/observability/loki/loki.yaml:
--------------------------------------------------------------------------------
1 | auth_enabled: false
2 |
3 | server:
4 | http_listen_port: 3100
5 |
6 | ingester:
7 | lifecycler:
8 | address: 127.0.0.1
9 | ring:
10 | kvstore:
11 | store: inmemory
12 | replication_factor: 1
13 | final_sleep: 0s
14 | chunk_idle_period: 1h # Any chunk not receiving new logs in this time will be flushed
15 | max_chunk_age: 1h # All chunks will be flushed when they hit this age, default is 1h
16 | chunk_target_size: 1048576 # Loki will attempt to build chunks up to 1.5MB, flushing first if chunk_idle_period or max_chunk_age is reached first
17 | chunk_retain_period: 30s # Must be greater than index read cache TTL if using an index cache (Default index read cache TTL is 5m)
18 | max_transfer_retries: 0 # Chunk transfers disabled
19 |
20 | wal:
21 | dir: /loki/wal
22 |
23 | schema_config:
24 | configs:
25 | - from: 2020-10-24
26 | store: boltdb-shipper
27 | object_store: filesystem
28 | schema: v11
29 | index:
30 | prefix: index_
31 | period: 24h
32 |
33 | storage_config:
34 | boltdb_shipper:
35 | active_index_directory: /tmp/loki/boltdb-shipper-active
36 | cache_location: /tmp/loki/boltdb-shipper-cache
37 | cache_ttl: 24h # Can be increased for faster performance over longer query periods, uses more disk space
38 | shared_store: filesystem
39 | filesystem:
40 | directory: /tmp/loki/chunks
41 |
42 | compactor:
43 | working_directory: /tmp/loki/boltdb-shipper-compactor
44 | shared_store: filesystem
45 |
46 | limits_config:
47 | reject_old_samples: true
48 | reject_old_samples_max_age: 168h
49 |
50 | chunk_store_config:
51 | max_look_back_period: 0s
52 |
53 | table_manager:
54 | retention_deletes_enabled: false
55 | retention_period: 0s
56 |
57 | ruler:
58 | storage:
59 | type: local
60 | local:
61 | directory: /tmp/loki/rules
62 | rule_path: /tmp/loki/rules-temp
63 | ring:
64 | kvstore:
65 | store: inmemory
66 | enable_api: true
67 |
--------------------------------------------------------------------------------
/observability/prometheus/prometheus.yml:
--------------------------------------------------------------------------------
1 | global:
2 | scrape_interval: 15s
3 |
4 | # A scrape configuration containing exactly one endpoint to scrape.
5 | scrape_configs:
6 | - job_name: "listory"
7 | metrics_path: "/metrics"
8 | static_configs:
9 | - targets: ["api:9464"]
10 |
11 | - job_name: "prometheus"
12 | static_configs:
13 | - targets: ["localhost:9090"]
14 |
15 | - job_name: "tempo"
16 | static_configs:
17 | - targets: ["tempo:3100"]
18 |
--------------------------------------------------------------------------------
/observability/promtail/promtail.yaml:
--------------------------------------------------------------------------------
1 | server:
2 | http_listen_port: 3102
3 |
4 | clients:
5 | - url: http://loki:3100/loki/api/v1/push
6 |
7 | positions:
8 | filename: /tmp/positions.yaml
9 |
10 | target_config:
11 | sync_period: 10s
12 |
13 | scrape_configs:
14 | - job_name: listory
15 | journal:
16 | labels:
17 | job: listory
18 | relabel_configs:
19 | # services
20 | - source_labels:
21 | - __journal__systemd_unit
22 | target_label: unit
23 | # docker containers
24 | - source_labels:
25 | - __journal_container_name
26 | target_label: container # use whatever label you like
27 | - source_labels:
28 | - container
29 | action: keep
30 | regex: listory-.* # only keep api logs
31 |
--------------------------------------------------------------------------------
/observability/tempo/tempo.yaml:
--------------------------------------------------------------------------------
1 | server:
2 | http_listen_port: 3101
3 |
4 | distributor:
5 | receivers:
6 | otlp:
7 | protocols:
8 | http:
9 |
10 | ingester:
11 | trace_idle_period: 10s # the length of time after a trace has not received spans to consider it complete and flush it
12 | max_block_bytes: 1_000_000 # cut the head block when it hits this size or ...
13 | max_block_duration: 5m # this much time passes
14 |
15 | compactor:
16 | compaction:
17 | compaction_window: 1h # blocks in this time window will be compacted together
18 | max_block_bytes: 100_000_000 # maximum size of compacted blocks
19 | block_retention: 1h
20 | compacted_block_retention: 10m
21 |
22 | storage:
23 | trace:
24 | backend: local # backend configuration to use
25 | block:
26 | bloom_filter_false_positive: .05 # bloom filter false positive rate. lower values create larger filters but fewer false positives
27 | wal:
28 | path: /tmp/tempo/wal # where to store the the wal locally
29 | local:
30 | path: /tmp/tempo/blocks
31 | pool:
32 | max_workers: 100 # the worker pool mainly drives querying, but is also used for polling the blocklist
33 | queue_depth: 10000
34 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "config:base",
4 | ":semanticCommits",
5 | ":semanticCommitTypeAll(chore)",
6 | ":automergeMinor",
7 | ":automergeBranch",
8 | ":automergeLinters",
9 | ":automergeTesters",
10 | ":automergeTypes",
11 | ":maintainLockFilesWeekly"
12 | ],
13 | "packageRules": [
14 | {
15 | "groupName": "pino",
16 | "matchPackageNames": ["pino", "pino-http", "pino-pretty", "nestjs-pino"]
17 | },
18 | {
19 | "groupName": "typeorm",
20 | "matchPackageNames": [
21 | "typeorm",
22 | "@nestjs/typeorm",
23 | "nestjs-typeorm-paginate"
24 | ]
25 | },
26 | {
27 | "groupName": "sentry",
28 | "matchPackageNames": ["@sentry/node", "nest-raven"]
29 | },
30 | {
31 | "groupName": "pg-boss",
32 | "matchPackageNames": ["pg-boss", "@apricote/nest-pg-boss"]
33 | }
34 | ]
35 | }
36 |
--------------------------------------------------------------------------------
/src/app.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from "@nestjs/common";
2 | import { ServeStaticModule } from "@nestjs/serve-static";
3 | import { RavenModule } from "nest-raven";
4 | import { join } from "path";
5 | import { AuthModule } from "./auth/auth.module";
6 | import { ConfigModule } from "./config/config.module";
7 | import { DatabaseModule } from "./database/database.module";
8 | import { HealthCheckModule } from "./health-check/health-check.module";
9 | import { ListensModule } from "./listens/listens.module";
10 | import { LoggerModule } from "./logger/logger.module";
11 | import { MusicLibraryModule } from "./music-library/music-library.module";
12 | import { ReportsModule } from "./reports/reports.module";
13 | import { SourcesModule } from "./sources/sources.module";
14 | import { UsersModule } from "./users/users.module";
15 | import { OpenTelemetryModule } from "./open-telemetry/open-telemetry.module";
16 | import { JobQueueModule } from "./job-queue/job-queue.module";
17 |
18 | @Module({
19 | imports: [
20 | LoggerModule,
21 | ConfigModule,
22 | DatabaseModule,
23 | JobQueueModule,
24 | ServeStaticModule.forRoot({
25 | rootPath: join(__dirname, "..", "static"),
26 | exclude: ["/api*"],
27 | }),
28 | RavenModule,
29 | OpenTelemetryModule,
30 | AuthModule,
31 | UsersModule,
32 | SourcesModule,
33 | MusicLibraryModule,
34 | ListensModule,
35 | HealthCheckModule,
36 | ReportsModule,
37 | ],
38 | })
39 | export class AppModule {}
40 |
--------------------------------------------------------------------------------
/src/auth/api-token.entity.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Column,
3 | CreateDateColumn,
4 | Entity,
5 | ManyToOne,
6 | PrimaryGeneratedColumn,
7 | } from "typeorm";
8 | import { User } from "../users/user.entity";
9 |
10 | @Entity()
11 | export class ApiToken {
12 | @PrimaryGeneratedColumn("uuid")
13 | id: string;
14 |
15 | @ManyToOne(() => User, { eager: true })
16 | user: User;
17 |
18 | @Column()
19 | description: string;
20 |
21 | @Column({ unique: true })
22 | token: string;
23 |
24 | @CreateDateColumn()
25 | createdAt: Date;
26 |
27 | @Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" })
28 | lastUsedAt: Date;
29 |
30 | @Column({ type: "timestamp", nullable: true })
31 | revokedAt: Date | null;
32 | }
33 |
--------------------------------------------------------------------------------
/src/auth/api-token.repository.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable max-classes-per-file */
2 | import { Repository, SelectQueryBuilder } from "typeorm";
3 | import { EntityRepository } from "../database/entity-repository";
4 | import { User } from "../users/user.entity";
5 | import { ApiToken } from "./api-token.entity";
6 |
7 | export class ApiTokenScopes extends SelectQueryBuilder {
8 | /**
9 | * `byUser` scopes the query to ApiTokens created by the user.
10 | * @param currentUser
11 | */
12 | byUser(currentUser: User): this {
13 | return this.andWhere(`token."userId" = :userID`, {
14 | userID: currentUser.id,
15 | });
16 | }
17 | }
18 |
19 | @EntityRepository(ApiToken)
20 | export class ApiTokenRepository extends Repository {
21 | get scoped(): ApiTokenScopes {
22 | return new ApiTokenScopes(this.createQueryBuilder("token"));
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/auth/auth-session.entity.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Column,
3 | CreateDateColumn,
4 | Entity,
5 | ManyToOne,
6 | PrimaryGeneratedColumn,
7 | } from "typeorm";
8 | import { User } from "../users/user.entity";
9 |
10 | @Entity()
11 | export class AuthSession {
12 | @PrimaryGeneratedColumn("uuid")
13 | id: string;
14 |
15 | @ManyToOne(() => User, { eager: true })
16 | user: User;
17 |
18 | @CreateDateColumn()
19 | createdAt: Date;
20 |
21 | @Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" })
22 | lastUsedAt: Date;
23 |
24 | @Column({ type: "timestamp", nullable: true })
25 | revokedAt: Date | null;
26 | }
27 |
--------------------------------------------------------------------------------
/src/auth/auth-session.repository.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable max-classes-per-file */
2 | import { Repository, SelectQueryBuilder } from "typeorm";
3 | import { EntityRepository } from "../database/entity-repository";
4 | import { User } from "../users/user.entity";
5 | import { AuthSession } from "./auth-session.entity";
6 |
7 | export class AuthSessionScopes extends SelectQueryBuilder {
8 | /**
9 | * `byUser` scopes the query to AuthSessions created by the user.
10 | * @param currentUser
11 | */
12 | byUser(currentUser: User): this {
13 | return this.andWhere(`session."userId" = :userID`, {
14 | userID: currentUser.id,
15 | });
16 | }
17 | }
18 |
19 | @EntityRepository(AuthSession)
20 | export class AuthSessionRepository extends Repository {
21 | get scoped(): AuthSessionScopes {
22 | return new AuthSessionScopes(this.createQueryBuilder("session"));
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/auth/auth.module.ts:
--------------------------------------------------------------------------------
1 | import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
2 | import { ConfigService } from "@nestjs/config";
3 | import { JwtModule } from "@nestjs/jwt";
4 | import { PassportModule } from "@nestjs/passport";
5 | import { CookieParserMiddleware } from "../cookie-parser";
6 | import { TypeOrmRepositoryModule } from "../database/entity-repository/typeorm-repository.module";
7 | import { UsersModule } from "../users/users.module";
8 | import { ApiTokenRepository } from "./api-token.repository";
9 | import { AuthSessionRepository } from "./auth-session.repository";
10 | import { AuthController } from "./auth.controller";
11 | import { AuthService } from "./auth.service";
12 | import { AccessTokenStrategy } from "./strategies/access-token.strategy";
13 | import { ApiTokenStrategy } from "./strategies/api-token.strategy";
14 | import { RefreshTokenStrategy } from "./strategies/refresh-token.strategy";
15 | import { SpotifyStrategy } from "./strategies/spotify.strategy";
16 |
17 | @Module({
18 | imports: [
19 | TypeOrmRepositoryModule.for([AuthSessionRepository, ApiTokenRepository]),
20 | PassportModule.register({ defaultStrategy: "jwt" }),
21 | JwtModule.registerAsync({
22 | useFactory: (config: ConfigService) => ({
23 | secret: config.get("JWT_SECRET"),
24 | signOptions: {
25 | expiresIn: config.get("JWT_EXPIRATION_TIME"),
26 | algorithm: config.get("JWT_ALGORITHM"),
27 | },
28 | }),
29 | inject: [ConfigService],
30 | }),
31 | UsersModule,
32 | ],
33 | providers: [
34 | AuthService,
35 | SpotifyStrategy,
36 | AccessTokenStrategy,
37 | RefreshTokenStrategy,
38 | ApiTokenStrategy,
39 | ],
40 | exports: [PassportModule],
41 | controllers: [AuthController],
42 | })
43 | export class AuthModule implements NestModule {
44 | configure(consumer: MiddlewareConsumer) {
45 | consumer.apply(CookieParserMiddleware).forRoutes("api/v1/auth");
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/auth/constants.ts:
--------------------------------------------------------------------------------
1 | export const COOKIE_REFRESH_TOKEN = "listory_refresh_token";
2 |
--------------------------------------------------------------------------------
/src/auth/decorators/auth-access-token.decorator.ts:
--------------------------------------------------------------------------------
1 | import { applyDecorators, UseGuards } from "@nestjs/common";
2 | import { ApiBearerAuth, ApiUnauthorizedResponse } from "@nestjs/swagger";
3 | import { ApiAuthGuard } from "../guards/auth-strategies.guard";
4 |
5 | export function AuthAccessToken() {
6 | return applyDecorators(
7 | UseGuards(ApiAuthGuard),
8 | ApiBearerAuth(),
9 | ApiUnauthorizedResponse({ description: "Unauthorized" }),
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/src/auth/decorators/req-user.decorator.ts:
--------------------------------------------------------------------------------
1 | import { createParamDecorator, ExecutionContext } from "@nestjs/common";
2 |
3 | export const ReqUser = createParamDecorator(
4 | (_: void, ctx: ExecutionContext) => {
5 | const request = ctx.switchToHttp().getRequest();
6 | return request.user;
7 | },
8 | );
9 |
--------------------------------------------------------------------------------
/src/auth/dto/api-token.dto.ts:
--------------------------------------------------------------------------------
1 | export interface ApiTokenDto {
2 | id: string;
3 | description: string;
4 | prefix: string;
5 | createdAt: Date;
6 | revokedAt: Date | null;
7 | }
8 |
--------------------------------------------------------------------------------
/src/auth/dto/create-api-token-request.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from "@nestjs/swagger";
2 |
3 | export class CreateApiTokenRequestDto {
4 | @ApiProperty({
5 | description: "Opaque text field to identify the API token",
6 | example: "My super duper token",
7 | })
8 | description: string;
9 | }
10 |
--------------------------------------------------------------------------------
/src/auth/dto/login.dto.ts:
--------------------------------------------------------------------------------
1 | export class LoginDto {
2 | accessToken: string;
3 | refreshToken: string;
4 | profile: {
5 | id: string;
6 | displayName: string;
7 | photos: { value: string }[];
8 | };
9 | }
10 |
--------------------------------------------------------------------------------
/src/auth/dto/new-api-token.dto.ts:
--------------------------------------------------------------------------------
1 | export interface NewApiTokenDto {
2 | id: string;
3 | description: string;
4 | token: string;
5 | createdAt: Date;
6 | }
7 |
--------------------------------------------------------------------------------
/src/auth/dto/refresh-access-token-response.dto.ts:
--------------------------------------------------------------------------------
1 | export class RefreshAccessTokenResponseDto {
2 | accessToken: string;
3 | }
4 |
--------------------------------------------------------------------------------
/src/auth/dto/revoke-api-token-request.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from "@nestjs/swagger";
2 |
3 | export class RevokeApiTokenRequestDto {
4 | @ApiProperty({
5 | description: "The API Token that should be revoked",
6 | example: "lisasdasdjaksr2381asd",
7 | })
8 | token: string;
9 | }
10 |
--------------------------------------------------------------------------------
/src/auth/guards/auth-strategies.guard.ts:
--------------------------------------------------------------------------------
1 | import { AuthGuard } from "@nestjs/passport";
2 | import { AuthStrategy } from "../strategies/strategies.enum";
3 |
4 | // Internal
5 | export const ApiAuthGuard = AuthGuard([
6 | AuthStrategy.AccessToken,
7 | AuthStrategy.ApiToken,
8 | ]);
9 | export const RefreshTokenAuthGuard = AuthGuard(AuthStrategy.RefreshToken);
10 |
11 | // Auth Provider
12 | export const SpotifyAuthGuard = AuthGuard(AuthStrategy.Spotify);
13 |
--------------------------------------------------------------------------------
/src/auth/spotify.filter.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ArgumentsHost,
3 | Catch,
4 | ExceptionFilter,
5 | ForbiddenException,
6 | Logger,
7 | } from "@nestjs/common";
8 | import type { Response as ExpressResponse } from "express";
9 |
10 | @Catch()
11 | export class SpotifyAuthFilter implements ExceptionFilter {
12 | private readonly logger = new Logger(this.constructor.name);
13 |
14 | catch(exception: Error, host: ArgumentsHost) {
15 | const response = host.switchToHttp().getResponse();
16 |
17 | let reason = "unknown";
18 |
19 | if (exception.name === "TokenError") {
20 | // Error during oauth2 flow
21 | reason = "oauth2";
22 | } else if (
23 | exception instanceof ForbiddenException &&
24 | exception.message === "UserNotInUserFilter"
25 | ) {
26 | // User ID is not in the whitelist
27 | reason = "whitelist";
28 | }
29 |
30 | this.logger.error(
31 | `Login with Spotify failed: ${exception}`,
32 | exception.stack,
33 | );
34 |
35 | response.redirect(`/login/failure?reason=${reason}&source=spotify`);
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/auth/strategies/access-token.strategy.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from "@nestjs/common";
2 | import { ConfigService } from "@nestjs/config";
3 | import { PassportStrategy } from "@nestjs/passport";
4 | import { ExtractJwt, Strategy } from "passport-jwt";
5 | import { AuthService } from "../auth.service";
6 | import { AuthStrategy } from "./strategies.enum";
7 |
8 | @Injectable()
9 | export class AccessTokenStrategy extends PassportStrategy(
10 | Strategy,
11 | AuthStrategy.AccessToken,
12 | ) {
13 | constructor(
14 | private readonly authService: AuthService,
15 | config: ConfigService,
16 | ) {
17 | super({
18 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
19 | ignoreExpiration: false,
20 | secretOrKey: config.get("JWT_SECRET"),
21 | });
22 | }
23 |
24 | async validate(payload: { sub: string }) {
25 | return this.authService.findUser(payload.sub);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/auth/strategies/api-token.strategy.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Injectable,
3 | UnauthorizedException,
4 | ForbiddenException,
5 | } from "@nestjs/common";
6 | import { PassportStrategy } from "@nestjs/passport";
7 | import { Strategy } from "passport-http-bearer";
8 | import { User } from "../../users/user.entity";
9 | import { AuthService } from "../auth.service";
10 | import { AuthStrategy } from "./strategies.enum";
11 |
12 | @Injectable()
13 | export class ApiTokenStrategy extends PassportStrategy(
14 | Strategy,
15 | AuthStrategy.ApiToken,
16 | ) {
17 | constructor(private readonly authService: AuthService) {
18 | super();
19 | }
20 |
21 | async validate(token: string): Promise {
22 | const apiToken = await this.authService.findApiToken(token);
23 |
24 | if (!apiToken) {
25 | throw new UnauthorizedException("TokenNotFound");
26 | }
27 |
28 | if (apiToken.revokedAt) {
29 | throw new ForbiddenException("TokenIsRevoked");
30 | }
31 |
32 | return apiToken.user;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/auth/strategies/refresh-token.strategy.spec.ts:
--------------------------------------------------------------------------------
1 | import { ForbiddenException, UnauthorizedException } from "@nestjs/common";
2 | import { ConfigService } from "@nestjs/config";
3 | import { Test, TestingModule } from "@nestjs/testing";
4 | import { AuthService } from "../auth.service";
5 | import { RefreshTokenStrategy } from "./refresh-token.strategy";
6 |
7 | describe("RefreshTokenStrategy", () => {
8 | let strategy: RefreshTokenStrategy;
9 | let authService: AuthService;
10 | let configService: ConfigService;
11 |
12 | beforeEach(async () => {
13 | const module: TestingModule = await Test.createTestingModule({
14 | providers: [
15 | RefreshTokenStrategy,
16 | { provide: AuthService, useFactory: () => ({}) },
17 | {
18 | provide: ConfigService,
19 | useFactory: () => ({ get: jest.fn().mockReturnValue("foobar") }),
20 | },
21 | ],
22 | }).compile();
23 |
24 | strategy = module.get(RefreshTokenStrategy);
25 | authService = module.get(AuthService);
26 | configService = module.get(ConfigService);
27 | });
28 |
29 | it("should be defined", () => {
30 | expect(strategy).toBeDefined();
31 | expect(authService).toBeDefined();
32 | expect(configService).toBeDefined();
33 | });
34 |
35 | describe("validate", () => {
36 | let session;
37 | let payload;
38 |
39 | beforeEach(() => {
40 | payload = { jti: "123-foo-bar" };
41 |
42 | session = { mock: "Session" };
43 | authService.findSession = jest.fn().mockResolvedValue(session);
44 | });
45 |
46 | it("return session from authService", async () => {
47 | await expect(strategy.validate(payload)).resolves.toEqual(session);
48 |
49 | expect(authService.findSession).toHaveBeenCalledTimes(1);
50 | expect(authService.findSession).toHaveBeenCalledWith(payload.jti);
51 | });
52 |
53 | it("throws UnauthorizedException if session does not exist", async () => {
54 | authService.findSession = jest.fn().mockResolvedValue(undefined);
55 |
56 | await expect(strategy.validate(payload)).rejects.toThrow(
57 | UnauthorizedException,
58 | );
59 | });
60 |
61 | it("throws ForbiddenException is session is revoked", async () => {
62 | session.revokedAt = "2021-01-01";
63 |
64 | await expect(strategy.validate(payload)).rejects.toThrow(
65 | ForbiddenException,
66 | );
67 | });
68 | });
69 | });
70 |
--------------------------------------------------------------------------------
/src/auth/strategies/refresh-token.strategy.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Injectable,
3 | UnauthorizedException,
4 | ForbiddenException,
5 | } from "@nestjs/common";
6 | import { ConfigService } from "@nestjs/config";
7 | import { PassportStrategy } from "@nestjs/passport";
8 | import { JwtFromRequestFunction, Strategy } from "passport-jwt";
9 | import { AuthService } from "../auth.service";
10 | import { COOKIE_REFRESH_TOKEN } from "../constants";
11 | import { AuthStrategy } from "./strategies.enum";
12 | import { AuthSession } from "../auth-session.entity";
13 |
14 | const extractJwtFromCookie: JwtFromRequestFunction = (req) => {
15 | const token = req.cookies[COOKIE_REFRESH_TOKEN] || null;
16 | return token;
17 | };
18 |
19 | @Injectable()
20 | export class RefreshTokenStrategy extends PassportStrategy(
21 | Strategy,
22 | AuthStrategy.RefreshToken,
23 | ) {
24 | constructor(
25 | private readonly authService: AuthService,
26 | config: ConfigService,
27 | ) {
28 | super({
29 | jwtFromRequest: extractJwtFromCookie,
30 | ignoreExpiration: false,
31 | secretOrKey: config.get("JWT_SECRET"),
32 | });
33 | }
34 |
35 | async validate(payload: { jti: string }): Promise {
36 | const session = await this.authService.findSession(payload.jti);
37 |
38 | if (!session) {
39 | throw new UnauthorizedException("SessionNotFound");
40 | }
41 |
42 | if (session.revokedAt) {
43 | throw new ForbiddenException("SessionIsRevoked");
44 | }
45 |
46 | return session;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/auth/strategies/spotify.strategy.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from "@nestjs/common";
2 | import { ConfigService } from "@nestjs/config";
3 | import { PassportStrategy } from "@nestjs/passport";
4 | import { Strategy } from "passport-spotify";
5 | import { AuthService } from "../auth.service";
6 | import { AuthStrategy } from "./strategies.enum";
7 |
8 | @Injectable()
9 | export class SpotifyStrategy extends PassportStrategy(
10 | Strategy,
11 | AuthStrategy.Spotify,
12 | ) {
13 | constructor(
14 | private readonly authService: AuthService,
15 | config: ConfigService,
16 | ) {
17 | super({
18 | clientID: config.get("SPOTIFY_CLIENT_ID"),
19 | clientSecret: config.get("SPOTIFY_CLIENT_SECRET"),
20 | callbackURL: `${config.get(
21 | "APP_URL",
22 | )}/api/v1/auth/spotify/callback`,
23 | scope: [
24 | "user-read-private",
25 | "user-read-email",
26 | "user-read-recently-played",
27 | ],
28 | });
29 | }
30 |
31 | async validate(accessToken: string, refreshToken: string, profile: any) {
32 | return await this.authService.spotifyLogin({
33 | accessToken,
34 | refreshToken,
35 | profile,
36 | });
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/auth/strategies/strategies.enum.ts:
--------------------------------------------------------------------------------
1 | export enum AuthStrategy {
2 | // Internal
3 | AccessToken = "access_token",
4 | RefreshToken = "refresh_token",
5 | ApiToken = "api_token",
6 |
7 | // Auth Provider
8 | Spotify = "spotify",
9 | }
10 |
--------------------------------------------------------------------------------
/src/config/config.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from "@nestjs/common";
2 | import { ConfigModule as NestConfigModule } from "@nestjs/config";
3 | import * as Joi from "joi";
4 |
5 | @Module({
6 | imports: [
7 | NestConfigModule.forRoot({
8 | isGlobal: true,
9 | validationSchema: Joi.object({
10 | // Application
11 | PORT: Joi.number().default(3000),
12 | APP_URL: Joi.string().default("http://localhost:3000"),
13 |
14 | // JWT
15 | JWT_SECRET: Joi.string().required(),
16 | JWT_ALGORITHM: Joi.string()
17 | .default("HS256")
18 | .allow("HS256", "HS384", "HS512"),
19 |
20 | JWT_EXPIRATION_TIME: Joi.string().default("15m"),
21 | SESSION_EXPIRATION_TIME: Joi.string().default("1y"),
22 |
23 | // Spotify
24 | SPOTIFY_CLIENT_ID: Joi.string().required(),
25 | SPOTIFY_CLIENT_SECRET: Joi.string().required(),
26 | SPOTIFY_FETCH_INTERVAL_SEC: Joi.number().default(60),
27 | SPOTIFY_UPDATE_INTERVAL_SEC: Joi.number().default(60),
28 | SPOTIFY_WEB_API_URL: Joi.string().default("https://api.spotify.com/"),
29 | SPOTIFY_AUTH_API_URL: Joi.string().default(
30 | "https://accounts.spotify.com/",
31 | ),
32 | SPOTIFY_USER_FILTER: Joi.string(),
33 |
34 | // DB
35 | DB_HOST: Joi.string().required(),
36 | DB_USERNAME: Joi.string().required(),
37 | DB_PASSWORD: Joi.string().required(),
38 | DB_DATABASE: Joi.string().required(),
39 | DB_POOL_MAX: Joi.number().default(50),
40 |
41 | // Sentry (Optional)
42 | SENTRY_ENABLED: Joi.boolean().default(false),
43 | SENTRY_DSN: Joi.string().when("SENTRY_ENABLED", {
44 | is: Joi.valid(true),
45 | then: Joi.required(),
46 | }),
47 |
48 | // Prometheus for Metrics (Optional)
49 | PROMETHEUS_ENABLED: Joi.boolean().default(false),
50 | PROMETHEUS_BASIC_AUTH: Joi.boolean().default(false),
51 | PROMETHEUS_BASIC_AUTH_USERNAME: Joi.string().when(
52 | "PROMETHEUS_BASIC_AUTH",
53 | {
54 | is: Joi.valid(true),
55 | then: Joi.required(),
56 | },
57 | ),
58 | PROMETHEUS_BASIC_AUTH_PASSWORD: Joi.string().when(
59 | "PROMETHEUS_BASIC_AUTH",
60 | {
61 | is: Joi.valid(true),
62 | then: Joi.required(),
63 | },
64 | ),
65 | }),
66 | }),
67 | ],
68 | })
69 | export class ConfigModule {}
70 |
--------------------------------------------------------------------------------
/src/console.ts:
--------------------------------------------------------------------------------
1 | import { repl } from "@nestjs/core";
2 | import { AppModule } from "./app.module";
3 | import { otelSDK } from "./open-telemetry/sdk";
4 |
5 | async function bootstrap() {
6 | await otelSDK.start();
7 |
8 | // TODO: Disable scheduled tasks from SourcesModule when in repl mode
9 | await repl(AppModule);
10 | }
11 | bootstrap();
12 |
--------------------------------------------------------------------------------
/src/cookie-parser/cookie-parser.middleware.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, NestMiddleware } from "@nestjs/common";
2 | import * as cookieParser from "cookie-parser";
3 | import type { RequestHandler } from "express";
4 |
5 | @Injectable()
6 | export class CookieParserMiddleware implements NestMiddleware {
7 | private readonly middleware: RequestHandler;
8 |
9 | constructor() {
10 | this.middleware = cookieParser();
11 | }
12 |
13 | use(req: any, res: any, next: () => void) {
14 | return this.middleware(req, res, next);
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/cookie-parser/index.ts:
--------------------------------------------------------------------------------
1 | export { CookieParserMiddleware } from "./cookie-parser.middleware";
2 |
--------------------------------------------------------------------------------
/src/database/database.module.ts:
--------------------------------------------------------------------------------
1 | import { ConfigService } from "@nestjs/config";
2 | import { TypeOrmModule } from "@nestjs/typeorm";
3 | import { join } from "path";
4 |
5 | // tslint:disable-next-line variable-name
6 | export const DatabaseModule = TypeOrmModule.forRootAsync({
7 | useFactory: (config: ConfigService) => ({
8 | type: "postgres",
9 |
10 | // Connection details
11 | host: config.get("DB_HOST"),
12 | username: config.get("DB_USERNAME"),
13 | password: config.get("DB_PASSWORD"),
14 | database: config.get("DB_DATABASE"),
15 |
16 | // Entities
17 | entities: [join(__dirname, "..", "**/*.entity.{ts,js}")],
18 |
19 | // Migrations
20 | migrationsRun: true,
21 | migrations: [join(__dirname, "migrations", "*.{ts,js}")],
22 |
23 | // PG Driver Options
24 | extra: {
25 | max: config.get("DB_POOL_MAX"),
26 | },
27 |
28 | // Debug/Development Options
29 | //
30 | //logging: true,
31 | //
32 | // synchronize: true,
33 | // migrationsRun: false,
34 | }),
35 | inject: [ConfigService],
36 | });
37 |
--------------------------------------------------------------------------------
/src/database/entity-repository/README.md:
--------------------------------------------------------------------------------
1 | This module restores the classes-based custom Repository functionality from
2 | typeorm v0.2.
3 |
4 | Directly adapted from anchan828: https://gist.github.com/anchan828/9e569f076e7bc18daf21c652f7c3d012
5 |
--------------------------------------------------------------------------------
/src/database/entity-repository/entity-repository.decorator.ts:
--------------------------------------------------------------------------------
1 | import { SetMetadata } from "@nestjs/common";
2 |
3 | export const TYPEORM_ENTITY_REPOSITORY = "TYPEORM_ENTITY_REPOSITORY";
4 |
5 | export function EntityRepository(entity: Function): ClassDecorator {
6 | return SetMetadata(TYPEORM_ENTITY_REPOSITORY, entity);
7 | }
8 |
--------------------------------------------------------------------------------
/src/database/entity-repository/index.ts:
--------------------------------------------------------------------------------
1 | import { EntityRepository } from "./entity-repository.decorator";
2 |
3 | export { EntityRepository };
4 |
--------------------------------------------------------------------------------
/src/database/entity-repository/typeorm-repository.module.ts:
--------------------------------------------------------------------------------
1 | import { DynamicModule, Provider } from "@nestjs/common";
2 | import { getDataSourceToken } from "@nestjs/typeorm";
3 | import { DataSource } from "typeorm";
4 | import { TYPEORM_ENTITY_REPOSITORY } from "./entity-repository.decorator";
5 |
6 | export class TypeOrmRepositoryModule {
7 | public static for any>(
8 | repositories: T[],
9 | ): DynamicModule {
10 | const providers: Provider[] = [];
11 |
12 | for (const repository of repositories) {
13 | const entity = Reflect.getMetadata(TYPEORM_ENTITY_REPOSITORY, repository);
14 |
15 | if (!entity) {
16 | continue;
17 | }
18 |
19 | providers.push({
20 | inject: [getDataSourceToken()],
21 | provide: repository,
22 | useFactory: (dataSource: DataSource): typeof repository => {
23 | const baseRepository = dataSource.getRepository(entity);
24 | return new repository(
25 | baseRepository.target,
26 | baseRepository.manager,
27 | baseRepository.queryRunner,
28 | );
29 | },
30 | });
31 | }
32 |
33 | return {
34 | exports: providers,
35 | module: TypeOrmRepositoryModule,
36 | providers,
37 | };
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/database/error-codes.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Offical postgres error codes to match against when checking database exceptions.
3 | *
4 | * https://www.postgresql.org/docs/current/errcodes-appendix.html
5 | */
6 | export enum PostgresErrorCodes {
7 | UNIQUE_VIOLATION = "23505",
8 | }
9 |
--------------------------------------------------------------------------------
/src/database/migrations/01-CreateUsersTable.ts:
--------------------------------------------------------------------------------
1 | import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm";
2 |
3 | export class CreateUsersTable0000000000001 implements MigrationInterface {
4 | async up(queryRunner: QueryRunner): Promise {
5 | await queryRunner.createTable(
6 | new Table({
7 | name: "user",
8 | columns: [
9 | {
10 | name: "id",
11 | type: "uuid",
12 | isPrimary: true,
13 | isGenerated: true,
14 | generationStrategy: "uuid",
15 | },
16 | {
17 | name: "displayName",
18 | type: "varchar",
19 | },
20 | {
21 | name: "photo",
22 | type: "varchar",
23 | isNullable: true,
24 | },
25 | { name: "spotifyId", type: "varchar", isNullable: true },
26 | { name: "spotifyAccesstoken", type: "varchar", isNullable: true },
27 | { name: "spotifyRefreshtoken", type: "varchar", isNullable: true },
28 | {
29 | name: "spotifyLastrefreshtime",
30 | type: "timestamp",
31 | isNullable: true,
32 | },
33 | ],
34 | indices: [
35 | new TableIndex({
36 | name: "IDX_USER_SPOTIFY_ID",
37 | columnNames: ["spotifyId"],
38 | isUnique: true,
39 | }),
40 | ],
41 | }),
42 | true,
43 | );
44 | }
45 |
46 | async down(queryRunner: QueryRunner): Promise {
47 | await queryRunner.dropTable("user");
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/database/migrations/03-CreateListensTable.ts:
--------------------------------------------------------------------------------
1 | import {
2 | MigrationInterface,
3 | QueryRunner,
4 | Table,
5 | TableIndex,
6 | TableForeignKey,
7 | } from "typeorm";
8 | import { TableColumnOptions } from "typeorm/schema-builder/options/TableColumnOptions";
9 |
10 | const primaryUUIDColumn: TableColumnOptions = {
11 | name: "id",
12 | type: "uuid",
13 | isPrimary: true,
14 | isGenerated: true,
15 | generationStrategy: "uuid",
16 | };
17 |
18 | export class CreateListensTable0000000000003 implements MigrationInterface {
19 | async up(queryRunner: QueryRunner): Promise {
20 | await queryRunner.createTable(
21 | new Table({
22 | name: "listen",
23 | columns: [
24 | primaryUUIDColumn,
25 | {
26 | name: "playedAt",
27 | type: "timestamp",
28 | },
29 | {
30 | name: "trackId",
31 | type: "uuid",
32 | },
33 | {
34 | name: "userId",
35 | type: "uuid",
36 | },
37 | ],
38 | indices: [
39 | new TableIndex({
40 | name: "IDX_LISTEN_TRACK_ID",
41 | columnNames: ["trackId"],
42 | }),
43 | new TableIndex({
44 | name: "IDX_LISTEN_USER_ID",
45 | columnNames: ["userId"],
46 | }),
47 | new TableIndex({
48 | name: "IDX_LISTEN_UNIQUE",
49 | isUnique: true,
50 | columnNames: ["trackId", "userId", "playedAt"],
51 | }),
52 | ],
53 | foreignKeys: [
54 | new TableForeignKey({
55 | name: "FK_LISTEN_TRACK_ID",
56 | columnNames: ["trackId"],
57 | referencedColumnNames: ["id"],
58 | referencedTableName: "track",
59 | }),
60 | new TableForeignKey({
61 | name: "FK_LISTEN_USER_ID",
62 | columnNames: ["userId"],
63 | referencedColumnNames: ["id"],
64 | referencedTableName: "user",
65 | }),
66 | ],
67 | }),
68 | true,
69 | );
70 | }
71 |
72 | async down(queryRunner: QueryRunner): Promise {
73 | await queryRunner.dropTable("listen");
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/database/migrations/04-CreateAuthSessionsTable.ts:
--------------------------------------------------------------------------------
1 | import {
2 | MigrationInterface,
3 | QueryRunner,
4 | Table,
5 | TableIndex,
6 | TableForeignKey,
7 | } from "typeorm";
8 | import { TableColumnOptions } from "typeorm/schema-builder/options/TableColumnOptions";
9 |
10 | const primaryUUIDColumn: TableColumnOptions = {
11 | name: "id",
12 | type: "uuid",
13 | isPrimary: true,
14 | isGenerated: true,
15 | generationStrategy: "uuid",
16 | };
17 |
18 | export class CreateAuthSessionsTable0000000000004
19 | implements MigrationInterface
20 | {
21 | async up(queryRunner: QueryRunner): Promise {
22 | await queryRunner.createTable(
23 | new Table({
24 | name: "auth_session",
25 | columns: [
26 | primaryUUIDColumn,
27 | {
28 | name: "userId",
29 | type: "uuid",
30 | },
31 | {
32 | name: "createdAt",
33 | type: "timestamp",
34 | default: "CURRENT_TIMESTAMP",
35 | },
36 | {
37 | name: "lastUsedAt",
38 | type: "timestamp",
39 | default: "CURRENT_TIMESTAMP",
40 | },
41 | {
42 | name: "revokedAt",
43 | type: "timestamp",
44 | default: null,
45 | isNullable: true,
46 | },
47 | ],
48 | indices: [
49 | new TableIndex({
50 | name: "IDX_AUTH_SESSION_USER_ID",
51 | columnNames: ["userId"],
52 | }),
53 | ],
54 | foreignKeys: [
55 | new TableForeignKey({
56 | name: "FK_AUTH_SESSION_USER_ID",
57 | columnNames: ["userId"],
58 | referencedColumnNames: ["id"],
59 | referencedTableName: "user",
60 | }),
61 | ],
62 | }),
63 | true,
64 | );
65 | }
66 |
67 | async down(queryRunner: QueryRunner): Promise {
68 | await queryRunner.dropTable("auth_session");
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/database/migrations/05-CreateGenreTables.ts:
--------------------------------------------------------------------------------
1 | import {
2 | MigrationInterface,
3 | QueryRunner,
4 | Table,
5 | TableIndex,
6 | TableForeignKey,
7 | } from "typeorm";
8 | import { TableColumnOptions } from "typeorm/schema-builder/options/TableColumnOptions";
9 |
10 | const primaryUUIDColumn: TableColumnOptions = {
11 | name: "id",
12 | type: "uuid",
13 | isPrimary: true,
14 | isGenerated: true,
15 | generationStrategy: "uuid",
16 | };
17 |
18 | export class CreateGenreTables0000000000005 implements MigrationInterface {
19 | async up(queryRunner: QueryRunner): Promise {
20 | await queryRunner.createTable(
21 | new Table({
22 | name: "genre",
23 | columns: [
24 | primaryUUIDColumn,
25 | {
26 | name: "name",
27 | type: "varchar",
28 | },
29 | ],
30 | indices: [
31 | new TableIndex({
32 | name: "IDX_GENRE_NAME",
33 | columnNames: ["name"],
34 | }),
35 | ],
36 | }),
37 | true,
38 | );
39 |
40 | await queryRunner.createTable(
41 | new Table({
42 | name: "artist_genres",
43 | columns: [
44 | {
45 | name: "artistId",
46 | type: "uuid",
47 | isPrimary: true,
48 | },
49 | {
50 | name: "genreId",
51 | type: "uuid",
52 | isPrimary: true,
53 | },
54 | ],
55 | indices: [
56 | new TableIndex({
57 | name: "IDX_ARTIST_GENRES_ARTIST_ID",
58 | columnNames: ["artistId"],
59 | }),
60 | new TableIndex({
61 | name: "IDX_ARTIST_GENRES_GENRE_ID",
62 | columnNames: ["genreId"],
63 | }),
64 | ],
65 | foreignKeys: [
66 | new TableForeignKey({
67 | name: "FK_ARTIST_ID",
68 | columnNames: ["artistId"],
69 | referencedColumnNames: ["id"],
70 | referencedTableName: "artist",
71 | }),
72 | new TableForeignKey({
73 | name: "FK_GENRE_ID",
74 | columnNames: ["genreId"],
75 | referencedColumnNames: ["id"],
76 | referencedTableName: "genre",
77 | }),
78 | ],
79 | }),
80 | true,
81 | );
82 | }
83 |
84 | async down(queryRunner: QueryRunner): Promise {
85 | await queryRunner.dropTable("artist_genres");
86 | await queryRunner.dropTable("genre");
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/database/migrations/06-AddUpdatedAtColumns.ts:
--------------------------------------------------------------------------------
1 | import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";
2 |
3 | export class AddUpdatedAtColumnes0000000000006 implements MigrationInterface {
4 | async up(queryRunner: QueryRunner): Promise {
5 | await queryRunner.addColumn(
6 | "artist",
7 | new TableColumn({
8 | name: "updatedAt",
9 | type: "timestamp",
10 | default: "NOW()",
11 | }),
12 | );
13 | }
14 |
15 | async down(queryRunner: QueryRunner): Promise {
16 | await queryRunner.dropColumn("artist", "updatedAt");
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/database/migrations/07-CreateApiTokenTable.ts:
--------------------------------------------------------------------------------
1 | import {
2 | MigrationInterface,
3 | QueryRunner,
4 | Table,
5 | TableIndex,
6 | TableForeignKey,
7 | } from "typeorm";
8 | import { TableColumnOptions } from "typeorm/schema-builder/options/TableColumnOptions";
9 |
10 | const primaryUUIDColumn: TableColumnOptions = {
11 | name: "id",
12 | type: "uuid",
13 | isPrimary: true,
14 | isGenerated: true,
15 | generationStrategy: "uuid",
16 | };
17 |
18 | export class CreateApiTokensTable0000000000007 implements MigrationInterface {
19 | async up(queryRunner: QueryRunner): Promise {
20 | await queryRunner.createTable(
21 | new Table({
22 | name: "api_token",
23 | columns: [
24 | primaryUUIDColumn,
25 | {
26 | name: "userId",
27 | type: "uuid",
28 | },
29 | {
30 | name: "description",
31 | type: "varchar",
32 | },
33 | {
34 | name: "token",
35 | type: "varchar",
36 | isUnique: true,
37 | },
38 | {
39 | name: "createdAt",
40 | type: "timestamp",
41 | default: "CURRENT_TIMESTAMP",
42 | },
43 | {
44 | name: "lastUsedAt",
45 | type: "timestamp",
46 | default: "CURRENT_TIMESTAMP",
47 | },
48 | {
49 | name: "revokedAt",
50 | type: "timestamp",
51 | default: null,
52 | isNullable: true,
53 | },
54 | ],
55 | indices: [
56 | new TableIndex({
57 | name: "IDX_API_TOKEN_USER_ID",
58 | columnNames: ["userId"],
59 | }),
60 | ],
61 | foreignKeys: [
62 | new TableForeignKey({
63 | name: "FK_API_TOKEN_USER_ID",
64 | columnNames: ["userId"],
65 | referencedColumnNames: ["id"],
66 | referencedTableName: "user",
67 | }),
68 | ],
69 | }),
70 | true,
71 | );
72 | }
73 |
74 | async down(queryRunner: QueryRunner): Promise {
75 | await queryRunner.dropTable("api_token");
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/database/migrations/08-OptimizeDBIndices.ts:
--------------------------------------------------------------------------------
1 | import { MigrationInterface, QueryRunner, TableIndex } from "typeorm";
2 |
3 | export class OptimizeDBIndices0000000000008 implements MigrationInterface {
4 | async up(queryRunner: QueryRunner): Promise {
5 | await queryRunner.createIndices("artist", [
6 | new TableIndex({
7 | // This index helps with the "update artist" job
8 | name: "IDX_ARTIST_UPDATED_AT",
9 | columnNames: ["updatedAt"],
10 | }),
11 | ]);
12 |
13 | await queryRunner.createIndices("listen", [
14 | new TableIndex({
15 | // This index helps with the "getCrawlableUserInfo" query
16 | name: "IDX_LISTEN_USER_ID_PLAYED_AT",
17 | columnNames: ["userId", "playedAt"],
18 | }),
19 | ]);
20 |
21 | // handled by Primary Key on (albumId, artistId)
22 | await queryRunner.dropIndex("album_artists", "IDX_ALBUM_ARTISTS_ALBUM_ID");
23 |
24 | // handled by Primary Key on (artistId, genreId)
25 | await queryRunner.dropIndex("artist_genres", "IDX_ARTIST_GENRES_ARTIST_ID");
26 |
27 | // handled by IDX_LISTEN_UNIQUE on (trackId, userId, playedAt)
28 | await queryRunner.dropIndex("listen", "IDX_LISTEN_TRACK_ID");
29 | // handled by IDX_LISTEN_USER_ID_PLAYED_AT on (userId, playedAt)
30 | await queryRunner.dropIndex("listen", "IDX_LISTEN_USER_ID");
31 |
32 | // handled by Primary Key on (trackId, artistId)
33 | await queryRunner.dropIndex("track_artists", "IDX_TRACK_ARTISTS_TRACK_ID");
34 | }
35 |
36 | async down(queryRunner: QueryRunner): Promise {
37 | await queryRunner.createIndices("album_artists", [
38 | new TableIndex({
39 | name: "IDX_ALBUM_ARTISTS_ALBUM_ID",
40 | columnNames: ["albumId"],
41 | }),
42 | ]);
43 |
44 | await queryRunner.createIndices("artist_genres", [
45 | new TableIndex({
46 | name: "IDX_ARTIST_GENRES_ARTIST_ID",
47 | columnNames: ["artistId"],
48 | }),
49 | ]);
50 |
51 | await queryRunner.createIndices("listen", [
52 | new TableIndex({
53 | name: "IDX_LISTEN_TRACK_ID",
54 | columnNames: ["trackId"],
55 | }),
56 | new TableIndex({
57 | name: "IDX_LISTEN_USER_ID",
58 | columnNames: ["userId"],
59 | }),
60 | ]);
61 |
62 | await queryRunner.createIndices("track_artists", [
63 | new TableIndex({
64 | name: "IDX_TRACK_ARTISTS_TRACK_ID",
65 | columnNames: ["trackId"],
66 | }),
67 | ]);
68 |
69 | await queryRunner.dropIndex("artist", "IDX_ARTIST_UPDATED_AT");
70 | await queryRunner.dropIndex("listen", "IDX_LISTEN_USER_ID_PLAYED_AT");
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/database/migrations/09-CreateSpotifyImportTables.ts:
--------------------------------------------------------------------------------
1 | import {
2 | MigrationInterface,
3 | QueryRunner,
4 | Table,
5 | TableIndex,
6 | TableForeignKey,
7 | } from "typeorm";
8 | import { TableColumnOptions } from "typeorm/schema-builder/options/TableColumnOptions";
9 |
10 | const primaryUUIDColumn: TableColumnOptions = {
11 | name: "id",
12 | type: "uuid",
13 | isPrimary: true,
14 | isGenerated: true,
15 | generationStrategy: "uuid",
16 | };
17 |
18 | export class CreateSpotifyImportTables0000000000009
19 | implements MigrationInterface
20 | {
21 | async up(queryRunner: QueryRunner): Promise {
22 | await queryRunner.createTable(
23 | new Table({
24 | name: "spotify_extended_streaming_history_listen",
25 | columns: [
26 | primaryUUIDColumn,
27 | { name: "userId", type: "uuid" },
28 | { name: "playedAt", type: "timestamp" },
29 | { name: "spotifyTrackUri", type: "varchar" },
30 | { name: "trackId", type: "uuid", isNullable: true },
31 | { name: "listenId", type: "uuid", isNullable: true },
32 | ],
33 | indices: [
34 | new TableIndex({
35 | name: "IDX_SPOTIFY_EXTENDED_STREAMING_HISTORY_LISTEN_USER_PLAYED_AT",
36 | columnNames: ["userId", "playedAt", "spotifyTrackUri"],
37 | isUnique: true,
38 | }),
39 | ],
40 | foreignKeys: [
41 | new TableForeignKey({
42 | name: "FK_SPOTIFY_EXTENDED_STREAMING_HISTORY_LISTEN_USER_ID",
43 | columnNames: ["userId"],
44 | referencedColumnNames: ["id"],
45 | referencedTableName: "user",
46 | }),
47 | new TableForeignKey({
48 | name: "FK_SPOTIFY_EXTENDED_STREAMING_HISTORY_LISTEN_TRACK_ID",
49 | columnNames: ["trackId"],
50 | referencedColumnNames: ["id"],
51 | referencedTableName: "track",
52 | }),
53 | new TableForeignKey({
54 | name: "FK_SPOTIFY_EXTENDED_STREAMING_HISTORY_LISTEN_LISTEN_ID",
55 | columnNames: ["listenId"],
56 | referencedColumnNames: ["id"],
57 | referencedTableName: "listen",
58 | }),
59 | ],
60 | }),
61 | true,
62 | );
63 | }
64 |
65 | async down(queryRunner: QueryRunner): Promise {
66 | await queryRunner.dropTable("spotify_extended_streaming_history_listen");
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/health-check/health-check.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get } from "@nestjs/common";
2 | import { ConfigService } from "@nestjs/config";
3 | import { ApiTags } from "@nestjs/swagger";
4 | import {
5 | HealthCheck,
6 | HealthCheckResult,
7 | HealthCheckService,
8 | HttpHealthIndicator,
9 | TypeOrmHealthIndicator,
10 | } from "@nestjs/terminus";
11 | import { configureScope, Scope } from "@sentry/node";
12 |
13 | @ApiTags("health")
14 | @Controller("api/v1/health")
15 | export class HealthCheckController {
16 | constructor(
17 | private readonly health: HealthCheckService,
18 | private readonly http: HttpHealthIndicator,
19 | private readonly typeorm: TypeOrmHealthIndicator,
20 | private readonly config: ConfigService,
21 | ) {}
22 |
23 | @Get()
24 | @HealthCheck()
25 | async check(): Promise {
26 | const health = await this.health.check([
27 | () =>
28 | this.http.pingCheck(
29 | "spotify-web",
30 | this.config.get("SPOTIFY_WEB_API_URL"),
31 | {
32 | validateStatus: () => true,
33 | }, // Successful as long as we get a valid HTTP response back }
34 | ),
35 | () => this.typeorm.pingCheck("db"),
36 | ]);
37 |
38 | configureScope((scope: Scope) => {
39 | scope.setContext("health", {
40 | status: health.status,
41 | info: health.info,
42 | error: health.error,
43 | });
44 | });
45 |
46 | return health;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/health-check/health-check.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from "@nestjs/common";
2 | import { TerminusModule } from "@nestjs/terminus";
3 | import { HealthCheckController } from "./health-check.controller";
4 |
5 | @Module({ imports: [TerminusModule], controllers: [HealthCheckController] })
6 | export class HealthCheckModule {}
7 |
--------------------------------------------------------------------------------
/src/job-queue/job-queue.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from "@nestjs/common";
2 | import { PGBossModule } from "@apricote/nest-pg-boss";
3 | import { ConfigService } from "@nestjs/config";
4 |
5 | @Module({
6 | imports: [
7 | PGBossModule.forRootAsync({
8 | application_name: "listory",
9 | useFactory: (config: ConfigService) => ({
10 | // Connection details
11 | host: config.get("DB_HOST"),
12 | user: config.get("DB_USERNAME"),
13 | password: config.get("DB_PASSWORD"),
14 | database: config.get("DB_DATABASE"),
15 | schema: "public",
16 | max: config.get("DB_POOL_MAX"),
17 | }),
18 | inject: [ConfigService],
19 | }),
20 | ],
21 | exports: [PGBossModule],
22 | })
23 | export class JobQueueModule {}
24 |
--------------------------------------------------------------------------------
/src/listens/dto/create-listen.dto.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable max-classes-per-file */
2 | import { Track } from "../../music-library/track.entity";
3 | import { User } from "../../users/user.entity";
4 | import { Listen } from "../listen.entity";
5 |
6 | export class CreateListenRequestDto {
7 | track: Track;
8 | user: User;
9 | playedAt: Date;
10 | }
11 |
12 | export class CreateListenResponseDto {
13 | listen: Listen;
14 | isDuplicate: boolean;
15 | }
16 |
--------------------------------------------------------------------------------
/src/listens/dto/get-listens.dto.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable max-classes-per-file */
2 | import { IsDate, IsOptional, ValidateNested } from "class-validator";
3 | import { Interval } from "date-fns";
4 | import { User } from "../../users/user.entity";
5 |
6 | export class GetListensFilterTimeDto implements Interval {
7 | @IsDate()
8 | start: Date;
9 |
10 | @IsDate()
11 | end: Date;
12 | }
13 |
14 | export class GetListensFilterDto {
15 | @IsOptional()
16 | @ValidateNested()
17 | time?: GetListensFilterTimeDto;
18 | }
19 |
20 | export class GetListensDto {
21 | user: User;
22 |
23 | @IsOptional()
24 | @ValidateNested()
25 | filter?: GetListensFilterDto;
26 | }
27 |
--------------------------------------------------------------------------------
/src/listens/listen.entity.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Column,
3 | Entity,
4 | Index,
5 | ManyToOne,
6 | PrimaryGeneratedColumn,
7 | } from "typeorm";
8 | import { Track } from "../music-library/track.entity";
9 | import { User } from "../users/user.entity";
10 |
11 | @Entity()
12 | @Index(["track", "user", "playedAt"], { unique: true })
13 | export class Listen {
14 | @PrimaryGeneratedColumn("uuid")
15 | id: string;
16 |
17 | @ManyToOne(() => Track, { eager: true })
18 | track: Track;
19 |
20 | @ManyToOne(() => User, { eager: true })
21 | user: User;
22 |
23 | @Column({ type: "timestamp" })
24 | playedAt: Date;
25 | }
26 |
--------------------------------------------------------------------------------
/src/listens/listen.repository.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable max-classes-per-file */
2 | import { Repository, SelectQueryBuilder } from "typeorm";
3 | import { EntityRepository } from "../database/entity-repository";
4 | import { Interval } from "../reports/interval";
5 | import { User } from "../users/user.entity";
6 | import { Listen } from "./listen.entity";
7 |
8 | export class ListenScopes extends SelectQueryBuilder {
9 | /**
10 | * `byUser` scopes the query to listens created by the user.
11 | * @param currentUser
12 | */
13 | byUser(currentUser: User): this {
14 | return this.andWhere(`listen."userId" = :userID`, {
15 | userID: currentUser.id,
16 | });
17 | }
18 |
19 | /**
20 | * `duringInterval` scopes the query to listens played during the interval.
21 | * @param interval
22 | */
23 | duringInterval(interval: Interval): this {
24 | return this.andWhere("listen.playedAt BETWEEN :timeStart AND :timeEnd", {
25 | timeStart: interval.start,
26 | timeEnd: interval.end,
27 | });
28 | }
29 | }
30 |
31 | @EntityRepository(Listen)
32 | export class ListenRepository extends Repository {
33 | get scoped(): ListenScopes {
34 | return new ListenScopes(this.createQueryBuilder("listen"));
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/listens/listens.controller.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from "@nestjs/testing";
2 | import { Pagination } from "nestjs-typeorm-paginate";
3 | import { User } from "../users/user.entity";
4 | import { GetListensFilterDto } from "./dto/get-listens.dto";
5 | import { Listen } from "./listen.entity";
6 | import { ListensController } from "./listens.controller";
7 | import { ListensService } from "./listens.service";
8 |
9 | describe("Listens Controller", () => {
10 | let controller: ListensController;
11 | let listensService: ListensService;
12 |
13 | beforeEach(async () => {
14 | const module: TestingModule = await Test.createTestingModule({
15 | controllers: [ListensController],
16 | providers: [{ provide: ListensService, useFactory: () => ({}) }],
17 | }).compile();
18 |
19 | controller = module.get(ListensController);
20 | listensService = module.get(ListensService);
21 | });
22 |
23 | it("should be defined", () => {
24 | expect(controller).toBeDefined();
25 | expect(listensService).toBeDefined();
26 | });
27 |
28 | describe("getRecentlyPlayed", () => {
29 | let filter: GetListensFilterDto;
30 | let user: User;
31 | let listens: Pagination;
32 |
33 | beforeEach(() => {
34 | filter = { time: { start: new Date(), end: new Date() } };
35 | user = { id: "USER" } as User;
36 |
37 | listens = { items: [{ id: "LISTEN" } as Listen] } as Pagination;
38 |
39 | listensService.getListens = jest.fn().mockResolvedValue(listens);
40 | });
41 |
42 | it("returns the listens", async () => {
43 | await expect(
44 | controller.getRecentlyPlayed(filter, user, 1, 10),
45 | ).resolves.toEqual(listens);
46 |
47 | expect(listensService.getListens).toHaveBeenCalledTimes(1);
48 | expect(listensService.getListens).toHaveBeenCalledWith({
49 | page: 1,
50 | limit: 10,
51 | user,
52 | filter,
53 | });
54 | });
55 |
56 | it("clamps the limit to 100", async () => {
57 | await controller.getRecentlyPlayed(filter, user, 1, 1000);
58 |
59 | expect(listensService.getListens).toHaveBeenCalledWith(
60 | expect.objectContaining({ limit: 100 }),
61 | );
62 | });
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/src/listens/listens.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get, Query } from "@nestjs/common";
2 | import { ApiTags } from "@nestjs/swagger";
3 | import { Pagination } from "nestjs-typeorm-paginate";
4 | import { AuthAccessToken } from "../auth/decorators/auth-access-token.decorator";
5 | import { ReqUser } from "../auth/decorators/req-user.decorator";
6 | import { User } from "../users/user.entity";
7 | import { GetListensFilterDto } from "./dto/get-listens.dto";
8 | import { Listen } from "./listen.entity";
9 | import { ListensService } from "./listens.service";
10 |
11 | @ApiTags("listens")
12 | @Controller("api/v1/listens")
13 | export class ListensController {
14 | constructor(private readonly listensService: ListensService) {}
15 |
16 | @Get()
17 | @AuthAccessToken()
18 | async getRecentlyPlayed(
19 | @Query("filter") filter: GetListensFilterDto,
20 | @ReqUser() user: User,
21 | @Query("page") page: number = 1,
22 | @Query("limit") limit: number = 10,
23 | ): Promise> {
24 | const clampedLimit = limit > 100 ? 100 : limit;
25 |
26 | return this.listensService.getListens({
27 | page,
28 | limit: clampedLimit,
29 | user,
30 | filter,
31 | });
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/listens/listens.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from "@nestjs/common";
2 | import { TypeOrmRepositoryModule } from "../database/entity-repository/typeorm-repository.module";
3 | import { ListenRepository } from "./listen.repository";
4 | import { ListensController } from "./listens.controller";
5 | import { ListensService } from "./listens.service";
6 |
7 | @Module({
8 | imports: [TypeOrmRepositoryModule.for([ListenRepository])],
9 | providers: [ListensService],
10 | exports: [ListensService],
11 | controllers: [ListensController],
12 | })
13 | export class ListensModule {}
14 |
--------------------------------------------------------------------------------
/src/listens/listens.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from "@nestjs/common";
2 | import { Span } from "nestjs-otel";
3 | import {
4 | IPaginationOptions,
5 | paginate,
6 | Pagination,
7 | PaginationTypeEnum,
8 | } from "nestjs-typeorm-paginate";
9 | import { CreateListenRequestDto } from "./dto/create-listen.dto";
10 | import { GetListensDto } from "./dto/get-listens.dto";
11 | import { Listen } from "./listen.entity";
12 | import { ListenRepository, ListenScopes } from "./listen.repository";
13 |
14 | @Injectable()
15 | export class ListensService {
16 | constructor(private readonly listenRepository: ListenRepository) {}
17 |
18 | @Span()
19 | async createListens(
20 | listensData: CreateListenRequestDto[],
21 | ): Promise {
22 | const existingListens = await this.listenRepository.findBy(listensData);
23 |
24 | const missingListens = listensData.filter(
25 | (newListen) =>
26 | !existingListens.some(
27 | (existingListen) =>
28 | newListen.user.id === existingListen.user.id &&
29 | newListen.track.id === existingListen.track.id &&
30 | newListen.playedAt.getTime() === existingListen.playedAt.getTime(),
31 | ),
32 | );
33 |
34 | const newListens = await this.listenRepository.save(
35 | missingListens.map((entry) => this.listenRepository.create(entry)),
36 | );
37 |
38 | return [...existingListens, ...newListens];
39 | }
40 |
41 | async getListens(
42 | options: GetListensDto & IPaginationOptions,
43 | ): Promise> {
44 | const { page, limit, user, filter } = options;
45 |
46 | let queryBuilder = this.listenRepository.scoped
47 | .byUser(user)
48 | .leftJoinAndSelect("listen.track", "track")
49 | .leftJoinAndSelect("track.artists", "artists")
50 | .leftJoinAndSelect("track.album", "album")
51 | .leftJoinAndSelect("album.artists", "albumArtists")
52 | .orderBy("listen.playedAt", "DESC");
53 |
54 | if (filter) {
55 | if (filter.time) {
56 | queryBuilder = queryBuilder.duringInterval(filter.time);
57 | }
58 | }
59 |
60 | return paginate(queryBuilder, {
61 | page,
62 | limit,
63 | paginationType: PaginationTypeEnum.TAKE_AND_SKIP,
64 | });
65 | }
66 |
67 | getScopedQueryBuilder(): ListenScopes {
68 | return this.listenRepository.scoped;
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/logger/logger.module.ts:
--------------------------------------------------------------------------------
1 | import { Module, RequestMethod } from "@nestjs/common";
2 | import { LoggerModule as PinoLoggerModule } from "nestjs-pino";
3 | import { logger } from "./logger";
4 |
5 | @Module({
6 | imports: [
7 | PinoLoggerModule.forRoot({
8 | pinoHttp: {
9 | logger: logger,
10 | autoLogging: true,
11 | quietReqLogger: true,
12 | redact: ["req.headers", "res.headers"],
13 | },
14 | exclude: [{ method: RequestMethod.ALL, path: "/" }],
15 | }),
16 | ],
17 | })
18 | export class LoggerModule {}
19 |
--------------------------------------------------------------------------------
/src/logger/logger.ts:
--------------------------------------------------------------------------------
1 | import { context, trace } from "@opentelemetry/api";
2 | import Pino, { Logger, LoggerOptions } from "pino";
3 |
4 | export const loggerOptions: LoggerOptions = {
5 | level: "debug",
6 | formatters: {
7 | level(label) {
8 | return { level: label };
9 | },
10 | log(object) {
11 | const span = trace.getSpan(context.active());
12 | if (!span) return { ...object };
13 | const { spanId, traceId } = trace
14 | .getSpan(context.active())
15 | ?.spanContext();
16 | return { ...object, spanId, traceId };
17 | },
18 | },
19 | transport:
20 | process.env.NODE_ENV === "local"
21 | ? {
22 | target: "pino-pretty",
23 | options: {
24 | colorize: true,
25 | levelFirst: true,
26 | translateTime: true,
27 | },
28 | }
29 | : null,
30 | };
31 |
32 | export const logger: Logger = Pino(loggerOptions);
33 |
--------------------------------------------------------------------------------
/src/music-library/album.entity.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Column,
3 | Entity,
4 | JoinTable,
5 | ManyToMany,
6 | OneToMany,
7 | PrimaryGeneratedColumn,
8 | } from "typeorm";
9 | import { SpotifyLibraryDetails } from "../sources/spotify/spotify-library-details.entity";
10 | import { Artist } from "./artist.entity";
11 | import { Track } from "./track.entity";
12 |
13 | @Entity()
14 | export class Album {
15 | @PrimaryGeneratedColumn("uuid")
16 | id: string;
17 |
18 | @Column()
19 | name: string;
20 |
21 | @ManyToMany(() => Artist, (artist) => artist.albums)
22 | @JoinTable({ name: "album_artists" })
23 | artists?: Artist[];
24 |
25 | @OneToMany(() => Track, (track) => track.album)
26 | tracks?: Track[];
27 |
28 | @Column(() => SpotifyLibraryDetails)
29 | spotify: SpotifyLibraryDetails;
30 | }
31 |
--------------------------------------------------------------------------------
/src/music-library/album.repository.ts:
--------------------------------------------------------------------------------
1 | import { Repository } from "typeorm";
2 | import { EntityRepository } from "../database/entity-repository";
3 | import { Album } from "./album.entity";
4 |
5 | @EntityRepository(Album)
6 | export class AlbumRepository extends Repository {}
7 |
--------------------------------------------------------------------------------
/src/music-library/artist.entity.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Column,
3 | Entity,
4 | JoinTable,
5 | ManyToMany,
6 | PrimaryGeneratedColumn,
7 | UpdateDateColumn,
8 | } from "typeorm";
9 | import { SpotifyLibraryDetails } from "../sources/spotify/spotify-library-details.entity";
10 | import { Album } from "./album.entity";
11 | import { Genre } from "./genre.entity";
12 |
13 | @Entity()
14 | export class Artist {
15 | @PrimaryGeneratedColumn("uuid")
16 | id: string;
17 |
18 | @Column()
19 | name: string;
20 |
21 | @ManyToMany(() => Album, (album) => album.artists)
22 | albums?: Album[];
23 |
24 | @ManyToMany(() => Genre)
25 | @JoinTable({ name: "artist_genres" })
26 | genres?: Genre[];
27 |
28 | @Column(() => SpotifyLibraryDetails)
29 | spotify: SpotifyLibraryDetails;
30 |
31 | @UpdateDateColumn({ type: "timestamp" })
32 | updatedAt: Date;
33 | }
34 |
--------------------------------------------------------------------------------
/src/music-library/artist.repository.ts:
--------------------------------------------------------------------------------
1 | import { Repository } from "typeorm";
2 | import { EntityRepository } from "../database/entity-repository";
3 | import { Artist } from "./artist.entity";
4 |
5 | @EntityRepository(Artist)
6 | export class ArtistRepository extends Repository {}
7 |
--------------------------------------------------------------------------------
/src/music-library/dto/create-album.dto.ts:
--------------------------------------------------------------------------------
1 | import { SpotifyLibraryDetails } from "../../sources/spotify/spotify-library-details.entity";
2 | import { Artist } from "../artist.entity";
3 |
4 | export class CreateAlbumDto {
5 | name: string;
6 | artists: Artist[];
7 | spotify?: SpotifyLibraryDetails;
8 | }
9 |
--------------------------------------------------------------------------------
/src/music-library/dto/create-artist.dto.ts:
--------------------------------------------------------------------------------
1 | import { SpotifyLibraryDetails } from "../../sources/spotify/spotify-library-details.entity";
2 | import { Genre } from "../genre.entity";
3 |
4 | export class CreateArtistDto {
5 | name: string;
6 | genres: Genre[];
7 | spotify?: SpotifyLibraryDetails;
8 | }
9 |
--------------------------------------------------------------------------------
/src/music-library/dto/create-genre.dto.ts:
--------------------------------------------------------------------------------
1 | export class CreateGenreDto {
2 | name: string;
3 | }
4 |
--------------------------------------------------------------------------------
/src/music-library/dto/create-track.dto.ts:
--------------------------------------------------------------------------------
1 | import { SpotifyLibraryDetails } from "../../sources/spotify/spotify-library-details.entity";
2 | import { Album } from "../album.entity";
3 | import { Artist } from "../artist.entity";
4 |
5 | export class CreateTrackDto {
6 | album: Album;
7 | artists: Artist[];
8 | name: string;
9 | spotify?: SpotifyLibraryDetails;
10 | }
11 |
--------------------------------------------------------------------------------
/src/music-library/dto/find-album.dto.ts:
--------------------------------------------------------------------------------
1 | export class FindAlbumDto {
2 | spotify: {
3 | id: string;
4 | };
5 | }
6 |
--------------------------------------------------------------------------------
/src/music-library/dto/find-artist.dto.ts:
--------------------------------------------------------------------------------
1 | export class FindArtistDto {
2 | spotify: {
3 | id: string;
4 | };
5 | }
6 |
--------------------------------------------------------------------------------
/src/music-library/dto/find-genre.dto.ts:
--------------------------------------------------------------------------------
1 | export class FindGenreDto {
2 | name: string;
3 | }
4 |
--------------------------------------------------------------------------------
/src/music-library/dto/find-track.dto.ts:
--------------------------------------------------------------------------------
1 | export class FindTrackDto {
2 | spotify: {
3 | id?: string;
4 | uri?: string;
5 | };
6 | }
7 |
--------------------------------------------------------------------------------
/src/music-library/dto/update-artist.dto.ts:
--------------------------------------------------------------------------------
1 | import { Artist } from "../artist.entity";
2 | import { Genre } from "../genre.entity";
3 |
4 | export class UpdateArtistDto {
5 | artist: Artist;
6 | updatedFields: {
7 | name: string;
8 | genres: Genre[];
9 | };
10 | }
11 |
--------------------------------------------------------------------------------
/src/music-library/genre.entity.ts:
--------------------------------------------------------------------------------
1 | import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
2 |
3 | @Entity()
4 | export class Genre {
5 | @PrimaryGeneratedColumn("uuid")
6 | id: string;
7 |
8 | @Column({ unique: true })
9 | name: string;
10 | }
11 |
--------------------------------------------------------------------------------
/src/music-library/genre.repository.ts:
--------------------------------------------------------------------------------
1 | import { Repository } from "typeorm";
2 | import { EntityRepository } from "../database/entity-repository";
3 | import { Genre } from "./genre.entity";
4 |
5 | @EntityRepository(Genre)
6 | export class GenreRepository extends Repository {}
7 |
--------------------------------------------------------------------------------
/src/music-library/music-library.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from "@nestjs/common";
2 | import { TypeOrmRepositoryModule } from "../database/entity-repository/typeorm-repository.module";
3 | import { AlbumRepository } from "./album.repository";
4 | import { ArtistRepository } from "./artist.repository";
5 | import { GenreRepository } from "./genre.repository";
6 | import { MusicLibraryService } from "./music-library.service";
7 | import { TrackRepository } from "./track.repository";
8 |
9 | @Module({
10 | imports: [
11 | TypeOrmRepositoryModule.for([
12 | AlbumRepository,
13 | ArtistRepository,
14 | GenreRepository,
15 | TrackRepository,
16 | ]),
17 | ],
18 | providers: [MusicLibraryService],
19 | exports: [MusicLibraryService],
20 | })
21 | export class MusicLibraryModule {}
22 |
--------------------------------------------------------------------------------
/src/music-library/track.entity.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Column,
3 | Entity,
4 | JoinTable,
5 | ManyToMany,
6 | ManyToOne,
7 | PrimaryGeneratedColumn,
8 | } from "typeorm";
9 | import { SpotifyLibraryDetails } from "../sources/spotify/spotify-library-details.entity";
10 | import { Album } from "./album.entity";
11 | import { Artist } from "./artist.entity";
12 |
13 | @Entity()
14 | export class Track {
15 | @PrimaryGeneratedColumn("uuid")
16 | id: string;
17 |
18 | @Column()
19 | name: string;
20 |
21 | @ManyToOne(() => Album, (album) => album.tracks)
22 | album?: Album;
23 |
24 | @ManyToMany(() => Artist)
25 | @JoinTable({ name: "track_artists" })
26 | artists?: Artist[];
27 |
28 | @Column(() => SpotifyLibraryDetails)
29 | spotify?: SpotifyLibraryDetails;
30 | }
31 |
--------------------------------------------------------------------------------
/src/music-library/track.repository.ts:
--------------------------------------------------------------------------------
1 | import { Repository } from "typeorm";
2 | import { EntityRepository } from "../database/entity-repository";
3 | import { Track } from "./track.entity";
4 |
5 | @EntityRepository(Track)
6 | export class TrackRepository extends Repository