Merge pull request #753 from Arthi-chaud/v3

v3.0.0
This commit is contained in:
Arthur Jamet authored and GitHub committed 2025-01-10 20:07:18 +00:00
commit 9d12a9ae2c
407 files changed
+16675 -10859

No files matched your search

+10 -3
View File
@@ -7,6 +7,10 @@ POSTGRES_USER=
POSTGRES_PASSWORD=
# Name of Meelo's database
POSTGRES_DB=
# Username of Meelo's Message Queue
RABBITMQ_USER=
# Password of Meelo's Message Queue
RABBITMQ_PASSWORD=
#################### Config
# The directory that contains the `settings.json` file (and where the illustrations will be stored) (on the host machine)
CONFIG_DIR=
@@ -17,15 +21,18 @@ DATA_DIR=
# This will not affect front-end behaviour
ALLOW_ANONYMOUS=
#################### Web app
# URL of the server/api that would be accessible to the front app's server or any client
# If PORT=5000, it should look like 'http://0.0.0.0:5000/api' (mind the '/api')
PUBLIC_SERVER_URL=
# Public URL to access Meelo, without trailing slash
# Example: http://localhost:5000
PUBLIC_URL=
#################### Security
# Random String used to sign JWT Tokens
JWT_SIGNATURE=
# Key used to authenticate the Meilisearch Instance
# Should be a random string, must be at least 16 bytes
MEILI_MASTER_KEY=
# A coma-separated list of random strings used for internal authentication
# Example: API_KEYS=azertyuip,qwertyuiop
API_KEYS=
#################### Internal
# Do not change this
INTERNAL_DATA_DIR=/data
+9
View File
@@ -0,0 +1,9 @@
name: 'Cache (Matcher)'
description: 'Cache for Matcher'
runs:
using: "composite"
steps:
- run: apk add --update --no-progress lsb-release python3 py3-pip
shell: sh
- run: pip3 install -r matcher/requirements.txt
shell: sh
+15
View File
@@ -0,0 +1,15 @@
name: 'Cache (Scanner)'
description: 'Cache for Scanner'
runs:
using: "composite"
steps:
- run: |
cd scanner
go install github.com/swaggo/swag/cmd/swag@latest
~/go/bin/swag init -d app -o ./app/docs
shell: bash
- uses: actions/setup-go@v5
with:
check-latest: true
go-version-file: 'scanner/go.mod'
cache-dependency-path: scanner/go.sum
+1 -1
View File
@@ -13,4 +13,4 @@ runs:
./server/node_modules
./server/dist
key: server
restore-keys: server
restore-keys: server
+3 -3
View File
@@ -1,9 +1,9 @@
name: "Front"
on:
pull_request:
branches: [ main, v2 ]
branches: [ main, v3 ]
push:
branches: [ main, v2 ]
branches: [ main, v3 ]
release:
types: [ published ]
@@ -169,4 +169,4 @@ jobs:
context: ./front
push: true
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-front:edge' }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
+131
View File
@@ -0,0 +1,131 @@
name: "Matcher"
on:
pull_request:
branches: [ main, v3 ]
push:
branches: [ main, v3 ]
release:
types: [ published ]
defaults:
run:
working-directory: ./matcher
jobs:
Changes:
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
matcher: ${{ steps.filter.outputs.matcher }}
steps:
- name: Check out the repo
uses: actions/checkout@v3
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
matcher:
- 'matcher/**'
- '.github/workflows/matcher.yml'
Typecheck:
needs: changes
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
container: python:3.14.0a2-alpine3.21
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Restore Dependencies
uses: ./.github/actions/cache-matcher
- uses: jakebailey/pyright-action@v2
with:
working-directory: './matcher/matcher'
Lint:
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
container: python:3.14.0a2-alpine3.21
runs-on: ubuntu-latest
needs: [ Typecheck ]
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Restore Dependencies
uses: ./.github/actions/cache-matcher
- run: pip install ruff
- run: ruff check matcher
Tests:
runs-on: ubuntu-latest
needs: [ Typecheck ]
container: python:3.14.0a2-alpine3.21
if: ${{ needs.changes.outputs.matcher == 'true' && always() }}
env:
GENIUS_ACCESS_TOKEN: "${{ secrets.GENIUS_ACCESS_TOKEN }}"
DISCOGS_ACCESS_TOKEN: "${{ secrets.DISCOGS_ACCESS_TOKEN }}"
CI: "1"
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Restore Dependencies
uses: ./.github/actions/cache-matcher
- run: python3 -m tests
Dockerize:
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
needs: [ Typecheck ]
runs-on: ubuntu-latest
permissions:
packages: read
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Typecheck Docker
uses: docker/build-push-action@v3
with:
context: ./matcher
push: false
Publish:
needs: changes
runs-on: ubuntu-latest
if: ${{ github.event_name == 'release' || (needs.changes.outputs.matcher == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
arthichaud/meelo-matcher
ghcr.io/${{ github.repository }}-matcher
- name: Typecheck and push Docker images
uses: docker/build-push-action@v3
with:
context: ./matcher
push: true
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-matcher:edge' }}
labels: ${{ steps.meta.outputs.labels }}
+155
View File
@@ -0,0 +1,155 @@
name: "Scanner"
on:
pull_request:
branches: [ main, v3 ]
push:
branches: [ main, v3 ]
release:
types: [ published ]
defaults:
run:
shell: bash
working-directory: ./scanner
jobs:
Changes:
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
scanner: ${{ steps.filter.outputs.scanner }}
steps:
- name: Check out the repo
uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
scanner:
- 'scanner/**'
- '.github/workflows/scanner.yml'
Build:
needs: changes
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Setup Go
uses: ./.github/actions/cache-scanner
- name: Install Dependencies and Build
run: go build -o scanner ./app
Format:
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
needs: [ Build ]
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Setup Go
uses: ./.github/actions/cache-scanner
- uses: Jerome1337/gofmt-action@v1.0.5
with:
gofmt-path: './scanner'
gofmt-flags: '-l'
Tests:
runs-on: ubuntu-latest
needs: [ Build ]
if: ${{ needs.changes.outputs.scanner == 'true' && always() }}
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Setup Go
uses: ./.github/actions/cache-scanner
- run: sudo apt-get update -y && sudo apt-get install -y ffmpeg
- name: Install Coverage Report
run: go install github.com/jandelgado/gcov2lcov@latest
- name: Run tests
run: go test ./... -coverprofile=coverage.out
- name: Upload Unit Tests artifact
if: ${{ always() }}
uses: actions/upload-artifact@v3
with:
name: coverage-report-scanner
path: ./scanner/coverage.out
Analysis:
needs: [ Tests ]
name: "Static Analysis"
runs-on: ubuntu-latest
if: ${{ needs.changes.outputs.scanner == 'true' && always() }}
steps:
- uses: actions/checkout@v3
- name: Downlad Coverage artifact
uses: actions/download-artifact@v3
with:
name: coverage-report-scanner
path: ./scanner
- name: Run SonarCloud Scan
uses: SonarSource/sonarcloud-github-action@master
with:
projectBaseDir: ./scanner
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_SCANNER }}
Dockerize:
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
needs: [ Build ]
runs-on: ubuntu-latest
permissions:
packages: read
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker
uses: docker/build-push-action@v3
with:
context: ./scanner
push: false
Publish:
needs: changes
runs-on: ubuntu-latest
if: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
arthichaud/meelo-scanner
ghcr.io/${{ github.repository }}-scanner
- name: Build and push Docker images
uses: docker/build-push-action@v3
with:
context: ./scanner
push: true
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-scanner:edge' }}
labels: ${{ steps.meta.outputs.labels }}
+3 -13
View File
@@ -1,9 +1,9 @@
name: "Server"
on:
pull_request:
branches: [ main, v2 ]
branches: [ main, v3 ]
push:
branches: [ main, v2 ]
branches: [ main, v3 ]
release:
types: [ published ]
@@ -77,7 +77,6 @@ jobs:
if: ${{ needs.changes.outputs.server == 'true' && always() }}
env:
NODE_OPTIONS: "--max_old_space_size=4096"
REDIS_HOST: "redis"
DATABASE_URL: "postgresql://test:test@postgres:5432/test?schema=public"
JWT_SIGNATURE: "12345"
GENIUS_ACCESS_TOKEN: "${{ secrets.GENIUS_ACCESS_TOKEN }}"
@@ -97,15 +96,6 @@ jobs:
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7.0-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
meilisearch:
image: getmeili/meilisearch:v1.5
ports:
@@ -208,4 +198,4 @@ jobs:
context: ./server
push: true
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-server:edge' }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
+2 -2
View File
@@ -10,8 +10,8 @@ While the software wants to be a flexible as possible, it requires a little bit
Meelo has all the basic features you might expect from a music player/server (playback, browsing, search, playlists, libraries ...). But it comes with a handful of features that make it unique :star_struck:
- Music Videos
- Access them directly from an album's or an artist's page
- Music Videos are first-class citizens
- Access them directly from an album's, artist's or song's page
- We also differentiate music video from interviews, behind-the-scenes, trailers...
- Identifies B-Sides
- Meelo shows you any tracks that could be related to an album directly on the album's page!
+79 -12
View File
@@ -10,12 +10,20 @@ services:
depends_on:
db:
condition: service_healthy
mq:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
interval: 5s
timeout: 5s
retries: 10
volumes:
- ./server/src:/app/src
- ./server/prisma:/app/prisma
- ./server/test:/app/test
- ./server/package.json:/app/package.json
- ./server/.yarn:/app/.yarn
- server_deps:/app/node_modules
- ./server/.yarnrc.yml:/app/.yarnrc.yml
- ./server/tsconfig.json:/app/tsconfig.json
- ./server/tsconfig.build.json:/app/tsconfig.build.json
@@ -26,16 +34,44 @@ services:
- .env
environment:
- TRANSCODER_URL=http://transcoder:7666
- POSTGRES_HOST=db
- MEILI_HOST=http://meilisearch:7700
- REDIS_HOST=redis
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?schema=public
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
scanner:
build:
context: ./scanner
dockerfile: Dockerfile.dev
restart: on-failure
depends_on:
server:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- localhost:8133"]
interval: 5s
timeout: 5s
retries: 5
ports:
- "8133:8133"
environment:
- API_URL=http://server:4000
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
- INTERNAL_DATA_DIR=${INTERNAL_DATA_DIR}
- API_KEYS=${API_KEYS}
volumes:
- ./scanner:/app
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
front:
build:
context: ./front
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
depends_on:
server:
condition: service_healthy
scanner:
condition: service_healthy
volumes:
- ./front/src:/app/src
- ./front/public:/app/public
@@ -43,17 +79,30 @@ services:
- ./front/package.json:/app/package.json
- ./front/tsconfig.json:/app/tsconfig.json
- ./front/yarn.lock:/app/yarn.lock
- front_deps:/app/node_modules
environment:
# This can be commented out. see front/next.config.js
- PUBLIC_SERVER_URL=http://localhost:4000
- SSR_SERVER_URL=http://server:4000
redis:
image: redis:7.0-alpine
healthcheck:
test: ["CMD", "redis-cli","ping"]
interval: 5s
timeout: 5s
retries: 5
- PUBLIC_SCANNER_URL=http://localhost:8133
- SSR_SCANNER_URL=http://scanner:8133
matcher:
build:
context: ./matcher
restart: on-failure
depends_on:
server:
condition: service_healthy
mq:
condition: service_healthy
environment:
- API_URL=http://server:4000
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
- API_KEYS=${API_KEYS}
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
volumes:
- ./matcher:/app
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
meilisearch:
image: getmeili/meilisearch:v1.5
restart: on-failure
@@ -85,6 +134,9 @@ services:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
- transcoder_cache:/cache
profiles: ['', 'cpu']
depends_on:
db:
condition: service_healthy
db:
image: postgres:alpine3.16
healthcheck:
@@ -94,7 +146,22 @@ services:
retries: 5
env_file:
- .env
mq:
image: rabbitmq:4.0-alpine
environment:
- RABBITMQ_NODENAME=meelo
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
healthcheck:
test: rabbitmq-diagnostics -q ping
interval: 5s
timeout: 5s
retries: 10
volumes:
- rabbitmq_data:/var/lib/rabbitmq
volumes:
search:
transcoder_cache:
transcoder_cache:
front_deps:
server_deps:
rabbitmq_data:
+61 -11
View File
@@ -10,6 +10,8 @@ services:
condition: service_healthy
meilisearch:
condition: service_healthy
mq:
condition: service_healthy
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}
@@ -18,14 +20,33 @@ services:
environment:
- TRANSCODER_URL=http://transcoder:7666
- MEILI_HOST=http://meilisearch:7700
- REDIS_HOST=redis
- POSTGRES_HOST=db
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?schema=public
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
healthcheck:
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
interval: 5s
timeout: 5s
retries: 5
scanner:
image: ghcr.io/arthi-chaud/meelo-scanner:latest
expose:
- 8133
depends_on:
server:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- localhost:8133"]
interval: 5s
timeout: 5s
retries: 10
environment:
- API_URL=http://server:4000
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
- INTERNAL_DATA_DIR=${INTERNAL_DATA_DIR}
- API_KEYS=${API_KEYS}
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
front:
image: ghcr.io/arthi-chaud/meelo-front:latest
expose:
@@ -33,16 +54,28 @@ services:
depends_on:
server:
condition: service_healthy
scanner:
condition: service_healthy
environment:
- PUBLIC_SERVER_URL=${PUBLIC_SERVER_URL}
- PUBLIC_SERVER_URL=${PUBLIC_URL}/api
- SSR_SERVER_URL=http://server:4000
redis:
image: redis:7.0-alpine
healthcheck:
test: ["CMD", "redis-cli","ping"]
interval: 5s
timeout: 5s
retries: 5
- PUBLIC_SCANNER_URL=${PUBLIC_URL}/scanner
- SSR_SCANNER_URL=http://scanner:8133
matcher:
image: ghcr.io/arthi-chaud/meelo-matcher:latest
restart: on-failure
depends_on:
server:
condition: service_healthy
mq:
condition: service_healthy
environment:
- API_URL=http://server:4000
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
- API_KEYS=${API_KEYS}
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
volumes:
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
db:
image: postgres:alpine3.14
healthcheck:
@@ -84,7 +117,23 @@ services:
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
- transcoder_cache:/cache
depends_on:
db:
condition: service_healthy
profiles: ['', 'cpu']
mq:
image: rabbitmq:4.0-alpine
environment:
- RABBITMQ_NODENAME=meelo
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
healthcheck:
test: rabbitmq-diagnostics -q ping
interval: 5s
timeout: 5s
retries: 10
volumes:
- rabbitmq_data:/var/lib/rabbitmq
nginx:
restart: on-failure
image: nginx:1.24.0-alpine
@@ -107,3 +156,4 @@ volumes:
data:
search:
transcoder_cache:
rabbitmq_data:
+61 -8
View File
@@ -11,6 +11,8 @@ services:
condition: service_healthy
meilisearch:
condition: service_healthy
mq:
condition: service_healthy
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}
@@ -19,9 +21,8 @@ services:
environment:
- TRANSCODER_URL=http://transcoder:7666
- MEILI_HOST=http://meilisearch:7700
- REDIS_HOST=redis
- POSTGRES_HOST=db
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?schema=public
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
healthcheck:
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
interval: 5s
@@ -35,22 +36,59 @@ services:
depends_on:
server:
condition: service_healthy
scanner:
condition: service_healthy
environment:
- PUBLIC_SERVER_URL=${PUBLIC_SERVER_URL}
- PUBLIC_SERVER_URL=${PUBLIC_URL}/api
- SSR_SERVER_URL=http://server:4000
redis:
image: redis:7.0-alpine
- PUBLIC_SCANNER_URL=${PUBLIC_URL}/scanner
- SSR_SCANNER_URL=http://scanner:8133
scanner:
build:
context: ./scanner
expose:
- 8133
depends_on:
server:
condition: service_healthy
healthcheck:
test: ["CMD", "redis-cli","ping"]
test: ["CMD-SHELL", "wget -qO- localhost:8133"]
interval: 5s
timeout: 5s
retries: 5
retries: 10
environment:
- API_URL=http://server:4000
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
- INTERNAL_DATA_DIR=${INTERNAL_DATA_DIR}
- API_KEYS=${API_KEYS}
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
matcher:
build:
context: ./matcher
restart: on-failure
depends_on:
server:
condition: service_healthy
mq:
condition: service_healthy
environment:
- API_URL=http://server:4000
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
- API_KEYS=${API_KEYS}
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
volumes:
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
transcoder:
image: ghcr.io/zoriya/kyoo_transcoder:master
restart: on-failure
cpus: 1
expose:
- 7666
depends_on:
db:
condition: service_healthy
environment:
- GOCODER_SAFE_PATH=${INTERNAL_DATA_DIR}
- POSTGRES_USER=${POSTGRES_USER}
@@ -87,6 +125,19 @@ services:
test: ["CMD", "wget", "--no-verbose", "--spider", "http://0.0.0.0:7700/health"]
timeout: 5s
retries: 5
mq:
image: rabbitmq:4.0-alpine
environment:
- RABBITMQ_NODENAME=meelo
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
healthcheck:
test: rabbitmq-diagnostics -q ping
interval: 5s
timeout: 5s
retries: 10
volumes:
- rabbitmq_data:/var/lib/rabbitmq
nginx:
restart: on-failure
image: nginx:1.24.0-alpine
@@ -101,6 +152,7 @@ services:
- PORT=5000
- FRONT_URL=http://front:3000
- SERVER_URL=http://server:4000
- SCANNER_URL=http://scanner:8133
volumes:
- ./nginx.conf.template:/etc/nginx/templates/meelo.conf.template:ro
volumes:
@@ -109,3 +161,4 @@ volumes:
data:
search:
transcoder_cache:
rabbitmq_data:
+4 -2
View File
@@ -30,11 +30,13 @@ If a provider is disabled, no call to its service will be done.
| Provider Name | Key | Additional Properties | Artist Description | Artist Illustration | Album Description | Album Rating | Song Description | Song Lyrics | Release Identification | Album Genres |
|---------------|---------------|--------------------------------------------------------------------------------------------|--------------------|---------------------|-------------------|--------------|------------------|-------------|------------------------|--------------|
| Genius | `genius` | `apiKey`: Access Token, you can get it [here](https://genius.com/api-clients) | x | x | x | | | x | | |
| Genius | `genius` | `apiKey`: Access Token, you can get it [here](https://genius.com/api-clients) | x | x | x | | x | x | | |
| MusicBrainz | `musicbrainz` | None | | | | | | | | x |
| AllMusic | `allmusic` | None | | | x | x | | | | |
| MetaCritic | `metacritic` | None | | | x | x | | | | |
| Wikipedia | `wikipedia` | None | x | | x | | x | | | |
| Discogs | `discogs` | `apiKey`: Access Token, you can get it [here](https://www.discogs.com/settings/developers) | x | | | | | | x | x |
**Warning**: Meelo heavily relies on Wikidata to optimize the metadata fetching. It is used if, and only if the MusicBrainz Provider is enabled. It is not possible to use one without the other.
**Warning**: Meelo heavily relies on Wikidata to optimize the metadata fetching. It is used if, and only if the MusicBrainz Provider is enabled. It is not possible to use one without the other.
*Note*: If MusicBrainz is enabled, we might make requests to [AcoustID](https://acoustid.org)'s API to match songs using audio fingerprints (No data will be submitted).
+1 -1
View File
@@ -5,4 +5,4 @@ Meelo is easy to setup! But you'll need the following things before continuing
- Have you music library ready (see [*this step*](../prepare-music-library))
- Know how `Docker` and `docker-compose` work
- Know how to create a `.env` file
- Know how to manipulate Regular Expressions (Especially the ECMAScript flavor)
- Know how to manipulate Regular Expressions (Especially the Golang flavor)
+3
View File
@@ -16,6 +16,7 @@ All fields are **required**
- `Track`: The name of the track
- `Genre`: The genre of the material
- `DiscogsID`: The Discogs ID of the parent release. A Discogs ID should take the form of a digit-only string. This ID can be found in the URL of the release's Discogs page or in the top-right corner of that page (displayed as `[rXXXXXXXX]`)
- *Note*: **If you do not use embedded data**, most regex groups are optional. Only `Track` and (`AlbumArtist` or `Artist`) are required for the file to be accepted by the scanner.
- `metadata`: Configuration of the **metadata extraction** system:
- `source`: The **primary metadata source** to use:
- `embedded`: Using the embedded tags
@@ -30,3 +31,5 @@ All fields are **required**
- `useID3CompTag`: If true, use iTunes' non-standard ID3 tags for compilations.
*Note*: The provided Regular Expression uses the iTunes file architecture.
*Note*: Regular Expression must use the Golang Flavour.
+3 -1
View File
@@ -11,12 +11,14 @@ Meelo supports the following song types:
- Acoustic
- Remix
- Instrumental
- Acapella
- A Cappella
- Edit
- Of the original version.
- If the song is an edit of a remix, it will be categorised as 'Remix'
- Clean
- Demo
- Medley
- Non-Music (e.g. Interviews)
## Type recognition
+6
View File
@@ -27,6 +27,12 @@ if (process.env.NODE_ENV !== "production") {
? `${process.env.SSR_SERVER_URL}/:path*`
: "/api/:path*",
},
{
source: "/scanner/:path*",
destination: process.env.SSR_SCANNER_URL
? `${process.env.SSR_SCANNER_URL}/:path*`
: "/scanner/:path*",
},
];
}
+209 -113
View File
@@ -41,7 +41,12 @@ import {
SongType,
SongWithRelations,
} from "../models/song";
import { VideoWithRelations } from "../models/video";
import {
VideoInclude,
VideoSortingKeys,
VideoType,
VideoWithRelations,
} from "../models/video";
import {
TrackInclude,
TrackSortingKeys,
@@ -50,7 +55,6 @@ import {
} from "../models/track";
import { TracklistItemWithRelations } from "../models/tracklist";
import { SortingParameters } from "../utils/sorting";
import LibraryTaskResponse from "../models/library-task-response";
import { ResourceNotFound } from "../exceptions";
import User, { UserSortingKeys } from "../models/user";
import store from "../state/store";
@@ -65,8 +69,17 @@ import Playlist, {
PlaylistWithRelations,
} from "../models/playlist";
import { isSSR } from "../utils/is-ssr";
import { ActiveTask, Task } from "../models/task";
import { SearchResult, SearchResultTransformer } from "../models/search";
import { TaskResponse } from "../models/task";
import {
AlbumExternalMetadata,
ArtistExternalMetadata,
SongExternalMetadata,
} from "../models/external-metadata";
import {
SaveSearchItem,
SearchResult,
SearchResultTransformer,
} from "../models/search";
import {
SongGroupSortingKeys,
SongGroupWithRelations,
@@ -78,8 +91,6 @@ const AuthenticationResponse = yup.object({
type Identifier = number | string;
type NullableIdentifier = Identifier | null;
type AuthenticationResponse = yup.InferType<typeof AuthenticationResponse>;
type QueryParameters<Keys extends readonly string[]> = {
@@ -93,11 +104,18 @@ type AuthenticationInput = {
password: string;
};
// eslint-disable-next-line no-shadow
enum Service {
API,
Scanner,
}
type FetchParameters<Keys extends readonly string[], ReturnType> = {
route: string;
parameters: QueryParameters<Keys>;
otherParameters?: any;
errorMessage?: string;
service?: Service;
data?: Record<string, any>;
method?: "GET" | "PUT" | "POST" | "DELETE";
} & RequireExactlyOne<{
@@ -127,6 +145,8 @@ export default class API {
private static isDev = () => process.env.NODE_ENV === "development";
private static SSR_API_URL =
process.env.SSR_SERVER_URL ?? process.env.PUBLIC_SERVER_URL!;
private static SSR_SCANNER_URL =
process.env.SSR_SCANNER_URL ?? process.env.PUBLIC_SCANNER_URL!;
static defaultPageSize = 35;
/**
@@ -153,7 +173,7 @@ export default class API {
*/
static async register(credentials: AuthenticationInput): Promise<User> {
return API.fetch({
route: "/users/new",
route: "/users",
data: {
name: credentials.username,
password: credentials.password,
@@ -176,9 +196,12 @@ export default class API {
route: `/tasks`,
errorMessage: "Tasks could not be loaded",
parameters: {},
service: Service.Scanner,
validator: yup.object({
active: ActiveTask.required().nullable(),
pending: yup.array(Task).required(),
current_task: yup.string().required().nullable(),
pending_tasks: yup
.array(yup.string().required())
.required(),
}),
}),
};
@@ -232,7 +255,7 @@ export default class API {
static async createPlaylist(playlistName: string): Promise<Playlist> {
return API.fetch({
route: "/playlists/new",
route: "/playlists",
data: { name: playlistName },
errorMessage: "Playlist Creation Failed",
parameters: {},
@@ -259,7 +282,7 @@ export default class API {
entriesIds: number[],
): Promise<void> {
return API.fetch({
route: `/playlists/${playlistSlugOrId}/reorder`,
route: `/playlists/${playlistSlugOrId}/entries/reorder`,
data: { entryIds: entriesIds },
parameters: {},
method: "PUT",
@@ -322,25 +345,14 @@ export default class API {
* Calls for a task to clean all libraries
* @returns A object with the status of the task
*/
static async cleanLibraries(): Promise<LibraryTaskResponse> {
static async cleanLibraries(): Promise<TaskResponse> {
return API.fetch({
route: `/tasks/clean`,
route: `/clean`,
errorMessage: "Library clean failed",
parameters: {},
validator: LibraryTaskResponse,
});
}
/**
* Calls for a task to fetch external metadata
* @returns A object with the status of the task
*/
static async fetchExternalMetadata(): Promise<LibraryTaskResponse> {
return API.fetch({
route: `/tasks/fetch-external-metadata`,
errorMessage: "Fetch failed",
parameters: {},
validator: LibraryTaskResponse,
service: Service.Scanner,
method: "POST",
validator: TaskResponse,
});
}
@@ -350,12 +362,14 @@ export default class API {
*/
static async scanLibrary(
librarySlugOrId: number | string,
): Promise<LibraryTaskResponse> {
): Promise<TaskResponse> {
return API.fetch({
route: `/tasks/scan/${librarySlugOrId}`,
route: `/scan/${librarySlugOrId}`,
errorMessage: "Library scan failed",
parameters: {},
validator: LibraryTaskResponse,
service: Service.Scanner,
method: "POST",
validator: TaskResponse,
});
}
@@ -365,12 +379,14 @@ export default class API {
*/
static async cleanLibrary(
librarySlugOrId: number | string,
): Promise<LibraryTaskResponse> {
): Promise<TaskResponse> {
return API.fetch({
route: `/tasks/clean/${librarySlugOrId}`,
route: `/clean/${librarySlugOrId}`,
errorMessage: "Library clean failed",
parameters: {},
validator: LibraryTaskResponse,
service: Service.Scanner,
method: "POST",
validator: TaskResponse,
});
}
@@ -378,11 +394,13 @@ export default class API {
* Calls for a task to scan all libraries
* @returns the status of the task
*/
static scanLibraries(): Promise<LibraryTaskResponse> {
return API.fetch<LibraryTaskResponse, []>({
route: `/tasks/scan`,
static scanLibraries(): Promise<TaskResponse> {
return API.fetch<TaskResponse, []>({
route: `/scan`,
parameters: {},
validator: LibraryTaskResponse,
service: Service.Scanner,
method: "POST",
validator: TaskResponse,
});
}
@@ -395,10 +413,12 @@ export default class API {
resourceSlugOrId: number | string,
): Promise<void> {
return API.fetch({
route: `/tasks/refresh-metadata`,
errorMessage: "Library refresh failed",
method: "POST",
route: `/refresh`,
errorMessage: "Metadata Refresh request failed",
otherParameters: { [parentResourceType]: resourceSlugOrId },
parameters: {},
service: Service.Scanner,
emptyResponse: true,
});
}
@@ -408,7 +428,7 @@ export default class API {
libraryPath: string,
): Promise<Library> {
return API.fetch({
route: "/libraries/new",
route: "/libraries",
data: { name: libraryName, path: libraryPath },
errorMessage: "Library Creation Failed",
parameters: {},
@@ -452,13 +472,13 @@ export default class API {
* Update Artist Illustration
*/
static async updateArtistIllustration(
artistSlugOrId: number | string,
artistId: number,
illustrationUrl: string,
): Promise<unknown> {
return API.updateResourceIllustration(
artistSlugOrId,
artistId,
illustrationUrl,
"artists",
"artist",
);
}
@@ -466,13 +486,13 @@ export default class API {
* Update Release Illustration
*/
static async updateReleaseIllustration(
releaseSlugOrId: number | string,
releaseId: number,
illustrationUrl: string,
): Promise<unknown> {
return API.updateResourceIllustration(
releaseSlugOrId,
releaseId,
illustrationUrl,
"releases",
"release",
);
}
@@ -480,13 +500,13 @@ export default class API {
* Update Track Illustration
*/
static async updateTrackIllustration(
trackSlugOrId: number | string,
trackId: number,
illustrationUrl: string,
): Promise<unknown> {
return API.updateResourceIllustration(
trackSlugOrId,
trackId,
illustrationUrl,
"tracks",
"track",
);
}
@@ -494,13 +514,13 @@ export default class API {
* Update Track Illustration
*/
static async updatePlaylistIllustration(
playlistSlugOrId: number | string,
playlistId: number,
illustrationUrl: string,
): Promise<unknown> {
return API.updateResourceIllustration(
playlistSlugOrId,
playlistId,
illustrationUrl,
"playlists",
"playlist",
);
}
@@ -508,17 +528,17 @@ export default class API {
* Update Resourse Illustration
*/
private static async updateResourceIllustration(
resourceSlugOrId: number | string,
resourceId: number,
illustrationUrl: string,
resourceType: "artists" | "releases" | "tracks" | "playlists",
resourceType: "artist" | "release" | "track" | "playlist",
): Promise<unknown> {
return API.fetch({
route: `/${resourceType}/${resourceSlugOrId}/illustration`,
route: `/illustrations/url`,
errorMessage: "Update Illustration Failed",
method: "POST",
parameters: {},
emptyResponse: true,
data: { url: illustrationUrl },
data: { url: illustrationUrl, [`${resourceType}Id`]: resourceId },
});
}
@@ -527,15 +547,15 @@ export default class API {
*/
static async updateAlbum(
albumSlugOrId: number | string,
newType: AlbumType,
): Promise<unknown> {
dto: Partial<{ type: AlbumType; masterReleaseId: number }>,
): Promise<void> {
return API.fetch({
route: `/albums/${albumSlugOrId}`,
errorMessage: "Update Album Failed",
method: "POST",
method: "PUT",
parameters: {},
emptyResponse: true,
data: { type: newType },
data: dto,
});
}
@@ -544,15 +564,29 @@ export default class API {
*/
static async updateSong(
songSlugOrId: number | string,
newType: SongType,
dto: Partial<{ type: SongType; masterTrackId: number }>,
): Promise<void> {
return API.fetch({
route: `/songs/${songSlugOrId}`,
errorMessage: "Update Song Failed",
method: "POST",
method: "PUT",
parameters: {},
emptyResponse: true,
data: { type: newType },
data: dto,
});
}
static async updateVideo(
videoSlugOrId: number | string,
dto: Partial<{ type: VideoType; masterTrackId: number }>,
): Promise<void> {
return API.fetch({
route: `/videos/${videoSlugOrId}`,
errorMessage: "Update Video Failed",
method: "PUT",
parameters: {},
emptyResponse: true,
data: dto,
});
}
@@ -731,19 +765,20 @@ export default class API {
}
/**
* Fetch all songs
* @returns An InfiniteQuery of Songs
* @returns An InfiniteQuery of Videos
*/
static getVideos<I extends SongInclude | never = never>(
static getVideos<I extends VideoInclude | never = never>(
filter: {
library?: Identifier;
artist?: Identifier;
album?: Identifier;
song?: Identifier;
group?: Identifier;
random?: number;
type?: SongType;
type?: VideoType;
query?: string;
},
sort?: SortingParameters<typeof SongSortingKeys>,
sort?: SortingParameters<typeof VideoSortingKeys>,
include?: I[],
): InfiniteQuery<VideoWithRelations<I>> {
return {
@@ -757,7 +792,11 @@ export default class API {
API.fetch({
route: `/videos`,
errorMessage: "Videos could not be loaded",
parameters: { pagination: pagination, include, sort },
parameters: {
pagination: pagination,
include,
sort,
},
otherParameters: filter,
validator: PaginatedResponse(
VideoWithRelations(include ?? []),
@@ -786,13 +825,28 @@ export default class API {
};
}
static getVideo<I extends VideoInclude | never = never>(
videoSlugOrId: string | number,
include?: I[],
): Query<VideoWithRelations<I>> {
return {
key: ["video", videoSlugOrId, ...API.formatIncludeKeys(include)],
exec: () =>
API.fetch({
route: `/videos/${videoSlugOrId}`,
parameters: { include },
validator: VideoWithRelations(include ?? []),
}),
};
}
/**
* Get the master track of a song
* @param songSlugOrId the identifier of a song
* @param include the fields to include in the fetched item
* @returns a Query for a Track
*/
static getMasterTrack<I extends TrackInclude | never = never>(
static getSongMasterTrack<I extends TrackInclude | never = never>(
songSlugOrId: string | number,
include?: I[],
): Query<TrackWithRelations<I>> {
@@ -805,7 +859,7 @@ export default class API {
],
exec: () =>
API.fetch({
route: `/tracks/master/${songSlugOrId}`,
route: `/tracks/master/song/${songSlugOrId}`,
parameters: { include },
validator: TrackWithRelations(include ?? []),
}),
@@ -881,14 +935,42 @@ export default class API {
key: ["search", query],
exec: () =>
API.fetch({
route: `/search/${query}`,
route: `/search`,
errorMessage: "Search failed",
parameters: {},
otherParameters: { query },
customValidator: SearchResultTransformer,
}),
};
}
static getSearchHistory(): Query<SearchResult[]> {
return {
key: ["search-history-items"],
exec: () =>
API.fetch({
route: `/search/history`,
errorMessage: "Getting Search History failed",
parameters: {},
customValidator: SearchResultTransformer,
}),
};
}
/**
* Search artists, albums and songs, all at once
*/
static saveSearchHistoryEntry(resource: SaveSearchItem): Promise<void> {
return API.fetch({
method: "POST",
route: `/search/history`,
errorMessage: "Saving Search History failed",
parameters: {},
data: resource,
emptyResponse: true,
});
}
/**
* Get the User object of the authentified user
* @returns A query to a User object
@@ -1150,6 +1232,52 @@ export default class API {
};
}
static getArtistExternalMetadata(
slugOrId: string | number,
): Query<ArtistExternalMetadata | null> {
return API.getResourceExternalMetadata(
slugOrId,
"artist",
ArtistExternalMetadata,
);
}
static getSongExternalMetadata(
slugOrId: string | number,
): Query<SongExternalMetadata | null> {
return API.getResourceExternalMetadata(
slugOrId,
"song",
SongExternalMetadata,
);
}
static getAlbumExternalMetadata(
slugOrId: string | number,
): Query<AlbumExternalMetadata | null> {
return API.getResourceExternalMetadata(
slugOrId,
"album",
AlbumExternalMetadata,
);
}
static getResourceExternalMetadata<T, V extends yup.Schema<T>>(
resourceSlugOrId: string | number,
resourceType: "artist" | "album" | "song",
validator: V,
): Query<T | null> {
return {
key: [resourceType, resourceSlugOrId, "external-metadata"],
exec: () =>
API.fetch({
route: `/external-metadata?${resourceType}=${resourceSlugOrId}`,
errorMessage: "Metadata could not be loaded",
parameters: {},
validator: validator,
}).catch(() => null),
};
}
private static async fetch<ReturnType, Keys extends readonly string[]>({
route,
parameters,
@@ -1160,6 +1288,7 @@ export default class API {
validator,
customValidator,
emptyResponse,
service,
}: FetchParameters<Keys, ReturnType>): Promise<ReturnType> {
const accessToken = store.getState().user.accessToken;
const header = {
@@ -1167,7 +1296,7 @@ export default class API {
};
const response = await fetch(
this.buildURL(route, parameters, otherParameters),
this.buildURL(route, parameters, otherParameters, service),
{
method: method ?? "GET",
body: data ? JSON.stringify(data) : undefined,
@@ -1263,10 +1392,10 @@ export default class API {
playlistId: number,
): Promise<unknown> {
return API.fetch({
route: `/playlists/entries/new`,
route: `/playlists/${playlistId}/entries`,
errorMessage: "Failed to add song to playlist",
parameters: {},
data: { songId, playlistId },
data: { songId },
method: "POST",
emptyResponse: true,
});
@@ -1320,51 +1449,18 @@ export default class API {
});
}
/**
* Mark a release as master
* @param releaseSlugOrId
* @returns
*/
static async setReleaseAsMaster(
releaseSlugOrId: string | number,
): Promise<unknown> {
return API.fetch({
route: `/releases/${releaseSlugOrId}/master`,
errorMessage: "Release update failed",
parameters: {},
method: "PUT",
validator: yup.mixed(),
});
}
/**
* Mark a track as master
* @param trackSlugOrId
* @returns
*/
static async setTrackAsMaster(
trackSlugOrId: string | number,
): Promise<unknown> {
return API.fetch({
route: `/tracks/${trackSlugOrId}/master`,
errorMessage: "Track update failed",
parameters: {},
method: "PUT",
validator: yup.mixed(),
});
}
private static buildURL(
route: string,
parameters: QueryParameters<any>,
otherParameters?: any,
service: Service = Service.API,
): string {
const apiHost = isSSR() ? this.SSR_API_URL : "/api";
const scannerHost = isSSR() ? this.SSR_SCANNER_URL : "/scanner";
return `${apiHost}${route}${this.formatQueryParameters(
parameters,
otherParameters,
)}`;
return `${
service == Service.API ? apiHost : scannerHost
}${route}${this.formatQueryParameters(parameters, otherParameters)}`;
}
private static formatQueryParameters<Keys extends string[]>(
@@ -1,94 +0,0 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { QueryClient } from "../../api/use-query";
import Action from "../actions/action";
import { useConfirm } from "material-ui-confirm";
import Album, { AlbumType } from "../../models/album";
import store from "../../state/store";
import { Chip, Grid } from "@mui/material";
import { useState } from "react";
import toast from "react-hot-toast";
import API from "../../api/api";
import { useMutation } from "react-query";
import { EditIcon } from "../icons";
import { useTranslation } from "react-i18next";
const AlbumTypeForm = (props: {
defaultValue: AlbumType;
onSelect: (type: AlbumType) => void;
}) => {
const [currentType, setType] = useState(props.defaultValue);
const { t } = useTranslation();
return (
<>
<Grid container spacing={2} justifyContent="center">
{AlbumType.map((type) => (
<Grid item key={type}>
<Chip
label={t(type)}
variant={
type == currentType ? "filled" : "outlined"
}
onClick={() => {
setType(type);
props.onSelect(type);
}}
/>
</Grid>
))}
</Grid>
</>
);
};
const ChangeAlbumType = (
album: Album,
queryClient: QueryClient,
confirm: ReturnType<typeof useConfirm>,
): Action => {
const { t } = useTranslation();
const mutation = useMutation((newType: AlbumType) => {
return API.updateAlbum(album.id, newType)
.then(() => {
queryClient.client.invalidateQueries("albums");
})
.catch((error: Error) => toast.error(error.message));
});
return {
label: "changeAlbumType",
icon: <EditIcon />,
disabled: store.getState().user.user?.admin !== true,
onClick: () =>
confirm({
title: t("changeAlbumType"),
description: (
<AlbumTypeForm
defaultValue={album.type}
onSelect={(type) => mutation.mutate(type)}
/>
),
cancellationButtonProps: { sx: { display: "none" } },
confirmationText: t("done"),
}),
};
};
export default ChangeAlbumType;
+7 -11
View File
@@ -18,18 +18,20 @@
import toast from "react-hot-toast";
import API from "../../api/api";
import LibraryTaskResponse from "../../models/library-task-response";
import { TaskResponse } from "../../models/task";
import Action from "./action";
import { CleanIcon, MetadataRefreshIcon, ScanIcon } from "../icons";
import { CleanIcon, ScanIcon } from "../icons";
/**
* Using the resolved value of the task porimise, triggers an appropriate toast
* @param task the returned promised from an API call to run a task
*/
const handleTask = <T extends LibraryTaskResponse>(task: Promise<T>) =>
const handleTask = <T extends TaskResponse>(task: Promise<T>) =>
task
.then(({ status }) => toast.success(status))
.catch(({ status }) => toast.error(status));
.then((s) => toast.success(s.message))
.catch((s) =>
toast.error(s.message ?? s.status ?? "Task request failed"),
);
export const ScanAllLibrariesAction: Action = {
label: "scanLibraries",
@@ -58,9 +60,3 @@ export const CleanLibraryAction = (
icon: CleanAllLibrariesAction.icon,
onClick: () => handleTask(API.cleanLibrary(librarySlugOrId)),
});
export const FetchExternalMetadata: Action = {
label: "fetchMetadata",
icon: <MetadataRefreshIcon />,
onClick: () => handleTask(API.fetchExternalMetadata()),
};
+9 -2
View File
@@ -27,6 +27,7 @@ import {
} from "../icons";
import { NextRouter } from "next/router";
import Action from "./action";
import toast from "react-hot-toast";
export const GoToSongLyricsAction = (
songIdentifier: string | number,
@@ -76,10 +77,16 @@ export const GoToReleaseAction = (
export const GoToReleaseAsyncAction = (
router: NextRouter,
albumIdentifier: () => PromiseLike<number | string>,
albumIdentifier: () => PromiseLike<number | string | null>,
): Action => ({
onClick: () =>
albumIdentifier().then((id) => router.push(`/releases/${id}`)),
albumIdentifier().then((id) => {
if (id === null) {
toast.error("This resource is not attached to any album.");
} else {
router.push(`/releases/${id}`);
}
}),
label: "goToAlbum",
icon: <AlbumIcon />,
});
@@ -0,0 +1,177 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { QueryClient } from "../../api/use-query";
import Action from "./action";
import { useConfirm } from "material-ui-confirm";
import store from "../../state/store";
import { Chip, Grid } from "@mui/material";
import { useState } from "react";
import toast from "react-hot-toast";
import { EditIcon } from "../icons";
import { useTranslation } from "react-i18next";
import { TranslationKey } from "../../i18n/i18n";
import Song, { SongType } from "../../models/song";
import API from "../../api/api";
import Album, { AlbumType } from "../../models/album";
import Video, { VideoType } from "../../models/video";
const ResourceTypeForm = <Enum extends TranslationKey>(props: {
defaultValue?: Enum;
values: readonly Enum[];
onSelect: (type: Enum) => void;
}) => {
const [currentType, setType] = useState(props.defaultValue);
const { t } = useTranslation();
return (
<>
<Grid container spacing={2} justifyContent="center">
{props.values.map((type) => (
<Grid item key={type}>
<Chip
label={t(type) as string}
variant={
type == currentType ? "filled" : "outlined"
}
onClick={() => {
setType(type);
props.onSelect(type);
}}
/>
</Grid>
))}
</Grid>
</>
);
};
const ChangeResourceType = <
T extends { type: TypeEnum },
TypeEnum extends TranslationKey,
>(
resource: T,
types: readonly TypeEnum[],
label: TranslationKey,
queryClient: QueryClient,
onSelect: (newType: TypeEnum) => Promise<void>,
confirm: ReturnType<typeof useConfirm>,
): Action => {
return {
label: label,
icon: <EditIcon />,
disabled: store.getState().user.user?.admin !== true,
onClick: () =>
confirm({
title: <br />,
description: (
<ResourceTypeForm
defaultValue={resource.type}
values={types}
onSelect={(type) =>
toast
.promise(onSelect(type), {
loading: null,
success: "Update successful!",
error: "Update failed...",
})
.then(() => {
queryClient.client.invalidateQueries(
"songs",
);
queryClient.client.invalidateQueries(
"release",
);
queryClient.client.invalidateQueries(
"tracks",
);
queryClient.client.invalidateQueries(
"videos",
);
queryClient.client.invalidateQueries(
"bsides",
);
})
}
/>
),
cancellationButtonProps: { sx: { display: "none" } },
confirmationText: "OK",
}),
};
};
const ChangeSongType = (
s: Song,
client: QueryClient,
confirm: ReturnType<typeof useConfirm>,
) =>
ChangeResourceType(
s,
SongType.filter((t) => t != "Unknown"),
"changeSongType",
client,
(newType: SongType) =>
API.updateSong(s.id, { type: newType }).then((res) => {
client.client.invalidateQueries("songs");
client.client.invalidateQueries("release");
client.client.invalidateQueries("tracks");
client.client.invalidateQueries("videos");
client.client.invalidateQueries("bsides");
return res;
}),
confirm,
);
const ChangeAlbumType = (
a: Album,
client: QueryClient,
confirm: ReturnType<typeof useConfirm>,
) =>
ChangeResourceType(
a,
AlbumType,
"changeAlbumType",
client,
(newType: AlbumType) =>
API.updateAlbum(a.id, { type: newType }).then((res) => {
client.client.invalidateQueries("albums");
return res;
}),
confirm,
);
const ChangeVideoType = (
v: Video,
client: QueryClient,
confirm: ReturnType<typeof useConfirm>,
) =>
ChangeResourceType(
v,
VideoType,
"changeVideoType",
client,
(newType: VideoType) =>
API.updateVideo(v.id, { type: newType }).then((res) => {
client.client.invalidateQueries("videos");
return res;
}),
confirm,
);
export { ChangeSongType, ChangeAlbumType, ChangeVideoType };
@@ -41,6 +41,6 @@ export const ShowMasterTrackFileInfoAction = (
label: "moreInfo",
onClick: () =>
queryClient
.fetchQuery(API.getMasterTrack(songId))
.fetchQuery(API.getSongMasterTrack(songId))
.then((track) => openTrackFileInfoModal(confirm, track.id)),
});
@@ -1,99 +0,0 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { QueryClient } from "../../api/use-query";
import Action from "./action";
import { useConfirm } from "material-ui-confirm";
import store from "../../state/store";
import { Chip, Grid } from "@mui/material";
import { useState } from "react";
import toast from "react-hot-toast";
import API from "../../api/api";
import { useMutation } from "react-query";
import { EditIcon } from "../icons";
import Song, { SongType } from "../../models/song";
import { useTranslation } from "react-i18next";
const SongTypeForm = (props: {
defaultValue: SongType;
onSelect: (type: SongType) => void;
}) => {
const [currentType, setType] = useState(props.defaultValue);
const { t } = useTranslation();
return (
<>
<Grid container spacing={2} justifyContent="center">
{SongType.filter((type) => type != "Unknown").map((type) => (
<Grid item key={type}>
<Chip
label={t(type)}
variant={
type == currentType ? "filled" : "outlined"
}
onClick={() => {
setType(type);
props.onSelect(type);
}}
/>
</Grid>
))}
</Grid>
</>
);
};
const ChangeSongType = (
song: Song,
queryClient: QueryClient,
confirm: ReturnType<typeof useConfirm>,
): Action => {
const { t } = useTranslation();
const mutation = useMutation((newType: SongType) => {
return API.updateSong(song.id, newType)
.then(() => {
toast.success("Update successful!");
queryClient.client.invalidateQueries("songs");
queryClient.client.invalidateQueries("release");
queryClient.client.invalidateQueries("tracks");
queryClient.client.invalidateQueries("videos");
queryClient.client.invalidateQueries("bsides");
})
.catch((error: Error) => toast.error(error.message));
});
return {
label: "changeSongType",
icon: <EditIcon />,
disabled: store.getState().user.user?.admin !== true,
onClick: () =>
confirm({
title: t("changeSongType"),
description: (
<SongTypeForm
defaultValue={song.type}
onSelect={(type) => mutation.mutate(type)}
/>
),
cancellationButtonProps: { sx: { display: "none" } },
confirmationText: t("done"),
}),
};
};
export default ChangeSongType;
@@ -83,7 +83,7 @@ const IllustrationUpdateForm = (props: IllustrationUpdateFormType) => {
const UpdateIllustrationAction = (
queryClient: QueryClient,
resourceId: number | string,
resourceId: number,
resourceType: "artist" | "track" | "release" | "playlist",
): Action => {
const textFieldId = `update-illustration-${resourceType}-${resourceId}`;
@@ -121,23 +121,23 @@ const UpdateIllustrationAction = (
const UpdateArtistIllustrationAction = (
queryClient: QueryClient,
artistSlugOrId: number | string,
) => UpdateIllustrationAction(queryClient, artistSlugOrId, "artist");
artistId: number,
) => UpdateIllustrationAction(queryClient, artistId, "artist");
const UpdateReleaseIllustrationAction = (
queryClient: QueryClient,
releaseSlugOrId: number | string,
) => UpdateIllustrationAction(queryClient, releaseSlugOrId, "release");
releaseId: number,
) => UpdateIllustrationAction(queryClient, releaseId, "release");
const UpdateTrackIllustrationAction = (
queryClient: QueryClient,
trackSlugOrId: number | string,
) => UpdateIllustrationAction(queryClient, trackSlugOrId, "track");
trackId: number,
) => UpdateIllustrationAction(queryClient, trackId, "track");
const UpdatePlaylistIllustrationAction = (
queryClient: QueryClient,
playlistSlugOrId: number | string,
) => UpdateIllustrationAction(queryClient, playlistSlugOrId, "playlist");
playlistId: number,
) => UpdateIllustrationAction(queryClient, playlistId, "playlist");
export {
UpdateArtistIllustrationAction,
@@ -24,7 +24,7 @@ import { ShareAlbumAction } from "../actions/share";
import ContextualMenu from "./contextual-menu";
import { AlbumWithRelations } from "../../models/album";
import { useQueryClient } from "../../api/use-query";
import ChangeAlbumType from "../actions/album-type";
import { ChangeAlbumType } from "../actions/resource-type";
import { RefreshAlbumMetadataAction } from "../actions/refresh-metadata";
import { useTranslation } from "react-i18next";
@@ -53,38 +53,41 @@ const ContextualMenu = (props: ContextualMenuProps) => {
onClose={handleClose}
style={{ zIndex: 99999 }}
>
{props.actions.map((actions, actionGroupIndex, allActions) =>
[
actions.map((action, actionIndex) => (
<Box
key={`${actionGroupIndex}/${actionIndex}`}
onClick={() => {
// If the action is NOT a dialog, close and unmount the menu.
// If it is a dialog, we need to keep it mounted, so we have to keep it open
if (!action.dialog) {
handleClose();
}
props.onSelect && props.onSelect(action);
}}
>
<ContextualMenuItem
{...action}
onDialogClose={handleClose}
/>
</Box>
)),
].concat(
actionGroupIndex < allActions.length - 1
? [
<Divider
key={actionGroupIndex}
sx={{ marginY: 0.5 }}
variant="middle"
/>,
]
: [],
),
)}
{props.actions
.filter((actionList) => actionList.length > 0)
.map((actions, actionGroupIndex, allActions) =>
[
actions.map((action, actionIndex) => (
<Box
key={`${actionGroupIndex}/${actionIndex}`}
onClick={() => {
// If the action is NOT a dialog, close and unmount the menu.
// If it is a dialog, we need to keep it mounted, so we have to keep it open
if (!action.dialog) {
handleClose();
}
props.onSelect &&
props.onSelect(action);
}}
>
<ContextualMenuItem
{...action}
onDialogClose={handleClose}
/>
</Box>
)),
].concat(
actionGroupIndex < allActions.length - 1
? [
<Divider
key={actionGroupIndex}
sx={{ marginY: 0.5 }}
variant="middle"
/>,
]
: [],
),
)}
</Menu>
</>
);
@@ -48,7 +48,7 @@ const PlaylistContextualMenu = (props: PlaylistContextualMenuProps) => {
[
UpdatePlaylistIllustrationAction(
queryClient,
props.playlist.slug,
props.playlist.id,
),
],
[SharePlaylistAction(props.playlist.slug, t)],
@@ -30,7 +30,7 @@ import { DownloadReleaseAction } from "../actions/download";
import { useConfirm } from "material-ui-confirm";
import { ReleaseWithRelations } from "../../models/release";
import { UpdateReleaseIllustrationAction } from "../actions/update-illustration";
import ChangeAlbumType from "../actions/album-type";
import { ChangeAlbumType } from "../actions/resource-type";
import { RefreshReleaseMetadataAction } from "../actions/refresh-metadata";
import { useTranslation } from "react-i18next";
@@ -46,7 +46,9 @@ const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
const confirm = useConfirm();
const { t } = useTranslation();
const masterMutation = useMutation(async () => {
return API.setReleaseAsMaster(props.release.id)
return API.updateAlbum(props.release.albumId, {
masterReleaseId: props.release.id,
})
.then(() => {
toast.success(t("releaseSetAsMaster"));
queryClient.client.invalidateQueries();
@@ -64,7 +66,12 @@ const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
Promise.allSettled(
tracks
.reverse()
.map((track) => API.setTrackAsMaster(track.id)),
.filter((track) => track.songId != null)
.map((track) =>
API.updateSong(track.songId!, {
masterTrackId: track.id,
}),
),
)
.then(() => {
toast.success(t("tracksUpdated"));
@@ -37,7 +37,7 @@ import { TrackWithRelations } from "../../models/track";
import { UpdateTrackIllustrationAction } from "../actions/update-illustration";
import { useQueryClient } from "../../api/use-query";
import { RefreshTrackMetadataAction } from "../actions/refresh-metadata";
import ChangeSongType from "../actions/song-type";
import { ChangeSongType } from "../actions/resource-type";
import { useTranslation } from "react-i18next";
import { usePlayerContext } from "../../contexts/player";
@@ -48,7 +48,7 @@ type ReleaseTrackContextualMenuProps = {
};
const ReleaseTrackContextualMenu = (props: ReleaseTrackContextualMenuProps) => {
const songSlug = props.track.song.slug;
const songSlug = props.track.song?.slug;
const confirm = useConfirm();
const queryClient = useQueryClient();
const { t } = useTranslation();
@@ -59,25 +59,37 @@ const ReleaseTrackContextualMenu = (props: ReleaseTrackContextualMenuProps) => {
onSelect={props.onSelect}
actions={[
[GoToArtistAction(props.artist.slug)],
[GoToSongLyricsAction(songSlug)],
songSlug ? [GoToSongLyricsAction(songSlug)] : [],
[
PlayNextAction(async () => props, playNext),
PlayAfterAction(async () => props, playAfter),
],
[AddToPlaylistAction(props.track.song.id, queryClient)],
props.track.songId
? [AddToPlaylistAction(props.track.songId, queryClient)]
: [],
songSlug
? [
GoToSongVersionAction(songSlug),
GoToRelatedTracksAction(songSlug),
]
: [],
[
GoToSongVersionAction(songSlug),
GoToRelatedTracksAction(songSlug),
],
[
ChangeSongType(props.track.song, queryClient, confirm),
...(props.track.song
? [
ChangeSongType(
props.track.song,
queryClient,
confirm,
),
]
: []),
UpdateTrackIllustrationAction(queryClient, props.track.id),
RefreshTrackMetadataAction(props.track.id, t),
],
[ShowTrackFileInfoAction(confirm, props.track.id)],
[
DownloadAction(confirm, props.track.sourceFileId, t),
ShareSongAction(songSlug, t),
...(songSlug ? [ShareSongAction(songSlug, t)] : []),
],
]}
/>
@@ -38,7 +38,7 @@ import { ShowMasterTrackFileInfoAction } from "../actions/show-track-info";
import { SongWithRelations } from "../../models/song";
import { useQueryClient } from "../../api/use-query";
import { toast } from "react-hot-toast";
import ChangeSongType from "../actions/song-type";
import { ChangeSongType } from "../actions/resource-type";
import { RefreshSongMetadataAction } from "../actions/refresh-metadata";
import { DeleteIcon } from "../icons";
import { useTranslation } from "react-i18next";
@@ -58,7 +58,7 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
const { playNext, playAfter } = usePlayerContext();
const getMasterTrack = () =>
queryClient.fetchQuery(
API.getMasterTrack(songSlug, ["release", "illustration"]),
API.getSongMasterTrack(songSlug, ["release", "illustration"]),
);
const router = useRouter();
const confirm = useConfirm();
@@ -76,7 +76,8 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
GoToArtistAction(props.song.artist.slug),
GoToReleaseAsyncAction(
router,
async () => (await getMasterTrack()).release.slug,
async () =>
(await getMasterTrack()).release?.slug ?? null,
),
],
[GoToSongLyricsAction(songSlug)],
@@ -26,11 +26,7 @@ import { RootState } from "../../state/store";
import ContextualMenu from "./contextual-menu";
import { useConfirm } from "material-ui-confirm";
import { DownloadAction } from "../actions/download";
import {
GoToArtistAction,
GoToReleaseAction,
GoToSongLyricsAction,
} from "../actions/link";
import { GoToReleaseAction, GoToSongLyricsAction } from "../actions/link";
import {
AddToPlaylistAction,
PlayAfterAction,
@@ -40,13 +36,13 @@ import { ShowTrackFileInfoAction } from "../actions/show-track-info";
import { TrackWithRelations } from "../../models/track";
import { UpdateTrackIllustrationAction } from "../actions/update-illustration";
import { RefreshTrackMetadataAction } from "../actions/refresh-metadata";
import ChangeSongType from "../actions/song-type";
import { useTranslation } from "react-i18next";
import { usePlayerContext } from "../../contexts/player";
import Action from "../actions/action";
import { ChangeSongType } from "../actions/resource-type";
type TrackContextualMenuProps = {
track: TrackWithRelations<"song" | "illustration">;
isVideo?: true; // If true, will add 'go to artist' to menu
track: TrackWithRelations<"video" | "song" | "illustration">;
onSelect?: () => void;
};
@@ -56,11 +52,16 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
);
const queryClient = useQueryClient();
const confirm = useConfirm();
const isMaster = props.track.song.masterId == props.track.id;
const isMaster = props.track.song?.masterId === props.track.id;
const getPlayNextProps = () =>
Promise.all([
queryClient.fetchQuery(API.getArtist(props.track.song.artistId)),
queryClient.fetchQuery(API.getRelease(props.track.releaseId)),
queryClient.fetchQuery(
API.getArtist(
(props.track.song ?? props.track.video)!.artistId,
),
),
props.track.releaseId &&
queryClient.fetchQuery(API.getRelease(props.track.releaseId)),
]).then(([artist, release]) => ({
track: props.track,
artist,
@@ -69,7 +70,9 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
const { t } = useTranslation();
const { playNext, playAfter } = usePlayerContext();
const masterMutation = useMutation(async () => {
return API.setTrackAsMaster(props.track.id)
return API.updateSong(props.track.songId!, {
masterTrackId: props.track.id,
})
.then(() => {
toast.success(t("trackSetAsMaster"));
queryClient.client.invalidateQueries();
@@ -81,28 +84,41 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
<ContextualMenu
onSelect={props.onSelect}
actions={[
props.isVideo
? [
GoToArtistAction(props.track.song.artistId),
GoToReleaseAction(props.track.releaseId),
]
: [GoToReleaseAction(props.track.releaseId)],
[GoToSongLyricsAction(props.track.song.slug)],
[
props.track.releaseId
? GoToReleaseAction(props.track.releaseId)
: undefined,
].filter((a): a is Action => a !== undefined),
props.track.song
? [GoToSongLyricsAction(props.track.song.slug)]
: [],
[
PlayNextAction(getPlayNextProps, playNext),
PlayAfterAction(getPlayNextProps, playAfter),
],
[AddToPlaylistAction(props.track.songId, queryClient)],
props.track.songId
? [AddToPlaylistAction(props.track.songId, queryClient)]
: [],
props.track.songId
? [
{
label: "setAsMaster",
disabled: isMaster || !userIsAdmin,
icon: <MasterIcon />,
onClick: () => masterMutation.mutate(),
},
]
: [],
[
{
label: "setAsMaster",
disabled: isMaster || !userIsAdmin,
icon: <MasterIcon />,
onClick: () => masterMutation.mutate(),
},
],
[
ChangeSongType(props.track.song, queryClient, confirm),
...(props.track.song
? [
ChangeSongType(
props.track.song,
queryClient,
confirm,
),
]
: []),
UpdateTrackIllustrationAction(queryClient, props.track.id),
RefreshTrackMetadataAction(props.track.id, t),
],
@@ -0,0 +1,104 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useQueryClient } from "../../api/use-query";
import API from "../../api/api";
import ContextualMenu from "./contextual-menu";
import { useConfirm } from "material-ui-confirm";
import { DownloadAction } from "../actions/download";
import {
GoToArtistAction,
GoToReleaseAction,
GoToSongLyricsAction,
} from "../actions/link";
import {
AddToPlaylistAction,
PlayAfterAction,
PlayNextAction,
} from "../actions/playlist";
import { ShowTrackFileInfoAction } from "../actions/show-track-info";
import { UpdateTrackIllustrationAction } from "../actions/update-illustration";
import { RefreshTrackMetadataAction } from "../actions/refresh-metadata";
import { useTranslation } from "react-i18next";
import { usePlayerContext } from "../../contexts/player";
import Action from "../actions/action";
import { VideoWithRelations } from "../../models/video";
import { ChangeVideoType } from "../actions/resource-type";
type VideoContextualMenuProps = {
video: VideoWithRelations<"master" | "illustration">;
onSelect?: () => void;
};
const VideoContextualMenu = (props: VideoContextualMenuProps) => {
const queryClient = useQueryClient();
const confirm = useConfirm();
const getPlayNextProps = () =>
Promise.all([
queryClient.fetchQuery(API.getArtist(props.video.artistId)),
props.video.master.releaseId &&
queryClient.fetchQuery(
API.getRelease(props.video.master.releaseId),
),
]).then(([artist, release]) => ({
track: {
...props.video.master,
illustration: props.video.illustration,
},
artist,
release,
}));
const { t } = useTranslation();
const { playNext, playAfter } = usePlayerContext();
return (
<ContextualMenu
onSelect={props.onSelect}
actions={[
[
GoToArtistAction(props.video.artistId),
props.video.master.releaseId
? GoToReleaseAction(props.video.master.releaseId)
: undefined,
].filter((a): a is Action => a !== undefined),
props.video.songId
? [GoToSongLyricsAction(props.video.songId)]
: [],
[
PlayNextAction(getPlayNextProps, playNext),
PlayAfterAction(getPlayNextProps, playAfter),
],
props.video.songId
? [AddToPlaylistAction(props.video.songId, queryClient)]
: [],
[
ChangeVideoType(props.video, queryClient, confirm),
UpdateTrackIllustrationAction(
queryClient,
props.video.master.id,
),
RefreshTrackMetadataAction(props.video.master.id, t),
],
[ShowTrackFileInfoAction(confirm, props.video.master.id)],
[DownloadAction(confirm, props.video.master.sourceFileId, t)],
]}
/>
);
};
export default VideoContextualMenu;
@@ -17,35 +17,35 @@
*/
import { Box, Button, Skeleton } from "@mui/material";
import ExternalId from "../models/external-id";
import Illustration from "./illustration";
import Link from "next/link";
import { ExternalMetadataSource } from "../models/external-metadata";
type ExternalIdBadgeProps = {
externalId: ExternalId | undefined;
type ExternalMetadataBadgeProps = {
source: ExternalMetadataSource | undefined;
};
const ExternalIdBadge = ({ externalId }: ExternalIdBadgeProps) => {
const ExternalMetadataBadge = ({ source }: ExternalMetadataBadgeProps) => {
const badge = (
<Button
variant="outlined"
startIcon={
<Box sx={{ width: 30 }}>
<Illustration
url={externalId?.provider.icon ?? undefined}
url={source?.providerIcon ?? undefined}
quality="original"
/>
</Box>
}
>
{externalId?.provider.name ?? <Skeleton width="50px" />}
{source?.providerName ?? <Skeleton width="50px" />}
</Button>
);
if (externalId?.url) {
if (source?.url) {
return (
<Link
href={externalId.url ?? undefined}
href={source.url ?? undefined}
rel="noopener noreferrer"
target="_blank"
>
@@ -56,4 +56,4 @@ const ExternalIdBadge = ({ externalId }: ExternalIdBadgeProps) => {
return badge;
};
export default ExternalIdBadge;
export default ExternalMetadataBadge;
@@ -20,30 +20,23 @@ import { useTranslation } from "react-i18next";
import { AlbumWithRelations } from "../../models/album";
import getYear from "../../utils/getYear";
import HighlightCard from "./highlight-card";
import { AlbumExternalMetadata } from "../../models/external-metadata";
type AlbumHighlightCardProps = {
album:
| AlbumWithRelations<
"artist" | "externalIds" | "genres" | "illustration"
>
| undefined;
album: AlbumWithRelations<"artist" | "genres" | "illustration"> | undefined;
externalMetadata: AlbumExternalMetadata | undefined;
};
const AlbumHighlightCard = ({ album }: AlbumHighlightCardProps) => {
const AlbumHighlightCard = ({
album,
externalMetadata,
}: AlbumHighlightCardProps) => {
const { t } = useTranslation();
return (
<HighlightCard
title={album?.name}
headline={album?.name}
body={
album?.externalIds
.filter(
({ provider }) =>
provider.name.toLowerCase() !== "discogs",
)
.map((id) => id.description)
.filter((desc): desc is string => desc !== null)
.sort((descA, descB) => descA.length - descB.length)
.at(0) ||
externalMetadata?.description ||
[
album ? album.artist?.name ?? t("compilation") : undefined,
album ? getYear(album.releaseDate) : undefined,
@@ -95,12 +95,14 @@ const InfiniteAlbumView = (
}
renderListItem={(item) => (
<AlbumItem
onClick={() => item && props.onItemClick?.(item)}
album={item}
formatSubtitle={props.formatSubtitle}
/>
)}
renderGridItem={(item) => (
<AlbumTile
onClick={() => item && props.onItemClick?.(item)}
album={item}
formatSubtitle={props.formatSubtitle}
/>
@@ -62,8 +62,18 @@ const InfiniteArtistView = (
"asc",
})
}
renderListItem={(item) => <ArtistItem artist={item} />}
renderGridItem={(item) => <ArtistTile artist={item} />}
renderListItem={(item) => (
<ArtistItem
artist={item}
onClick={() => item && props.onItemClick?.(item)}
/>
)}
renderGridItem={(item) => (
<ArtistTile
artist={item}
onClick={() => item && props.onItemClick?.(item)}
/>
)}
/>
</>
);
@@ -69,8 +69,18 @@ const InfinitePlaylistView = (
view: "grid",
})
}
renderListItem={(item) => <PlaylistItem playlist={item} />}
renderGridItem={(item) => <PlaylistTile playlist={item} />}
renderListItem={(item) => (
<PlaylistItem
playlist={item}
onClick={() => item && props.onItemClick?.(item)}
/>
)}
renderGridItem={(item) => (
<PlaylistTile
playlist={item}
onClick={() => item && props.onItemClick?.(item)}
/>
)}
/>
</>
);
@@ -31,6 +31,7 @@ type InfiniteResourceViewProps<
options: OptionState<SortingKeys> & AdditionalQueryParams,
) => ReturnType<MeeloInfiniteQueryFn<ResourceType>>;
light?: boolean;
onItemClick?: (item: ResourceType) => void;
disableSorting?: true;
defaultLayout?: LayoutOption;
initialSortingOrder?: Order;
@@ -223,7 +223,11 @@ const InfiniteSongView = <
});
}}
renderListItem={(item) => (
<SongItem song={item} subtitles={props.subtitles} />
<SongItem
song={item}
subtitles={props.subtitles}
onClick={() => item && props.onItemClick?.(item)}
/>
)}
renderGridItem={() => <></>}
/>
@@ -26,7 +26,7 @@ import InfiniteView from "../infinite-view";
const InfiniteTrackView = (
props: InfiniteResourceViewProps<
TrackWithRelations<"song" | "release" | "illustration">,
TrackWithRelations<"video" | "song" | "release" | "illustration">,
typeof TrackSortingKeys
>,
) => {
@@ -55,7 +55,12 @@ const InfiniteTrackView = (
order: options?.order ?? "asc",
})
}
renderListItem={(item) => <TrackItem track={item} />}
renderListItem={(item) => (
<TrackItem
track={item}
onClick={() => item && props.onItemClick?.(item)}
/>
)}
renderGridItem={(item) => <></>}
/>
</>
@@ -18,13 +18,15 @@
import { useRouter } from "next/router";
import { ComponentProps, useState } from "react";
import { SongSortingKeys, SongType } from "../../../models/song";
import { VideoWithRelations } from "../../../models/video";
import {
VideoSortingKeys,
VideoType,
VideoWithRelations,
} from "../../../models/video";
import Controls, { OptionState } from "../../controls/controls";
import InfiniteView from "../infinite-view";
import InfiniteResourceViewProps from "./infinite-resource-view-props";
import VideoTile from "../../tile/video-tile";
import { PaginationParameters } from "../../../models/pagination";
import { PlayIcon, ShuffleIcon } from "../../icons";
import { PlayerActions, usePlayerContext } from "../../../contexts/player";
import {
@@ -33,19 +35,30 @@ import {
prepareMeeloInfiniteQuery,
useQueryClient,
} from "../../../api/use-query";
import VideoItem from "../../list-item/video-item";
const playVideosAction = (
emptyPlaylist: PlayerActions["emptyPlaylist"],
playTrack: PlayerActions["playTrack"],
playAfter: PlayerActions["playAfter"],
queryClient: QueryClient,
query: () => InfiniteQuery<VideoWithRelations<"artist" | "featuring">>,
query: () => InfiniteQuery<
VideoWithRelations<"artist" | "master" | "illustration">
>,
) => {
emptyPlaylist();
queryClient.client
.fetchInfiniteQuery(prepareMeeloInfiniteQuery(query))
.then(async (res) => {
const videos = res.pages.flatMap(({ items }) => items);
const videos = res.pages
.flatMap(({ items }) => items)
.map((video) => ({
...video,
track: {
...video.master,
illustration: video.illustration,
},
}));
let i = 0;
for (const video of videos) {
if (i == 0) {
@@ -59,16 +72,16 @@ const playVideosAction = (
};
type AdditionalProps = {
type?: SongType;
type?: VideoType;
random?: number;
};
const InfiniteVideoView = <
T extends VideoWithRelations<"artist" | "featuring">,
T extends VideoWithRelations<"artist" | "illustration" | "master">,
>(
props: InfiniteResourceViewProps<
T,
typeof SongSortingKeys,
typeof VideoSortingKeys,
AdditionalProps
> &
Omit<ComponentProps<typeof VideoTile>, "video">,
@@ -76,11 +89,11 @@ const InfiniteVideoView = <
const router = useRouter();
const queryClient = useQueryClient();
const [options, setOptions] =
useState<OptionState<typeof SongSortingKeys, AdditionalProps>>();
useState<OptionState<typeof VideoSortingKeys, AdditionalProps>>();
const query = {
type:
// @ts-ignore
options?.type == "All" ? undefined : (options?.type as SongType),
options?.type == "All" ? undefined : (options?.type as VideoType),
sortBy: options?.sortBy ?? props.initialSortingField ?? "name",
order: options?.order ?? props.initialSortingOrder ?? "asc",
view: "grid",
@@ -122,55 +135,40 @@ const InfiniteVideoView = <
<Controls
options={[
{
label: (options?.type as SongType) ?? "All",
label: (options?.type as VideoType) ?? "All",
name: "type",
values: [
"All",
...SongType.filter(
(type) =>
![
"Unknown",
"Demo",
"Clean",
"Edit",
"Acapella",
"Instrumental",
].includes(type),
),
],
values: ["All", ...VideoType],
currentValue: options?.type,
},
]}
onChange={setOptions}
sortingKeys={SongSortingKeys}
sortingKeys={VideoSortingKeys}
defaultSortingOrder={props.initialSortingOrder}
defaultSortingKey={props.initialSortingField}
router={props.light == true ? undefined : router}
defaultLayout={"grid"}
disableLayoutToggle
actions={[playAction, shuffleAction]}
/>
<InfiniteView
view={options?.view ?? "grid"}
query={() => {
const { key, exec } = props.query(query);
return {
key,
exec: (pagination: PaginationParameters) =>
exec(pagination).then((page) => {
return {
...page,
items: page.items.map((item) => ({
...item,
illustration: item.track.illustration,
})),
};
}),
};
return props.query({
...query,
sortBy: query.sortBy,
});
}}
renderListItem={(item) => <></>}
renderListItem={(item) => (
<VideoItem
video={item}
onClick={() => item && props.onItemClick?.(item)}
/>
)}
renderGridItem={(item) => (
<VideoTile video={item} subtitle={props.subtitle} />
<VideoTile
video={item}
subtitle={props.subtitle}
onClick={() => item && props.onItemClick?.(item)}
/>
)}
/>
</>
@@ -25,6 +25,7 @@ import { useTranslation } from "react-i18next";
type AlbumItemProps = {
album: AlbumWithRelations<"artist" | "illustration"> | undefined;
formatSubtitle?: (album: AlbumWithRelations<"artist">) => string;
onClick?: () => void;
};
/**
@@ -32,7 +33,7 @@ type AlbumItemProps = {
* @param props
* @returns
*/
const AlbumItem = ({ album, formatSubtitle }: AlbumItemProps) => {
const AlbumItem = ({ album, formatSubtitle, onClick }: AlbumItemProps) => {
const artist = album?.artist;
const { t } = useTranslation();
@@ -44,6 +45,7 @@ const AlbumItem = ({ album, formatSubtitle }: AlbumItemProps) => {
quality="low"
/>
}
onClick={onClick}
href={album ? `/albums/${album.slug}` : undefined}
title={album?.name}
secondTitle={
@@ -23,6 +23,7 @@ import ArtistAvatar from "../artist-avatar";
type ArtistItemProps = {
artist: ArtistWithRelations<"illustration"> | undefined;
onClick?: () => void;
};
/**
@@ -30,7 +31,7 @@ type ArtistItemProps = {
* @param props
* @returns
*/
const ArtistItem = ({ artist }: ArtistItemProps) => {
const ArtistItem = ({ artist, onClick }: ArtistItemProps) => {
return (
<ListItem
icon={
@@ -40,6 +41,7 @@ const ArtistItem = ({ artist }: ArtistItemProps) => {
/>
}
secondTitle={null}
onClick={onClick}
href={artist ? `/artists/${artist.slug}` : undefined}
title={artist?.name}
trailing={artist && <ArtistContextualMenu artist={artist} />}
+3 -5
View File
@@ -27,7 +27,6 @@ import {
Typography,
useTheme,
} from "@mui/material";
import { RequireExactlyOne } from "type-fest";
import Link from "next/link";
type ListItemProps = {
@@ -35,10 +34,9 @@ type ListItemProps = {
title: string | undefined;
secondTitle: string | undefined | null;
trailing?: JSX.Element;
} & RequireExactlyOne<{
href: string | undefined;
onClick: (() => void) | undefined;
}>;
href?: string;
onClick?: () => void;
};
const textStyle = {
whiteSpace: "nowrap",
@@ -24,6 +24,7 @@ import PlaylistContextualMenu from "../contextual-menu/playlist-contextual-menu"
type PlaylistItemProps = {
playlist: PlaylistWithRelations<"illustration"> | undefined;
onClick?: () => void;
};
/**
@@ -31,7 +32,7 @@ type PlaylistItemProps = {
* @param props
* @returns
*/
const PlaylistItem = ({ playlist }: PlaylistItemProps) => {
const PlaylistItem = ({ playlist, onClick }: PlaylistItemProps) => {
return (
<ListItem
icon={
@@ -45,6 +46,7 @@ const PlaylistItem = ({ playlist }: PlaylistItemProps) => {
secondTitle={null}
href={playlist ? `/playlists/${playlist.slug}` : undefined}
title={playlist?.name}
onClick={onClick}
trailing={
playlist && <PlaylistContextualMenu playlist={playlist} />
}
@@ -26,9 +26,10 @@ import { Star } from "iconsax-react";
type ReleaseItemProps = {
release: ReleaseWithRelations<"album" | "illustration">;
onClick?: () => void;
};
const ReleaseItem = ({ release }: ReleaseItemProps) => {
const ReleaseItem = ({ release, onClick }: ReleaseItemProps) => {
const isMaster = release.id == release.album.masterId;
return (
@@ -42,6 +43,7 @@ const ReleaseItem = ({ release }: ReleaseItemProps) => {
}
href={`/releases/${release.slug}`}
title={release.name}
onClick={onClick}
secondTitle={getYear(release.releaseDate)?.toString()}
trailing={
<Grid
+15 -6
View File
@@ -33,11 +33,12 @@ type SongItemProps<
>,
> = {
song: T | undefined;
onClick?: () => void;
subtitles?: ((
song: SongWithRelations<
"artist" | "featuring" | "master" | "illustration"
>,
) => Promise<string>)[];
) => Promise<string | null>)[];
};
export const SongGroupItem = <
@@ -86,11 +87,12 @@ const SongItem = <
>({
song,
subtitles,
onClick,
}: SongItemProps<T>) => {
const artist = song?.artist;
const { playTrack } = usePlayerContext();
const queryClient = useQueryClient();
const [subtitle, setSubtitle] = useState(
const [subtitle, setSubtitle] = useState<string | null | undefined>(
subtitles?.length
? ((<br />) as unknown as string)
: song
@@ -103,8 +105,13 @@ const SongItem = <
Promise.allSettled(subtitles.map((s) => s(song))).then((r) =>
setSubtitle(
r
.map((s) => (s as PromiseFulfilledResult<string>).value)
.join(" • "),
.map(
(s) =>
(s as PromiseFulfilledResult<string | null>)
.value,
)
.filter((s): s is string => s !== null)
.join(" • ") || null,
),
);
}
@@ -122,14 +129,16 @@ const SongItem = <
onClick={
song &&
artist &&
(() =>
(() => {
onClick?.();
playTrack({
artist,
track: {
...song.master,
illustration: song.illustration,
},
}))
});
})
}
secondTitle={subtitle}
trailing={song && <SongContextualMenu song={song} />}
+19 -11
View File
@@ -27,7 +27,10 @@ import { MasterIcon, TrackIcon } from "../icons";
import { usePlayerContext } from "../../contexts/player";
type TrackItemProps = {
track: TrackWithRelations<"release" | "song" | "illustration"> | undefined;
track:
| TrackWithRelations<"video" | "release" | "song" | "illustration">
| undefined;
onClick?: () => void;
};
/**
@@ -35,10 +38,10 @@ type TrackItemProps = {
* @param props
* @returns
*/
const TrackItem = ({ track }: TrackItemProps) => {
const TrackItem = ({ track, onClick }: TrackItemProps) => {
const release = track?.release;
const { playTrack } = usePlayerContext();
const isMaster = track ? track.song.masterId == track.id : false;
const isMaster = track ? track.song?.masterId == track.id : false;
const queryClient = useQueryClient();
return (
@@ -52,19 +55,24 @@ const TrackItem = ({ track }: TrackItemProps) => {
}
onClick={
track &&
release &&
(() =>
(() => {
onClick?.();
queryClient
.fetchQuery(API.getSong(track.songId, ["artist"]))
.then((song) => {
.fetchQuery(
track.song
? API.getArtist(track.song.artistId)
: API.getArtist(track.video!.artistId),
)
.then((artist) => {
playTrack({
artist: song.artist,
artist,
track,
});
}))
});
})
}
title={track?.name}
secondTitle={release?.name}
secondTitle={release?.name ?? null}
trailing={
<Grid
container
@@ -72,7 +80,7 @@ const TrackItem = ({ track }: TrackItemProps) => {
sx={{ justifyContent: "flex-end", flexWrap: "nowrap" }}
>
<Grid item sx={{ display: "flex", alignItems: "center" }}>
{isMaster ? <MasterIcon /> : undefined}
{track?.song && isMaster ? <MasterIcon /> : undefined}
</Grid>
<Grid item>
{track && <TrackContextualMenu track={track} />}
@@ -0,0 +1,106 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Illustration from "../illustration";
import ListItem from "./item";
import { useQueryClient } from "../../api/use-query";
import { useEffect, useState } from "react";
import { VideoIcon } from "../icons";
import formatArtists from "../../utils/formatArtists";
import { usePlayerContext } from "../../contexts/player";
import { VideoWithRelations } from "../../models/video";
import VideoContextualMenu from "../contextual-menu/video-contextual-menu";
type VideoType = VideoWithRelations<"artist" | "master" | "illustration">;
type VideoItemProps<T extends VideoType> = {
video: T | undefined;
onClick?: () => void;
subtitles?: ((video: VideoType) => Promise<string | null>)[];
};
const defaultSubtitle = (s: VideoType) => formatArtists(s.artist);
/**
* Item for a list of videos
* @param props
* @returns
*/
const VideoItem = <T extends VideoType>({
video,
subtitles,
onClick,
}: VideoItemProps<T>) => {
const artist = video?.artist;
const { playTrack } = usePlayerContext();
const queryClient = useQueryClient();
const [subtitle, setSubtitle] = useState<string | null | undefined>(
subtitles?.length
? ((<br />) as unknown as string)
: video
? defaultSubtitle(video)
: undefined,
);
useEffect(() => {
if (subtitles && video) {
Promise.allSettled(subtitles.map((s) => s(video))).then((r) =>
setSubtitle(
r
.map(
(s) =>
(s as PromiseFulfilledResult<string | null>)
.value,
)
.filter((s): s is string => s !== null)
.join(" • ") || null,
),
);
}
}, []);
return (
<ListItem
icon={
<Illustration
illustration={video?.illustration}
fallback={<VideoIcon />}
quality="low"
/>
}
title={video?.name}
onClick={
video &&
artist &&
(() => {
onClick?.();
playTrack({
artist,
track: {
...video.master,
illustration: video.illustration,
},
});
})
}
secondTitle={subtitle}
trailing={video && <VideoContextualMenu video={video} />}
/>
);
};
export default VideoItem;
+6 -1
View File
@@ -148,7 +148,12 @@ export const AlbumListPageSection = (
};
export const VideoListPageSection = (
props: Omit<PageSectionProps<VideoWithRelations<"artist">>, "child"> &
props: Omit<
PageSectionProps<
VideoWithRelations<"artist" | "master" | "illustration">
>,
"child"
> &
Omit<ComponentProps<typeof VideoTile>, "video">,
) => {
return (
+20 -11
View File
@@ -57,7 +57,6 @@ import ListItem from "../list-item/item";
import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd";
import formatDuration from "../../utils/formatDuration";
import formatArtists from "../../utils/formatArtists";
import { useTranslation } from "react-i18next";
import { usePlayerContext } from "../../contexts/player";
const parentSongQuery = (id: number) =>
@@ -112,7 +111,10 @@ const PreviousButton = (props: Omit<ControlButtonProps, "icon">) => (
<ControlButton {...props} icon={<RewindIcon />} />
);
const MinimizedPlayerControls = (props: PlayerControlsProps) => {
const parentSong = useQuery(parentSongQuery, props.track?.songId);
const parentSong = useQuery(
parentSongQuery,
props.track?.songId ?? undefined,
);
return (
<ButtonBase
@@ -229,14 +231,14 @@ const MinimizedPlayerControls = (props: PlayerControlsProps) => {
);
};
const Panels = ["lyrics", "playlist"] as const;
const ExpandedPlayerControls = (
props: PlayerControlsProps & { videoRef: LegacyRef<HTMLVideoElement> },
) => {
const theme = useTheme();
const { t } = useTranslation();
const parentSong = useQuery(parentSongQuery, props.track?.songId);
const parentSong = useQuery(
parentSongQuery,
props.track?.songId ?? undefined,
);
const { playlist, cursor, reorder, skipTrack } = usePlayerContext();
const [selectedTab, selectTab] = useState<"player" | "lyrics" | "playlist">(
"player",
@@ -414,7 +416,13 @@ const ExpandedPlayerControls = (
>
{props.artist && props.track ? (
<Link
href={`/releases/${props.track.releaseId}`}
href={
props.track.releaseId
? `/releases/${props.track.releaseId}`
: props.track.songId
? `/songs/${props.track.songId}/lyrics`
: {}
}
style={{
overflow: "hidden",
textOverflow: "ellipsis",
@@ -422,6 +430,7 @@ const ExpandedPlayerControls = (
>
<Button
onClick={() =>
props.track &&
props.onExpand(false)
}
sx={{
@@ -446,14 +455,14 @@ const ExpandedPlayerControls = (
</Grid>
<Grid item xs={1}>
{
props.track &&
parentSong.data &&
props.artist ? (
props.track && props.artist ? (
<ReleaseTrackContextualMenu
artist={props.artist}
track={{
...props.track,
song: parentSong.data,
song: props.track.songId
? parentSong.data ?? null
: null,
}}
onSelect={() =>
props.onExpand(false)
+3 -1
View File
@@ -121,7 +121,9 @@ const Player = () => {
progress.current = player.current!.currentTime;
};
player.current!.onended = () => {
API.setSongAsPlayed(currentTrack.track.songId);
if (currentTrack.track.songId) {
API.setSongAsPlayed(currentTrack.track.songId);
}
progress.current = null;
skipTrack();
};
+182 -167
View File
@@ -45,16 +45,17 @@ import { Fragment, useMemo } from "react";
import { generateArray } from "../utils/gen-list";
import { TrackState, usePlayerContext } from "../contexts/player";
import { Audio } from "react-loader-spinner";
import { VideoWithRelations } from "../models/video";
import { RequireAtLeastOne } from "type-fest";
type TrackType = TrackWithRelations<"illustration"> &
RequireAtLeastOne<{
song: SongWithRelations<"artist" | "featuring">;
video: VideoWithRelations<"artist">;
}>;
type ReleaseTracklistProps = {
mainArtist: Artist | undefined | null;
tracklist:
| Tracklist<
TrackWithRelations<"illustration"> & {
song: SongWithRelations<"artist" | "featuring">;
}
>
| undefined;
tracklist: Tracklist<TrackType> | undefined;
release: Release | undefined;
};
@@ -84,10 +85,12 @@ const ReleaseTrackList = ({
const flatTracklist = tracklist
? Array.from(Object.values(tracklist)).flat()
: undefined;
const formatTracksubtitle = (
song: SongWithRelations<"artist" | "featuring">,
) => {
if (song.artistId === mainArtist?.id && song.featuring?.length === 0) {
const formatTracksubtitle = (song: {
artistId: number;
artist: Artist;
featuring?: Artist[];
}) => {
if (song.artistId === mainArtist?.id && !song.featuring?.length) {
return undefined;
}
return formatArtists(song.artist, song.featuring);
@@ -113,169 +116,181 @@ const ReleaseTrackList = ({
)
}
>
{(
disc[1] as (
| (TrackWithRelations<"illustration"> & {
song: SongWithRelations<
"artist" | "featuring"
>;
})
| undefined
)[]
).map((currentTrack, index) => (
<Fragment key={index}>
<ListItem
dense={
mainArtist === undefined
? false
: currentTrack
? currentTrack.song.artistId !=
mainArtist?.id
: false
}
disablePadding
disableGutters
secondaryAction={
currentTrack ? (
<ReleaseTrackContextualMenu
track={currentTrack}
artist={currentTrack.song.artist}
/>
) : (
<IconButton disabled>
<ContextualMenuIcon />
</IconButton>
)
}
>
<ListItemButton
onClick={
flatTracklist &&
currentTrack &&
release &&
(() =>
playTracks({
tracks: flatTracklist.map(
(flatTrack) => ({
track: flatTrack,
release,
artist: flatTrack.song
.artist,
}),
),
cursor: flatTracklist.findIndex(
(flatTrack) =>
flatTrack.id ==
currentTrack.id,
),
}))
{(disc[1] as (TrackType | undefined)[]).map(
(currentTrack, index) => (
<Fragment key={index}>
<ListItem
dense={
mainArtist === undefined
? false
: currentTrack
? (currentTrack.song ??
currentTrack.video)!
.artistId !=
mainArtist?.id
: false
}
sx={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}}
>
<ListItemIcon>
{currentTrack ? (
currentTrack.id ===
currentlyPlayingTrack?.track.id ? (
<PlayingIcon />
) : (
<Typography color="text.disabled">
{currentTrack.trackIndex}
</Typography>
)
) : (
<Skeleton
width="20px"
sx={{ color: "text.disabled" }}
/>
)}
</ListItemIcon>
<ListItemText
primary={
currentTrack?.name ?? (
<Skeleton width="120px" />
)
}
primaryTypographyProps={{
fontSize: "medium",
}}
secondary={
mainArtist === undefined
? null
: currentTrack
? formatTracksubtitle(
currentTrack.song,
)
: undefined
}
secondaryTypographyProps={{
fontSize: "small",
color: "text.disabled",
}}
/>
{currentTrack?.isBonus && (
<Typography
sx={{
display: {
xs: "none",
sm: "block",
},
}}
color="text.disabled"
>
{t("bonusTrack")}
</Typography>
)}
{currentTrack?.isRemastered && (
<Typography
color="text.disabled"
sx={{
display: {
xs: "none",
sm: "block",
},
}}
>
{t("remastered")}
</Typography>
)}
{currentTrack?.type == "Video" && (
<Icon
sx={{
marginLeft: 2,
display: "flex",
alignItems: "center",
}}
>
<VideoIcon
color={
theme.palette.text.disabled
disablePadding
disableGutters
secondaryAction={
currentTrack ? (
<ReleaseTrackContextualMenu
track={{
...currentTrack,
song:
currentTrack.song ??
null,
}}
artist={
(currentTrack.song ??
currentTrack.video)!
.artist
}
/>
</Icon>
)}
<Typography
color="text.disabled"
) : (
<IconButton disabled>
<ContextualMenuIcon />
</IconButton>
)
}
>
<ListItemButton
onClick={
flatTracklist &&
currentTrack &&
release &&
(() =>
playTracks({
tracks: flatTracklist.map(
(flatTrack) => ({
track: flatTrack,
release,
artist: (flatTrack.song ??
flatTrack.video)!
.artist,
}),
),
cursor: flatTracklist.findIndex(
(flatTrack) =>
flatTrack.id ==
currentTrack.id,
),
}))
}
sx={{
marginLeft: 2,
overflow: "unset",
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}}
>
{currentTrack ? (
formatDuration(
currentTrack.duration,
)
) : (
<Skeleton width="30px" />
<ListItemIcon>
{currentTrack ? (
currentTrack.id ===
currentlyPlayingTrack?.track
.id ? (
<PlayingIcon />
) : (
<Typography color="text.disabled">
{
currentTrack.trackIndex
}
</Typography>
)
) : (
<Skeleton
width="20px"
sx={{
color: "text.disabled",
}}
/>
)}
</ListItemIcon>
<ListItemText
primary={
currentTrack?.name ?? (
<Skeleton width="120px" />
)
}
primaryTypographyProps={{
fontSize: "medium",
}}
secondary={
mainArtist === undefined
? null
: currentTrack
? formatTracksubtitle(
(currentTrack.song ??
currentTrack.video)!,
)
: undefined
}
secondaryTypographyProps={{
fontSize: "small",
color: "text.disabled",
}}
/>
{currentTrack?.isBonus && (
<Typography
sx={{
display: {
xs: "none",
sm: "block",
},
}}
color="text.disabled"
>
{t("bonusTrack")}
</Typography>
)}
</Typography>
</ListItemButton>
</ListItem>
<Divider variant="inset" />
</Fragment>
))}
{currentTrack?.isRemastered && (
<Typography
color="text.disabled"
sx={{
display: {
xs: "none",
sm: "block",
},
}}
>
{t("remastered")}
</Typography>
)}
{currentTrack?.type == "Video" && (
<Icon
sx={{
marginLeft: 2,
display: "flex",
alignItems: "center",
}}
>
<VideoIcon
color={
theme.palette.text
.disabled
}
/>
</Icon>
)}
<Typography
color="text.disabled"
sx={{
marginLeft: 2,
overflow: "unset",
}}
>
{currentTrack ? (
formatDuration(
currentTrack.duration,
)
) : (
<Skeleton width="30px" />
)}
</Typography>
</ListItemButton>
</ListItem>
<Divider variant="inset" />
</Fragment>
),
)}
</List>
))}
</Box>
@@ -17,17 +17,16 @@
*/
import { Box, Link, Skeleton } from "@mui/material";
import ExternalId from "../models/external-id";
import { type CommonExternalMetadata } from "../models/external-metadata";
import { useState } from "react";
import { capitalCase } from "change-case";
import { useTranslation } from "react-i18next";
import { generateArray } from "../utils/gen-list";
type Props = {
externalDescription: ExternalId | undefined;
externalMetadata: CommonExternalMetadata | undefined;
};
const ResourceDescriptionExpandable = ({ externalDescription }: Props) => {
const ResourceDescriptionExpandable = ({ externalMetadata }: Props) => {
const [isExpanded, setIsExpanded] = useState(false);
const maxLine = 5;
const smallBoxStyle = {
@@ -42,27 +41,13 @@ const ResourceDescriptionExpandable = ({ externalDescription }: Props) => {
return (
<Box id="description" sx={isExpanded ? bigBoxStyle : smallBoxStyle}>
{externalDescription
? externalDescription.description
: generateArray(5).map((_, index) => <Skeleton key={index} />)}
{isExpanded && (
<>
{" Source: "}
<Link
href={externalDescription?.url ?? undefined}
rel="noopener noreferrer"
target="_blank"
>
{externalDescription ? (
capitalCase(externalDescription?.provider.name)
) : (
<Skeleton width={50} />
)}
</Link>
{"."}
</>
)}{" "}
{externalDescription && (
{externalMetadata?.description ||
(externalMetadata?.description === undefined &&
generateArray(5).map((_, index) => (
<Skeleton key={index} />
)))}
{externalMetadata?.description && (
<Link
href="#description"
onClick={() => setIsExpanded(!isExpanded)}
@@ -41,7 +41,6 @@ import AdminGrid from "../admin-grid";
import {
CleanAllLibrariesAction,
CleanLibraryAction,
FetchExternalMetadata,
ScanAllLibrariesAction,
ScanLibraryAction,
} from "../actions/library-task";
@@ -51,7 +50,6 @@ import { ComponentProps, useMemo, useState } from "react";
import LibraryForm from "../library-form";
import { RefreshLibraryMetadataAction } from "../actions/refresh-metadata";
import SectionHeader from "../section-header";
import { capitalCase } from "change-case";
import { useTranslation } from "react-i18next";
const actionButtonStyle = {
@@ -102,25 +100,21 @@ const LibrariesSettings = () => {
CleanAllLibrariesAction.onClick?.();
},
};
const fetchMetadata = {
...FetchExternalMetadata,
onClick: () => {
tasks.refetch();
FetchExternalMetadata.onClick?.();
},
};
const confirm = useConfirm();
const [createModalOpen, setCreateModalOpen] = useState(false);
const [libraryEdit, setLibraryEdit] = useState<Library | undefined>(); // If set, open modal to edit library
const closeEditModal = () => setLibraryEdit(undefined);
const closeCreateModal = () => setCreateModalOpen(false);
const deletionMutation = useMutation((libraryId: number) =>
API.deleteLibrary(libraryId)
.then(() => {
toast.success(t("libraryDeleted"));
queryClient.client.invalidateQueries(["libraries"]);
toast
.promise(API.deleteLibrary(libraryId), {
loading: t("deletingLibrary"),
success: t("libraryDeleted"),
error: t("libraryDeletionFail"),
})
.catch(() => toast.error(t("libraryDeletionFail"))),
.then(() => {
queryClient.client.invalidateQueries(["libraries"]);
}),
);
const createMutation = useMutation(
(createForm: { name: string; path: string }) =>
@@ -243,19 +237,17 @@ const LibrariesSettings = () => {
{t("createLibrary")}
</Button>
</Grid>
{[cleanAllLibaries, scanAllLibaries, fetchMetadata].map(
(action, index) => (
<Grid item key={"Library-action-" + index}>
<Button
variant={index % 2 ? "contained" : "outlined"}
startIcon={action.icon}
onClick={action.onClick}
>
{t(action.label)}
</Button>
</Grid>
),
)}
{[cleanAllLibaries, scanAllLibaries].map((action, index) => (
<Grid item key={"Library-action-" + index}>
<Button
variant={index % 2 ? "contained" : "outlined"}
startIcon={action.icon}
onClick={action.onClick}
>
{t(action.label)}
</Button>
</Grid>
))}
</Grid>
<Dialog
open={libraryEdit != undefined}
@@ -298,16 +290,9 @@ const LibrariesSettings = () => {
<ListItemText
primary={
tasks.data ? (
`${t("current")}: ${capitalCase(
tasks.data.active?.name ?? t("none"),
)}`
) : (
<Skeleton />
)
}
secondary={
tasks.data ? (
tasks.data.active?.description
`${t("current")}: ${
tasks.data.current_task ?? t("none")
}`
) : (
<Skeleton />
)
@@ -319,7 +304,7 @@ const LibrariesSettings = () => {
primary={
tasks.data ? (
`${t("pending")}: ${
tasks.data.pending.length || t("none")
tasks.data.pending_tasks.length || t("none")
}`
) : (
<Skeleton />
@@ -327,13 +312,9 @@ const LibrariesSettings = () => {
}
/>
</ListItem>
{tasks.data?.pending.map((task, index) => (
{tasks.data?.pending_tasks.map((task, index) => (
<ListItem key={"task-" + index}>
<ListItemText
inset
primary={capitalCase(task.name)}
secondary={task.description}
/>
<ListItemText inset primary={task} />
</ListItem>
))}
</List>
+2
View File
@@ -25,6 +25,7 @@ import { useTranslation } from "react-i18next";
const AlbumTile = (props: {
album: AlbumWithRelations<"artist" | "illustration"> | undefined;
formatSubtitle?: (album: AlbumWithRelations<"artist">) => string;
onClick?: () => void;
}) => {
const { t } = useTranslation();
@@ -41,6 +42,7 @@ const AlbumTile = (props: {
props.album?.artist?.name ??
t("compilation")
}
onClick={props.onClick}
href={props.album ? `/albums/${props.album.slug}` : undefined}
secondaryHref={
props.album
@@ -24,6 +24,7 @@ import { Box } from "@mui/material";
const ArtistTile = (props: {
artist: ArtistWithRelations<"illustration"> | undefined;
onClick?: () => void;
}) => {
return (
<Tile
@@ -31,6 +32,7 @@ const ArtistTile = (props: {
contextualMenu={
props.artist && <ArtistContextualMenu artist={props.artist} />
}
onClick={props.onClick}
subtitle={null}
title={props.artist?.name}
href={props.artist && `/artists/${props.artist.slug}`}
@@ -24,8 +24,10 @@ import { PlaylistIcon } from "../icons";
const PlaylistTile = ({
playlist,
onClick,
}: {
playlist: PlaylistWithRelations<"illustration"> | undefined;
onClick?: () => void;
}) => {
return (
<Tile
@@ -34,6 +36,7 @@ const PlaylistTile = ({
}
title={playlist?.name}
subtitle={null}
onClick={onClick}
href={playlist ? `/playlists/${playlist.slug}` : undefined}
illustration={
<Illustration
@@ -25,6 +25,7 @@ import ReleaseContextualMenu from "../contextual-menu/release-contextual-menu";
const ReleaseTile = (props: {
release: ReleaseWithRelations<"album" | "illustration"> | undefined;
formatSubtitle?: (release: ReleaseWithRelations<"album">) => string;
onClick?: () => void;
}) => {
const yearFormat = props.release
? getYear(props.release.releaseDate)?.toString() ?? ""
@@ -36,6 +37,7 @@ const ReleaseTile = (props: {
<ReleaseContextualMenu release={props.release} />
)
}
onClick={props.onClick}
title={props.release?.name}
subtitle={
props.release
+25 -20
View File
@@ -21,21 +21,28 @@ import Illustration from "../illustration";
import { useQueryClient } from "../../api/use-query";
import API from "../../api/api";
import formatDuration from "../../utils/formatDuration";
import TrackContextualMenu from "../contextual-menu/track-contextual-menu";
import { usePlayerContext } from "../../contexts/player";
import Video, { VideoWithRelations } from "../../models/video";
import { VideoWithRelations } from "../../models/video";
import VideoContextualMenu from "../contextual-menu/video-contextual-menu";
type VideoTileProps =
type VideoTileProps = (
| {
video: Video | undefined;
video: VideoWithRelations<"master" | "illustration"> | undefined;
subtitle: "duration";
}
| {
video: VideoWithRelations<"artist"> | undefined;
video:
| VideoWithRelations<"artist" | "master" | "illustration">
| undefined;
subtitle: "artist";
};
}
) & { onClick?: () => void };
const VideoTile = ({ video, subtitle: subtitleType }: VideoTileProps) => {
const VideoTile = ({
video,
subtitle: subtitleType,
onClick,
}: VideoTileProps) => {
const queryClient = useQueryClient();
const { playTrack } = usePlayerContext();
const { subtitle, secondaryHref } = !video
@@ -44,7 +51,7 @@ const VideoTile = ({ video, subtitle: subtitleType }: VideoTileProps) => {
subtitle:
subtitleType == "artist"
? video.artist.name
: formatDuration(video.track.duration),
: formatDuration(video.master.duration),
secondaryHref:
subtitleType == "artist"
? `/artists/${video.artist.slug}`
@@ -53,25 +60,23 @@ const VideoTile = ({ video, subtitle: subtitleType }: VideoTileProps) => {
return (
<Tile
contextualMenu={
video && (
<TrackContextualMenu
isVideo
track={{ ...video.track, song: video }}
/>
)
}
contextualMenu={video && <VideoContextualMenu video={video} />}
onClick={
video
? () =>
? () => {
onClick?.();
queryClient
.fetchQuery(API.getArtist(video.artistId))
.then((artist) =>
playTrack({
track: video.track,
track: {
...video.master,
illustration: video.illustration,
},
artist: artist,
}),
)
);
}
: undefined
}
title={video?.name}
@@ -81,7 +86,7 @@ const VideoTile = ({ video, subtitle: subtitleType }: VideoTileProps) => {
<Illustration
quality="medium"
aspectRatio={16 / 9}
illustration={video?.track.illustration}
illustration={video?.illustration}
imgProps={{ objectFit: "cover" }}
/>
}
+12 -26
View File
@@ -42,6 +42,7 @@
"artists": "Artists",
"albums": "Albums",
"album": "Album",
"video": "Video",
"songs": "Songs",
"song": "Song",
"videos": "Videos",
@@ -58,15 +59,12 @@
"path": "Path",
"registrationDate": "Registration Date",
"noLyricsFound": "No Lyrics Found",
"changeAlbumType": "Change Album Type",
"libraryDeleted": "Library deleted",
"libraryDeletionFail": "Deleting library failed",
"libraryCreated": "Library created",
"libraryCreationFail": "Creating library failed",
"libraryUpdated": "Library updated",
"nameOfLibrary": "Name of the library",
"nameIsRequired": "Name is required",
"pathOfLibrary": "Path of the library",
@@ -74,29 +72,24 @@
"goBackHome": "Go Back Home",
"oops": "Oops",
"by": "by",
"warning": "Warning",
"downloadWarning": "Downloading copyrighted material you don't own is not authorized. Please proceed if, and only if, you own the original content.",
"download": "Download",
"compilation": "Compilation",
"clean": "Clean",
"refresh": "Refresh",
"scan": "Scan",
"refreshMetadata": "Refresh Metadata",
"createLibrary": "Create Library",
"deletingLibrary": "Deleting library...",
"deleteLibrary": "Delete Library",
"deleteLibraryAction": "Delete a Library",
"deleteLibraryWarning": "You are about to delete a library. This can not be undone",
"deleteUser": "Delete User",
"deleteUserWarning": "You are about to delete a user. This can not be undone.",
"userDeleted": "User deleted",
"userDeletionFail": "User deletion failed",
"userUpdateFail": "Updating user failed",
"userNowEnabled": "User is now enabled",
"userNowDisabled": "User is now disabled",
"userNowAdmin": "User is now an admin and can run administrative tasks",
@@ -104,12 +97,9 @@
"you": "You",
"admin": "Admin",
"enabled": "Enabled",
"trackSetAsMaster": "Track set as Master",
"showAllTrack": "Show all tracks",
"showOnlyExclusiveTracks": "Show only exclusive tracks",
"All": "All",
"StudioRecording": "Studio Recording",
"Single": "Single",
@@ -118,7 +108,14 @@
"Soundtrack": "Soundtrack",
"RemixAlbum": "Remix Album",
"VideoAlbum": "Video Album",
"MusicVideo": "Music Video",
"LyricsVideo": "Lyric Video",
"BehindTheScenes": "Behind The Scenes",
"Interview": "Interview",
"Advert": "Advert",
"PhotoGallery": "Photo Gallery",
"Documentary": "Documentary",
"Other": "Other",
"pluralStudioRecording": "Studio Albums",
"pluralSingle": "Singles",
"pluralLiveRecording": "Live Albums",
@@ -126,15 +123,11 @@
"pluralSoundtrack": "Soundtrack Albums",
"pluralRemixAlbum": "Remixes",
"pluralVideoAlbum": "Video Albums",
"groupVersions": "Group Versions",
"showAllSongs": "Show All Versions",
"onThisAlbum": "Featured Artists",
"backToTop": "Back To Top",
"changeLayout": "Change layout",
"sortBy": "Sort By",
"order": "Order",
"asc": "Asc",
@@ -151,18 +144,14 @@
"bitrate": "Bitrate",
"trackIndex": "Track Index",
"discIndex": "Disc Index",
"allLibraries": "All Libraries",
"setAsMaster": "Set as Master",
"setAllTracksAsMaster": "Set all tracks as Master",
"deleteFromPlaylist": "Delete from Playlist",
"changeIllutration": "Change Illustration",
"releaseSetAsMaster": "Release set as master",
"tracksUpdated": "Tracks successfully updated",
"librariesLoadFail": "Libraries could not be loaded",
"username": "Username",
"password": "Password",
"confirmPassword": "Confirm",
@@ -198,12 +187,12 @@
"darkMode": "Dark Mode",
"autoMode": "Auto Mode",
"logout": "Logout",
"appearsOn": "Appears On",
"featuredOnPlaylists": "Featured On",
"bonusTracks": "Bonus Tracks",
"relatedAlbums": "Related Albums",
"changeSongType": "Change Song Type",
"changeVideoType": "Change Video Type",
"Original": "Original",
"Remix": "Remix",
"Live": "Live",
@@ -213,12 +202,10 @@
"Clean": "Clean",
"Demo": "Demo",
"Unknown": "Unknown",
"Acapella": "Acapella",
"Acappella": "A Cappella",
"Medley": "Medley/Megamix",
"refreshMetadataStarted": "Refreshing Metadata... (You might need to refresh the page)",
"refreshMetadataFailed": "Oops... Refreshing Metadata Failed",
"more": "More",
"networkError": "There seem to be some network issues...",
"playbackError": "The track's format is not supported by this browser. Skipping...",
@@ -247,7 +234,6 @@
"encounteredABug": "You encountered a bug?",
"openAnIssue": "Open an issue on GitHub!",
"readTheDoc": "Read the user documentation",
"en": "English",
"fr": "French",
"yes": "Yes",
+12 -15
View File
@@ -48,7 +48,6 @@
"extension": "Extension",
"path": "Chemin",
"registrationDate": "Date d'ajout",
"nameOfLibrary": "Nom de la bibliothèque",
"nameIsRequired": "Le nom est obligatoire",
"pathOfLibrary": "Chemin de la bibliothèque",
@@ -57,12 +56,11 @@
"oops": "Oups",
"by": "de",
"noLyricsFound": "Aucune Parole Trouvée",
"warning": "Attention",
"downloadWarning": "Il est interdit de télécharger du contenu sans en posséder l'original. Ne téléchagez pas si vous ne le posséder pas légalement.",
"download": "Télécharger",
"compilation": "Compilation",
"deletingLibrary": "Suppression de la bibliothèque",
"libraryDeleted": "Bibliothèque Supprimée",
"libraryDeletionFail": "Echec de l'effacement de la Bibliothèque",
"libraryCreated": "Bibliothèque créée",
@@ -76,7 +74,6 @@
"deleteLibrary": "Supprimer la Bibliothèque",
"deleteLibraryAction": "Supprimer une Bibliothèque",
"deleteLibraryWarning": "Vous êtes sur le point de supprimer une bibliothèque. Il sera impossible de revenir en arrière.",
"deleteUser": "Supprimer Compte",
"deleteUserWarning": "Vous allez supprimer un compte utilisateur. Cela ne pourra pas être annulé.",
"userDeleted": "Compte Supprimé",
@@ -89,7 +86,6 @@
"you": "Vous",
"admin": "Administrateur",
"enabled": "Activé",
"All": "Tous Types",
"StudioRecording": "Album Studio",
"Single": "Single",
@@ -98,6 +94,14 @@
"Soundtrack": "Bande Originale",
"RemixAlbum": "Album de Remixes",
"VideoAlbum": "Collection de Vidéos",
"MusicVideo": "Clip Video",
"LyricsVideo": "Paroles",
"BehindTheScenes": "Making Of",
"Interview": "Interview",
"Advert": "Teaser",
"PhotoGallery": "Galerie Photo",
"Documentary": "Documentaire",
"Other": "Autre",
"pluralStudioRecording": "Albums",
"pluralSingle": "Singles",
"pluralLiveRecording": "Albums Live",
@@ -105,19 +109,14 @@
"pluralSoundtrack": "Bandes Originales",
"pluralRemixAlbum": "Remixes",
"pluralVideoAlbum": "Albums Vidéos",
"groupVersions": "Grouper les versions",
"showAllSongs": "Afficher toutes les versions",
"backToTop": "Retour en haut",
"changeLayout": "Changer la disposition",
"showAllTrack": "Afficher toutes les morceaux",
"showOnlyExclusiveTracks": "Afficher seulement les morceaux exclusifs",
"changeAlbumType": "Changer le Type de l'Album",
"onThisAlbum": "Artistes Invités",
"sortBy": "Trier par",
"order": "Ordre",
"asc": "Asc",
@@ -137,6 +136,7 @@
"artist": "Artiste",
"artists": "Artistes",
"album": "Album",
"video": "Vidéo",
"songs": "Morceaux",
"song": "Titre",
"search": "Rechercher",
@@ -187,13 +187,12 @@
"darkMode": "Thème Sombre",
"autoMode": "Thème Automatique",
"logout": "Se Déconnecter",
"appearsOn": "Apparaît sur",
"featuredOnPlaylists": "Apparaît dans",
"bonusTracks": "Pistes Bonus",
"relatedAlbums": "Albums Associés",
"changeSongType": "Changer le type",
"changeVideoType": "Change le type",
"Original": "Version Originale",
"Remix": "Remix",
"Live": "Version Live",
@@ -203,9 +202,8 @@
"Clean": "Version Clean",
"Demo": "Maquette",
"Unknown": "Type Inconnu",
"Acapella": "Acapella",
"Acappella": "A Cappella",
"Medley": "Medley/Megamix",
"refreshMetadataStarted": "Mise à jour des métadonées... (Rechargez la page pour actualiser)",
"refreshMetadataFailed": "Oups... La mise à jour des données a échoué.",
"more": "Plus",
@@ -236,7 +234,6 @@
"encounteredABug": "Vous avez rencontré un problème?",
"openAnIssue": "Ouvrir une issue sur GitHub!",
"readTheDoc": "Accéder à la documentation utilisateur",
"en": "Anglais",
"fr": "Français",
"yes": "Oui",
+1 -11
View File
@@ -20,7 +20,6 @@ import * as yup from "yup";
import Artist from "./artist";
import Illustration from "./illustration";
import Resource from "./resource";
import ExternalId from "./external-id";
import Genre from "./genre";
export const AlbumType = [
@@ -71,7 +70,7 @@ type Album = yup.InferType<typeof Album>;
export default Album;
export type AlbumInclude = "artist" | "externalIds" | "genres" | "illustration";
export type AlbumInclude = "artist" | "genres" | "illustration";
const AlbumWithRelations = <Selection extends AlbumInclude | never = never>(
relation: Selection[],
@@ -82,15 +81,6 @@ const AlbumWithRelations = <Selection extends AlbumInclude | never = never>(
artist: Artist.required().nullable(),
genres: yup.array(Genre.required()).required(),
illustration: Illustration.required().nullable(),
externalIds: yup
.array(
ExternalId.required().concat(
yup.object({
rating: yup.number().required().nullable(),
}),
),
)
.required(),
})
.pick(relation),
);
+1 -3
View File
@@ -16,7 +16,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import ExternalId from "./external-id";
import Illustration from "./illustration";
import Resource from "./resource";
import * as yup from "yup";
@@ -37,7 +36,7 @@ const Artist = Resource.concat(
type Artist = yup.InferType<typeof Artist>;
export type ArtistInclude = "externalIds" | "illustration";
export type ArtistInclude = "illustration";
const ArtistWithRelations = <Selection extends ArtistInclude | never = never>(
relation: Selection[],
@@ -45,7 +44,6 @@ const ArtistWithRelations = <Selection extends ArtistInclude | never = never>(
Artist.concat(
yup
.object({
externalIds: yup.array(ExternalId.required()).required(),
illustration: Illustration.required().nullable(),
})
.pick(relation),
-57
View File
@@ -1,57 +0,0 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import * as yup from "yup";
const ExternalId = yup.object({
/**
* Info about the provider
*/
provider: yup
.object({
/**
* Name of the Provider
*/
name: yup.string().required(),
/**
* URL to the Homepage of the provider
*/
homepage: yup.string().required(),
/**
* API-relative route to the provider's icon
*/
icon: yup.string().required(),
})
.required(),
/**
* Value of the External Identifier
*/
value: yup.string().required(),
/**
* Description of the resource, from the External Identifier
*/
description: yup.string().defined().strict(true).nullable(),
/**
* URL to the related resource
*/
url: yup.string().required().nullable(),
});
type ExternalId = yup.InferType<typeof ExternalId>;
export default ExternalId;
+70
View File
@@ -0,0 +1,70 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import * as yup from "yup";
const ExternalMetadataSource = yup.object({
url: yup.string().required(),
providerName: yup.string().required(),
providerId: yup.number().required(),
providerIcon: yup.string().required(),
});
const CommonExternalMetadata = yup.object({
description: yup.string().required().nullable(),
sources: yup.array(ExternalMetadataSource.required()).required(),
});
const ArtistExternalMetadata = CommonExternalMetadata.concat(
yup.object({
artistId: yup.number().required(),
}),
);
const SongExternalMetadata = CommonExternalMetadata.concat(
yup.object({
songId: yup.number().required(),
}),
);
const ReleaseExternalMetadata = CommonExternalMetadata.concat(
yup.object({
releaseId: yup.number().required(),
}),
);
const AlbumExternalMetadata = CommonExternalMetadata.concat(
yup.object({
albumId: yup.number().required(),
rating: yup.number().nullable(),
}),
);
type CommonExternalMetadata = yup.InferType<typeof CommonExternalMetadata>;
type ArtistExternalMetadata = yup.InferType<typeof ArtistExternalMetadata>;
type AlbumExternalMetadata = yup.InferType<typeof AlbumExternalMetadata>;
type SongExternalMetadata = yup.InferType<typeof SongExternalMetadata>;
type ReleaseExternalMetadata = yup.InferType<typeof CommonExternalMetadata>;
type ExternalMetadataSource = yup.InferType<typeof ExternalMetadataSource>;
export {
CommonExternalMetadata,
ArtistExternalMetadata,
AlbumExternalMetadata,
SongExternalMetadata,
ReleaseExternalMetadata,
ExternalMetadataSource,
};
-4
View File
@@ -25,10 +25,6 @@ const File = Resource.concat(
* Path of the track, relative to the parent library
*/
path: yup.string().required(),
/**
* MD5 checksum of the file
*/
md5Checksum: yup.string().required(),
/**
* Date of the file registration
*/
+1 -3
View File
@@ -19,7 +19,6 @@
import * as yup from "yup";
import Album from "./album";
import Resource from "./resource";
import ExternalId from "./external-id";
import Illustration from "./illustration";
/**
@@ -58,7 +57,7 @@ type Release = yup.InferType<typeof Release>;
export default Release;
export type ReleaseInclude = "album" | "externalIds" | "illustration";
export type ReleaseInclude = "album" | "illustration";
const ReleaseWithRelations = <Selection extends ReleaseInclude | never = never>(
relation: Selection[],
@@ -68,7 +67,6 @@ const ReleaseWithRelations = <Selection extends ReleaseInclude | never = never>(
.object({
album: Album.required(),
illustration: Illustration.required().nullable(),
externalIds: yup.array(ExternalId.required()).required(),
})
.pick(relation),
);
+14
View File
@@ -20,13 +20,18 @@ import { RequireExactlyOne } from "type-fest";
import { SongWithRelations } from "./song";
import { ArtistWithRelations } from "./artist";
import { AlbumWithRelations } from "./album";
import { VideoWithRelations } from "./video";
export type SearchResult = RequireExactlyOne<{
song: SongWithRelations<"artist" | "featuring" | "illustration" | "master">;
album: AlbumWithRelations<"artist" | "illustration">;
artist: ArtistWithRelations<"illustration">;
video: VideoWithRelations<"illustration" | "master" | "artist">;
}>;
export type SaveSearchItem = Partial<
Record<"songId" | "albumId" | "artistId" | "videoId", number>
>;
export const SearchResultTransformer = (
results: unknown,
): Promise<SearchResult[]> => {
@@ -36,6 +41,15 @@ export const SearchResultTransformer = (
return Promise.all(
results.map(async (result) => {
if ("groupId" in result) {
if ("songId" in result) {
return {
video: await VideoWithRelations([
"artist",
"illustration",
"master",
] as const).validate(result),
};
}
return {
song: await SongWithRelations([
"artist",
+1 -4
View File
@@ -21,7 +21,6 @@ import Artist from "./artist";
import Illustration from "./illustration";
import Lyrics from "./lyrics";
import Resource from "./resource";
import ExternalId from "./external-id";
import Track from "./track";
export const SongType = [
@@ -34,7 +33,7 @@ export const SongType = [
"Clean",
"Demo",
"Unknown",
"Acapella",
"Acappella",
"Medley",
"NonMusic",
] as const;
@@ -74,7 +73,6 @@ type Song = yup.InferType<typeof Song>;
type SongInclude =
| "artist"
| "lyrics"
| "externalIds"
| "featuring"
| "master"
| "illustration";
@@ -84,7 +82,6 @@ const SongRelations = yup.object({
master: yup.lazy(() => Track.required()),
featuring: yup.array(Artist.required()).required(),
lyrics: Lyrics.required().nullable(),
externalIds: yup.array(ExternalId.required()).required(),
illustration: Illustration.required().nullable(),
});
+10 -8
View File
@@ -23,13 +23,15 @@ const Task = yup.object({
description: yup.string().required(),
});
const ActiveTask = Task.concat(
yup.object({
data: yup.mixed(),
}),
);
type Task = yup.InferType<typeof Task>;
type ActiveTask = yup.InferType<typeof ActiveTask>;
export { Task, ActiveTask };
const TaskResponse = yup.object({
/**
* Status of the task
*/
message: yup.string().required(),
});
type TaskResponse = yup.InferType<typeof TaskResponse>;
export { Task, TaskResponse };
+11 -5
View File
@@ -21,6 +21,7 @@ import Illustration from "./illustration";
import Release from "./release";
import Resource from "./resource";
import Song from "./song";
import Video from "./video";
export const TrackType = ["Audio", "Video"] as const;
export type TrackType = (typeof TrackType)[number];
@@ -33,11 +34,15 @@ const Track = Resource.concat(
/**
* Unique identifier of the parent song
*/
songId: yup.number().required(),
songId: yup.number().required().nullable(),
/**
* Unique identifier of the parent video
*/
videoId: yup.number().required().nullable(),
/**
* Unique identifier of the parent release
*/
releaseId: yup.number().required(),
releaseId: yup.number().required().nullable(),
/**
* Title of the track
*/
@@ -82,11 +87,12 @@ type Track = yup.InferType<typeof Track>;
export default Track;
export type TrackInclude = "song" | "release" | "illustration";
export type TrackInclude = "song" | "release" | "illustration" | "video";
const TrackRelations = yup.object({
song: yup.lazy(() => Song.required()),
release: Release.required(),
song: yup.lazy(() => Song.required().nullable()),
video: yup.lazy(() => Video.required().nullable()),
release: Release.required().nullable(),
illustration: Illustration.required().nullable(),
});
+11 -2
View File
@@ -19,11 +19,13 @@
import Song, { SongInclude, SongRelations } from "./song";
import { TrackWithRelations } from "./track";
import * as yup from "yup";
import Video, { VideoRelations } from "./video";
const TracklistItem = TrackWithRelations(["illustration"])
.concat(
yup.object({
song: Song.required(),
song: Song.required().nullable(),
video: Video.required().nullable(),
}),
)
.required();
@@ -34,7 +36,14 @@ const TracklistItemWithRelations = <Selection extends SongInclude | never>(
TrackWithRelations(["illustration"])
.concat(
yup.object({
song: Song.concat(SongRelations.pick(selection)),
song: Song.concat(SongRelations.pick(selection)).nullable(),
video: Video.concat(
VideoRelations.pick(
selection.includes("artist" as Selection)
? ["artist"]
: [],
),
).nullable(),
}),
)
.required();
+53 -8
View File
@@ -16,24 +16,69 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Song, { SongInclude, SongRelations } from "./song";
import { TrackWithRelations } from "./track";
import Artist from "./artist";
import Illustration from "./illustration";
import Resource from "./resource";
import Song from "./song";
import Track from "./track";
import * as yup from "yup";
const Video = Song.concat(
export const VideoType = [
"MusicVideo",
"LyricsVideo",
"Live",
"BehindTheScenes",
"Interview",
"Advert",
"PhotoGallery",
"Documentary",
"Other",
] as const;
export const VideoTypeIsExtra = (vType: VideoType) =>
!["MusicVideo", "LyricsVideo", "Live"].includes(vType);
export type VideoType = (typeof VideoType)[number];
const Video = Resource.concat(
yup.object({
track: TrackWithRelations(["illustration"]).required(),
id: yup.number().required(),
slug: yup.string().required(),
name: yup.string().required(),
nameSlug: yup.string().required(),
artistId: yup.number().required(),
songId: yup.number().required().nullable(),
groupId: yup.number().required().nullable(),
registeredAt: yup.date().required(),
type: yup.mixed<VideoType>().oneOf(VideoType).required(),
}),
);
type Video = yup.InferType<typeof Video>;
const VideoWithRelations = <Selection extends SongInclude | never = never>(
relation: Selection[],
) => Video.concat(SongRelations.pick(relation));
export type VideoInclude = "artist" | "song" | "illustration" | "master";
export const VideoRelations = yup.object({
artist: Artist.required(),
song: yup.lazy(() => Song.required().nullable()),
master: yup.lazy(() => Track.required()),
illustration: Illustration.required().nullable(),
});
type VideoWithRelations<Selection extends SongInclude | never = never> =
const VideoWithRelations = <Selection extends VideoInclude | never = never>(
relation: Selection[],
) => Video.concat(VideoRelations.pick(relation));
type VideoWithRelations<Selection extends VideoInclude | never = never> =
yup.InferType<ReturnType<typeof VideoWithRelations<Selection>>>;
export default Video;
export const videoTypeIsExtra = (type: VideoType) =>
[
"BehindTheScenes",
"Interview",
"Advert",
"PhotoGallery",
"Documentary",
].includes(type);
export const VideoSortingKeys = ["name", "artistName", "addDate"] as const;
export { VideoWithRelations };
+26 -19
View File
@@ -23,7 +23,6 @@ import { useInfiniteQuery, useQuery } from "../../../api/use-query";
import getSlugOrId from "../../../utils/getSlugOrId";
import { GetPropsTypesFrom, Page } from "../../../ssr";
import SectionHeader from "../../../components/section-header";
import ExternalIdBadge from "../../../components/external-id-badge";
import ResourceDescriptionExpandable from "../../../components/resource-description-expandable";
import ArtistRelationPageHeader from "../../../components/relation-page-header/artist-relation-page-header";
import { AlbumType } from "../../../models/album";
@@ -39,6 +38,8 @@ import {
VideoListPageSection,
} from "../../../components/page-section";
import { Head } from "../../../components/head";
import ExternalMetadataBadge from "../../../components/external-metadata-badge";
import { VideoTypeIsExtra } from "../../../models/video";
// Number of Song item in the 'Top Song' section
const SongListSize = 6;
@@ -60,8 +61,8 @@ const latestAlbumsQuery = AlbumType.map((type) => ({
const videosQuery = (artistSlugOrId: string | number) =>
API.getVideos(
{ artist: artistSlugOrId },
{ sortBy: "totalPlayCount", order: "desc" },
["artist"],
{ sortBy: "addDate", order: "desc" },
["artist", "master", "illustration"],
);
const topSongsQuery = (artistSlugOrId: string | number) =>
@@ -79,8 +80,10 @@ const rareSongsQuery = (artistSlugOrId: string | number) =>
);
const artistQuery = (artistSlugOrId: string | number) =>
API.getArtist(artistSlugOrId, ["externalIds", "illustration"]);
API.getArtist(artistSlugOrId, ["illustration"]);
const externalMetadataQuery = (artistSlugOrId: string | number) =>
API.getArtistExternalMetadata(artistSlugOrId);
const appearanceQuery = (artistSlugOrId: string | number) =>
API.getAlbums(
{ appearance: artistSlugOrId },
@@ -93,7 +96,10 @@ const prepareSSR = (context: NextPageContext) => {
return {
additionalProps: { artistIdentifier },
queries: [artistQuery(artistIdentifier)],
queries: [
artistQuery(artistIdentifier),
externalMetadataQuery(artistIdentifier),
],
infiniteQueries: [
rareSongsQuery(artistIdentifier),
videosQuery(artistIdentifier),
@@ -119,20 +125,20 @@ const ArtistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
const topSongs = useInfiniteQuery(topSongsQuery, artistIdentifier);
const rareSongs = useInfiniteQuery(rareSongsQuery, artistIdentifier);
const appearances = useInfiniteQuery(appearanceQuery, artistIdentifier);
const externalIdWithDescription = artist.data?.externalIds
.filter(({ provider }) => provider.name.toLowerCase() !== "discogs")
.find(({ description }) => description !== null);
const externalMetadata = useQuery(externalMetadataQuery, artistIdentifier);
const { musicVideos, liveVideos, extras } = useMemo(() => {
const firstPage = videos.data?.pages.at(0)?.items;
return {
musicVideos:
firstPage?.filter(
(video) => video.type != "NonMusic" && video.type != "Live",
(video) =>
!VideoTypeIsExtra(video.type) && video.type != "Live",
) ?? [],
liveVideos:
firstPage?.filter((video) => video.type == "Live") ?? [],
extras:
firstPage?.filter((video) => video.type == "NonMusic") ?? [],
firstPage?.filter((video) => VideoTypeIsExtra(video.type)) ??
[],
};
}, [videos]);
const { GradientBackground } = useGradientBackground(
@@ -143,9 +149,7 @@ const ArtistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
<Box sx={{ width: "100%" }}>
<Head
title={artist.data?.name}
description={
externalIdWithDescription?.description ?? undefined
}
description={externalMetadata.data?.description ?? undefined}
/>
<GradientBackground />
<ArtistRelationPageHeader artist={artist.data} />
@@ -217,7 +221,7 @@ const ArtistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
subtitleIs="artistName"
query={appearances}
/>
{externalIdWithDescription && (
{externalMetadata.data?.description && (
<>
<Divider sx={{ marginBottom: SectionPadding }} />
<SectionHeader heading={t("about")} />
@@ -226,12 +230,13 @@ const ArtistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
sx={{ paddingBottom: 4, paddingTop: 3 }}
>
<ResourceDescriptionExpandable
externalDescription={externalIdWithDescription}
externalMetadata={externalMetadata.data}
/>
</Container>
</>
)}
{(!artist.data || artist.data.externalIds.length != 0) && (
{(externalMetadata.data === undefined ||
externalMetadata.data?.sources.length) && (
<>
<Divider />
<Grid
@@ -244,12 +249,14 @@ const ArtistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
<SectionHeader heading={t("externalLinks")} />
</Grid>
{(
artist.data?.externalIds.filter(
externalMetadata.data?.sources.filter(
({ url }) => url !== null,
) ?? generateArray(2)
).map((externalId, index) => (
).map((externalSource, index) => (
<Grid item key={index}>
<ExternalIdBadge externalId={externalId} />
<ExternalMetadataBadge
source={externalSource}
/>
</Grid>
)) ?? []}
</Grid>
+8 -5
View File
@@ -69,8 +69,9 @@ const prepareSSR = async (
artistQuery(artistIdentifier),
...songs.pages
.flatMap(({ items }) => items)
.filter(({ master }) => master.releaseId)
.map(({ master }) =>
API.getRelease(master.releaseId, ["album"]),
API.getRelease(master.releaseId!, ["album"]),
),
],
infiniteQueries: [],
@@ -83,10 +84,12 @@ const ArtistSongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
const router = useRouter();
const isRareSongs = isRareSongsPage(router);
const queryClient = useQueryClient();
const getTrackReleaseName = (track: Track) =>
queryClient
.fetchQuery(API.getRelease(track.releaseId))
.then((release) => release.name);
const getTrackReleaseName = (track: Track): Promise<string | null> =>
track.releaseId
? queryClient
.fetchQuery(API.getRelease(track.releaseId))
.then((release) => release.name)
: Promise.resolve(null);
const artistIdentifier =
props?.artistIdentifier ?? getSlugOrId(router.query);
const artist = useQuery(artistQuery, artistIdentifier);
@@ -22,7 +22,7 @@ import getSlugOrId from "../../../utils/getSlugOrId";
import ArtistRelationPageHeader from "../../../components/relation-page-header/artist-relation-page-header";
import { GetPropsTypesFrom, Page } from "../../../ssr";
import InfiniteVideoView from "../../../components/infinite/infinite-resource-view/infinite-video-view";
import { SongSortingKeys } from "../../../models/song";
import { VideoSortingKeys } from "../../../models/video";
import { getOrderParams, getSortingFieldParams } from "../../../utils/sorting";
import { useQuery } from "../../../api/use-query";
import { NextPageContext } from "next";
@@ -36,7 +36,10 @@ const artistQuery = (identifier: string | number) =>
const prepareSSR = (context: NextPageContext) => {
const artistIdentifier = getSlugOrId(context.query);
const order = getOrderParams(context.query.order) ?? "asc";
const sortBy = getSortingFieldParams(context.query.sortBy, SongSortingKeys);
const sortBy = getSortingFieldParams(
context.query.sortBy,
VideoSortingKeys,
);
return {
additionalProps: { artistIdentifier, order, sortBy },
@@ -45,7 +48,7 @@ const prepareSSR = (context: NextPageContext) => {
API.getVideos(
{ artist: artistIdentifier },
{ sortBy: sortBy, order: order },
["artist", "featuring"],
["artist", "master", "illustration"],
),
],
};
@@ -82,7 +85,7 @@ const ArtistSongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
library: library ?? undefined,
},
{ sortBy, order },
["artist", "featuring"],
["artist", "master", "illustration"],
)
}
subtitle="duration"
+43 -5
View File
@@ -19,7 +19,12 @@
import { Box, Grid, Stack } from "@mui/material";
import API from "../api/api";
import { GetPropsTypesFrom, Page } from "../ssr";
import { useInfiniteQuery } from "../api/use-query";
import {
prepareMeeloInfiniteQuery,
useInfiniteQuery,
useQueries,
useQuery,
} from "../api/use-query";
import SectionHeader from "../components/section-header";
import TileRow from "../components/tile-row";
import AlbumTile from "../components/tile/album-tile";
@@ -33,6 +38,9 @@ import { useTranslation } from "react-i18next";
import { generateArray } from "../utils/gen-list";
import { getRandomNumber } from "../utils/random";
import { useGradientBackground } from "../utils/gradient-background";
import { QueryClient } from "react-query";
import { NextPageContext } from "next";
import { AlbumExternalMetadata } from "../models/external-metadata";
const newlyAddedAlbumsQuery = API.getAlbums(
{},
@@ -68,7 +76,6 @@ const albumRecommendations = (seed: number) =>
API.getAlbums({ random: seed, type: "StudioRecording" }, undefined, [
"artist",
"genres",
"externalIds",
"illustration",
]);
@@ -93,21 +100,28 @@ const HomePageSection = <T,>(props: {
);
};
const prepareSSR = () => {
const prepareSSR = async (_: NextPageContext, queryClient: QueryClient) => {
const seed = Math.floor(Math.random() * 10000000);
const albumRecs = await queryClient
.fetchInfiniteQuery(
prepareMeeloInfiniteQuery(() => albumRecommendations(seed)),
)
.then((res) => res.pages.at(0)?.items);
return {
additionalProps: {
blurhashIndex: Math.random(),
recommendationSeed: seed,
},
queries: albumRecs?.map((album) =>
API.getAlbumExternalMetadata(album.id),
),
infiniteQueries: [
newlyAddedArtistsQuery,
newestAlbumsQuery,
newlyAddedAlbumsQuery,
newlyAddedReleasesQuery,
mostListenedSongsQuery,
albumRecommendations(seed),
],
};
};
@@ -119,6 +133,21 @@ const HomePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
albumRecommendations,
props?.recommendationSeed ?? seed,
);
const featuredAlbumsExternalMetadata = useQueries(
...(featuredAlbums.data?.pages
.at(0)
?.items.map(
(
album,
): Parameters<
typeof useQuery<
AlbumExternalMetadata | null,
Parameters<typeof API.getAlbumExternalMetadata>
>
> => [API.getAlbumExternalMetadata, album.id],
) ?? []),
);
const newlyAddedAlbums = useInfiniteQuery(() => newlyAddedAlbumsQuery);
const newlyAddedArtists = useInfiniteQuery(() => newlyAddedArtistsQuery);
const newlyAddedReleases = useInfiniteQuery(() => newlyAddedReleasesQuery);
@@ -195,7 +224,16 @@ const HomePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
xl={4}
key={index}
>
<AlbumHighlightCard album={album} />
<AlbumHighlightCard
album={album}
externalMetadata={
featuredAlbumsExternalMetadata.find(
({ data }) =>
data?.albumId ===
album?.id,
)?.data ?? undefined
}
/>
</Grid>
))}
</Grid>
+25 -9
View File
@@ -22,6 +22,7 @@ import RelationPageHeader from "../../../components/relation-page-header/relatio
import { GetPropsTypesFrom, Page } from "../../../ssr";
import getSlugOrId from "../../../utils/getSlugOrId";
import {
Query,
prepareMeeloQuery,
useQueries,
useQuery,
@@ -89,7 +90,16 @@ const prepareSSR = async (
additionalProps: { playlistIdentifier },
queries: [
playlistQuery(playlistIdentifier),
...entries.map((entry) => API.getRelease(entry.master.releaseId)),
...entries
.map((entry) =>
entry.master.releaseId
? API.getRelease(entry.master.releaseId)
: undefined,
)
.filter(
(promise): promise is Query<Release> =>
promise !== undefined,
),
],
};
};
@@ -219,13 +229,15 @@ const PlaylistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
const playlist = useQuery(playlistQuery, playlistIdentifier);
const entriesQuery = useQuery(playlistEntriesQuery, playlistIdentifier);
const masterTracksReleaseQueries = useQueries(
...(entriesQuery.data?.map(
({
master,
}): Parameters<
typeof useQuery<Release, Parameters<typeof API.getRelease>>
> => [API.getRelease, master.releaseId],
) ?? []),
...(entriesQuery.data
?.filter(({ master }) => master.releaseId)
.map(
({
master,
}): Parameters<
typeof useQuery<Release, Parameters<typeof API.getRelease>>
> => [API.getRelease, master.releaseId ?? undefined],
) ?? []),
);
const reorderMutation = useMutation((reorderedEntries: number[]) => {
return API.reorderPlaylist(playlistIdentifier, reorderedEntries)
@@ -240,7 +252,11 @@ const PlaylistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
const releases = masterTracksReleaseQueries.map((query) => query.data);
const resolvedReleases = releases.filter((data) => data !== undefined);
if (resolvedReleases.length !== entriesQuery.data?.length) {
if (
resolvedReleases.length !==
entriesQuery.data?.filter(({ master }) => master.releaseId != null)
.length
) {
return undefined;
}
+69 -55
View File
@@ -49,7 +49,6 @@ import { GetPropsTypesFrom, Page } from "../../ssr";
import ReleaseContextualMenu from "../../components/contextual-menu/release-contextual-menu";
import TileRow from "../../components/tile-row";
import VideoTile from "../../components/tile/video-tile";
import ExternalIdBadge from "../../components/external-id-badge";
import ArtistTile from "../../components/tile/artist-tile";
import PlaylistTile from "../../components/tile/playlist-tile";
import ReleaseTile from "../../components/tile/release-tile";
@@ -61,7 +60,7 @@ import ResourceDescriptionExpandable from "../../components/resource-description
import { Star1 } from "iconsax-react";
import GenreButton from "../../components/genre-button";
import { SongWithRelations } from "../../models/song";
import Video from "../../models/video";
import { VideoTypeIsExtra, VideoWithRelations } from "../../models/video";
import { useAccentColor } from "../../utils/accent-color";
import { useTranslation } from "react-i18next";
import { generateArray } from "../../utils/gen-list";
@@ -73,9 +72,10 @@ import Tracklist, { TracklistItemWithRelations } from "../../models/tracklist";
import { Head } from "../../components/head";
import { useThemedSxValue } from "../../utils/themed-sx-value";
import { parentScrollableDivId } from "../../components/infinite/infinite-scroll";
import ExternalMetadataBadge from "../../components/external-metadata-badge";
const releaseQuery = (releaseIdentifier: string | number) =>
API.getRelease(releaseIdentifier, ["album", "externalIds", "illustration"]);
API.getRelease(releaseIdentifier, ["album", "illustration"]);
const releaseTracklistQuery = (
releaseIdentifier: number | string,
exclusiveOnly: boolean,
@@ -107,7 +107,9 @@ const releaseTracklistQuery = (
};
};
const albumQuery = (albumId: number) =>
API.getAlbum(albumId, ["externalIds", "genres", "artist"]);
API.getAlbum(albumId, ["genres", "artist"]);
const externalMetadataQuery = (albumIdentifier: string | number) =>
API.getAlbumExternalMetadata(albumIdentifier);
const artistsOnAlbumQuery = (albumId: number) => {
const query = API.getArtists({ album: albumId }, undefined, [
"illustration",
@@ -127,7 +129,8 @@ const releaseBSidesQuery = (releaseId: number) =>
"master",
"illustration",
]);
const albumVideosQuery = (albumId: number) => API.getVideos({ album: albumId });
const albumVideosQuery = (albumId: number) =>
API.getVideos({ album: albumId }, undefined, ["master", "illustration"]);
const relatedAlbumsQuery = (albumId: number) =>
API.getAlbums({ related: albumId }, { sortBy: "releaseDate" }, [
"artist",
@@ -155,6 +158,7 @@ const prepareSSR = async (
releaseTracklistQuery(releaseIdentifier, false),
albumQuery(release.albumId),
artistsOnAlbumQuery(release.albumId),
externalMetadataQuery(release.albumId),
],
infiniteQueries: [
albumGenreQuery(release.albumId),
@@ -198,6 +202,10 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
const theme = useTheme();
const { playTracks } = usePlayerContext();
const release = useQuery(releaseQuery, releaseIdentifier);
const externalMetadata = useQuery(
externalMetadataQuery,
release.data?.albumId,
);
const artistId = useMemo(() => release.data?.album?.artistId, [release]);
const album = useQuery(albumQuery, release.data?.albumId);
const tracklistQuery = useQuery(
@@ -240,7 +248,7 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
() =>
(bSidesQuery.data?.pages.at(0)?.items ?? []).reduce(
(prev, current) => {
if (current.type === "NonMusic") {
if (["NonMusic", "Medley"].includes(current.type)) {
return {
bSides: prev.bSides,
extras: prev.extras.concat(current),
@@ -280,20 +288,41 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
const { videos, liveVideos, videoExtras } = useMemo(
() =>
(albumVideos.data?.pages.at(0)?.items ?? [])
.map(
(video) =>
[
video,
tracks.findIndex(
(track) => track.song.groupId == video.groupId,
),
] as const,
.map((video) => {
const videoIndex = tracks.findIndex(
(track) =>
(track.song ?? track.video)!.groupId ==
video.groupId,
);
return [
video,
videoIndex == -1 ? tracks.length : videoIndex,
] as const;
})
.sort(
([v1, i1], [v2, i2]) =>
i1 - i2 || v1.slug.localeCompare(v2.slug),
)
.map(([video, tracklistIndex], _, videosWithIndexes) => {
if (album.data?.type === "Single") {
return [video, tracklistIndex] as const;
}
const firstVideoOfSameGroup = videosWithIndexes.find(
([__, i]) => i == tracklistIndex,
)!;
return [
video,
firstVideoOfSameGroup[0].id == video.id
? tracklistIndex
: 10000 + tracklistIndex,
] as const;
})
.sort(([_, i1], [__, i2]) => i1 - i2)
.map(([v, _]) => v)
.reduce(
(prev, current) => {
if (current.type === "NonMusic") {
if (VideoTypeIsExtra(current.type)) {
return {
videos: prev.videos,
liveVideos: prev.liveVideos,
@@ -313,10 +342,13 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
};
},
{
videos: [] as Video[],
videoExtras: [] as Video[],
liveVideos: [] as Video[],
},
videos: [],
videoExtras: [],
liveVideos: [],
} as Record<
"videos" | "videoExtras" | "liveVideos",
VideoWithRelations<"master" | "illustration">[]
>,
),
[albumVideos.data, tracks],
);
@@ -324,31 +356,6 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
() => release.data?.illustration,
[release.data],
);
const externalIdWithDescription = useMemo(
() =>
album.data?.externalIds
.filter(
({ provider }) => provider.name.toLowerCase() !== "discogs",
)
.find(({ description }) => description !== null),
[album.data],
);
const externalIds = useMemo(() => {
if (album.data === undefined || release.data === undefined) {
return undefined;
}
return [...album.data.externalIds, ...release.data.externalIds];
}, [album.data, release.data]);
const albumRating = useMemo(() => {
return album.data
? album.data.externalIds
.map(({ rating }) => rating)
.filter((rating) => rating !== null)
.sort()
.at(-1) ?? null
: undefined;
}, [album.data]);
const accentColor = useAccentColor(illustration);
const { GradientBackground } = useGradientBackground(illustration?.colors);
const ratingColor = useThemedSxValue(
@@ -487,14 +494,14 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
<Skeleton width={"100px"} />
)}
</Typography>
{albumRating && (
{externalMetadata.data?.rating && (
<Rating
sx={{
paddingLeft: 1.5,
...ratingColor,
}}
readOnly
value={albumRating / 20}
value={externalMetadata.data.rating / 20}
icon={
<Star1
size={18}
@@ -543,7 +550,9 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
artist: artists.data.find(
(artist) =>
artist.id ==
track.song.artistId,
(track.song ??
track.video)!
.artistId,
)!,
release: release.data,
}),
@@ -648,7 +657,8 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
discKey,
discTracks.map((discTrack) => ({
...discTrack,
song: discTrack.song,
song: discTrack.song!,
video: discTrack.video!,
})),
]),
)
@@ -831,28 +841,32 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
}
/>
</RelatedContentSection>
{externalIdWithDescription && (
{externalMetadata.data?.description && (
<RelatedContentSection display title={t("about")}>
<Box sx={{ paddingBottom: 2 }}>
<ResourceDescriptionExpandable
externalDescription={externalIdWithDescription}
externalMetadata={externalMetadata.data}
/>
</Box>
</RelatedContentSection>
)}
<RelatedContentSection
display={
externalIds === undefined || externalIds.length > 0
externalMetadata.data === undefined ||
(externalMetadata.data?.sources.length ?? 0) > 0
}
title={t("externalLinks")}
>
<Grid container spacing={1}>
{(
externalIds?.filter(({ url }) => url !== null) ??
generateArray(2)
).map((externalId, index) => (
externalMetadata.data?.sources.filter(
({ url }) => url !== null,
) ?? generateArray(2)
).map((externalSource, index) => (
<Grid item key={index}>
<ExternalIdBadge externalId={externalId} />
<ExternalMetadataBadge
source={externalSource}
/>
</Grid>
))}
</Grid>
+133 -33
View File
@@ -30,11 +30,20 @@ import InfiniteArtistView from "../../components/infinite/infinite-resource-view
import InfiniteAlbumView from "../../components/infinite/infinite-resource-view/infinite-album-view";
import InfiniteSongView from "../../components/infinite/infinite-resource-view/infinite-song-view";
import InfiniteView from "../../components/infinite/infinite-view";
import { toInfiniteQuery, transformPage } from "../../api/use-query";
import {
Query,
toInfiniteQuery,
transformPage,
useQueryClient,
} from "../../api/use-query";
import AlbumItem from "../../components/list-item/album-item";
import SongItem from "../../components/list-item/song-item";
import ArtistItem from "../../components/list-item/artist-item";
import { useMutation } from "react-query";
import { SaveSearchItem, SearchResult } from "../../models/search";
import formatArtists from "../../utils/formatArtists";
import InfiniteVideoView from "../../components/infinite/infinite-resource-view/infinite-video-view";
import VideoItem from "../../components/list-item/video-item";
const prepareSSR = (context: NextPageContext) => {
const searchQuery = context.query.query?.at(0) ?? null;
@@ -42,26 +51,47 @@ const prepareSSR = (context: NextPageContext) => {
return {
additionalProps: { searchQuery, type },
infiniteQueries: searchQuery
? [
API.getArtists({ query: searchQuery }, undefined, [
"illustration",
]),
API.getAlbums({ query: searchQuery }, undefined, [
"artist",
"illustration",
]),
API.getSongs({ query: searchQuery }, undefined, [
"artist",
"featuring",
"master",
"illustration",
]),
]
: [],
infiniteQueries: [
searchResultQueryToInfinite(API.getSearchHistory()),
...(searchQuery
? [
API.getArtists({ query: searchQuery }, undefined, [
"illustration",
]),
API.getAlbums({ query: searchQuery }, undefined, [
"artist",
"illustration",
]),
API.getSongs({ query: searchQuery }, undefined, [
"artist",
"featuring",
"master",
"illustration",
]),
API.getVideos({ query: searchQuery }, undefined, [
"artist",
"master",
"illustration",
]),
searchResultQueryToInfinite(API.searchAll(searchQuery)),
]
: []),
],
};
};
const searchResultQueryToInfinite = (q: Query<SearchResult[]>) => {
return transformPage(toInfiniteQuery(q), (item, index) => ({
...item,
id: index,
illustration:
item.song?.illustration ??
item.artist?.illustration ??
item.album?.illustration ??
null,
}));
};
const buildSearchUrl = (
query: string | undefined,
type: string | undefined,
@@ -69,7 +99,7 @@ const buildSearchUrl = (
return "/search/" + (query ?? "") + (type ? `?t=${type}` : "");
};
const tabs = ["all", "artist", "album", "song"] as const;
const tabs = ["all", "artist", "album", "song", "video"] as const;
const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
const router = useRouter();
@@ -84,8 +114,25 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
(newTab) => buildSearchUrl(query, newTab),
...tabs,
);
const queryClient = useQueryClient();
const [inputValue, setInputValue] = useState(query);
const [debounceId, setDebounceId] = useState<NodeJS.Timeout>();
const saveSearch = useMutation((dto: SaveSearchItem) => {
return (
API.saveSearchHistoryEntry(dto)
.then(() => {
// Sometimes, it refreshes to fast, and shifts the history
// before openning a page (for artists) is done
setTimeout(() => {
queryClient.client.invalidateQueries(
API.getSearchHistory().key,
);
}, 500);
})
// eslint-disable-next-line no-console
.catch((error: Error) => console.error(error))
);
});
useEffect(() => {
if (debounceId) {
clearTimeout(debounceId);
@@ -101,7 +148,7 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
},
);
setDebounceId(undefined);
}, 500),
}, 400),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputValue]);
@@ -161,27 +208,26 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
))}
</Tabs>
<Box sx={{ paddingBottom: 2 }} />
{query && selectedTab == "all" && (
{selectedTab == "all" && (
<InfiniteView
view="list"
query={() =>
transformPage(
toInfiniteQuery(API.searchAll(query)),
(item, index) => ({
...item,
id: index,
illustration:
item.song?.illustration ??
item.artist?.illustration ??
item.album?.illustration ??
null,
}),
searchResultQueryToInfinite(
query
? API.searchAll(query)
: API.getSearchHistory(),
)
}
renderGridItem={(item) => <></>}
renderListItem={(item) =>
!item || item.album ? (
<AlbumItem
onClick={() =>
item &&
saveSearch.mutate({
albumId: item.album.id,
})
}
album={item?.album}
formatSubtitle={(album) =>
`${t("album")} • ${
@@ -191,6 +237,9 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
/>
) : item.song ? (
<SongItem
onClick={() =>
saveSearch.mutate({ songId: item.song.id })
}
song={item.song}
subtitles={[
async (song) =>
@@ -200,14 +249,39 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
)}`,
]}
/>
) : item.video ? (
<VideoItem
onClick={() =>
saveSearch.mutate({
videoId: item.video.id,
})
}
video={item.video}
subtitles={[
async (video) =>
`${t("video")} • ${formatArtists(
video.artist,
)}`,
]}
/>
) : (
<ArtistItem artist={item.artist} />
<ArtistItem
artist={item.artist}
onClick={() =>
saveSearch.mutate({
artistId: item.artist.id,
})
}
/>
)
}
/>
)}
{query && selectedTab == "artist" && (
<InfiniteArtistView
onItemClick={(item) =>
item && saveSearch.mutate({ artistId: item.id })
}
query={({ library }) =>
API.getArtists(
{
@@ -222,6 +296,9 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
)}
{query && selectedTab == "album" && (
<InfiniteAlbumView
onItemClick={(item) =>
item && saveSearch.mutate({ albumId: item.id })
}
defaultAlbumType={null}
query={({ library, type: newType }) =>
API.getAlbums(
@@ -238,6 +315,9 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
)}
{query && selectedTab == "song" && (
<InfiniteSongView
onItemClick={(item) =>
item && saveSearch.mutate({ songId: item.id })
}
query={({ library, type: newType }) =>
API.getSongs(
{
@@ -251,6 +331,26 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
}
/>
)}
{query && selectedTab == "video" && (
<InfiniteVideoView
onItemClick={(item) =>
item && saveSearch.mutate({ videoId: item.id })
}
subtitle="artist"
query={({ library, type: newType }) =>
API.getVideos(
{
query: encodeURIComponent(query),
type: newType,
library: library ?? undefined,
},
undefined,
["artist", "master", "illustration"],
)
}
/>
)}
</Box>
);
};
+66 -21
View File
@@ -21,6 +21,7 @@ import API from "../../../api/api";
import { GetPropsTypesFrom, Page } from "../../../ssr";
import getSlugOrId from "../../../utils/getSlugOrId";
import {
prepareMeeloQuery,
useInfiniteQuery,
useQuery,
useQueryClient,
@@ -38,7 +39,6 @@ import LyricsBox from "../../../components/lyrics";
import SongRelationPageHeader from "../../../components/relation-page-header/song-relation-page-header";
import InfiniteSongView from "../../../components/infinite/infinite-resource-view/infinite-song-view";
import InfiniteTrackView from "../../../components/infinite/infinite-resource-view/infinite-track-view";
import ExternalIdBadge from "../../../components/external-id-badge";
import { PlayIcon } from "../../../components/icons";
import GenreButton from "../../../components/genre-button";
import { useTranslation } from "react-i18next";
@@ -50,22 +50,33 @@ import { Head } from "../../../components/head";
import { useThemedSxValue } from "../../../utils/themed-sx-value";
import { useAccentColor } from "../../../utils/accent-color";
import { useTabRouter } from "../../../components/tab-router";
import ExternalMetadataBadge from "../../../components/external-metadata-badge";
import InfiniteVideoView from "../../../components/infinite/infinite-resource-view/infinite-video-view";
import { QueryClient } from "react-query";
const prepareSSR = (context: NextPageContext) => {
const externalMetadataQuery = (songIdentifier: string | number) =>
API.getSongExternalMetadata(songIdentifier);
const prepareSSR = async (
context: NextPageContext,
queryClient: QueryClient,
) => {
const songIdentifier = getSlugOrId(context.query);
return {
additionalProps: { songIdentifier },
queries: [
const song = await queryClient.fetchQuery(
prepareMeeloQuery(() =>
API.getSong(songIdentifier, [
"artist",
"externalIds",
"featuring",
"lyrics",
"master",
"illustration",
]),
],
),
);
return {
additionalProps: { songIdentifier },
queries: [externalMetadataQuery(songIdentifier)],
infiniteQueries: [
API.getGenres({ song: songIdentifier }),
API.getSongs(
@@ -73,16 +84,22 @@ const prepareSSR = (context: NextPageContext) => {
{ sortBy: "name", order: "asc" },
["artist", "featuring", "master", "illustration"],
),
API.getVideos(
{ group: song.groupId },
{ sortBy: "name", order: "asc" },
["artist", "master", "illustration"],
),
API.getTracks(
{ song: songIdentifier },
{ sortBy: "name", order: "asc" },
["release", "song", "illustration"],
["release", "song", "illustration", "video"],
),
],
};
};
const tabs = ["lyrics", "versions", "tracks", "more"] as const;
const tabs = ["lyrics", "versions", "videos", "tracks", "more"] as const;
const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
const { selectedTab, selectTab } = useTabRouter(
@@ -98,13 +115,13 @@ const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
const song = useQuery(() =>
API.getSong(songIdentifier, [
"artist",
"externalIds",
"featuring",
"lyrics",
"master",
"illustration",
]),
);
const externalMetadata = useQuery(externalMetadataQuery, songIdentifier);
const genres = useInfiniteQuery(API.getGenres, { song: songIdentifier });
const { GradientBackground } = useGradientBackground(
song.data?.illustration?.colors,
@@ -189,7 +206,8 @@ const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
))}
</Stack>
)}
{(!song.data || song.data.externalIds.length != 0) && (
{(song.data === undefined ||
externalMetadata.data?.sources.length) && (
<Stack
direction="row"
sx={{
@@ -203,22 +221,19 @@ const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
{`${t("externalLinks")}: `}
</Typography>
{(
song.data?.externalIds.filter(
externalMetadata.data?.sources.filter(
({ url }) => url !== null,
) ?? generateArray(2)
).map((externalId, index) => (
<ExternalIdBadge
).map((source, index) => (
<ExternalMetadataBadge
key={index}
externalId={externalId}
source={source}
/>
))}
</Stack>
)}
<Typography variant="body1" sx={{ paddingTop: 2 }}>
{song.data?.externalIds
.map(({ description }) => description)
.filter((desc) => desc !== null)
.at(0)}
{externalMetadata.data?.description}
</Typography>
</>
)}
@@ -270,6 +285,31 @@ const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
/>
</>
)}
{selectedTab == "videos" && (
<>
<Head
title={
song.data &&
`${song.data.name} (${t("videos")})`
}
/>
<InfiniteVideoView
query={({ library, sortBy, order, type }) =>
API.getVideos(
{
library: library ?? undefined,
type,
group: song.data?.groupId,
},
{ sortBy, order },
["artist", "master", "illustration"],
)
}
subtitle="duration"
/>
</>
)}
{selectedTab == "tracks" && (
<>
<Head
@@ -283,7 +323,12 @@ const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
API.getTracks(
{ song: songIdentifier },
{ sortBy, order },
["release", "song", "illustration"],
[
"release",
"song",
"illustration",
"video",
],
)
}
/>
+11 -4
View File
@@ -18,7 +18,7 @@
import React from "react";
import { useRouter } from "next/router";
import { SongSortingKeys } from "../../models/song";
import { VideoSortingKeys } from "../../models/video";
import API from "../../api/api";
import { getOrderParams, getSortingFieldParams } from "../../utils/sorting";
import { GetPropsTypesFrom, Page } from "../../ssr";
@@ -29,12 +29,19 @@ import { Head } from "../../components/head";
const prepareSSR = (context: NextPageContext) => {
const order = getOrderParams(context.query.order) ?? "asc";
const sortBy = getSortingFieldParams(context.query.sortBy, SongSortingKeys);
const sortBy = getSortingFieldParams(
context.query.sortBy,
VideoSortingKeys,
);
return {
additionalProps: { sortBy, order },
infiniteQueries: [
API.getVideos({}, { sortBy, order }, ["artist", "featuring"]),
API.getVideos({}, { sortBy, order }, [
"artist",
"master",
"illustration",
]),
],
};
};
@@ -55,7 +62,7 @@ const LibraryVideosPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
API.getVideos(
{ library: library ?? undefined, random, type },
{ sortBy, order },
["artist", "featuring"],
["artist", "master", "illustration"],
)
}
subtitle="artist"
+4
View File
@@ -0,0 +1,4 @@
**/__pycache__/
bin/
lib/
pyvenv.cfg
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.13-alpine
WORKDIR /app
COPY ./requirements.txt .
RUN pip3 install -r ./requirements.txt
COPY . .
ENTRYPOINT ["python3", "-m"]
CMD ["matcher"]
+21
View File
@@ -0,0 +1,21 @@
# Meelo's Matcher
This microservice is responsible for getting metadata from external providers (MusicBrainz, Genius, etc.)
It receives events through a message queue, and pushes the metadata to the API (using the HTTP API).
## Configuration
### Environment Variables
- `RABBITMQ_URL`: URL to the RabbitMQ service
- `API_URL`: URL to the API/server service
- `API_KEYS`: List of comma separated keys used by microservices (e.g. scanner, matcher) to authenticate
For tests, we need additional variables:
- `GENIUS_ACCESS_TOKEN`: Token to authenticate to the Genius Provider
- `DISCOGS_ACCESS_TOKEN`: Token to authenticate to the Discogs Provider
### Files
- `settings.json`: JSON File located in `INTERNAL_CONFIG_DIR`. See user doc for specs.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+71
View File
@@ -0,0 +1,71 @@
import pika
import os
import sys
import logging
import signal
from pika.adapters.blocking_connection import BlockingChannel
from matcher.bootstrap import bootstrap_context
from matcher.matcher.album import match_and_post_album
from matcher.matcher.song import match_and_post_song
from matcher.matcher.artist import match_and_post_artist
from .models.event import Event
channel: BlockingChannel | None = None
queue_name = "meelo"
def consume(ch: BlockingChannel, method, prop, body):
event = Event.from_json(body)
delivery_tag = method.delivery_tag
logging.info(f"Received event: {event} (P={prop.priority})")
match event.type:
case "artist":
match_and_post_artist(event.id, event.name)
ch.basic_ack(delivery_tag)
pass
case "album":
match_and_post_album(event.id, event.name)
ch.basic_ack(delivery_tag)
pass
case "song":
match_and_post_song(event.id, event.name)
ch.basic_ack(delivery_tag)
pass
case _:
logging.warning("No handler for event " + event.type)
pass
def main():
global channel
logging.basicConfig(level=logging.INFO)
rabbitUrl = os.environ.get("RABBITMQ_URL")
if not rabbitUrl:
logging.error("Missing env var 'RABBITMQ_URL'")
exit(1)
connectionParams = pika.URLParameters(rabbitUrl)
connection = pika.BlockingConnection(connectionParams)
channel = connection.channel()
channel.queue_declare(
queue=queue_name, durable=True, arguments={"x-max-priority": 5}
)
bootstrap_context()
logging.info("Ready to match!")
channel.basic_qos(prefetch_count=1)
channel.basic_consume(queue_name, on_message_callback=consume)
channel.start_consuming()
def terminate(signal, term):
logging.info("Shutting Down Matcher...")
if channel:
channel.close()
sys.exit(0)
# From https://www.rabbitmq.com/tutorials/tutorial-one-python
if __name__ == "__main__":
signal.signal(signal.SIGTERM, terminate)
main()
+131
View File
@@ -0,0 +1,131 @@
from datetime import date
import os
import logging
from typing import Any, List, TypeVar
from dataclasses_json import DataClassJsonMixin
import requests
from matcher.models.api.domain import Album, Song, File
from matcher.models.api.dto import (
CreateProviderDto,
ExternalMetadataDto,
UpdateAlbumDto,
)
from matcher.models.api.page import Page
from matcher.models.api.provider import Provider
from matcher.providers.domain import AlbumType
T = TypeVar("T", bound=DataClassJsonMixin)
class API:
def __init__(self):
self._url = os.environ.get("API_URL")
if not self._url:
raise Exception("Missing env variable: 'API_URL'")
self._key = (os.environ.get("API_KEYS") or "").split(",")[0]
if not self._key:
raise Exception("Missing or empty env variable: 'API_KEYS'")
def ping(self) -> bool:
return True if self._get("/") else False
def _get(self, route: str) -> requests.Response:
response = requests.get(f"{self._url}{route}", headers={"x-api-key": self._key})
if response.status_code != 200:
logging.error("GETting API failed: ")
raise Exception(response.content)
return response
def _post(
self, route: str, json: dict = {}, file_path: str = ""
) -> requests.Response:
response = requests.post(
f"{self._url}{route}",
headers={
"x-api-key": self._key,
},
files={"file": open(file_path, "rb")} if len(file_path) else None,
json=json if len(json.keys()) else None,
)
if response.status_code != 201:
logging.error("POSTting API failed: ")
raise Exception(response.content)
return response
def _put(
self, route: str, json: dict = {}, file_path: str = ""
) -> requests.Response:
response = requests.put(
f"{self._url}{route}",
headers={
"x-api-key": self._key,
},
files={"file": open(file_path, "rb")} if len(file_path) else None,
json=json if len(json.keys()) else None,
)
if response.status_code != 200:
logging.error("PUTting API failed: ")
raise Exception(response.content)
return response
def post_external_metadata(self, dto: ExternalMetadataDto):
self._post("/external-metadata", json=dto.to_dict())
def post_artist_illustration(self, artist_id: int, image_url):
self._post(
"/illustrations/url",
json={"url": image_url, "artistId": artist_id},
)
def get_providers(self) -> Page[Provider]:
response = self._get("/external-providers").json()
return API._to_page(response, Provider)
def get_album(self, album_id: int) -> Album:
response = self._get(f"/albums/{album_id}?with=artist")
json = response.json()
return Album.schema().load(json)
def get_song(self, song_id: int) -> Song:
response = self._get(f"/songs/{song_id}?with=artist,featuring,master")
json = response.json()
return Song.schema().load(json)
def get_file(self, file_id: int) -> File:
response = self._get(f"/files/{file_id}")
json = response.json()
return File.schema().load(json)
def post_provider(self, provider_name: str) -> Provider:
dto = CreateProviderDto(name=provider_name)
response = self._post("/external-providers", json=dto.to_dict())
return Provider.schema().load(response.json())
def post_provider_icon(self, provider_id: int, icon_path):
self._post(f"/external-providers/{provider_id}/icon", file_path=icon_path)
def post_album_update(
self,
album_id: int,
release_date: date | None,
genres: List[str] | None,
type: AlbumType | None,
):
dto = UpdateAlbumDto(
release_date=release_date.isoformat() if release_date else None,
genres=genres,
type=type.value if type else None,
)
self._put(f"/albums/{album_id}", json=dto.to_dict())
def post_song_lyrics(self, song_id: int, lyrics: str):
self._post(f"/songs/{song_id}/lyrics", json={"lyrics": lyrics})
def post_song_genres(self, song_id: int, genres: List[str]):
self._put(f"/songs/{song_id}", json={"genres": genres})
@staticmethod
def _to_page(obj: Any, t: type[T]) -> Page[T]:
items = t.schema().load(obj["items"], many=True)
return Page(items=items)
+69
View File
@@ -0,0 +1,69 @@
import logging
from typing import List
from matcher.api import API
from matcher.context import Context
from matcher.providers.boilerplate import BaseProviderBoilerplate
from matcher.settings import Settings
from matcher.models.api.provider import Provider as ProviderApiModel
from .providers.factory import ProviderFactory
from .settings import BaseProviderSettings
# Reads settings, push to API providers that do not exist
# Builds Provider classes and sets up global context
def bootstrap_context():
try:
api_client = API()
settings = Settings()
if not api_client.ping():
raise Exception("Could not connect to API.")
logging.info(f"{len(settings.provider_settings)} providers enabled.")
provider_api_entries = push_missing_providers(
api_client.get_providers().items, settings.provider_settings, api_client
)
resolved_providers = build_provider_models(
provider_api_entries, settings.provider_settings
)
Context.init(api_client, settings, resolved_providers)
except Exception as e:
logging.fatal(e)
exit(1)
def push_missing_providers(
api_providers: List[ProviderApiModel],
enabled_providers: List[BaseProviderSettings],
api_client: API,
):
created_providers_name = []
for enabled_provider in enabled_providers:
if [
api_prov
for api_prov in api_providers
if api_prov.name == enabled_provider.name
] == []:
res = api_client.post_provider(enabled_provider.name)
icon_path = f"./assets/{res.slug}/icon.png"
api_client.post_provider_icon(res.id, icon_path)
api_providers.append(res)
if created_providers_name != []:
logging.info(
f"Added {len(created_providers_name)} providers: {created_providers_name}"
)
else:
logging.info("Providers up to date.")
return api_providers
# Builds provider instances from .providers using their settings
def build_provider_models(
api_models: List[ProviderApiModel], provider_settings: List[BaseProviderSettings]
) -> List[BaseProviderBoilerplate]:
providers = []
for provider_setting in provider_settings:
api_model = [
entry for entry in api_models if entry.name == provider_setting.name
][0]
providers.append(ProviderFactory.buildProvider(api_model, provider_setting))
return providers
+36
View File
@@ -0,0 +1,36 @@
from dataclasses import dataclass
from typing import List, TypeVar, Type
from matcher.providers.boilerplate import BaseProviderBoilerplate
from .api import API
from .settings import Settings
T = TypeVar("T", bound=BaseProviderBoilerplate)
@dataclass
class _InternalContext:
client: API
settings: Settings
providers: List[BaseProviderBoilerplate]
def get_provider(self, cl: Type[T]) -> T | None:
for provider in self.providers:
if isinstance(provider, cl):
return provider
return None
class Context:
_instance: _InternalContext | None
@classmethod
def init(
cls, client: API, settings: Settings, providers: List[BaseProviderBoilerplate]
):
cls._instance = _InternalContext(client, settings, providers)
@classmethod
def get(cls) -> _InternalContext:
if not cls._instance:
raise Exception("Cannot access context, it is not initialised.")
return cls._instance
+160
View File
@@ -0,0 +1,160 @@
import logging
from datetime import date, datetime
from typing import List
from matcher.models.api.dto import ExternalMetadataDto
from matcher.providers.domain import AlbumType
from matcher.providers.features import (
GetAlbumDescriptionFeature,
GetAlbumGenresFeature,
GetAlbumRatingFeature,
GetAlbumReleaseDateFeature,
GetAlbumTypeFeature,
)
from . import common
from ..models.api.dto import ExternalMetadataSourceDto
from ..context import Context
def match_and_post_album(album_id: int, album_name: str):
try:
context = Context.get()
album = context.client.get_album(album_id)
(dto, release_date, album_type, genres) = match_album(
album_id,
album_name,
album.artist.name if album.artist else None,
album.type,
)
# We only care about the new album type if the previous type is Studio
album_type = (
album_type if album.type == AlbumType.STUDIO and album_type else album.type
)
old_release_date = (
datetime.fromisoformat(album.release_date).date()
if album.release_date
else None
)
if dto:
logging.info(
f"Matched with {len(dto.sources)} providers for album {album_name}"
)
context.client.post_external_metadata(dto)
if old_release_date and release_date:
if abs(release_date.year - old_release_date.year) > 2:
logging.info(
f"Release date found ({release_date.year}) is too far from the one from the API ({old_release_date.year})."
)
logging.info("Probably a mismatch. Ignoring...")
release_date = None
if release_date:
logging.info(f"Updating release date for album {album_name}")
if genres:
logging.info(f"Found {len(genres)} genres for album {album_name}")
if album_type != album.type and album_type != AlbumType.OTHER:
logging.info(f"Found type for album {album_name}: {album_type.value}")
if (
release_date
or genres
or (album_type != album.type)
and album_type != AlbumType.OTHER
):
context.client.post_album_update(album_id, release_date, genres, album_type)
except Exception as e:
logging.error(e)
def match_album(
album_id: int, album_name: str, artist_name: str | None, type: AlbumType
) -> tuple[ExternalMetadataDto | None, date | None, AlbumType | None, List[str]]:
need_genres = Context.get().settings.push_genres
context = Context.get()
release_date: date | None = None
genres: List[str] = []
rating: int | None = None
# The type is none if it needs to be found, or sth else if the API already knows it
album_type: AlbumType | None = (
None if type == AlbumType.OTHER or type == AlbumType.STUDIO else type
)
(wikidata_id, external_sources) = common.get_sources_from_musicbrainz(
lambda mb: mb.search_album(album_name, artist_name),
lambda mb, mbid: mb.get_album(mbid),
lambda mb, mbid: mb.get_album_url_from_id(mbid),
)
description: str | None = None
# Link using Wikidata
sources_ids = [source.provider_id for source in external_sources]
if wikidata_id:
external_sources = external_sources + common.get_sources_from_wikidata(
wikidata_id,
[p for p in context.providers if p.api_model.id not in sources_ids],
lambda p: p.get_wikidata_album_relation_key(),
lambda p, album_id: p.get_album_url_from_id(album_id),
)
# Resolve by searching
sources_ids = [source.provider_id for source in external_sources]
for provider in [p for p in context.providers if p.api_model.id not in sources_ids]:
search_res = provider.search_album(album_name, artist_name)
if not search_res:
continue
album_url = provider.get_album_url_from_id(str(search_res.id))
if not album_url:
continue
external_sources.append(
ExternalMetadataSourceDto(album_url, provider.api_model.id)
)
for source in external_sources:
provider = common.get_provider_from_external_source(source)
is_useful = (
(not description and provider.has_feature(GetAlbumDescriptionFeature))
or (not release_date and provider.has_feature(GetAlbumReleaseDateFeature))
or (not rating and provider.has_feature(GetAlbumRatingFeature))
or (not album_type and provider.has_feature(GetAlbumTypeFeature))
or (
need_genres
and len(genres) == 0
and provider.has_feature(GetAlbumGenresFeature)
)
)
if not is_useful:
continue
provider_album_id = provider.get_album_id_from_url(source.url)
if not provider_album_id:
continue
album = provider.get_album(provider_album_id)
if not album:
continue
if not album_type:
album_type = provider.get_album_type(album)
if not rating:
rating = provider.get_album_rating(album)
if not description:
description = provider.get_album_description(album)
if not release_date:
release_date = provider.get_album_release_date(album)
genres = genres + (
[g for g in provider.get_album_genres(album) or [] if g not in genres]
if need_genres
else []
)
if description and release_date and rating and genres:
break
return (
ExternalMetadataDto(
description,
artist_id=None,
rating=rating,
album_id=album_id,
song_id=None,
sources=external_sources,
)
if len(external_sources) > 0 or description or rating
else None,
release_date,
album_type if album_type != type else None,
genres,
)
+97
View File
@@ -0,0 +1,97 @@
import logging
from matcher.models.api.dto import ExternalMetadataDto, ExternalMetadataSourceDto
from matcher.providers.features import (
GetArtistDescriptionFeature,
GetArtistIllustrationUrlFeature,
)
from ..context import Context
from . import common
def match_and_post_artist(artist_id: int, artist_name: str):
try:
(dto, illustration_url) = match_artist(artist_id, artist_name)
context = Context.get()
if dto:
logging.info(
f"Matched with {len(dto.sources)} providers for artist {artist_name}"
)
context.client.post_external_metadata(dto)
if illustration_url:
logging.info(f"Found image for artist {artist_name}")
context.client.post_artist_illustration(artist_id, illustration_url)
except Exception as e:
logging.error(e)
def match_artist(
artist_id: int, artist_name: str
) -> tuple[ExternalMetadataDto | None, str | None]:
context = Context.get()
artist_illustration_url: str | None = None
(wikidata_id, external_sources) = common.get_sources_from_musicbrainz(
lambda mb: mb.search_artist(artist_name),
lambda mb, mbid: mb.get_artist(mbid),
lambda mb, mbid: mb.get_artist_url_from_id(mbid),
)
description: str | None = None
# Link using Wikidata
sources_ids = [source.provider_id for source in external_sources]
if wikidata_id:
external_sources = external_sources + common.get_sources_from_wikidata(
wikidata_id,
[p for p in context.providers if p.api_model.id not in sources_ids],
lambda p: p.get_wikidata_artist_relation_key(),
lambda p, artist_id: p.get_artist_url_from_id(artist_id),
)
# Resolve by searching
sources_ids = [source.provider_id for source in external_sources]
for provider in [p for p in context.providers if p.api_model.id not in sources_ids]:
search_res = provider.search_artist(artist_name)
if not search_res:
continue
artist_url = provider.get_artist_url_from_id(str(search_res.id))
if not artist_url:
continue
external_sources.append(
ExternalMetadataSourceDto(artist_url, provider.api_model.id)
)
for source in external_sources:
provider = common.get_provider_from_external_source(source)
is_useful = (
not description and provider.has_feature(GetArtistDescriptionFeature)
) or (
not artist_illustration_url
and provider.has_feature(GetArtistIllustrationUrlFeature)
)
if not is_useful:
continue
provider_artist_id = provider.get_artist_id_from_url(source.url)
if not provider_artist_id:
continue
artist = provider.get_artist(provider_artist_id)
if not artist:
continue
if not description:
description = provider.get_artist_description(artist)
if not artist_illustration_url:
artist_illustration_url = provider.get_artist_illustration_url(artist)
if description and artist_illustration_url:
break
return (
ExternalMetadataDto(
description,
artist_id=artist_id,
rating=None,
album_id=None,
song_id=None,
sources=external_sources,
)
if len(external_sources) > 0 or description
else None,
artist_illustration_url,
)
+113
View File
@@ -0,0 +1,113 @@
from matcher.models.api.dto import ExternalMetadataSourceDto
from matcher.providers.boilerplate import BaseProviderBoilerplate
from ..context import Context
from typing import Any, Callable, List
from ..providers.wikidata import WikidataProvider
from ..providers.musicbrainz import MusicBrainzProvider
from ..providers.wikipedia import WikipediaProvider
def get_provider_from_external_source(dto: ExternalMetadataSourceDto):
return [p for p in Context.get().providers if p.api_model.id == dto.provider_id][0]
def get_sources_from_wikidata(
wikidata_id: str,
missing_providers: List[BaseProviderBoilerplate],
get_wikidata_relation_key: Callable[[BaseProviderBoilerplate], str | None],
get_resource_url_from_id: Callable[[BaseProviderBoilerplate, str], str | None],
) -> List[ExternalMetadataSourceDto]:
wikidata_provider = WikidataProvider()
wikidata_rels = wikidata_provider.get_resource_relations(wikidata_id)
if not wikidata_rels:
return []
sources: List[ExternalMetadataSourceDto] = []
for provider in missing_providers:
wiki_rel_key = get_wikidata_relation_key(provider)
if not wiki_rel_key:
continue
article_id = wikidata_rels.get_identifier_from_provider_using_relation_key(
wiki_rel_key
)
if not article_id:
continue
article_url = get_resource_url_from_id(provider, article_id)
if article_url:
sources.append(
ExternalMetadataSourceDto(article_url, provider.api_model.id)
)
# We link Wikipedia manually, using Wikidata
wikipedia_provider = Context().get().get_provider(WikipediaProvider)
if not wikipedia_provider:
return sources
wikipedia_is_missing = not any(
[
source
for source in sources
if source.provider_id == wikipedia_provider.api_model.id
]
)
if wikipedia_is_missing:
wiki_article_name = wikipedia_provider.get_article_name_from_wikidata(
wikidata_id
)
if not wiki_article_name:
return sources
article_url = wikipedia_provider.get_article_url_from_id(wiki_article_name)
if article_url:
sources.append(
ExternalMetadataSourceDto(article_url, wikipedia_provider.api_model.id)
)
return sources
def get_sources_from_musicbrainz(
mb_search_resource: Callable[[BaseProviderBoilerplate], Any],
mb_get_resource: Callable[[BaseProviderBoilerplate, str], Any],
mb_get_url_from_id: Callable[[BaseProviderBoilerplate, str], str | None],
) -> tuple[str | None, List[ExternalMetadataSourceDto]]:
context = Context.get()
mb_provider = context.get_provider(MusicBrainzProvider)
if mb_provider is None:
return (None, [])
wikidata_id: str | None = None
mbEntry = mb_search_resource(mb_provider)
external_sources = []
if mbEntry is None:
return (None, [])
resource_url = mb_get_url_from_id(mb_provider, mbEntry.id)
if resource_url:
external_sources.append(
ExternalMetadataSourceDto(resource_url, mb_provider.api_model.id)
)
try:
resource = mb_get_resource(mb_provider, mbEntry.id)
if "relations" not in resource.keys():
return (wikidata_id, external_sources)
for rel in resource["relations"]:
if rel["type"] == "wikidata":
wikidata_id = rel["url"]["resource"].replace(
"https://www.wikidata.org/wiki/", ""
)
continue
providers = [
p
for p in context.providers
if p.get_musicbrainz_relation_key() == rel["type"]
or p.is_musicbrainz_relation(rel)
]
if not len(providers):
continue
provider = providers[0]
provider_id = provider.api_model.id
if not [
previous_match
for previous_match in external_sources
if previous_match.provider_id == provider_id
]:
external_sources.append(
ExternalMetadataSourceDto(rel["url"]["resource"], provider_id)
)
except Exception:
pass
return (wikidata_id, external_sources)
Loaded 100 of 407 files, more files were not shown because too many files have changed in this diff. Show more