├── .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 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 29 | 33 | 34 | 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 |
11 |
12 | 18 | v{VERSION} 19 | {" "} 20 |
21 |
22 | 28 | Check out on GitHub 29 | 30 |
31 |
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 |
34 |
38 |
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 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /frontend/src/icons/Error.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const ErrorIcon: React.FC> = (props) => { 4 | return ( 5 | 11 | 12 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /frontend/src/icons/Import.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | export const ImportIcon: React.FC> = (props) => ( 3 | 9 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /frontend/src/icons/Reload.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const ReloadIcon: React.FC> = (props) => { 4 | return ( 5 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /frontend/src/icons/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const SpinnerIcon: React.FC> = (props) => { 4 | return ( 5 | 14 | 18 | 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /frontend/src/icons/Spotify.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const SpotifyLogo: React.FC> = (props) => { 4 | return ( 5 | 13 | 17 | 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 | 15 | 19 | 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 {} 7 | -------------------------------------------------------------------------------- /src/open-telemetry/open-telemetry.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module, OnApplicationShutdown } from "@nestjs/common"; 2 | import { OpenTelemetryModule as UpstreamModule } from "nestjs-otel"; 3 | import { otelSDK } from "./sdk"; 4 | import { UrlValueParserService } from "./url-value-parser.service"; 5 | 6 | @Module({ 7 | imports: [ 8 | UpstreamModule.forRoot({ 9 | metrics: { 10 | hostMetrics: true, // Includes Host Metrics 11 | apiMetrics: { 12 | enable: true, // Includes api metrics 13 | ignoreUndefinedRoutes: false, //Records metrics for all URLs, even undefined ones 14 | }, 15 | }, 16 | }), 17 | ], 18 | providers: [UrlValueParserService], 19 | exports: [UpstreamModule, UrlValueParserService], 20 | }) 21 | @Global() 22 | export class OpenTelemetryModule implements OnApplicationShutdown { 23 | async onApplicationShutdown(): Promise { 24 | await otelSDK.shutdown(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/open-telemetry/sdk.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; 2 | import { PrometheusExporter } from "@opentelemetry/exporter-prometheus"; 3 | import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; 4 | import { DnsInstrumentation } from "@opentelemetry/instrumentation-dns"; 5 | import { ExpressInstrumentation } from "@opentelemetry/instrumentation-express"; 6 | import { HttpInstrumentation } from "@opentelemetry/instrumentation-http"; 7 | import { NestInstrumentation } from "@opentelemetry/instrumentation-nestjs-core"; 8 | import { PgInstrumentation } from "@opentelemetry/instrumentation-pg"; 9 | import { PinoInstrumentation } from "@opentelemetry/instrumentation-pino"; 10 | import { Resource } from "@opentelemetry/resources"; 11 | import { NodeSDK, NodeSDKConfiguration } from "@opentelemetry/sdk-node"; 12 | import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions"; 13 | import { hostname } from "os"; 14 | 15 | const metricsEnabled = process.env.OTEL_METRICS_ENABLED === "true"; 16 | const tracesEnabled = process.env.OTEL_TRACES_ENABLED === "true"; 17 | const anyEnabled = metricsEnabled || tracesEnabled; 18 | 19 | // We can not use ConfigService because the SDK needs to be initialized before 20 | // Nest is allowed to start. 21 | let sdkOptions: Partial = {}; 22 | 23 | if (metricsEnabled) { 24 | sdkOptions.metricReader = new PrometheusExporter(); 25 | } 26 | 27 | if (tracesEnabled) { 28 | sdkOptions.traceExporter = new OTLPTraceExporter({}); 29 | sdkOptions.contextManager = new AsyncLocalStorageContextManager(); 30 | sdkOptions.resource = new Resource({ 31 | [SemanticResourceAttributes.SERVICE_NAMESPACE]: "listory", 32 | [SemanticResourceAttributes.SERVICE_NAME]: "api", 33 | [SemanticResourceAttributes.SERVICE_INSTANCE_ID]: hostname(), 34 | }); 35 | } 36 | 37 | if (anyEnabled) { 38 | sdkOptions.instrumentations = [ 39 | new DnsInstrumentation(), 40 | new HttpInstrumentation(), 41 | new ExpressInstrumentation(), 42 | new NestInstrumentation(), 43 | new PgInstrumentation(), 44 | new PinoInstrumentation(), 45 | ]; 46 | } 47 | 48 | export const otelSDK = new NodeSDK(sdkOptions); 49 | -------------------------------------------------------------------------------- /src/open-telemetry/url-value-parser.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from "@nestjs/testing"; 2 | import { UrlValueParserService } from "./url-value-parser.service"; 3 | 4 | describe("UrlValueParserService", () => { 5 | let service: UrlValueParserService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [UrlValueParserService], 10 | }).compile(); 11 | 12 | service = module.get(UrlValueParserService); 13 | }); 14 | 15 | it("should be defined", () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | 19 | describe("replacePathValues", () => { 20 | it("works with default replacement", () => { 21 | const replaced = service.replacePathValues( 22 | "/in/world/14/userId/abca12d231", 23 | ); 24 | expect(replaced).toBe("/in/world/#val/userId/#val"); 25 | }); 26 | 27 | it("works with custom replacement", () => { 28 | const replaced = service.replacePathValues( 29 | "/in/world/14/userId/abca12d231", 30 | "", 31 | ); 32 | expect(replaced).toBe("/in/world//userId/"); 33 | }); 34 | 35 | it("works with negative decimal numbers", () => { 36 | const replaced = service.replacePathValues( 37 | "/some/path/-154/userId/-ABC363AFE2", 38 | ); 39 | expect(replaced).toBe("/some/path/#val/userId/-ABC363AFE2"); 40 | }); 41 | 42 | it("works with spotify ids", () => { 43 | const replaced = service.replacePathValues( 44 | "/v1/albums/2PzfMWIpq6JKucGhkS1X5M", 45 | ); 46 | expect(replaced).toBe("/v1/albums/#val"); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/open-telemetry/url-value-parser.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | 3 | // Service adapted from https://github.com/disjunction/url-value-parser 4 | 5 | const REPLACE_MASKS: RegExp[] = [ 6 | /^\-?\d+$/, 7 | 8 | /^(\d{2}|\d{4})\-\d\d\-\d\d$/, // date 9 | 10 | /^[\da-f]{8}\-[\da-f]{4}\-[\da-f]{4}\-[\da-f]{4}\-[\da-f]{12}$/, // UUID 11 | /^[\dA-F]{8}\-[\dA-F]{4}\-[\dA-F]{4}\-[\dA-F]{4}\-[\dA-F]{12}$/, // UUID uppercased 12 | 13 | // hex code sould have a consistent case 14 | /^[\da-f]{7,}$/, 15 | /^[\dA-F]{7,}$/, 16 | 17 | // base64 encoded with URL safe Base64 18 | /^[a-zA-Z0-9\-_]{22,}$/, 19 | 20 | // classic Base64 21 | /^(?:[A-Za-z0-9+/]{4}){16,}(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?/, 22 | ]; 23 | 24 | @Injectable() 25 | export class UrlValueParserService { 26 | /** 27 | * replacePathValues replaces IDs and other identifiers from URL paths. 28 | */ 29 | public replacePathValues(path: string, replacement: string = "#val"): string { 30 | const parseResult = this.parsePathValues(path); 31 | return ( 32 | "/" + 33 | parseResult.chunks 34 | .map((chunk, i) => 35 | parseResult.valueIndexes.includes(i) ? replacement : chunk, 36 | ) 37 | .join("/") 38 | ); 39 | } 40 | 41 | private getPathChunks(path: string): string[] { 42 | return path.split("/").filter((chunk) => chunk !== ""); 43 | } 44 | 45 | private parsePathValues(path: string): { 46 | chunks: string[]; 47 | valueIndexes: number[]; 48 | } { 49 | const chunks = this.getPathChunks(path); 50 | const valueIndexes = chunks 51 | .map((chunk, index) => (this.isValue(chunk) ? index : null)) 52 | .filter((index) => index !== null); 53 | 54 | return { chunks, valueIndexes }; 55 | } 56 | 57 | private isValue(str: string): boolean { 58 | for (let mask of REPLACE_MASKS) { 59 | if (str.match(mask)) { 60 | return true; 61 | } 62 | } 63 | return false; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/reports/dto/get-listen-report.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEnum, ValidateNested } from "class-validator"; 2 | import { User } from "../../users/user.entity"; 3 | import { Timeframe } from "../timeframe.enum"; 4 | import { ReportTimeDto } from "./report-time.dto"; 5 | 6 | export class GetListenReportDto { 7 | user: User; 8 | 9 | @IsEnum(Timeframe) 10 | timeFrame: Timeframe; 11 | 12 | @ValidateNested() 13 | time: ReportTimeDto; 14 | } 15 | -------------------------------------------------------------------------------- /src/reports/dto/get-top-albums-report.dto.ts: -------------------------------------------------------------------------------- 1 | import { ValidateNested } from "class-validator"; 2 | import { User } from "../../users/user.entity"; 3 | import { ReportTimeDto } from "./report-time.dto"; 4 | 5 | export class GetTopAlbumsReportDto { 6 | user: User; 7 | 8 | @ValidateNested() 9 | time: ReportTimeDto; 10 | } 11 | -------------------------------------------------------------------------------- /src/reports/dto/get-top-artists-report.dto.ts: -------------------------------------------------------------------------------- 1 | import { ValidateNested } from "class-validator"; 2 | import { User } from "../../users/user.entity"; 3 | import { ReportTimeDto } from "./report-time.dto"; 4 | 5 | export class GetTopArtistsReportDto { 6 | user: User; 7 | 8 | @ValidateNested() 9 | time: ReportTimeDto; 10 | } 11 | -------------------------------------------------------------------------------- /src/reports/dto/get-top-genres-report.dto.ts: -------------------------------------------------------------------------------- 1 | import { ValidateNested } from "class-validator"; 2 | import { User } from "../../users/user.entity"; 3 | import { ReportTimeDto } from "./report-time.dto"; 4 | 5 | export class GetTopGenresReportDto { 6 | user: User; 7 | 8 | @ValidateNested() 9 | time: ReportTimeDto; 10 | } 11 | -------------------------------------------------------------------------------- /src/reports/dto/get-top-tracks-report.dto.ts: -------------------------------------------------------------------------------- 1 | import { ValidateNested } from "class-validator"; 2 | import { User } from "../../users/user.entity"; 3 | import { ReportTimeDto } from "./report-time.dto"; 4 | 5 | export class GetTopTracksReportDto { 6 | user: User; 7 | 8 | @ValidateNested() 9 | time: ReportTimeDto; 10 | } 11 | -------------------------------------------------------------------------------- /src/reports/dto/listen-report.dto.ts: -------------------------------------------------------------------------------- 1 | export class ListenReportDto { 2 | items: { 3 | date: string; 4 | count: number; 5 | }[]; 6 | } 7 | -------------------------------------------------------------------------------- /src/reports/dto/report-time.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEnum, IsISO8601, ValidateIf } from "class-validator"; 2 | import { TimePreset } from "../timePreset.enum"; 3 | 4 | export class ReportTimeDto { 5 | @IsEnum(TimePreset) 6 | timePreset: TimePreset; 7 | 8 | @ValidateIf((o) => o.timePreset === TimePreset.CUSTOM) 9 | @IsISO8601() 10 | customTimeStart: string; 11 | 12 | @ValidateIf((o) => o.timePreset === TimePreset.CUSTOM) 13 | @IsISO8601() 14 | customTimeEnd: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/reports/dto/top-albums-report.dto.ts: -------------------------------------------------------------------------------- 1 | import { Album } from "../../music-library/album.entity"; 2 | 3 | export class TopAlbumsReportDto { 4 | items: { 5 | album: Album; 6 | count: number; 7 | }[]; 8 | } 9 | -------------------------------------------------------------------------------- /src/reports/dto/top-artists-report.dto.ts: -------------------------------------------------------------------------------- 1 | import { Artist } from "../../music-library/artist.entity"; 2 | 3 | export class TopArtistsReportDto { 4 | items: { 5 | artist: Artist; 6 | count: number; 7 | }[]; 8 | } 9 | -------------------------------------------------------------------------------- /src/reports/dto/top-genres-report.dto.ts: -------------------------------------------------------------------------------- 1 | import { Genre } from "../../music-library/genre.entity"; 2 | import { TopArtistsReportDto } from "./top-artists-report.dto"; 3 | 4 | export class TopGenresReportDto { 5 | items: { 6 | genre: Genre; 7 | artists: TopArtistsReportDto["items"]; 8 | count: number; 9 | }[]; 10 | } 11 | -------------------------------------------------------------------------------- /src/reports/dto/top-tracks-report.dto.ts: -------------------------------------------------------------------------------- 1 | import { Track } from "../../music-library/track.entity"; 2 | 3 | export class TopTracksReportDto { 4 | items: { 5 | track: Track; 6 | count: number; 7 | }[]; 8 | } 9 | -------------------------------------------------------------------------------- /src/reports/interval.d.ts: -------------------------------------------------------------------------------- 1 | export interface Interval { 2 | start: Date; 3 | end: Date; 4 | } 5 | -------------------------------------------------------------------------------- /src/reports/reports.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from "@nestjs/testing"; 2 | import { ReportsController } from "./reports.controller"; 3 | import { ReportsService } from "./reports.service"; 4 | 5 | describe("Reports Controller", () => { 6 | let controller: ReportsController; 7 | let reportsService: ReportsService; 8 | 9 | beforeEach(async () => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | controllers: [ReportsController], 12 | providers: [{ provide: ReportsService, useFactory: () => ({}) }], 13 | }).compile(); 14 | 15 | controller = module.get(ReportsController); 16 | reportsService = module.get(ReportsService); 17 | }); 18 | 19 | it("should be defined", () => { 20 | expect(controller).toBeDefined(); 21 | expect(reportsService).toBeDefined(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/reports/reports.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Query } from "@nestjs/common"; 2 | import { ApiTags } from "@nestjs/swagger"; 3 | import { AuthAccessToken } from "../auth/decorators/auth-access-token.decorator"; 4 | import { ReqUser } from "../auth/decorators/req-user.decorator"; 5 | import { User } from "../users/user.entity"; 6 | import { ListenReportDto } from "./dto/listen-report.dto"; 7 | import { ReportTimeDto } from "./dto/report-time.dto"; 8 | import { TopAlbumsReportDto } from "./dto/top-albums-report.dto"; 9 | import { TopArtistsReportDto } from "./dto/top-artists-report.dto"; 10 | import { TopGenresReportDto } from "./dto/top-genres-report.dto"; 11 | import { TopTracksReportDto } from "./dto/top-tracks-report.dto"; 12 | import { ReportsService } from "./reports.service"; 13 | import { Timeframe } from "./timeframe.enum"; 14 | 15 | @ApiTags("reports") 16 | @Controller("api/v1/reports") 17 | export class ReportsController { 18 | constructor(private readonly reportsService: ReportsService) {} 19 | 20 | @Get("listens") 21 | @AuthAccessToken() 22 | async getListens( 23 | @Query() time: ReportTimeDto, 24 | @Query("timeFrame") timeFrame: Timeframe, 25 | @ReqUser() user: User, 26 | ): Promise { 27 | return this.reportsService.getListens({ user, timeFrame, time }); 28 | } 29 | 30 | @Get("top-artists") 31 | @AuthAccessToken() 32 | async getTopArtists( 33 | @Query() time: ReportTimeDto, 34 | @ReqUser() user: User, 35 | ): Promise { 36 | return this.reportsService.getTopArtists({ user, time }); 37 | } 38 | 39 | @Get("top-albums") 40 | @AuthAccessToken() 41 | async getTopAlbums( 42 | @Query() time: ReportTimeDto, 43 | @ReqUser() user: User, 44 | ): Promise { 45 | return this.reportsService.getTopAlbums({ user, time }); 46 | } 47 | 48 | @Get("top-tracks") 49 | @AuthAccessToken() 50 | async getTopTracks( 51 | @Query() time: ReportTimeDto, 52 | @ReqUser() user: User, 53 | ): Promise { 54 | return this.reportsService.getTopTracks({ user, time }); 55 | } 56 | 57 | @Get("top-genres") 58 | @AuthAccessToken() 59 | async getTopGenres( 60 | @Query() time: ReportTimeDto, 61 | @ReqUser() user: User, 62 | ): Promise { 63 | return this.reportsService.getTopGenres({ user, time }); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/reports/reports.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { ListensModule } from "../listens/listens.module"; 3 | import { ReportsController } from "./reports.controller"; 4 | import { ReportsService } from "./reports.service"; 5 | 6 | @Module({ 7 | imports: [ListensModule], 8 | providers: [ReportsService], 9 | controllers: [ReportsController], 10 | }) 11 | export class ReportsModule {} 12 | -------------------------------------------------------------------------------- /src/reports/reports.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from "@nestjs/testing"; 2 | import { ListensService } from "../listens/listens.service"; 3 | import { ReportsService } from "./reports.service"; 4 | 5 | describe("ReportsService", () => { 6 | let service: ReportsService; 7 | let listensService: ListensService; 8 | 9 | beforeEach(async () => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | providers: [ 12 | ReportsService, 13 | { provide: ListensService, useFactory: () => ({}) }, 14 | ], 15 | }).compile(); 16 | 17 | service = module.get(ReportsService); 18 | listensService = module.get(ListensService); 19 | }); 20 | 21 | it("should be defined", () => { 22 | expect(service).toBeDefined(); 23 | expect(listensService).toBeDefined(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/reports/timePreset.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 | -------------------------------------------------------------------------------- /src/reports/timeframe.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Timeframe { 2 | Day = "day", 3 | Week = "week", 4 | Month = "month", 5 | Year = "year", 6 | } 7 | -------------------------------------------------------------------------------- /src/sources/jobs.ts: -------------------------------------------------------------------------------- 1 | import { createJob } from "@apricote/nest-pg-boss"; 2 | 3 | export type ICrawlerSupervisorJob = {}; 4 | export const CrawlerSupervisorJob = createJob( 5 | "spotify-crawler-supervisor", 6 | ); 7 | 8 | export type IUpdateSpotifyLibraryJob = {}; 9 | export const UpdateSpotifyLibraryJob = createJob( 10 | "update-spotify-library", 11 | ); 12 | 13 | export type IImportSpotifyJob = { userID: string }; 14 | export const ImportSpotifyJob = createJob("import-spotify"); 15 | -------------------------------------------------------------------------------- /src/sources/sources.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { PGBossModule } from "@apricote/nest-pg-boss"; 3 | import { 4 | CrawlerSupervisorJob, 5 | ImportSpotifyJob, 6 | UpdateSpotifyLibraryJob, 7 | } from "./jobs"; 8 | import { SchedulerService } from "./scheduler.service"; 9 | import { SpotifyModule } from "./spotify/spotify.module"; 10 | 11 | @Module({ 12 | imports: [ 13 | SpotifyModule, 14 | PGBossModule.forJobs([ 15 | CrawlerSupervisorJob, 16 | ImportSpotifyJob, 17 | UpdateSpotifyLibraryJob, 18 | ]), 19 | ], 20 | providers: [SchedulerService], 21 | }) 22 | export class SourcesModule {} 23 | -------------------------------------------------------------------------------- /src/sources/spotify/import-extended-streaming-history/dto/extended-streaming-history-status.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | 3 | export class ExtendedStreamingHistoryStatusDto { 4 | @ApiProperty({ 5 | type: Number, 6 | }) 7 | total: number; 8 | 9 | @ApiProperty({ 10 | type: Number, 11 | }) 12 | imported: number; 13 | } 14 | -------------------------------------------------------------------------------- /src/sources/spotify/import-extended-streaming-history/dto/import-extended-streaming-history.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { ArrayMaxSize } from "class-validator"; 3 | import { SpotifyExtendedStreamingHistoryItemDto } from "./spotify-extended-streaming-history-item.dto"; 4 | 5 | export class ImportExtendedStreamingHistoryDto { 6 | @ApiProperty({ 7 | type: SpotifyExtendedStreamingHistoryItemDto, 8 | isArray: true, 9 | maxItems: 50_000, 10 | }) 11 | @ArrayMaxSize(50_000) // File size is ~16k by default, might need refactoring if Spotify starts exporting larger files 12 | listens: SpotifyExtendedStreamingHistoryItemDto[]; 13 | } 14 | -------------------------------------------------------------------------------- /src/sources/spotify/import-extended-streaming-history/dto/spotify-extended-streaming-history-item.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | 3 | export class SpotifyExtendedStreamingHistoryItemDto { 4 | @ApiProperty({ format: "iso8601", example: "2018-11-30T08:33:33Z" }) 5 | ts: string; 6 | 7 | @ApiProperty({ example: "spotify:track:6askbS4pEVWbbDnUGEXh3G" }) 8 | spotify_track_uri: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/sources/spotify/import-extended-streaming-history/import.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body as NestBody, Controller, Get, Post } from "@nestjs/common"; 2 | import { ApiBody, ApiTags } from "@nestjs/swagger"; 3 | import { AuthAccessToken } from "../../../auth/decorators/auth-access-token.decorator"; 4 | import { ReqUser } from "../../../auth/decorators/req-user.decorator"; 5 | import { User } from "../../../users/user.entity"; 6 | import { ExtendedStreamingHistoryStatusDto } from "./dto/extended-streaming-history-status.dto"; 7 | import { ImportExtendedStreamingHistoryDto } from "./dto/import-extended-streaming-history.dto"; 8 | import { ImportService } from "./import.service"; 9 | 10 | @ApiTags("import") 11 | @Controller("api/v1/import") 12 | export class ImportController { 13 | constructor(private readonly importService: ImportService) {} 14 | 15 | @Post("extended-streaming-history") 16 | @ApiBody({ type: () => ImportExtendedStreamingHistoryDto }) 17 | @AuthAccessToken() 18 | async importExtendedStreamingHistory( 19 | @ReqUser() user: User, 20 | @NestBody() data: ImportExtendedStreamingHistoryDto, 21 | ): Promise { 22 | return this.importService.importExtendedStreamingHistory(user, data); 23 | } 24 | 25 | @Get("extended-streaming-history/status") 26 | @AuthAccessToken() 27 | async getExtendedStreamingHistoryStatus( 28 | @ReqUser() user: User, 29 | ): Promise { 30 | return this.importService.getExtendedStreamingHistoryStatus(user); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/sources/spotify/import-extended-streaming-history/index.ts: -------------------------------------------------------------------------------- 1 | export { ImportController } from "./import.controller"; 2 | export { ImportService } from "./import.service"; 3 | export { ProcessSpotifyExtendedStreamingHistoryListenJob } from "./jobs"; 4 | export { SpotifyExtendedStreamingHistoryListenRepository } from "./listen.repository"; 5 | -------------------------------------------------------------------------------- /src/sources/spotify/import-extended-streaming-history/jobs.ts: -------------------------------------------------------------------------------- 1 | import { createJob } from "@apricote/nest-pg-boss"; 2 | 3 | export type IProcessSpotifyExtendedStreamingHistoryListenJob = { id: string }; 4 | export const ProcessSpotifyExtendedStreamingHistoryListenJob = 5 | createJob( 6 | "process-spotify-extended-streaming-history-listen", 7 | ); 8 | -------------------------------------------------------------------------------- /src/sources/spotify/import-extended-streaming-history/listen.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; 2 | import { Track } from "../../../music-library/track.entity"; 3 | import { User } from "../../../users/user.entity"; 4 | import { Listen } from "../../../listens/listen.entity"; 5 | 6 | @Entity({ name: "spotify_extended_streaming_history_listen" }) 7 | export class SpotifyExtendedStreamingHistoryListen { 8 | @PrimaryGeneratedColumn("uuid") 9 | id: string; 10 | 11 | @ManyToOne(() => User, { eager: true }) 12 | user: User; 13 | 14 | @Column({ type: "timestamp" }) 15 | playedAt: Date; 16 | 17 | @Column() 18 | spotifyTrackUri: string; 19 | 20 | @ManyToOne(() => Track, { nullable: true, eager: true }) 21 | track?: Track; 22 | 23 | @ManyToOne(() => Listen, { nullable: true, eager: true }) 24 | listen?: Listen; 25 | } 26 | -------------------------------------------------------------------------------- /src/sources/spotify/import-extended-streaming-history/listen.repository.ts: -------------------------------------------------------------------------------- 1 | import { Repository } from "typeorm"; 2 | import { EntityRepository } from "../../../database/entity-repository"; 3 | import { SpotifyExtendedStreamingHistoryListen } from "./listen.entity"; 4 | 5 | @EntityRepository(SpotifyExtendedStreamingHistoryListen) 6 | export class SpotifyExtendedStreamingHistoryListenRepository extends Repository {} 7 | -------------------------------------------------------------------------------- /src/sources/spotify/spotify-api/entities/album-object.ts: -------------------------------------------------------------------------------- 1 | import { PagingObject } from "./paging-object"; 2 | import { SimplifiedTrackObject } from "./simplified-track-object"; 3 | import { SimplifiedArtistObject } from "./simplified-artist-object"; 4 | 5 | // tslint:disable: variable-name 6 | 7 | export class AlbumObject { 8 | /** 9 | * The artists of the album. 10 | * Each artist object includes a link in href to more detailed information about the artist. 11 | */ 12 | artists: SimplifiedArtistObject[]; 13 | 14 | /** 15 | * A link to the Web API endpoint providing full details of the album. 16 | */ 17 | href: string; 18 | 19 | /** 20 | * The Spotify ID for the album. 21 | */ 22 | id: string; 23 | 24 | /** 25 | * The label for the album. 26 | */ 27 | label: string; 28 | 29 | /** 30 | * The name of the album. 31 | * In case of an album takedown, the value may be an empty string. 32 | */ 33 | name: string; 34 | 35 | /** 36 | * The object type: "album". 37 | */ 38 | type: "album"; 39 | 40 | /** 41 | * The Spotify URI for the album. 42 | */ 43 | uri: string; 44 | 45 | /** 46 | * The date the album was first released, for example `1981`. 47 | * Depending on the precision, it might be shown as `1981-12` or `1981-12-15`. 48 | */ 49 | release_date: string; 50 | 51 | /** 52 | * The precision with which `release_date` value is known: `year` , `month` , or `day`. 53 | */ 54 | release_date_precision: "year" | "month" | "day"; 55 | 56 | /** 57 | * The tracks of the album. 58 | */ 59 | tracks: PagingObject; 60 | } 61 | -------------------------------------------------------------------------------- /src/sources/spotify/spotify-api/entities/artist-object.ts: -------------------------------------------------------------------------------- 1 | export class ArtistObject { 2 | /** 3 | * A list of the genres the artist is associated with. 4 | * For example: "Prog Rock" , "Post-Grunge". 5 | * (If not yet classified, the array is empty.) 6 | */ 7 | genres: string[]; 8 | /** 9 | * A link to the Web API endpoint providing full details of the artist. 10 | */ 11 | href: string; 12 | 13 | /** 14 | * The Spotify ID for the artist. 15 | */ 16 | id: string; 17 | 18 | /** 19 | * The name of the artist. 20 | */ 21 | name: string; 22 | 23 | /** 24 | * The object type: "artist". 25 | */ 26 | type: "artist"; 27 | 28 | /** 29 | * The Spotify URI for the artist. 30 | */ 31 | uri: string; 32 | } 33 | -------------------------------------------------------------------------------- /src/sources/spotify/spotify-api/entities/context-object.ts: -------------------------------------------------------------------------------- 1 | import { ExternalUrlObject } from "./external-url-object"; 2 | 3 | // tslint:disable variable-name 4 | 5 | export class ContextObject { 6 | /** 7 | * External URLs for this context. 8 | */ 9 | external_urls: ExternalUrlObject; 10 | 11 | /** 12 | * A link to the Web API endpoint providing full details of the track. 13 | */ 14 | href: string; 15 | 16 | /** 17 | * The object type, e.g. "artist", "playlist", "album". 18 | */ 19 | type: string; 20 | 21 | /** 22 | * The Spotify URI for the context. 23 | */ 24 | uri: string; 25 | } 26 | -------------------------------------------------------------------------------- /src/sources/spotify/spotify-api/entities/external-url-object.ts: -------------------------------------------------------------------------------- 1 | export class ExternalUrlObject { 2 | // No documentation for this exists 3 | } 4 | -------------------------------------------------------------------------------- /src/sources/spotify/spotify-api/entities/paging-object.ts: -------------------------------------------------------------------------------- 1 | export class PagingObject { 2 | /** 3 | * A link to the Web API endpoint returning the full result of the request 4 | */ 5 | href: string; 6 | 7 | /** 8 | * The requested data 9 | */ 10 | items: T[]; 11 | 12 | /** 13 | * The maximum number of items in the response (as set in the query or by default). 14 | */ 15 | limit: number; 16 | 17 | /** 18 | * URL to the next page of items. ( null if none) 19 | */ 20 | next: string; 21 | 22 | /** 23 | * The offset of the items returned (as set in the query or by default) 24 | */ 25 | offset: number; 26 | 27 | /** 28 | * URL to the previous page of items. ( null if none) 29 | */ 30 | previous: string; 31 | 32 | /** 33 | * The total number of items available to return. 34 | */ 35 | total: number; 36 | } 37 | -------------------------------------------------------------------------------- /src/sources/spotify/spotify-api/entities/play-history-object.ts: -------------------------------------------------------------------------------- 1 | import { ContextObject } from "./context-object"; 2 | import { SimplifiedTrackObject } from "./simplified-track-object"; 3 | 4 | // tslint:disable variable-name 5 | 6 | export class PlayHistoryObject { 7 | /** 8 | * The context the track was played from. 9 | */ 10 | context: ContextObject; 11 | 12 | /** 13 | * The date and time the track was played. 14 | */ 15 | played_at: string; 16 | 17 | /** 18 | * The track the user listened to. 19 | */ 20 | track: SimplifiedTrackObject; 21 | } 22 | -------------------------------------------------------------------------------- /src/sources/spotify/spotify-api/entities/simplified-album-object.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable: variable-name 2 | 3 | export class SimplifiedAlbumObject { 4 | album_type: "album" | "single" | "compilation"; 5 | 6 | /** 7 | * A link to the Web API endpoint providing full details of the album. 8 | */ 9 | href: string; 10 | 11 | /** 12 | * The Spotify ID for the album. 13 | */ 14 | id: string; 15 | 16 | /** 17 | * The name of the album. In case of an album takedown, the value may be an empty string. 18 | */ 19 | name: string; 20 | 21 | /** 22 | * The object type: "album". 23 | */ 24 | type: "album"; 25 | 26 | /** 27 | * The Spotify URI for the album. 28 | */ 29 | uri: string; 30 | 31 | /** 32 | * The date the album was first released, for example `1981`. 33 | * Depending on the precision, it might be shown as `1981-12` or `1981-12-15`. 34 | */ 35 | release_date: string; 36 | 37 | /** 38 | * The precision with which `release_date` value is known: `year` , `month` , or `day`. 39 | */ 40 | release_date_precision: "year" | "month" | "day"; 41 | } 42 | -------------------------------------------------------------------------------- /src/sources/spotify/spotify-api/entities/simplified-artist-object.ts: -------------------------------------------------------------------------------- 1 | export class SimplifiedArtistObject { 2 | /** 3 | * A link to the Web API endpoint providing full details of the artist. 4 | */ 5 | href: string; 6 | 7 | /** 8 | * The Spotify ID for the artist. 9 | */ 10 | id: string; 11 | 12 | /** 13 | * The name of the artist. 14 | */ 15 | name: string; 16 | 17 | /** 18 | * The object type: "artist". 19 | */ 20 | type: "artist"; 21 | 22 | /** 23 | * The Spotify URI for the artist. 24 | */ 25 | uri: string; 26 | } 27 | -------------------------------------------------------------------------------- /src/sources/spotify/spotify-api/entities/simplified-track-object.ts: -------------------------------------------------------------------------------- 1 | export class SimplifiedTrackObject { 2 | /** 3 | * The Spotify ID for the track. 4 | */ 5 | id: string; 6 | 7 | /** 8 | * The name of the track. 9 | */ 10 | name: string; 11 | 12 | /** 13 | * The Spotify URI for the track. 14 | */ 15 | uri: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/sources/spotify/spotify-api/entities/track-object.ts: -------------------------------------------------------------------------------- 1 | import { SimplifiedAlbumObject } from "./simplified-album-object"; 2 | import { SimplifiedArtistObject } from "./simplified-artist-object"; 3 | 4 | // tslint:disable: variable-name 5 | 6 | export class TrackObject { 7 | /** 8 | * The album on which the track appears. The album object includes a link in href to full information about the album. 9 | */ 10 | album: SimplifiedAlbumObject; 11 | 12 | /** 13 | * The album on which the track appears. The album object includes a link in href to full information about the album. 14 | */ 15 | artists: SimplifiedArtistObject[]; 16 | 17 | /** 18 | * The track length in milliseconds. 19 | */ 20 | duration_ms: number; 21 | 22 | /** 23 | * A link to the Web API endpoint providing full details of the track. 24 | */ 25 | href: string; 26 | 27 | /** 28 | * The Spotify ID for the track. 29 | */ 30 | id: string; 31 | 32 | /** 33 | * The name of the track. 34 | */ 35 | name: string; 36 | 37 | /** 38 | * The object type: "track". 39 | */ 40 | type: "track"; 41 | 42 | /** 43 | * The Spotify URI for the track. 44 | */ 45 | uri: string; 46 | } 47 | -------------------------------------------------------------------------------- /src/sources/spotify/spotify-api/spotify-api.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpModule } from "@nestjs/axios"; 2 | import { Module } from "@nestjs/common"; 3 | import { ConfigService } from "@nestjs/config"; 4 | import { MetricsInterceptor } from "./metrics.axios-interceptor"; 5 | import { SpotifyApiService } from "./spotify-api.service"; 6 | 7 | @Module({ 8 | imports: [ 9 | HttpModule.registerAsync({ 10 | useFactory: (config: ConfigService) => ({ 11 | timeout: 5000, 12 | baseURL: config.get("SPOTIFY_WEB_API_URL"), 13 | }), 14 | inject: [ConfigService], 15 | }), 16 | ], 17 | providers: [SpotifyApiService, MetricsInterceptor], 18 | exports: [SpotifyApiService], 19 | }) 20 | export class SpotifyApiModule {} 21 | -------------------------------------------------------------------------------- /src/sources/spotify/spotify-api/spotify-api.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpService } from "@nestjs/axios"; 2 | import { Test, TestingModule } from "@nestjs/testing"; 3 | import { SpotifyApiService } from "./spotify-api.service"; 4 | 5 | describe("SpotifyApiService", () => { 6 | let service: SpotifyApiService; 7 | let httpService: HttpService; 8 | 9 | beforeEach(async () => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | providers: [ 12 | SpotifyApiService, 13 | { provide: HttpService, useFactory: () => ({}) }, 14 | ], 15 | }).compile(); 16 | 17 | service = module.get(SpotifyApiService); 18 | httpService = module.get(HttpService); 19 | }); 20 | 21 | it("should be defined", () => { 22 | expect(service).toBeDefined(); 23 | expect(httpService).toBeDefined(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/sources/spotify/spotify-auth/spotify-auth.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpModule } from "@nestjs/axios"; 2 | import { Module } from "@nestjs/common"; 3 | import { ConfigService } from "@nestjs/config"; 4 | import { SpotifyAuthService } from "./spotify-auth.service"; 5 | 6 | @Module({ 7 | imports: [ 8 | HttpModule.registerAsync({ 9 | useFactory: (config: ConfigService) => ({ 10 | timeout: 5000, 11 | baseURL: config.get("SPOTIFY_AUTH_API_URL"), 12 | }), 13 | inject: [ConfigService], 14 | }), 15 | ], 16 | providers: [SpotifyAuthService], 17 | exports: [SpotifyAuthService], 18 | }) 19 | export class SpotifyAuthModule {} 20 | -------------------------------------------------------------------------------- /src/sources/spotify/spotify-auth/spotify-auth.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpService } from "@nestjs/axios"; 2 | import { Injectable } from "@nestjs/common"; 3 | import { ConfigService } from "@nestjs/config"; 4 | import { firstValueFrom } from "rxjs"; 5 | import { SpotifyConnection } from "../spotify-connection.entity"; 6 | 7 | @Injectable() 8 | export class SpotifyAuthService { 9 | private readonly clientID: string; 10 | private readonly clientSecret: string; 11 | 12 | constructor( 13 | private readonly httpService: HttpService, 14 | config: ConfigService, 15 | ) { 16 | this.clientID = config.get("SPOTIFY_CLIENT_ID"); 17 | this.clientSecret = config.get("SPOTIFY_CLIENT_SECRET"); 18 | } 19 | 20 | async clientCredentialsGrant(): Promise { 21 | const response = await firstValueFrom( 22 | this.httpService.post<{ access_token: string }>( 23 | `api/token`, 24 | "grant_type=client_credentials", 25 | { 26 | auth: { 27 | username: this.clientID, 28 | password: this.clientSecret, 29 | }, 30 | }, 31 | ), 32 | ); 33 | 34 | return response.data.access_token; 35 | } 36 | 37 | async refreshAccessToken(connection: SpotifyConnection): Promise { 38 | const response = await firstValueFrom( 39 | this.httpService.post( 40 | `api/token`, 41 | `grant_type=refresh_token&refresh_token=${connection.refreshToken}`, 42 | { 43 | auth: { 44 | username: this.clientID, 45 | password: this.clientSecret, 46 | }, 47 | }, 48 | ), 49 | ); 50 | 51 | return response.data.access_token; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/sources/spotify/spotify-connection.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column } from "typeorm"; 2 | 3 | export class SpotifyConnection { 4 | @Column() 5 | id: string; 6 | 7 | @Column() 8 | accessToken: string; 9 | 10 | @Column() 11 | refreshToken: string; 12 | 13 | @Column({ type: "timestamp", nullable: true }) 14 | lastRefreshTime?: Date; 15 | } 16 | -------------------------------------------------------------------------------- /src/sources/spotify/spotify-library-details.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column } from "typeorm"; 2 | 3 | export class SpotifyLibraryDetails { 4 | @Column() 5 | id: string; 6 | 7 | @Column() 8 | uri: string; 9 | 10 | @Column() 11 | type: string; 12 | 13 | @Column() 14 | href: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/sources/spotify/spotify.module.ts: -------------------------------------------------------------------------------- 1 | import { PGBossModule } from "@apricote/nest-pg-boss"; 2 | import { Module } from "@nestjs/common"; 3 | import { TypeOrmRepositoryModule } from "../../database/entity-repository/typeorm-repository.module"; 4 | import { ListensModule } from "../../listens/listens.module"; 5 | import { MusicLibraryModule } from "../../music-library/music-library.module"; 6 | import { UsersModule } from "../../users/users.module"; 7 | import { ImportSpotifyJob } from "../jobs"; 8 | import { 9 | ImportController, 10 | ImportService, 11 | ProcessSpotifyExtendedStreamingHistoryListenJob, 12 | SpotifyExtendedStreamingHistoryListenRepository, 13 | } from "./import-extended-streaming-history"; 14 | import { SpotifyApiModule } from "./spotify-api/spotify-api.module"; 15 | import { SpotifyAuthModule } from "./spotify-auth/spotify-auth.module"; 16 | import { SpotifyService } from "./spotify.service"; 17 | 18 | @Module({ 19 | imports: [ 20 | PGBossModule.forJobs([ 21 | ImportSpotifyJob, 22 | ProcessSpotifyExtendedStreamingHistoryListenJob, 23 | ]), 24 | TypeOrmRepositoryModule.for([ 25 | SpotifyExtendedStreamingHistoryListenRepository, 26 | ]), 27 | UsersModule, 28 | ListensModule, 29 | MusicLibraryModule, 30 | SpotifyApiModule, 31 | SpotifyAuthModule, 32 | ], 33 | providers: [SpotifyService, ImportService], 34 | controllers: [ImportController], 35 | exports: [SpotifyService], 36 | }) 37 | export class SpotifyModule {} 38 | -------------------------------------------------------------------------------- /src/sources/spotify/spotify.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from "@nestjs/testing"; 2 | import { ListensService } from "../../listens/listens.service"; 3 | import { MusicLibraryService } from "../../music-library/music-library.service"; 4 | import { UsersService } from "../../users/users.service"; 5 | import { SpotifyApiService } from "./spotify-api/spotify-api.service"; 6 | import { SpotifyAuthService } from "./spotify-auth/spotify-auth.service"; 7 | import { SpotifyService } from "./spotify.service"; 8 | 9 | describe("SpotifyService", () => { 10 | let service: SpotifyService; 11 | let usersService: UsersService; 12 | let listensService: ListensService; 13 | let musicLibraryService: MusicLibraryService; 14 | let spotifyApi: SpotifyApiService; 15 | let spotifyAuth: SpotifyAuthService; 16 | 17 | beforeEach(async () => { 18 | const module: TestingModule = await Test.createTestingModule({ 19 | providers: [ 20 | SpotifyService, 21 | { provide: UsersService, useFactory: () => ({}) }, 22 | { provide: ListensService, useFactory: () => ({}) }, 23 | { provide: MusicLibraryService, useFactory: () => ({}) }, 24 | { provide: SpotifyApiService, useFactory: () => ({}) }, 25 | { provide: SpotifyAuthService, useFactory: () => ({}) }, 26 | ], 27 | }).compile(); 28 | 29 | service = module.get(SpotifyService); 30 | usersService = module.get(UsersService); 31 | listensService = module.get(ListensService); 32 | musicLibraryService = module.get(MusicLibraryService); 33 | spotifyApi = module.get(SpotifyApiService); 34 | spotifyAuth = module.get(SpotifyAuthService); 35 | }); 36 | 37 | it("should be defined", () => { 38 | expect(service).toBeDefined(); 39 | expect(usersService).toBeDefined(); 40 | expect(listensService).toBeDefined(); 41 | expect(musicLibraryService).toBeDefined(); 42 | expect(spotifyApi).toBeDefined(); 43 | expect(spotifyAuth).toBeDefined(); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/users/dto/create-or-update.dto.ts: -------------------------------------------------------------------------------- 1 | export class CreateOrUpdateDto { 2 | displayName: string; 3 | photo?: string; 4 | 5 | spotify: { 6 | id: string; 7 | accessToken: string; 8 | refreshToken: string; 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/users/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; 2 | import { SpotifyConnection } from "../sources/spotify/spotify-connection.entity"; 3 | 4 | @Entity() 5 | export class User { 6 | @PrimaryGeneratedColumn("uuid") 7 | id: string; 8 | 9 | @Column() 10 | displayName: string; 11 | 12 | @Column({ nullable: true }) 13 | photo?: string; 14 | 15 | @Column(() => SpotifyConnection) 16 | spotify: SpotifyConnection; 17 | } 18 | -------------------------------------------------------------------------------- /src/users/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { Repository } from "typeorm"; 2 | import { EntityRepository } from "../database/entity-repository"; 3 | import { User } from "./user.entity"; 4 | 5 | @EntityRepository(User) 6 | export class UserRepository extends Repository {} 7 | -------------------------------------------------------------------------------- /src/users/users.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from "@nestjs/testing"; 2 | import { UsersController } from "./users.controller"; 3 | 4 | describe("Users Controller", () => { 5 | let controller: UsersController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [UsersController], 10 | }).compile(); 11 | 12 | controller = module.get(UsersController); 13 | }); 14 | 15 | it("should be defined", () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from "@nestjs/common"; 2 | import { ApiTags } from "@nestjs/swagger"; 3 | import { AuthAccessToken } from "../auth/decorators/auth-access-token.decorator"; 4 | import { ReqUser } from "../auth/decorators/req-user.decorator"; 5 | import { User } from "./user.entity"; 6 | 7 | @ApiTags("users") 8 | @Controller("api/v1/users") 9 | export class UsersController { 10 | @Get("me") 11 | @AuthAccessToken() 12 | getMe(@ReqUser() user: User): Omit { 13 | return { 14 | id: user.id, 15 | displayName: user.displayName, 16 | photo: user.photo, 17 | }; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { PGBossModule } from "@apricote/nest-pg-boss"; 2 | import { Module } from "@nestjs/common"; 3 | import { TypeOrmRepositoryModule } from "../database/entity-repository/typeorm-repository.module"; 4 | import { ImportSpotifyJob } from "../sources/jobs"; 5 | import { UserRepository } from "./user.repository"; 6 | import { UsersController } from "./users.controller"; 7 | import { UsersService } from "./users.service"; 8 | 9 | @Module({ 10 | imports: [ 11 | TypeOrmRepositoryModule.for([UserRepository]), 12 | PGBossModule.forJobs([ImportSpotifyJob]), 13 | ], 14 | providers: [UsersService], 15 | exports: [UsersService], 16 | controllers: [UsersController], 17 | }) 18 | export class UsersModule {} 19 | -------------------------------------------------------------------------------- /src/users/users.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { JobService } from "@apricote/nest-pg-boss"; 2 | import { Test, TestingModule } from "@nestjs/testing"; 3 | import { IImportSpotifyJob, ImportSpotifyJob } from "../sources/jobs"; 4 | import { UserRepository } from "./user.repository"; 5 | import { UsersService } from "./users.service"; 6 | 7 | describe("UsersService", () => { 8 | let service: UsersService; 9 | let userRepository: UserRepository; 10 | let importSpotifyJobService: JobService; 11 | 12 | beforeEach(async () => { 13 | const module: TestingModule = await Test.createTestingModule({ 14 | providers: [ 15 | UsersService, 16 | { provide: UserRepository, useFactory: () => ({}) }, 17 | { 18 | provide: ImportSpotifyJob.ServiceProvider.provide, 19 | useFactory: () => ({}), 20 | }, 21 | ], 22 | }).compile(); 23 | 24 | service = module.get(UsersService); 25 | userRepository = module.get(UserRepository); 26 | importSpotifyJobService = module.get>( 27 | ImportSpotifyJob.ServiceProvider.provide, 28 | ); 29 | }); 30 | 31 | it("should be defined", () => { 32 | expect(service).toBeDefined(); 33 | expect(userRepository).toBeDefined(); 34 | expect(importSpotifyJobService).toBeDefined(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { SelectQueryBuilder } from "typeorm"; 2 | import { JobService } from "@apricote/nest-pg-boss"; 3 | import { Injectable, NotFoundException } from "@nestjs/common"; 4 | import { IImportSpotifyJob, ImportSpotifyJob } from "../sources/jobs"; 5 | import { SpotifyConnection } from "../sources/spotify/spotify-connection.entity"; 6 | import { CreateOrUpdateDto } from "./dto/create-or-update.dto"; 7 | import { User } from "./user.entity"; 8 | import { UserRepository } from "./user.repository"; 9 | 10 | @Injectable() 11 | export class UsersService { 12 | constructor( 13 | private readonly userRepository: UserRepository, 14 | @ImportSpotifyJob.Inject() 15 | private readonly importSpotifyJobService: JobService, 16 | ) {} 17 | 18 | async findById(id: string): Promise { 19 | const user = await this.userRepository.findOneBy({ id }); 20 | 21 | if (!user) { 22 | throw new NotFoundException("UserNotFound"); 23 | } 24 | 25 | return user; 26 | } 27 | 28 | async findAll(): Promise { 29 | return this.userRepository.find(); 30 | } 31 | 32 | async createOrUpdate(data: CreateOrUpdateDto): Promise { 33 | let user = await this.userRepository.findOneBy({ 34 | spotify: { id: data.spotify.id }, 35 | }); 36 | 37 | const isNew = !user; 38 | if (isNew) { 39 | user = this.userRepository.create({ 40 | spotify: { 41 | id: data.spotify.id, 42 | }, 43 | }); 44 | } 45 | 46 | user.spotify.accessToken = data.spotify.accessToken; 47 | user.spotify.refreshToken = data.spotify.refreshToken; 48 | user.displayName = data.displayName; 49 | user.photo = data.photo; 50 | 51 | await this.userRepository.save(user); 52 | 53 | if (isNew) { 54 | // Make sure that existing listens are crawled immediately 55 | this.importSpotifyJobService.sendOnce({ userID: user.id }, {}, user.id); 56 | } 57 | 58 | return user; 59 | } 60 | 61 | async updateSpotifyConnection( 62 | user: User, 63 | spotify: SpotifyConnection, 64 | ): Promise { 65 | // eslint-disable-next-line no-param-reassign 66 | user.spotify = spotify; 67 | await this.userRepository.save(user); 68 | } 69 | 70 | getQueryBuilder(): SelectQueryBuilder { 71 | return this.userRepository.createQueryBuilder("user"); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from "@nestjs/testing"; 2 | import { INestApplication } from "@nestjs/common"; 3 | import * as request from "supertest"; 4 | import { AppModule } from "../src/app.module"; 5 | 6 | describe("AppController (e2e)", () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it("/ (GET)", () => 19 | request(app.getHttpServer()).get("/").expect(200).expect("Hello World!")); 20 | }); 21 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "ES2022", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "incremental": true, 13 | "paths": {}, 14 | }, 15 | "exclude": ["node_modules", "dist"], 16 | "include": ["src", "test"] 17 | } 18 | --------------------------------------------------------------------------------