Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecc51ac6f0 | ||
|
|
a9bc1b8151 | ||
|
|
e3b030d6f2 | ||
|
|
52e6b6b57d | ||
|
|
c65b202111 | ||
|
|
cc2d69f1f0 | ||
|
|
a57aa8c3c0 | ||
|
|
771e8867c6 | ||
|
|
528cd64269 | ||
|
|
ac766d4070 | ||
|
|
009b34997a | ||
|
|
3a636e2c3c | ||
|
|
0af23c0a45 | ||
|
|
f7acf4b4d4 | ||
|
|
c036c8f123 | ||
|
|
983cc3a629 | ||
|
|
8810b04073 | ||
|
|
0017e7fcb4 | ||
|
|
2e83324a58 | ||
|
|
d368f8a332 | ||
|
|
6f1f9cc084 | ||
|
|
72dd8e302c | ||
|
|
6ef9808ca6 | ||
|
|
13635e57b4 | ||
|
|
f86025a0d8 | ||
|
|
0d29db2df1 | ||
|
|
7d93263c0c | ||
|
|
b525f240a5 | ||
|
|
89cd012a7c | ||
|
|
7e92c2b43b | ||
|
|
49664306d3 | ||
|
|
a9764ff197 | ||
|
|
d185e3c21d | ||
|
|
07ec640757 | ||
|
|
22d61732cf | ||
|
|
e3d3dd3619 | ||
|
|
6a080c69a5 | ||
|
|
c84c6948f3 | ||
|
|
92df81dd89 | ||
|
|
5f286b1eb0 | ||
|
|
10d419dc29 | ||
|
|
4e2c097d22 | ||
|
|
374688e135 | ||
|
|
ede743b97f | ||
|
|
986a24545d | ||
|
|
31f5699f17 | ||
|
|
f6233bdd5c | ||
|
|
8dd2384801 | ||
|
|
a369a2316e | ||
|
|
e80fb98ba1 | ||
|
|
601aa49789 | ||
|
|
b730326130 | ||
|
|
3c58225483 | ||
|
|
ee0fb392a9 | ||
|
|
d5f2bdb527 | ||
|
|
230f84245d | ||
|
|
bb9925c616 | ||
|
|
aea005baad | ||
|
|
bc301f685a | ||
|
|
b96a744336 | ||
|
|
2ce5ad7d56 | ||
|
|
f662a7aceb | ||
|
|
dfff5a43c4 | ||
|
|
9b4d1de727 | ||
|
|
70c959bdeb | ||
|
|
7bd8010137 | ||
|
|
cd78bd408a | ||
|
|
1de13e7354 | ||
|
|
eda1529a6a | ||
|
|
ed680064d7 | ||
|
|
4d5bf293f5 | ||
|
|
3f633803f4 | ||
|
|
a3095b4dfa | ||
|
|
385ca37848 | ||
|
|
ec02d780ea | ||
|
|
ecdb82782f | ||
|
|
ace1d78505 | ||
|
|
87dc8a3f66 | ||
|
|
e8b6db6ed5 | ||
|
|
7aa484b1e4 | ||
|
|
988a2e38f0 | ||
|
|
af359358d5 | ||
|
|
fd3e5c9e5f | ||
|
|
bae28fb94a | ||
|
|
8964086a61 | ||
|
|
e0c2711fac | ||
|
|
27146f2bb0 | ||
|
|
e7e89a4baf | ||
|
|
315807aca2 | ||
|
|
7817a871bf | ||
|
|
fb22c1cee7 | ||
|
|
961a9f0036 | ||
|
|
81f4f3aecb | ||
|
|
f2eabb94c8 | ||
|
|
1ba71dbf15 | ||
|
|
a29cf5f7da | ||
|
|
95bf08c3fb | ||
|
|
cde9df4d81 | ||
|
|
30d3f1600f | ||
|
|
c2d28d1dc8 | ||
|
|
8387805f86 | ||
|
|
c555b0063f | ||
|
|
c741a6122a | ||
|
|
c1c474e2e8 | ||
|
|
9da35dc7ff | ||
|
|
640653bed3 | ||
|
|
93662b802b | ||
|
|
549eff117b | ||
|
|
12dea95ccb | ||
|
|
945188928e | ||
|
|
82b169718f | ||
|
|
becfd85476 | ||
|
|
05cb71f3e3 | ||
|
|
55a929dee8 | ||
|
|
e7d7d20997 | ||
|
|
4d09f9e519 | ||
|
|
103725bc15 | ||
|
|
340a17165c | ||
|
|
f58c3b1081 | ||
|
|
a0623fdeff | ||
|
|
ba16ff1c14 | ||
|
|
788a86331d | ||
|
|
568e8996d4 | ||
|
|
4f232ac245 | ||
|
|
8ce73e9920 | ||
|
|
a45d42bb95 | ||
|
|
1258de0571 | ||
|
|
6f0a651b53 | ||
|
|
8c0f341632 | ||
|
|
b122a48cba | ||
|
|
a7eb3514fc | ||
|
|
54514bb178 | ||
|
|
693f617957 | ||
|
|
a3fa306ac1 | ||
|
|
a7162352ec | ||
|
|
51de0d1a03 | ||
|
|
43f5d9f973 | ||
|
|
a1e1060ae3 | ||
|
|
3063fad0d2 | ||
|
|
4ad434a71c | ||
|
|
8be291d9d2 | ||
|
|
9b07d2ecbc |
No files matched your search
@@ -1,3 +1,10 @@
|
||||
#################### Version
|
||||
# The version of meelo to use (edge, latest, vX.X.X)
|
||||
# See available tags here: https://hub.docker.com/r/arthichaud/meelo-front/tags
|
||||
# The default value is 'latest'
|
||||
# Please read the changelog before changing this value
|
||||
# https://github.com/Arthi-chaud/Meelo/releases
|
||||
TAG=
|
||||
#################### Database
|
||||
# The port on the host where meelo will be accessible
|
||||
PORT=
|
||||
@@ -33,7 +40,17 @@ MEILI_MASTER_KEY=
|
||||
# A coma-separated list of random strings used for internal authentication
|
||||
# Example: API_KEYS=azertyuip,qwertyuiop
|
||||
API_KEYS=
|
||||
#################### Scrobblers
|
||||
# All values here are optional
|
||||
# Fill in the values according to what scrobblers you want to enable
|
||||
## LastFM:
|
||||
## Values obtained when creating an API account
|
||||
## https://www.last.fm/api/account/create
|
||||
LASTFM_API_KEY=
|
||||
LASTFM_API_SECRET=
|
||||
## ListenBrainz:
|
||||
### No configuration needed
|
||||
#################### Internal
|
||||
# Do not change this
|
||||
INTERNAL_DATA_DIR=/data
|
||||
INTERNAL_CONFIG_DIR=/config
|
||||
INTERNAL_CONFIG_DIR=/config
|
||||
@@ -12,5 +12,5 @@ runs:
|
||||
path: |
|
||||
./front/node_modules
|
||||
./front/.next
|
||||
key: front
|
||||
restore-keys: front
|
||||
key: front-${{ hashFiles('front/yarn.lock') }}
|
||||
restore-keys: front-
|
||||
@@ -3,7 +3,18 @@ description: 'Cache for Matcher'
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- run: apk add --update --no-progress lsb-release python3 py3-pip
|
||||
- run: apk add --update --no-progress lsb-release python3 py3-pip tar
|
||||
shell: sh
|
||||
- run: mkdir ./pip-cache
|
||||
shell: sh
|
||||
- name: Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
./pip-cache
|
||||
key: matcher-${{ hashFiles('matcher/requirements.txt') }}
|
||||
restore-keys: matcher-
|
||||
- run: pip3 install -r matcher/requirements.txt
|
||||
shell: sh
|
||||
env:
|
||||
PIP_CACHE_DIR: ./pip-cache
|
||||
@@ -12,4 +12,5 @@ runs:
|
||||
with:
|
||||
check-latest: true
|
||||
go-version-file: 'scanner/go.mod'
|
||||
cache-dependency-path: scanner/go.sum
|
||||
cache-dependency-path: scanner/go.sum
|
||||
go-version: '1.24.1'
|
||||
@@ -12,5 +12,5 @@ runs:
|
||||
path: |
|
||||
./server/node_modules
|
||||
./server/dist
|
||||
key: server
|
||||
restore-keys: server
|
||||
key: server-${{ hashFiles('server/yarn.lock') }}
|
||||
restore-keys: server-
|
||||
@@ -0,0 +1,59 @@
|
||||
name: 'Dockerisation'
|
||||
description: 'Build using Docker, and publish if necessary'
|
||||
inputs:
|
||||
docker-username:
|
||||
description: 'Username to publish Docker image under'
|
||||
required: true
|
||||
docker-token:
|
||||
description: 'Token to publish Docker image'
|
||||
required: true
|
||||
github-token:
|
||||
description: 'GitHub Token'
|
||||
required: true
|
||||
image-suffix:
|
||||
description: "Suffix to 'meelo-'"
|
||||
required: true
|
||||
publish:
|
||||
description: 'Publish to registry'
|
||||
default: 'false'
|
||||
context:
|
||||
description: 'Directory of the Dockerfile'
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ inputs.docker-username }}
|
||||
password: ${{ inputs.docker-token }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ inputs.github-token }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
arthichaud/meelo-${{ inputs.image-suffix }}
|
||||
ghcr.io/${{ github.repository }}-${{ inputs.image-suffix }}
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ${{ inputs.context }}
|
||||
platforms: ${{ (inputs.publish == 'true' || inputs.publish == true) && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
|
||||
push: ${{ inputs.publish == 'true' || inputs.publish == true }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || format('arthichaud/meelo-{0}:edge', inputs.image-suffix) }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -1,40 +0,0 @@
|
||||
name: Documentation
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
Changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
docs: ${{ steps.filter.outputs.docs }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
docs:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/documentation.yml'
|
||||
- 'assets/**'
|
||||
Build:
|
||||
needs: changes
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.changes.outputs.docs == 'true' }}
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v3
|
||||
- name: Deploy docs
|
||||
uses: mhausenblas/mkdocs-deploy-gh-pages@master
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CONFIG_FILE: docs/mkdocs.yml
|
||||
REQUIREMENTS: docs/requirements.txt
|
||||
@@ -28,6 +28,8 @@ jobs:
|
||||
front:
|
||||
- 'front/**'
|
||||
- '.github/workflows/front.yml'
|
||||
- '.github/actions/cache-front/**'
|
||||
- '.github/actions/dockerize/**'
|
||||
Build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
|
||||
@@ -92,70 +94,27 @@ jobs:
|
||||
name: coverage-report-front
|
||||
path: ./front/coverage
|
||||
- name: Run SonarCloud Scan
|
||||
uses: SonarSource/sonarcloud-github-action@master
|
||||
uses: SonarSource/sonarqube-scan-action@master
|
||||
with:
|
||||
projectBaseDir: ./front
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_FRONT }}
|
||||
Dockerize:
|
||||
if: ${{ needs.changes.outputs.front == '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: ./front
|
||||
push: false
|
||||
Publish:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && (github.event_name == 'pull_request' || (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-front
|
||||
ghcr.io/${{ github.repository }}-front
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: ./front
|
||||
push: true
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-front:edge' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
image-suffix: front
|
||||
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
@@ -28,6 +28,8 @@ jobs:
|
||||
matcher:
|
||||
- 'matcher/**'
|
||||
- '.github/workflows/matcher.yml'
|
||||
- '.github/actions/cache-matcher/**'
|
||||
- '.github/actions/dockerize/**'
|
||||
Typecheck:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
|
||||
@@ -69,63 +71,21 @@ jobs:
|
||||
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') }}
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.matcher == 'true' && (github.event_name == 'pull_request' || (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:
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: ./matcher
|
||||
push: true
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-matcher:edge' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
image-suffix: matcher
|
||||
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.matcher == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
|
||||
@@ -29,6 +29,8 @@ jobs:
|
||||
scanner:
|
||||
- 'scanner/**'
|
||||
- '.github/workflows/scanner.yml'
|
||||
- '.github/actions/cache-scanner/**'
|
||||
- '.github/actions/dockerize/**'
|
||||
Build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
|
||||
@@ -86,70 +88,28 @@ jobs:
|
||||
name: coverage-report-scanner
|
||||
path: ./scanner
|
||||
- name: Run SonarCloud Scan
|
||||
uses: SonarSource/sonarcloud-github-action@master
|
||||
uses: SonarSource/sonarqube-scan-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@v4
|
||||
- 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') }}
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && (github.event_name == 'pull_request' || (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:
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: ./scanner
|
||||
push: true
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-scanner:edge' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
image-suffix: scanner
|
||||
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
|
||||
@@ -28,6 +28,8 @@ jobs:
|
||||
server:
|
||||
- 'server/**'
|
||||
- '.github/workflows/server.yml'
|
||||
- '.github/actions/cache-server/**'
|
||||
- '.github/actions/dockerize/**'
|
||||
Build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
|
||||
@@ -121,70 +123,27 @@ jobs:
|
||||
name: coverage-report-server
|
||||
path: ./server/coverage
|
||||
- name: Run SonarCloud Scan
|
||||
uses: SonarSource/sonarcloud-github-action@master
|
||||
uses: SonarSource/sonarqube-scan-action@master
|
||||
with:
|
||||
projectBaseDir: ./server
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_BACK }}
|
||||
Dockerize:
|
||||
if: ${{ needs.changes.outputs.server == '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: ./server
|
||||
push: false
|
||||
Publish:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.server == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.server == 'true' && (github.event_name == 'pull_request' || (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-server
|
||||
ghcr.io/${{ github.repository }}-server
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: ./server
|
||||
push: true
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-server:edge' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
image-suffix: server
|
||||
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.server == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
@@ -1,4 +1,4 @@
|
||||
# <img alt="Icon" src="./front/public/banner.png" width="150px">
|
||||
# <img alt="Icon" src="./front/public/banner1-white.png" width="150px">
|
||||
|
||||
Welcome! Meelo is a self-hosted music server and web app. It works similarly to Plex, Jellyfin, Koel and Black Candy, but focuses on flexibility, browsing and listening experiences. Actually, Meelo is designed for music collectors. What does it mean? Scroll down to see our killer features :arrow_heading_down:
|
||||
|
||||
@@ -37,7 +37,8 @@ Meelo has all the basic features you might expect from a music player/server (pl
|
||||
- Use either the embedded metadata or the file's name (or both!) to extract metadata
|
||||
- Also works with album covers!
|
||||
- Get genres, descriptions, and ratings using MusicBrainz, Genius, Wikipedia and many more providers!
|
||||
- Download lyrics
|
||||
- Push Scrobbles to ListenBrainz and LastFM!
|
||||
- Download (synced) lyrics or get them from embedded metadata and `.lrc` files
|
||||
|
||||
Keep track of the upcoming features [here](https://github.com/Arthi-chaud/Meelo/issues)
|
||||
|
||||
@@ -46,7 +47,7 @@ Keep track of the upcoming features [here](https://github.com/Arthi-chaud/Meelo/
|
||||
To use Meelo, you'll need a 'clean' collection: either with embedded metadata or standard file/folder architecture. Using iTunes or Beets is a great way to start.
|
||||
|
||||
Meelo is shipped though Docker images. You might need to know a bit about Regexes.
|
||||
Look over [here](https://arthi-chaud.github.io/Meelo/) for setup instructions.
|
||||
Look over [here](https://github.com/Arthi-chaud/Meelo/wiki) for setup instructions.
|
||||
|
||||
## :desktop_computer: Live Demo
|
||||
|
||||
@@ -65,19 +66,31 @@ https://github.com/user-attachments/assets/48141241-25ec-4570-a4f1-696c0cdfcaaa
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
More screenshots [here](./assets/examples/)
|
||||
|
||||
</details>
|
||||
|
||||
## :handshake: Contributing and Bugs reporting
|
||||
## 🌐 Translation
|
||||
|
||||
:hammer: Would you like to contribute to Meelo? Feel free to [open a pull request](https://github.com/Arthi-chaud/Meelo/compare).
|
||||
Meelo uses [Weblate](https://weblate.org/en-gb/) to handle translations.
|
||||
Feel free to contribute [here](https://hosted.weblate.org/engage/meelo/)!
|
||||
|
||||
[](https://hosted.weblate.org/engage/meelo/)
|
||||
|
||||
Any questions about this? Open a discussion [here](https://github.com/Arthi-chaud/Meelo/discussions/categories/translation)!
|
||||
|
||||
## :handshake: Contributing and Bugs reporting
|
||||
|
||||
:thinking: Is there a feature you would like to have? [Open an issue](https://github.com/Arthi-chaud/Meelo/issues/new/choose), and we will be happy to discuss it!
|
||||
|
||||
:wrench: Need help with the setup process? Open a discussion [here](https://github.com/Arthi-chaud/Meelo/discussions/categories/need-help-with-setting-up).
|
||||
|
||||
:bug: Have you encountered a bug? Ugh we don't like 'em here! Report it by [opening an issue](https://github.com/Arthi-chaud/Meelo/issues/new/choose).
|
||||
|
||||
:hammer: Would you like to contribute to Meelo? Feel free to [open a pull request](https://github.com/Arthi-chaud/Meelo/compare).
|
||||
|
||||
---
|
||||
|
||||
Get ready to make the most out of you music collection!
|
||||
|
||||
|
After Width: | Height: | Size: 5.3 MiB |
@@ -134,7 +134,6 @@ services:
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- transcoder_cache:/cache
|
||||
profiles: ['', 'cpu']
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# This compose allows to setup the whole project in the production environment
|
||||
services:
|
||||
server:
|
||||
image: ghcr.io/arthi-chaud/meelo-server:latest
|
||||
image: arthichaud/meelo-server:${TAG:-latest}
|
||||
expose:
|
||||
- 4000
|
||||
restart: on-failure
|
||||
@@ -28,7 +28,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
scanner:
|
||||
image: ghcr.io/arthi-chaud/meelo-scanner:latest
|
||||
image: arthichaud/meelo-scanner:${TAG:-latest}
|
||||
expose:
|
||||
- 8133
|
||||
depends_on:
|
||||
@@ -48,7 +48,7 @@ services:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
|
||||
front:
|
||||
image: ghcr.io/arthi-chaud/meelo-front:latest
|
||||
image: arthichaud/meelo-front:${TAG:-latest}
|
||||
expose:
|
||||
- 3000
|
||||
depends_on:
|
||||
@@ -62,7 +62,7 @@ services:
|
||||
- PUBLIC_SCANNER_URL=${PUBLIC_URL}/scanner
|
||||
- SSR_SCANNER_URL=http://scanner:8133
|
||||
matcher:
|
||||
image: ghcr.io/arthi-chaud/meelo-matcher:latest
|
||||
image: arthichaud/meelo-matcher:${TAG:-latest}
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
server:
|
||||
@@ -121,7 +121,6 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
profiles: ['', 'cpu']
|
||||
mq:
|
||||
image: rabbitmq:4.0-alpine
|
||||
environment:
|
||||
|
||||
@@ -99,7 +99,6 @@ services:
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- transcoder_cache:/cache
|
||||
profiles: ['', 'cpu']
|
||||
db:
|
||||
image: postgres:alpine3.14
|
||||
healthcheck:
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
site/
|
||||
@@ -1,45 +0,0 @@
|
||||
site_name: Meelo - User Documentation
|
||||
docs_dir: './src'
|
||||
repo_url: https://github.com/Arthi-chaud/Meelo
|
||||
nav:
|
||||
- Setup:
|
||||
- TLDR: setup/tldr.md
|
||||
- Requirements: setup/requirements.md
|
||||
- Prepare Music Library: setup/prepare-music-library.md
|
||||
- Create Configuration Folder: setup/create-conf-folder.md
|
||||
- Prepare Your Settings: setup/settings.md
|
||||
- Configure Providers: setup/providers.md
|
||||
- Environment Variables: setup/env.md
|
||||
- Docker: setup/docker.md
|
||||
- Start: setup/startup.md
|
||||
- Create First Library: setup/create-library.md
|
||||
- Album Types: album-types.md
|
||||
- Song Types: song-types.md
|
||||
- Featuring Artists: featuring-artists.md
|
||||
theme:
|
||||
name: material
|
||||
language: en
|
||||
palette:
|
||||
scheme: default
|
||||
titles_only: true
|
||||
icon:
|
||||
repo: fontawesome/brands/github
|
||||
font:
|
||||
text: Noto Sans
|
||||
logo: ./icon.png
|
||||
favicon: ./favicon.ico
|
||||
features:
|
||||
- navigation.instant
|
||||
- navigation.tracking
|
||||
- navigation.tabs
|
||||
- navigation.tabs.sticky
|
||||
- navigation.sections
|
||||
- navigation.indexes
|
||||
- toc.integrate
|
||||
- navigation.top
|
||||
extra_css:
|
||||
- stylesheets/extra.css
|
||||
plugins:
|
||||
- search
|
||||
markdown_extensions:
|
||||
- mdx_truly_sane_lists
|
||||
@@ -1,2 +0,0 @@
|
||||
mkdocs-material
|
||||
mdx_truly_sane_lists
|
||||
@@ -1,42 +0,0 @@
|
||||
# Meelo's Album Types
|
||||
|
||||
In Music Apps, we usually call any song container an *Album*. While this is technically incorrect, many music streaming services categorizes these *Albums* in sub-categories: Albums, Singles, Live, Compilations, etc.
|
||||
|
||||
## Available Types
|
||||
|
||||
Meelo supports this categorization, and the following album types:
|
||||
|
||||
- Studio Recording
|
||||
- What we would usually call *Albums*
|
||||
- Live Recording
|
||||
- Compilation
|
||||
- Single
|
||||
- Soundtrack
|
||||
- Remix Album
|
||||
- Collections that would mainly have remixes of songs by a single artist, while not being a Single
|
||||
- Video Album
|
||||
- Any kind of collection that would only contain video tracks
|
||||
|
||||
## Type recognition
|
||||
|
||||
Meelo tries to be as independent of user interactions as possible. Therefore, type recognition is done automatically, when a new album is registered.
|
||||
|
||||
For now, Type recognition is solely based on the Album's title. Keywords to recognize an album's type were inspired by the way other music streaming services would categorize an album. Therefore, if your music collection's naming convention work a similar way to Spotify, Apple Music, ..., there might not be a lot to change if you want to fully enjoy this feature. However, if you consider that one of the following ways to recognize a type is incomplete or incorrect, feel free to [open an issue](https://github.com/Arthi-chaud/Meelo/issues).
|
||||
|
||||
### Keywords
|
||||
|
||||
| Album Type | Keywords |
|
||||
|---|---|
|
||||
| Studio Album | See note below |
|
||||
| Compilations | - `best of`<br>- `hits`<br>- `greatest hits`<br>- `singles`<br>- `collection`<br>+ See note below |
|
||||
| Live Recording | - '` tour`' (Notice the leading whitespace)<br>- `live from`<br>- `live at`<br>- `unplugged`<br>- `(live)` |
|
||||
| Single | - `- single`<br>- `- ep`<br>- `(remixes)` |
|
||||
| Video Album | - `music videos`<br>- `the video`<br>- `dvd` |
|
||||
| Remix Album | - `remix album`<br>- `mixes`<br>- `the remixes`<br>- `remixed`<br>- `remixes`<br>- `best mixes` |
|
||||
| Soundtrack | - `soundtrack`<br>- `from the motion picture`<br>- `music from and inspired by` |
|
||||
|
||||
Notes:
|
||||
|
||||
- Keywords are case-insensitive.
|
||||
- Any album that would not fall into the other categories will be considered to be a Compilation IF the album is a 'compilation' album (see metadata)
|
||||
- Any album that would not fall into the other categories will be considered to be a Studio Recording
|
||||
@@ -1 +0,0 @@
|
||||
../../front/public/banner-black.png
|
||||
@@ -1 +0,0 @@
|
||||
../../front/public/favicon.ico
|
||||
@@ -1,53 +0,0 @@
|
||||
# Meelo's Featuring Artist Management
|
||||
|
||||
Meelo is now able to parse and recognise featuring artists on a song, based on:
|
||||
|
||||
- Its name
|
||||
- Its artist field (from the metadata)
|
||||
|
||||
It does not leverage the album artist.
|
||||
|
||||
For example, a song named 'My Song (feat. Another Artist)' by 'Primary Artist & Other Artist' would be parsed to 'My Song' by artist 'Primary artist', featuring 'Other Artist' and 'Another Artist'.
|
||||
|
||||
Therefore, each featuring artist will be added to the database, and treated like any other artist.
|
||||
|
||||
Parsing works with `()`, `[]` and `-` as delimiters.
|
||||
|
||||
## Examples
|
||||
|
||||
In this section, we list examples that would be successfully parsed by Meelo
|
||||
|
||||
- 'My Song (feat. B)' by 'A'
|
||||
- Name: 'My Song'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B'
|
||||
- 'My Song (Remix) [feat. B]' by 'A'
|
||||
- Name: 'My Song (Remix)'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B'
|
||||
- 'My Song' by 'A & B'
|
||||
- Name: 'My Song'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B'
|
||||
- 'My Song' by 'A, B & C'
|
||||
- Name: 'My Song'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B', 'C'
|
||||
- 'My Song - With D' by 'A, B & C'
|
||||
- Name: 'My Song'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B', 'C', 'D'
|
||||
- 'My Song - Version With Drums' by 'A'
|
||||
- Name: 'My Song - Version With Drums'
|
||||
- Artist: 'A'
|
||||
- Featuring: None
|
||||
- 'My Song' by 'A Vs. B'
|
||||
- Name: 'My Song'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B'
|
||||
|
||||
## Special Case: Artist is already known
|
||||
|
||||
By default, we use '&' to find and split a list of artist. However, this behaviour changes if an artist containing '&' already exists.
|
||||
|
||||
For example, if we had an *album* artist named 'A & B' (since album artist are not parsed as much as song artists), a song named 'My Song (feat. A & B)' would be parsed as 'My Song', with featuring artist 'A & B'.
|
||||
@@ -1 +0,0 @@
|
||||
../../front/public/icon.png
|
||||
@@ -1,3 +0,0 @@
|
||||

|
||||
|
||||
# Welcome to Meelo's User Documentation
|
||||
@@ -1,7 +0,0 @@
|
||||
# Create a configuration folder
|
||||
|
||||
Meelo is runnable through a Docker container. But it needs a way to be parameterized and persist some data.
|
||||
|
||||
Therefore, it is recommended that you create a dedicated folder on your computer for Meelo.
|
||||
|
||||
For the sake of clarity, in this documentation, we'll refer to this folder as `Meelo`.
|
||||
@@ -1,8 +0,0 @@
|
||||
# Create your first library
|
||||
|
||||
After creating the admin account (from Web App),
|
||||
Visit the `/settings/libraries`, and click `Create Library` to create your first library.
|
||||
|
||||
Once your library is created, you should run a scan to collect the related files. To do so, click the *Scan* button.
|
||||
|
||||
Your Meelo server is ready to be used! Visit the `/` route on your favourite browser to enjoy your favourite music :).
|
||||
@@ -1,5 +0,0 @@
|
||||
# Docker and Docker-compose
|
||||
|
||||
In your `Meelo` folder, copy this [docker-compose file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/docker-compose.prod.yml) as `docker-compose.yml`. It can be used as-is.
|
||||
|
||||
Additionally, you should copy [nginx configuration file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/nginx.conf.template) as is in your `Meelo` folder.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Environment variables
|
||||
|
||||
In your `Meelo` directory, copy this [`.env.example`](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/.env.example) as `.env` and fill the variables
|
||||
@@ -1,18 +0,0 @@
|
||||
# Prepare your music library
|
||||
|
||||
The toughest part is to have an structured music library. The way files are organised are up to you! But here is a recommended architecture:
|
||||
|
||||
```tree
|
||||
Library's Folder
|
||||
│
|
||||
Artist 1
|
||||
│ │
|
||||
│ └───Album 1
|
||||
│ │ 01 Track 1.m4a
|
||||
│ │ 02 Track 2.m4a
|
||||
| | cover.jpg
|
||||
│ │ ...
|
||||
│ ...
|
||||
```
|
||||
|
||||
Meelo provides two ways of collecting metadata: using embedded tags, or using the path of the file. If a metadata source does not provide a specific field, you can use the other as a fallback. (You will configure this behaviour in the next sections). But before going any further, you should decide which metadata collection method to use.
|
||||
@@ -1,42 +0,0 @@
|
||||
# Providers
|
||||
|
||||
## Enabling Providers
|
||||
|
||||
In the `settings.json`, ff the `provider` object does not contain a provider's settings, this provider will be disabled.
|
||||
|
||||
Providers can also be disabled explicitly by setting the Provider's settings' field `enabled` at `false`. For example:
|
||||
|
||||
```json
|
||||
// In settings.json
|
||||
{
|
||||
//... Other properties
|
||||
"providers": {
|
||||
"provider1": {}, // Provider 1 is enabled
|
||||
// Provider 2 was not specified, it is therefore disabled
|
||||
"provider3": {
|
||||
"enabled": true // Provider 3 is enabled
|
||||
},
|
||||
"provider4": {
|
||||
"enabled": false, // Provider 4 is disabled
|
||||
"apiKey": "" // Other property of provider 4
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If a provider is disabled, no call to its service will be done.
|
||||
|
||||
## Providers' properties and features
|
||||
|
||||
| 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 | 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.
|
||||
|
||||
*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,8 +0,0 @@
|
||||
# Requirements
|
||||
|
||||
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 Golang flavor)
|
||||
@@ -1,35 +0,0 @@
|
||||
# Prepare your settings
|
||||
|
||||
In your `Meelo` folder, download and fill [this `settings.json` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/settings.json)
|
||||
|
||||
All fields are **required**
|
||||
|
||||
- `trackRegex`: Probably the most important setting for Meelo. This field takes an array of strings, each string being a Regular Expression string. The RegExp will be used to find the files to import (It will be matched against the **absolute path** of the file). As mentioned before, the path of the file can be used as a metadata source. Therefore, **the Regular Expression accepts groups to find metadata**:
|
||||
- `AlbumArtist`: The artist of the parent album.
|
||||
- Special Case: If it equals `Compilations`, related album will be considered as compilation albums (with no main artist, like soundtrack albums)
|
||||
- `Artist`: The artist of the track. If it is not present, it will use the `AlbumArtist` field.
|
||||
- `Release`: Usually no present in a standard file architecture; the name of the *release*. When it is not present, the `Album` field will be used.
|
||||
- `Album`: The name of the album of the material. This field will then be parsed to group related album releases.
|
||||
- `Year`: The Year of the release of the material
|
||||
- `Disc`: The Index of the disc the track is on.
|
||||
- `Index`: The index of the track in a playlist/on a disc.
|
||||
- `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
|
||||
- `path`: Using the Regular Expression's groups.
|
||||
- `order`: Define the missing metadata fallback behaviour. *Note*: if this method is enabled, it will consider 'cover.*' file in the same directory as the media file as an illustration candidate.
|
||||
- `only`: If a field is missing from the primary metadata source, will not use the other method to get it.
|
||||
- `preferred`: If a field is missing from the primary metadata source, will try to use the other method to get it.
|
||||
- `useExternalProviderGenres`: Set to `true` if you want genres from external metadata providers to be used. These genres will be used alongside with embedded genres (if parsing embedded metadata is enabled)
|
||||
- `providers`: Please refer to [*Providers*](../providers)
|
||||
- `compilations`:
|
||||
- `artists`: Optional field. If an album artist in this list, their albums will be considered to be compilation albums.
|
||||
- `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.
|
||||
@@ -1,16 +0,0 @@
|
||||
# Start Meelo
|
||||
|
||||
Once these three files are ready, run the following command from your `Meelo` folder:
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Two things can prevent Meelo from starting normally:
|
||||
|
||||
- A bad `settings.json` file
|
||||
- In that case, a message should tell you what went wrong
|
||||
- Connection to database failed
|
||||
- In that case, you should have a red message telling you a query failed. To fix, simply restart the server
|
||||
@@ -1,12 +0,0 @@
|
||||
# TLDR
|
||||
|
||||
Don't feel like reading? Here's how to setup Meelo quickly
|
||||
|
||||
- Download and fill [this `docker-compose` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/docker-compose.prod.yml)
|
||||
- Download and fill [this `nginx` configuration file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/nginx.conf.template)
|
||||
- Download and fill [this `.env` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/.env.example)
|
||||
- Download and fill [this `settings.json` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/settings.json)
|
||||
|
||||
You should be ready to go! If something goes wrong, please take the time to read the `Setup` section of this documentation.
|
||||
|
||||
Enjoy!
|
||||
@@ -1,31 +0,0 @@
|
||||
# Meelo's Album Types
|
||||
|
||||
It is possible to filter songs based on their *type*.
|
||||
|
||||
## Available Types
|
||||
|
||||
Meelo supports the following song types:
|
||||
|
||||
- Original
|
||||
- Live
|
||||
- Acoustic
|
||||
- Remix
|
||||
- Instrumental
|
||||
- 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
|
||||
|
||||
Songs' type recognition is solely based on keywords present in their name. For example:
|
||||
|
||||
- 'My Song (Remix)' would be typed as 'Remix'
|
||||
- 'My Song (Live)' would be recognised as a Live rendition
|
||||
- 'My Song (Rough Mix)' would be defined as 'Demo'
|
||||
- 'My Song (Instrumental Mix)' would be an 'Instrumental'
|
||||
- 'My Song (Instrumental Break Down Mix)' would be a 'Remix'
|
||||
@@ -1,3 +0,0 @@
|
||||
:root {
|
||||
--md-primary-fg-color: #2F2C2B;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import userReducer, {
|
||||
setAccessToken,
|
||||
setUserProfile,
|
||||
unsetAccessToken,
|
||||
} from "../../src/state/userSlice";
|
||||
|
||||
describe("User Slice", () => {
|
||||
let state: ReturnType<typeof userReducer> | undefined = undefined;
|
||||
|
||||
it("Should set the access token", () => {
|
||||
state = userReducer({}, setAccessToken("1234"));
|
||||
|
||||
expect(state).toStrictEqual({ accessToken: "1234" });
|
||||
});
|
||||
|
||||
it("Should set the user profile", () => {
|
||||
const user = { id: 1, name: "user", admin: true, enabled: true };
|
||||
state = userReducer(state, setUserProfile(user));
|
||||
|
||||
expect(state).toStrictEqual({ accessToken: "1234", user });
|
||||
});
|
||||
|
||||
it("Should unset the access token, and user profile", () => {
|
||||
state = userReducer(state, unsetAccessToken());
|
||||
|
||||
expect(state.accessToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
"lineWidth": 80
|
||||
},
|
||||
"files": {
|
||||
"ignore": ["**/.next/**"]
|
||||
"ignore": ["**/.next/**", "src/i18n/translations/*.json"]
|
||||
},
|
||||
"linter": {
|
||||
"rules": {
|
||||
|
||||
@@ -12,15 +12,15 @@
|
||||
"test:cov": "jest -i --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/cache": "^11.10.0",
|
||||
"@emotion/react": "^11.10.0",
|
||||
"@emotion/cache": "^11.14.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@emotion/styled": "^11.10.0",
|
||||
"@mui/material": "^5.10.1",
|
||||
"@mui/utils": "^5.10.6",
|
||||
"@mui/x-data-grid": "^5.17.14",
|
||||
"@mui/x-date-pickers": "^5.0.8",
|
||||
"@reduxjs/toolkit": "^1.8.5",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/material": "^7.0.1",
|
||||
"@mui/material-nextjs": "^7.0.0",
|
||||
"@mui/utils": "^7.0.1",
|
||||
"@mui/x-data-grid": "^7.28.3",
|
||||
"@mui/x-date-pickers": "^7.28.3",
|
||||
"accept-language-parser": "^1.5.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"change-case": "^5.1.2",
|
||||
@@ -31,9 +31,10 @@
|
||||
"hls.js": "^1.5.11",
|
||||
"i18next": "^23.7.16",
|
||||
"iconsax-react": "^0.0.8",
|
||||
"jotai": "^2.12.1",
|
||||
"material-ui-confirm": "^3.0.7",
|
||||
"mui-react-hook-form-plus": "^1.3.2",
|
||||
"next": "14.2.23",
|
||||
"next": "14.2.25",
|
||||
"react": "18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-blurhash": "^0.3.0",
|
||||
@@ -43,13 +44,10 @@
|
||||
"react-hot-toast": "^2.3.0",
|
||||
"react-i18next": "^12.2.2",
|
||||
"react-infinite-scroller": "^1.2.6",
|
||||
"react-loader-spinner": "^5.2.0",
|
||||
"react-query": "^3.39.2",
|
||||
"react-redux": "^8.0.2",
|
||||
"react-use": "^17.5.1",
|
||||
"redux-persist": "^6.0.0",
|
||||
"sharp": "^0.33.4",
|
||||
"type-fest": "^2.19.0",
|
||||
"type-fest": "^4.33.0",
|
||||
"usehooks-ts": "^2.9.5",
|
||||
"uuid": "^10.0.0",
|
||||
"yup": "^1.0.0"
|
||||
|
||||
|
Before Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 478 KiB After Width: | Height: | Size: 374 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
Before Width: | Height: | Size: 669 KiB |
@@ -17,7 +17,6 @@
|
||||
*/
|
||||
|
||||
import type en from "../i18n/translations/en.json";
|
||||
import type fr from "../i18n/translations/fr.json";
|
||||
import "i18next";
|
||||
|
||||
declare module "i18next" {
|
||||
@@ -26,7 +25,7 @@ declare module "i18next" {
|
||||
returnNull: false;
|
||||
returnObjects: false;
|
||||
resources: {
|
||||
translation: typeof en | typeof fr;
|
||||
translation: typeof en;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* 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 { atom, useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
useInfiniteQuery as useReactInfiniteQuery,
|
||||
useQueries as useReactQueries,
|
||||
useQuery as useReactQuery,
|
||||
useQueryClient as useReactQueryClient,
|
||||
} from "react-query";
|
||||
import type { PaginationParameters } from "~/models/pagination";
|
||||
import type Resource from "~/models/resource";
|
||||
import {
|
||||
type InfiniteQueryFn,
|
||||
type QueryFn,
|
||||
toTanStackInfiniteQuery,
|
||||
toTanStackQuery,
|
||||
} from "~/query";
|
||||
import { store } from "~/state/store";
|
||||
import { accessTokenAtom } from "~/state/user";
|
||||
import API from ".";
|
||||
|
||||
const apiInstancesAtom = atom({} as Record<string, API>);
|
||||
|
||||
export const useAPI = () => {
|
||||
const [accessToken] = useAtom(accessTokenAtom);
|
||||
const [apiInstances, setApiInstances] = useAtom(apiInstancesAtom);
|
||||
if (!accessToken) {
|
||||
return getAPI_(null);
|
||||
}
|
||||
const matchingInstance = apiInstances[accessToken];
|
||||
if (!matchingInstance) {
|
||||
const newInstance = getAPI_(accessToken);
|
||||
setApiInstances((rest) => ({ ...rest, [accessToken]: newInstance }));
|
||||
return newInstance;
|
||||
}
|
||||
return matchingInstance;
|
||||
};
|
||||
|
||||
// Get API instance using atom to resolve access token
|
||||
export const getAPI = () => {
|
||||
const accessToken = store.get(accessTokenAtom);
|
||||
return getAPI_(accessToken ?? null);
|
||||
};
|
||||
|
||||
export const getAPI_ = (accessToken: string | null) => {
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const api = new API(
|
||||
accessToken,
|
||||
{
|
||||
api: {
|
||||
ssr:
|
||||
process.env.SSR_SERVER_URL ??
|
||||
process.env.PUBLIC_SERVER_URL!,
|
||||
csr: isDev ? "/api" : (process.env.PUBLIC_SERVER_URL ?? "/api"),
|
||||
},
|
||||
scanner: {
|
||||
ssr:
|
||||
process.env.SSR_SCANNER_URL ??
|
||||
process.env.PUBLIC_SCANNER_URL!,
|
||||
csr: isDev
|
||||
? "/scanner"
|
||||
: (process.env.PUBLIC_SCANNER_URL ?? "/scanner"),
|
||||
},
|
||||
},
|
||||
isDev,
|
||||
);
|
||||
return api;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for the react-query's *useQuery*
|
||||
*/
|
||||
export const useQuery = <
|
||||
ReturnType,
|
||||
Params extends any[],
|
||||
Transformed = ReturnType,
|
||||
>(
|
||||
query: QueryFn<ReturnType, Transformed, Params>,
|
||||
...queryParams: Partial<Params>
|
||||
) => {
|
||||
const api = useAPI();
|
||||
const queryOpts = useMemo(
|
||||
() => toTanStackQuery(api, query, ...queryParams),
|
||||
[query, queryParams],
|
||||
);
|
||||
const hook = useReactQuery(queryOpts);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryOpts.enabled) {
|
||||
hook.refetch();
|
||||
}
|
||||
}, [api]);
|
||||
return hook;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for the react-query's *useQueries*
|
||||
*/
|
||||
export const useQueries = <ReturnType, Params extends any[][]>(
|
||||
...queries: readonly Parameters<
|
||||
typeof useQuery<ReturnType, Params[number], ReturnType>
|
||||
>[]
|
||||
) => {
|
||||
const api = useAPI();
|
||||
const queryOpts = useMemo(
|
||||
() =>
|
||||
queries.map(([query, ...params]) =>
|
||||
toTanStackQuery(api, query, ...(params as Params[number])),
|
||||
),
|
||||
[queries],
|
||||
);
|
||||
const hook = useReactQueries(queryOpts);
|
||||
|
||||
useEffect(() => {
|
||||
queryOpts.forEach((queryOpt, idx) => {
|
||||
if (queryOpt.enabled) {
|
||||
hook[idx].refetch();
|
||||
}
|
||||
});
|
||||
}, [api]);
|
||||
return hook;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for the react-query's *useInfiniteQuery*
|
||||
*/
|
||||
export const useInfiniteQuery = <
|
||||
ReturnType extends Resource,
|
||||
Params extends any[],
|
||||
TransformedType = ReturnType,
|
||||
>(
|
||||
query: InfiniteQueryFn<ReturnType, TransformedType, Params>,
|
||||
...queryParams: Partial<Params>
|
||||
) => {
|
||||
const api = useAPI();
|
||||
const queryOpts = useMemo(
|
||||
() => toTanStackInfiniteQuery(api, query, ...queryParams),
|
||||
[query, queryParams],
|
||||
);
|
||||
const { data, ...rest } = useReactInfiniteQuery({
|
||||
...queryOpts,
|
||||
getNextPageParam: (lastPage): PaginationParameters | null => {
|
||||
if (lastPage.metadata.next === null) {
|
||||
return null;
|
||||
}
|
||||
const lastItemId = lastPage.items.at(-1)?.id;
|
||||
if (lastItemId) {
|
||||
return { afterId: lastItemId };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
const [items, setItems] = useState(data?.pages.at(0)?.items);
|
||||
useEffect(() => {
|
||||
setItems(data?.pages.flatMap((p) => p.items));
|
||||
}, [data?.pages]);
|
||||
// useEffect(() => {
|
||||
// if (queryOpts.enabled) {
|
||||
// rest.refetch();
|
||||
// }
|
||||
// }, [api]);
|
||||
|
||||
return { ...rest, data, items };
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper of the useQueryClient Hook, to wrap `toTanStackQuery`
|
||||
*/
|
||||
export const useQueryClient = () => {
|
||||
const api = useAPI();
|
||||
const queryClient = useReactQueryClient();
|
||||
|
||||
return {
|
||||
api: api,
|
||||
client: queryClient,
|
||||
fetchQuery: <R, R1, Params extends any[]>(
|
||||
query: ReturnType<QueryFn<R, R1, Params>>,
|
||||
) => queryClient.fetchQuery(toTanStackQuery(api, () => query)),
|
||||
};
|
||||
};
|
||||
|
||||
export type QueryClient = ReturnType<typeof useQueryClient>;
|
||||
@@ -0,0 +1,701 @@
|
||||
/*
|
||||
* 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 { ResourceNotFound } from "exceptions";
|
||||
import type { RequireExactlyOne } from "type-fest";
|
||||
import * as yup from "yup";
|
||||
import type { AlbumType } from "~/models/album";
|
||||
import Library from "~/models/library";
|
||||
import type { PaginationParameters } from "~/models/pagination";
|
||||
import Playlist, {
|
||||
type CreatePlaylistDto,
|
||||
type UpdatePlaylistDto,
|
||||
} from "~/models/playlist";
|
||||
import type { Scrobbler } from "~/models/scrobblers";
|
||||
import type { SaveSearchItem } from "~/models/search";
|
||||
import type { SongType } from "~/models/song";
|
||||
import { TaskResponse } from "~/models/task";
|
||||
import type { TrackType } from "~/models/track";
|
||||
import User from "~/models/user";
|
||||
import type { VideoType } from "~/models/video";
|
||||
import { isSSR } from "~/utils/is-ssr";
|
||||
import type { SortingParameters } from "~/utils/sorting";
|
||||
|
||||
const AuthenticationResponse = yup.object({
|
||||
access_token: yup.string().required(),
|
||||
});
|
||||
|
||||
type AuthenticationResponse = yup.InferType<typeof AuthenticationResponse>;
|
||||
|
||||
type QueryParameters<Keys extends readonly string[]> = {
|
||||
pagination?: PaginationParameters | null;
|
||||
include?: string[];
|
||||
sort?: SortingParameters<Keys>;
|
||||
};
|
||||
|
||||
type AuthenticationInput = {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export enum Service {
|
||||
API = 0,
|
||||
Scanner = 1,
|
||||
}
|
||||
|
||||
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<{
|
||||
emptyResponse: true;
|
||||
validator: yup.Schema<ReturnType>;
|
||||
customValidator: (value: unknown) => Promise<ReturnType>;
|
||||
}>;
|
||||
|
||||
type APIUrls = {
|
||||
api: Record<"ssr" | "csr", string>;
|
||||
scanner: Record<"ssr" | "csr", string>;
|
||||
};
|
||||
|
||||
export default class API {
|
||||
constructor(
|
||||
private readonly accessToken: string | null,
|
||||
private readonly urls: APIUrls,
|
||||
private readonly isDev = false,
|
||||
public readonly pageSize = API.DefaultPageSize,
|
||||
) {}
|
||||
public static readonly DefaultPageSize = 35;
|
||||
|
||||
///// Auth + User management
|
||||
|
||||
async login(
|
||||
credentials: AuthenticationInput,
|
||||
): Promise<AuthenticationResponse> {
|
||||
return this.fetch({
|
||||
route: "/auth/login",
|
||||
data: credentials,
|
||||
errorMessage: "Username or password is incorrect",
|
||||
parameters: {},
|
||||
method: "POST",
|
||||
validator: AuthenticationResponse,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new user
|
||||
* @param credentials the credentails of the new user
|
||||
* @returns The newly created user
|
||||
*/
|
||||
async register(credentials: AuthenticationInput): Promise<User> {
|
||||
return this.fetch({
|
||||
route: "/users",
|
||||
data: {
|
||||
name: credentials.username,
|
||||
password: credentials.password,
|
||||
},
|
||||
parameters: {},
|
||||
method: "POST",
|
||||
validator: User,
|
||||
});
|
||||
}
|
||||
|
||||
async updateUser(
|
||||
userId: number,
|
||||
updatedFields: Partial<Pick<User, "admin" | "enabled">>,
|
||||
): Promise<User> {
|
||||
return this.fetch({
|
||||
route: `/users/${userId}`,
|
||||
errorMessage: "User could not be updated",
|
||||
data: updatedFields,
|
||||
parameters: {},
|
||||
method: "PUT",
|
||||
validator: User,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user
|
||||
* @param userId the id of the user to delete
|
||||
*/
|
||||
async deleteUser(userId: number): Promise<User> {
|
||||
return this.fetch({
|
||||
route: `/users/${userId}`,
|
||||
errorMessage: "User could not be deleted",
|
||||
parameters: {},
|
||||
method: "DELETE",
|
||||
validator: User,
|
||||
});
|
||||
}
|
||||
|
||||
//// Tasks
|
||||
|
||||
async cleanLibraries(): Promise<TaskResponse> {
|
||||
return this.fetch({
|
||||
route: "/clean",
|
||||
errorMessage: "Library clean failed",
|
||||
parameters: {},
|
||||
service: Service.Scanner,
|
||||
method: "POST",
|
||||
validator: TaskResponse,
|
||||
});
|
||||
}
|
||||
|
||||
async scanLibrary(librarySlugOrId: number | string): Promise<TaskResponse> {
|
||||
return this.fetch({
|
||||
route: `/scan/${librarySlugOrId}`,
|
||||
errorMessage: "Library scan failed",
|
||||
parameters: {},
|
||||
service: Service.Scanner,
|
||||
method: "POST",
|
||||
validator: TaskResponse,
|
||||
});
|
||||
}
|
||||
|
||||
async cleanLibrary(
|
||||
librarySlugOrId: number | string,
|
||||
): Promise<TaskResponse> {
|
||||
return this.fetch({
|
||||
route: `/clean/${librarySlugOrId}`,
|
||||
errorMessage: "Library clean failed",
|
||||
parameters: {},
|
||||
service: Service.Scanner,
|
||||
method: "POST",
|
||||
validator: TaskResponse,
|
||||
});
|
||||
}
|
||||
|
||||
async scanLibraries(): Promise<TaskResponse> {
|
||||
return this.fetch<TaskResponse, []>({
|
||||
route: "/scan",
|
||||
parameters: {},
|
||||
service: Service.Scanner,
|
||||
method: "POST",
|
||||
validator: TaskResponse,
|
||||
});
|
||||
}
|
||||
|
||||
async refreshMetadata(
|
||||
parentResourceType: "library" | "album" | "song" | "release" | "track",
|
||||
resourceSlugOrId: number | string,
|
||||
force: boolean,
|
||||
): Promise<void> {
|
||||
return this.fetch({
|
||||
method: "POST",
|
||||
route: "/refresh",
|
||||
errorMessage: "Metadata Refresh request failed",
|
||||
otherParameters: { [parentResourceType]: resourceSlugOrId, force },
|
||||
parameters: {},
|
||||
service: Service.Scanner,
|
||||
emptyResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
//// Playlists
|
||||
|
||||
async createPlaylist(dto: CreatePlaylistDto): Promise<Playlist> {
|
||||
return this.fetch({
|
||||
route: "/playlists",
|
||||
data: dto,
|
||||
errorMessage: "Playlist Creation Failed",
|
||||
parameters: {},
|
||||
method: "POST",
|
||||
validator: Playlist,
|
||||
});
|
||||
}
|
||||
|
||||
async updatePlaylist(
|
||||
playlistSlugOrId: number | string,
|
||||
dto: UpdatePlaylistDto,
|
||||
): Promise<Playlist> {
|
||||
return this.fetch({
|
||||
route: `/playlists/${playlistSlugOrId}`,
|
||||
data: dto,
|
||||
parameters: {},
|
||||
method: "PUT",
|
||||
validator: Playlist,
|
||||
});
|
||||
}
|
||||
|
||||
async reorderPlaylist(
|
||||
playlistSlugOrId: number | string,
|
||||
entriesIds: number[],
|
||||
): Promise<void> {
|
||||
return this.fetch({
|
||||
route: `/playlists/${playlistSlugOrId}/entries/reorder`,
|
||||
data: { entryIds: entriesIds },
|
||||
parameters: {},
|
||||
method: "PUT",
|
||||
emptyResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
async addSongToPlaylist(
|
||||
songId: number,
|
||||
playlistId: number,
|
||||
): Promise<unknown> {
|
||||
return this.fetch({
|
||||
route: `/playlists/${playlistId}/entries`,
|
||||
errorMessage: "Failed to add song to playlist",
|
||||
parameters: {},
|
||||
data: { songId },
|
||||
method: "POST",
|
||||
emptyResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
async deletePlaylistEntry(entryId: number): Promise<unknown> {
|
||||
return this.fetch({
|
||||
route: `/playlists/entries/${entryId}`,
|
||||
errorMessage: "Failed to remove song from playlist",
|
||||
parameters: {},
|
||||
method: "DELETE",
|
||||
emptyResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
async deletePlaylist(playlistSlugOrId: number | string): Promise<unknown> {
|
||||
return this.fetch({
|
||||
route: `/playlists/${playlistSlugOrId}`,
|
||||
errorMessage: "Failed to remove playlist",
|
||||
parameters: {},
|
||||
method: "DELETE",
|
||||
emptyResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
//// Libraries
|
||||
|
||||
async createLibrary(
|
||||
libraryName: string,
|
||||
libraryPath: string,
|
||||
): Promise<Library> {
|
||||
return this.fetch({
|
||||
route: "/libraries",
|
||||
data: { name: libraryName, path: libraryPath },
|
||||
parameters: {},
|
||||
method: "POST",
|
||||
validator: Library,
|
||||
});
|
||||
}
|
||||
|
||||
async updateLibrary(
|
||||
libraryId: number,
|
||||
libraryName: string,
|
||||
libraryPath: string,
|
||||
): Promise<Library> {
|
||||
return this.fetch({
|
||||
route: `/libraries/${libraryId}`,
|
||||
data: { name: libraryName, path: libraryPath },
|
||||
errorMessage: "Library Update Failed",
|
||||
parameters: {},
|
||||
method: "PUT",
|
||||
validator: Library,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteLibrary(librarySlugOrId: number | string): Promise<unknown> {
|
||||
return this.fetch({
|
||||
route: `/libraries/${librarySlugOrId}`,
|
||||
errorMessage: "Library deletion failed",
|
||||
parameters: {},
|
||||
method: "DELETE",
|
||||
validator: yup.mixed(),
|
||||
});
|
||||
}
|
||||
|
||||
///// Illustrations
|
||||
|
||||
async updateArtistIllustration(artistId: number, illustrationUrl: string) {
|
||||
return this.updateResourceIllustration(
|
||||
artistId,
|
||||
illustrationUrl,
|
||||
"artist",
|
||||
);
|
||||
}
|
||||
|
||||
async updateReleaseIllustration(
|
||||
releaseId: number,
|
||||
illustrationUrl: string,
|
||||
) {
|
||||
return this.updateResourceIllustration(
|
||||
releaseId,
|
||||
illustrationUrl,
|
||||
"release",
|
||||
);
|
||||
}
|
||||
|
||||
async updateTrackIllustration(trackId: number, illustrationUrl: string) {
|
||||
return this.updateResourceIllustration(
|
||||
trackId,
|
||||
illustrationUrl,
|
||||
"track",
|
||||
);
|
||||
}
|
||||
|
||||
async updatePlaylistIllustration(
|
||||
playlistId: number,
|
||||
illustrationUrl: string,
|
||||
) {
|
||||
return this.updateResourceIllustration(
|
||||
playlistId,
|
||||
illustrationUrl,
|
||||
"playlist",
|
||||
);
|
||||
}
|
||||
|
||||
private async updateResourceIllustration(
|
||||
resourceId: number,
|
||||
illustrationUrl: string,
|
||||
resourceType: "artist" | "release" | "track" | "playlist",
|
||||
): Promise<unknown> {
|
||||
return this.fetch({
|
||||
route: "/illustrations/url",
|
||||
errorMessage: "Update Illustration Failed",
|
||||
method: "POST",
|
||||
parameters: {},
|
||||
emptyResponse: true,
|
||||
data: { url: illustrationUrl, [`${resourceType}Id`]: resourceId },
|
||||
});
|
||||
}
|
||||
|
||||
/// Albums
|
||||
|
||||
async updateAlbum(
|
||||
albumSlugOrId: number | string,
|
||||
dto: Partial<{ type: AlbumType; masterReleaseId: number }>,
|
||||
): Promise<void> {
|
||||
return this.fetch({
|
||||
route: `/albums/${albumSlugOrId}`,
|
||||
errorMessage: "Update Album Failed",
|
||||
method: "PUT",
|
||||
parameters: {},
|
||||
emptyResponse: true,
|
||||
data: dto,
|
||||
});
|
||||
}
|
||||
|
||||
//// Songs
|
||||
|
||||
async updateSong(
|
||||
songSlugOrId: number | string,
|
||||
dto: Partial<{ type: SongType; masterTrackId: number }>,
|
||||
): Promise<void> {
|
||||
return this.fetch({
|
||||
route: `/songs/${songSlugOrId}`,
|
||||
errorMessage: "Update Song Failed",
|
||||
method: "PUT",
|
||||
parameters: {},
|
||||
emptyResponse: true,
|
||||
data: dto,
|
||||
});
|
||||
}
|
||||
|
||||
async setSongAsPlayed(songSlugOrId: string | number): Promise<unknown> {
|
||||
return this.fetch({
|
||||
route: `/songs/${songSlugOrId}/played`,
|
||||
errorMessage: "Song update failed",
|
||||
parameters: {},
|
||||
method: "PUT",
|
||||
validator: yup.mixed(),
|
||||
});
|
||||
}
|
||||
|
||||
async mergeSongs(srcSongId: number, destSongId: number): Promise<void> {
|
||||
return this.fetch({
|
||||
route: `/songs/${srcSongId}/merge`,
|
||||
data: { songId: destSongId },
|
||||
parameters: {},
|
||||
method: "POST",
|
||||
emptyResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
//// tracks
|
||||
async updateTrack(trackId: number, destSongId: number): Promise<void> {
|
||||
return this.fetch({
|
||||
route: `/tracks/${trackId}`,
|
||||
data: { songId: destSongId },
|
||||
parameters: {},
|
||||
method: "PUT",
|
||||
emptyResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
//// videos
|
||||
|
||||
async updateVideo(
|
||||
videoSlugOrId: number | string,
|
||||
dto: Partial<{ type: VideoType; masterTrackId: number }>,
|
||||
): Promise<void> {
|
||||
return this.fetch({
|
||||
route: `/videos/${videoSlugOrId}`,
|
||||
errorMessage: "Update Video Failed",
|
||||
method: "PUT",
|
||||
parameters: {},
|
||||
emptyResponse: true,
|
||||
data: dto,
|
||||
});
|
||||
}
|
||||
|
||||
//// search
|
||||
|
||||
saveSearchHistoryEntry(resource: SaveSearchItem): Promise<void> {
|
||||
return this.fetch({
|
||||
method: "POST",
|
||||
route: "/search/history",
|
||||
errorMessage: "Saving Search History failed",
|
||||
parameters: {},
|
||||
data: resource,
|
||||
emptyResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
/// LastFM
|
||||
|
||||
getLastFMAuthUrl(origin: string) {
|
||||
return this.fetch({
|
||||
route: "/scrobblers/lastfm/url",
|
||||
parameters: {},
|
||||
otherParameters: {
|
||||
callback: `${origin}/scrobblers/lastfm/callback_handler`, //redirecting to the settings page
|
||||
},
|
||||
validator: yup.object({ url: yup.string().required() }),
|
||||
});
|
||||
}
|
||||
|
||||
async postLastFMToken(token: string) {
|
||||
return this.fetch({
|
||||
method: "POST",
|
||||
route: "/scrobblers/lastfm",
|
||||
data: { token },
|
||||
parameters: {},
|
||||
emptyResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
/// ListenBrainz
|
||||
|
||||
async postListenBrainzToken(token: string, instanceUrl: string | null) {
|
||||
return this.fetch({
|
||||
method: "POST",
|
||||
route: "/scrobblers/listenbrainz",
|
||||
data: { token, instanceUrl },
|
||||
parameters: {},
|
||||
emptyResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
async disconnectScrobbler(scrobbler: Scrobbler) {
|
||||
let route = "/scrobblers";
|
||||
switch (scrobbler) {
|
||||
case "LastFM":
|
||||
route = `${route}/lastfm`;
|
||||
break;
|
||||
case "ListenBrainz":
|
||||
route = `${route}/listenbrainz`;
|
||||
break;
|
||||
}
|
||||
return this.fetch({
|
||||
method: "DELETE",
|
||||
route,
|
||||
parameters: {},
|
||||
emptyResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
async fetch<ReturnType, Keys extends readonly string[]>({
|
||||
route,
|
||||
parameters,
|
||||
otherParameters,
|
||||
errorMessage,
|
||||
data,
|
||||
method,
|
||||
validator,
|
||||
customValidator,
|
||||
emptyResponse,
|
||||
service,
|
||||
}: FetchParameters<Keys, ReturnType>): Promise<ReturnType> {
|
||||
const header = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
this.buildURL(route, parameters, otherParameters, service),
|
||||
{
|
||||
method: method ?? "GET",
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
headers: this.accessToken
|
||||
? {
|
||||
...header,
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
}
|
||||
: header,
|
||||
},
|
||||
);
|
||||
const jsonResponse = emptyResponse
|
||||
? undefined
|
||||
: await response.json().catch(() => {
|
||||
throw new Error("Error while parsing Server's response");
|
||||
});
|
||||
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
throw new Error(jsonResponse.message ?? errorMessage);
|
||||
case 403:
|
||||
throw new Error(
|
||||
errorMessage ?? "Unauthorized: Only for admins",
|
||||
);
|
||||
case 404:
|
||||
throw new ResourceNotFound(
|
||||
errorMessage ?? jsonResponse.message ?? response.statusText,
|
||||
);
|
||||
default:
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
errorMessage ??
|
||||
jsonResponse.message ??
|
||||
response.statusText,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (emptyResponse) {
|
||||
return {} as ReturnType;
|
||||
}
|
||||
try {
|
||||
if (customValidator) {
|
||||
return await customValidator(jsonResponse);
|
||||
}
|
||||
const validated = await validator.validate(jsonResponse);
|
||||
|
||||
return validator.cast(validated);
|
||||
} catch (err) {
|
||||
// biome-ignore lint/suspicious/noConsole: OK for debugging
|
||||
console.error(jsonResponse, err);
|
||||
throw new Error("Error: Invalid Response Type");
|
||||
}
|
||||
}
|
||||
|
||||
/// URL builders
|
||||
|
||||
/**
|
||||
* Builds the URL to fetch an illustration, from the browser/client pov
|
||||
* @param imageURL
|
||||
* @returns the correct, rerouted URL
|
||||
*/
|
||||
getIllustrationURL(imageURL: string): string {
|
||||
if (this.isDev) {
|
||||
return `${"/api"}${imageURL}`;
|
||||
}
|
||||
return `${this.urls.api.csr ?? "/api"}${imageURL}`;
|
||||
}
|
||||
|
||||
getDirectStreamURL(fileId: number): string {
|
||||
return this.buildURL(`/stream/${fileId}/direct`, {});
|
||||
}
|
||||
getTranscodeStreamURL(fileId: number, type: TrackType): string {
|
||||
if (type === "Video") {
|
||||
return this.buildURL(`/stream/${fileId}/master.m3u8`, {});
|
||||
}
|
||||
return this.buildURL(`/stream/${fileId}/audio/0/index.m3u8`, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the URL to get an archive of a given release
|
||||
* @param releaseId slug or id of the release
|
||||
* @returns the correct, rerouted URL
|
||||
*/
|
||||
getReleaseArchiveURL(releaseId: number | string): string {
|
||||
return this.buildURL(`/releases/${releaseId}/archive`, {});
|
||||
}
|
||||
|
||||
private buildURL(
|
||||
route: string,
|
||||
parameters: QueryParameters<any>,
|
||||
otherParameters?: any,
|
||||
service: Service = Service.API,
|
||||
): string {
|
||||
const apiHost = isSSR() ? this.urls.api.ssr : this.urls.api.csr;
|
||||
const scannerHost = isSSR()
|
||||
? this.urls.scanner.ssr
|
||||
: this.urls.scanner.csr;
|
||||
const host = service === Service.API ? apiHost : scannerHost;
|
||||
return `${host}${route}${this.formatQueryParameters(parameters, otherParameters)}`;
|
||||
}
|
||||
|
||||
private formatQueryParameters<Keys extends string[]>(
|
||||
parameters: QueryParameters<Keys>,
|
||||
otherParameters?: any,
|
||||
): string {
|
||||
const formattedQueryParams: string[] = [];
|
||||
|
||||
if (parameters.sort) {
|
||||
formattedQueryParams.push(`sortBy=${parameters.sort.sortBy}`);
|
||||
formattedQueryParams.push(
|
||||
`order=${parameters.sort.order ?? "asc"}`,
|
||||
);
|
||||
}
|
||||
if ((parameters.include?.length ?? 0) !== 0) {
|
||||
formattedQueryParams.push(API.formatInclude(parameters.include!)!);
|
||||
}
|
||||
if (
|
||||
parameters.pagination !== undefined &&
|
||||
parameters.pagination !== null
|
||||
) {
|
||||
formattedQueryParams.push(
|
||||
this.formatPagination(parameters.pagination),
|
||||
);
|
||||
}
|
||||
for (const otherParams in otherParameters) {
|
||||
if (otherParameters[otherParams] !== undefined) {
|
||||
formattedQueryParams.push(
|
||||
`${encodeURIComponent(otherParams)}=${encodeURIComponent(
|
||||
otherParameters[otherParams],
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (formattedQueryParams.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return `?${formattedQueryParams.join("&")}`;
|
||||
}
|
||||
|
||||
private static formatInclude(include: string[]): string | null {
|
||||
if (include.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return `with=${include.join(",")}`;
|
||||
}
|
||||
|
||||
private formatPagination(pagination: PaginationParameters): string {
|
||||
const formattedParameters: string[] = [];
|
||||
|
||||
if (pagination.afterId !== undefined) {
|
||||
formattedParameters.push(`afterId=${pagination.afterId}`);
|
||||
}
|
||||
formattedParameters.push(
|
||||
`take=${pagination.pageSize ?? this.pageSize}`,
|
||||
);
|
||||
return formattedParameters.join("&");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
/*
|
||||
* 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 type { RequireExactlyOne } from "type-fest";
|
||||
import * as yup from "yup";
|
||||
import {
|
||||
type AlbumInclude,
|
||||
type AlbumSortingKeys,
|
||||
type AlbumType,
|
||||
AlbumWithRelations,
|
||||
} from "~/models/album";
|
||||
import {
|
||||
type ArtistInclude,
|
||||
type ArtistSortingKeys,
|
||||
ArtistWithRelations,
|
||||
} from "~/models/artist";
|
||||
import {
|
||||
AlbumExternalMetadata,
|
||||
ArtistExternalMetadata,
|
||||
SongExternalMetadata,
|
||||
} from "~/models/external-metadata";
|
||||
import File from "~/models/file";
|
||||
import Genre, { type GenreSortingKeys } from "~/models/genre";
|
||||
import Label, { type LabelSortingKeys } from "~/models/label";
|
||||
import Library from "~/models/library";
|
||||
import PaginatedResponse, {
|
||||
type PaginationParameters,
|
||||
} from "~/models/pagination";
|
||||
import {
|
||||
PlaylistEntryWithRelations,
|
||||
type PlaylistInclude,
|
||||
type PlaylistSortingKeys,
|
||||
PlaylistWithRelations,
|
||||
} from "~/models/playlist";
|
||||
import {
|
||||
type ReleaseInclude,
|
||||
type ReleaseSortingKeys,
|
||||
ReleaseWithRelations,
|
||||
} from "~/models/release";
|
||||
import { ScrobblersStatus } from "~/models/scrobblers";
|
||||
import { type SearchResult, SearchResultTransformer } from "~/models/search";
|
||||
import {
|
||||
type SongInclude,
|
||||
type SongSortingKeys,
|
||||
type SongType,
|
||||
SongWithRelations,
|
||||
} from "~/models/song";
|
||||
import {
|
||||
type SongGroupSortingKeys,
|
||||
SongGroupWithRelations,
|
||||
} from "~/models/song-group";
|
||||
import {
|
||||
type TrackInclude,
|
||||
type TrackSortingKeys,
|
||||
TrackWithRelations,
|
||||
} from "~/models/track";
|
||||
import { TracklistItemWithRelations } from "~/models/tracklist";
|
||||
import User, { type UserSortingKeys } from "~/models/user";
|
||||
import {
|
||||
type VideoInclude,
|
||||
type VideoSortingKeys,
|
||||
type VideoType,
|
||||
VideoWithRelations,
|
||||
} from "~/models/video";
|
||||
import type { InfiniteQuery, Query } from "~/query";
|
||||
import { store } from "~/state/store";
|
||||
import { accessTokenAtom } from "~/state/user";
|
||||
import type { SortingParameters } from "~/utils/sorting";
|
||||
import type API from ".";
|
||||
import { Service } from ".";
|
||||
|
||||
type Identifier = number | string;
|
||||
|
||||
/// Artists
|
||||
|
||||
export const getArtists = <I extends ArtistInclude | never = never>(
|
||||
filter: {
|
||||
library?: Identifier[];
|
||||
album?: Identifier;
|
||||
genre?: Identifier;
|
||||
query?: Identifier;
|
||||
label?: Identifier;
|
||||
},
|
||||
sort?: SortingParameters<typeof ArtistSortingKeys>,
|
||||
include: I[] = [],
|
||||
): InfiniteQuery<ArtistWithRelations<I>> => {
|
||||
return _mkSimplePaginatedQuery({
|
||||
route: "/artists",
|
||||
filter: {
|
||||
albumArtistOnly: filter.album ? undefined : "true",
|
||||
...filter,
|
||||
library: formatOr(filter.library),
|
||||
},
|
||||
sort,
|
||||
include,
|
||||
validator: PaginatedResponse(ArtistWithRelations(include ?? [])),
|
||||
});
|
||||
};
|
||||
|
||||
export const getArtist = <I extends ArtistInclude | never = never>(
|
||||
slugOrId: string | number,
|
||||
include: I[] = [],
|
||||
): Query<ArtistWithRelations<I>> => {
|
||||
return _mkSimpleQuery({
|
||||
route: `/artists/${slugOrId}`,
|
||||
include,
|
||||
validator: ArtistWithRelations(include ?? []),
|
||||
});
|
||||
};
|
||||
|
||||
/// Albums
|
||||
|
||||
export const getAlbum = <I extends AlbumInclude | never = never>(
|
||||
albumSlugOrId: string | number,
|
||||
include?: I[],
|
||||
): Query<AlbumWithRelations<I>> => {
|
||||
return _mkSimpleQuery({
|
||||
route: `/albums/${albumSlugOrId}`,
|
||||
include,
|
||||
validator: AlbumWithRelations(include ?? []),
|
||||
});
|
||||
};
|
||||
|
||||
export const getAlbums = <I extends AlbumInclude | never = never>(
|
||||
filter: {
|
||||
library?: Identifier[];
|
||||
artist?: Identifier;
|
||||
genre?: Identifier;
|
||||
type?: AlbumType[];
|
||||
related?: Identifier;
|
||||
appearance?: Identifier;
|
||||
query?: Identifier;
|
||||
label?: Identifier;
|
||||
random?: number;
|
||||
},
|
||||
sort?: SortingParameters<typeof AlbumSortingKeys>,
|
||||
include?: I[],
|
||||
): InfiniteQuery<AlbumWithRelations<I>> => {
|
||||
return _mkSimplePaginatedQuery({
|
||||
route: "/albums",
|
||||
include,
|
||||
sort,
|
||||
filter: {
|
||||
...filter,
|
||||
library: formatOr(filter.library),
|
||||
type: formatOr(filter.type),
|
||||
},
|
||||
validator: PaginatedResponse(AlbumWithRelations(include ?? [])),
|
||||
});
|
||||
};
|
||||
|
||||
/// Songs
|
||||
|
||||
export const getSongs = <I extends SongInclude | never = never>(
|
||||
filter: {
|
||||
library?: Identifier[];
|
||||
type?: SongType[];
|
||||
genre?: Identifier;
|
||||
artist?: Identifier;
|
||||
versionsOf?: Identifier;
|
||||
rare?: Identifier;
|
||||
query?: string;
|
||||
bsides?: Identifier;
|
||||
random?: number;
|
||||
},
|
||||
sort?: SortingParameters<typeof SongSortingKeys>,
|
||||
include?: I[],
|
||||
): InfiniteQuery<SongWithRelations<I>> => {
|
||||
return _mkSimplePaginatedQuery({
|
||||
route: "/songs",
|
||||
filter: {
|
||||
...filter,
|
||||
library: formatOr(filter.library),
|
||||
type: formatOr(filter.type),
|
||||
},
|
||||
sort,
|
||||
include,
|
||||
validator: PaginatedResponse(SongWithRelations(include ?? [])),
|
||||
});
|
||||
};
|
||||
|
||||
export const getSong = <I extends SongInclude | never = never>(
|
||||
songSlugOrId: string | number,
|
||||
include?: I[],
|
||||
): Query<SongWithRelations<I>> => {
|
||||
return _mkSimpleQuery({
|
||||
route: `/songs/${songSlugOrId}`,
|
||||
include,
|
||||
validator: SongWithRelations(include ?? []),
|
||||
});
|
||||
};
|
||||
|
||||
export const getSongGroups = <I extends SongInclude | never = never>(
|
||||
filter: {
|
||||
library?: Identifier[];
|
||||
genre?: Identifier;
|
||||
artist?: Identifier;
|
||||
query?: string;
|
||||
type?: SongType;
|
||||
},
|
||||
sort?: SortingParameters<typeof SongGroupSortingKeys>,
|
||||
include?: I[],
|
||||
): InfiniteQuery<SongGroupWithRelations<I>> => {
|
||||
return _mkSimplePaginatedQuery({
|
||||
route: "/song-groups",
|
||||
include,
|
||||
sort,
|
||||
filter: { ...filter, library: formatOr(filter.library) },
|
||||
validator: PaginatedResponse(SongGroupWithRelations(include ?? [])),
|
||||
});
|
||||
};
|
||||
|
||||
/// Videos
|
||||
export const getVideos = <I extends VideoInclude | never = never>(
|
||||
filter: {
|
||||
library?: Identifier[];
|
||||
artist?: Identifier;
|
||||
album?: Identifier;
|
||||
song?: Identifier;
|
||||
group?: Identifier;
|
||||
random?: number;
|
||||
type?: VideoType[];
|
||||
query?: string;
|
||||
},
|
||||
sort?: SortingParameters<typeof VideoSortingKeys>,
|
||||
include?: I[],
|
||||
): InfiniteQuery<VideoWithRelations<I>> => {
|
||||
return _mkSimplePaginatedQuery({
|
||||
route: "/videos",
|
||||
include,
|
||||
sort,
|
||||
filter: {
|
||||
...filter,
|
||||
library: formatOr(filter.library),
|
||||
type: formatOr(filter.type),
|
||||
},
|
||||
validator: PaginatedResponse(VideoWithRelations(include ?? [])),
|
||||
});
|
||||
};
|
||||
|
||||
export const getVideo = <I extends VideoInclude | never = never>(
|
||||
videoSlugOrId: string | number,
|
||||
include?: I[],
|
||||
): Query<VideoWithRelations<I>> => {
|
||||
return _mkSimpleQuery({
|
||||
route: `/videos/${videoSlugOrId}`,
|
||||
include,
|
||||
validator: VideoWithRelations(include ?? []),
|
||||
});
|
||||
};
|
||||
|
||||
/// Releases
|
||||
|
||||
export const getReleases = <I extends ReleaseInclude | never = never>(
|
||||
filter: { album?: Identifier },
|
||||
sort?: SortingParameters<typeof ReleaseSortingKeys>,
|
||||
include?: I[],
|
||||
): InfiniteQuery<ReleaseWithRelations<I>> => {
|
||||
return _mkSimplePaginatedQuery({
|
||||
route: "/releases",
|
||||
filter,
|
||||
sort,
|
||||
include,
|
||||
validator: PaginatedResponse(ReleaseWithRelations(include ?? [])),
|
||||
});
|
||||
};
|
||||
|
||||
export const getMasterRelease = <I extends ReleaseInclude | never = never>(
|
||||
albumSlugOrId: string | number,
|
||||
include?: I[],
|
||||
): Query<ReleaseWithRelations<I>> => {
|
||||
return _mkSimpleQuery({
|
||||
route: `/releases/master/${albumSlugOrId}`,
|
||||
include,
|
||||
validator: ReleaseWithRelations(include ?? []),
|
||||
});
|
||||
};
|
||||
|
||||
export const getRelease = <I extends ReleaseInclude | never = never>(
|
||||
slugOrId: string | number,
|
||||
include?: I[],
|
||||
): Query<ReleaseWithRelations<I>> => {
|
||||
return _mkSimpleQuery({
|
||||
route: `/releases/${slugOrId}`,
|
||||
include,
|
||||
validator: ReleaseWithRelations(include ?? []),
|
||||
});
|
||||
};
|
||||
|
||||
/// Tracks
|
||||
|
||||
export const getTrack = <I extends TrackInclude | never = never>(
|
||||
trackId: string | number,
|
||||
include?: I[],
|
||||
): Query<TrackWithRelations<I>> => {
|
||||
return _mkSimpleQuery({
|
||||
route: `/tracks/${trackId}`,
|
||||
include,
|
||||
validator: TrackWithRelations(include ?? []),
|
||||
});
|
||||
};
|
||||
|
||||
export const getTracks = <I extends TrackInclude | never = never>(
|
||||
filter: { song?: string | number; library?: Identifier[] },
|
||||
sort?: SortingParameters<typeof TrackSortingKeys>,
|
||||
include?: I[],
|
||||
): InfiniteQuery<TrackWithRelations<I>> => {
|
||||
return _mkSimplePaginatedQuery({
|
||||
route: "/tracks",
|
||||
include,
|
||||
sort,
|
||||
filter: { ...filter, library: formatOr(filter.library) },
|
||||
validator: PaginatedResponse(TrackWithRelations(include ?? [])),
|
||||
});
|
||||
};
|
||||
|
||||
export const getReleaseTracklist = <I extends SongInclude | never = never>(
|
||||
slugOrId: string | number,
|
||||
exclusiveOnly = false,
|
||||
include?: I[],
|
||||
): InfiniteQuery<TracklistItemWithRelations<I>> => {
|
||||
return _mkSimplePaginatedQuery({
|
||||
route: `/releases/${slugOrId.toString()}/tracklist`,
|
||||
include,
|
||||
filter: { exclusive: exclusiveOnly ? "true" : undefined },
|
||||
validator: PaginatedResponse(TracklistItemWithRelations(include ?? [])),
|
||||
});
|
||||
};
|
||||
|
||||
export const getSongMasterTrack = <I extends TrackInclude | never = never>(
|
||||
songSlugOrId: string | number,
|
||||
include?: I[],
|
||||
): Query<TrackWithRelations<I>> => {
|
||||
return _mkSimpleQuery({
|
||||
route: `/tracks/master/song/${songSlugOrId}`,
|
||||
include,
|
||||
validator: TrackWithRelations(include ?? []),
|
||||
});
|
||||
};
|
||||
|
||||
/// Libraries
|
||||
|
||||
export const getLibraries = (): InfiniteQuery<Library> => {
|
||||
return _mkSimplePaginatedQuery({
|
||||
route: "/libraries",
|
||||
validator: PaginatedResponse(Library),
|
||||
});
|
||||
};
|
||||
|
||||
/// Playlists
|
||||
|
||||
export const getPlaylists = <I extends PlaylistInclude | never = never>(
|
||||
filter: { album?: Identifier; changeable?: true },
|
||||
sort?: SortingParameters<typeof PlaylistSortingKeys>,
|
||||
include?: I[],
|
||||
): InfiniteQuery<PlaylistWithRelations<I>> => {
|
||||
return _mkSimplePaginatedQuery({
|
||||
route: "/playlists",
|
||||
sort,
|
||||
include,
|
||||
filter: {
|
||||
...filter,
|
||||
changeable: filter.changeable ? "true" : undefined,
|
||||
},
|
||||
validator: PaginatedResponse(PlaylistWithRelations(include ?? [])),
|
||||
});
|
||||
};
|
||||
|
||||
export const getPlaylist = <I extends PlaylistInclude | never = never>(
|
||||
playlistSlugOrId: string | number,
|
||||
include?: I[],
|
||||
): Query<PlaylistWithRelations<I>> => {
|
||||
return _mkSimpleQuery({
|
||||
route: `/playlists/${playlistSlugOrId}`,
|
||||
include,
|
||||
validator: PlaylistWithRelations(include ?? []),
|
||||
});
|
||||
};
|
||||
|
||||
export const getPlaylistEntries = <I extends SongInclude | never = never>(
|
||||
playlistSlugOrId: string | number,
|
||||
include?: I[],
|
||||
): InfiniteQuery<PlaylistEntryWithRelations<I>> => {
|
||||
return _mkSimplePaginatedQuery({
|
||||
route: `/playlists/${playlistSlugOrId}/entries`,
|
||||
include,
|
||||
validator: PaginatedResponse(PlaylistEntryWithRelations(include ?? [])),
|
||||
});
|
||||
};
|
||||
|
||||
/// Genres
|
||||
|
||||
export const getGenres = (
|
||||
filter: { artist?: Identifier; album?: Identifier; song?: Identifier },
|
||||
sort?: SortingParameters<typeof GenreSortingKeys>,
|
||||
): InfiniteQuery<Genre> => {
|
||||
return _mkSimplePaginatedQuery({
|
||||
route: "/genres",
|
||||
sort,
|
||||
filter,
|
||||
validator: PaginatedResponse(Genre),
|
||||
});
|
||||
};
|
||||
|
||||
export const getGenre = (idOrSlug: string | number): Query<Genre> => {
|
||||
return _mkSimpleQuery({
|
||||
route: `/genres/${idOrSlug}`,
|
||||
validator: Genre,
|
||||
});
|
||||
};
|
||||
|
||||
/// Labels
|
||||
export const getLabel = (identifier: string | number): Query<Label> => {
|
||||
return _mkSimpleQuery({
|
||||
route: `/labels/${identifier}`,
|
||||
validator: Label,
|
||||
});
|
||||
};
|
||||
|
||||
export const getLabels = (
|
||||
filter: { artist?: Identifier; album?: Identifier },
|
||||
sort?: SortingParameters<typeof LabelSortingKeys>,
|
||||
): InfiniteQuery<Label> => {
|
||||
return _mkSimplePaginatedQuery({
|
||||
route: "/labels",
|
||||
filter,
|
||||
sort,
|
||||
validator: PaginatedResponse(Label),
|
||||
});
|
||||
};
|
||||
|
||||
/// External Metadata
|
||||
|
||||
export const getArtistExternalMetadata = (slugOrId: string | number) =>
|
||||
getResourceExternalMetadata<ArtistExternalMetadata>(
|
||||
slugOrId,
|
||||
"artist",
|
||||
ArtistExternalMetadata,
|
||||
);
|
||||
|
||||
export const getSongExternalMetadata = (slugOrId: string | number) =>
|
||||
getResourceExternalMetadata<SongExternalMetadata>(
|
||||
slugOrId,
|
||||
"song",
|
||||
SongExternalMetadata,
|
||||
);
|
||||
|
||||
export const getAlbumExternalMetadata = (slugOrId: string | number) =>
|
||||
getResourceExternalMetadata<AlbumExternalMetadata>(
|
||||
slugOrId,
|
||||
"album",
|
||||
AlbumExternalMetadata,
|
||||
);
|
||||
|
||||
const getResourceExternalMetadata = <
|
||||
T,
|
||||
V extends yup.Schema<T> = yup.Schema<T>,
|
||||
>(
|
||||
resourceSlugOrId: string | number,
|
||||
resourceType: "artist" | "album" | "song",
|
||||
validator: V,
|
||||
): Query<T | null> => {
|
||||
const query = _mkSimpleQuery({
|
||||
route: "/external-metadata",
|
||||
params: { [resourceType]: resourceSlugOrId },
|
||||
validator,
|
||||
});
|
||||
return {
|
||||
...query,
|
||||
exec: (api) => () =>
|
||||
query
|
||||
.exec(api)()
|
||||
.catch(() => null),
|
||||
};
|
||||
};
|
||||
|
||||
/// File
|
||||
|
||||
export const getSourceFile = (sourceFileId: string | number): Query<File> => {
|
||||
return _mkSimpleQuery({
|
||||
route: `/files/${sourceFileId}`,
|
||||
validator: File,
|
||||
});
|
||||
};
|
||||
|
||||
/// Search
|
||||
|
||||
export const searchAll = (query: string): Query<SearchResult[]> => {
|
||||
return _mkSimpleQuery({
|
||||
route: "/search",
|
||||
params: { query },
|
||||
customValidator: SearchResultTransformer,
|
||||
});
|
||||
};
|
||||
|
||||
export const getSearchHistory = (): Query<SearchResult[]> => {
|
||||
return _mkSimpleQuery({
|
||||
route: "/search/history",
|
||||
customValidator: SearchResultTransformer,
|
||||
});
|
||||
};
|
||||
|
||||
/// Users
|
||||
|
||||
export const getCurrentUserStatus = (): Query<User> => {
|
||||
const accessToken = store.get(accessTokenAtom);
|
||||
|
||||
return {
|
||||
key: ["user", accessToken ?? ""],
|
||||
exec: (api) => () =>
|
||||
api.fetch({
|
||||
route: "/users/me",
|
||||
parameters: {},
|
||||
validator: User,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const getUsers = (
|
||||
sort?: SortingParameters<typeof UserSortingKeys>,
|
||||
): InfiniteQuery<User> => {
|
||||
return _mkSimplePaginatedQuery({
|
||||
route: "/users",
|
||||
sort,
|
||||
validator: PaginatedResponse(User),
|
||||
});
|
||||
};
|
||||
|
||||
/// Tasks
|
||||
|
||||
export const getTasks = () => {
|
||||
return _mkSimpleQuery({
|
||||
route: "/tasks",
|
||||
validator: yup.object({
|
||||
current_task: yup.string().required().nullable(),
|
||||
progress: yup.number().required().nullable(),
|
||||
pending_tasks: yup.array(yup.string().required()).required(),
|
||||
}),
|
||||
service: Service.Scanner,
|
||||
});
|
||||
};
|
||||
|
||||
/// Scrobblers
|
||||
|
||||
export const getScrobblerStatus = (): Query<ScrobblersStatus> => {
|
||||
return _mkSimpleQuery({
|
||||
route: "/scrobblers",
|
||||
validator: ScrobblersStatus,
|
||||
});
|
||||
};
|
||||
|
||||
export const _mkSimpleQuery = <T>(
|
||||
arg: {
|
||||
route: string;
|
||||
include?: string[];
|
||||
params?: Record<string, string | number>;
|
||||
service?: Service;
|
||||
errorMessage?: string;
|
||||
} & RequireExactlyOne<{
|
||||
emptyResponse: true;
|
||||
validator: yup.Schema<T>;
|
||||
customValidator: (value: unknown) => Promise<T>;
|
||||
}>,
|
||||
) => {
|
||||
const key = [
|
||||
...arg.route.split("/").filter((t) => t.length > 0),
|
||||
...formatIncludeKeys(arg.include),
|
||||
...formatObject(arg.params),
|
||||
];
|
||||
|
||||
return {
|
||||
key,
|
||||
exec: (api: API) => () =>
|
||||
api.fetch({
|
||||
route: arg.route,
|
||||
errorMessage: arg.errorMessage,
|
||||
parameters: { include: arg.include },
|
||||
otherParameters: arg.params,
|
||||
service: arg.service ?? Service.API,
|
||||
...(arg.validator
|
||||
? { validator: arg.validator }
|
||||
: arg.customValidator
|
||||
? { customValidator: arg.customValidator }
|
||||
: { emptyResponse: true }),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const _mkSimplePaginatedQuery = <T>(
|
||||
arg: {
|
||||
route: string;
|
||||
include?: string[];
|
||||
filter?: Record<
|
||||
string,
|
||||
undefined | string | number | (string | number)[]
|
||||
>;
|
||||
sort?: SortingParameters<any>;
|
||||
service?: Service;
|
||||
errorMessage?: string;
|
||||
} & RequireExactlyOne<{
|
||||
emptyResponse: true;
|
||||
validator: yup.Schema<T>;
|
||||
customValidator: (value: unknown) => Promise<T>;
|
||||
}>,
|
||||
) => {
|
||||
const key = [
|
||||
...arg.route.split("/").filter((t) => t.length > 0),
|
||||
...formatIncludeKeys(arg.include),
|
||||
...formatObject(arg.sort),
|
||||
...formatObject(arg.filter),
|
||||
];
|
||||
|
||||
return {
|
||||
key,
|
||||
exec: (api: API) => (pagination?: PaginationParameters) => {
|
||||
if (pagination === undefined) {
|
||||
pagination = { pageSize: api.pageSize };
|
||||
}
|
||||
return api.fetch({
|
||||
route: arg.route,
|
||||
errorMessage: arg.errorMessage,
|
||||
parameters: {
|
||||
include: arg.include,
|
||||
pagination,
|
||||
sort: arg.sort,
|
||||
},
|
||||
otherParameters: arg.filter,
|
||||
service: arg.service ?? Service.API,
|
||||
...(arg.validator
|
||||
? { validator: arg.validator }
|
||||
: arg.customValidator
|
||||
? { customValidator: arg.customValidator }
|
||||
: { emptyResponse: true }),
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Format Includes for the query key
|
||||
const formatIncludeKeys = (includes?: string[]) =>
|
||||
includes?.map((include) => `include-${include}`) ?? [];
|
||||
|
||||
// format any object for the query key
|
||||
const formatObject = (includes?: object) =>
|
||||
includes
|
||||
? Object.entries(includes)
|
||||
.filter(
|
||||
([_, value]) =>
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
value !== "view" &&
|
||||
(Array.isArray(value) ? value.length > 0 : true),
|
||||
)
|
||||
.map(([key, value]) => `params-${key}-${value}`)
|
||||
: [];
|
||||
|
||||
const formatOr = (
|
||||
items: (string | number)[] | undefined,
|
||||
): string | undefined => {
|
||||
if (!items || items.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (items.length === 1) {
|
||||
return items[0].toString();
|
||||
}
|
||||
return `or:${items.map((i) => i.toString()).join(",")}`;
|
||||
};
|
||||
@@ -1,242 +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/>.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-restricted-imports */
|
||||
|
||||
import {
|
||||
type QueryFunctionContext,
|
||||
useInfiniteQuery as useReactInfiniteQuery,
|
||||
useQueries as useReactQueries,
|
||||
useQuery as useReactQuery,
|
||||
useQueryClient as useReactQueryClient,
|
||||
} from "react-query";
|
||||
import type {
|
||||
InfiniteFetchFn,
|
||||
Page,
|
||||
} from "../components/infinite/infinite-scroll";
|
||||
import type Resource from "../models/resource";
|
||||
import API from "./api";
|
||||
|
||||
type Key = string | number;
|
||||
|
||||
//// Query types
|
||||
|
||||
export type Query<Type> = {
|
||||
key: Key[];
|
||||
// @warning If you call this method 'by hand', please consider using a query client instead
|
||||
exec: () => Promise<Type>;
|
||||
};
|
||||
|
||||
export type InfiniteQuery<Type> = {
|
||||
key: Key[];
|
||||
exec: InfiniteFetchFn<Type>;
|
||||
};
|
||||
//// Query functions
|
||||
|
||||
export type MeeloQueryFn<
|
||||
QueryReturnType = unknown,
|
||||
Params extends any[] = any[],
|
||||
> = (...args: Params) => Query<QueryReturnType>;
|
||||
|
||||
export type MeeloInfiniteQueryFn<
|
||||
QueryReturnType = unknown,
|
||||
Params extends any[] = any[],
|
||||
> = (...args: Params) => InfiniteQuery<QueryReturnType>;
|
||||
|
||||
export const DefaultMeeloQueryOptions = {
|
||||
useErrorBoundary: true,
|
||||
retry: 2,
|
||||
staleTime: 5 * (60 * 1000),
|
||||
cacheTime: 10 * (60 * 1000),
|
||||
refetchOnMount: true,
|
||||
// We want this in dev, not in prod to avoid useless refecthes.
|
||||
refetchOnWindowFocus: process.env.NODE_ENV !== "production",
|
||||
refetchOnReconnect: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for 'react-query''s useQuery's parameters, to manage dependent queries more easily
|
||||
* @param query the function that prepare the query
|
||||
* @param queryArgs the arguments to pass the the query function. If one of them is undefined, the query will not be enabled
|
||||
* @returns
|
||||
*/
|
||||
const prepareMeeloQuery = <
|
||||
QueryReturnType = unknown,
|
||||
Params extends any[] = unknown[],
|
||||
>(
|
||||
query: MeeloQueryFn<QueryReturnType, Params>,
|
||||
...queryArgs: Partial<Params>
|
||||
) => {
|
||||
const enabled = isEnabled(queryArgs);
|
||||
const queryParams = query(...(queryArgs as Parameters<typeof query>));
|
||||
|
||||
return {
|
||||
queryKey: queryParams.key,
|
||||
queryFn: queryParams.exec,
|
||||
enabled: enabled,
|
||||
...DefaultMeeloQueryOptions,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Using the query parameters, defines if a query should be enabled or not
|
||||
* @param args
|
||||
* @returns
|
||||
*/
|
||||
const isEnabled = (args: any[]) => {
|
||||
return args.findIndex((elem) => elem === undefined || elem === null) === -1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for 'react-query''s useInfiniteQuery's parameters, to manage dependent queries more easily
|
||||
* @param query the function that prepare the query
|
||||
* @param queryArgs the arguments to pass the the query function. If one of them is undefined, the query will not be enabled
|
||||
* @returns
|
||||
*/
|
||||
const prepareMeeloInfiniteQuery = <
|
||||
QueryReturnType extends Resource = Resource,
|
||||
Params extends any[] = unknown[],
|
||||
>(
|
||||
query: MeeloInfiniteQueryFn<QueryReturnType, Params>,
|
||||
...queryArgs: Partial<Params>
|
||||
) => {
|
||||
const enabled = isEnabled(queryArgs);
|
||||
const queryParams = query(...(queryArgs as Params));
|
||||
|
||||
return {
|
||||
queryKey: queryParams.key,
|
||||
queryFn: (context: QueryFunctionContext) =>
|
||||
queryParams
|
||||
.exec(
|
||||
context.pageParam ?? {
|
||||
index: 0,
|
||||
pageSize: API.defaultPageSize,
|
||||
},
|
||||
)
|
||||
.then(
|
||||
(result): Page<QueryReturnType> => ({
|
||||
pageSize: result.items.length,
|
||||
items: result.items,
|
||||
afterId: result.items.at(-1)?.id ?? null,
|
||||
end: result.metadata.next === null,
|
||||
}),
|
||||
),
|
||||
enabled: enabled,
|
||||
...DefaultMeeloQueryOptions,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for the react-query's *useQuery*
|
||||
*/
|
||||
const useQuery = <ReturnType, Params extends any[]>(
|
||||
query: MeeloQueryFn<ReturnType, Params>,
|
||||
...queryParams: Partial<Params>
|
||||
) => {
|
||||
return useReactQuery(prepareMeeloQuery(query, ...queryParams));
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for the react-query's *useQueries*
|
||||
*/
|
||||
const useQueries = <ReturnType, Params extends any[][]>(
|
||||
...queries: Parameters<typeof useQuery<ReturnType, Params[number]>>[]
|
||||
) => {
|
||||
return useReactQueries(
|
||||
queries.map(([query, ...params]) =>
|
||||
prepareMeeloQuery(query, ...(params as Params[number])),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for the react-query's *useInfiniteQuery*
|
||||
*/
|
||||
const useInfiniteQuery = <ReturnType extends Resource, Params extends any[]>(
|
||||
query: MeeloInfiniteQueryFn<ReturnType, Params>,
|
||||
...queryParams: Partial<Params>
|
||||
) => {
|
||||
const pageSize = API.defaultPageSize;
|
||||
|
||||
return useReactInfiniteQuery({
|
||||
...prepareMeeloInfiniteQuery(query, ...queryParams),
|
||||
getNextPageParam: (
|
||||
lastPage: Page<ReturnType>,
|
||||
): Page<ReturnType> | undefined => {
|
||||
if (lastPage.end || lastPage.items.length < pageSize) {
|
||||
return undefined;
|
||||
}
|
||||
return lastPage;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper of the useQueryClient Hook, to wrap `prepareMeeloQuery`
|
||||
*/
|
||||
const useQueryClient = () => {
|
||||
const queryClient = useReactQueryClient();
|
||||
|
||||
return {
|
||||
client: queryClient,
|
||||
fetchQuery: <R, Params extends any[]>(
|
||||
query: ReturnType<MeeloQueryFn<R, Params>>,
|
||||
) => queryClient.fetchQuery(prepareMeeloQuery(() => query)),
|
||||
};
|
||||
};
|
||||
|
||||
export const toInfiniteQuery = <T>(q: Query<T[]>): InfiniteQuery<T> => {
|
||||
return {
|
||||
key: q.key,
|
||||
exec: () =>
|
||||
q.exec().then((res) => ({
|
||||
items: res,
|
||||
metadata: {
|
||||
next: null,
|
||||
page: 0,
|
||||
previous: null,
|
||||
this: "",
|
||||
},
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export const transformPage = <To, From>(
|
||||
q: InfiniteQuery<From>,
|
||||
transformer: (item: From, index: number) => To,
|
||||
): InfiniteQuery<To> => {
|
||||
return {
|
||||
key: q.key,
|
||||
exec: (p) =>
|
||||
q.exec(p).then((res) => ({
|
||||
items: res.items.map(transformer),
|
||||
metadata: res.metadata,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export type QueryClient = ReturnType<typeof useQueryClient>;
|
||||
|
||||
export {
|
||||
useQuery,
|
||||
useQueries,
|
||||
useInfiniteQuery,
|
||||
useQueryClient,
|
||||
prepareMeeloQuery,
|
||||
prepareMeeloInfiniteQuery,
|
||||
};
|
||||
@@ -16,14 +16,14 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import store from "../../state/store";
|
||||
import { unsetAccessToken } from "../../state/userSlice";
|
||||
import { LogoutIcon } from "../icons";
|
||||
import type Action from "./action";
|
||||
import { LogoutIcon } from "~/components/icons";
|
||||
import { store } from "~/state/store";
|
||||
import { accessTokenAtom } from "~/state/user";
|
||||
import type Action from "./";
|
||||
|
||||
export const LogoutAction: Action = {
|
||||
label: "logout",
|
||||
label: "actions.logout",
|
||||
icon: <LogoutIcon />,
|
||||
href: "/",
|
||||
onClick: () => store.dispatch(unsetAccessToken()),
|
||||
onClick: () => store.set(accessTokenAtom, undefined),
|
||||
};
|
||||
@@ -17,56 +17,60 @@
|
||||
*/
|
||||
|
||||
import type { useConfirm } from "material-ui-confirm";
|
||||
import API from "../../api/api";
|
||||
import type { Translator } from "../../i18n/i18n";
|
||||
import confirmDownloadAction from "../confirm-download-action";
|
||||
import { ArchiveIcon, DownloadIcon } from "../icons";
|
||||
import type Action from "./action";
|
||||
import type API from "~/api";
|
||||
import confirmDownloadAction from "~/components/confirm-download-action";
|
||||
import { ArchiveIcon, DownloadIcon } from "~/components/icons";
|
||||
import type { Translator } from "~/i18n/i18n";
|
||||
import type Action from "./";
|
||||
|
||||
export const DownloadAction = (
|
||||
api: API,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
sourceFileId: number,
|
||||
t: Translator,
|
||||
): Action => ({
|
||||
icon: <DownloadIcon />,
|
||||
label: "download",
|
||||
label: "actions.download.label",
|
||||
onClick: () =>
|
||||
confirmDownloadAction(confirm, API.getDirectStreamURL(sourceFileId), t),
|
||||
confirmDownloadAction(confirm, api.getDirectStreamURL(sourceFileId), t),
|
||||
});
|
||||
|
||||
export const DownloadAsyncAction = (
|
||||
api: API,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
sourceFileId: () => PromiseLike<number>,
|
||||
t: Translator,
|
||||
): Action => ({
|
||||
icon: <DownloadIcon />,
|
||||
label: "download",
|
||||
label: "actions.download.label",
|
||||
onClick: () =>
|
||||
sourceFileId().then((id) =>
|
||||
confirmDownloadAction(confirm, API.getDirectStreamURL(id), t),
|
||||
confirmDownloadAction(confirm, api.getDirectStreamURL(id), t),
|
||||
),
|
||||
});
|
||||
|
||||
export const DownloadReleaseAction = (
|
||||
api: API,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
releaseId: number | string,
|
||||
t: Translator,
|
||||
): Action => ({
|
||||
icon: <ArchiveIcon />,
|
||||
label: "archive",
|
||||
label: "actions.archive",
|
||||
onClick: () =>
|
||||
confirmDownloadAction(confirm, API.getReleaseArchiveURL(releaseId), t),
|
||||
confirmDownloadAction(confirm, api.getReleaseArchiveURL(releaseId), t),
|
||||
});
|
||||
|
||||
export const DownloadReleaseAsyncAction = (
|
||||
api: API,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
releaseId: () => PromiseLike<number | string>,
|
||||
t: Translator,
|
||||
): Action => ({
|
||||
icon: <ArchiveIcon />,
|
||||
label: "archive",
|
||||
label: "actions.archive",
|
||||
onClick: () =>
|
||||
releaseId().then((id) =>
|
||||
confirmDownloadAction(confirm, API.getReleaseArchiveURL(id), t),
|
||||
confirmDownloadAction(confirm, api.getReleaseArchiveURL(id), t),
|
||||
),
|
||||
});
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
import type { RequireExactlyOne } from "type-fest";
|
||||
import type { TranslationKey } from "../../i18n/i18n";
|
||||
import type { TranslationKey } from "~/i18n/i18n";
|
||||
|
||||
/**
|
||||
* Props for a generic component to run an action/go to page
|
||||
@@ -17,10 +17,10 @@
|
||||
*/
|
||||
|
||||
import toast from "react-hot-toast";
|
||||
import API from "../../api/api";
|
||||
import type { TaskResponse } from "../../models/task";
|
||||
import { CleanIcon, ScanIcon } from "../icons";
|
||||
import type Action from "./action";
|
||||
import type API from "~/api";
|
||||
import { CleanIcon, ScanIcon } from "~/components/icons";
|
||||
import type { TaskResponse } from "~/models/task";
|
||||
import type Action from "./";
|
||||
|
||||
/**
|
||||
* Using the resolved value of the task porimise, triggers an appropriate toast
|
||||
@@ -33,30 +33,32 @@ const handleTask = <T extends TaskResponse>(task: Promise<T>) =>
|
||||
toast.error(s.message ?? s.status ?? "Task request failed"),
|
||||
);
|
||||
|
||||
export const ScanAllLibrariesAction: Action = {
|
||||
label: "scanLibraries",
|
||||
export const ScanAllLibrariesAction = (api: API): Action => ({
|
||||
label: "tasks.scanLibraries",
|
||||
icon: <ScanIcon />,
|
||||
onClick: () => handleTask(API.scanLibraries()),
|
||||
};
|
||||
onClick: () => handleTask(api.scanLibraries()),
|
||||
});
|
||||
|
||||
export const ScanLibraryAction = (
|
||||
api: API,
|
||||
librarySlugOrId: number | string,
|
||||
): Action => ({
|
||||
label: "scan",
|
||||
icon: ScanAllLibrariesAction.icon,
|
||||
onClick: () => handleTask(API.scanLibrary(librarySlugOrId)),
|
||||
label: "tasks.scan",
|
||||
icon: <ScanIcon />,
|
||||
onClick: () => handleTask(api.scanLibrary(librarySlugOrId)),
|
||||
});
|
||||
|
||||
export const CleanAllLibrariesAction: Action = {
|
||||
label: "cleanLibraries",
|
||||
export const CleanAllLibrariesAction = (api: API): Action => ({
|
||||
label: "tasks.cleanLibraries",
|
||||
icon: <CleanIcon />,
|
||||
onClick: () => handleTask(API.cleanLibraries()),
|
||||
};
|
||||
onClick: () => handleTask(api.cleanLibraries()),
|
||||
});
|
||||
|
||||
export const CleanLibraryAction = (
|
||||
api: API,
|
||||
librarySlugOrId: number | string,
|
||||
): Action => ({
|
||||
label: "clean",
|
||||
icon: CleanAllLibrariesAction.icon,
|
||||
onClick: () => handleTask(API.cleanLibrary(librarySlugOrId)),
|
||||
label: "tasks.clean",
|
||||
icon: <CleanIcon />,
|
||||
onClick: () => handleTask(api.cleanLibrary(librarySlugOrId)),
|
||||
});
|
||||
@@ -21,27 +21,36 @@ import toast from "react-hot-toast";
|
||||
import {
|
||||
AlbumIcon,
|
||||
ArtistIcon,
|
||||
InfoIcon,
|
||||
LyricsIcon,
|
||||
RelatedTracksIcon,
|
||||
SearchIcon,
|
||||
SettingsIcon,
|
||||
SongIcon,
|
||||
} from "../icons";
|
||||
import type Action from "./action";
|
||||
} from "~/components/icons";
|
||||
import type Action from "./";
|
||||
|
||||
export const GoToSongLyricsAction = (
|
||||
songIdentifier: string | number,
|
||||
): Action => ({
|
||||
href: `/songs/${songIdentifier}/lyrics`,
|
||||
label: "seeLyrics",
|
||||
label: "actions.song.seeLyrics",
|
||||
icon: <LyricsIcon />,
|
||||
});
|
||||
|
||||
export const GoToSongInfoAction = (
|
||||
songIdentifier: string | number,
|
||||
): Action => ({
|
||||
href: `/songs/${songIdentifier}/info`,
|
||||
label: "actions.song.seeSongInfo",
|
||||
icon: <InfoIcon />,
|
||||
});
|
||||
|
||||
export const GoToArtistAction = (
|
||||
artistIdentifier: string | number,
|
||||
): Action => ({
|
||||
href: `/artists/${artistIdentifier}`,
|
||||
label: "goToArtist",
|
||||
label: "actions.goToArtist",
|
||||
icon: <ArtistIcon />,
|
||||
});
|
||||
|
||||
@@ -49,7 +58,7 @@ export const GoToArtistAlbumsAction = (
|
||||
artistIdentifier: string | number,
|
||||
): Action => ({
|
||||
href: `/artists/${artistIdentifier}/albums`,
|
||||
label: "seeAlbums",
|
||||
label: "actions.artist.seeAlbums",
|
||||
icon: <AlbumIcon />,
|
||||
});
|
||||
|
||||
@@ -57,13 +66,13 @@ export const GoToArtistSongsAction = (
|
||||
artistIdentifier: string | number,
|
||||
): Action => ({
|
||||
href: `/artists/${artistIdentifier}/songs`,
|
||||
label: "seeSongs",
|
||||
label: "actions.artist.seeSongs",
|
||||
icon: <SongIcon />,
|
||||
});
|
||||
|
||||
export const GoToAlbumAction = (albumIdentifier: string | number): Action => ({
|
||||
href: `/albums/${albumIdentifier}`,
|
||||
label: "goToAlbum",
|
||||
label: "actions.album.goToAlbum",
|
||||
icon: <AlbumIcon />,
|
||||
});
|
||||
|
||||
@@ -71,7 +80,7 @@ export const GoToReleaseAction = (
|
||||
releaseIdentifier: string | number,
|
||||
): Action => ({
|
||||
href: `/releases/${releaseIdentifier}`,
|
||||
label: "goToAlbum",
|
||||
label: "actions.album.goToAlbum",
|
||||
icon: <AlbumIcon />,
|
||||
});
|
||||
|
||||
@@ -87,7 +96,7 @@ export const GoToReleaseAsyncAction = (
|
||||
router.push(`/releases/${id}`);
|
||||
}
|
||||
}),
|
||||
label: "goToAlbum",
|
||||
label: "actions.album.goToAlbum",
|
||||
icon: <AlbumIcon />,
|
||||
});
|
||||
|
||||
@@ -95,7 +104,7 @@ export const GoToSongVersionAction = (
|
||||
songIdentifier: string | number,
|
||||
): Action => ({
|
||||
href: `/songs/${songIdentifier}/versions`,
|
||||
label: "seeOtherVersions",
|
||||
label: "actions.song.seeOtherVersions",
|
||||
icon: <AlbumIcon />,
|
||||
});
|
||||
|
||||
@@ -103,18 +112,18 @@ export const GoToRelatedTracksAction = (
|
||||
songIdentifier: string | number,
|
||||
): Action => ({
|
||||
href: `/songs/${songIdentifier}/tracks`,
|
||||
label: "seeRelatedTracks",
|
||||
label: "actions.song.seeRelatedTracks",
|
||||
icon: <RelatedTracksIcon />,
|
||||
});
|
||||
|
||||
export const GoToSearchAction = {
|
||||
label: "search",
|
||||
label: "nav.search",
|
||||
icon: <SearchIcon />,
|
||||
href: "/search",
|
||||
} as const;
|
||||
|
||||
export const GoToSettingsAction: Action = {
|
||||
label: "settings",
|
||||
label: "actions.goToSettingsPage",
|
||||
icon: <SettingsIcon />,
|
||||
href: "/settings",
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* 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 {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@mui/material";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "react-query";
|
||||
import type { QueryClient } from "~/api/hook";
|
||||
import { getSongs } from "~/api/queries";
|
||||
import type { Song, SongWithRelations } from "~/models/song";
|
||||
import type { TrackWithRelations } from "~/models/track";
|
||||
import type { InfiniteQueryFn } from "~/query";
|
||||
import type Action from ".";
|
||||
import { MergeIcon, SongIcon } from "../icons";
|
||||
import Illustration from "../illustration";
|
||||
import InfiniteList from "../infinite/list";
|
||||
import ListItem from "../list-item";
|
||||
|
||||
export const MergeSongAction = (
|
||||
song: Song,
|
||||
queryClient: QueryClient,
|
||||
): Action => ({
|
||||
icon: <MergeIcon />,
|
||||
label: "actions.song.mergeSong.label",
|
||||
dialog: ({ close }) => {
|
||||
const [selectedSongId, selectSong] = useState<number>();
|
||||
const closeModal = () => selectSong(undefined);
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const mutation = useMutation(async (destSongId: number) => {
|
||||
await toast.promise(
|
||||
queryClient.api.mergeSongs(song.id, destSongId),
|
||||
{
|
||||
loading: t("misc.loading"),
|
||||
success: "Merging successfull",
|
||||
error: "error",
|
||||
},
|
||||
);
|
||||
router.replace(`/songs/${destSongId}/tracks`);
|
||||
await queryClient.client.invalidateQueries("songs");
|
||||
await queryClient.client.invalidateQueries(song.id.toString());
|
||||
await queryClient.client.invalidateQueries(
|
||||
song.artistId.toString(),
|
||||
);
|
||||
await queryClient.client.invalidateQueries(song.slug);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={selectedSongId !== undefined}
|
||||
onClose={closeModal}
|
||||
fullWidth
|
||||
sx={{ zIndex: 999999 }}
|
||||
>
|
||||
<DialogTitle>{t("form.confirm")} ?</DialogTitle>
|
||||
<DialogContent>
|
||||
{t("actions.song.mergeSong.warningBeforeMerging")}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={closeModal} variant="outlined">
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
mutation.mutate(selectedSongId!);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{t("form.confirm")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<SelectSongForm
|
||||
onClose={close}
|
||||
onSelect={(songId) => {
|
||||
selectSong(songId);
|
||||
}}
|
||||
songQuery={() =>
|
||||
getSongs(
|
||||
{ artist: song.artistId },
|
||||
{ sortBy: "name" },
|
||||
["illustration", "artist"],
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const ReassignTrackAction = (
|
||||
track: TrackWithRelations<"song"> & { song: Song },
|
||||
queryClient: QueryClient,
|
||||
): Action => ({
|
||||
icon: <MergeIcon />,
|
||||
label: "actions.track.reassignTrack",
|
||||
dialog: ({ close }) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const mutation = useMutation(async (destSongId: number) => {
|
||||
await toast.promise(
|
||||
queryClient.api.updateTrack(track.id, destSongId),
|
||||
{
|
||||
loading: t("misc.loading"),
|
||||
success: "Merging successfull",
|
||||
error: "error",
|
||||
},
|
||||
);
|
||||
router.replace(`/songs/${destSongId}/tracks`);
|
||||
await queryClient.client.invalidateQueries("songs");
|
||||
await queryClient.client.invalidateQueries("tracks");
|
||||
await queryClient.client.invalidateQueries(track.id.toString());
|
||||
await queryClient.client.invalidateQueries(
|
||||
track.song.id.toString(),
|
||||
);
|
||||
await queryClient.client.invalidateQueries(track.song.slug);
|
||||
});
|
||||
|
||||
return (
|
||||
<SelectSongForm
|
||||
onClose={close}
|
||||
onSelect={(songId) => {
|
||||
mutation.mutate(songId);
|
||||
close();
|
||||
}}
|
||||
songQuery={() =>
|
||||
getSongs(
|
||||
{ artist: track.song?.artistId },
|
||||
{ sortBy: "name" },
|
||||
["illustration", "artist"],
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// TODO it's a bit too similar to 'SelectPlaylistForm'
|
||||
|
||||
type SelectSongFormProps = {
|
||||
songQuery: InfiniteQueryFn<SongWithRelations<"illustration" | "artist">>;
|
||||
onSelect: (songId: number) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const SelectSongForm = (props: SelectSongFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>{t("actions.song.mergeSong.modalTitle")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<InfiniteList
|
||||
query={props.songQuery}
|
||||
render={(item, _, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
title={item?.name}
|
||||
icon={
|
||||
<Illustration
|
||||
illustration={item?.illustration}
|
||||
fallback={<SongIcon />}
|
||||
quality="low"
|
||||
/>
|
||||
}
|
||||
secondTitle={item?.artist?.name}
|
||||
onClick={
|
||||
item &&
|
||||
(() => {
|
||||
props.onSelect(item.id);
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="outlined">
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -18,74 +18,91 @@
|
||||
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
Grid,
|
||||
} from "@mui/material";
|
||||
import { Add, Edit } from "iconsax-react";
|
||||
import type { useConfirm } from "material-ui-confirm";
|
||||
import { HookTextField, useHookForm } from "mui-react-hook-form-plus";
|
||||
import {
|
||||
HookCheckBox,
|
||||
HookTextField,
|
||||
useHookForm,
|
||||
} from "mui-react-hook-form-plus";
|
||||
import { useState } from "react";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "react-query";
|
||||
import API from "../../api/api";
|
||||
import type { MeeloInfiniteQueryFn, QueryClient } from "../../api/use-query";
|
||||
import type { PlayerActions, TrackState } from "../../contexts/player";
|
||||
import type Playlist from "../../models/playlist";
|
||||
import type { PlaylistWithRelations } from "../../models/playlist";
|
||||
import { type QueryClient, useQueryClient } from "~/api/hook";
|
||||
import { getPlaylists } from "~/api/queries";
|
||||
import {
|
||||
AddItemToPlaylistIcon,
|
||||
AddToPlaylistIcon,
|
||||
DeleteIcon,
|
||||
PlayAfterIcon,
|
||||
PlayNextIcon,
|
||||
} from "../icons";
|
||||
import Illustration from "../illustration";
|
||||
import InfiniteList from "../infinite/infinite-list";
|
||||
import ListItem from "../list-item/item";
|
||||
import { WideLoadingComponent } from "../loading/loading";
|
||||
import type Action from "./action";
|
||||
} from "~/components/icons";
|
||||
import Illustration from "~/components/illustration";
|
||||
import InfiniteList from "~/components/infinite/list";
|
||||
import ListItem from "~/components/list-item";
|
||||
import type Playlist from "~/models/playlist";
|
||||
import type {
|
||||
CreatePlaylistDto,
|
||||
PlaylistWithRelations,
|
||||
UpdatePlaylistDto,
|
||||
} from "~/models/playlist";
|
||||
import type { InfiniteQueryFn } from "~/query";
|
||||
import { type TrackState, playAfterAtom, playNextAtom } from "~/state/player";
|
||||
import { store } from "~/state/store";
|
||||
import type Action from "./";
|
||||
|
||||
export const PlayNextAction = (
|
||||
getTrack: () => PromiseLike<TrackState>,
|
||||
playNext: PlayerActions["playNext"],
|
||||
): Action => ({
|
||||
onClick: () =>
|
||||
getTrack().then((track) => {
|
||||
playNext(track);
|
||||
store.set(playNextAtom, track);
|
||||
toast.success(`'${track.track.name}' will play next!`);
|
||||
}),
|
||||
label: "playNext",
|
||||
label: "actions.playback.playNext",
|
||||
icon: <PlayNextIcon />,
|
||||
});
|
||||
|
||||
export const PlayAfterAction = (
|
||||
getTrack: () => PromiseLike<TrackState>,
|
||||
playAfter: PlayerActions["playAfter"],
|
||||
): Action => ({
|
||||
onClick: () =>
|
||||
getTrack().then((track) => {
|
||||
playAfter(track);
|
||||
store.set(playAfterAtom, track);
|
||||
toast.success(`'${track.track.name}' will play after!`);
|
||||
}),
|
||||
label: "playAfter",
|
||||
label: "actions.playback.playAfter",
|
||||
icon: <PlayAfterIcon />,
|
||||
});
|
||||
|
||||
type CreateOrUpdatePlaylistFormProps = {
|
||||
onSubmit: (newUrl: string) => void;
|
||||
onSubmit: (dto: CreatePlaylistDto) => void;
|
||||
onClose: () => void;
|
||||
defaultValue?: string;
|
||||
defaultValue?: CreatePlaylistDto;
|
||||
};
|
||||
|
||||
const CreateOrUpdatePlaylistForm = (props: CreateOrUpdatePlaylistFormProps) => {
|
||||
const defaultValues = { name: props.defaultValue ?? "" };
|
||||
const { registerState, handleSubmit } = useHookForm({
|
||||
const defaultValues: CreatePlaylistDto = {
|
||||
name: props.defaultValue?.name ?? "",
|
||||
isPublic: props.defaultValue?.isPublic ?? true,
|
||||
allowChanges: props.defaultValue?.allowChanges ?? false,
|
||||
};
|
||||
const { registerState, handleSubmit, control, setValue } = useHookForm({
|
||||
defaultValues,
|
||||
});
|
||||
const isPublic = useWatch({ control, name: "isPublic" });
|
||||
const { t } = useTranslation();
|
||||
const onSubmit = (values: typeof defaultValues) => {
|
||||
props.onSubmit(values.name);
|
||||
props.onSubmit(values);
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
@@ -106,7 +123,6 @@ const CreateOrUpdatePlaylistForm = (props: CreateOrUpdatePlaylistFormProps) => {
|
||||
fullWidth: true,
|
||||
label: "Enter name of the playlist",
|
||||
}}
|
||||
gridProps={{}}
|
||||
rules={{
|
||||
required: {
|
||||
value: true,
|
||||
@@ -114,9 +130,47 @@ const CreateOrUpdatePlaylistForm = (props: CreateOrUpdatePlaylistFormProps) => {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Grid container sx={{ paddingTop: 2 }}>
|
||||
<HookCheckBox
|
||||
{...registerState("isPublic")}
|
||||
checkBoxProps={{
|
||||
autoFocus: false,
|
||||
onChange: (_, checked) => {
|
||||
if (!checked) {
|
||||
setValue("allowChanges", false);
|
||||
}
|
||||
},
|
||||
}}
|
||||
formControlLabelProps={{
|
||||
label: t("form.playlist.playlistIsPublic"),
|
||||
}}
|
||||
gridProps={{
|
||||
size: {
|
||||
xs: 12,
|
||||
md: 6,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<HookCheckBox
|
||||
{...registerState("allowChanges")}
|
||||
checkBoxProps={{
|
||||
autoFocus: false,
|
||||
disabled: !isPublic,
|
||||
}}
|
||||
formControlLabelProps={{
|
||||
label: t("form.playlist.allowPlaylistChanges"),
|
||||
}}
|
||||
gridProps={{
|
||||
size: {
|
||||
xs: 12,
|
||||
md: 6,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose}>{t("cancel")}</Button>
|
||||
<Button onClick={props.onClose}>{t("form.cancel")}</Button>
|
||||
<Button type="submit" color="primary" variant="contained">
|
||||
{props.defaultValue ? "Update" : "Create"}
|
||||
</Button>
|
||||
@@ -126,15 +180,20 @@ const CreateOrUpdatePlaylistForm = (props: CreateOrUpdatePlaylistFormProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const CreatePlaylistAction = (queryClient: QueryClient): Action => ({
|
||||
label: "new",
|
||||
export const CreatePlaylistAction = (
|
||||
queryClient: QueryClient,
|
||||
onCreated?: (playlistId: number) => void,
|
||||
): Action => ({
|
||||
label: "actions.new",
|
||||
icon: <Add />,
|
||||
dialog: ({ close }) => {
|
||||
const mutation = useMutation((playlistName: string) => {
|
||||
return API.createPlaylist(playlistName)
|
||||
.then(() => {
|
||||
const mutation = useMutation((formFields: CreatePlaylistDto) => {
|
||||
return queryClient.api
|
||||
.createPlaylist(formFields)
|
||||
.then((playlist) => {
|
||||
toast.success("Playlist created!");
|
||||
queryClient.client.invalidateQueries("playlists");
|
||||
onCreated?.(playlist.id);
|
||||
})
|
||||
.catch((error: Error) => toast.error(error.message));
|
||||
});
|
||||
@@ -152,11 +211,12 @@ export const UpdatePlaylistAction = (
|
||||
playlist: Playlist,
|
||||
queryClient: QueryClient,
|
||||
): Action => ({
|
||||
label: "update",
|
||||
label: "actions.update",
|
||||
icon: <Edit />,
|
||||
dialog: ({ close }) => {
|
||||
const mutation = useMutation((playlistName: string) => {
|
||||
return API.updatePlaylist(playlistName, playlist.slug)
|
||||
const mutation = useMutation((dto: UpdatePlaylistDto) => {
|
||||
return queryClient.api
|
||||
.updatePlaylist(playlist.slug, dto)
|
||||
.then(() => {
|
||||
toast.success("Playlist updated!");
|
||||
queryClient.client.invalidateQueries("playlists");
|
||||
@@ -169,7 +229,7 @@ export const UpdatePlaylistAction = (
|
||||
<CreateOrUpdatePlaylistForm
|
||||
onClose={close}
|
||||
onSubmit={(name) => mutation.mutate(name)}
|
||||
defaultValue={playlist.name}
|
||||
defaultValue={playlist}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -181,7 +241,7 @@ export const DeletePlaylistAction = (
|
||||
librarySlugOrId: number | string,
|
||||
onDeleted: () => void,
|
||||
): Action => ({
|
||||
label: "delete",
|
||||
label: "actions.delete",
|
||||
icon: <DeleteIcon />,
|
||||
onClick: () =>
|
||||
confirm({
|
||||
@@ -193,7 +253,8 @@ export const DeletePlaylistAction = (
|
||||
variant: "outlined",
|
||||
color: "error",
|
||||
onClickCapture: () =>
|
||||
API.deletePlaylist(librarySlugOrId)
|
||||
queryClient.api
|
||||
.deletePlaylist(librarySlugOrId)
|
||||
.then(() => {
|
||||
onDeleted();
|
||||
queryClient.client.invalidateQueries("playlist");
|
||||
@@ -206,22 +267,46 @@ export const DeletePlaylistAction = (
|
||||
});
|
||||
|
||||
type SelectPlaylistFormProps = {
|
||||
playlistQuery: MeeloInfiniteQueryFn<PlaylistWithRelations<"illustration">>;
|
||||
playlistQuery: InfiniteQueryFn<PlaylistWithRelations<"illustration">>;
|
||||
onSubmit: (playlistId: number) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const SelectPlaylistForm = (props: SelectPlaylistFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const createPlaylistAction = CreatePlaylistAction(queryClient);
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
const closeModal = () => setOpenModal(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>Select a playlist</DialogTitle>
|
||||
<Dialog
|
||||
open={openModal}
|
||||
onClose={closeModal}
|
||||
fullWidth
|
||||
sx={{ zIndex: 999999 }}
|
||||
>
|
||||
{createPlaylistAction.dialog?.({ close: closeModal })}
|
||||
</Dialog>
|
||||
<DialogTitle>{t("actions.addToPlaylist.modalTitle")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<ListItem
|
||||
title={t(createPlaylistAction.label!)}
|
||||
icon={
|
||||
<Illustration
|
||||
illustration={null}
|
||||
fallback={createPlaylistAction.icon}
|
||||
quality="low"
|
||||
/>
|
||||
}
|
||||
onClick={() => setOpenModal(true)}
|
||||
secondTitle={null}
|
||||
/>
|
||||
<Divider sx={{ paddingTop: 1 }} />
|
||||
<InfiniteList
|
||||
loader={() => <WideLoadingComponent />}
|
||||
query={props.playlistQuery}
|
||||
render={(item, index) => (
|
||||
render={(item, _, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
title={item?.name}
|
||||
@@ -246,7 +331,7 @@ const SelectPlaylistForm = (props: SelectPlaylistFormProps) => {
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="outlined">
|
||||
{t("cancel")}
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
@@ -258,10 +343,11 @@ export const AddToPlaylistAction = (
|
||||
queryClient: QueryClient,
|
||||
): Action => ({
|
||||
icon: <AddToPlaylistIcon />,
|
||||
label: "addToPlaylist",
|
||||
label: "actions.addToPlaylist.label",
|
||||
dialog: ({ close }) => {
|
||||
const mutation = useMutation((playlistId: number) => {
|
||||
return API.addSongToPlaylist(songId, playlistId)
|
||||
return queryClient.api
|
||||
.addSongToPlaylist(songId, playlistId)
|
||||
.then(() => {
|
||||
toast.success("Song added to Playlist");
|
||||
queryClient.client.invalidateQueries("playlists");
|
||||
@@ -275,8 +361,8 @@ export const AddToPlaylistAction = (
|
||||
onClose={close}
|
||||
onSubmit={(playlistId) => mutation.mutate(playlistId)}
|
||||
playlistQuery={() =>
|
||||
API.getPlaylists(
|
||||
{},
|
||||
getPlaylists(
|
||||
{ changeable: true },
|
||||
{ sortBy: "creationDate", order: "desc" },
|
||||
["illustration"],
|
||||
)
|
||||
|
||||
@@ -16,22 +16,72 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Button, Checkbox, Grid } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import API from "../../api/api";
|
||||
import type { Translator } from "../../i18n/i18n";
|
||||
import { MetadataRefreshIcon } from "../icons";
|
||||
import type Action from "./action";
|
||||
import type { ArraySlice } from "type-fest";
|
||||
import type API from "~/api";
|
||||
import { useAPI } from "~/api/hook";
|
||||
import { MetadataRefreshIcon } from "~/components/icons";
|
||||
import type { Translator } from "~/i18n/i18n";
|
||||
import type Action from "./";
|
||||
|
||||
type APIMethodParams = ArraySlice<Parameters<API["refreshMetadata"]>, 0, -1>;
|
||||
|
||||
const RefreshMetadataActionContent = ({
|
||||
t,
|
||||
params,
|
||||
close,
|
||||
}: { t: Translator; params: APIMethodParams; close: () => void }) => {
|
||||
const [force, setForce] = useState(false);
|
||||
const api = useAPI();
|
||||
|
||||
return (
|
||||
<Grid container spacing={2} sx={{ padding: 2 }}>
|
||||
<Grid
|
||||
size={{ xs: 12 }}
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={force} onClick={() => setForce((f) => !f)} />
|
||||
{t("tasks.refreshMetadataForm.forceLabel")}
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
api.refreshMetadata(...[...params, force])
|
||||
.then(() =>
|
||||
toast.success(
|
||||
t("toasts.refreshMetadata.started"),
|
||||
),
|
||||
)
|
||||
.catch(() =>
|
||||
toast.error(t("toasts.refreshMetadata.failed")),
|
||||
);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{t("tasks.refreshMetadata")}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
const RefreshMetadataAction = (
|
||||
t: Translator,
|
||||
...params: Parameters<typeof API.refreshMetadata>
|
||||
...params: APIMethodParams
|
||||
): Action => ({
|
||||
label: "refreshMetadata",
|
||||
label: "tasks.refreshMetadata",
|
||||
icon: <MetadataRefreshIcon />,
|
||||
onClick: () =>
|
||||
API.refreshMetadata(...params)
|
||||
.then(() => toast.success(t("refreshMetadataStarted")))
|
||||
.catch(() => toast.error(t("refreshMetadataFailed"))),
|
||||
dialog: ({ close }) => (
|
||||
<RefreshMetadataActionContent t={t} params={params} close={close} />
|
||||
),
|
||||
});
|
||||
|
||||
export const RefreshLibraryMetadataAction = (
|
||||
|
||||
@@ -21,22 +21,24 @@ import type { useConfirm } from "material-ui-confirm";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import API from "../../api/api";
|
||||
import type { QueryClient } from "../../api/use-query";
|
||||
import type { TranslationKey } from "../../i18n/i18n";
|
||||
import type Album from "../../models/album";
|
||||
import { AlbumType } from "../../models/album";
|
||||
import type Song from "../../models/song";
|
||||
import { SongType } from "../../models/song";
|
||||
import type Video from "../../models/video";
|
||||
import { VideoType } from "../../models/video";
|
||||
import store from "../../state/store";
|
||||
import { EditIcon } from "../icons";
|
||||
import type Action from "./action";
|
||||
import type { QueryClient } from "~/api/hook";
|
||||
import { EditIcon } from "~/components/icons";
|
||||
import type { TranslationKey } from "~/i18n/i18n";
|
||||
import type Album from "~/models/album";
|
||||
import { AlbumType } from "~/models/album";
|
||||
import type Song from "~/models/song";
|
||||
import { SongType } from "~/models/song";
|
||||
import type Video from "~/models/video";
|
||||
import { VideoType } from "~/models/video";
|
||||
import { store } from "~/state/store";
|
||||
import { userAtom } from "~/state/user";
|
||||
import { uncapitalize } from "~/utils/uncapitalize";
|
||||
import type Action from "./";
|
||||
|
||||
const ResourceTypeForm = <Enum extends TranslationKey>(props: {
|
||||
const ResourceTypeForm = <Enum extends string>(props: {
|
||||
defaultValue?: Enum;
|
||||
values: readonly Enum[];
|
||||
translate: (s: Enum) => TranslationKey;
|
||||
onSelect: (type: Enum) => void;
|
||||
}) => {
|
||||
const [currentType, setType] = useState(props.defaultValue);
|
||||
@@ -46,9 +48,9 @@ const ResourceTypeForm = <Enum extends TranslationKey>(props: {
|
||||
<>
|
||||
<Grid container spacing={2} justifyContent="center">
|
||||
{props.values.map((type) => (
|
||||
<Grid item key={type}>
|
||||
<Grid key={type}>
|
||||
<Chip
|
||||
label={t(type) as string}
|
||||
label={t(props.translate(type))}
|
||||
variant={
|
||||
type === currentType ? "filled" : "outlined"
|
||||
}
|
||||
@@ -66,10 +68,11 @@ const ResourceTypeForm = <Enum extends TranslationKey>(props: {
|
||||
|
||||
const ChangeResourceType = <
|
||||
T extends { type: TypeEnum },
|
||||
TypeEnum extends TranslationKey,
|
||||
TypeEnum extends string,
|
||||
>(
|
||||
resource: T,
|
||||
types: readonly TypeEnum[],
|
||||
translateType: (s: TypeEnum) => TranslationKey,
|
||||
label: TranslationKey,
|
||||
queryClient: QueryClient,
|
||||
onSelect: (newType: TypeEnum) => Promise<void>,
|
||||
@@ -78,7 +81,7 @@ const ChangeResourceType = <
|
||||
return {
|
||||
label: label,
|
||||
icon: <EditIcon />,
|
||||
disabled: store.getState().user.user?.admin !== true,
|
||||
disabled: store.get(userAtom)?.admin !== true,
|
||||
onClick: () =>
|
||||
confirm({
|
||||
title: <br />,
|
||||
@@ -86,10 +89,11 @@ const ChangeResourceType = <
|
||||
<ResourceTypeForm
|
||||
defaultValue={resource.type}
|
||||
values={types}
|
||||
translate={translateType}
|
||||
onSelect={(type) =>
|
||||
toast
|
||||
.promise(onSelect(type), {
|
||||
loading: null,
|
||||
loading: "Updating...",
|
||||
success: "Update successful!",
|
||||
error: "Update failed...",
|
||||
})
|
||||
@@ -127,10 +131,11 @@ const ChangeSongType = (
|
||||
ChangeResourceType(
|
||||
s,
|
||||
SongType.filter((t) => t !== "Unknown"),
|
||||
"changeSongType",
|
||||
(type) => `songType.${uncapitalize(type)}`,
|
||||
"actions.song.changeType",
|
||||
client,
|
||||
(newType: SongType) =>
|
||||
API.updateSong(s.id, { type: newType }).then((res) => {
|
||||
client.api.updateSong(s.id, { type: newType }).then((res) => {
|
||||
client.client.invalidateQueries("songs");
|
||||
client.client.invalidateQueries("release");
|
||||
client.client.invalidateQueries("tracks");
|
||||
@@ -149,10 +154,11 @@ const ChangeAlbumType = (
|
||||
ChangeResourceType(
|
||||
a,
|
||||
AlbumType,
|
||||
"changeAlbumType",
|
||||
(type) => `albumType.${uncapitalize(type)}`,
|
||||
"actions.album.changeType",
|
||||
client,
|
||||
(newType: AlbumType) =>
|
||||
API.updateAlbum(a.id, { type: newType }).then((res) => {
|
||||
client.api.updateAlbum(a.id, { type: newType }).then((res) => {
|
||||
client.client.invalidateQueries("albums");
|
||||
return res;
|
||||
}),
|
||||
@@ -167,10 +173,11 @@ const ChangeVideoType = (
|
||||
ChangeResourceType(
|
||||
v,
|
||||
VideoType,
|
||||
"changeVideoType",
|
||||
(type) => `videoType.${uncapitalize(type)}`,
|
||||
"actions.video.changeType",
|
||||
client,
|
||||
(newType: VideoType) =>
|
||||
API.updateVideo(v.id, { type: newType }).then((res) => {
|
||||
client.api.updateVideo(v.id, { type: newType }).then((res) => {
|
||||
client.client.invalidateQueries("videos");
|
||||
return res;
|
||||
}),
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Translator } from "../../i18n/i18n";
|
||||
import copyLinkToClipboard from "../../utils/copy-link";
|
||||
import { ShareIcon } from "../icons";
|
||||
import type Action from "./action";
|
||||
import { ShareIcon } from "~/components/icons";
|
||||
import type { Translator } from "~/i18n/i18n";
|
||||
import copyLinkToClipboard from "~/utils/copy-link";
|
||||
import type Action from "./";
|
||||
|
||||
export const ShareAction = (url: string, t: Translator): Action => ({
|
||||
onClick: () => copyLinkToClipboard(url, t),
|
||||
label: "share",
|
||||
label: "actions.share",
|
||||
icon: <ShareIcon />,
|
||||
});
|
||||
|
||||
|
||||
@@ -17,18 +17,18 @@
|
||||
*/
|
||||
|
||||
import type { useConfirm } from "material-ui-confirm";
|
||||
import API from "../../api/api";
|
||||
import type { QueryClient } from "../../api/use-query";
|
||||
import { InfoIcon } from "../icons";
|
||||
import { openTrackFileInfoModal } from "../track-file-info";
|
||||
import type Action from "./action";
|
||||
import type { QueryClient } from "~/api/hook";
|
||||
import { getSongMasterTrack } from "~/api/queries";
|
||||
import { InfoIcon } from "~/components/icons";
|
||||
import { openTrackFileInfoModal } from "~/components/track-file-info";
|
||||
import type Action from "./";
|
||||
|
||||
export const ShowTrackFileInfoAction = (
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
trackId: number,
|
||||
): Action => ({
|
||||
icon: <InfoIcon />,
|
||||
label: "moreInfo",
|
||||
label: "actions.track.seeTrackInfo",
|
||||
onClick: () => openTrackFileInfoModal(confirm, trackId),
|
||||
});
|
||||
|
||||
@@ -38,9 +38,9 @@ export const ShowMasterTrackFileInfoAction = (
|
||||
songId: number,
|
||||
): Action => ({
|
||||
icon: <InfoIcon />,
|
||||
label: "moreInfo",
|
||||
label: "actions.track.seeTrackInfo",
|
||||
onClick: () =>
|
||||
queryClient
|
||||
.fetchQuery(API.getSongMasterTrack(songId))
|
||||
.fetchQuery(getSongMasterTrack(songId))
|
||||
.then((track) => openTrackFileInfoModal(confirm, track.id)),
|
||||
});
|
||||
@@ -26,11 +26,11 @@ import { HookTextField, useHookForm } from "mui-react-hook-form-plus";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "react-query";
|
||||
import API from "../../api/api";
|
||||
import type { QueryClient } from "../../api/use-query";
|
||||
import store from "../../state/store";
|
||||
import { UpdateIllustrationIcon } from "../icons";
|
||||
import type Action from "./action";
|
||||
import type { QueryClient } from "~/api/hook";
|
||||
import { UpdateIllustrationIcon } from "~/components/icons";
|
||||
import { store } from "~/state/store";
|
||||
import { userAtom } from "~/state/user";
|
||||
import type Action from "./";
|
||||
|
||||
type IllustrationUpdateFormType = {
|
||||
onSubmit: (newUrl: string) => void;
|
||||
@@ -73,8 +73,8 @@ const IllustrationUpdateForm = (props: IllustrationUpdateFormType) => {
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose}>{t("cancel")}</Button>
|
||||
<Button type="submit">{t("update")}</Button>
|
||||
<Button onClick={props.onClose}>{t("form.cancel")}</Button>
|
||||
<Button type="submit">{t("actions.update")}</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</>
|
||||
@@ -89,14 +89,15 @@ const UpdateIllustrationAction = (
|
||||
const mutation = useMutation(async (newUrl: string) => {
|
||||
const updator =
|
||||
resourceType === "artist"
|
||||
? API.updateArtistIllustration
|
||||
? queryClient.api.updateArtistIllustration
|
||||
: resourceType === "release"
|
||||
? API.updateReleaseIllustration
|
||||
? queryClient.api.updateReleaseIllustration
|
||||
: resourceType === "playlist"
|
||||
? API.updatePlaylistIllustration
|
||||
: API.updateTrackIllustration;
|
||||
? queryClient.api.updatePlaylistIllustration
|
||||
: queryClient.api.updateTrackIllustration;
|
||||
|
||||
return updator(resourceId, newUrl)
|
||||
return updator
|
||||
.call(queryClient.api, resourceId, newUrl)
|
||||
.then(() => {
|
||||
toast.success("Illustration updated!");
|
||||
queryClient.client.invalidateQueries(resourceType);
|
||||
@@ -106,8 +107,8 @@ const UpdateIllustrationAction = (
|
||||
});
|
||||
|
||||
return {
|
||||
label: "changeIllutration",
|
||||
disabled: store.getState().user.user?.admin !== true,
|
||||
label: "actions.changeIllutration",
|
||||
disabled: store.get(userAtom)?.admin !== true,
|
||||
icon: <UpdateIllustrationIcon />,
|
||||
dialog: (controls) => (
|
||||
<IllustrationUpdateForm
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
|
||||
import { DataGrid, type GridColDef } from "@mui/x-data-grid";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import API from "../api/api";
|
||||
import { type MeeloInfiniteQueryFn, useInfiniteQuery } from "../api/use-query";
|
||||
import type Resource from "../models/resource";
|
||||
import { useAPI, useInfiniteQuery } from "~/api/hook";
|
||||
import type Resource from "~/models/resource";
|
||||
import type { InfiniteQueryFn } from "~/query";
|
||||
|
||||
type AdminGridProps<DataType extends Resource> = {
|
||||
infiniteQuery: MeeloInfiniteQueryFn<DataType>;
|
||||
infiniteQuery: InfiniteQueryFn<DataType>;
|
||||
columns: GridColDef<DataType>[];
|
||||
};
|
||||
|
||||
@@ -36,19 +36,15 @@ const AdminGrid = <DataType extends Resource>({
|
||||
}: AdminGridProps<DataType>) => {
|
||||
const {
|
||||
data,
|
||||
items,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
fetchNextPage,
|
||||
fetchPreviousPage,
|
||||
} = useInfiniteQuery(infiniteQuery);
|
||||
const api = useAPI();
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const itemsCount = useMemo(
|
||||
() =>
|
||||
data?.pages
|
||||
.map((page) => page.items.length)
|
||||
.reduce((pageSize, sum) => pageSize + sum, 0) ?? 0,
|
||||
[data?.pages],
|
||||
);
|
||||
const itemsCount = useMemo(() => items?.length, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
hasNextPage && fetchNextPage();
|
||||
@@ -58,15 +54,16 @@ const AdminGrid = <DataType extends Resource>({
|
||||
loading={data?.pages.at(currentPage) === undefined}
|
||||
rows={data?.pages[currentPage]?.items ?? []}
|
||||
rowCount={itemsCount}
|
||||
pageSize={API.defaultPageSize}
|
||||
rowsPerPageOptions={[API.defaultPageSize]}
|
||||
page={currentPage}
|
||||
paginationModel={{
|
||||
pageSize: api.pageSize,
|
||||
page: currentPage,
|
||||
}}
|
||||
pageSizeOptions={[api.pageSize]}
|
||||
disableColumnSelector
|
||||
disableRowSelectionOnClick
|
||||
disableColumnMenu
|
||||
disableSelectionOnClick
|
||||
paginationMode="server"
|
||||
autoHeight
|
||||
onPageChange={(page) => {
|
||||
onPaginationModelChange={({ page }) => {
|
||||
if (page === currentPage + 1 && hasNextPage) {
|
||||
fetchNextPage();
|
||||
} else if (page === currentPage - 1 && hasPreviousPage) {
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
|
||||
import { Avatar } from "@mui/material";
|
||||
import type { ComponentProps } from "react";
|
||||
import type IllustrationModel from "../models/illustration";
|
||||
import type IllustrationModel from "~/models/illustration";
|
||||
import { ArtistIcon } from "./icons";
|
||||
import Illustration from "./illustration";
|
||||
|
||||
const ArtistAvatar = (props: {
|
||||
@@ -39,6 +40,7 @@ const ArtistAvatar = (props: {
|
||||
imgProps={{ objectFit: "cover" }}
|
||||
illustration={props.illustration}
|
||||
quality={props.quality}
|
||||
fallback={<ArtistIcon />}
|
||||
/>
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
@@ -16,24 +16,25 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Button, Divider, Grid } from "@mui/material";
|
||||
import { Box, Button, Divider, Stack } from "@mui/material";
|
||||
import { useAtom } from "jotai";
|
||||
import { HookTextField, useHookForm } from "mui-react-hook-form-plus";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch } from "react-redux";
|
||||
import API from "../../api/api";
|
||||
import { setAccessToken } from "../../state/userSlice";
|
||||
import { useAPI } from "~/api/hook";
|
||||
import { accessTokenAtom } from "~/state/user";
|
||||
|
||||
/**
|
||||
* Authentication form
|
||||
* On successful authentication, update store with access token
|
||||
*/
|
||||
const AuthenticationForm = () => {
|
||||
const dispatch = useDispatch();
|
||||
const api = useAPI();
|
||||
const [formType, setFormType] = useState<"login" | "signup">("login");
|
||||
const defaultValues = { username: "", password: "", confirm: "" };
|
||||
const [password, setPassword] = useState(defaultValues.password);
|
||||
const [_, setAccessToken] = useAtom(accessTokenAtom);
|
||||
const { registerState, handleSubmit } = useHookForm({
|
||||
defaultValues,
|
||||
});
|
||||
@@ -42,15 +43,15 @@ const AuthenticationForm = () => {
|
||||
const onSubmit = async (values: typeof defaultValues) => {
|
||||
try {
|
||||
if (formType === "signup") {
|
||||
const createdUser = await API.register(values);
|
||||
const createdUser = await api.register(values);
|
||||
|
||||
if (!createdUser.enabled) {
|
||||
setFormType("login");
|
||||
toast.success(t("accountCreated"));
|
||||
toast.success(t("toasts.auth.accountCreated"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
dispatch(setAccessToken((await API.login(values)).access_token));
|
||||
setAccessToken((await api.login(values)).access_token);
|
||||
} catch (error: any) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
@@ -61,9 +62,7 @@ const AuthenticationForm = () => {
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
>
|
||||
<Grid
|
||||
container
|
||||
direction="column"
|
||||
<Stack
|
||||
spacing={3}
|
||||
sx={{
|
||||
display: "flex",
|
||||
@@ -77,24 +76,24 @@ const AuthenticationForm = () => {
|
||||
<HookTextField
|
||||
{...registerState("username")}
|
||||
textFieldProps={{
|
||||
label: t("username"),
|
||||
label: t("form.auth.username"),
|
||||
}}
|
||||
gridProps={{}}
|
||||
rules={{
|
||||
required: {
|
||||
value: true,
|
||||
message: t("usernameIsRequired"),
|
||||
message: t("form.auth.usernameIsRequired"),
|
||||
},
|
||||
minLength: {
|
||||
value: 4,
|
||||
message: t("usernameTooShort"),
|
||||
message: t("form.auth.usernameTooShort"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<HookTextField
|
||||
{...registerState("password")}
|
||||
textFieldProps={{
|
||||
label: t("password"),
|
||||
label: t("form.auth.password"),
|
||||
type: "password",
|
||||
onChange: (event) => setPassword(event.target.value),
|
||||
}}
|
||||
@@ -102,11 +101,11 @@ const AuthenticationForm = () => {
|
||||
rules={{
|
||||
required: {
|
||||
value: true,
|
||||
message: t("passwordIsRequired"),
|
||||
message: t("form.auth.passwordIsRequired"),
|
||||
},
|
||||
minLength: {
|
||||
value: 6,
|
||||
message: t("passwordTooShort"),
|
||||
message: t("form.auth.passwordTooShort"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
@@ -114,24 +113,24 @@ const AuthenticationForm = () => {
|
||||
<HookTextField
|
||||
{...registerState("confirm")}
|
||||
textFieldProps={{
|
||||
label: t("confirmPassword"),
|
||||
label: t("form.confirm"),
|
||||
type: "password",
|
||||
}}
|
||||
gridProps={{}}
|
||||
rules={{
|
||||
required: {
|
||||
value: true,
|
||||
message: t("pleaseConfirm"),
|
||||
message: t("form.auth.pleaseConfirm"),
|
||||
},
|
||||
validate: (confirmValue) => {
|
||||
if (confirmValue !== password) {
|
||||
return t("passwordsAreDifferent");
|
||||
return t("form.auth.passwordsAreDifferent");
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Grid item>
|
||||
<Box>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
@@ -139,9 +138,9 @@ const AuthenticationForm = () => {
|
||||
>
|
||||
{formType === "login" ? "Login" : "Signup"}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Divider sx={{ width: "100%", paddingY: 1 }} variant="middle" />
|
||||
<Grid item>
|
||||
</Box>
|
||||
<Divider sx={{ width: "100%" }} variant="middle" />
|
||||
<Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() =>
|
||||
@@ -152,12 +151,12 @@ const AuthenticationForm = () => {
|
||||
>
|
||||
{t(
|
||||
formType === "login"
|
||||
? "signupButton"
|
||||
: "signinButton",
|
||||
? "auth.signup"
|
||||
: "auth.signin",
|
||||
)}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -16,28 +16,26 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Grid } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Box, Stack } from "@mui/material";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery as useReactQuery } from "react-query";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import API from "../../api/api";
|
||||
import { prepareMeeloQuery } from "../../api/use-query";
|
||||
import type { RootState } from "../../state/store";
|
||||
import { setUserProfile } from "../../state/userSlice";
|
||||
import ModalPage from "../modal-page";
|
||||
import ThemedImage from "../themed-image";
|
||||
import AuthenticationForm from "./authentication-form";
|
||||
import { getAPI_ } from "~/api/hook";
|
||||
import { getCurrentUserStatus } from "~/api/queries";
|
||||
import ModalPage from "~/components/modal-page";
|
||||
import ThemedImage from "~/components/themed-image";
|
||||
import { toTanStackQuery } from "~/query";
|
||||
import { accessTokenAtom, userAtom } from "~/state/user";
|
||||
import AuthenticationForm from "./form";
|
||||
|
||||
const AuthenticationWall = (props: { children: any }) => {
|
||||
const accessToken = useSelector(
|
||||
(store: RootState) => store.user.accessToken,
|
||||
);
|
||||
const [accessToken] = useAtom(accessTokenAtom);
|
||||
const [_, setUser] = useAtom(userAtom);
|
||||
const api = useMemo(() => getAPI_(accessToken ?? null), [accessToken]);
|
||||
const status = useReactQuery({
|
||||
...prepareMeeloQuery(API.getCurrentUserStatus),
|
||||
...toTanStackQuery(api, getCurrentUserStatus),
|
||||
useErrorBoundary: false,
|
||||
});
|
||||
const dispatch = useDispatch();
|
||||
const [authentified, setAuthenticationStatus] = useState(
|
||||
status.data?.id !== undefined,
|
||||
);
|
||||
@@ -49,33 +47,29 @@ const AuthenticationWall = (props: { children: any }) => {
|
||||
if (accessToken && status.data?.id && !status.error) {
|
||||
setAuthenticationStatus(true);
|
||||
}
|
||||
if (status.error || accessToken?.valueOf() === undefined) {
|
||||
if (status.error || accessToken === undefined) {
|
||||
setAuthenticationStatus(false);
|
||||
}
|
||||
}, [accessToken, status, authentified]);
|
||||
useEffect(() => {
|
||||
if (accessToken && status.data && !status.error) {
|
||||
dispatch(setUserProfile(status.data));
|
||||
setUser(status.data);
|
||||
}
|
||||
}, [accessToken, status, dispatch]);
|
||||
}, [accessToken, status]);
|
||||
if (!authentified || !status.data?.id) {
|
||||
return (
|
||||
<ModalPage open>
|
||||
<Grid
|
||||
container
|
||||
direction="column"
|
||||
<Stack
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexWrap: "nowrap",
|
||||
justifyContent: "center",
|
||||
justifyContent: "space-evenly",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Grid
|
||||
xs={2}
|
||||
item
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
@@ -84,19 +78,19 @@ const AuthenticationWall = (props: { children: any }) => {
|
||||
}}
|
||||
>
|
||||
<ThemedImage
|
||||
light={"/banner-black.png"}
|
||||
dark={"/banner.png"}
|
||||
light={"/banner1-black.png"}
|
||||
dark={"/banner1-white.png"}
|
||||
alt="title"
|
||||
width={200}
|
||||
height={150}
|
||||
priority
|
||||
style={{ objectFit: "contain" }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<AuthenticationForm />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Stack>
|
||||
</ModalPage>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
import { Box } from "@mui/material";
|
||||
import { type ComponentProps, useEffect, useMemo, useState } from "react";
|
||||
import { Blurhash as RBlurhash } from "react-blurhash";
|
||||
import { blurHashToDataURL } from "../utils/blurhashToDataUrl";
|
||||
import { blurHashToDataURL } from "~/utils/blurhashToDataUrl";
|
||||
|
||||
type BlurhashProps = ComponentProps<typeof Box> & {
|
||||
blurhash?: string;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
import type { useConfirm } from "material-ui-confirm";
|
||||
import type { Translator } from "../i18n/i18n";
|
||||
import type { Translator } from "~/i18n/i18n";
|
||||
|
||||
const confirmDownloadAction = (
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
@@ -25,9 +25,9 @@ const confirmDownloadAction = (
|
||||
t: Translator,
|
||||
) => {
|
||||
confirm({
|
||||
title: t("warning"),
|
||||
description: t("downloadWarning"),
|
||||
confirmationText: t("download"),
|
||||
title: t("actions.warningModalTitle"),
|
||||
description: t("actions.download.warning"),
|
||||
confirmationText: t("actions.download.label"),
|
||||
confirmationButtonProps: {
|
||||
color: "error",
|
||||
variant: "outlined",
|
||||
|
||||
@@ -1,72 +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 { Dialog, ListItemIcon, ListItemText, MenuItem } from "@mui/material";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Action from "../actions/action";
|
||||
|
||||
const ContextualMenuItem = (props: Action & { onDialogClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const onClick = () => {
|
||||
if (props.disabled === true) {
|
||||
return;
|
||||
}
|
||||
props.onClick?.();
|
||||
if (props.dialog) {
|
||||
setModalOpen(true);
|
||||
}
|
||||
};
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
props.onDialogClose();
|
||||
};
|
||||
const item = (
|
||||
<MenuItem
|
||||
disabled={props.disabled}
|
||||
onClick={onClick}
|
||||
sx={{ borderRadius: "0" }}
|
||||
>
|
||||
{props.icon && <ListItemIcon>{props.icon}</ListItemIcon>}
|
||||
<ListItemText>{t(props.label)}</ListItemText>
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
if (props.href && !props.disabled) {
|
||||
return <Link href={props.href}>{item}</Link>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{item}
|
||||
{props.dialog && (
|
||||
<Dialog
|
||||
open={modalOpen}
|
||||
onClose={closeModal}
|
||||
fullWidth
|
||||
sx={{ zIndex: 999999 }}
|
||||
>
|
||||
{props.dialog({ close: closeModal })}
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextualMenuItem;
|
||||
@@ -16,18 +16,29 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Box, Divider, IconButton, Menu } from "@mui/material";
|
||||
import {
|
||||
Box,
|
||||
Dialog,
|
||||
Divider,
|
||||
IconButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Menu,
|
||||
MenuItem,
|
||||
} from "@mui/material";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import type Action from "../actions/action";
|
||||
import { ContextualMenuIcon } from "../icons";
|
||||
import ContextualMenuItem from "./contextual-menu-item";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Action from "~/components/actions";
|
||||
import { ContextualMenuIcon } from "~/components/icons";
|
||||
|
||||
type ContextualMenuProps = {
|
||||
export type ContextualMenuProps = {
|
||||
actions: Action[][];
|
||||
onSelect?: (action: Action) => void;
|
||||
buttonIcon?: JSX.Element;
|
||||
};
|
||||
const ContextualMenu = (props: ContextualMenuProps) => {
|
||||
|
||||
export const ContextualMenu = (props: ContextualMenuProps) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
@@ -92,4 +103,51 @@ const ContextualMenu = (props: ContextualMenuProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextualMenu;
|
||||
export const ContextualMenuItem = (
|
||||
props: Action & { onDialogClose: () => void },
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const onClick = () => {
|
||||
if (props.disabled === true) {
|
||||
return;
|
||||
}
|
||||
props.onClick?.();
|
||||
if (props.dialog) {
|
||||
setModalOpen(true);
|
||||
}
|
||||
};
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
props.onDialogClose();
|
||||
};
|
||||
const item = (
|
||||
<MenuItem
|
||||
disabled={props.disabled}
|
||||
onClick={onClick}
|
||||
sx={{ borderRadius: "0" }}
|
||||
>
|
||||
{props.icon && <ListItemIcon>{props.icon}</ListItemIcon>}
|
||||
<ListItemText>{t(props.label)}</ListItemText>
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
if (props.href && !props.disabled) {
|
||||
return <Link href={props.href}>{item}</Link>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{item}
|
||||
{props.dialog && (
|
||||
<Dialog
|
||||
open={modalOpen}
|
||||
onClose={closeModal}
|
||||
fullWidth
|
||||
sx={{ zIndex: 999999 }}
|
||||
>
|
||||
{props.dialog({ close: closeModal })}
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -18,15 +18,15 @@
|
||||
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import API from "../../api/api";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import type { AlbumWithRelations } from "../../models/album";
|
||||
import { DownloadReleaseAsyncAction } from "../actions/download";
|
||||
import { GoToArtistAction } from "../actions/link";
|
||||
import { RefreshAlbumMetadataAction } from "../actions/refresh-metadata";
|
||||
import { ChangeAlbumType } from "../actions/resource-type";
|
||||
import { ShareAlbumAction } from "../actions/share";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
import { useQueryClient } from "~/api/hook";
|
||||
import { getMasterRelease } from "~/api/queries";
|
||||
import { DownloadReleaseAsyncAction } from "~/components/actions/download";
|
||||
import { GoToArtistAction } from "~/components/actions/link";
|
||||
import { RefreshAlbumMetadataAction } from "~/components/actions/refresh-metadata";
|
||||
import { ChangeAlbumType } from "~/components/actions/resource-type";
|
||||
import { ShareAlbumAction } from "~/components/actions/share";
|
||||
import type { AlbumWithRelations } from "~/models/album";
|
||||
import { ContextualMenu } from "..";
|
||||
|
||||
type AlbumContextualMenuProps = {
|
||||
album: AlbumWithRelations<"artist">;
|
||||
@@ -52,10 +52,11 @@ const AlbumContextualMenu = (props: AlbumContextualMenuProps) => {
|
||||
],
|
||||
[
|
||||
DownloadReleaseAsyncAction(
|
||||
queryClient.api,
|
||||
confirm,
|
||||
() =>
|
||||
queryClient
|
||||
.fetchQuery(API.getMasterRelease(albumSlug))
|
||||
.fetchQuery(getMasterRelease(albumSlug))
|
||||
.then((release) => release.id),
|
||||
t,
|
||||
),
|
||||
@@ -17,16 +17,16 @@
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import type Artist from "../../models/artist";
|
||||
import { useQueryClient } from "~/api/hook";
|
||||
import {
|
||||
GoToArtistAction,
|
||||
GoToArtistAlbumsAction,
|
||||
GoToArtistSongsAction,
|
||||
} from "../actions/link";
|
||||
import { ShareArtistAction } from "../actions/share";
|
||||
import { UpdateArtistIllustrationAction } from "../actions/update-illustration";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
} from "~/components/actions/link";
|
||||
import { ShareArtistAction } from "~/components/actions/share";
|
||||
import { UpdateArtistIllustrationAction } from "~/components/actions/update-illustration";
|
||||
import type Artist from "~/models/artist";
|
||||
import { ContextualMenu } from "..";
|
||||
|
||||
type ArtistContextualMenuProps = {
|
||||
artist: Artist;
|
||||
@@ -19,15 +19,16 @@
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import { useRouter } from "next/router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import type Playlist from "../../models/playlist";
|
||||
import { useQuery, useQueryClient } from "~/api/hook";
|
||||
import { getCurrentUserStatus } from "~/api/queries";
|
||||
import {
|
||||
DeletePlaylistAction,
|
||||
UpdatePlaylistAction,
|
||||
} from "../actions/playlist";
|
||||
import { SharePlaylistAction } from "../actions/share";
|
||||
import { UpdatePlaylistIllustrationAction } from "../actions/update-illustration";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
} from "~/components/actions/playlist";
|
||||
import { SharePlaylistAction } from "~/components/actions/share";
|
||||
import { UpdatePlaylistIllustrationAction } from "~/components/actions/update-illustration";
|
||||
import type Playlist from "~/models/playlist";
|
||||
import { ContextualMenu } from "..";
|
||||
|
||||
type PlaylistContextualMenuProps = {
|
||||
playlist: Playlist;
|
||||
@@ -39,27 +40,36 @@ const PlaylistContextualMenu = (props: PlaylistContextualMenuProps) => {
|
||||
const confirm = useConfirm();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { data: user } = useQuery(getCurrentUserStatus);
|
||||
|
||||
return (
|
||||
<ContextualMenu
|
||||
onSelect={props.onSelect}
|
||||
actions={[
|
||||
[UpdatePlaylistAction(props.playlist, queryClient)],
|
||||
[
|
||||
UpdatePlaylistIllustrationAction(
|
||||
queryClient,
|
||||
props.playlist.id,
|
||||
),
|
||||
],
|
||||
...(user?.id === props.playlist.ownerId
|
||||
? [
|
||||
[UpdatePlaylistAction(props.playlist, queryClient)],
|
||||
[
|
||||
UpdatePlaylistIllustrationAction(
|
||||
queryClient,
|
||||
props.playlist.id,
|
||||
),
|
||||
],
|
||||
]
|
||||
: []),
|
||||
[SharePlaylistAction(props.playlist.slug, t)],
|
||||
[
|
||||
DeletePlaylistAction(
|
||||
confirm,
|
||||
queryClient,
|
||||
props.playlist.slug,
|
||||
() => router.push("/playlists"),
|
||||
),
|
||||
],
|
||||
...(user?.id === props.playlist.ownerId
|
||||
? [
|
||||
[
|
||||
DeletePlaylistAction(
|
||||
confirm,
|
||||
queryClient,
|
||||
props.playlist.slug,
|
||||
() => router.push("/playlists"),
|
||||
),
|
||||
],
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
@@ -18,28 +18,28 @@
|
||||
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import type Artist from "../../models/artist";
|
||||
import type { TrackWithRelations } from "../../models/track";
|
||||
import { DownloadAction } from "../actions/download";
|
||||
import { useQueryClient } from "~/api/hook";
|
||||
import { DownloadAction } from "~/components/actions/download";
|
||||
import {
|
||||
GoToArtistAction,
|
||||
GoToRelatedTracksAction,
|
||||
GoToSongInfoAction,
|
||||
GoToSongLyricsAction,
|
||||
GoToSongVersionAction,
|
||||
} from "../actions/link";
|
||||
} from "~/components/actions/link";
|
||||
import {
|
||||
AddToPlaylistAction,
|
||||
PlayAfterAction,
|
||||
PlayNextAction,
|
||||
} from "../actions/playlist";
|
||||
import { RefreshTrackMetadataAction } from "../actions/refresh-metadata";
|
||||
import { ChangeSongType } from "../actions/resource-type";
|
||||
import { ShareSongAction } from "../actions/share";
|
||||
import { ShowTrackFileInfoAction } from "../actions/show-track-info";
|
||||
import { UpdateTrackIllustrationAction } from "../actions/update-illustration";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
} from "~/components/actions/playlist";
|
||||
import { RefreshTrackMetadataAction } from "~/components/actions/refresh-metadata";
|
||||
import { ChangeSongType } from "~/components/actions/resource-type";
|
||||
import { ShareSongAction } from "~/components/actions/share";
|
||||
import { ShowTrackFileInfoAction } from "~/components/actions/show-track-info";
|
||||
import { UpdateTrackIllustrationAction } from "~/components/actions/update-illustration";
|
||||
import type Artist from "~/models/artist";
|
||||
import type { TrackWithRelations } from "~/models/track";
|
||||
import { ContextualMenu } from "..";
|
||||
|
||||
type ReleaseTrackContextualMenuProps = {
|
||||
track: TrackWithRelations<"song" | "illustration">;
|
||||
@@ -52,17 +52,21 @@ const ReleaseTrackContextualMenu = (props: ReleaseTrackContextualMenuProps) => {
|
||||
const confirm = useConfirm();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { playNext, playAfter } = usePlayerContext();
|
||||
|
||||
return (
|
||||
<ContextualMenu
|
||||
onSelect={props.onSelect}
|
||||
actions={[
|
||||
[GoToArtistAction(props.artist.slug)],
|
||||
songSlug ? [GoToSongLyricsAction(songSlug)] : [],
|
||||
songSlug
|
||||
? [
|
||||
GoToSongLyricsAction(songSlug),
|
||||
GoToSongInfoAction(songSlug),
|
||||
]
|
||||
: [],
|
||||
[
|
||||
PlayNextAction(async () => props, playNext),
|
||||
PlayAfterAction(async () => props, playAfter),
|
||||
PlayNextAction(async () => props),
|
||||
PlayAfterAction(async () => props),
|
||||
],
|
||||
props.track.songId
|
||||
? [AddToPlaylistAction(props.track.songId, queryClient)]
|
||||
@@ -88,7 +92,12 @@ const ReleaseTrackContextualMenu = (props: ReleaseTrackContextualMenuProps) => {
|
||||
],
|
||||
[ShowTrackFileInfoAction(confirm, props.track.id)],
|
||||
[
|
||||
DownloadAction(confirm, props.track.sourceFileId, t),
|
||||
DownloadAction(
|
||||
queryClient.api,
|
||||
confirm,
|
||||
props.track.sourceFileId,
|
||||
t,
|
||||
),
|
||||
...(songSlug ? [ShareSongAction(songSlug, t)] : []),
|
||||
],
|
||||
]}
|
||||
@@ -16,51 +16,51 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "react-query";
|
||||
import { useSelector } from "react-redux";
|
||||
import API from "../../api/api";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import type { ReleaseWithRelations } from "../../models/release";
|
||||
import type { RootState } from "../../state/store";
|
||||
import { DownloadReleaseAction } from "../actions/download";
|
||||
import { GoToAlbumAction, GoToArtistAction } from "../actions/link";
|
||||
import { RefreshReleaseMetadataAction } from "../actions/refresh-metadata";
|
||||
import { ChangeAlbumType } from "../actions/resource-type";
|
||||
import { ShareReleaseAction } from "../actions/share";
|
||||
import { UpdateReleaseIllustrationAction } from "../actions/update-illustration";
|
||||
import { MasterIcon, UpgradeIcon } from "../icons";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
import { useQueryClient } from "~/api/hook";
|
||||
import { getReleaseTracklist } from "~/api/queries";
|
||||
import { DownloadReleaseAction } from "~/components/actions/download";
|
||||
import { GoToAlbumAction, GoToArtistAction } from "~/components/actions/link";
|
||||
import { RefreshReleaseMetadataAction } from "~/components/actions/refresh-metadata";
|
||||
import { ChangeAlbumType } from "~/components/actions/resource-type";
|
||||
import { ShareReleaseAction } from "~/components/actions/share";
|
||||
import { UpdateReleaseIllustrationAction } from "~/components/actions/update-illustration";
|
||||
import { MasterIcon, UpgradeIcon } from "~/components/icons";
|
||||
import type { ReleaseWithRelations } from "~/models/release";
|
||||
import { userAtom } from "~/state/user";
|
||||
import { ContextualMenu } from "..";
|
||||
|
||||
type ReleaseContextualMenuProps = {
|
||||
release: ReleaseWithRelations<"album">;
|
||||
};
|
||||
|
||||
const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
|
||||
const userIsAdmin = useSelector(
|
||||
(state: RootState) => state.user.user?.admin === true,
|
||||
);
|
||||
const [user] = useAtom(userAtom);
|
||||
const userIsAdmin = user?.admin === true;
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const { t } = useTranslation();
|
||||
const masterMutation = useMutation(async () => {
|
||||
return API.updateAlbum(props.release.albumId, {
|
||||
masterReleaseId: props.release.id,
|
||||
})
|
||||
return queryClient.api
|
||||
.updateAlbum(props.release.albumId, {
|
||||
masterReleaseId: props.release.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t("releaseSetAsMaster"));
|
||||
toast.success(t("toasts.releaseSetAsMaster"));
|
||||
queryClient.client.invalidateQueries();
|
||||
})
|
||||
.catch((error: Error) => toast.error(error.message));
|
||||
});
|
||||
const tracksMasterMutation = useMutation(async () => {
|
||||
const query = API.getReleaseTracklist(props.release.id);
|
||||
const query = getReleaseTracklist(props.release.id);
|
||||
return queryClient
|
||||
.fetchQuery({
|
||||
key: query.key,
|
||||
exec: () => query.exec({ pageSize: 1000 }),
|
||||
exec: (api_) => () => query.exec(api_)({ pageSize: 1000 }),
|
||||
})
|
||||
.then(({ items: tracks }) => {
|
||||
Promise.allSettled(
|
||||
@@ -68,13 +68,13 @@ const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
|
||||
.reverse()
|
||||
.filter((track) => track.songId != null)
|
||||
.map((track) =>
|
||||
API.updateSong(track.songId!, {
|
||||
queryClient.api.updateSong(track.songId!, {
|
||||
masterTrackId: track.id,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.then(() => {
|
||||
toast.success(t("tracksUpdated"));
|
||||
toast.success(t("toasts.tracksUpdated"));
|
||||
queryClient.client.invalidateQueries();
|
||||
})
|
||||
.catch((error) => toast.error(error.message));
|
||||
@@ -90,7 +90,7 @@ const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
|
||||
: []),
|
||||
GoToAlbumAction(props.release.album.id),
|
||||
{
|
||||
label: "setAsMaster",
|
||||
label: "actions.setAsMaster",
|
||||
disabled:
|
||||
props.release.id === props.release.album.masterId ||
|
||||
!userIsAdmin,
|
||||
@@ -98,7 +98,7 @@ const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
|
||||
onClick: () => masterMutation.mutate(),
|
||||
},
|
||||
{
|
||||
label: "setAllTracksAsMaster",
|
||||
label: "actions.release.setAllTracksAsMaster",
|
||||
icon: <UpgradeIcon />,
|
||||
disabled: !userIsAdmin,
|
||||
onClick: () => tracksMasterMutation.mutate(),
|
||||
@@ -112,7 +112,14 @@ const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
|
||||
),
|
||||
RefreshReleaseMetadataAction(props.release.id, t),
|
||||
],
|
||||
[DownloadReleaseAction(confirm, props.release.id, t)],
|
||||
[
|
||||
DownloadReleaseAction(
|
||||
queryClient.api,
|
||||
confirm,
|
||||
props.release.id,
|
||||
t,
|
||||
),
|
||||
],
|
||||
[ShareReleaseAction(props.release.id, t)],
|
||||
]}
|
||||
/>
|
||||
@@ -20,29 +20,30 @@ import { useConfirm } from "material-ui-confirm";
|
||||
import { useRouter } from "next/router";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import API from "../../api/api";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import type { SongWithRelations } from "../../models/song";
|
||||
import { DownloadAsyncAction } from "../actions/download";
|
||||
import { useQueryClient } from "~/api/hook";
|
||||
import { getSongMasterTrack } from "~/api/queries";
|
||||
import { DownloadAsyncAction } from "~/components/actions/download";
|
||||
import {
|
||||
GoToArtistAction,
|
||||
GoToRelatedTracksAction,
|
||||
GoToReleaseAsyncAction,
|
||||
GoToSongInfoAction,
|
||||
GoToSongLyricsAction,
|
||||
GoToSongVersionAction,
|
||||
} from "../actions/link";
|
||||
} from "~/components/actions/link";
|
||||
import { MergeSongAction } from "~/components/actions/merge";
|
||||
import {
|
||||
AddToPlaylistAction,
|
||||
PlayAfterAction,
|
||||
PlayNextAction,
|
||||
} from "../actions/playlist";
|
||||
import { RefreshSongMetadataAction } from "../actions/refresh-metadata";
|
||||
import { ChangeSongType } from "../actions/resource-type";
|
||||
import { ShareSongAction } from "../actions/share";
|
||||
import { ShowMasterTrackFileInfoAction } from "../actions/show-track-info";
|
||||
import { DeleteIcon } from "../icons";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
} from "~/components/actions/playlist";
|
||||
import { RefreshSongMetadataAction } from "~/components/actions/refresh-metadata";
|
||||
import { ChangeSongType } from "~/components/actions/resource-type";
|
||||
import { ShareSongAction } from "~/components/actions/share";
|
||||
import { ShowMasterTrackFileInfoAction } from "~/components/actions/show-track-info";
|
||||
import { DeleteIcon } from "~/components/icons";
|
||||
import type { SongWithRelations } from "~/models/song";
|
||||
import { ContextualMenu } from "..";
|
||||
|
||||
type SongContextualMenuProps = {
|
||||
song: SongWithRelations<"artist">;
|
||||
@@ -55,10 +56,9 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
|
||||
const songSlug = props.song.slug;
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { playNext, playAfter } = usePlayerContext();
|
||||
const getMasterTrack = () =>
|
||||
queryClient.fetchQuery(
|
||||
API.getSongMasterTrack(songSlug, ["release", "illustration"]),
|
||||
getSongMasterTrack(songSlug, ["release", "illustration"]),
|
||||
);
|
||||
const router = useRouter();
|
||||
const confirm = useConfirm();
|
||||
@@ -80,10 +80,10 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
|
||||
(await getMasterTrack()).release?.slug ?? null,
|
||||
),
|
||||
],
|
||||
[GoToSongLyricsAction(songSlug)],
|
||||
[GoToSongLyricsAction(songSlug), GoToSongInfoAction(songSlug)],
|
||||
[
|
||||
PlayNextAction(getPlayNextProps, playNext),
|
||||
PlayAfterAction(getPlayNextProps, playAfter),
|
||||
PlayNextAction(getPlayNextProps),
|
||||
PlayAfterAction(getPlayNextProps),
|
||||
],
|
||||
[AddToPlaylistAction(props.song.id, queryClient)],
|
||||
[
|
||||
@@ -93,6 +93,7 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
|
||||
[
|
||||
ChangeSongType(props.song, queryClient, confirm),
|
||||
RefreshSongMetadataAction(props.song.id, t),
|
||||
MergeSongAction(props.song, queryClient),
|
||||
],
|
||||
[
|
||||
ShowMasterTrackFileInfoAction(
|
||||
@@ -103,6 +104,7 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
|
||||
],
|
||||
[
|
||||
DownloadAsyncAction(
|
||||
queryClient.api,
|
||||
confirm,
|
||||
() =>
|
||||
getMasterTrack().then(
|
||||
@@ -117,14 +119,15 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
|
||||
? [
|
||||
[
|
||||
{
|
||||
label: "deleteFromPlaylist",
|
||||
label: "actions.deleteFromPlaylist",
|
||||
icon: <DeleteIcon />,
|
||||
onClick: () =>
|
||||
API.deletePlaylistEntry(props.entryId!)
|
||||
queryClient.api
|
||||
.deletePlaylistEntry(props.entryId!)
|
||||
.then(() => {
|
||||
toast.success(
|
||||
t(
|
||||
"playlistItemDeletionSuccess",
|
||||
"toasts.playlist.itemDeletionSuccess",
|
||||
),
|
||||
);
|
||||
queryClient.client.invalidateQueries(
|
||||
@@ -16,30 +16,34 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "react-query";
|
||||
import { useSelector } from "react-redux";
|
||||
import API from "../../api/api";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import type { TrackWithRelations } from "../../models/track";
|
||||
import type { RootState } from "../../state/store";
|
||||
import type Action from "../actions/action";
|
||||
import { DownloadAction } from "../actions/download";
|
||||
import { GoToReleaseAction, GoToSongLyricsAction } from "../actions/link";
|
||||
import { useQueryClient } from "~/api/hook";
|
||||
import { getArtist, getRelease } from "~/api/queries";
|
||||
import type Action from "~/components/actions";
|
||||
import { DownloadAction } from "~/components/actions/download";
|
||||
import {
|
||||
GoToReleaseAction,
|
||||
GoToSongInfoAction,
|
||||
GoToSongLyricsAction,
|
||||
} from "~/components/actions/link";
|
||||
import { ReassignTrackAction } from "~/components/actions/merge";
|
||||
import {
|
||||
AddToPlaylistAction,
|
||||
PlayAfterAction,
|
||||
PlayNextAction,
|
||||
} from "../actions/playlist";
|
||||
import { RefreshTrackMetadataAction } from "../actions/refresh-metadata";
|
||||
import { ChangeSongType } from "../actions/resource-type";
|
||||
import { ShowTrackFileInfoAction } from "../actions/show-track-info";
|
||||
import { UpdateTrackIllustrationAction } from "../actions/update-illustration";
|
||||
import { MasterIcon } from "../icons";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
} from "~/components/actions/playlist";
|
||||
import { RefreshTrackMetadataAction } from "~/components/actions/refresh-metadata";
|
||||
import { ChangeSongType } from "~/components/actions/resource-type";
|
||||
import { ShowTrackFileInfoAction } from "~/components/actions/show-track-info";
|
||||
import { UpdateTrackIllustrationAction } from "~/components/actions/update-illustration";
|
||||
import { MasterIcon } from "~/components/icons";
|
||||
import type { TrackWithRelations } from "~/models/track";
|
||||
import { userAtom } from "~/state/user";
|
||||
import { ContextualMenu } from "..";
|
||||
|
||||
type TrackContextualMenuProps = {
|
||||
track: TrackWithRelations<"video" | "song" | "illustration">;
|
||||
@@ -47,34 +51,31 @@ type TrackContextualMenuProps = {
|
||||
};
|
||||
|
||||
const TrackContextualMenu = (props: TrackContextualMenuProps) => {
|
||||
const userIsAdmin = useSelector(
|
||||
(state: RootState) => state.user.user?.admin === true,
|
||||
);
|
||||
const [user] = useAtom(userAtom);
|
||||
const userIsAdmin = user?.admin === true;
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const isMaster = props.track.song?.masterId === props.track.id;
|
||||
const getPlayNextProps = () =>
|
||||
Promise.all([
|
||||
queryClient.fetchQuery(
|
||||
API.getArtist(
|
||||
(props.track.song ?? props.track.video)!.artistId,
|
||||
),
|
||||
getArtist((props.track.song ?? props.track.video)!.artistId),
|
||||
),
|
||||
props.track.releaseId &&
|
||||
queryClient.fetchQuery(API.getRelease(props.track.releaseId)),
|
||||
queryClient.fetchQuery(getRelease(props.track.releaseId)),
|
||||
]).then(([artist, release]) => ({
|
||||
track: props.track,
|
||||
artist,
|
||||
release,
|
||||
}));
|
||||
const { t } = useTranslation();
|
||||
const { playNext, playAfter } = usePlayerContext();
|
||||
const masterMutation = useMutation(async () => {
|
||||
return API.updateSong(props.track.songId!, {
|
||||
masterTrackId: props.track.id,
|
||||
})
|
||||
return queryClient.api
|
||||
.updateSong(props.track.songId!, {
|
||||
masterTrackId: props.track.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t("trackSetAsMaster"));
|
||||
toast.success(t("toasts.trackSetAsMaster"));
|
||||
queryClient.client.invalidateQueries();
|
||||
})
|
||||
.catch((error: Error) => toast.error(error.message));
|
||||
@@ -90,11 +91,14 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
|
||||
: undefined,
|
||||
].filter((a): a is Action => a !== undefined),
|
||||
props.track.song
|
||||
? [GoToSongLyricsAction(props.track.song.slug)]
|
||||
? [
|
||||
GoToSongLyricsAction(props.track.song.slug),
|
||||
GoToSongInfoAction(props.track.song.slug),
|
||||
]
|
||||
: [],
|
||||
[
|
||||
PlayNextAction(getPlayNextProps, playNext),
|
||||
PlayAfterAction(getPlayNextProps, playAfter),
|
||||
PlayNextAction(getPlayNextProps),
|
||||
PlayAfterAction(getPlayNextProps),
|
||||
],
|
||||
props.track.songId
|
||||
? [AddToPlaylistAction(props.track.songId, queryClient)]
|
||||
@@ -102,7 +106,7 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
|
||||
props.track.songId
|
||||
? [
|
||||
{
|
||||
label: "setAsMaster",
|
||||
label: "actions.setAsMaster",
|
||||
disabled: isMaster || !userIsAdmin,
|
||||
icon: <MasterIcon />,
|
||||
onClick: () => masterMutation.mutate(),
|
||||
@@ -121,9 +125,19 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
|
||||
: []),
|
||||
UpdateTrackIllustrationAction(queryClient, props.track.id),
|
||||
RefreshTrackMetadataAction(props.track.id, t),
|
||||
...(props.track.song !== null && props.track.songId !== null
|
||||
? [ReassignTrackAction(props.track as any, queryClient)]
|
||||
: []),
|
||||
],
|
||||
[ShowTrackFileInfoAction(confirm, props.track.id)],
|
||||
[DownloadAction(confirm, props.track.sourceFileId, t)],
|
||||
[
|
||||
DownloadAction(
|
||||
queryClient.api,
|
||||
confirm,
|
||||
props.track.sourceFileId,
|
||||
t,
|
||||
),
|
||||
],
|
||||
]}
|
||||
/>
|
||||
);
|
||||
@@ -18,27 +18,27 @@
|
||||
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import API from "../../api/api";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import type { VideoWithRelations } from "../../models/video";
|
||||
import type Action from "../actions/action";
|
||||
import { DownloadAction } from "../actions/download";
|
||||
import { useQueryClient } from "~/api/hook";
|
||||
import { getArtist, getRelease } from "~/api/queries";
|
||||
import type Action from "~/components/actions";
|
||||
import { DownloadAction } from "~/components/actions/download";
|
||||
import {
|
||||
GoToArtistAction,
|
||||
GoToReleaseAction,
|
||||
GoToSongInfoAction,
|
||||
GoToSongLyricsAction,
|
||||
} from "../actions/link";
|
||||
} from "~/components/actions/link";
|
||||
import {
|
||||
AddToPlaylistAction,
|
||||
PlayAfterAction,
|
||||
PlayNextAction,
|
||||
} from "../actions/playlist";
|
||||
import { RefreshTrackMetadataAction } from "../actions/refresh-metadata";
|
||||
import { ChangeVideoType } from "../actions/resource-type";
|
||||
import { ShowTrackFileInfoAction } from "../actions/show-track-info";
|
||||
import { UpdateTrackIllustrationAction } from "../actions/update-illustration";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
} from "~/components/actions/playlist";
|
||||
import { RefreshTrackMetadataAction } from "~/components/actions/refresh-metadata";
|
||||
import { ChangeVideoType } from "~/components/actions/resource-type";
|
||||
import { ShowTrackFileInfoAction } from "~/components/actions/show-track-info";
|
||||
import { UpdateTrackIllustrationAction } from "~/components/actions/update-illustration";
|
||||
import type { VideoWithRelations } from "~/models/video";
|
||||
import { ContextualMenu } from "..";
|
||||
|
||||
type VideoContextualMenuProps = {
|
||||
video: VideoWithRelations<"master" | "illustration">;
|
||||
@@ -50,10 +50,10 @@ const VideoContextualMenu = (props: VideoContextualMenuProps) => {
|
||||
const confirm = useConfirm();
|
||||
const getPlayNextProps = () =>
|
||||
Promise.all([
|
||||
queryClient.fetchQuery(API.getArtist(props.video.artistId)),
|
||||
queryClient.fetchQuery(getArtist(props.video.artistId)),
|
||||
props.video.master.releaseId &&
|
||||
queryClient.fetchQuery(
|
||||
API.getRelease(props.video.master.releaseId),
|
||||
getRelease(props.video.master.releaseId),
|
||||
),
|
||||
]).then(([artist, release]) => ({
|
||||
track: {
|
||||
@@ -64,7 +64,6 @@ const VideoContextualMenu = (props: VideoContextualMenuProps) => {
|
||||
release,
|
||||
}));
|
||||
const { t } = useTranslation();
|
||||
const { playNext, playAfter } = usePlayerContext();
|
||||
|
||||
return (
|
||||
<ContextualMenu
|
||||
@@ -77,11 +76,14 @@ const VideoContextualMenu = (props: VideoContextualMenuProps) => {
|
||||
: undefined,
|
||||
].filter((a): a is Action => a !== undefined),
|
||||
props.video.songId
|
||||
? [GoToSongLyricsAction(props.video.songId)]
|
||||
? [
|
||||
GoToSongLyricsAction(props.video.songId),
|
||||
GoToSongInfoAction(props.video.songId),
|
||||
]
|
||||
: [],
|
||||
[
|
||||
PlayNextAction(getPlayNextProps, playNext),
|
||||
PlayAfterAction(getPlayNextProps, playAfter),
|
||||
PlayNextAction(getPlayNextProps),
|
||||
PlayAfterAction(getPlayNextProps),
|
||||
],
|
||||
props.video.songId
|
||||
? [AddToPlaylistAction(props.video.songId, queryClient)]
|
||||
@@ -95,7 +97,14 @@ const VideoContextualMenu = (props: VideoContextualMenuProps) => {
|
||||
RefreshTrackMetadataAction(props.video.master.id, t),
|
||||
],
|
||||
[ShowTrackFileInfoAction(confirm, props.video.master.id)],
|
||||
[DownloadAction(confirm, props.video.master.sourceFileId, t)],
|
||||
[
|
||||
DownloadAction(
|
||||
queryClient.api,
|
||||
confirm,
|
||||
props.video.master.sourceFileId,
|
||||
t,
|
||||
),
|
||||
],
|
||||
]}
|
||||
/>
|
||||
);
|
||||
@@ -1,375 +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 {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Dialog,
|
||||
Grid,
|
||||
Tooltip,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import type { NextRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useInfiniteQuery as useReactInfiniteQuery } from "react-query";
|
||||
import API from "../../api/api";
|
||||
import { prepareMeeloInfiniteQuery } from "../../api/use-query";
|
||||
import type { TranslationKey } from "../../i18n/i18n";
|
||||
import globalLibrary from "../../utils/global-library";
|
||||
import { type LayoutOption, getLayoutParams } from "../../utils/layout";
|
||||
import parseQueryParam from "../../utils/parse-query-param";
|
||||
import { type Order, getOrderParams } from "../../utils/sorting";
|
||||
import type Action from "../actions/action";
|
||||
import Fade from "../fade";
|
||||
import { AscIcon, DescIcon, GridIcon, ListIcon } from "../icons";
|
||||
import type Option from "./option";
|
||||
import OptionButton from "./option-button";
|
||||
|
||||
export type Toggle = {
|
||||
name: string;
|
||||
label: TranslationKey;
|
||||
defaultValue?: boolean;
|
||||
};
|
||||
|
||||
export type OptionState<
|
||||
SortingKeys extends readonly string[],
|
||||
AdditionalProps extends object = object,
|
||||
> = {
|
||||
view: LayoutOption;
|
||||
order: Order;
|
||||
sortBy: SortingKeys[number];
|
||||
library: string | null;
|
||||
} & AdditionalProps;
|
||||
|
||||
type ControllerProps<
|
||||
SortingKeys extends readonly string[],
|
||||
Options extends Option<Values[number]>[],
|
||||
Values extends string[][],
|
||||
> = {
|
||||
options?: Options;
|
||||
actions?: Action[];
|
||||
toggles?: Toggle[];
|
||||
disableSorting?: true;
|
||||
sortingKeys: SortingKeys;
|
||||
defaultSortingKey?: SortingKeys[number];
|
||||
defaultSortingOrder?: Order;
|
||||
defaultLayout: LayoutOption;
|
||||
disableLayoutToggle?: boolean;
|
||||
onChange: (state: OptionState<SortingKeys>) => void;
|
||||
/**
|
||||
* If defined, will push changes as query parameters in router
|
||||
*/
|
||||
router?: NextRouter;
|
||||
disableLibrarySelector?: true;
|
||||
};
|
||||
|
||||
const Controls = <
|
||||
SortingKeys extends readonly string[],
|
||||
Options extends Option<Values[number]>[],
|
||||
Values extends string[][],
|
||||
>(
|
||||
props: ControllerProps<SortingKeys, Options, Values>,
|
||||
) => {
|
||||
const _theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const librariesQuery = useReactInfiniteQuery({
|
||||
...prepareMeeloInfiniteQuery(API.getLibraries),
|
||||
useErrorBoundary: false,
|
||||
onError: () => {
|
||||
toast.error(t("librariesLoadFail"));
|
||||
},
|
||||
});
|
||||
const libraries = useMemo(
|
||||
() => librariesQuery.data?.pages.at(0)?.items ?? null,
|
||||
[librariesQuery.data],
|
||||
);
|
||||
const [optionsState, setOptionState] = useState<OptionState<SortingKeys>>(
|
||||
() => {
|
||||
const libraryQuery = Array.isArray(props.router?.query.library)
|
||||
? props.router?.query.library?.at(0)
|
||||
: props.router?.query.library;
|
||||
const baseOptions: OptionState<SortingKeys> = {
|
||||
view:
|
||||
((props.disableLayoutToggle !== true &&
|
||||
getLayoutParams(props.router?.query.view)) ||
|
||||
undefined) ??
|
||||
props.defaultLayout,
|
||||
sortBy:
|
||||
parseQueryParam(
|
||||
props.router?.query.sortBy,
|
||||
props.sortingKeys,
|
||||
) ??
|
||||
props.defaultSortingKey ??
|
||||
props.sortingKeys[0],
|
||||
order:
|
||||
getOrderParams(props.router?.query.order) ??
|
||||
props.defaultSortingOrder ??
|
||||
"asc",
|
||||
library: props.disableLibrarySelector
|
||||
? null
|
||||
: (libraryQuery ?? null),
|
||||
};
|
||||
|
||||
for (const option of props.options ?? []) {
|
||||
// @ts-ignore
|
||||
baseOptions[option.name] =
|
||||
parseQueryParam(
|
||||
props.router?.query[option.name],
|
||||
option.values,
|
||||
) ?? option.values[0];
|
||||
}
|
||||
for (const option of props.toggles ?? []) {
|
||||
// @ts-ignore
|
||||
baseOptions[option.name] =
|
||||
(parseQueryParam(props.router?.query[option.name], [
|
||||
"false",
|
||||
"true",
|
||||
]) ?? "false") === "true";
|
||||
}
|
||||
return baseOptions;
|
||||
},
|
||||
);
|
||||
const [openActionModal, setOpenActionModal] = useState<string | null>(null);
|
||||
const closeModal = () => setOpenActionModal(null);
|
||||
|
||||
/**
|
||||
* To pass the options to parent after initialization
|
||||
*/
|
||||
useEffect(() => {
|
||||
props.onChange(optionsState);
|
||||
}, [optionsState]);
|
||||
|
||||
const updateOptionState = ({
|
||||
name,
|
||||
value,
|
||||
}: {
|
||||
name: string;
|
||||
value: boolean | string | null;
|
||||
}) => {
|
||||
setOptionState({ ...optionsState, [name]: value });
|
||||
value = value?.toString() ?? null;
|
||||
if (props.router) {
|
||||
const path = props.router.asPath.split("?")[0];
|
||||
const params = new URLSearchParams(
|
||||
props.router.asPath.split("?").at(1) ?? "",
|
||||
);
|
||||
|
||||
if (value) {
|
||||
params.set(name, value);
|
||||
} else {
|
||||
params.delete(name);
|
||||
}
|
||||
props.router.push(`${path}?${params.toString()}`, undefined, {
|
||||
shallow: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (libraries === null) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fade in>
|
||||
<Grid
|
||||
container
|
||||
gap={1}
|
||||
sx={{
|
||||
zIndex: 1000,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
marginBottom: 2,
|
||||
position: "sticky",
|
||||
top: 16,
|
||||
left: 0,
|
||||
}}
|
||||
>
|
||||
<Grid item>
|
||||
<ButtonGroup variant="contained">
|
||||
{props.disableLibrarySelector !== true && (
|
||||
<OptionButton
|
||||
optionGroup={{
|
||||
name:
|
||||
optionsState.library ??
|
||||
t("allLibraries"),
|
||||
options: [
|
||||
{
|
||||
name: "library",
|
||||
values: [
|
||||
globalLibrary,
|
||||
...libraries,
|
||||
].map((library) => library.name),
|
||||
currentValue:
|
||||
[
|
||||
globalLibrary,
|
||||
...libraries,
|
||||
].find(
|
||||
({ slug }) =>
|
||||
slug ===
|
||||
optionsState.library,
|
||||
)?.name ?? globalLibrary.name,
|
||||
},
|
||||
],
|
||||
}}
|
||||
onSelect={({ name, value }) => {
|
||||
if (value === globalLibrary.name) {
|
||||
updateOptionState({
|
||||
name,
|
||||
value: null,
|
||||
});
|
||||
} else {
|
||||
updateOptionState({
|
||||
name,
|
||||
value:
|
||||
libraries.find(
|
||||
(lib) => lib.name === value,
|
||||
)?.slug ?? null,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!props.disableSorting && (
|
||||
<OptionButton
|
||||
optionGroup={{
|
||||
name: `${t("sortBy")} ${t(
|
||||
optionsState.sortBy as TranslationKey,
|
||||
)}`,
|
||||
icon:
|
||||
optionsState.order === "desc" ? (
|
||||
<DescIcon />
|
||||
) : (
|
||||
<AscIcon />
|
||||
),
|
||||
options: [
|
||||
{
|
||||
name: "sortBy",
|
||||
values: props.sortingKeys,
|
||||
currentValue: optionsState.sortBy,
|
||||
},
|
||||
{
|
||||
name: "order",
|
||||
values: ["asc", "desc"],
|
||||
currentValue: optionsState.order,
|
||||
},
|
||||
],
|
||||
}}
|
||||
onSelect={updateOptionState}
|
||||
/>
|
||||
)}
|
||||
{props.options?.map((option) => {
|
||||
return (
|
||||
<OptionButton
|
||||
key={option.name}
|
||||
optionGroup={{
|
||||
name: option.label ?? option.name,
|
||||
icon: option.icon,
|
||||
options: [
|
||||
{
|
||||
...option,
|
||||
|
||||
currentValue:
|
||||
optionsState[
|
||||
option.name as keyof typeof optionsState
|
||||
] ?? undefined,
|
||||
},
|
||||
],
|
||||
}}
|
||||
onSelect={updateOptionState}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{props.toggles?.map((toggle) => (
|
||||
<Button
|
||||
key={toggle.name}
|
||||
onClick={() => {
|
||||
updateOptionState({
|
||||
name: toggle.name,
|
||||
value:
|
||||
!optionsState[
|
||||
toggle.name as keyof OptionState<SortingKeys>
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t(toggle.label)}
|
||||
</Button>
|
||||
))}
|
||||
{props.disableLayoutToggle !== true && (
|
||||
<Tooltip title={t("changeLayout")}>
|
||||
<Button
|
||||
onClick={() =>
|
||||
updateOptionState({
|
||||
name: "view",
|
||||
value:
|
||||
optionsState.view === "grid"
|
||||
? "list"
|
||||
: "grid",
|
||||
})
|
||||
}
|
||||
>
|
||||
{optionsState.view === "grid" ? (
|
||||
<ListIcon />
|
||||
) : (
|
||||
<GridIcon />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<ButtonGroup variant="contained">
|
||||
{props.actions?.map((action) => (
|
||||
<Button
|
||||
key={`action-${action.label}`}
|
||||
startIcon={action.icon}
|
||||
variant="contained"
|
||||
onClickCapture={() => {
|
||||
if (action.disabled === true) {
|
||||
return;
|
||||
}
|
||||
action.onClick?.();
|
||||
action.dialog &&
|
||||
setOpenActionModal(action.label);
|
||||
}}
|
||||
>
|
||||
{t(action.label)}
|
||||
{action.dialog && (
|
||||
<Dialog
|
||||
open={openActionModal === action.label}
|
||||
onClose={closeModal}
|
||||
fullWidth
|
||||
>
|
||||
{action.dialog({ close: closeModal })}
|
||||
</Dialog>
|
||||
)}
|
||||
</Button>
|
||||
)) ?? []}
|
||||
</ButtonGroup>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
export default Controls;
|
||||
@@ -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 { Button, Divider, ListItemIcon, Menu, MenuItem } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TranslationKey } from "../../i18n/i18n";
|
||||
import { CheckIcon } from "../icons";
|
||||
import type Option from "./option";
|
||||
import type { OptionGroup } from "./option";
|
||||
|
||||
type OptionButtonProps<
|
||||
Options extends Option<OptionsKeys[number]>[],
|
||||
OptionsKeys extends (readonly string[])[],
|
||||
> = {
|
||||
optionGroup: OptionGroup<Options, OptionsKeys>;
|
||||
onSelect?: (selected: {
|
||||
name: Options[number]["name"];
|
||||
value: OptionsKeys[number][number];
|
||||
}) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param props an option group
|
||||
* @returns Dropdown Button with selector.
|
||||
*/
|
||||
|
||||
const OptionButton = <
|
||||
Options extends Option<OptionsKeys[number]>[],
|
||||
OptionsKeys extends (readonly string[])[],
|
||||
>(
|
||||
props: OptionButtonProps<Options, OptionsKeys>,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const menuOpen = Boolean(anchorEl);
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLButtonElement>) =>
|
||||
setAnchorEl(event.currentTarget);
|
||||
const handleMenuClose = () => setAnchorEl(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={handleMenuOpen} endIcon={props.optionGroup.icon}>
|
||||
{t(props.optionGroup.name as TranslationKey)}
|
||||
</Button>
|
||||
<Menu anchorEl={anchorEl} open={menuOpen} onClose={handleMenuClose}>
|
||||
{props.optionGroup.options.map((option, index, array) =>
|
||||
option.values
|
||||
.map((value: OptionsKeys[number][number]) => (
|
||||
<MenuItem
|
||||
key={value}
|
||||
sx={{ borderRadius: "0" }}
|
||||
selected={option.currentValue === value}
|
||||
onClick={() => {
|
||||
if (props.onSelect) {
|
||||
props.onSelect({
|
||||
name: option.name,
|
||||
value: value,
|
||||
});
|
||||
}
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{option.currentValue === value && (
|
||||
<CheckIcon />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
{/* If value is not a Translation Key, will fall through */}
|
||||
{t(value as TranslationKey)}
|
||||
</MenuItem>
|
||||
))
|
||||
.concat(
|
||||
index + 1 !== array.length
|
||||
? [<Divider key={index} />]
|
||||
: [],
|
||||
),
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OptionButton;
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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 { Box, Button, Grid, Stack, Typography } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TranslationKey } from "~/i18n/i18n";
|
||||
import type Action from "./actions";
|
||||
|
||||
export type EmptyStateProps = {
|
||||
icon: React.ReactNode;
|
||||
text: TranslationKey;
|
||||
actions: Action[];
|
||||
};
|
||||
|
||||
export const EmptyState = (props: EmptyStateProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="column"
|
||||
spacing={3}
|
||||
sx={{
|
||||
paddingTop: 3,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
justifyContent: "center",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
color: "text.disabled",
|
||||
}}
|
||||
>
|
||||
<Box>{props.icon}</Box>
|
||||
<Typography>{t(props.text)}</Typography>
|
||||
<Grid container sx={{ display: "flex", justifyContent: "center" }}>
|
||||
{props.actions?.map((a, i) => (
|
||||
<Button
|
||||
variant="outlined"
|
||||
key={i}
|
||||
href={a.href}
|
||||
onClick={a.onClick}
|
||||
>
|
||||
{t(a.label)}
|
||||
</Button>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -42,7 +42,7 @@ const ErrorPage = ({ heading }: ErrorPageProps) => {
|
||||
minHeight="100vh"
|
||||
flexDirection="column"
|
||||
>
|
||||
<Head title={t("oops")} />
|
||||
<Head title={t("errors.oops")} />
|
||||
<Typography
|
||||
variant="h1"
|
||||
sx={{
|
||||
@@ -56,7 +56,7 @@ const ErrorPage = ({ heading }: ErrorPageProps) => {
|
||||
</Typography>
|
||||
<Link href="/" onClick={() => resetBoundary()}>
|
||||
<Button color="inherit" variant="outlined">
|
||||
{t("goBackHome")}
|
||||
{t("actions.goBackHome")}
|
||||
</Button>
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
|
||||
import { Box, Button, Skeleton } from "@mui/material";
|
||||
import Link from "next/link";
|
||||
import type { ExternalMetadataSource } from "../models/external-metadata";
|
||||
import type { ExternalMetadataSource } from "~/models/external-metadata";
|
||||
import { ProviderIcon } from "./icons";
|
||||
import Illustration from "./illustration";
|
||||
|
||||
type ExternalMetadataBadgeProps = {
|
||||
@@ -34,6 +35,7 @@ const ExternalMetadataBadge = ({ source }: ExternalMetadataBadgeProps) => {
|
||||
<Illustration
|
||||
url={source?.providerIcon ?? undefined}
|
||||
quality="original"
|
||||
fallback={<ProviderIcon />}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import { Fade as MUIFade } from "@mui/material";
|
||||
import type { ComponentProps } from "react";
|
||||
import { isClientSideRendering } from "../utils/is-ssr";
|
||||
import { isClientSideRendering } from "~/utils/is-ssr";
|
||||
/**
|
||||
* Wrapper around MUI's Fade.
|
||||
* Fade is the main animation used in the app.
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { Chip, Skeleton } from "@mui/material";
|
||||
import Link from "next/link";
|
||||
import type { ComponentProps } from "react";
|
||||
import type Genre from "../models/genre";
|
||||
import type Genre from "~/models/genre";
|
||||
|
||||
type GenreButtonProps = {
|
||||
genre: Pick<Genre, "name" | "slug"> | undefined;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
import NextHead from "next/head";
|
||||
import { DefaultWindowTitle } from "../utils/constants";
|
||||
import { DefaultWindowTitle } from "~/utils/constants";
|
||||
|
||||
export const Head = ({
|
||||
title,
|
||||
|
||||
@@ -16,12 +16,20 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Chip, Grid, Skeleton, Typography, useTheme } from "@mui/material";
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Grid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import type IllustrationModel from "../../models/illustration";
|
||||
import { useAccentColor } from "../../utils/accent-color";
|
||||
import Illustration from "../illustration";
|
||||
import Illustration from "~/components/illustration";
|
||||
import type IllustrationModel from "~/models/illustration";
|
||||
import { useAccentColor } from "~/utils/accent-color";
|
||||
|
||||
type HighlightCardProps = {
|
||||
title: string | undefined;
|
||||
@@ -87,74 +95,74 @@ const HighlightCard = (props: HighlightCardProps) => {
|
||||
}}
|
||||
style={{ borderRadius: theme.shape.borderRadius }}
|
||||
>
|
||||
<Grid item sx={{ aspectRatio: "1", height: "100%" }}>
|
||||
<Grid sx={{ aspectRatio: "1", height: "100%" }}>
|
||||
<Illustration
|
||||
illustration={props.illustration}
|
||||
imgProps={{ borderRadius: 0 }}
|
||||
quality="medium"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
container
|
||||
sx={{ width: "100%", overflow: "hidden" }}
|
||||
direction="column"
|
||||
padding={2}
|
||||
>
|
||||
<Grid item sx={{ width: "100%" }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
noWrap
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
width: "100%",
|
||||
paddingRight: 1,
|
||||
}}
|
||||
>
|
||||
{props.headline ?? <Skeleton variant="text" />}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
xs
|
||||
<Grid container size="grow" sx={{ height: "100%" }}>
|
||||
<Stack
|
||||
sx={{
|
||||
overflowY: "scroll",
|
||||
overflowX: "clip",
|
||||
marginRight: -2,
|
||||
paddingY: 1,
|
||||
paddingRight: 2,
|
||||
...scrollbarColorStyle,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: 2,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.disabled"
|
||||
lineHeight={1.5}
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
noWrap
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
width: "100%",
|
||||
paddingRight: 1,
|
||||
}}
|
||||
>
|
||||
{props.headline ?? <Skeleton variant="text" />}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
overflowY: "scroll",
|
||||
overflowX: "clip",
|
||||
flexGrow: 1,
|
||||
marginRight: -2,
|
||||
paddingY: 1,
|
||||
paddingRight: 2,
|
||||
...scrollbarColorStyle,
|
||||
}}
|
||||
>
|
||||
{props.body ?? <Skeleton variant="text" />}
|
||||
</Typography>
|
||||
</Grid>
|
||||
{props.tags.length > 0 && (
|
||||
<Grid
|
||||
item
|
||||
container
|
||||
columnSpacing={1}
|
||||
sx={{ height: 32, overflow: "hidden" }}
|
||||
>
|
||||
{props.tags.map((tag, index) => (
|
||||
<Grid key={index} item>
|
||||
<Link href={tag.href} key={index}>
|
||||
<Chip
|
||||
variant="filled"
|
||||
clickable
|
||||
label={tag.label}
|
||||
/>
|
||||
</Link>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.disabled"
|
||||
lineHeight={1.5}
|
||||
>
|
||||
{props.body ?? <Skeleton variant="text" />}
|
||||
</Typography>
|
||||
</Box>
|
||||
{props.tags.length > 0 && (
|
||||
<Grid
|
||||
container
|
||||
spacing={1}
|
||||
sx={{ marginTop: 1, height: 24 }}
|
||||
>
|
||||
{props.tags.map((tag, index) => (
|
||||
<Grid key={index}>
|
||||
<Link href={tag.href} key={index}>
|
||||
<Chip
|
||||
variant="filled"
|
||||
clickable
|
||||
label={tag.label}
|
||||
/>
|
||||
</Link>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Link>
|
||||
@@ -17,10 +17,10 @@
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AlbumWithRelations } from "../../models/album";
|
||||
import type { AlbumExternalMetadata } from "../../models/external-metadata";
|
||||
import getYear from "../../utils/getYear";
|
||||
import HighlightCard from "./highlight-card";
|
||||
import HighlightCard from "~/components/highlight-card";
|
||||
import type { AlbumWithRelations } from "~/models/album";
|
||||
import type { AlbumExternalMetadata } from "~/models/external-metadata";
|
||||
import { getYear } from "~/utils/date";
|
||||
|
||||
type AlbumHighlightCardProps = {
|
||||
album: AlbumWithRelations<"artist" | "genres" | "illustration"> | undefined;
|
||||
@@ -39,7 +39,7 @@ const AlbumHighlightCard = ({
|
||||
externalMetadata?.description ||
|
||||
[
|
||||
album
|
||||
? (album.artist?.name ?? t("compilation"))
|
||||
? (album.artist?.name ?? t("compilationArtistLabel"))
|
||||
: undefined,
|
||||
album ? getYear(album.releaseDate) : undefined,
|
||||
]
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import * as Iconsax from "iconsax-react";
|
||||
|
||||
type IconProps = Iconsax.IconProps;
|
||||
export type IconProps = Iconsax.IconProps;
|
||||
|
||||
export const TranscodeIcon = Iconsax.Polyswarm;
|
||||
export const PlaylistIcon = Iconsax.MusicFilter;
|
||||
@@ -27,7 +27,10 @@ export const ReleaseIcon = Iconsax.Cd;
|
||||
export const AlbumIcon = Iconsax.MusicDashboard;
|
||||
export const ArtistIcon = Iconsax.Profile2User;
|
||||
export const SongIcon = Iconsax.Musicnote;
|
||||
export const GenreIcon = Iconsax.Speaker;
|
||||
export const RadioIcon = Iconsax.Radio;
|
||||
export const PlayerIcon = Iconsax.PlayCircle;
|
||||
export const CompilationIcon = Iconsax.Category;
|
||||
export const TrackIcon = Iconsax.Musicnote;
|
||||
export const LyricsIcon = Iconsax.MessageText;
|
||||
export const VideoIcon = Iconsax.VideoSquare;
|
||||
@@ -36,6 +39,7 @@ export const CloseIcon = Iconsax.CloseCircle;
|
||||
export const EditIcon = Iconsax.Edit2;
|
||||
export const LogoutIcon = Iconsax.Logout;
|
||||
export const DarkMode = Iconsax.Sun1;
|
||||
export const ProviderIcon = Iconsax.Global;
|
||||
export const LightMode = (props: IconProps) => (
|
||||
<Iconsax.Sun1 variant="Bold" {...props} />
|
||||
);
|
||||
@@ -48,7 +52,9 @@ export const BurgerIcon = Iconsax.HambergerMenu;
|
||||
export const AddIcon = Iconsax.AddCircle;
|
||||
export const DeleteIcon = Iconsax.Trash;
|
||||
export const ShuffleIcon = Iconsax.Shuffle;
|
||||
export const InfoIcon = Iconsax.InfoCircle;
|
||||
export const InfoIcon = (props: IconProps) => (
|
||||
<Iconsax.InfoCircle style={{ rotate: "180deg" }} {...props} />
|
||||
);
|
||||
export const AddToPlaylistIcon = Iconsax.MusicSquareAdd;
|
||||
export const AddItemToPlaylistIcon = Iconsax.MusicSquareAdd;
|
||||
export const PlayNextIcon = Iconsax.MusicSquareAdd;
|
||||
@@ -81,5 +87,24 @@ export const PauseIcon = Iconsax.Pause;
|
||||
export const RewindIcon = Iconsax.Previous;
|
||||
export const ForwardIcon = Iconsax.Next;
|
||||
export const FullscreenIcon = Iconsax.Maximize4;
|
||||
|
||||
export const PlusIcon = Iconsax.Add;
|
||||
export const MinusIcon = Iconsax.Minus;
|
||||
export const DoneIcon = Iconsax.TickSquare;
|
||||
|
||||
export const EmptyStateIcon = Iconsax.SearchStatus1;
|
||||
|
||||
export const MergeIcon = Iconsax.Hierarchy;
|
||||
|
||||
export const OpenExternalIcon = Iconsax.ExportCircle;
|
||||
// Icons for song type
|
||||
export const SongTypeOriginalIcon = Iconsax.Music;
|
||||
export const SongTypeRemixIcon = Iconsax.Repeat;
|
||||
export const SongTypeLiveIcon = Iconsax.Microphone2;
|
||||
export const SongTypeAcousticIcon = Iconsax.VoiceSquare;
|
||||
export const SongTypeInstrumentalIcon = Iconsax.Musicnote;
|
||||
export const SongTypeEditIcon = Iconsax.Cut;
|
||||
export const SongTypeCleanIcon = Iconsax.TickCircle;
|
||||
export const SongTypeDemoIcon = Iconsax.ReceiptEdit;
|
||||
export const SongTypeAcapellaIcon = Iconsax.VoiceCricle;
|
||||
export const SongTypeMedleyIcon = Iconsax.Layer;
|
||||
export const SongTypeNonMusicIcon = Iconsax.VolumeSlash;
|
||||
@@ -20,14 +20,11 @@ import { Box, IconButton, Skeleton, useTheme } from "@mui/material";
|
||||
import Image, { type ImageProps } from "next/image";
|
||||
import { useState } from "react";
|
||||
import type { RequireExactlyOne } from "type-fest";
|
||||
import blackIllustrationFallback from "../../public/icon-black.png";
|
||||
import whiteIllustrationFallback from "../../public/icon.png";
|
||||
import API from "../api/api";
|
||||
import type IllustrationModel from "../models/illustration";
|
||||
import { isSSR } from "../utils/is-ssr";
|
||||
import { useAPI } from "~/api/hook";
|
||||
import type IllustrationModel from "~/models/illustration";
|
||||
import { isSSR } from "~/utils/is-ssr";
|
||||
import Blurhash from "./blurhash";
|
||||
import Fade from "./fade";
|
||||
import ThemedImage from "./themed-image";
|
||||
|
||||
type ImageQuality = "low" | "medium" | "high" | "original";
|
||||
|
||||
@@ -61,6 +58,7 @@ type IllustrationProps = {
|
||||
|
||||
const Illustration = (props: IllustrationProps) => {
|
||||
const theme = useTheme();
|
||||
const api = useAPI();
|
||||
const [loadingState, setLoadingState] = useState<
|
||||
"loading" | "errored" | "finished"
|
||||
>(isSSR() ? "finished" : "loading");
|
||||
@@ -152,36 +150,49 @@ const Illustration = (props: IllustrationProps) => {
|
||||
}}
|
||||
>
|
||||
{loadingState === "errored" || url === null ? (
|
||||
props.fallback ? (
|
||||
<Box
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
animation={false}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
disabled
|
||||
sx={{ fontSize: "large" }}
|
||||
component="div"
|
||||
>
|
||||
{props.fallback}
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<ThemedImage
|
||||
dark={whiteIllustrationFallback}
|
||||
light={blackIllustrationFallback}
|
||||
fill
|
||||
alt="missing-illustration"
|
||||
loading="eager"
|
||||
style={{
|
||||
padding: "15%",
|
||||
aspectRatio:
|
||||
props.aspectRatio?.toString() ?? "1",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
{props.fallback && (
|
||||
<Box
|
||||
sx={{
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
position: "absolute",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
disabled
|
||||
sx={{ fontSize: "large" }}
|
||||
component="div"
|
||||
>
|
||||
{props.fallback}
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Image
|
||||
onError={() => setLoadingState("errored")}
|
||||
@@ -205,7 +216,7 @@ const Illustration = (props: IllustrationProps) => {
|
||||
...props.imgProps,
|
||||
}}
|
||||
src={
|
||||
API.getIllustrationURL(url) +
|
||||
api.getIllustrationURL(url) +
|
||||
(props.quality === "original"
|
||||
? ""
|
||||
: `${
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* 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 {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Dialog,
|
||||
Grid,
|
||||
ListItemIcon,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ThemeProvider,
|
||||
Tooltip,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { deepmerge } from "@mui/utils";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Action from "~/components/actions";
|
||||
import {
|
||||
AscIcon,
|
||||
CheckIcon,
|
||||
DescIcon,
|
||||
GridIcon,
|
||||
ListIcon,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
} from "~/components/icons";
|
||||
import type { TranslationKey } from "~/i18n/i18n";
|
||||
import { ItemSize } from "~/utils/layout";
|
||||
import type { FilterControl } from "./filters/control";
|
||||
import type { LayoutControl } from "./layout";
|
||||
import type { SortControl } from "./sort";
|
||||
|
||||
// Controls should not maintain state regarding options
|
||||
// It should rely on the onUpdate function to update external state
|
||||
export const Controls = <SortingKey extends string>(props: {
|
||||
layout?: LayoutControl;
|
||||
sort?: SortControl<SortingKey>;
|
||||
actions?: Action[][];
|
||||
filters?: FilterControl<any>[]; // Putting string prevent from typechecking if filter is library + song type
|
||||
}) => {
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
gap={1}
|
||||
sx={{
|
||||
zIndex: 1000,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
marginBottom: 2,
|
||||
position: "sticky",
|
||||
top: 16,
|
||||
left: 0,
|
||||
}}
|
||||
>
|
||||
{props.filters && (
|
||||
<Grid>
|
||||
<ButtonGroup variant="contained">
|
||||
{props.filters?.map((filter, idx) => (
|
||||
<FilterMenuButton key={idx} filter={filter} />
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</Grid>
|
||||
)}
|
||||
{props.sort && (
|
||||
<Grid>
|
||||
<ButtonGroup variant="contained">
|
||||
<SortMenuButton sort={props.sort} />
|
||||
</ButtonGroup>
|
||||
</Grid>
|
||||
)}
|
||||
{props.layout && (
|
||||
<Grid>
|
||||
<LayoutButtonGroup layout={props.layout} />
|
||||
</Grid>
|
||||
)}
|
||||
{props.actions?.map((actions, idx) => (
|
||||
<Grid key={idx}>
|
||||
<ActionButtonGroup actions={actions} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
const FilterMenuButton = <FilterKeys extends string>({
|
||||
filter,
|
||||
}: { filter: FilterControl<FilterKeys> }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MenuButton
|
||||
label={
|
||||
filter.values === undefined
|
||||
? "misc.loading"
|
||||
: filter.buttonLabel
|
||||
}
|
||||
icon={filter.values && filter.buttonIcon}
|
||||
items={(closeMenu) =>
|
||||
filter.values?.map((key) => (
|
||||
<MenuItem
|
||||
key={key}
|
||||
selected={
|
||||
filter.multipleChoice
|
||||
? filter.selected?.includes(key)
|
||||
: filter.selected === key
|
||||
}
|
||||
onClick={() => {
|
||||
if (filter.multipleChoice) {
|
||||
const selectedKeys = filter.selected.includes(
|
||||
key,
|
||||
)
|
||||
? filter.selected.filter((k) => k !== key)
|
||||
: [key, ...filter.selected];
|
||||
filter.onUpdate(selectedKeys);
|
||||
} else {
|
||||
filter.onUpdate(
|
||||
filter.selected === key ? null : key,
|
||||
);
|
||||
}
|
||||
// TODO Not close at every select
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{(filter.multipleChoice &&
|
||||
filter.selected.includes(key)) ||
|
||||
filter.selected === key ? (
|
||||
<CheckIcon />
|
||||
) : undefined}
|
||||
</ListItemIcon>
|
||||
{t(filter.formatItem(key))}
|
||||
</MenuItem>
|
||||
)) ?? []
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SortMenuButton = <SortingKey extends string>({
|
||||
sort,
|
||||
}: { sort: SortControl<SortingKey> }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MenuButton
|
||||
label={sort.buttonLabel}
|
||||
icon={sort.selected.order === "asc" ? <AscIcon /> : <DescIcon />}
|
||||
items={(closeMenu) =>
|
||||
sort.sortingKeys.map((key) => (
|
||||
<MenuItem
|
||||
key={key}
|
||||
selected={sort.selected.sort === key}
|
||||
onClick={() => {
|
||||
sort?.onUpdate({
|
||||
sort: key,
|
||||
order:
|
||||
sort.selected.sort !== key
|
||||
? "asc"
|
||||
: sort.selected.order === "asc"
|
||||
? "desc"
|
||||
: "asc",
|
||||
});
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{sort?.selected.sort === key ? (
|
||||
sort.selected.order === "asc" ? (
|
||||
<AscIcon />
|
||||
) : (
|
||||
<DescIcon />
|
||||
)
|
||||
) : undefined}
|
||||
</ListItemIcon>
|
||||
{t(sort!.formatItem(key))}
|
||||
</MenuItem>
|
||||
)) ?? []
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const LayoutButtonGroup = ({ layout }: { layout: LayoutControl }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ButtonGroup variant="contained">
|
||||
{layout.layout === "grid" &&
|
||||
[
|
||||
[
|
||||
"xs",
|
||||
(currentIndex: number) => currentIndex - 1,
|
||||
MinusIcon,
|
||||
] as const,
|
||||
[
|
||||
"xl",
|
||||
(currentIndex: number) => currentIndex + 1,
|
||||
PlusIcon,
|
||||
] as const,
|
||||
].map(([size, f, Icon]) => (
|
||||
<Button
|
||||
key={`size-button-${size}`}
|
||||
disabled={layout.itemSize === size}
|
||||
onClick={() =>
|
||||
layout.onUpdate({
|
||||
layout: layout.layout,
|
||||
itemSize:
|
||||
ItemSize[
|
||||
f(ItemSize.indexOf(layout.itemSize))
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icon />
|
||||
</Button>
|
||||
))}
|
||||
{layout.enableToggle && (
|
||||
<Tooltip title={t("browsing.controls.changeLayout")}>
|
||||
<Button
|
||||
onClick={() =>
|
||||
layout.onUpdate({
|
||||
itemSize: layout.itemSize,
|
||||
layout:
|
||||
layout.layout === "grid" ? "list" : "grid",
|
||||
})
|
||||
}
|
||||
>
|
||||
{layout.layout === "grid" ? <ListIcon /> : <GridIcon />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const ActionButtonGroup = ({ actions }: { actions: Action[] }) => {
|
||||
const { t } = useTranslation();
|
||||
const [openedDialogIndex, setOpenedDialogIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const closeModal = () => setOpenedDialogIndex(null);
|
||||
|
||||
return (
|
||||
<ButtonGroup variant="contained">
|
||||
{actions.map((action, idx) => (
|
||||
<Fragment key={`action-${action.label}`}>
|
||||
<Button
|
||||
key={`action-${action.label}`}
|
||||
startIcon={action.icon}
|
||||
variant="contained"
|
||||
onClickCapture={() => {
|
||||
if (action.disabled === true) {
|
||||
return;
|
||||
}
|
||||
action.onClick?.();
|
||||
action.dialog && setOpenedDialogIndex(idx);
|
||||
}}
|
||||
>
|
||||
{t(action.label)}
|
||||
</Button>
|
||||
{action.dialog && (
|
||||
<Dialog
|
||||
key={idx}
|
||||
open={openedDialogIndex === idx}
|
||||
onClose={closeModal}
|
||||
fullWidth
|
||||
>
|
||||
{action.dialog?.({ close: closeModal })}
|
||||
</Dialog>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuButton = (props: {
|
||||
label: TranslationKey;
|
||||
icon: JSX.Element | undefined;
|
||||
items: (closeMenu: () => void) => JSX.Element | JSX.Element[];
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const extendedTheme = useMemo(
|
||||
() =>
|
||||
deepmerge(theme, {
|
||||
components: {
|
||||
MuiMenuItem: { defaultProps: { sx: { borderRadius: 0 } } },
|
||||
},
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const menuOpen = Boolean(anchorEl);
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLButtonElement>) =>
|
||||
setAnchorEl(event.currentTarget);
|
||||
const handleMenuClose = () => setAnchorEl(null);
|
||||
// TODO Remove border radius or menu items
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={extendedTheme}>
|
||||
<Button onClick={handleMenuOpen} endIcon={props.icon}>
|
||||
{t(props.label as TranslationKey)}
|
||||
</Button>
|
||||
<Menu anchorEl={anchorEl} open={menuOpen} onClose={handleMenuClose}>
|
||||
{props.items(handleMenuClose)}
|
||||
</Menu>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* 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 { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import type { TranslationKey } from "~/i18n/i18n";
|
||||
import { parseQueryParam, setQueryParam } from "~/utils/query-param";
|
||||
|
||||
export type FilterControl<Key extends string> = {
|
||||
// Gives the translation key from an item to choose from
|
||||
formatItem: (k: Key) => TranslationKey;
|
||||
values: readonly Key[] | undefined;
|
||||
buttonLabel: TranslationKey;
|
||||
buttonIcon: JSX.Element | undefined;
|
||||
} & (
|
||||
| {
|
||||
multipleChoice: true;
|
||||
selected: Key[];
|
||||
onUpdate: (keys: Key[]) => void;
|
||||
}
|
||||
| {
|
||||
multipleChoice: false;
|
||||
selected: Key | null;
|
||||
onUpdate: (key: Key | null) => void;
|
||||
}
|
||||
);
|
||||
|
||||
// Control for filter that accept multiple values
|
||||
export const useFiltersControl = <FilterKey extends string>({
|
||||
filterKeys,
|
||||
filterId,
|
||||
buttonLabel,
|
||||
buttonIcon,
|
||||
formatItem,
|
||||
}: {
|
||||
buttonLabel: (
|
||||
selected: FilterKey[],
|
||||
) => FilterControl<FilterKey>["buttonLabel"];
|
||||
buttonIcon: FilterControl<FilterKey>["buttonIcon"];
|
||||
formatItem: FilterControl<FilterKey>["formatItem"];
|
||||
filterKeys: readonly FilterKey[] | undefined;
|
||||
// Used to persist Filter in query param, must be unique
|
||||
filterId: string;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const [selectedFilters, setFilterState] = useState(() => {
|
||||
let query = router.query[filterId];
|
||||
if (!filterKeys || query === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (typeof query === "string") {
|
||||
query = query.trim().split(",");
|
||||
}
|
||||
return query
|
||||
.map((f) => parseQueryParam(f, filterKeys))
|
||||
.filter((f): f is FilterKey => f !== null);
|
||||
});
|
||||
const control: FilterControl<FilterKey> = {
|
||||
values: filterKeys,
|
||||
buttonIcon,
|
||||
buttonLabel: buttonLabel(selectedFilters),
|
||||
multipleChoice: true,
|
||||
selected: selectedFilters,
|
||||
formatItem: (t: FilterKey) => formatItem(t),
|
||||
onUpdate: (selected) => {
|
||||
setQueryParam(
|
||||
[[filterId, selected.length ? selected.join(",") : null]],
|
||||
router,
|
||||
);
|
||||
setFilterState(selected);
|
||||
},
|
||||
};
|
||||
return [selectedFilters, control] as const;
|
||||
};
|
||||
|
||||
export const useFilterControl = <FilterKey extends string>({
|
||||
filterKeys,
|
||||
filterId,
|
||||
buttonLabel,
|
||||
buttonIcon,
|
||||
formatItem,
|
||||
}: {
|
||||
buttonLabel: (
|
||||
selected: FilterKey | null,
|
||||
) => FilterControl<FilterKey>["buttonLabel"];
|
||||
buttonIcon: FilterControl<FilterKey>["buttonIcon"];
|
||||
formatItem: FilterControl<FilterKey>["formatItem"];
|
||||
filterKeys: readonly FilterKey[] | undefined;
|
||||
filterId: string;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const [selectedFilter, setSelectedFilter] = useState(() => {
|
||||
let query = router.query[filterId];
|
||||
if (!filterKeys || query === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof query !== "string") {
|
||||
query = query[0];
|
||||
}
|
||||
return parseQueryParam(query, filterKeys);
|
||||
});
|
||||
const control: FilterControl<FilterKey> = {
|
||||
values: filterKeys,
|
||||
buttonIcon,
|
||||
buttonLabel: buttonLabel(selectedFilter),
|
||||
multipleChoice: false,
|
||||
selected: selectedFilter,
|
||||
formatItem: (t: FilterKey) => formatItem(t),
|
||||
onUpdate: (selected) => {
|
||||
setQueryParam([[filterId, selected ?? null]], router);
|
||||
setSelectedFilter(selected);
|
||||
},
|
||||
};
|
||||
return [selectedFilter, control] as const;
|
||||
};
|
||||