Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
6264d094c9 | ||
|
|
280cf19742 | ||
|
|
4625a738ef | ||
|
|
698913bbaf | ||
|
|
d072fbf7af | ||
|
|
3517e2650b | ||
|
|
b118fd20a6 | ||
|
|
0a42fd6460 | ||
|
|
f5b80e1873 | ||
|
|
016c33143d | ||
|
|
cdb29d8cab | ||
|
|
5f63a5122d | ||
|
|
b6283838ce | ||
|
|
7c3dd7738c | ||
|
|
aed577a855 | ||
|
|
928445af44 | ||
|
|
526c546f58 | ||
|
|
64050bbdb9 | ||
|
|
95e55b9307 | ||
|
|
dc9d141072 | ||
|
|
e4bc45aa04 | ||
|
|
41285090f7 | ||
|
|
d091cb550b | ||
|
|
e3ce0a843b | ||
|
|
7ba90a6e0d | ||
|
|
ce298f5340 | ||
|
|
21b3df3b00 | ||
|
|
e92e0cf394 | ||
|
|
9d12a9ae2c | ||
|
|
2596726088 | ||
|
|
79d5aaf5dd | ||
|
|
c757027286 | ||
|
|
85ce3fbbf4 | ||
|
|
7c65d8f113 | ||
|
|
a2a283e146 | ||
|
|
24c5c3aead | ||
|
|
3dddcf4c36 | ||
|
|
ec64e16553 | ||
|
|
ff31958623 | ||
|
|
e31c7f49b6 | ||
|
|
16cc16830c | ||
|
|
fbbc2fbae5 | ||
|
|
3d6f60e850 | ||
|
|
d788a5f704 | ||
|
|
affd84272c | ||
|
|
634b0f2de4 | ||
|
|
32a52ec102 | ||
|
|
118484b766 | ||
|
|
50647a3e86 | ||
|
|
655728b657 | ||
|
|
79aee767cf | ||
|
|
2cd96e10dc | ||
|
|
f05b51300c | ||
|
|
2fa3946af7 | ||
|
|
b00badc0ca | ||
|
|
95dc125712 | ||
|
|
7fe4de658e | ||
|
|
b254796fc7 | ||
|
|
556df14a87 | ||
|
|
de2cfc18f7 | ||
|
|
68229e5e0c | ||
|
|
f02d3e3b6a | ||
|
|
e9b9577673 | ||
|
|
28f220c4cb | ||
|
|
3f200dcf3a | ||
|
|
db9150af9f | ||
|
|
5aaaa63a63 | ||
|
|
0f50129c21 | ||
|
|
a913482e18 | ||
|
|
c86b8f0fb7 | ||
|
|
f95127a105 | ||
|
|
6f485b3099 | ||
|
|
a07b78e697 | ||
|
|
d11dc7308b | ||
|
|
42ecd2d930 | ||
|
|
225babc1d0 | ||
|
|
be1503633a | ||
|
|
fc3fdcf19c | ||
|
|
390a7fe2f4 | ||
|
|
dc4a5ce0da | ||
|
|
d915a4dfdb | ||
|
|
7cbdca9439 | ||
|
|
d7b6feec2b | ||
|
|
0c05dfee29 | ||
|
|
026eb05cd3 | ||
|
|
02d5bc95e8 | ||
|
|
ce4153a954 | ||
|
|
3aa66792c9 | ||
|
|
24222e3623 | ||
|
|
a908d6756c | ||
|
|
3479713970 | ||
|
|
40b2a3b010 | ||
|
|
1ef9026776 | ||
|
|
19549ba923 | ||
|
|
878579a0b8 | ||
|
|
d0603e0141 | ||
|
|
5089aa0c3f | ||
|
|
ab29710e99 | ||
|
|
0a05e20497 | ||
|
|
4bc542e360 | ||
|
|
b65590c1eb | ||
|
|
3f176700bb | ||
|
|
dc9cbbd442 | ||
|
|
5dc0ed1623 | ||
|
|
a9ab63591a | ||
|
|
135bb7d40d | ||
|
|
72559e7a8f | ||
|
|
b042bdf9cb | ||
|
|
0226ca166c | ||
|
|
77abd9f3e6 | ||
|
|
26772e2b76 | ||
|
|
321f2b3d88 | ||
|
|
37c34f6e3e | ||
|
|
4bbe1a4514 | ||
|
|
d521415458 | ||
|
|
fcc2407d26 | ||
|
|
76bc2410ec | ||
|
|
2c91936682 | ||
|
|
c411bc7b37 | ||
|
|
1cb6820c2b | ||
|
|
3a26a696dc | ||
|
|
a3e6bdefff | ||
|
|
09777dcfc1 | ||
|
|
b10a1b0cba | ||
|
|
5fb615013d | ||
|
|
7607f09396 | ||
|
|
99805cf9d6 | ||
|
|
faa837fff7 | ||
|
|
fb0756aa23 | ||
|
|
1d8f4963d4 | ||
|
|
98e1fdff2e | ||
|
|
6e36c4988b | ||
|
|
6c335874e1 | ||
|
|
34ca446bc9 | ||
|
|
b415eba73a | ||
|
|
8fe7db2ea6 | ||
|
|
751997d6cb | ||
|
|
cb1013fde1 | ||
|
|
33eb777d4f | ||
|
|
9c0c039a0f | ||
|
|
ebf9c57318 | ||
|
|
84f6868390 | ||
|
|
b44fba44a3 | ||
|
|
b1a585cec4 | ||
|
|
16a1ad0810 | ||
|
|
160513e82b | ||
|
|
6f6e03bda5 | ||
|
|
19ef1cc6b8 | ||
|
|
a609f1058e | ||
|
|
b0e6a361bb | ||
|
|
9f05114be2 | ||
|
|
bbbe62f880 | ||
|
|
023c74d1c2 | ||
|
|
07da1f5428 | ||
|
|
dbf75b8d20 | ||
|
|
a2e351c670 | ||
|
|
bb147d567f | ||
|
|
4e82669f4b | ||
|
|
31a4ae5b9a | ||
|
|
606ad93395 | ||
|
|
b078ba20a4 | ||
|
|
26dd43c2ee | ||
|
|
87d82b3928 | ||
|
|
93771c4551 | ||
|
|
a5d151470e | ||
|
|
7d65700a70 | ||
|
|
5f58e26314 | ||
|
|
4968ea0a40 | ||
|
|
3560edb27b | ||
|
|
67c7e2b80b | ||
|
|
1dc7819162 | ||
|
|
169383519b | ||
|
|
e2a9ea96fd | ||
|
|
20d7f69ef2 | ||
|
|
f68dc3a394 | ||
|
|
502f01f8b2 | ||
|
|
4d47ba66a2 | ||
|
|
25d5e194dc | ||
|
|
4bdbf6340b | ||
|
|
5e5cfd8722 | ||
|
|
2e1337d5d7 | ||
|
|
3ad0cc92b6 | ||
|
|
db52907042 | ||
|
|
7c41127075 | ||
|
|
777e68c869 | ||
|
|
62087ae13b | ||
|
|
e86115740e | ||
|
|
c4b79f37f7 | ||
|
|
18e0b0d00c | ||
|
|
52e3130fea | ||
|
|
59d49e1617 | ||
|
|
85ee9baf51 | ||
|
|
5042cca9f0 | ||
|
|
866041bfb6 | ||
|
|
0723e0e9a1 | ||
|
|
d564252623 | ||
|
|
2124d23cb9 | ||
|
|
7faa482135 | ||
|
|
992d022ba2 | ||
|
|
f22e397de3 | ||
|
|
6d785d4459 | ||
|
|
e96c86b85b | ||
|
|
a530fd04d6 | ||
|
|
220959124a | ||
|
|
1b842094f7 | ||
|
|
96c7e6a99c | ||
|
|
00ca276859 | ||
|
|
0c765703e0 | ||
|
|
7f65834614 | ||
|
|
88a79348c5 | ||
|
|
90882b8e60 | ||
|
|
4ae9de61d7 | ||
|
|
5f7ae2eb30 | ||
|
|
8bcc0fb29d | ||
|
|
cd8a1185f3 | ||
|
|
e4cd30a3e4 | ||
|
|
11c7be61cd | ||
|
|
17089b20c7 | ||
|
|
76dffbd280 | ||
|
|
005006b02a | ||
|
|
de5bca03d9 | ||
|
|
c0a2527284 | ||
|
|
4787a204ab | ||
|
|
9cf58eb3f9 | ||
|
|
6886c9d757 | ||
|
|
4ab088fd34 | ||
|
|
fed101e8ae | ||
|
|
ea5b8d440e | ||
|
|
b3f7f2206f | ||
|
|
4d58545b3b | ||
|
|
4f3d542159 | ||
|
|
e5b9c2e3b2 | ||
|
|
f3e56f32c0 | ||
|
|
2aa6eb8499 | ||
|
|
ed03dca79e | ||
|
|
2e438f12f4 | ||
|
|
28fdc0154d | ||
|
|
60eb19e29f | ||
|
|
f69ba8a7f1 | ||
|
|
0fac03a214 | ||
|
|
dca04b202b | ||
|
|
155cb9e039 | ||
|
|
858b9e0464 | ||
|
|
b04cbfad92 | ||
|
|
762b31108e | ||
|
|
bb430aea37 | ||
|
|
2a541cb643 | ||
|
|
ad3fbd7388 | ||
|
|
ee40992008 | ||
|
|
1beeeb1836 | ||
|
|
e97e811998 | ||
|
|
ffe576d601 | ||
|
|
30def99958 | ||
|
|
76c1ecd71c | ||
|
|
16d62a7e19 | ||
|
|
522330d14d | ||
|
|
7c9d78e875 | ||
|
|
aae10672dd | ||
|
|
3ebac555fa | ||
|
|
75788e478b | ||
|
|
73ba1ca398 | ||
|
|
527ca4df5d | ||
|
|
27b2b9d813 | ||
|
|
ae44cd5574 | ||
|
|
31831e25ce | ||
|
|
ba9fc80f82 | ||
|
|
f45d4df3fe | ||
|
|
5ff9ad6953 | ||
|
|
6eb0055ca5 | ||
|
|
aa009b4352 | ||
|
|
05b69097e9 | ||
|
|
ab204507e7 | ||
|
|
879f3a8805 | ||
|
|
571cde7010 | ||
|
|
6c643b6f95 | ||
|
|
5d686259c7 | ||
|
|
f0ced36c54 | ||
|
|
65341ebc2f | ||
|
|
b873869559 | ||
|
|
967328f494 | ||
|
|
88e8359d2c | ||
|
|
4f76203dcc | ||
|
|
b2002336e1 | ||
|
|
b39dc06298 | ||
|
|
9db513b786 | ||
|
|
4091703afe | ||
|
|
aa17aba53c | ||
|
|
7e7aacfead | ||
|
|
e2f3b6c07d | ||
|
|
0b93b767f2 | ||
|
|
d4286349e9 |
No files matched your search
+18
-4
@@ -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=
|
||||
@@ -7,6 +14,10 @@ POSTGRES_USER=
|
||||
POSTGRES_PASSWORD=
|
||||
# Name of Meelo's database
|
||||
POSTGRES_DB=
|
||||
# Username of Meelo's Message Queue
|
||||
RABBITMQ_USER=
|
||||
# Password of Meelo's Message Queue
|
||||
RABBITMQ_PASSWORD=
|
||||
#################### Config
|
||||
# The directory that contains the `settings.json` file (and where the illustrations will be stored) (on the host machine)
|
||||
CONFIG_DIR=
|
||||
@@ -17,16 +28,19 @@ DATA_DIR=
|
||||
# This will not affect front-end behaviour
|
||||
ALLOW_ANONYMOUS=
|
||||
#################### Web app
|
||||
# URL of the server/api that would be accessible to the front app's server or any client
|
||||
# If PORT=5000, it should look like 'http://0.0.0.0:5000/api' (mind the '/api')
|
||||
PUBLIC_SERVER_URL=
|
||||
# Public URL to access Meelo, without trailing slash
|
||||
# Example: http://localhost:5000
|
||||
PUBLIC_URL=
|
||||
#################### Security
|
||||
# Random String used to sign JWT Tokens
|
||||
JWT_SIGNATURE=
|
||||
# Key used to authenticate the Meilisearch Instance
|
||||
# Should be a random string, must be at least 16 bytes
|
||||
MEILI_MASTER_KEY=
|
||||
# A coma-separated list of random strings used for internal authentication
|
||||
# Example: API_KEYS=azertyuip,qwertyuiop
|
||||
API_KEYS=
|
||||
#################### Internal
|
||||
# Do not change this
|
||||
INTERNAL_DATA_DIR=/data
|
||||
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-
|
||||
@@ -0,0 +1,20 @@
|
||||
name: 'Cache (Matcher)'
|
||||
description: 'Cache for Matcher'
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- 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
|
||||
@@ -0,0 +1,15 @@
|
||||
name: 'Cache (Scanner)'
|
||||
description: 'Cache for Scanner'
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- run: |
|
||||
cd scanner
|
||||
go install github.com/swaggo/swag/cmd/swag@latest
|
||||
~/go/bin/swag init -d app -o ./app/docs
|
||||
shell: bash
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
check-latest: true
|
||||
go-version-file: 'scanner/go.mod'
|
||||
cache-dependency-path: scanner/go.sum
|
||||
@@ -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: linux/amd64,linux/arm64
|
||||
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
|
||||
+20
-73
@@ -1,9 +1,9 @@
|
||||
name: "Front"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main, v2 ]
|
||||
branches: [ main, v3 ]
|
||||
push:
|
||||
branches: [ main, v2 ]
|
||||
branches: [ main, v3 ]
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
@@ -28,6 +28,7 @@ jobs:
|
||||
front:
|
||||
- 'front/**'
|
||||
- '.github/workflows/front.yml'
|
||||
- '.github/actions/cache-front/**'
|
||||
Build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
|
||||
@@ -42,11 +43,14 @@ jobs:
|
||||
run: yarn install
|
||||
- name: Build
|
||||
run: yarn build
|
||||
Lint:
|
||||
Biome:
|
||||
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
|
||||
container: node:18-alpine3.18
|
||||
container: node:22-alpine3.20
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Build ]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
@@ -55,21 +59,7 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
- name: Run Linter
|
||||
run: yarn lint
|
||||
Format:
|
||||
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
|
||||
container: node:18-alpine3.18
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Build ]
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-front
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
- name: Check Format
|
||||
run: yarn prettier --check src
|
||||
run: yarn run biome ci .
|
||||
Tests:
|
||||
runs-on: ubuntu-latest
|
||||
container: node:19-alpine
|
||||
@@ -86,7 +76,7 @@ jobs:
|
||||
run: yarn test:cov
|
||||
- name: Upload Unit Tests artifact
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report-front
|
||||
path: ./front/coverage
|
||||
@@ -98,75 +88,32 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Downlad Coverage artifact
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
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') }}
|
||||
@@ -0,0 +1,90 @@
|
||||
name: "Matcher"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main, v3 ]
|
||||
push:
|
||||
branches: [ main, v3 ]
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./matcher
|
||||
|
||||
jobs:
|
||||
Changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
matcher: ${{ steps.filter.outputs.matcher }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
matcher:
|
||||
- 'matcher/**'
|
||||
- '.github/workflows/matcher.yml'
|
||||
- '.github/actions/cache-matcher/**'
|
||||
Typecheck:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
|
||||
container: python:3.14.0a2-alpine3.21
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-matcher
|
||||
- uses: jakebailey/pyright-action@v2
|
||||
with:
|
||||
working-directory: './matcher/matcher'
|
||||
Lint:
|
||||
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
|
||||
container: python:3.14.0a2-alpine3.21
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Typecheck ]
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-matcher
|
||||
- run: pip install ruff
|
||||
- run: ruff check matcher
|
||||
Tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Typecheck ]
|
||||
container: python:3.14.0a2-alpine3.21
|
||||
if: ${{ needs.changes.outputs.matcher == 'true' && always() }}
|
||||
env:
|
||||
GENIUS_ACCESS_TOKEN: "${{ secrets.GENIUS_ACCESS_TOKEN }}"
|
||||
DISCOGS_ACCESS_TOKEN: "${{ secrets.DISCOGS_ACCESS_TOKEN }}"
|
||||
CI: "1"
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-matcher
|
||||
- run: python3 -m tests
|
||||
Dockerize:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
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
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: ./matcher
|
||||
image-suffix: matcher
|
||||
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.matcher == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
name: "Scanner"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main, v3 ]
|
||||
push:
|
||||
branches: [ main, v3 ]
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: ./scanner
|
||||
|
||||
jobs:
|
||||
Changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
scanner: ${{ steps.filter.outputs.scanner }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
scanner:
|
||||
- 'scanner/**'
|
||||
- '.github/workflows/scanner.yml'
|
||||
Build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/cache-scanner
|
||||
- name: Install Dependencies and Build
|
||||
run: go build -o scanner ./app
|
||||
Format:
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Build ]
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/cache-scanner
|
||||
- uses: Jerome1337/gofmt-action@v1.0.5
|
||||
with:
|
||||
gofmt-path: './scanner'
|
||||
gofmt-flags: '-l'
|
||||
Tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Build ]
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && always() }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/cache-scanner
|
||||
- run: sudo apt-get update -y && sudo apt-get install -y ffmpeg
|
||||
- name: Install Coverage Report
|
||||
run: go install github.com/jandelgado/gcov2lcov@latest
|
||||
- name: Run tests
|
||||
run: go test ./... -coverprofile=coverage.out
|
||||
- name: Upload Unit Tests artifact
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report-scanner
|
||||
path: ./scanner/coverage.out
|
||||
Analysis:
|
||||
needs: [ Tests ]
|
||||
name: "Static Analysis"
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && always() }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Downlad Coverage artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-report-scanner
|
||||
path: ./scanner
|
||||
- name: Run SonarCloud Scan
|
||||
uses: SonarSource/sonarqube-scan-action@master
|
||||
with:
|
||||
projectBaseDir: ./scanner
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_SCANNER }}
|
||||
Dockerize:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
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
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: ./scanner
|
||||
image-suffix: scanner
|
||||
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: "Server"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main, v2 ]
|
||||
branches: [ main, v3 ]
|
||||
push:
|
||||
branches: [ main, v2 ]
|
||||
branches: [ main, v3 ]
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
@@ -28,10 +28,11 @@ jobs:
|
||||
server:
|
||||
- 'server/**'
|
||||
- '.github/workflows/server.yml'
|
||||
- '.github/actions/cache-server/**'
|
||||
Build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
|
||||
container: node:18-alpine3.18
|
||||
container: node:22-alpine3.20
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
@@ -42,11 +43,14 @@ jobs:
|
||||
run: yarn install
|
||||
- name: Build
|
||||
run: yarn build
|
||||
Lint:
|
||||
Biome:
|
||||
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
|
||||
container: node:18-alpine3.18
|
||||
container: node:22-alpine3.20
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Build ]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
@@ -55,29 +59,14 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
- name: Run Linter
|
||||
run: yarn lint
|
||||
Format:
|
||||
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
|
||||
container: node:18-alpine3.18
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Build ]
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-server
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
- name: Check Format
|
||||
run: yarn prettier --check src
|
||||
run: yarn run biome ci .
|
||||
Tests:
|
||||
runs-on: ubuntu-latest
|
||||
container: node:18-alpine3.18
|
||||
container: node:22-alpine3.20
|
||||
needs: [ Build ]
|
||||
if: ${{ needs.changes.outputs.server == 'true' && always() }}
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
REDIS_HOST: "redis"
|
||||
DATABASE_URL: "postgresql://test:test@postgres:5432/test?schema=public"
|
||||
JWT_SIGNATURE: "12345"
|
||||
GENIUS_ACCESS_TOKEN: "${{ secrets.GENIUS_ACCESS_TOKEN }}"
|
||||
@@ -97,15 +86,6 @@ jobs:
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7.0-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.5
|
||||
ports:
|
||||
@@ -125,7 +105,7 @@ jobs:
|
||||
run: yarn --cwd server test --coverage --ci --no-cache --maxConcurrency=1
|
||||
- name: Upload Unit Tests artifact
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report-server
|
||||
path: ./server/coverage
|
||||
@@ -137,75 +117,32 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Downlad Coverage artifact
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
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') }}
|
||||
@@ -10,8 +10,8 @@ While the software wants to be a flexible as possible, it requires a little bit
|
||||
|
||||
Meelo has all the basic features you might expect from a music player/server (playback, browsing, search, playlists, libraries ...). But it comes with a handful of features that make it unique :star_struck:
|
||||
|
||||
- Music Videos
|
||||
- Access them directly from an album's or an artist's page
|
||||
- Music Videos are first-class citizens
|
||||
- Access them directly from an album's, artist's or song's page
|
||||
- We also differentiate music video from interviews, behind-the-scenes, trailers...
|
||||
- Identifies B-Sides
|
||||
- Meelo shows you any tracks that could be related to an album directly on the album's page!
|
||||
@@ -46,7 +46,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
|
||||
|
||||
@@ -57,6 +57,10 @@ We are actively working on a public demo, so that you can try out Meelo. Stay tu
|
||||
<details>
|
||||
<summary>Unfold to see what the web application looks like!</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/48141241-25ec-4570-a4f1-696c0cdfcaaa
|
||||
|
||||
---
|
||||
|
||||

|
||||

|
||||

|
||||
@@ -68,12 +72,14 @@ More screenshots [here](./assets/examples/)
|
||||
|
||||
## :handshake: Contributing and Bugs reporting
|
||||
|
||||
:hammer: Would you like to contribute to Meelo? Feel free to [open a pull request](https://github.com/Arthi-chaud/Meelo/compare).
|
||||
|
||||
: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!
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": false,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true,
|
||||
"defaultBranch": "main"
|
||||
},
|
||||
"files": {
|
||||
"ignore": ["**/.yarn/**", "**/dist/**", "**/package.json"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"indentWidth": 4
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"noParameterAssign": "off"
|
||||
},
|
||||
"performance": {
|
||||
"noAccumulatingSpread": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noBannedTypes": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noConsole": "error"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedImports": "warn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
},
|
||||
"parser": {
|
||||
"unsafeParameterDecoratorsEnabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
-12
@@ -10,12 +10,20 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
mq:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
volumes:
|
||||
- ./server/src:/app/src
|
||||
- ./server/prisma:/app/prisma
|
||||
- ./server/test:/app/test
|
||||
- ./server/package.json:/app/package.json
|
||||
- ./server/.yarn:/app/.yarn
|
||||
- server_deps:/app/node_modules
|
||||
- ./server/.yarnrc.yml:/app/.yarnrc.yml
|
||||
- ./server/tsconfig.json:/app/tsconfig.json
|
||||
- ./server/tsconfig.build.json:/app/tsconfig.build.json
|
||||
@@ -26,16 +34,44 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
- TRANSCODER_URL=http://transcoder:7666
|
||||
- POSTGRES_HOST=db
|
||||
- MEILI_HOST=http://meilisearch:7700
|
||||
- REDIS_HOST=redis
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?schema=public
|
||||
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
|
||||
scanner:
|
||||
build:
|
||||
context: ./scanner
|
||||
dockerfile: Dockerfile.dev
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- localhost:8133"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
ports:
|
||||
- "8133:8133"
|
||||
environment:
|
||||
- API_URL=http://server:4000
|
||||
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
|
||||
- INTERNAL_DATA_DIR=${INTERNAL_DATA_DIR}
|
||||
- API_KEYS=${API_KEYS}
|
||||
volumes:
|
||||
- ./scanner:/app
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
|
||||
front:
|
||||
build:
|
||||
context: ./front
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
scanner:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./front/src:/app/src
|
||||
- ./front/public:/app/public
|
||||
@@ -43,17 +79,30 @@ services:
|
||||
- ./front/package.json:/app/package.json
|
||||
- ./front/tsconfig.json:/app/tsconfig.json
|
||||
- ./front/yarn.lock:/app/yarn.lock
|
||||
- front_deps:/app/node_modules
|
||||
environment:
|
||||
# This can be commented out. see front/next.config.js
|
||||
- PUBLIC_SERVER_URL=http://localhost:4000
|
||||
- SSR_SERVER_URL=http://server:4000
|
||||
redis:
|
||||
image: redis:7.0-alpine
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli","ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
- PUBLIC_SCANNER_URL=http://localhost:8133
|
||||
- SSR_SCANNER_URL=http://scanner:8133
|
||||
matcher:
|
||||
build:
|
||||
context: ./matcher
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
mq:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- API_URL=http://server:4000
|
||||
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
|
||||
- API_KEYS=${API_KEYS}
|
||||
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
|
||||
volumes:
|
||||
- ./matcher:/app
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.5
|
||||
restart: on-failure
|
||||
@@ -64,6 +113,7 @@ services:
|
||||
environment:
|
||||
- MEILI_ENV=development
|
||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
|
||||
- MEILI_LOG_LEVEL=WARN
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--spider", "http://0.0.0.0:7700/health"]
|
||||
timeout: 5s
|
||||
@@ -85,6 +135,9 @@ services:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- transcoder_cache:/cache
|
||||
profiles: ['', 'cpu']
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
db:
|
||||
image: postgres:alpine3.16
|
||||
healthcheck:
|
||||
@@ -94,7 +147,22 @@ services:
|
||||
retries: 5
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
mq:
|
||||
image: rabbitmq:4.0-alpine
|
||||
environment:
|
||||
- RABBITMQ_NODENAME=meelo
|
||||
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
|
||||
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
|
||||
healthcheck:
|
||||
test: rabbitmq-diagnostics -q ping
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
volumes:
|
||||
search:
|
||||
transcoder_cache:
|
||||
transcoder_cache:
|
||||
front_deps:
|
||||
server_deps:
|
||||
rabbitmq_data:
|
||||
+65
-13
@@ -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
|
||||
@@ -10,6 +10,8 @@ services:
|
||||
condition: service_healthy
|
||||
meilisearch:
|
||||
condition: service_healthy
|
||||
mq:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}
|
||||
@@ -18,31 +20,62 @@ services:
|
||||
environment:
|
||||
- TRANSCODER_URL=http://transcoder:7666
|
||||
- MEILI_HOST=http://meilisearch:7700
|
||||
- REDIS_HOST=redis
|
||||
- POSTGRES_HOST=db
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?schema=public
|
||||
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
scanner:
|
||||
image: arthichaud/meelo-scanner:${TAG:-latest}
|
||||
expose:
|
||||
- 8133
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- localhost:8133"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
environment:
|
||||
- API_URL=http://server:4000
|
||||
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
|
||||
- INTERNAL_DATA_DIR=${INTERNAL_DATA_DIR}
|
||||
- API_KEYS=${API_KEYS}
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
|
||||
front:
|
||||
image: ghcr.io/arthi-chaud/meelo-front:latest
|
||||
image: arthichaud/meelo-front:${TAG:-latest}
|
||||
expose:
|
||||
- 3000
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
scanner:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- PUBLIC_SERVER_URL=${PUBLIC_SERVER_URL}
|
||||
- PUBLIC_SERVER_URL=${PUBLIC_URL}/api
|
||||
- SSR_SERVER_URL=http://server:4000
|
||||
redis:
|
||||
image: redis:7.0-alpine
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli","ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
- PUBLIC_SCANNER_URL=${PUBLIC_URL}/scanner
|
||||
- SSR_SCANNER_URL=http://scanner:8133
|
||||
matcher:
|
||||
image: arthichaud/meelo-matcher:${TAG:-latest}
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
mq:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- API_URL=http://server:4000
|
||||
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
|
||||
- API_KEYS=${API_KEYS}
|
||||
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
|
||||
volumes:
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
|
||||
db:
|
||||
image: postgres:alpine3.14
|
||||
healthcheck:
|
||||
@@ -64,6 +97,7 @@ services:
|
||||
environment:
|
||||
- MEILI_ENV=production
|
||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
|
||||
- MEILI_LOG_LEVEL=WARN
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--spider", "http://0.0.0.0:7700/health"]
|
||||
timeout: 5s
|
||||
@@ -84,7 +118,23 @@ services:
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- transcoder_cache:/cache
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
profiles: ['', 'cpu']
|
||||
mq:
|
||||
image: rabbitmq:4.0-alpine
|
||||
environment:
|
||||
- RABBITMQ_NODENAME=meelo
|
||||
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
|
||||
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
|
||||
healthcheck:
|
||||
test: rabbitmq-diagnostics -q ping
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
nginx:
|
||||
restart: on-failure
|
||||
image: nginx:1.24.0-alpine
|
||||
@@ -99,6 +149,7 @@ services:
|
||||
- PORT=5000
|
||||
- FRONT_URL=http://front:3000
|
||||
- SERVER_URL=http://server:4000
|
||||
- SCANNER_URL=http://scanner:8133
|
||||
volumes:
|
||||
- ./nginx.conf.template:/etc/nginx/templates/meelo.conf.template:ro
|
||||
volumes:
|
||||
@@ -107,3 +158,4 @@ volumes:
|
||||
data:
|
||||
search:
|
||||
transcoder_cache:
|
||||
rabbitmq_data:
|
||||
+62
-8
@@ -11,6 +11,8 @@ services:
|
||||
condition: service_healthy
|
||||
meilisearch:
|
||||
condition: service_healthy
|
||||
mq:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}
|
||||
@@ -19,9 +21,8 @@ services:
|
||||
environment:
|
||||
- TRANSCODER_URL=http://transcoder:7666
|
||||
- MEILI_HOST=http://meilisearch:7700
|
||||
- REDIS_HOST=redis
|
||||
- POSTGRES_HOST=db
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?schema=public
|
||||
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
|
||||
interval: 5s
|
||||
@@ -35,22 +36,59 @@ services:
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
scanner:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- PUBLIC_SERVER_URL=${PUBLIC_SERVER_URL}
|
||||
- PUBLIC_SERVER_URL=${PUBLIC_URL}/api
|
||||
- SSR_SERVER_URL=http://server:4000
|
||||
redis:
|
||||
image: redis:7.0-alpine
|
||||
- PUBLIC_SCANNER_URL=${PUBLIC_URL}/scanner
|
||||
- SSR_SCANNER_URL=http://scanner:8133
|
||||
scanner:
|
||||
build:
|
||||
context: ./scanner
|
||||
expose:
|
||||
- 8133
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli","ping"]
|
||||
test: ["CMD-SHELL", "wget -qO- localhost:8133"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
retries: 10
|
||||
environment:
|
||||
- API_URL=http://server:4000
|
||||
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
|
||||
- INTERNAL_DATA_DIR=${INTERNAL_DATA_DIR}
|
||||
- API_KEYS=${API_KEYS}
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
|
||||
matcher:
|
||||
build:
|
||||
context: ./matcher
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
mq:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- API_URL=http://server:4000
|
||||
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
|
||||
- API_KEYS=${API_KEYS}
|
||||
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
|
||||
volumes:
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
|
||||
transcoder:
|
||||
image: ghcr.io/zoriya/kyoo_transcoder:master
|
||||
restart: on-failure
|
||||
cpus: 1
|
||||
expose:
|
||||
- 7666
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- GOCODER_SAFE_PATH=${INTERNAL_DATA_DIR}
|
||||
- POSTGRES_USER=${POSTGRES_USER}
|
||||
@@ -83,10 +121,24 @@ services:
|
||||
environment:
|
||||
- MEILI_ENV=production
|
||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
|
||||
- MEILI_LOG_LEVEL=WARN
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--spider", "http://0.0.0.0:7700/health"]
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
mq:
|
||||
image: rabbitmq:4.0-alpine
|
||||
environment:
|
||||
- RABBITMQ_NODENAME=meelo
|
||||
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
|
||||
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
|
||||
healthcheck:
|
||||
test: rabbitmq-diagnostics -q ping
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
nginx:
|
||||
restart: on-failure
|
||||
image: nginx:1.24.0-alpine
|
||||
@@ -101,6 +153,7 @@ services:
|
||||
- PORT=5000
|
||||
- FRONT_URL=http://front:3000
|
||||
- SERVER_URL=http://server:4000
|
||||
- SCANNER_URL=http://scanner:8133
|
||||
volumes:
|
||||
- ./nginx.conf.template:/etc/nginx/templates/meelo.conf.template:ro
|
||||
volumes:
|
||||
@@ -109,3 +162,4 @@ volumes:
|
||||
data:
|
||||
search:
|
||||
transcoder_cache:
|
||||
rabbitmq_data:
|
||||
@@ -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,40 +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 | | |
|
||||
| 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.
|
||||
@@ -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 ECMAScript flavor)
|
||||
@@ -1,32 +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]`)
|
||||
- `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.
|
||||
@@ -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,29 +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
|
||||
- Acapella
|
||||
- Edit
|
||||
- Of the original version.
|
||||
- If the song is an edit of a remix, it will be categorised as 'Remix'
|
||||
- Clean
|
||||
- Demo
|
||||
|
||||
## 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,3 +0,0 @@
|
||||
.next/
|
||||
node_modules/
|
||||
.yarn/
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"tabWidth": 4,
|
||||
"useTabs": true,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"quoteProps": "as-needed",
|
||||
"jsxSingleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"singleAttributePerLine": false
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"extends": ["../biome.json"],
|
||||
"formatter": {
|
||||
"lineWidth": 80
|
||||
},
|
||||
"files": {
|
||||
"ignore": ["**/.next/**"]
|
||||
},
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExtraNonNullAssertion": "warn",
|
||||
"noArrayIndexKey": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "off",
|
||||
"noUnusedVariables": "warn"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "off"
|
||||
},
|
||||
"nursery": {
|
||||
"noRestrictedImports": {
|
||||
"options": {
|
||||
"paths": {
|
||||
"format-duration": "Use wrapper from 'theme/provider'",
|
||||
"@mui/icons-material": "Use icons in 'src/components/icons'"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,12 @@ if (process.env.NODE_ENV !== "production") {
|
||||
? `${process.env.SSR_SERVER_URL}/:path*`
|
||||
: "/api/:path*",
|
||||
},
|
||||
{
|
||||
source: "/scanner/:path*",
|
||||
destination: process.env.SSR_SCANNER_URL
|
||||
? `${process.env.SSR_SCANNER_URL}/:path*`
|
||||
: "/scanner/:path*",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+7
-18
@@ -6,8 +6,8 @@
|
||||
"start:dev": "next dev",
|
||||
"build": "next build",
|
||||
"start:prod": "next start",
|
||||
"lint": "next lint",
|
||||
"format": "prettier --write .",
|
||||
"lint": "biome lint .",
|
||||
"format": "biome format .",
|
||||
"test": "jest -i",
|
||||
"test:cov": "jest -i --coverage"
|
||||
},
|
||||
@@ -20,20 +20,20 @@
|
||||
"@mui/utils": "^5.10.6",
|
||||
"@mui/x-data-grid": "^5.17.14",
|
||||
"@mui/x-date-pickers": "^5.0.8",
|
||||
"@reduxjs/toolkit": "^1.8.5",
|
||||
"accept-language-parser": "^1.5.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"change-case": "^5.1.2",
|
||||
"cookies-next": "^2.1.1",
|
||||
"cookies-next": "^5.1.0",
|
||||
"core-js": "^3.26.1",
|
||||
"d3-array": "^3.2.0",
|
||||
"format-duration": "^2.0.0",
|
||||
"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.5",
|
||||
"next": "14.2.23",
|
||||
"react": "18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-blurhash": "^0.3.0",
|
||||
@@ -45,16 +45,15 @@
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@types/accept-language-parser": "^1.5.3",
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/node": "18.7.7",
|
||||
@@ -63,17 +62,7 @@
|
||||
"@types/react-dom": "18.0.6",
|
||||
"@types/react-infinite-scroller": "^1.2.3",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "12.2.5",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-header": "^3.1.1",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-react": "^7.31.11",
|
||||
"eslint-plugin-unused-imports": "^3.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.1.1",
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
"packageManager": "yarn@4.4.0"
|
||||
|
||||
Vendored
+2
-2
@@ -16,8 +16,8 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import en from "../i18n/translations/en.json";
|
||||
import fr from "../i18n/translations/fr.json";
|
||||
import type en from "../i18n/translations/en.json";
|
||||
import type fr from "../i18n/translations/fr.json";
|
||||
import "i18next";
|
||||
|
||||
declare module "i18next" {
|
||||
|
||||
+276
-177
@@ -16,61 +16,77 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { RequireExactlyOne } from "type-fest";
|
||||
import * as yup from "yup";
|
||||
import { ResourceNotFound } from "../exceptions";
|
||||
import {
|
||||
AlbumInclude,
|
||||
AlbumSortingKeys,
|
||||
AlbumType,
|
||||
type AlbumInclude,
|
||||
type AlbumSortingKeys,
|
||||
type AlbumType,
|
||||
AlbumWithRelations,
|
||||
} from "../models/album";
|
||||
import {
|
||||
ArtistInclude,
|
||||
ArtistSortingKeys,
|
||||
type ArtistInclude,
|
||||
type ArtistSortingKeys,
|
||||
ArtistWithRelations,
|
||||
} from "../models/artist";
|
||||
import {
|
||||
AlbumExternalMetadata,
|
||||
ArtistExternalMetadata,
|
||||
SongExternalMetadata,
|
||||
} from "../models/external-metadata";
|
||||
import File from "../models/file";
|
||||
import Genre from "../models/genre";
|
||||
import Library from "../models/library";
|
||||
import PaginatedResponse, { PaginationParameters } from "../models/pagination";
|
||||
import PaginatedResponse, {
|
||||
type PaginationParameters,
|
||||
} from "../models/pagination";
|
||||
import Playlist, {
|
||||
PlaylistEntryWithRelations,
|
||||
type PlaylistInclude,
|
||||
type PlaylistSortingKeys,
|
||||
PlaylistWithRelations,
|
||||
} from "../models/playlist";
|
||||
import {
|
||||
ReleaseInclude,
|
||||
ReleaseSortingKeys,
|
||||
type ReleaseInclude,
|
||||
type ReleaseSortingKeys,
|
||||
ReleaseWithRelations,
|
||||
} from "../models/release";
|
||||
import {
|
||||
SongInclude,
|
||||
SongSortingKeys,
|
||||
SongType,
|
||||
type SaveSearchItem,
|
||||
type SearchResult,
|
||||
SearchResultTransformer,
|
||||
} from "../models/search";
|
||||
import {
|
||||
type SongInclude,
|
||||
type SongSortingKeys,
|
||||
type SongType,
|
||||
SongWithRelations,
|
||||
} from "../models/song";
|
||||
import { VideoWithRelations } from "../models/video";
|
||||
import {
|
||||
TrackInclude,
|
||||
TrackSortingKeys,
|
||||
TrackType,
|
||||
type SongGroupSortingKeys,
|
||||
SongGroupWithRelations,
|
||||
} from "../models/song-group";
|
||||
import { TaskResponse } from "../models/task";
|
||||
import {
|
||||
type TrackInclude,
|
||||
type TrackSortingKeys,
|
||||
type TrackType,
|
||||
TrackWithRelations,
|
||||
} from "../models/track";
|
||||
import { TracklistItemWithRelations } from "../models/tracklist";
|
||||
import { SortingParameters } from "../utils/sorting";
|
||||
import LibraryTaskResponse from "../models/library-task-response";
|
||||
import { ResourceNotFound } from "../exceptions";
|
||||
import User, { UserSortingKeys } from "../models/user";
|
||||
import store from "../state/store";
|
||||
import File from "../models/file";
|
||||
import { InfiniteQuery, Query } from "./use-query";
|
||||
import * as yup from "yup";
|
||||
import { RequireExactlyOne } from "type-fest";
|
||||
import Playlist, {
|
||||
PlaylistEntryWithRelations,
|
||||
PlaylistInclude,
|
||||
PlaylistSortingKeys,
|
||||
PlaylistWithRelations,
|
||||
} from "../models/playlist";
|
||||
import { isSSR } from "../utils/is-ssr";
|
||||
import { ActiveTask, Task } from "../models/task";
|
||||
import { SearchResult, SearchResultTransformer } from "../models/search";
|
||||
import User, { type UserSortingKeys } from "../models/user";
|
||||
import {
|
||||
SongGroupSortingKeys,
|
||||
SongGroupWithRelations,
|
||||
} from "../models/song-group";
|
||||
type VideoInclude,
|
||||
type VideoSortingKeys,
|
||||
type VideoType,
|
||||
VideoWithRelations,
|
||||
} from "../models/video";
|
||||
import { store } from "../state/store";
|
||||
import { accessTokenAtom } from "../state/user";
|
||||
import { isSSR } from "../utils/is-ssr";
|
||||
import type { SortingParameters } from "../utils/sorting";
|
||||
import type { InfiniteQuery, Query } from "./use-query";
|
||||
|
||||
const AuthenticationResponse = yup.object({
|
||||
access_token: yup.string().required(),
|
||||
@@ -78,8 +94,6 @@ const AuthenticationResponse = yup.object({
|
||||
|
||||
type Identifier = number | string;
|
||||
|
||||
type NullableIdentifier = Identifier | null;
|
||||
|
||||
type AuthenticationResponse = yup.InferType<typeof AuthenticationResponse>;
|
||||
|
||||
type QueryParameters<Keys extends readonly string[]> = {
|
||||
@@ -93,11 +107,17 @@ type AuthenticationInput = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
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<{
|
||||
@@ -106,6 +126,7 @@ type FetchParameters<Keys extends readonly string[], ReturnType> = {
|
||||
customValidator: (value: unknown) => Promise<ReturnType>;
|
||||
}>;
|
||||
|
||||
// biome-ignore lint/complexity/noStaticOnlyClass: OK
|
||||
export default class API {
|
||||
/**
|
||||
* Formats an array of include keys for Query keys
|
||||
@@ -116,7 +137,7 @@ export default class API {
|
||||
includes
|
||||
? Object.entries(includes)
|
||||
.filter(
|
||||
([key, value]) =>
|
||||
([_, value]) =>
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
value !== "view",
|
||||
@@ -127,6 +148,8 @@ export default class API {
|
||||
private static isDev = () => process.env.NODE_ENV === "development";
|
||||
private static SSR_API_URL =
|
||||
process.env.SSR_SERVER_URL ?? process.env.PUBLIC_SERVER_URL!;
|
||||
private static SSR_SCANNER_URL =
|
||||
process.env.SSR_SCANNER_URL ?? process.env.PUBLIC_SCANNER_URL!;
|
||||
static defaultPageSize = 35;
|
||||
|
||||
/**
|
||||
@@ -153,7 +176,7 @@ export default class API {
|
||||
*/
|
||||
static async register(credentials: AuthenticationInput): Promise<User> {
|
||||
return API.fetch({
|
||||
route: "/users/new",
|
||||
route: "/users",
|
||||
data: {
|
||||
name: credentials.username,
|
||||
password: credentials.password,
|
||||
@@ -173,12 +196,16 @@ export default class API {
|
||||
key: ["tasks"],
|
||||
exec: () =>
|
||||
API.fetch({
|
||||
route: `/tasks`,
|
||||
route: "/tasks",
|
||||
errorMessage: "Tasks could not be loaded",
|
||||
parameters: {},
|
||||
service: Service.Scanner,
|
||||
validator: yup.object({
|
||||
active: ActiveTask.required().nullable(),
|
||||
pending: yup.array(Task).required(),
|
||||
current_task: yup.string().required().nullable(),
|
||||
progress: yup.number().required().nullable(),
|
||||
pending_tasks: yup
|
||||
.array(yup.string().required())
|
||||
.required(),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
@@ -193,7 +220,7 @@ export default class API {
|
||||
key: ["libraries"],
|
||||
exec: (pagination) =>
|
||||
API.fetch({
|
||||
route: `/libraries`,
|
||||
route: "/libraries",
|
||||
errorMessage: "Libraries could not be loaded",
|
||||
parameters: { pagination: pagination, include: [] },
|
||||
validator: PaginatedResponse(Library),
|
||||
@@ -219,7 +246,7 @@ export default class API {
|
||||
],
|
||||
exec: (pagination) =>
|
||||
API.fetch({
|
||||
route: `/playlists`,
|
||||
route: "/playlists",
|
||||
errorMessage: "Playlists could not be loaded",
|
||||
parameters: { pagination: pagination, include, sort },
|
||||
otherParameters: filter,
|
||||
@@ -232,7 +259,7 @@ export default class API {
|
||||
|
||||
static async createPlaylist(playlistName: string): Promise<Playlist> {
|
||||
return API.fetch({
|
||||
route: "/playlists/new",
|
||||
route: "/playlists",
|
||||
data: { name: playlistName },
|
||||
errorMessage: "Playlist Creation Failed",
|
||||
parameters: {},
|
||||
@@ -259,7 +286,7 @@ export default class API {
|
||||
entriesIds: number[],
|
||||
): Promise<void> {
|
||||
return API.fetch({
|
||||
route: `/playlists/${playlistSlugOrId}/reorder`,
|
||||
route: `/playlists/${playlistSlugOrId}/entries/reorder`,
|
||||
data: { entryIds: entriesIds },
|
||||
parameters: {},
|
||||
method: "PUT",
|
||||
@@ -322,25 +349,14 @@ export default class API {
|
||||
* Calls for a task to clean all libraries
|
||||
* @returns A object with the status of the task
|
||||
*/
|
||||
static async cleanLibraries(): Promise<LibraryTaskResponse> {
|
||||
static async cleanLibraries(): Promise<TaskResponse> {
|
||||
return API.fetch({
|
||||
route: `/tasks/clean`,
|
||||
route: "/clean",
|
||||
errorMessage: "Library clean failed",
|
||||
parameters: {},
|
||||
validator: LibraryTaskResponse,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls for a task to fetch external metadata
|
||||
* @returns A object with the status of the task
|
||||
*/
|
||||
static async fetchExternalMetadata(): Promise<LibraryTaskResponse> {
|
||||
return API.fetch({
|
||||
route: `/tasks/fetch-external-metadata`,
|
||||
errorMessage: "Fetch failed",
|
||||
parameters: {},
|
||||
validator: LibraryTaskResponse,
|
||||
service: Service.Scanner,
|
||||
method: "POST",
|
||||
validator: TaskResponse,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -350,12 +366,14 @@ export default class API {
|
||||
*/
|
||||
static async scanLibrary(
|
||||
librarySlugOrId: number | string,
|
||||
): Promise<LibraryTaskResponse> {
|
||||
): Promise<TaskResponse> {
|
||||
return API.fetch({
|
||||
route: `/tasks/scan/${librarySlugOrId}`,
|
||||
route: `/scan/${librarySlugOrId}`,
|
||||
errorMessage: "Library scan failed",
|
||||
parameters: {},
|
||||
validator: LibraryTaskResponse,
|
||||
service: Service.Scanner,
|
||||
method: "POST",
|
||||
validator: TaskResponse,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -365,12 +383,14 @@ export default class API {
|
||||
*/
|
||||
static async cleanLibrary(
|
||||
librarySlugOrId: number | string,
|
||||
): Promise<LibraryTaskResponse> {
|
||||
): Promise<TaskResponse> {
|
||||
return API.fetch({
|
||||
route: `/tasks/clean/${librarySlugOrId}`,
|
||||
route: `/clean/${librarySlugOrId}`,
|
||||
errorMessage: "Library clean failed",
|
||||
parameters: {},
|
||||
validator: LibraryTaskResponse,
|
||||
service: Service.Scanner,
|
||||
method: "POST",
|
||||
validator: TaskResponse,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -378,11 +398,13 @@ export default class API {
|
||||
* Calls for a task to scan all libraries
|
||||
* @returns the status of the task
|
||||
*/
|
||||
static scanLibraries(): Promise<LibraryTaskResponse> {
|
||||
return API.fetch<LibraryTaskResponse, []>({
|
||||
route: `/tasks/scan`,
|
||||
static scanLibraries(): Promise<TaskResponse> {
|
||||
return API.fetch<TaskResponse, []>({
|
||||
route: "/scan",
|
||||
parameters: {},
|
||||
validator: LibraryTaskResponse,
|
||||
service: Service.Scanner,
|
||||
method: "POST",
|
||||
validator: TaskResponse,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -393,12 +415,15 @@ export default class API {
|
||||
static async refreshMetadata(
|
||||
parentResourceType: "library" | "album" | "song" | "release" | "track",
|
||||
resourceSlugOrId: number | string,
|
||||
force: boolean,
|
||||
): Promise<void> {
|
||||
return API.fetch({
|
||||
route: `/tasks/refresh-metadata`,
|
||||
errorMessage: "Library refresh failed",
|
||||
otherParameters: { [parentResourceType]: resourceSlugOrId },
|
||||
method: "POST",
|
||||
route: "/refresh",
|
||||
errorMessage: "Metadata Refresh request failed",
|
||||
otherParameters: { [parentResourceType]: resourceSlugOrId, force },
|
||||
parameters: {},
|
||||
service: Service.Scanner,
|
||||
emptyResponse: true,
|
||||
});
|
||||
}
|
||||
@@ -408,9 +433,8 @@ export default class API {
|
||||
libraryPath: string,
|
||||
): Promise<Library> {
|
||||
return API.fetch({
|
||||
route: "/libraries/new",
|
||||
route: "/libraries",
|
||||
data: { name: libraryName, path: libraryPath },
|
||||
errorMessage: "Library Creation Failed",
|
||||
parameters: {},
|
||||
method: "POST",
|
||||
validator: Library,
|
||||
@@ -452,13 +476,13 @@ export default class API {
|
||||
* Update Artist Illustration
|
||||
*/
|
||||
static async updateArtistIllustration(
|
||||
artistSlugOrId: number | string,
|
||||
artistId: number,
|
||||
illustrationUrl: string,
|
||||
): Promise<unknown> {
|
||||
return API.updateResourceIllustration(
|
||||
artistSlugOrId,
|
||||
artistId,
|
||||
illustrationUrl,
|
||||
"artists",
|
||||
"artist",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -466,13 +490,13 @@ export default class API {
|
||||
* Update Release Illustration
|
||||
*/
|
||||
static async updateReleaseIllustration(
|
||||
releaseSlugOrId: number | string,
|
||||
releaseId: number,
|
||||
illustrationUrl: string,
|
||||
): Promise<unknown> {
|
||||
return API.updateResourceIllustration(
|
||||
releaseSlugOrId,
|
||||
releaseId,
|
||||
illustrationUrl,
|
||||
"releases",
|
||||
"release",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -480,13 +504,13 @@ export default class API {
|
||||
* Update Track Illustration
|
||||
*/
|
||||
static async updateTrackIllustration(
|
||||
trackSlugOrId: number | string,
|
||||
trackId: number,
|
||||
illustrationUrl: string,
|
||||
): Promise<unknown> {
|
||||
return API.updateResourceIllustration(
|
||||
trackSlugOrId,
|
||||
trackId,
|
||||
illustrationUrl,
|
||||
"tracks",
|
||||
"track",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -494,13 +518,13 @@ export default class API {
|
||||
* Update Track Illustration
|
||||
*/
|
||||
static async updatePlaylistIllustration(
|
||||
playlistSlugOrId: number | string,
|
||||
playlistId: number,
|
||||
illustrationUrl: string,
|
||||
): Promise<unknown> {
|
||||
return API.updateResourceIllustration(
|
||||
playlistSlugOrId,
|
||||
playlistId,
|
||||
illustrationUrl,
|
||||
"playlists",
|
||||
"playlist",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -508,17 +532,17 @@ export default class API {
|
||||
* Update Resourse Illustration
|
||||
*/
|
||||
private static async updateResourceIllustration(
|
||||
resourceSlugOrId: number | string,
|
||||
resourceId: number,
|
||||
illustrationUrl: string,
|
||||
resourceType: "artists" | "releases" | "tracks" | "playlists",
|
||||
resourceType: "artist" | "release" | "track" | "playlist",
|
||||
): Promise<unknown> {
|
||||
return API.fetch({
|
||||
route: `/${resourceType}/${resourceSlugOrId}/illustration`,
|
||||
route: "/illustrations/url",
|
||||
errorMessage: "Update Illustration Failed",
|
||||
method: "POST",
|
||||
parameters: {},
|
||||
emptyResponse: true,
|
||||
data: { url: illustrationUrl },
|
||||
data: { url: illustrationUrl, [`${resourceType}Id`]: resourceId },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -527,15 +551,15 @@ export default class API {
|
||||
*/
|
||||
static async updateAlbum(
|
||||
albumSlugOrId: number | string,
|
||||
newType: AlbumType,
|
||||
): Promise<unknown> {
|
||||
dto: Partial<{ type: AlbumType; masterReleaseId: number }>,
|
||||
): Promise<void> {
|
||||
return API.fetch({
|
||||
route: `/albums/${albumSlugOrId}`,
|
||||
errorMessage: "Update Album Failed",
|
||||
method: "POST",
|
||||
method: "PUT",
|
||||
parameters: {},
|
||||
emptyResponse: true,
|
||||
data: { type: newType },
|
||||
data: dto,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -544,15 +568,29 @@ export default class API {
|
||||
*/
|
||||
static async updateSong(
|
||||
songSlugOrId: number | string,
|
||||
newType: SongType,
|
||||
dto: Partial<{ type: SongType; masterTrackId: number }>,
|
||||
): Promise<void> {
|
||||
return API.fetch({
|
||||
route: `/songs/${songSlugOrId}`,
|
||||
errorMessage: "Update Song Failed",
|
||||
method: "POST",
|
||||
method: "PUT",
|
||||
parameters: {},
|
||||
emptyResponse: true,
|
||||
data: { type: newType },
|
||||
data: dto,
|
||||
});
|
||||
}
|
||||
|
||||
static async updateVideo(
|
||||
videoSlugOrId: number | string,
|
||||
dto: Partial<{ type: VideoType; masterTrackId: number }>,
|
||||
): Promise<void> {
|
||||
return API.fetch({
|
||||
route: `/videos/${videoSlugOrId}`,
|
||||
errorMessage: "Update Video Failed",
|
||||
method: "PUT",
|
||||
parameters: {},
|
||||
emptyResponse: true,
|
||||
data: dto,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -579,7 +617,7 @@ export default class API {
|
||||
],
|
||||
exec: (pagination) =>
|
||||
API.fetch({
|
||||
route: `/artists`,
|
||||
route: "/artists",
|
||||
errorMessage: "Artists could not be loaded",
|
||||
parameters: { pagination: pagination, include, sort },
|
||||
otherParameters: {
|
||||
@@ -620,7 +658,7 @@ export default class API {
|
||||
],
|
||||
exec: (pagination) =>
|
||||
API.fetch({
|
||||
route: `/albums`,
|
||||
route: "/albums",
|
||||
errorMessage: "Albums could not be loaded",
|
||||
parameters: { pagination: pagination, include, sort },
|
||||
otherParameters: { ...filter },
|
||||
@@ -649,7 +687,7 @@ export default class API {
|
||||
],
|
||||
exec: (pagination) =>
|
||||
API.fetch({
|
||||
route: `/releases`,
|
||||
route: "/releases",
|
||||
errorMessage: "Releases could not be loaded",
|
||||
parameters: { pagination: pagination, include, sort },
|
||||
otherParameters: filter,
|
||||
@@ -688,7 +726,7 @@ export default class API {
|
||||
],
|
||||
exec: (pagination) =>
|
||||
API.fetch({
|
||||
route: `/songs`,
|
||||
route: "/songs",
|
||||
errorMessage: "Songs could not be loaded",
|
||||
parameters: { pagination: pagination, include, sort },
|
||||
otherParameters: { ...filter },
|
||||
@@ -719,7 +757,7 @@ export default class API {
|
||||
],
|
||||
exec: (pagination) =>
|
||||
API.fetch({
|
||||
route: `/song-groups`,
|
||||
route: "/song-groups",
|
||||
errorMessage: "Songs could not be loaded",
|
||||
parameters: { pagination: pagination, include, sort },
|
||||
otherParameters: { ...filter },
|
||||
@@ -731,19 +769,20 @@ export default class API {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all songs
|
||||
* @returns An InfiniteQuery of Songs
|
||||
* @returns An InfiniteQuery of Videos
|
||||
*/
|
||||
static getVideos<I extends SongInclude | never = never>(
|
||||
static getVideos<I extends VideoInclude | never = never>(
|
||||
filter: {
|
||||
library?: Identifier;
|
||||
artist?: Identifier;
|
||||
album?: Identifier;
|
||||
song?: Identifier;
|
||||
group?: Identifier;
|
||||
random?: number;
|
||||
type?: SongType;
|
||||
type?: VideoType;
|
||||
query?: string;
|
||||
},
|
||||
sort?: SortingParameters<typeof SongSortingKeys>,
|
||||
sort?: SortingParameters<typeof VideoSortingKeys>,
|
||||
include?: I[],
|
||||
): InfiniteQuery<VideoWithRelations<I>> {
|
||||
return {
|
||||
@@ -755,9 +794,13 @@ export default class API {
|
||||
],
|
||||
exec: (pagination) =>
|
||||
API.fetch({
|
||||
route: `/videos`,
|
||||
route: "/videos",
|
||||
errorMessage: "Videos could not be loaded",
|
||||
parameters: { pagination: pagination, include, sort },
|
||||
parameters: {
|
||||
pagination: pagination,
|
||||
include,
|
||||
sort,
|
||||
},
|
||||
otherParameters: filter,
|
||||
validator: PaginatedResponse(
|
||||
VideoWithRelations(include ?? []),
|
||||
@@ -786,13 +829,28 @@ export default class API {
|
||||
};
|
||||
}
|
||||
|
||||
static getVideo<I extends VideoInclude | never = never>(
|
||||
videoSlugOrId: string | number,
|
||||
include?: I[],
|
||||
): Query<VideoWithRelations<I>> {
|
||||
return {
|
||||
key: ["video", videoSlugOrId, ...API.formatIncludeKeys(include)],
|
||||
exec: () =>
|
||||
API.fetch({
|
||||
route: `/videos/${videoSlugOrId}`,
|
||||
parameters: { include },
|
||||
validator: VideoWithRelations(include ?? []),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the master track of a song
|
||||
* @param songSlugOrId the identifier of a song
|
||||
* @param include the fields to include in the fetched item
|
||||
* @returns a Query for a Track
|
||||
*/
|
||||
static getMasterTrack<I extends TrackInclude | never = never>(
|
||||
static getSongMasterTrack<I extends TrackInclude | never = never>(
|
||||
songSlugOrId: string | number,
|
||||
include?: I[],
|
||||
): Query<TrackWithRelations<I>> {
|
||||
@@ -805,7 +863,7 @@ export default class API {
|
||||
],
|
||||
exec: () =>
|
||||
API.fetch({
|
||||
route: `/tracks/master/${songSlugOrId}`,
|
||||
route: `/tracks/master/song/${songSlugOrId}`,
|
||||
parameters: { include },
|
||||
validator: TrackWithRelations(include ?? []),
|
||||
}),
|
||||
@@ -881,26 +939,54 @@ export default class API {
|
||||
key: ["search", query],
|
||||
exec: () =>
|
||||
API.fetch({
|
||||
route: `/search/${query}`,
|
||||
route: "/search",
|
||||
errorMessage: "Search failed",
|
||||
parameters: {},
|
||||
otherParameters: { query },
|
||||
customValidator: SearchResultTransformer,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
static getSearchHistory(): Query<SearchResult[]> {
|
||||
return {
|
||||
key: ["search-history-items"],
|
||||
exec: () =>
|
||||
API.fetch({
|
||||
route: "/search/history",
|
||||
errorMessage: "Getting Search History failed",
|
||||
parameters: {},
|
||||
customValidator: SearchResultTransformer,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search artists, albums and songs, all at once
|
||||
*/
|
||||
static saveSearchHistoryEntry(resource: SaveSearchItem): Promise<void> {
|
||||
return API.fetch({
|
||||
method: "POST",
|
||||
route: "/search/history",
|
||||
errorMessage: "Saving Search History failed",
|
||||
parameters: {},
|
||||
data: resource,
|
||||
emptyResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the User object of the authentified user
|
||||
* @returns A query to a User object
|
||||
*/
|
||||
static getCurrentUserStatus(): Query<User> {
|
||||
const accessToken = store.getState().user.accessToken;
|
||||
const accessToken = store.get(accessTokenAtom);
|
||||
|
||||
return {
|
||||
key: ["user", accessToken ?? ""],
|
||||
exec: () =>
|
||||
API.fetch({
|
||||
route: `/users/me`,
|
||||
route: "/users/me",
|
||||
parameters: {},
|
||||
validator: User,
|
||||
}),
|
||||
@@ -918,7 +1004,7 @@ export default class API {
|
||||
key: ["users", ...API.formatObject(sort)],
|
||||
exec: (pagination) =>
|
||||
API.fetch({
|
||||
route: `/users`,
|
||||
route: "/users",
|
||||
errorMessage: "Users could not be loaded",
|
||||
parameters: { pagination: pagination, include: [], sort },
|
||||
validator: PaginatedResponse(User),
|
||||
@@ -1006,7 +1092,7 @@ export default class API {
|
||||
],
|
||||
exec: (pagination) =>
|
||||
API.fetch({
|
||||
route: `/tracks`,
|
||||
route: "/tracks",
|
||||
otherParameters: filter,
|
||||
parameters: { pagination: pagination, include, sort },
|
||||
validator: PaginatedResponse(
|
||||
@@ -1044,7 +1130,7 @@ export default class API {
|
||||
*/
|
||||
static getReleaseTracklist<I extends SongInclude | never = never>(
|
||||
slugOrId: string | number,
|
||||
exclusiveOnly: boolean = false,
|
||||
exclusiveOnly = false,
|
||||
include?: I[],
|
||||
): InfiniteQuery<TracklistItemWithRelations<I>> {
|
||||
return {
|
||||
@@ -1123,7 +1209,7 @@ export default class API {
|
||||
],
|
||||
exec: (pagination) =>
|
||||
API.fetch({
|
||||
route: `/genres`,
|
||||
route: "/genres",
|
||||
errorMessage: "Genres could not be loaded",
|
||||
parameters: { pagination: pagination, include: [], sort },
|
||||
otherParameters: filter,
|
||||
@@ -1150,6 +1236,52 @@ export default class API {
|
||||
};
|
||||
}
|
||||
|
||||
static getArtistExternalMetadata(
|
||||
slugOrId: string | number,
|
||||
): Query<ArtistExternalMetadata | null> {
|
||||
return API.getResourceExternalMetadata(
|
||||
slugOrId,
|
||||
"artist",
|
||||
ArtistExternalMetadata,
|
||||
);
|
||||
}
|
||||
|
||||
static getSongExternalMetadata(
|
||||
slugOrId: string | number,
|
||||
): Query<SongExternalMetadata | null> {
|
||||
return API.getResourceExternalMetadata(
|
||||
slugOrId,
|
||||
"song",
|
||||
SongExternalMetadata,
|
||||
);
|
||||
}
|
||||
static getAlbumExternalMetadata(
|
||||
slugOrId: string | number,
|
||||
): Query<AlbumExternalMetadata | null> {
|
||||
return API.getResourceExternalMetadata(
|
||||
slugOrId,
|
||||
"album",
|
||||
AlbumExternalMetadata,
|
||||
);
|
||||
}
|
||||
|
||||
static getResourceExternalMetadata<T, V extends yup.Schema<T>>(
|
||||
resourceSlugOrId: string | number,
|
||||
resourceType: "artist" | "album" | "song",
|
||||
validator: V,
|
||||
): Query<T | null> {
|
||||
return {
|
||||
key: [resourceType, resourceSlugOrId, "external-metadata"],
|
||||
exec: () =>
|
||||
API.fetch({
|
||||
route: `/external-metadata?${resourceType}=${resourceSlugOrId}`,
|
||||
errorMessage: "Metadata could not be loaded",
|
||||
parameters: {},
|
||||
validator: validator,
|
||||
}).catch(() => null),
|
||||
};
|
||||
}
|
||||
|
||||
private static async fetch<ReturnType, Keys extends readonly string[]>({
|
||||
route,
|
||||
parameters,
|
||||
@@ -1160,14 +1292,15 @@ export default class API {
|
||||
validator,
|
||||
customValidator,
|
||||
emptyResponse,
|
||||
service,
|
||||
}: FetchParameters<Keys, ReturnType>): Promise<ReturnType> {
|
||||
const accessToken = store.getState().user.accessToken;
|
||||
const accessToken = store.get(accessTokenAtom);
|
||||
const header = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
this.buildURL(route, parameters, otherParameters),
|
||||
API.buildURL(route, parameters, otherParameters, service),
|
||||
{
|
||||
method: method ?? "GET",
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
@@ -1216,11 +1349,10 @@ export default class API {
|
||||
|
||||
return validator.cast(validated);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
// biome-ignore lint/suspicious/noConsole: OK for debugging
|
||||
console.error(jsonResponse, err);
|
||||
throw new Error("Error: Invalid Response Type");
|
||||
}
|
||||
return jsonResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1229,20 +1361,20 @@ export default class API {
|
||||
* @returns the correct, rerouted URL
|
||||
*/
|
||||
static getIllustrationURL(imageURL: string): string {
|
||||
if (this.isDev()) {
|
||||
if (API.isDev()) {
|
||||
return `${"/api"}${imageURL}`;
|
||||
}
|
||||
return `${process.env.PUBLIC_SERVER_URL ?? "/api"}${imageURL}`;
|
||||
}
|
||||
|
||||
static getDirectStreamURL(fileId: number): string {
|
||||
return this.buildURL(`/stream/${fileId}/direct`, {});
|
||||
return API.buildURL(`/stream/${fileId}/direct`, {});
|
||||
}
|
||||
static getTranscodeStreamURL(fileId: number, type: TrackType): string {
|
||||
if (type == "Video") {
|
||||
return this.buildURL(`/stream/${fileId}/master.m3u8`, {});
|
||||
if (type === "Video") {
|
||||
return API.buildURL(`/stream/${fileId}/master.m3u8`, {});
|
||||
}
|
||||
return this.buildURL(`/stream/${fileId}/audio/0/index.m3u8`, {});
|
||||
return API.buildURL(`/stream/${fileId}/audio/0/index.m3u8`, {});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1251,7 +1383,7 @@ export default class API {
|
||||
* @returns the correct, rerouted URL
|
||||
*/
|
||||
static getReleaseArchiveURL(releaseId: number | string): string {
|
||||
return this.buildURL(`/releases/${releaseId}/archive`, {});
|
||||
return API.buildURL(`/releases/${releaseId}/archive`, {});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1263,10 +1395,10 @@ export default class API {
|
||||
playlistId: number,
|
||||
): Promise<unknown> {
|
||||
return API.fetch({
|
||||
route: `/playlists/entries/new`,
|
||||
route: `/playlists/${playlistId}/entries`,
|
||||
errorMessage: "Failed to add song to playlist",
|
||||
parameters: {},
|
||||
data: { songId, playlistId },
|
||||
data: { songId },
|
||||
method: "POST",
|
||||
emptyResponse: true,
|
||||
});
|
||||
@@ -1320,51 +1452,18 @@ export default class API {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a release as master
|
||||
* @param releaseSlugOrId
|
||||
* @returns
|
||||
*/
|
||||
static async setReleaseAsMaster(
|
||||
releaseSlugOrId: string | number,
|
||||
): Promise<unknown> {
|
||||
return API.fetch({
|
||||
route: `/releases/${releaseSlugOrId}/master`,
|
||||
errorMessage: "Release update failed",
|
||||
parameters: {},
|
||||
method: "PUT",
|
||||
validator: yup.mixed(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a track as master
|
||||
* @param trackSlugOrId
|
||||
* @returns
|
||||
*/
|
||||
static async setTrackAsMaster(
|
||||
trackSlugOrId: string | number,
|
||||
): Promise<unknown> {
|
||||
return API.fetch({
|
||||
route: `/tracks/${trackSlugOrId}/master`,
|
||||
errorMessage: "Track update failed",
|
||||
parameters: {},
|
||||
method: "PUT",
|
||||
validator: yup.mixed(),
|
||||
});
|
||||
}
|
||||
|
||||
private static buildURL(
|
||||
route: string,
|
||||
parameters: QueryParameters<any>,
|
||||
otherParameters?: any,
|
||||
service: Service = Service.API,
|
||||
): string {
|
||||
const apiHost = isSSR() ? this.SSR_API_URL : "/api";
|
||||
const apiHost = isSSR() ? API.SSR_API_URL : "/api";
|
||||
const scannerHost = isSSR() ? API.SSR_SCANNER_URL : "/scanner";
|
||||
|
||||
return `${apiHost}${route}${this.formatQueryParameters(
|
||||
parameters,
|
||||
otherParameters,
|
||||
)}`;
|
||||
return `${
|
||||
service === Service.API ? apiHost : scannerHost
|
||||
}${route}${API.formatQueryParameters(parameters, otherParameters)}`;
|
||||
}
|
||||
|
||||
private static formatQueryParameters<Keys extends string[]>(
|
||||
@@ -1380,11 +1479,11 @@ export default class API {
|
||||
);
|
||||
}
|
||||
if ((parameters.include?.length ?? 0) !== 0) {
|
||||
formattedQueryParams.push(this.formatInclude(parameters.include!)!);
|
||||
formattedQueryParams.push(API.formatInclude(parameters.include!)!);
|
||||
}
|
||||
if (parameters.pagination) {
|
||||
formattedQueryParams.push(
|
||||
this.formatPagination(parameters.pagination),
|
||||
API.formatPagination(parameters.pagination),
|
||||
);
|
||||
}
|
||||
for (const otherParams in otherParameters) {
|
||||
@@ -1403,7 +1502,7 @@ export default class API {
|
||||
}
|
||||
|
||||
private static formatInclude(include: string[]): string | null {
|
||||
if (include.length == 0) {
|
||||
if (include.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return `with=${include.join(",")}`;
|
||||
@@ -1411,7 +1510,7 @@ export default class API {
|
||||
|
||||
private static formatPagination(pagination: PaginationParameters): string {
|
||||
const formattedParameters: string[] = [];
|
||||
const pageSize = pagination.pageSize ?? this.defaultPageSize;
|
||||
const pageSize = pagination.pageSize ?? API.defaultPageSize;
|
||||
const afterId = pagination.afterId;
|
||||
|
||||
if (afterId !== undefined) {
|
||||
|
||||
@@ -18,16 +18,20 @@
|
||||
|
||||
/* eslint-disable no-restricted-imports */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
QueryFunctionContext,
|
||||
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";
|
||||
import { InfiniteFetchFn, Page } from "../components/infinite/infinite-scroll";
|
||||
import Resource from "../models/resource";
|
||||
|
||||
type Key = string | number;
|
||||
|
||||
@@ -62,7 +66,7 @@ export const DefaultMeeloQueryOptions = {
|
||||
cacheTime: 10 * (60 * 1000),
|
||||
refetchOnMount: true,
|
||||
// We want this in dev, not in prod to avoid useless refecthes.
|
||||
refetchOnWindowFocus: process.env.NODE_ENV != "production",
|
||||
refetchOnWindowFocus: process.env.NODE_ENV !== "production",
|
||||
refetchOnReconnect: true,
|
||||
};
|
||||
|
||||
@@ -169,8 +173,7 @@ const useInfiniteQuery = <ReturnType extends Resource, Params extends any[]>(
|
||||
...queryParams: Partial<Params>
|
||||
) => {
|
||||
const pageSize = API.defaultPageSize;
|
||||
|
||||
return useReactInfiniteQuery({
|
||||
const { data, ...res } = useReactInfiniteQuery({
|
||||
...prepareMeeloInfiniteQuery(query, ...queryParams),
|
||||
getNextPageParam: (
|
||||
lastPage: Page<ReturnType>,
|
||||
@@ -181,6 +184,12 @@ const useInfiniteQuery = <ReturnType extends Resource, Params extends any[]>(
|
||||
return lastPage;
|
||||
},
|
||||
});
|
||||
const [items, setItems] = useState(data?.pages.at(0)?.items);
|
||||
useEffect(() => {
|
||||
setItems(data?.pages.flatMap((p) => p.items));
|
||||
}, [data?.pages]);
|
||||
|
||||
return { ...res, data, items };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { RequireExactlyOne } from "type-fest";
|
||||
import { TranslationKey } from "../../i18n/i18n";
|
||||
import type { RequireExactlyOne } from "type-fest";
|
||||
import type { TranslationKey } from "../../i18n/i18n";
|
||||
|
||||
/**
|
||||
* Props for a generic component to run an action/go to page
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { QueryClient } from "../../api/use-query";
|
||||
import Action from "../actions/action";
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import Album, { AlbumType } from "../../models/album";
|
||||
import store from "../../state/store";
|
||||
import { Chip, Grid } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import API from "../../api/api";
|
||||
import { useMutation } from "react-query";
|
||||
import { EditIcon } from "../icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const AlbumTypeForm = (props: {
|
||||
defaultValue: AlbumType;
|
||||
onSelect: (type: AlbumType) => void;
|
||||
}) => {
|
||||
const [currentType, setType] = useState(props.defaultValue);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid container spacing={2} justifyContent="center">
|
||||
{AlbumType.map((type) => (
|
||||
<Grid item key={type}>
|
||||
<Chip
|
||||
label={t(type)}
|
||||
variant={
|
||||
type == currentType ? "filled" : "outlined"
|
||||
}
|
||||
onClick={() => {
|
||||
setType(type);
|
||||
props.onSelect(type);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ChangeAlbumType = (
|
||||
album: Album,
|
||||
queryClient: QueryClient,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
): Action => {
|
||||
const { t } = useTranslation();
|
||||
const mutation = useMutation((newType: AlbumType) => {
|
||||
return API.updateAlbum(album.id, newType)
|
||||
.then(() => {
|
||||
queryClient.client.invalidateQueries("albums");
|
||||
})
|
||||
.catch((error: Error) => toast.error(error.message));
|
||||
});
|
||||
|
||||
return {
|
||||
label: "changeAlbumType",
|
||||
icon: <EditIcon />,
|
||||
disabled: store.getState().user.user?.admin !== true,
|
||||
onClick: () =>
|
||||
confirm({
|
||||
title: t("changeAlbumType"),
|
||||
description: (
|
||||
<AlbumTypeForm
|
||||
defaultValue={album.type}
|
||||
onSelect={(type) => mutation.mutate(type)}
|
||||
/>
|
||||
),
|
||||
cancellationButtonProps: { sx: { display: "none" } },
|
||||
confirmationText: t("done"),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export default ChangeAlbumType;
|
||||
@@ -16,14 +16,14 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { store } from "../../state/store";
|
||||
import { accessTokenAtom } from "../../state/user";
|
||||
import { LogoutIcon } from "../icons";
|
||||
import store from "../../state/store";
|
||||
import { unsetAccessToken } from "../../state/userSlice";
|
||||
import Action from "./action";
|
||||
import type Action from "./action";
|
||||
|
||||
export const LogoutAction: Action = {
|
||||
label: "logout",
|
||||
icon: <LogoutIcon />,
|
||||
href: "/",
|
||||
onClick: () => store.dispatch(unsetAccessToken()),
|
||||
onClick: () => store.set(accessTokenAtom, undefined),
|
||||
};
|
||||
@@ -16,12 +16,12 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Action from "./action";
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
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 { Translator } from "../../i18n/i18n";
|
||||
import type Action from "./action";
|
||||
|
||||
export const DownloadAction = (
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
|
||||
@@ -18,18 +18,20 @@
|
||||
|
||||
import toast from "react-hot-toast";
|
||||
import API from "../../api/api";
|
||||
import LibraryTaskResponse from "../../models/library-task-response";
|
||||
import Action from "./action";
|
||||
import { CleanIcon, MetadataRefreshIcon, ScanIcon } from "../icons";
|
||||
import type { TaskResponse } from "../../models/task";
|
||||
import { CleanIcon, ScanIcon } from "../icons";
|
||||
import type Action from "./action";
|
||||
|
||||
/**
|
||||
* Using the resolved value of the task porimise, triggers an appropriate toast
|
||||
* @param task the returned promised from an API call to run a task
|
||||
*/
|
||||
const handleTask = <T extends LibraryTaskResponse>(task: Promise<T>) =>
|
||||
const handleTask = <T extends TaskResponse>(task: Promise<T>) =>
|
||||
task
|
||||
.then(({ status }) => toast.success(status))
|
||||
.catch(({ status }) => toast.error(status));
|
||||
.then((s) => toast.success(s.message))
|
||||
.catch((s) =>
|
||||
toast.error(s.message ?? s.status ?? "Task request failed"),
|
||||
);
|
||||
|
||||
export const ScanAllLibrariesAction: Action = {
|
||||
label: "scanLibraries",
|
||||
@@ -58,9 +60,3 @@ export const CleanLibraryAction = (
|
||||
icon: CleanAllLibrariesAction.icon,
|
||||
onClick: () => handleTask(API.cleanLibrary(librarySlugOrId)),
|
||||
});
|
||||
|
||||
export const FetchExternalMetadata: Action = {
|
||||
label: "fetchMetadata",
|
||||
icon: <MetadataRefreshIcon />,
|
||||
onClick: () => handleTask(API.fetchExternalMetadata()),
|
||||
};
|
||||
@@ -16,17 +16,19 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { NextRouter } from "next/router";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
AlbumIcon,
|
||||
ArtistIcon,
|
||||
InfoIcon,
|
||||
LyricsIcon,
|
||||
RelatedTracksIcon,
|
||||
SearchIcon,
|
||||
SettingsIcon,
|
||||
SongIcon,
|
||||
} from "../icons";
|
||||
import { NextRouter } from "next/router";
|
||||
import Action from "./action";
|
||||
import type Action from "./action";
|
||||
|
||||
export const GoToSongLyricsAction = (
|
||||
songIdentifier: string | number,
|
||||
@@ -36,6 +38,14 @@ export const GoToSongLyricsAction = (
|
||||
icon: <LyricsIcon />,
|
||||
});
|
||||
|
||||
export const GoToSongInfoAction = (
|
||||
songIdentifier: string | number,
|
||||
): Action => ({
|
||||
href: `/songs/${songIdentifier}/info`,
|
||||
label: "songInfo",
|
||||
icon: <InfoIcon />,
|
||||
});
|
||||
|
||||
export const GoToArtistAction = (
|
||||
artistIdentifier: string | number,
|
||||
): Action => ({
|
||||
@@ -76,10 +86,16 @@ export const GoToReleaseAction = (
|
||||
|
||||
export const GoToReleaseAsyncAction = (
|
||||
router: NextRouter,
|
||||
albumIdentifier: () => PromiseLike<number | string>,
|
||||
albumIdentifier: () => PromiseLike<number | string | null>,
|
||||
): Action => ({
|
||||
onClick: () =>
|
||||
albumIdentifier().then((id) => router.push(`/releases/${id}`)),
|
||||
albumIdentifier().then((id) => {
|
||||
if (id === null) {
|
||||
toast.error("This resource is not attached to any album.");
|
||||
} else {
|
||||
router.push(`/releases/${id}`);
|
||||
}
|
||||
}),
|
||||
label: "goToAlbum",
|
||||
icon: <AlbumIcon />,
|
||||
});
|
||||
|
||||
@@ -16,7 +16,35 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Action from "./action";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
} 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 { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "react-query";
|
||||
import API from "../../api/api";
|
||||
import {
|
||||
type MeeloInfiniteQueryFn,
|
||||
type QueryClient,
|
||||
useQueryClient,
|
||||
} from "../../api/use-query";
|
||||
import type Playlist from "../../models/playlist";
|
||||
import type { PlaylistWithRelations } from "../../models/playlist";
|
||||
import {
|
||||
type TrackState,
|
||||
playAfterAtom,
|
||||
playNextAtom,
|
||||
} from "../../state/player";
|
||||
import { store } from "../../state/store";
|
||||
import {
|
||||
AddItemToPlaylistIcon,
|
||||
AddToPlaylistIcon,
|
||||
@@ -24,34 +52,18 @@ import {
|
||||
PlayAfterIcon,
|
||||
PlayNextIcon,
|
||||
} from "../icons";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
Button,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@mui/material";
|
||||
import { HookTextField, useHookForm } from "mui-react-hook-form-plus";
|
||||
import { MeeloInfiniteQueryFn, QueryClient } from "../../api/use-query";
|
||||
import { useMutation } from "react-query";
|
||||
import API from "../../api/api";
|
||||
import Playlist, { PlaylistWithRelations } from "../../models/playlist";
|
||||
import InfiniteList from "../infinite/infinite-list";
|
||||
import { WideLoadingComponent } from "../loading/loading";
|
||||
import ListItem from "../list-item/item";
|
||||
import Illustration from "../illustration";
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import { Add, Edit } from "iconsax-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlayerActions, TrackState } from "../../contexts/player";
|
||||
import InfiniteList from "../infinite/infinite-list";
|
||||
import ListItem from "../list-item/item";
|
||||
import { WideLoadingComponent } from "../loading/loading";
|
||||
import type Action from "./action";
|
||||
|
||||
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",
|
||||
@@ -60,11 +72,10 @@ export const PlayNextAction = (
|
||||
|
||||
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",
|
||||
@@ -72,7 +83,7 @@ export const PlayAfterAction = (
|
||||
});
|
||||
|
||||
type CreateOrUpdatePlaylistFormProps = {
|
||||
onSubmit: (newUrl: string) => void;
|
||||
onSubmit: (playlistName: string) => void;
|
||||
onClose: () => void;
|
||||
defaultValue?: string;
|
||||
};
|
||||
@@ -125,16 +136,19 @@ const CreateOrUpdatePlaylistForm = (props: CreateOrUpdatePlaylistFormProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const CreatePlaylistAction = (queryClient: QueryClient): Action => ({
|
||||
export const CreatePlaylistAction = (
|
||||
queryClient: QueryClient,
|
||||
onCreated?: (playlistId: number) => void,
|
||||
): Action => ({
|
||||
label: "new",
|
||||
icon: <Add />,
|
||||
dialog: ({ close }) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const mutation = useMutation((playlistName: string) => {
|
||||
return API.createPlaylist(playlistName)
|
||||
.then(() => {
|
||||
.then((playlist) => {
|
||||
toast.success("Playlist created!");
|
||||
queryClient.client.invalidateQueries("playlists");
|
||||
onCreated?.(playlist.id);
|
||||
})
|
||||
.catch((error: Error) => toast.error(error.message));
|
||||
});
|
||||
@@ -155,7 +169,6 @@ export const UpdatePlaylistAction = (
|
||||
label: "update",
|
||||
icon: <Edit />,
|
||||
dialog: ({ close }) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const mutation = useMutation((playlistName: string) => {
|
||||
return API.updatePlaylist(playlistName, playlist.slug)
|
||||
.then(() => {
|
||||
@@ -214,11 +227,36 @@ type SelectPlaylistFormProps = {
|
||||
|
||||
const SelectPlaylistForm = (props: SelectPlaylistFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const createPlaylistAction = CreatePlaylistAction(queryClient);
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
const closeModal = () => setOpenModal(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={openModal}
|
||||
onClose={closeModal}
|
||||
fullWidth
|
||||
sx={{ zIndex: 999999 }}
|
||||
>
|
||||
{createPlaylistAction.dialog?.({ close: closeModal })}
|
||||
</Dialog>
|
||||
<DialogTitle>Select a playlist</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}
|
||||
@@ -261,7 +299,6 @@ export const AddToPlaylistAction = (
|
||||
icon: <AddToPlaylistIcon />,
|
||||
label: "addToPlaylist",
|
||||
dialog: ({ close }) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const mutation = useMutation((playlistId: number) => {
|
||||
return API.addSongToPlaylist(songId, playlistId)
|
||||
.then(() => {
|
||||
|
||||
@@ -16,22 +16,76 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { MetadataRefreshIcon } from "../icons";
|
||||
import API from "../../api/api";
|
||||
import Action from "./action";
|
||||
import { Button, Checkbox, Grid } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Translator } from "../../i18n/i18n";
|
||||
import type { ArraySlice } from "type-fest";
|
||||
import API from "../../api/api";
|
||||
import type { Translator } from "../../i18n/i18n";
|
||||
import { MetadataRefreshIcon } from "../icons";
|
||||
import type Action from "./action";
|
||||
|
||||
type APIMethodParams = ArraySlice<
|
||||
Parameters<typeof API.refreshMetadata>,
|
||||
0,
|
||||
-1
|
||||
>;
|
||||
|
||||
const RefreshMetadataActionContent = ({
|
||||
t,
|
||||
params,
|
||||
close,
|
||||
}: { t: Translator; params: APIMethodParams; close: () => void }) => {
|
||||
const [force, setForce] = useState(false);
|
||||
|
||||
return (
|
||||
<Grid container spacing={2} sx={{ padding: 2 }}>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={force}
|
||||
onChange={(_, isChecked) => setForce(isChecked)}
|
||||
/>
|
||||
{t("refreshMetadataForceLabel")}
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
API.refreshMetadata(...[...params, force])
|
||||
.then(() =>
|
||||
toast.success(t("refreshMetadataStarted")),
|
||||
)
|
||||
.catch(() =>
|
||||
toast.error(t("refreshMetadataFailed")),
|
||||
);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{t("refreshMetadata")}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
const RefreshMetadataAction = (
|
||||
t: Translator,
|
||||
...params: Parameters<typeof API.refreshMetadata>
|
||||
...params: APIMethodParams
|
||||
): Action => ({
|
||||
label: "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 = (
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* 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 { Chip, Grid } from "@mui/material";
|
||||
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 { userAtom } from "../../state/user";
|
||||
import { EditIcon } from "../icons";
|
||||
import type Action from "./action";
|
||||
|
||||
const ResourceTypeForm = <Enum extends TranslationKey>(props: {
|
||||
defaultValue?: Enum;
|
||||
values: readonly Enum[];
|
||||
onSelect: (type: Enum) => void;
|
||||
}) => {
|
||||
const [currentType, setType] = useState(props.defaultValue);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid container spacing={2} justifyContent="center">
|
||||
{props.values.map((type) => (
|
||||
<Grid item key={type}>
|
||||
<Chip
|
||||
label={t(type) as string}
|
||||
variant={
|
||||
type === currentType ? "filled" : "outlined"
|
||||
}
|
||||
onClick={() => {
|
||||
setType(type);
|
||||
props.onSelect(type);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ChangeResourceType = <
|
||||
T extends { type: TypeEnum },
|
||||
TypeEnum extends TranslationKey,
|
||||
>(
|
||||
resource: T,
|
||||
types: readonly TypeEnum[],
|
||||
label: TranslationKey,
|
||||
queryClient: QueryClient,
|
||||
onSelect: (newType: TypeEnum) => Promise<void>,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
): Action => {
|
||||
return {
|
||||
label: label,
|
||||
icon: <EditIcon />,
|
||||
disabled: store.get(userAtom)?.admin !== true,
|
||||
onClick: () =>
|
||||
confirm({
|
||||
title: <br />,
|
||||
description: (
|
||||
<ResourceTypeForm
|
||||
defaultValue={resource.type}
|
||||
values={types}
|
||||
onSelect={(type) =>
|
||||
toast
|
||||
.promise(onSelect(type), {
|
||||
loading: null,
|
||||
success: "Update successful!",
|
||||
error: "Update failed...",
|
||||
})
|
||||
.then(() => {
|
||||
queryClient.client.invalidateQueries(
|
||||
"songs",
|
||||
);
|
||||
queryClient.client.invalidateQueries(
|
||||
"release",
|
||||
);
|
||||
queryClient.client.invalidateQueries(
|
||||
"tracks",
|
||||
);
|
||||
queryClient.client.invalidateQueries(
|
||||
"videos",
|
||||
);
|
||||
queryClient.client.invalidateQueries(
|
||||
"bsides",
|
||||
);
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
cancellationButtonProps: { sx: { display: "none" } },
|
||||
confirmationText: "OK",
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const ChangeSongType = (
|
||||
s: Song,
|
||||
client: QueryClient,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
) =>
|
||||
ChangeResourceType(
|
||||
s,
|
||||
SongType.filter((t) => t !== "Unknown"),
|
||||
"changeSongType",
|
||||
client,
|
||||
(newType: SongType) =>
|
||||
API.updateSong(s.id, { type: newType }).then((res) => {
|
||||
client.client.invalidateQueries("songs");
|
||||
client.client.invalidateQueries("release");
|
||||
client.client.invalidateQueries("tracks");
|
||||
client.client.invalidateQueries("videos");
|
||||
client.client.invalidateQueries("bsides");
|
||||
return res;
|
||||
}),
|
||||
confirm,
|
||||
);
|
||||
|
||||
const ChangeAlbumType = (
|
||||
a: Album,
|
||||
client: QueryClient,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
) =>
|
||||
ChangeResourceType(
|
||||
a,
|
||||
AlbumType,
|
||||
"changeAlbumType",
|
||||
client,
|
||||
(newType: AlbumType) =>
|
||||
API.updateAlbum(a.id, { type: newType }).then((res) => {
|
||||
client.client.invalidateQueries("albums");
|
||||
return res;
|
||||
}),
|
||||
confirm,
|
||||
);
|
||||
|
||||
const ChangeVideoType = (
|
||||
v: Video,
|
||||
client: QueryClient,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
) =>
|
||||
ChangeResourceType(
|
||||
v,
|
||||
VideoType,
|
||||
"changeVideoType",
|
||||
client,
|
||||
(newType: VideoType) =>
|
||||
API.updateVideo(v.id, { type: newType }).then((res) => {
|
||||
client.client.invalidateQueries("videos");
|
||||
return res;
|
||||
}),
|
||||
confirm,
|
||||
);
|
||||
|
||||
export { ChangeSongType, ChangeAlbumType, ChangeVideoType };
|
||||
@@ -16,10 +16,10 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Translator } from "../../i18n/i18n";
|
||||
import type { Translator } from "../../i18n/i18n";
|
||||
import copyLinkToClipboard from "../../utils/copy-link";
|
||||
import { ShareIcon } from "../icons";
|
||||
import Action from "./action";
|
||||
import type Action from "./action";
|
||||
|
||||
export const ShareAction = (url: string, t: Translator): Action => ({
|
||||
onClick: () => copyLinkToClipboard(url, t),
|
||||
|
||||
@@ -16,19 +16,19 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { InfoIcon } from "../icons";
|
||||
import Action from "./action";
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
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 { QueryClient } from "../../api/use-query";
|
||||
import type Action from "./action";
|
||||
|
||||
export const ShowTrackFileInfoAction = (
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
trackId: number,
|
||||
): Action => ({
|
||||
icon: <InfoIcon />,
|
||||
label: "moreInfo",
|
||||
label: "trackInfo",
|
||||
onClick: () => openTrackFileInfoModal(confirm, trackId),
|
||||
});
|
||||
|
||||
@@ -38,9 +38,9 @@ export const ShowMasterTrackFileInfoAction = (
|
||||
songId: number,
|
||||
): Action => ({
|
||||
icon: <InfoIcon />,
|
||||
label: "moreInfo",
|
||||
label: "trackInfo",
|
||||
onClick: () =>
|
||||
queryClient
|
||||
.fetchQuery(API.getMasterTrack(songId))
|
||||
.fetchQuery(API.getSongMasterTrack(songId))
|
||||
.then((track) => openTrackFileInfoModal(confirm, track.id)),
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { QueryClient } from "../../api/use-query";
|
||||
import Action from "./action";
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import store from "../../state/store";
|
||||
import { Chip, Grid } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import API from "../../api/api";
|
||||
import { useMutation } from "react-query";
|
||||
import { EditIcon } from "../icons";
|
||||
import Song, { SongType } from "../../models/song";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const SongTypeForm = (props: {
|
||||
defaultValue: SongType;
|
||||
onSelect: (type: SongType) => void;
|
||||
}) => {
|
||||
const [currentType, setType] = useState(props.defaultValue);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid container spacing={2} justifyContent="center">
|
||||
{SongType.filter((type) => type != "Unknown").map((type) => (
|
||||
<Grid item key={type}>
|
||||
<Chip
|
||||
label={t(type)}
|
||||
variant={
|
||||
type == currentType ? "filled" : "outlined"
|
||||
}
|
||||
onClick={() => {
|
||||
setType(type);
|
||||
props.onSelect(type);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ChangeSongType = (
|
||||
song: Song,
|
||||
queryClient: QueryClient,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
): Action => {
|
||||
const { t } = useTranslation();
|
||||
const mutation = useMutation((newType: SongType) => {
|
||||
return API.updateSong(song.id, newType)
|
||||
.then(() => {
|
||||
toast.success("Update successful!");
|
||||
queryClient.client.invalidateQueries("songs");
|
||||
queryClient.client.invalidateQueries("release");
|
||||
queryClient.client.invalidateQueries("tracks");
|
||||
queryClient.client.invalidateQueries("videos");
|
||||
queryClient.client.invalidateQueries("bsides");
|
||||
})
|
||||
.catch((error: Error) => toast.error(error.message));
|
||||
});
|
||||
|
||||
return {
|
||||
label: "changeSongType",
|
||||
icon: <EditIcon />,
|
||||
disabled: store.getState().user.user?.admin !== true,
|
||||
onClick: () =>
|
||||
confirm({
|
||||
title: t("changeSongType"),
|
||||
description: (
|
||||
<SongTypeForm
|
||||
defaultValue={song.type}
|
||||
onSelect={(type) => mutation.mutate(type)}
|
||||
/>
|
||||
),
|
||||
cancellationButtonProps: { sx: { display: "none" } },
|
||||
confirmationText: t("done"),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export default ChangeSongType;
|
||||
@@ -16,21 +16,22 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { QueryClient } from "../../api/use-query";
|
||||
import {
|
||||
Button,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@mui/material";
|
||||
import Action from "./action";
|
||||
import store from "../../state/store";
|
||||
import { toast } from "react-hot-toast";
|
||||
import API from "../../api/api";
|
||||
import { useMutation } from "react-query";
|
||||
import { HookTextField, useHookForm } from "mui-react-hook-form-plus";
|
||||
import { UpdateIllustrationIcon } from "../icons";
|
||||
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 { userAtom } from "../../state/user";
|
||||
import { UpdateIllustrationIcon } from "../icons";
|
||||
import type Action from "./action";
|
||||
|
||||
type IllustrationUpdateFormType = {
|
||||
onSubmit: (newUrl: string) => void;
|
||||
@@ -83,17 +84,16 @@ const IllustrationUpdateForm = (props: IllustrationUpdateFormType) => {
|
||||
|
||||
const UpdateIllustrationAction = (
|
||||
queryClient: QueryClient,
|
||||
resourceId: number | string,
|
||||
resourceId: number,
|
||||
resourceType: "artist" | "track" | "release" | "playlist",
|
||||
): Action => {
|
||||
const textFieldId = `update-illustration-${resourceType}-${resourceId}`;
|
||||
const mutation = useMutation(async (newUrl: string) => {
|
||||
const updator =
|
||||
resourceType == "artist"
|
||||
resourceType === "artist"
|
||||
? API.updateArtistIllustration
|
||||
: resourceType == "release"
|
||||
: resourceType === "release"
|
||||
? API.updateReleaseIllustration
|
||||
: resourceType == "playlist"
|
||||
: resourceType === "playlist"
|
||||
? API.updatePlaylistIllustration
|
||||
: API.updateTrackIllustration;
|
||||
|
||||
@@ -101,14 +101,14 @@ const UpdateIllustrationAction = (
|
||||
.then(() => {
|
||||
toast.success("Illustration updated!");
|
||||
queryClient.client.invalidateQueries(resourceType);
|
||||
queryClient.client.invalidateQueries(resourceType + "s");
|
||||
queryClient.client.invalidateQueries(`${resourceType}s`);
|
||||
})
|
||||
.catch(() => toast.error("Illustration update failed"));
|
||||
});
|
||||
|
||||
return {
|
||||
label: "changeIllutration",
|
||||
disabled: store.getState().user.user?.admin !== true,
|
||||
disabled: store.get(userAtom)?.admin !== true,
|
||||
icon: <UpdateIllustrationIcon />,
|
||||
dialog: (controls) => (
|
||||
<IllustrationUpdateForm
|
||||
@@ -121,23 +121,23 @@ const UpdateIllustrationAction = (
|
||||
|
||||
const UpdateArtistIllustrationAction = (
|
||||
queryClient: QueryClient,
|
||||
artistSlugOrId: number | string,
|
||||
) => UpdateIllustrationAction(queryClient, artistSlugOrId, "artist");
|
||||
artistId: number,
|
||||
) => UpdateIllustrationAction(queryClient, artistId, "artist");
|
||||
|
||||
const UpdateReleaseIllustrationAction = (
|
||||
queryClient: QueryClient,
|
||||
releaseSlugOrId: number | string,
|
||||
) => UpdateIllustrationAction(queryClient, releaseSlugOrId, "release");
|
||||
releaseId: number,
|
||||
) => UpdateIllustrationAction(queryClient, releaseId, "release");
|
||||
|
||||
const UpdateTrackIllustrationAction = (
|
||||
queryClient: QueryClient,
|
||||
trackSlugOrId: number | string,
|
||||
) => UpdateIllustrationAction(queryClient, trackSlugOrId, "track");
|
||||
trackId: number,
|
||||
) => UpdateIllustrationAction(queryClient, trackId, "track");
|
||||
|
||||
const UpdatePlaylistIllustrationAction = (
|
||||
queryClient: QueryClient,
|
||||
playlistSlugOrId: number | string,
|
||||
) => UpdateIllustrationAction(queryClient, playlistSlugOrId, "playlist");
|
||||
playlistId: number,
|
||||
) => UpdateIllustrationAction(queryClient, playlistId, "playlist");
|
||||
|
||||
export {
|
||||
UpdateArtistIllustrationAction,
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
import { DataGrid, type GridColDef } from "@mui/x-data-grid";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import API from "../api/api";
|
||||
import { MeeloInfiniteQueryFn, useInfiniteQuery } from "../api/use-query";
|
||||
import Resource from "../models/resource";
|
||||
import { type MeeloInfiniteQueryFn, useInfiniteQuery } from "../api/use-query";
|
||||
import type Resource from "../models/resource";
|
||||
|
||||
type AdminGridProps<DataType extends Resource> = {
|
||||
infiniteQuery: MeeloInfiniteQueryFn<DataType>;
|
||||
@@ -36,27 +36,21 @@ const AdminGrid = <DataType extends Resource>({
|
||||
}: AdminGridProps<DataType>) => {
|
||||
const {
|
||||
data,
|
||||
items,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
fetchNextPage,
|
||||
fetchPreviousPage,
|
||||
} = useInfiniteQuery(infiniteQuery);
|
||||
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();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasNextPage]);
|
||||
return (
|
||||
<DataGrid
|
||||
loading={data?.pages[currentPage] == undefined}
|
||||
loading={data?.pages.at(currentPage) === undefined}
|
||||
rows={data?.pages[currentPage]?.items ?? []}
|
||||
rowCount={itemsCount}
|
||||
pageSize={API.defaultPageSize}
|
||||
@@ -68,9 +62,9 @@ const AdminGrid = <DataType extends Resource>({
|
||||
paginationMode="server"
|
||||
autoHeight
|
||||
onPageChange={(page) => {
|
||||
if (page == currentPage + 1 && hasNextPage) {
|
||||
if (page === currentPage + 1 && hasNextPage) {
|
||||
fetchNextPage();
|
||||
} else if (page == currentPage - 1 && hasPreviousPage) {
|
||||
} else if (page === currentPage - 1 && hasPreviousPage) {
|
||||
fetchPreviousPage();
|
||||
}
|
||||
setCurrentPage(page);
|
||||
|
||||
@@ -17,9 +17,10 @@
|
||||
*/
|
||||
|
||||
import { Avatar } from "@mui/material";
|
||||
import type { ComponentProps } from "react";
|
||||
import type IllustrationModel from "../models/illustration";
|
||||
import { ArtistIcon } from "./icons";
|
||||
import Illustration from "./illustration";
|
||||
import IllustrationModel from "../models/illustration";
|
||||
import { ComponentProps } from "react";
|
||||
|
||||
const ArtistAvatar = (props: {
|
||||
illustration: IllustrationModel | undefined | null;
|
||||
@@ -39,6 +40,7 @@ const ArtistAvatar = (props: {
|
||||
imgProps={{ objectFit: "cover" }}
|
||||
illustration={props.illustration}
|
||||
quality={props.quality}
|
||||
fallback={<ArtistIcon />}
|
||||
/>
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
@@ -17,23 +17,23 @@
|
||||
*/
|
||||
|
||||
import { Button, Divider, Grid } 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 { useDispatch } from "react-redux";
|
||||
import API from "../../api/api";
|
||||
import { setAccessToken } from "../../state/userSlice";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import API from "../../api/api";
|
||||
import { accessTokenAtom } from "../../state/user";
|
||||
|
||||
/**
|
||||
* Authentication form
|
||||
* On successful authentication, update store with access token
|
||||
*/
|
||||
const AuthenticationForm = () => {
|
||||
const dispatch = useDispatch();
|
||||
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,
|
||||
});
|
||||
@@ -41,7 +41,7 @@ const AuthenticationForm = () => {
|
||||
|
||||
const onSubmit = async (values: typeof defaultValues) => {
|
||||
try {
|
||||
if (formType == "signup") {
|
||||
if (formType === "signup") {
|
||||
const createdUser = await API.register(values);
|
||||
|
||||
if (!createdUser.enabled) {
|
||||
@@ -50,7 +50,7 @@ const AuthenticationForm = () => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
dispatch(setAccessToken((await API.login(values)).access_token));
|
||||
setAccessToken((await API.login(values)).access_token);
|
||||
} catch (error: any) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
@@ -110,7 +110,7 @@ const AuthenticationForm = () => {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{formType == "signup" && (
|
||||
{formType === "signup" && (
|
||||
<HookTextField
|
||||
{...registerState("confirm")}
|
||||
textFieldProps={{
|
||||
@@ -137,7 +137,7 @@ const AuthenticationForm = () => {
|
||||
variant="contained"
|
||||
onClick={() => {}}
|
||||
>
|
||||
{formType == "login" ? "Login" : "Signup"}
|
||||
{formType === "login" ? "Login" : "Signup"}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Divider sx={{ width: "100%", paddingY: 1 }} variant="middle" />
|
||||
@@ -146,12 +146,12 @@ const AuthenticationForm = () => {
|
||||
variant="outlined"
|
||||
onClick={() =>
|
||||
setFormType(
|
||||
formType == "login" ? "signup" : "login",
|
||||
formType === "login" ? "signup" : "login",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t(
|
||||
formType == "login"
|
||||
formType === "login"
|
||||
? "signupButton"
|
||||
: "signinButton",
|
||||
)}
|
||||
|
||||
@@ -16,28 +16,25 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { prepareMeeloQuery } from "../../api/use-query";
|
||||
import ModalPage from "../modal-page";
|
||||
import AuthenticationForm from "./authentication-form";
|
||||
import { Grid } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import API from "../../api/api";
|
||||
import { RootState } from "../../state/store";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { setUserProfile } from "../../state/userSlice";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { useQuery as useReactQuery } from "react-query";
|
||||
import API from "../../api/api";
|
||||
import { prepareMeeloQuery } from "../../api/use-query";
|
||||
import { accessTokenAtom, userAtom } from "../../state/user";
|
||||
import ModalPage from "../modal-page";
|
||||
import ThemedImage from "../themed-image";
|
||||
import AuthenticationForm from "./authentication-form";
|
||||
|
||||
const AuthenticationWall = (props: { children: any }) => {
|
||||
const accessToken = useSelector(
|
||||
(store: RootState) => store.user.accessToken,
|
||||
);
|
||||
const [accessToken] = useAtom(accessTokenAtom);
|
||||
const [_, setUser] = useAtom(userAtom);
|
||||
const status = useReactQuery({
|
||||
...prepareMeeloQuery(API.getCurrentUserStatus),
|
||||
useErrorBoundary: false,
|
||||
});
|
||||
const dispatch = useDispatch();
|
||||
const [authentified, setAuthenticationStatus] = useState(
|
||||
status.data?.id !== undefined,
|
||||
);
|
||||
@@ -49,15 +46,15 @@ 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>
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
*/
|
||||
|
||||
import { Box } from "@mui/material";
|
||||
import { blurHashToDataURL } from "../utils/blurhashToDataUrl";
|
||||
import { type ComponentProps, useEffect, useMemo, useState } from "react";
|
||||
import { Blurhash as RBlurhash } from "react-blurhash";
|
||||
import { ComponentProps, useEffect, useMemo, useState } from "react";
|
||||
import { blurHashToDataURL } from "../utils/blurhashToDataUrl";
|
||||
|
||||
type BlurhashProps = ComponentProps<typeof Box> & {
|
||||
blurhash?: string;
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import { Translator } from "../i18n/i18n";
|
||||
import type { useConfirm } from "material-ui-confirm";
|
||||
import type { Translator } from "../i18n/i18n";
|
||||
|
||||
const confirmDownloadAction = (
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
|
||||
@@ -17,16 +17,16 @@
|
||||
*/
|
||||
|
||||
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 { AlbumWithRelations } from "../../models/album";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import ChangeAlbumType from "../actions/album-type";
|
||||
import { RefreshAlbumMetadataAction } from "../actions/refresh-metadata";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type AlbumContextualMenuProps = {
|
||||
album: AlbumWithRelations<"artist">;
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Artist from "../../models/artist";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import type Artist from "../../models/artist";
|
||||
import {
|
||||
GoToArtistAction,
|
||||
GoToArtistAlbumsAction,
|
||||
@@ -25,8 +26,7 @@ import {
|
||||
} from "../actions/link";
|
||||
import { ShareArtistAction } from "../actions/share";
|
||||
import { UpdateArtistIllustrationAction } from "../actions/update-illustration";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
|
||||
type ArtistContextualMenuProps = {
|
||||
artist: Artist;
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
|
||||
import { Dialog, ListItemIcon, ListItemText, MenuItem } from "@mui/material";
|
||||
import Link from "next/link";
|
||||
import Action from "../actions/action";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Action from "../actions/action";
|
||||
|
||||
const ContextualMenuItem = (props: Action & { onDialogClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -29,7 +29,7 @@ const ContextualMenuItem = (props: Action & { onDialogClose: () => void }) => {
|
||||
if (props.disabled === true) {
|
||||
return;
|
||||
}
|
||||
props.onClick && props.onClick();
|
||||
props.onClick?.();
|
||||
if (props.dialog) {
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { ContextualMenuIcon } from "../icons";
|
||||
import { Box, Divider, IconButton, Menu } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import Action from "../actions/action";
|
||||
import type Action from "../actions/action";
|
||||
import { ContextualMenuIcon } from "../icons";
|
||||
import ContextualMenuItem from "./contextual-menu-item";
|
||||
|
||||
type ContextualMenuProps = {
|
||||
@@ -53,38 +53,40 @@ const ContextualMenu = (props: ContextualMenuProps) => {
|
||||
onClose={handleClose}
|
||||
style={{ zIndex: 99999 }}
|
||||
>
|
||||
{props.actions.map((actions, actionGroupIndex, allActions) =>
|
||||
[
|
||||
actions.map((action, actionIndex) => (
|
||||
<Box
|
||||
key={`${actionGroupIndex}/${actionIndex}`}
|
||||
onClick={() => {
|
||||
// If the action is NOT a dialog, close and unmount the menu.
|
||||
// If it is a dialog, we need to keep it mounted, so we have to keep it open
|
||||
if (!action.dialog) {
|
||||
handleClose();
|
||||
}
|
||||
props.onSelect && props.onSelect(action);
|
||||
}}
|
||||
>
|
||||
<ContextualMenuItem
|
||||
{...action}
|
||||
onDialogClose={handleClose}
|
||||
/>
|
||||
</Box>
|
||||
)),
|
||||
].concat(
|
||||
actionGroupIndex < allActions.length - 1
|
||||
? [
|
||||
<Divider
|
||||
key={actionGroupIndex}
|
||||
sx={{ marginY: 0.5 }}
|
||||
variant="middle"
|
||||
/>,
|
||||
]
|
||||
: [],
|
||||
),
|
||||
)}
|
||||
{props.actions
|
||||
.filter((actionList) => actionList.length > 0)
|
||||
.map((actions, actionGroupIndex, allActions) =>
|
||||
[
|
||||
actions.map((action, actionIndex) => (
|
||||
<Box
|
||||
key={`${actionGroupIndex}/${actionIndex}`}
|
||||
onClick={() => {
|
||||
// If the action is NOT a dialog, close and unmount the menu.
|
||||
// If it is a dialog, we need to keep it mounted, so we have to keep it open
|
||||
if (!action.dialog) {
|
||||
handleClose();
|
||||
}
|
||||
props.onSelect?.(action);
|
||||
}}
|
||||
>
|
||||
<ContextualMenuItem
|
||||
{...action}
|
||||
onDialogClose={handleClose}
|
||||
/>
|
||||
</Box>
|
||||
)),
|
||||
].concat(
|
||||
actionGroupIndex < allActions.length - 1
|
||||
? [
|
||||
<Divider
|
||||
key={actionGroupIndex}
|
||||
sx={{ marginY: 0.5 }}
|
||||
variant="middle"
|
||||
/>,
|
||||
]
|
||||
: [],
|
||||
),
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,18 +16,18 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Playlist from "../../models/playlist";
|
||||
import { SharePlaylistAction } from "../actions/share";
|
||||
import { UpdatePlaylistIllustrationAction } from "../actions/update-illustration";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
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 {
|
||||
DeletePlaylistAction,
|
||||
UpdatePlaylistAction,
|
||||
} from "../actions/playlist";
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import { useRouter } from "next/router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SharePlaylistAction } from "../actions/share";
|
||||
import { UpdatePlaylistIllustrationAction } from "../actions/update-illustration";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
|
||||
type PlaylistContextualMenuProps = {
|
||||
playlist: Playlist;
|
||||
@@ -48,7 +48,7 @@ const PlaylistContextualMenu = (props: PlaylistContextualMenuProps) => {
|
||||
[
|
||||
UpdatePlaylistIllustrationAction(
|
||||
queryClient,
|
||||
props.playlist.slug,
|
||||
props.playlist.id,
|
||||
),
|
||||
],
|
||||
[SharePlaylistAction(props.playlist.slug, t)],
|
||||
|
||||
@@ -16,37 +16,39 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { MasterIcon, UpgradeIcon } from "../icons";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useMutation } from "react-query";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import API from "../../api/api";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
import { GoToAlbumAction, GoToArtistAction } from "../actions/link";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "../../state/store";
|
||||
import { ShareReleaseAction } from "../actions/share";
|
||||
import { DownloadReleaseAction } from "../actions/download";
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import { ReleaseWithRelations } from "../../models/release";
|
||||
import { UpdateReleaseIllustrationAction } from "../actions/update-illustration";
|
||||
import ChangeAlbumType from "../actions/album-type";
|
||||
import { RefreshReleaseMetadataAction } from "../actions/refresh-metadata";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "react-query";
|
||||
import API from "../../api/api";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import type { ReleaseWithRelations } from "../../models/release";
|
||||
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 { useAtom } from "jotai";
|
||||
import { userAtom } from "../../state/user";
|
||||
|
||||
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.setReleaseAsMaster(props.release.id)
|
||||
return API.updateAlbum(props.release.albumId, {
|
||||
masterReleaseId: props.release.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t("releaseSetAsMaster"));
|
||||
queryClient.client.invalidateQueries();
|
||||
@@ -64,7 +66,12 @@ const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
|
||||
Promise.allSettled(
|
||||
tracks
|
||||
.reverse()
|
||||
.map((track) => API.setTrackAsMaster(track.id)),
|
||||
.filter((track) => track.songId != null)
|
||||
.map((track) =>
|
||||
API.updateSong(track.songId!, {
|
||||
masterTrackId: track.id,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.then(() => {
|
||||
toast.success(t("tracksUpdated"));
|
||||
@@ -85,7 +92,7 @@ const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
|
||||
{
|
||||
label: "setAsMaster",
|
||||
disabled:
|
||||
props.release.id == props.release.album.masterId ||
|
||||
props.release.id === props.release.album.masterId ||
|
||||
!userIsAdmin,
|
||||
icon: <MasterIcon />,
|
||||
onClick: () => masterMutation.mutate(),
|
||||
|
||||
@@ -16,13 +16,16 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Artist from "../../models/artist";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import type Artist from "../../models/artist";
|
||||
import type { TrackWithRelations } from "../../models/track";
|
||||
import { DownloadAction } from "../actions/download";
|
||||
import {
|
||||
GoToArtistAction,
|
||||
GoToRelatedTracksAction,
|
||||
GoToSongInfoAction,
|
||||
GoToSongLyricsAction,
|
||||
GoToSongVersionAction,
|
||||
} from "../actions/link";
|
||||
@@ -31,15 +34,12 @@ import {
|
||||
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 { TrackWithRelations } from "../../models/track";
|
||||
import { UpdateTrackIllustrationAction } from "../actions/update-illustration";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import { RefreshTrackMetadataAction } from "../actions/refresh-metadata";
|
||||
import ChangeSongType from "../actions/song-type";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
|
||||
type ReleaseTrackContextualMenuProps = {
|
||||
track: TrackWithRelations<"song" | "illustration">;
|
||||
@@ -48,36 +48,52 @@ type ReleaseTrackContextualMenuProps = {
|
||||
};
|
||||
|
||||
const ReleaseTrackContextualMenu = (props: ReleaseTrackContextualMenuProps) => {
|
||||
const songSlug = props.track.song.slug;
|
||||
const songSlug = props.track.song?.slug;
|
||||
const confirm = useConfirm();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { playNext, playAfter } = usePlayerContext();
|
||||
|
||||
return (
|
||||
<ContextualMenu
|
||||
onSelect={props.onSelect}
|
||||
actions={[
|
||||
[GoToArtistAction(props.artist.slug)],
|
||||
[GoToSongLyricsAction(songSlug)],
|
||||
songSlug
|
||||
? [
|
||||
GoToSongLyricsAction(songSlug),
|
||||
GoToSongInfoAction(songSlug),
|
||||
]
|
||||
: [],
|
||||
[
|
||||
PlayNextAction(async () => props, playNext),
|
||||
PlayAfterAction(async () => props, playAfter),
|
||||
PlayNextAction(async () => props),
|
||||
PlayAfterAction(async () => props),
|
||||
],
|
||||
[AddToPlaylistAction(props.track.song.id, queryClient)],
|
||||
props.track.songId
|
||||
? [AddToPlaylistAction(props.track.songId, queryClient)]
|
||||
: [],
|
||||
songSlug
|
||||
? [
|
||||
GoToSongVersionAction(songSlug),
|
||||
GoToRelatedTracksAction(songSlug),
|
||||
]
|
||||
: [],
|
||||
[
|
||||
GoToSongVersionAction(songSlug),
|
||||
GoToRelatedTracksAction(songSlug),
|
||||
],
|
||||
[
|
||||
ChangeSongType(props.track.song, queryClient, confirm),
|
||||
...(props.track.song
|
||||
? [
|
||||
ChangeSongType(
|
||||
props.track.song,
|
||||
queryClient,
|
||||
confirm,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
UpdateTrackIllustrationAction(queryClient, props.track.id),
|
||||
RefreshTrackMetadataAction(props.track.id, t),
|
||||
],
|
||||
[ShowTrackFileInfoAction(confirm, props.track.id)],
|
||||
[
|
||||
DownloadAction(confirm, props.track.sourceFileId, t),
|
||||
ShareSongAction(songSlug, t),
|
||||
...(songSlug ? [ShareSongAction(songSlug, t)] : []),
|
||||
],
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -16,15 +16,19 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import API from "../../api/api";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
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 type { SongWithRelations } from "../../models/song";
|
||||
import { DownloadAsyncAction } from "../actions/download";
|
||||
import {
|
||||
GoToArtistAction,
|
||||
GoToRelatedTracksAction,
|
||||
GoToReleaseAsyncAction,
|
||||
GoToSongInfoAction,
|
||||
GoToSongLyricsAction,
|
||||
GoToSongVersionAction,
|
||||
} from "../actions/link";
|
||||
@@ -33,16 +37,12 @@ import {
|
||||
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 { SongWithRelations } from "../../models/song";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import { toast } from "react-hot-toast";
|
||||
import ChangeSongType from "../actions/song-type";
|
||||
import { RefreshSongMetadataAction } from "../actions/refresh-metadata";
|
||||
import { DeleteIcon } from "../icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
|
||||
type SongContextualMenuProps = {
|
||||
song: SongWithRelations<"artist">;
|
||||
@@ -55,10 +55,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.getMasterTrack(songSlug, ["release", "illustration"]),
|
||||
API.getSongMasterTrack(songSlug, ["release", "illustration"]),
|
||||
);
|
||||
const router = useRouter();
|
||||
const confirm = useConfirm();
|
||||
@@ -76,13 +75,14 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
|
||||
GoToArtistAction(props.song.artist.slug),
|
||||
GoToReleaseAsyncAction(
|
||||
router,
|
||||
async () => (await getMasterTrack()).release.slug,
|
||||
async () =>
|
||||
(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)],
|
||||
[
|
||||
|
||||
@@ -16,19 +16,20 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { MasterIcon } from "../icons";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useMutation } from "react-query";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import { useSelector } from "react-redux";
|
||||
import API from "../../api/api";
|
||||
import { RootState } from "../../state/store";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
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 API from "../../api/api";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import type { TrackWithRelations } from "../../models/track";
|
||||
import { userAtom } from "../../state/user";
|
||||
import type Action from "../actions/action";
|
||||
import { DownloadAction } from "../actions/download";
|
||||
import {
|
||||
GoToArtistAction,
|
||||
GoToReleaseAction,
|
||||
GoToSongInfoAction,
|
||||
GoToSongLyricsAction,
|
||||
} from "../actions/link";
|
||||
import {
|
||||
@@ -36,40 +37,43 @@ import {
|
||||
PlayAfterAction,
|
||||
PlayNextAction,
|
||||
} from "../actions/playlist";
|
||||
import { ShowTrackFileInfoAction } from "../actions/show-track-info";
|
||||
import { TrackWithRelations } from "../../models/track";
|
||||
import { UpdateTrackIllustrationAction } from "../actions/update-illustration";
|
||||
import { RefreshTrackMetadataAction } from "../actions/refresh-metadata";
|
||||
import ChangeSongType from "../actions/song-type";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import { 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";
|
||||
|
||||
type TrackContextualMenuProps = {
|
||||
track: TrackWithRelations<"song" | "illustration">;
|
||||
isVideo?: true; // If true, will add 'go to artist' to menu
|
||||
track: TrackWithRelations<"video" | "song" | "illustration">;
|
||||
onSelect?: () => void;
|
||||
};
|
||||
|
||||
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 isMaster = props.track.song?.masterId === props.track.id;
|
||||
const getPlayNextProps = () =>
|
||||
Promise.all([
|
||||
queryClient.fetchQuery(API.getArtist(props.track.song.artistId)),
|
||||
queryClient.fetchQuery(API.getRelease(props.track.releaseId)),
|
||||
queryClient.fetchQuery(
|
||||
API.getArtist(
|
||||
(props.track.song ?? props.track.video)!.artistId,
|
||||
),
|
||||
),
|
||||
props.track.releaseId &&
|
||||
queryClient.fetchQuery(API.getRelease(props.track.releaseId)),
|
||||
]).then(([artist, release]) => ({
|
||||
track: props.track,
|
||||
artist,
|
||||
release,
|
||||
}));
|
||||
const { t } = useTranslation();
|
||||
const { playNext, playAfter } = usePlayerContext();
|
||||
const masterMutation = useMutation(async () => {
|
||||
return API.setTrackAsMaster(props.track.id)
|
||||
return API.updateSong(props.track.songId!, {
|
||||
masterTrackId: props.track.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t("trackSetAsMaster"));
|
||||
queryClient.client.invalidateQueries();
|
||||
@@ -81,28 +85,44 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
|
||||
<ContextualMenu
|
||||
onSelect={props.onSelect}
|
||||
actions={[
|
||||
props.isVideo
|
||||
[
|
||||
props.track.releaseId
|
||||
? GoToReleaseAction(props.track.releaseId)
|
||||
: undefined,
|
||||
].filter((a): a is Action => a !== undefined),
|
||||
props.track.song
|
||||
? [
|
||||
GoToArtistAction(props.track.song.artistId),
|
||||
GoToReleaseAction(props.track.releaseId),
|
||||
GoToSongLyricsAction(props.track.song.slug),
|
||||
GoToSongInfoAction(props.track.song.slug),
|
||||
]
|
||||
: [GoToReleaseAction(props.track.releaseId)],
|
||||
[GoToSongLyricsAction(props.track.song.slug)],
|
||||
: [],
|
||||
[
|
||||
PlayNextAction(getPlayNextProps, playNext),
|
||||
PlayAfterAction(getPlayNextProps, playAfter),
|
||||
PlayNextAction(getPlayNextProps),
|
||||
PlayAfterAction(getPlayNextProps),
|
||||
],
|
||||
[AddToPlaylistAction(props.track.songId, queryClient)],
|
||||
props.track.songId
|
||||
? [AddToPlaylistAction(props.track.songId, queryClient)]
|
||||
: [],
|
||||
props.track.songId
|
||||
? [
|
||||
{
|
||||
label: "setAsMaster",
|
||||
disabled: isMaster || !userIsAdmin,
|
||||
icon: <MasterIcon />,
|
||||
onClick: () => masterMutation.mutate(),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
[
|
||||
{
|
||||
label: "setAsMaster",
|
||||
disabled: isMaster || !userIsAdmin,
|
||||
icon: <MasterIcon />,
|
||||
onClick: () => masterMutation.mutate(),
|
||||
},
|
||||
],
|
||||
[
|
||||
ChangeSongType(props.track.song, queryClient, confirm),
|
||||
...(props.track.song
|
||||
? [
|
||||
ChangeSongType(
|
||||
props.track.song,
|
||||
queryClient,
|
||||
confirm,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
UpdateTrackIllustrationAction(queryClient, props.track.id),
|
||||
RefreshTrackMetadataAction(props.track.id, t),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import API from "../../api/api";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import type { VideoWithRelations } from "../../models/video";
|
||||
import type Action from "../actions/action";
|
||||
import { DownloadAction } from "../actions/download";
|
||||
import {
|
||||
GoToArtistAction,
|
||||
GoToReleaseAction,
|
||||
GoToSongInfoAction,
|
||||
GoToSongLyricsAction,
|
||||
} from "../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";
|
||||
|
||||
type VideoContextualMenuProps = {
|
||||
video: VideoWithRelations<"master" | "illustration">;
|
||||
onSelect?: () => void;
|
||||
};
|
||||
|
||||
const VideoContextualMenu = (props: VideoContextualMenuProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const getPlayNextProps = () =>
|
||||
Promise.all([
|
||||
queryClient.fetchQuery(API.getArtist(props.video.artistId)),
|
||||
props.video.master.releaseId &&
|
||||
queryClient.fetchQuery(
|
||||
API.getRelease(props.video.master.releaseId),
|
||||
),
|
||||
]).then(([artist, release]) => ({
|
||||
track: {
|
||||
...props.video.master,
|
||||
illustration: props.video.illustration,
|
||||
},
|
||||
artist,
|
||||
release,
|
||||
}));
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ContextualMenu
|
||||
onSelect={props.onSelect}
|
||||
actions={[
|
||||
[
|
||||
GoToArtistAction(props.video.artistId),
|
||||
props.video.master.releaseId
|
||||
? GoToReleaseAction(props.video.master.releaseId)
|
||||
: undefined,
|
||||
].filter((a): a is Action => a !== undefined),
|
||||
props.video.songId
|
||||
? [
|
||||
GoToSongLyricsAction(props.video.songId),
|
||||
GoToSongInfoAction(props.video.songId),
|
||||
]
|
||||
: [],
|
||||
[
|
||||
PlayNextAction(getPlayNextProps),
|
||||
PlayAfterAction(getPlayNextProps),
|
||||
],
|
||||
props.video.songId
|
||||
? [AddToPlaylistAction(props.video.songId, queryClient)]
|
||||
: [],
|
||||
[
|
||||
ChangeVideoType(props.video, queryClient, confirm),
|
||||
UpdateTrackIllustrationAction(
|
||||
queryClient,
|
||||
props.video.master.id,
|
||||
),
|
||||
RefreshTrackMetadataAction(props.video.master.id, t),
|
||||
],
|
||||
[ShowTrackFileInfoAction(confirm, props.video.master.id)],
|
||||
[DownloadAction(confirm, props.video.master.sourceFileId, t)],
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoContextualMenu;
|
||||
@@ -16,7 +16,6 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { AscIcon, DescIcon, GridIcon, ListIcon } from "../icons";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
@@ -25,23 +24,35 @@ import {
|
||||
Tooltip,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { NextRouter } from "next/router";
|
||||
import type { NextRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Option from "./option";
|
||||
import OptionButton from "./option-button";
|
||||
import { LayoutOption, getLayoutParams } from "../../utils/layout";
|
||||
import { Order, getOrderParams } from "../../utils/sorting";
|
||||
import parseQueryParam from "../../utils/parse-query-param";
|
||||
import Action from "../actions/action";
|
||||
import { TranslationKey } from "../../i18n/i18n";
|
||||
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";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useInfiniteQuery as useReactInfiniteQuery } from "react-query";
|
||||
import Fade from "../fade";
|
||||
import type { TranslationKey } from "../../i18n/i18n";
|
||||
import globalLibrary from "../../utils/global-library";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
DefaultItemSize,
|
||||
ItemSize,
|
||||
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,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
} from "../icons";
|
||||
import type Option from "./option";
|
||||
import OptionButton from "./option-button";
|
||||
|
||||
export type Toggle = {
|
||||
name: string;
|
||||
@@ -54,6 +65,7 @@ export type OptionState<
|
||||
AdditionalProps extends object = object,
|
||||
> = {
|
||||
view: LayoutOption;
|
||||
itemSize: ItemSize;
|
||||
order: Order;
|
||||
sortBy: SortingKeys[number];
|
||||
library: string | null;
|
||||
@@ -88,7 +100,7 @@ const Controls = <
|
||||
>(
|
||||
props: ControllerProps<SortingKeys, Options, Values>,
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
const _theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const librariesQuery = useReactInfiniteQuery({
|
||||
...prepareMeeloInfiniteQuery(API.getLibraries),
|
||||
@@ -107,6 +119,7 @@ const Controls = <
|
||||
? props.router?.query.library?.at(0)
|
||||
: props.router?.query.library;
|
||||
const baseOptions: OptionState<SortingKeys> = {
|
||||
itemSize: DefaultItemSize,
|
||||
view:
|
||||
((props.disableLayoutToggle !== true &&
|
||||
getLayoutParams(props.router?.query.view)) ||
|
||||
@@ -125,27 +138,25 @@ const Controls = <
|
||||
"asc",
|
||||
library: props.disableLibrarySelector
|
||||
? null
|
||||
: libraryQuery ?? null,
|
||||
: (libraryQuery ?? null),
|
||||
};
|
||||
|
||||
props.options?.forEach((option) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
for (const option of props.options ?? []) {
|
||||
// @ts-ignore
|
||||
baseOptions[option.name] =
|
||||
parseQueryParam(
|
||||
props.router?.query[option.name],
|
||||
option.values,
|
||||
) ?? option.values[0];
|
||||
});
|
||||
props.toggles?.forEach((option) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
}
|
||||
for (const option of props.toggles ?? []) {
|
||||
// @ts-ignore
|
||||
baseOptions[option.name] =
|
||||
(parseQueryParam(props.router?.query[option.name], [
|
||||
"false",
|
||||
"true",
|
||||
]) ?? "false") === "true";
|
||||
});
|
||||
}
|
||||
return baseOptions;
|
||||
},
|
||||
);
|
||||
@@ -166,7 +177,12 @@ const Controls = <
|
||||
name: string;
|
||||
value: boolean | string | null;
|
||||
}) => {
|
||||
setOptionState({ ...optionsState, [name]: value });
|
||||
setOptionState({
|
||||
...optionsState,
|
||||
[name]: value,
|
||||
// Reset itemsize when changing layout
|
||||
itemSize: name === "view" ? DefaultItemSize : optionsState.itemSize,
|
||||
});
|
||||
value = value?.toString() ?? null;
|
||||
if (props.router) {
|
||||
const path = props.router.asPath.split("?")[0];
|
||||
@@ -226,14 +242,14 @@ const Controls = <
|
||||
...libraries,
|
||||
].find(
|
||||
({ slug }) =>
|
||||
slug ==
|
||||
slug ===
|
||||
optionsState.library,
|
||||
)?.name ?? globalLibrary.name,
|
||||
},
|
||||
],
|
||||
}}
|
||||
onSelect={({ name, value }) => {
|
||||
if (value == globalLibrary.name) {
|
||||
if (value === globalLibrary.name) {
|
||||
updateOptionState({
|
||||
name,
|
||||
value: null,
|
||||
@@ -243,7 +259,7 @@ const Controls = <
|
||||
name,
|
||||
value:
|
||||
libraries.find(
|
||||
(lib) => lib.name == value,
|
||||
(lib) => lib.name === value,
|
||||
)?.slug ?? null,
|
||||
});
|
||||
}
|
||||
@@ -257,7 +273,7 @@ const Controls = <
|
||||
optionsState.sortBy as TranslationKey,
|
||||
)}`,
|
||||
icon:
|
||||
optionsState.order == "desc" ? (
|
||||
optionsState.order === "desc" ? (
|
||||
<DescIcon />
|
||||
) : (
|
||||
<AscIcon />
|
||||
@@ -288,7 +304,7 @@ const Controls = <
|
||||
options: [
|
||||
{
|
||||
...option,
|
||||
// eslint-disable-next-line max-len
|
||||
|
||||
currentValue:
|
||||
optionsState[
|
||||
option.name as keyof typeof optionsState
|
||||
@@ -306,15 +322,55 @@ const Controls = <
|
||||
onClick={() => {
|
||||
updateOptionState({
|
||||
name: toggle.name,
|
||||
value: !optionsState[
|
||||
toggle.name as keyof OptionState<SortingKeys>
|
||||
],
|
||||
value:
|
||||
!optionsState[
|
||||
toggle.name as keyof OptionState<SortingKeys>
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t(toggle.label)}
|
||||
</Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<ButtonGroup variant="contained">
|
||||
{optionsState.view === "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={optionsState.itemSize === size}
|
||||
onClick={() =>
|
||||
setOptionState(
|
||||
({ itemSize, ...rest }) => ({
|
||||
...rest,
|
||||
itemSize:
|
||||
ItemSize[
|
||||
f(
|
||||
ItemSize.indexOf(
|
||||
itemSize,
|
||||
),
|
||||
)
|
||||
],
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon />
|
||||
</Button>
|
||||
))}
|
||||
{props.disableLayoutToggle !== true && (
|
||||
<Tooltip title={t("changeLayout")}>
|
||||
<Button
|
||||
@@ -322,13 +378,13 @@ const Controls = <
|
||||
updateOptionState({
|
||||
name: "view",
|
||||
value:
|
||||
optionsState.view == "grid"
|
||||
optionsState.view === "grid"
|
||||
? "list"
|
||||
: "grid",
|
||||
})
|
||||
}
|
||||
>
|
||||
{optionsState.view == "grid" ? (
|
||||
{optionsState.view === "grid" ? (
|
||||
<ListIcon />
|
||||
) : (
|
||||
<GridIcon />
|
||||
@@ -340,16 +396,16 @@ const Controls = <
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<ButtonGroup variant="contained">
|
||||
{props.actions?.map((action, index) => (
|
||||
{props.actions?.map((action) => (
|
||||
<Button
|
||||
key={"action-" + action.label}
|
||||
key={`action-${action.label}`}
|
||||
startIcon={action.icon}
|
||||
variant="contained"
|
||||
onClickCapture={() => {
|
||||
if (action.disabled === true) {
|
||||
return;
|
||||
}
|
||||
action.onClick && action.onClick();
|
||||
action.onClick?.();
|
||||
action.dialog &&
|
||||
setOpenActionModal(action.label);
|
||||
}}
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { CheckIcon } from "../icons";
|
||||
import { Button, Divider, ListItemIcon, Menu, MenuItem } from "@mui/material";
|
||||
import Option, { OptionGroup } from "./option";
|
||||
import { useState } from "react";
|
||||
import { TranslationKey } from "../../i18n/i18n";
|
||||
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]>[],
|
||||
@@ -64,7 +65,7 @@ const OptionButton = <
|
||||
<MenuItem
|
||||
key={value}
|
||||
sx={{ borderRadius: "0" }}
|
||||
selected={option.currentValue == value}
|
||||
selected={option.currentValue === value}
|
||||
onClick={() => {
|
||||
if (props.onSelect) {
|
||||
props.onSelect({
|
||||
@@ -76,7 +77,7 @@ const OptionButton = <
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{option.currentValue == value && (
|
||||
{option.currentValue === value && (
|
||||
<CheckIcon />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
@@ -85,7 +86,7 @@ const OptionButton = <
|
||||
</MenuItem>
|
||||
))
|
||||
.concat(
|
||||
index + 1 != array.length
|
||||
index + 1 !== array.length
|
||||
? [<Divider key={index} />]
|
||||
: [],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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, Grid } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TranslationKey } from "../i18n/i18n";
|
||||
import type Action from "./actions/action";
|
||||
|
||||
export type EmptyStateProps = {
|
||||
icon: React.ReactNode;
|
||||
text: TranslationKey;
|
||||
actions: Action[];
|
||||
};
|
||||
|
||||
export const EmptyState = (props: EmptyStateProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
direction="column"
|
||||
rowSpacing={3}
|
||||
sx={{
|
||||
paddingTop: 3,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
justifyContent: "center",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
color: "text.disabled",
|
||||
}}
|
||||
>
|
||||
<Grid item>{props.icon}</Grid>
|
||||
<Grid item>{t(props.text)}</Grid>
|
||||
<Grid
|
||||
item
|
||||
container
|
||||
sx={{ display: "flex", justifyContent: "center" }}
|
||||
>
|
||||
{props.actions?.map((a, i) => (
|
||||
<Grid item key={i}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
href={a.href}
|
||||
onClick={a.onClick}
|
||||
>
|
||||
{t(a.label)}
|
||||
</Button>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
+12
-10
@@ -17,35 +17,37 @@
|
||||
*/
|
||||
|
||||
import { Box, Button, Skeleton } from "@mui/material";
|
||||
import ExternalId from "../models/external-id";
|
||||
import Illustration from "./illustration";
|
||||
import Link from "next/link";
|
||||
import type { ExternalMetadataSource } from "../models/external-metadata";
|
||||
import { ProviderIcon } from "./icons";
|
||||
import Illustration from "./illustration";
|
||||
|
||||
type ExternalIdBadgeProps = {
|
||||
externalId: ExternalId | undefined;
|
||||
type ExternalMetadataBadgeProps = {
|
||||
source: ExternalMetadataSource | undefined;
|
||||
};
|
||||
|
||||
const ExternalIdBadge = ({ externalId }: ExternalIdBadgeProps) => {
|
||||
const ExternalMetadataBadge = ({ source }: ExternalMetadataBadgeProps) => {
|
||||
const badge = (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={
|
||||
<Box sx={{ width: 30 }}>
|
||||
<Illustration
|
||||
url={externalId?.provider.icon ?? undefined}
|
||||
url={source?.providerIcon ?? undefined}
|
||||
quality="original"
|
||||
fallback={<ProviderIcon />}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{externalId?.provider.name ?? <Skeleton width="50px" />}
|
||||
{source?.providerName ?? <Skeleton width="50px" />}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (externalId?.url) {
|
||||
if (source?.url) {
|
||||
return (
|
||||
<Link
|
||||
href={externalId.url ?? undefined}
|
||||
href={source.url ?? undefined}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
@@ -56,4 +58,4 @@ const ExternalIdBadge = ({ externalId }: ExternalIdBadgeProps) => {
|
||||
return badge;
|
||||
};
|
||||
|
||||
export default ExternalIdBadge;
|
||||
export default ExternalMetadataBadge;
|
||||
@@ -16,10 +16,9 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Fade as MUIFade } from "@mui/material";
|
||||
import type { ComponentProps } from "react";
|
||||
import { isClientSideRendering } from "../utils/is-ssr";
|
||||
import React, { ComponentProps } from "react";
|
||||
/**
|
||||
* Wrapper around MUI's Fade.
|
||||
* Fade is the main animation used in the app.
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import Genre from "../models/genre";
|
||||
import { Chip, Skeleton } from "@mui/material";
|
||||
import { ComponentProps } from "react";
|
||||
import Link from "next/link";
|
||||
import type { ComponentProps } from "react";
|
||||
import type Genre from "../models/genre";
|
||||
|
||||
type GenreButtonProps = {
|
||||
genre: Pick<Genre, "name" | "slug"> | undefined;
|
||||
|
||||
@@ -17,35 +17,30 @@
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlbumWithRelations } from "../../models/album";
|
||||
import getYear from "../../utils/getYear";
|
||||
import type { AlbumWithRelations } from "../../models/album";
|
||||
import type { AlbumExternalMetadata } from "../../models/external-metadata";
|
||||
import { getYear } from "../../utils/date";
|
||||
import HighlightCard from "./highlight-card";
|
||||
|
||||
type AlbumHighlightCardProps = {
|
||||
album:
|
||||
| AlbumWithRelations<
|
||||
"artist" | "externalIds" | "genres" | "illustration"
|
||||
>
|
||||
| undefined;
|
||||
album: AlbumWithRelations<"artist" | "genres" | "illustration"> | undefined;
|
||||
externalMetadata: AlbumExternalMetadata | undefined;
|
||||
};
|
||||
const AlbumHighlightCard = ({ album }: AlbumHighlightCardProps) => {
|
||||
const AlbumHighlightCard = ({
|
||||
album,
|
||||
externalMetadata,
|
||||
}: AlbumHighlightCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<HighlightCard
|
||||
title={album?.name}
|
||||
headline={album?.name}
|
||||
body={
|
||||
album?.externalIds
|
||||
.filter(
|
||||
({ provider }) =>
|
||||
provider.name.toLowerCase() !== "discogs",
|
||||
)
|
||||
.map((id) => id.description)
|
||||
.filter((desc): desc is string => desc !== null)
|
||||
.sort((descA, descB) => descA.length - descB.length)
|
||||
.at(0) ||
|
||||
externalMetadata?.description ||
|
||||
[
|
||||
album ? album.artist?.name ?? t("compilation") : undefined,
|
||||
album
|
||||
? (album.artist?.name ?? t("compilation"))
|
||||
: undefined,
|
||||
album ? getYear(album.releaseDate) : undefined,
|
||||
]
|
||||
.filter((elem) => elem != null)
|
||||
|
||||
@@ -18,10 +18,10 @@
|
||||
|
||||
import { Chip, Grid, Skeleton, Typography, useTheme } from "@mui/material";
|
||||
import Link from "next/link";
|
||||
import IllustrationModel from "../../models/illustration";
|
||||
import Illustration from "../illustration";
|
||||
import { useMemo } from "react";
|
||||
import type IllustrationModel from "../../models/illustration";
|
||||
import { useAccentColor } from "../../utils/accent-color";
|
||||
import Illustration from "../illustration";
|
||||
|
||||
type HighlightCardProps = {
|
||||
title: string | undefined;
|
||||
@@ -35,7 +35,6 @@ type HighlightCardProps = {
|
||||
const HighlightCard = (props: HighlightCardProps) => {
|
||||
const theme = useTheme();
|
||||
const accentColor = useAccentColor(props.illustration);
|
||||
|
||||
const cardColor = useMemo(() => {
|
||||
if (accentColor !== null) {
|
||||
const themePaperColor = `rgba(${theme.vars.palette.background.defaultChannel} / 0.75)`;
|
||||
@@ -52,6 +51,16 @@ const HighlightCard = (props: HighlightCardProps) => {
|
||||
backgroundColor: `rgba(${theme.vars.palette.background.defaultChannel} / 0.40)`,
|
||||
};
|
||||
}, [accentColor, theme]);
|
||||
const scrollbarColorStyle = useMemo(() => {
|
||||
return {
|
||||
[theme.getColorSchemeSelector("light")]: {
|
||||
scrollbarColor: `${accentColor?.light} transparent`,
|
||||
},
|
||||
[theme.getColorSchemeSelector("dark")]: {
|
||||
scrollbarColor: `${accentColor?.dark} transparent`,
|
||||
},
|
||||
};
|
||||
}, [accentColor, theme]);
|
||||
const style = {
|
||||
...cardColor,
|
||||
boxShadow: "none",
|
||||
@@ -110,10 +119,12 @@ const HighlightCard = (props: HighlightCardProps) => {
|
||||
item
|
||||
xs
|
||||
sx={{
|
||||
overflow: "scroll",
|
||||
overflowY: "scroll",
|
||||
overflowX: "clip",
|
||||
marginRight: -2,
|
||||
paddingY: 1,
|
||||
paddingRight: 2,
|
||||
...scrollbarColorStyle,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
|
||||
@@ -28,6 +28,7 @@ export const AlbumIcon = Iconsax.MusicDashboard;
|
||||
export const ArtistIcon = Iconsax.Profile2User;
|
||||
export const SongIcon = Iconsax.Musicnote;
|
||||
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 +37,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 +50,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 +85,9 @@ 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;
|
||||
@@ -17,17 +17,14 @@
|
||||
*/
|
||||
|
||||
import { Box, IconButton, Skeleton, useTheme } from "@mui/material";
|
||||
import Image, { ImageProps } from "next/image";
|
||||
import Image, { type ImageProps } from "next/image";
|
||||
import { useState } from "react";
|
||||
import type { RequireExactlyOne } from "type-fest";
|
||||
import API from "../api/api";
|
||||
import whiteIllustrationFallback from "../../public/icon.png";
|
||||
import blackIllustrationFallback from "../../public/icon-black.png";
|
||||
import { RequireExactlyOne } from "type-fest";
|
||||
import IllustrationModel from "../models/illustration";
|
||||
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";
|
||||
import { isSSR } from "../utils/is-ssr";
|
||||
|
||||
type ImageQuality = "low" | "medium" | "high" | "original";
|
||||
|
||||
@@ -67,8 +64,8 @@ const Illustration = (props: IllustrationProps) => {
|
||||
const url =
|
||||
props.url === null
|
||||
? null
|
||||
: props.url ??
|
||||
(props.illustration === null ? null : props.illustration?.url);
|
||||
: (props.url ??
|
||||
(props.illustration === null ? null : props.illustration?.url));
|
||||
const blurhash = props.illustration?.blurhash;
|
||||
const aspectRatio =
|
||||
props.aspectRatio ?? props.illustration?.aspectRatio ?? 1;
|
||||
@@ -80,7 +77,7 @@ const Illustration = (props: IllustrationProps) => {
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={"illustration-" + url}
|
||||
key={`illustration-${url}`}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
@@ -124,7 +121,7 @@ const Illustration = (props: IllustrationProps) => {
|
||||
overflow: "hidden",
|
||||
aspectRatio: aspectRatio.toString(),
|
||||
...props.imgProps,
|
||||
...(props.imgProps?.objectFit == "cover"
|
||||
...(props.imgProps?.objectFit === "cover"
|
||||
? { width: "100%", height: "100%" }
|
||||
: dimensionsFromAspectRatio),
|
||||
}}
|
||||
@@ -141,47 +138,60 @@ const Illustration = (props: IllustrationProps) => {
|
||||
style={{
|
||||
position: "relative",
|
||||
aspectRatio:
|
||||
props.imgProps?.objectFit == "cover"
|
||||
props.imgProps?.objectFit === "cover"
|
||||
? undefined
|
||||
: aspectRatio.toString(),
|
||||
overflow: "hidden",
|
||||
display: "block",
|
||||
...(props.imgProps?.objectFit == "cover"
|
||||
...(props.imgProps?.objectFit === "cover"
|
||||
? { width: "100%", height: "100%" }
|
||||
: dimensionsFromAspectRatio),
|
||||
}}
|
||||
>
|
||||
{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")}
|
||||
@@ -196,17 +206,17 @@ const Illustration = (props: IllustrationProps) => {
|
||||
unoptimized
|
||||
style={{
|
||||
borderRadius:
|
||||
props.quality == "low"
|
||||
props.quality === "low"
|
||||
? 6
|
||||
: theme.shape.borderRadius,
|
||||
objectFit: "contain",
|
||||
opacity: loadingState == "loading" ? 0 : 1,
|
||||
opacity: loadingState === "loading" ? 0 : 1,
|
||||
transition: `opacity ${theme.transitions.duration.enteringScreen}ms ${theme.transitions.easing.easeIn}`,
|
||||
...props.imgProps,
|
||||
}}
|
||||
src={
|
||||
API.getIllustrationURL(url) +
|
||||
(props.quality == "original"
|
||||
(props.quality === "original"
|
||||
? ""
|
||||
: `${
|
||||
url.includes("?") ? "&" : "?"
|
||||
|
||||
@@ -16,17 +16,28 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Grid } from "@mui/material";
|
||||
import Resource from "../../models/resource";
|
||||
import InfiniteScroll from "./infinite-scroll";
|
||||
import { IllustratedResource } from "../../models/illustration";
|
||||
import { type Breakpoint, Grid } from "@mui/material";
|
||||
import type { IllustratedResource } from "../../models/illustration";
|
||||
import type Resource from "../../models/resource";
|
||||
import { useGradientBackground } from "../../utils/gradient-background";
|
||||
import type { ItemSize } from "../../utils/layout";
|
||||
import InfiniteScroll from "./infinite-scroll";
|
||||
|
||||
type TypedList<T extends Resource> = typeof InfiniteScroll<T>;
|
||||
type InfiniteGridProps<T extends Resource> = Omit<
|
||||
Parameters<TypedList<T>>[0],
|
||||
"render"
|
||||
> & { render: (item: T | undefined) => JSX.Element };
|
||||
> & { render: (item: T | undefined) => JSX.Element; itemSize: ItemSize };
|
||||
|
||||
const GridPresets: Record<ItemSize, Partial<Record<Breakpoint, number>>> = {
|
||||
// Note: values of sm == values of md,
|
||||
// because of navbar reducing the actual sufrace of md
|
||||
xs: { xs: 12 / 5, sm: 2, md: 12 / 8, lg: 12 / 10, xl: 1 },
|
||||
s: { xs: 3, sm: 12 / 5, md: 12 / 5, lg: 12 / 8, xl: 12 / 10 },
|
||||
m: { xs: 4, sm: 3, md: 3, lg: 2, xl: 12 / 8 },
|
||||
l: { xs: 6, sm: 4, md: 4, lg: 12 / 5, xl: 12 / 6 },
|
||||
xl: { xs: 12, sm: 6, md: 6, lg: 12 / 4, xl: 12 / 5 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Infinite Scrolling List
|
||||
@@ -40,7 +51,6 @@ const InfiniteGrid = <T extends IllustratedResource>(
|
||||
<InfiniteScroll
|
||||
{...props}
|
||||
render={(items) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { GradientBackground } = useGradientBackground(
|
||||
items.find((item) => item?.illustration !== undefined)
|
||||
?.illustration?.colors,
|
||||
@@ -50,18 +60,16 @@ const InfiniteGrid = <T extends IllustratedResource>(
|
||||
<>
|
||||
<GradientBackground />
|
||||
<Grid
|
||||
columnSpacing={2}
|
||||
columnSpacing={
|
||||
["xs", "s"].includes(props.itemSize) ? 1.5 : 2
|
||||
}
|
||||
container
|
||||
sx={{ alignItems: "stretch", display: "flex" }}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<Grid
|
||||
item
|
||||
xs={6}
|
||||
sm={3}
|
||||
// No MD because we get more space when sidebar disappears
|
||||
lg={2}
|
||||
xl={12 / 8}
|
||||
{...GridPresets[props.itemSize]}
|
||||
key={`item-${index}`}
|
||||
>
|
||||
{props.render(item)}
|
||||
|
||||
@@ -17,17 +17,19 @@
|
||||
*/
|
||||
|
||||
import { Divider, List } from "@mui/material";
|
||||
import Resource from "../../models/resource";
|
||||
import InfiniteScroll from "./infinite-scroll";
|
||||
import { Fragment } from "react";
|
||||
import { IllustratedResource } from "../../models/illustration";
|
||||
import type { IllustratedResource } from "../../models/illustration";
|
||||
import type Resource from "../../models/resource";
|
||||
import { useGradientBackground } from "../../utils/gradient-background";
|
||||
import InfiniteScroll from "./infinite-scroll";
|
||||
|
||||
type TypedList<T extends Resource> = typeof InfiniteScroll<T>;
|
||||
type InfiniteListProps<T extends Resource> = Omit<
|
||||
Parameters<TypedList<T>>[0],
|
||||
"render"
|
||||
> & { render: (item: T | undefined, index: number) => JSX.Element };
|
||||
> & { render: (item: T | undefined, index: number) => JSX.Element } & Partial<
|
||||
Pick<Parameters<typeof InfiniteScroll>[0], "emptyState">
|
||||
>;
|
||||
|
||||
/**
|
||||
* Similar to InfiniteGrid, but rendered as a list
|
||||
@@ -41,7 +43,6 @@ const InfiniteList = <T extends IllustratedResource>(
|
||||
<InfiniteScroll
|
||||
{...props}
|
||||
render={(items: (T | undefined)[]) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { GradientBackground } = useGradientBackground(
|
||||
items.find((item) => item?.illustration !== undefined)
|
||||
?.illustration?.colors,
|
||||
@@ -54,7 +55,7 @@ const InfiniteList = <T extends IllustratedResource>(
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={`item-${index}`}>
|
||||
{props.render(item, index)}
|
||||
{index == items.length - 1 || (
|
||||
{index === items.length - 1 || (
|
||||
<Divider variant="middle" />
|
||||
)}
|
||||
</Fragment>
|
||||
|
||||
@@ -16,19 +16,20 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlbumSortingKeys,
|
||||
AlbumType,
|
||||
AlbumWithRelations,
|
||||
type AlbumWithRelations,
|
||||
} from "../../../models/album";
|
||||
import { DefaultItemSize } from "../../../utils/layout";
|
||||
import Controls, { type OptionState } from "../../controls/controls";
|
||||
import AlbumItem from "../../list-item/album-item";
|
||||
import AlbumTile from "../../tile/album-tile";
|
||||
import Controls, { OptionState } from "../../controls/controls";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import { useRouter } from "next/router";
|
||||
import { ComponentProps, useState } from "react";
|
||||
import InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
|
||||
type AdditionalProps = { type?: AlbumType };
|
||||
|
||||
@@ -52,6 +53,7 @@ const InfiniteAlbumView = (
|
||||
order: props.initialSortingOrder ?? "asc",
|
||||
sortBy: props.initialSortingField ?? "name",
|
||||
view: props.defaultLayout ?? "grid",
|
||||
itemSize: DefaultItemSize,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -70,19 +72,19 @@ const InfiniteAlbumView = (
|
||||
disableSorting={props.disableSorting}
|
||||
defaultSortingOrder={props.initialSortingOrder}
|
||||
defaultSortingKey={props.initialSortingField}
|
||||
router={props.light == true ? undefined : router}
|
||||
router={props.light === true ? undefined : router}
|
||||
defaultLayout={props.defaultLayout ?? "grid"}
|
||||
/>
|
||||
<InfiniteView
|
||||
itemSize={options.itemSize}
|
||||
view={options?.view ?? props.defaultLayout ?? "grid"}
|
||||
query={() =>
|
||||
props.query({
|
||||
library: options?.library,
|
||||
view: options?.view ?? props.defaultLayout ?? "grid",
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
type:
|
||||
// @ts-ignore
|
||||
options?.type == "All" ? undefined : options?.type,
|
||||
options?.type === "All" ? undefined : options?.type,
|
||||
sortBy:
|
||||
options?.sortBy ??
|
||||
props.initialSortingField ??
|
||||
@@ -95,12 +97,14 @@ const InfiniteAlbumView = (
|
||||
}
|
||||
renderListItem={(item) => (
|
||||
<AlbumItem
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
album={item}
|
||||
formatSubtitle={props.formatSubtitle}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={(item) => (
|
||||
<AlbumTile
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
album={item}
|
||||
formatSubtitle={props.formatSubtitle}
|
||||
/>
|
||||
|
||||
@@ -18,12 +18,16 @@
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { ArtistSortingKeys, ArtistWithRelations } from "../../../models/artist";
|
||||
import Controls, { OptionState } from "../../controls/controls";
|
||||
import {
|
||||
ArtistSortingKeys,
|
||||
type ArtistWithRelations,
|
||||
} from "../../../models/artist";
|
||||
import { DefaultItemSize } from "../../../utils/layout";
|
||||
import Controls, { type OptionState } from "../../controls/controls";
|
||||
import ArtistItem from "../../list-item/artist-item";
|
||||
import ArtistTile from "../../tile/artist-tile";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
import type InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
|
||||
const InfiniteArtistView = (
|
||||
props: InfiniteResourceViewProps<
|
||||
@@ -43,10 +47,11 @@ const InfiniteArtistView = (
|
||||
disableSorting={props.disableSorting}
|
||||
defaultSortingOrder={props.initialSortingOrder}
|
||||
defaultSortingKey={props.initialSortingField}
|
||||
router={props.light == true ? undefined : router}
|
||||
router={props.light === true ? undefined : router}
|
||||
defaultLayout={props.defaultLayout ?? "list"}
|
||||
/>
|
||||
<InfiniteView
|
||||
itemSize={options?.itemSize ?? DefaultItemSize}
|
||||
view={options?.view ?? props.defaultLayout ?? "list"}
|
||||
query={() =>
|
||||
props.query({
|
||||
@@ -62,8 +67,18 @@ const InfiniteArtistView = (
|
||||
"asc",
|
||||
})
|
||||
}
|
||||
renderListItem={(item) => <ArtistItem artist={item} />}
|
||||
renderGridItem={(item) => <ArtistTile artist={item} />}
|
||||
renderListItem={(item) => (
|
||||
<ArtistItem
|
||||
artist={item}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={(item) => (
|
||||
<ArtistTile
|
||||
artist={item}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -18,17 +18,18 @@
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "../../../api/use-query";
|
||||
import {
|
||||
PlaylistSortingKeys,
|
||||
PlaylistWithRelations,
|
||||
type PlaylistWithRelations,
|
||||
} from "../../../models/playlist";
|
||||
import Controls, { OptionState } from "../../controls/controls";
|
||||
import { DefaultItemSize } from "../../../utils/layout";
|
||||
import { CreatePlaylistAction } from "../../actions/playlist";
|
||||
import Controls, { type OptionState } from "../../controls/controls";
|
||||
import PlaylistItem from "../../list-item/playlist-item";
|
||||
import PlaylistTile from "../../tile/playlist-tile";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
import { CreatePlaylistAction } from "../../actions/playlist";
|
||||
import { useQueryClient } from "../../../api/use-query";
|
||||
import type InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
|
||||
const InfinitePlaylistView = (
|
||||
props: InfiniteResourceViewProps<
|
||||
@@ -50,10 +51,12 @@ const InfinitePlaylistView = (
|
||||
sortingKeys={PlaylistSortingKeys}
|
||||
defaultSortingOrder={props.initialSortingOrder}
|
||||
defaultSortingKey={props.initialSortingField}
|
||||
router={props.light == true ? undefined : router}
|
||||
router={props.light === true ? undefined : router}
|
||||
defaultLayout={props.defaultLayout ?? "list"}
|
||||
/>
|
||||
<InfiniteView
|
||||
emptyState={props.emptyState}
|
||||
itemSize={options?.itemSize ?? DefaultItemSize}
|
||||
view={options?.view ?? props.defaultLayout ?? "list"}
|
||||
query={() =>
|
||||
props.query({
|
||||
@@ -69,8 +72,18 @@ const InfinitePlaylistView = (
|
||||
view: "grid",
|
||||
})
|
||||
}
|
||||
renderListItem={(item) => <PlaylistItem playlist={item} />}
|
||||
renderGridItem={(item) => <PlaylistTile playlist={item} />}
|
||||
renderListItem={(item) => (
|
||||
<PlaylistItem
|
||||
playlist={item}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={(item) => (
|
||||
<PlaylistTile
|
||||
playlist={item}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+10
-6
@@ -16,11 +16,12 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { MeeloInfiniteQueryFn } from "../../../api/use-query";
|
||||
import Resource from "../../../models/resource";
|
||||
import { LayoutOption } from "../../../utils/layout";
|
||||
import { Order } from "../../../utils/sorting";
|
||||
import { OptionState } from "../../controls/controls";
|
||||
import type { MeeloInfiniteQueryFn } from "../../../api/use-query";
|
||||
import type Resource from "../../../models/resource";
|
||||
import type { LayoutOption } from "../../../utils/layout";
|
||||
import type { Order } from "../../../utils/sorting";
|
||||
import type { OptionState } from "../../controls/controls";
|
||||
import type { EmptyStateProps } from "../../empty-state";
|
||||
|
||||
type InfiniteResourceViewProps<
|
||||
ResourceType extends Resource,
|
||||
@@ -28,13 +29,16 @@ type InfiniteResourceViewProps<
|
||||
AdditionalQueryParams extends object = object,
|
||||
> = {
|
||||
query: (
|
||||
options: OptionState<SortingKeys> & AdditionalQueryParams,
|
||||
options: Omit<OptionState<SortingKeys>, "layout" | "itemSize"> &
|
||||
AdditionalQueryParams,
|
||||
) => ReturnType<MeeloInfiniteQueryFn<ResourceType>>;
|
||||
light?: boolean;
|
||||
onItemClick?: (item: ResourceType) => void;
|
||||
disableSorting?: true;
|
||||
defaultLayout?: LayoutOption;
|
||||
initialSortingOrder?: Order;
|
||||
initialSortingField?: SortingKeys[number];
|
||||
emptyState?: Partial<EmptyStateProps>;
|
||||
};
|
||||
|
||||
export default InfiniteResourceViewProps;
|
||||
@@ -17,28 +17,34 @@
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { ComponentProps, useState } from "react";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import {
|
||||
SongSortingKeys,
|
||||
SongType,
|
||||
SongWithRelations,
|
||||
} from "../../../models/song";
|
||||
import Controls, { OptionState } from "../../controls/controls";
|
||||
import SongItem, { SongGroupItem } from "../../list-item/song-item";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
import { PlayIcon, ShuffleIcon } from "../../icons";
|
||||
import {
|
||||
InfiniteQuery,
|
||||
QueryClient,
|
||||
type InfiniteQuery,
|
||||
type QueryClient,
|
||||
prepareMeeloInfiniteQuery,
|
||||
useQueryClient,
|
||||
} from "../../../api/use-query";
|
||||
import { PlayerActions, usePlayerContext } from "../../../contexts/player";
|
||||
import {
|
||||
SongSortingKeys,
|
||||
SongType,
|
||||
type SongWithRelations,
|
||||
} from "../../../models/song";
|
||||
import {
|
||||
SongGroupSortingKeys,
|
||||
SongGroupWithRelations,
|
||||
type SongGroupWithRelations,
|
||||
} from "../../../models/song-group";
|
||||
import {
|
||||
emptyPlaylistAtom,
|
||||
playAfterAtom,
|
||||
playTrackAtom,
|
||||
} from "../../../state/player";
|
||||
import { store } from "../../../state/store";
|
||||
import { DefaultItemSize } from "../../../utils/layout";
|
||||
import Controls, { type OptionState } from "../../controls/controls";
|
||||
import { PlayIcon, ShuffleIcon } from "../../icons";
|
||||
import SongItem, { SongGroupItem } from "../../list-item/song-item";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import type InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
|
||||
type AdditionalProps = {
|
||||
type?: SongType;
|
||||
@@ -48,23 +54,20 @@ type AdditionalProps = {
|
||||
};
|
||||
|
||||
const playSongsAction = (
|
||||
emptyPlaylist: PlayerActions["emptyPlaylist"],
|
||||
playTrack: PlayerActions["playTrack"],
|
||||
playAfter: PlayerActions["playAfter"],
|
||||
queryClient: QueryClient,
|
||||
query: () => InfiniteQuery<
|
||||
SongWithRelations<"artist" | "featuring" | "master" | "illustration">
|
||||
>,
|
||||
) => {
|
||||
emptyPlaylist();
|
||||
store.set(emptyPlaylistAtom);
|
||||
queryClient.client
|
||||
.fetchInfiniteQuery(prepareMeeloInfiniteQuery(query))
|
||||
.then(async (res) => {
|
||||
const songs = res.pages.flatMap(({ items }) => items);
|
||||
let i = 0;
|
||||
for (const song of songs) {
|
||||
if (i == 0) {
|
||||
playTrack({
|
||||
if (i === 0) {
|
||||
store.set(playTrackAtom, {
|
||||
track: {
|
||||
...song.master,
|
||||
illustration: song.illustration,
|
||||
@@ -72,7 +75,7 @@ const playSongsAction = (
|
||||
artist: song.artist,
|
||||
});
|
||||
} else {
|
||||
playAfter({
|
||||
store.set(playAfterAtom, {
|
||||
track: {
|
||||
...song.master,
|
||||
illustration: song.illustration,
|
||||
@@ -98,7 +101,11 @@ const InfiniteSongView = <
|
||||
Pick<ComponentProps<typeof SongItem<T>>, "subtitles"> & {
|
||||
disableShuffle?: boolean;
|
||||
groupsQuery?: (
|
||||
p: OptionState<typeof SongGroupSortingKeys> & AdditionalProps,
|
||||
p: Omit<
|
||||
OptionState<typeof SongGroupSortingKeys>,
|
||||
"layout" | "itemSize"
|
||||
> &
|
||||
AdditionalProps,
|
||||
) => InfiniteQuery<
|
||||
SongGroupWithRelations<
|
||||
"artist" | "featuring" | "master" | "illustration"
|
||||
@@ -111,31 +118,25 @@ const InfiniteSongView = <
|
||||
const [options, setOptions] =
|
||||
useState<OptionState<typeof SongSortingKeys, AdditionalProps>>();
|
||||
const queryClient = useQueryClient();
|
||||
const { emptyPlaylist, playAfter, playTrack } = usePlayerContext();
|
||||
const query = {
|
||||
type:
|
||||
// @ts-ignore
|
||||
options?.type == "All" ? undefined : (options?.type as SongType),
|
||||
options?.type === "All" ? undefined : (options?.type as SongType),
|
||||
sortBy: options?.sortBy ?? props.initialSortingField ?? "name",
|
||||
order: options?.order ?? props.initialSortingOrder ?? "asc",
|
||||
view: "grid",
|
||||
groups: disableGroupingVersions ? false : options?.groups ?? false,
|
||||
groups: disableGroupingVersions ? false : (options?.groups ?? false),
|
||||
library: options?.library ?? null,
|
||||
} as const;
|
||||
const shuffleAction = {
|
||||
label: "shuffle",
|
||||
icon: <ShuffleIcon />,
|
||||
onClick: () => {
|
||||
playSongsAction(
|
||||
emptyPlaylist,
|
||||
playTrack,
|
||||
playAfter,
|
||||
queryClient,
|
||||
() =>
|
||||
props.query({
|
||||
...query,
|
||||
random: Math.floor(Math.random() * 10000),
|
||||
}),
|
||||
playSongsAction(queryClient, () =>
|
||||
props.query({
|
||||
...query,
|
||||
random: Math.floor(Math.random() * 10000),
|
||||
}),
|
||||
);
|
||||
},
|
||||
} as const;
|
||||
@@ -143,13 +144,7 @@ const InfiniteSongView = <
|
||||
label: "playAll",
|
||||
icon: <PlayIcon />,
|
||||
onClick: () => {
|
||||
playSongsAction(
|
||||
emptyPlaylist,
|
||||
playTrack,
|
||||
playAfter,
|
||||
queryClient,
|
||||
() => props.query(query),
|
||||
);
|
||||
playSongsAction(queryClient, () => props.query(query));
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -162,7 +157,7 @@ const InfiniteSongView = <
|
||||
name: "type",
|
||||
values: [
|
||||
"All",
|
||||
...SongType.filter((type) => type != "Unknown"),
|
||||
...SongType.filter((type) => type !== "Unknown"),
|
||||
],
|
||||
currentValue: options?.type,
|
||||
},
|
||||
@@ -192,12 +187,13 @@ const InfiniteSongView = <
|
||||
}
|
||||
defaultSortingOrder={props.initialSortingOrder}
|
||||
defaultSortingKey={props.initialSortingField}
|
||||
router={props.light == true ? undefined : router}
|
||||
router={props.light === true ? undefined : router}
|
||||
disableLayoutToggle
|
||||
defaultLayout={"list"}
|
||||
/>
|
||||
{options?.groups ? (
|
||||
<InfiniteView
|
||||
itemSize={options?.itemSize ?? DefaultItemSize}
|
||||
view={"list"}
|
||||
query={() => {
|
||||
return props.groupsQuery!({
|
||||
@@ -215,6 +211,7 @@ const InfiniteSongView = <
|
||||
/>
|
||||
) : (
|
||||
<InfiniteView
|
||||
itemSize={options?.itemSize ?? DefaultItemSize}
|
||||
view={"list"}
|
||||
query={() => {
|
||||
return props.query({
|
||||
@@ -223,7 +220,11 @@ const InfiniteSongView = <
|
||||
});
|
||||
}}
|
||||
renderListItem={(item) => (
|
||||
<SongItem song={item} subtitles={props.subtitles} />
|
||||
<SongItem
|
||||
song={item}
|
||||
subtitles={props.subtitles}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={() => <></>}
|
||||
/>
|
||||
|
||||
@@ -16,17 +16,21 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
import Controls, { OptionState } from "../../controls/controls";
|
||||
import { useRouter } from "next/router";
|
||||
import { TrackSortingKeys, TrackWithRelations } from "../../../models/track";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
TrackSortingKeys,
|
||||
type TrackWithRelations,
|
||||
} from "../../../models/track";
|
||||
import { DefaultItemSize } from "../../../utils/layout";
|
||||
import Controls, { type OptionState } from "../../controls/controls";
|
||||
import TrackItem from "../../list-item/track-item";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import type InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
|
||||
const InfiniteTrackView = (
|
||||
props: InfiniteResourceViewProps<
|
||||
TrackWithRelations<"song" | "release" | "illustration">,
|
||||
TrackWithRelations<"video" | "song" | "release" | "illustration">,
|
||||
typeof TrackSortingKeys
|
||||
>,
|
||||
) => {
|
||||
@@ -41,11 +45,12 @@ const InfiniteTrackView = (
|
||||
sortingKeys={TrackSortingKeys}
|
||||
defaultSortingOrder={props.initialSortingOrder}
|
||||
defaultSortingKey={props.initialSortingField}
|
||||
router={props.light == true ? undefined : router}
|
||||
router={props.light === true ? undefined : router}
|
||||
disableLayoutToggle
|
||||
defaultLayout={"list"}
|
||||
/>
|
||||
<InfiniteView
|
||||
itemSize={options?.itemSize ?? DefaultItemSize}
|
||||
view={options?.view ?? "list"}
|
||||
query={() =>
|
||||
props.query({
|
||||
@@ -55,8 +60,13 @@ const InfiniteTrackView = (
|
||||
order: options?.order ?? "asc",
|
||||
})
|
||||
}
|
||||
renderListItem={(item) => <TrackItem track={item} />}
|
||||
renderGridItem={(item) => <></>}
|
||||
renderListItem={(item) => (
|
||||
<TrackItem
|
||||
track={item}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={() => <></>}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -17,41 +17,57 @@
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { ComponentProps, useState } from "react";
|
||||
import { SongSortingKeys, SongType } from "../../../models/song";
|
||||
import { VideoWithRelations } from "../../../models/video";
|
||||
import Controls, { OptionState } from "../../controls/controls";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
import VideoTile from "../../tile/video-tile";
|
||||
import { PaginationParameters } from "../../../models/pagination";
|
||||
import { PlayIcon, ShuffleIcon } from "../../icons";
|
||||
import { PlayerActions, usePlayerContext } from "../../../contexts/player";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import {
|
||||
InfiniteQuery,
|
||||
QueryClient,
|
||||
type InfiniteQuery,
|
||||
type QueryClient,
|
||||
prepareMeeloInfiniteQuery,
|
||||
useQueryClient,
|
||||
} from "../../../api/use-query";
|
||||
import {
|
||||
VideoSortingKeys,
|
||||
VideoType,
|
||||
type VideoWithRelations,
|
||||
} from "../../../models/video";
|
||||
import {
|
||||
emptyPlaylistAtom,
|
||||
playAfterAtom,
|
||||
playTrackAtom,
|
||||
} from "../../../state/player";
|
||||
import { store } from "../../../state/store";
|
||||
import { DefaultItemSize } from "../../../utils/layout";
|
||||
import Controls, { type OptionState } from "../../controls/controls";
|
||||
import { PlayIcon, ShuffleIcon } from "../../icons";
|
||||
import VideoItem from "../../list-item/video-item";
|
||||
import VideoTile from "../../tile/video-tile";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import type InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
|
||||
const playVideosAction = (
|
||||
emptyPlaylist: PlayerActions["emptyPlaylist"],
|
||||
playTrack: PlayerActions["playTrack"],
|
||||
playAfter: PlayerActions["playAfter"],
|
||||
queryClient: QueryClient,
|
||||
query: () => InfiniteQuery<VideoWithRelations<"artist" | "featuring">>,
|
||||
query: () => InfiniteQuery<
|
||||
VideoWithRelations<"artist" | "master" | "illustration">
|
||||
>,
|
||||
) => {
|
||||
emptyPlaylist();
|
||||
store.set(emptyPlaylistAtom);
|
||||
queryClient.client
|
||||
.fetchInfiniteQuery(prepareMeeloInfiniteQuery(query))
|
||||
.then(async (res) => {
|
||||
const videos = res.pages.flatMap(({ items }) => items);
|
||||
const videos = res.pages
|
||||
.flatMap(({ items }) => items)
|
||||
.map((video) => ({
|
||||
...video,
|
||||
track: {
|
||||
...video.master,
|
||||
illustration: video.illustration,
|
||||
},
|
||||
}));
|
||||
let i = 0;
|
||||
for (const video of videos) {
|
||||
if (i == 0) {
|
||||
playTrack(video);
|
||||
if (i === 0) {
|
||||
store.set(playTrackAtom, video);
|
||||
} else {
|
||||
playAfter(video);
|
||||
store.set(playAfterAtom, video);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
@@ -59,16 +75,16 @@ const playVideosAction = (
|
||||
};
|
||||
|
||||
type AdditionalProps = {
|
||||
type?: SongType;
|
||||
type?: VideoType;
|
||||
random?: number;
|
||||
};
|
||||
|
||||
const InfiniteVideoView = <
|
||||
T extends VideoWithRelations<"artist" | "featuring">,
|
||||
T extends VideoWithRelations<"artist" | "illustration" | "master">,
|
||||
>(
|
||||
props: InfiniteResourceViewProps<
|
||||
T,
|
||||
typeof SongSortingKeys,
|
||||
typeof VideoSortingKeys,
|
||||
AdditionalProps
|
||||
> &
|
||||
Omit<ComponentProps<typeof VideoTile>, "video">,
|
||||
@@ -76,44 +92,33 @@ const InfiniteVideoView = <
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [options, setOptions] =
|
||||
useState<OptionState<typeof SongSortingKeys, AdditionalProps>>();
|
||||
useState<OptionState<typeof VideoSortingKeys, AdditionalProps>>();
|
||||
const query = {
|
||||
type:
|
||||
// @ts-ignore
|
||||
options?.type == "All" ? undefined : (options?.type as SongType),
|
||||
options?.type === "All" ? undefined : (options?.type as VideoType),
|
||||
sortBy: options?.sortBy ?? props.initialSortingField ?? "name",
|
||||
order: options?.order ?? props.initialSortingOrder ?? "asc",
|
||||
view: "grid",
|
||||
itemSize: DefaultItemSize,
|
||||
library: options?.library ?? null,
|
||||
} as const;
|
||||
const { emptyPlaylist, playAfter, playTrack } = usePlayerContext();
|
||||
const playAction = {
|
||||
label: "playAll",
|
||||
icon: <PlayIcon />,
|
||||
onClick: () => {
|
||||
playVideosAction(
|
||||
emptyPlaylist,
|
||||
playTrack,
|
||||
playAfter,
|
||||
queryClient,
|
||||
() => props.query(query),
|
||||
);
|
||||
playVideosAction(queryClient, () => props.query(query));
|
||||
},
|
||||
} as const;
|
||||
const shuffleAction = {
|
||||
label: "shuffle",
|
||||
icon: <ShuffleIcon />,
|
||||
onClick: () => {
|
||||
playVideosAction(
|
||||
emptyPlaylist,
|
||||
playTrack,
|
||||
playAfter,
|
||||
queryClient,
|
||||
() =>
|
||||
props.query({
|
||||
...query,
|
||||
random: Math.floor(Math.random() * 10000),
|
||||
}),
|
||||
playVideosAction(queryClient, () =>
|
||||
props.query({
|
||||
...query,
|
||||
random: Math.floor(Math.random() * 10000),
|
||||
}),
|
||||
);
|
||||
},
|
||||
} as const;
|
||||
@@ -122,55 +127,41 @@ const InfiniteVideoView = <
|
||||
<Controls
|
||||
options={[
|
||||
{
|
||||
label: (options?.type as SongType) ?? "All",
|
||||
label: (options?.type as VideoType) ?? "All",
|
||||
name: "type",
|
||||
values: [
|
||||
"All",
|
||||
...SongType.filter(
|
||||
(type) =>
|
||||
![
|
||||
"Unknown",
|
||||
"Demo",
|
||||
"Clean",
|
||||
"Edit",
|
||||
"Acapella",
|
||||
"Instrumental",
|
||||
].includes(type),
|
||||
),
|
||||
],
|
||||
values: ["All", ...VideoType],
|
||||
currentValue: options?.type,
|
||||
},
|
||||
]}
|
||||
onChange={setOptions}
|
||||
sortingKeys={SongSortingKeys}
|
||||
sortingKeys={VideoSortingKeys}
|
||||
defaultSortingOrder={props.initialSortingOrder}
|
||||
defaultSortingKey={props.initialSortingField}
|
||||
router={props.light == true ? undefined : router}
|
||||
router={props.light === true ? undefined : router}
|
||||
defaultLayout={"grid"}
|
||||
disableLayoutToggle
|
||||
actions={[playAction, shuffleAction]}
|
||||
/>
|
||||
<InfiniteView
|
||||
itemSize={options?.itemSize ?? DefaultItemSize}
|
||||
view={options?.view ?? "grid"}
|
||||
query={() => {
|
||||
const { key, exec } = props.query(query);
|
||||
return {
|
||||
key,
|
||||
exec: (pagination: PaginationParameters) =>
|
||||
exec(pagination).then((page) => {
|
||||
return {
|
||||
...page,
|
||||
items: page.items.map((item) => ({
|
||||
...item,
|
||||
illustration: item.track.illustration,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
return props.query({
|
||||
...query,
|
||||
sortBy: query.sortBy,
|
||||
});
|
||||
}}
|
||||
renderListItem={(item) => <></>}
|
||||
renderListItem={(item) => (
|
||||
<VideoItem
|
||||
video={item}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={(item) => (
|
||||
<VideoTile video={item} subtitle={props.subtitle} />
|
||||
<VideoTile
|
||||
video={item}
|
||||
subtitle={props.subtitle}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -17,12 +17,16 @@
|
||||
*/
|
||||
|
||||
import * as IScroll from "react-infinite-scroller";
|
||||
import Resource from "../../models/resource";
|
||||
import PaginatedResponse, {
|
||||
PaginationParameters,
|
||||
} from "../../models/pagination";
|
||||
import { MeeloInfiniteQueryFn, useInfiniteQuery } from "../../api/use-query";
|
||||
import {
|
||||
type MeeloInfiniteQueryFn,
|
||||
useInfiniteQuery,
|
||||
} from "../../api/use-query";
|
||||
import type PaginatedResponse from "../../models/pagination";
|
||||
import type { PaginationParameters } from "../../models/pagination";
|
||||
import type Resource from "../../models/resource";
|
||||
import { generateArray } from "../../utils/gen-list";
|
||||
import { EmptyState, type EmptyStateProps } from "../empty-state";
|
||||
import { EmptyStateIcon } from "../icons";
|
||||
|
||||
export const parentScrollableDivId = "scrollableDiv" as const;
|
||||
|
||||
@@ -43,6 +47,8 @@ type InfiniteScrollProps<T extends Resource> = {
|
||||
* Component to display on page fetching (except first)
|
||||
*/
|
||||
loader: () => JSX.Element;
|
||||
|
||||
emptyState?: EmptyStateProps;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -74,11 +80,17 @@ export type Page<T> = {
|
||||
* @returns a dynamic list component
|
||||
*/
|
||||
const InfiniteScroll = <T extends Resource>(props: InfiniteScrollProps<T>) => {
|
||||
const { isFetching, data, hasNextPage, fetchNextPage, isFetchingNextPage } =
|
||||
const { items, hasNextPage, fetchNextPage, isFetchingNextPage } =
|
||||
useInfiniteQuery(props.query);
|
||||
|
||||
return (
|
||||
<>
|
||||
{items?.length === 0 && (
|
||||
<EmptyState
|
||||
icon={props.emptyState?.icon ?? <EmptyStateIcon />}
|
||||
text={props.emptyState?.text ?? "emptyStateDefault"}
|
||||
actions={props.emptyState?.actions ?? []}
|
||||
/>
|
||||
)}
|
||||
<IScroll.default
|
||||
pageStart={0}
|
||||
loadMore={() => {
|
||||
@@ -94,10 +106,10 @@ const InfiniteScroll = <T extends Resource>(props: InfiniteScrollProps<T>) => {
|
||||
threshold={500}
|
||||
>
|
||||
{props.render(
|
||||
data === undefined
|
||||
items === undefined
|
||||
? generateArray(3)
|
||||
: [
|
||||
...data.pages.map((page) => page.items).flat(),
|
||||
...items,
|
||||
...(isFetchingNextPage ? generateArray(3) : []),
|
||||
],
|
||||
)}
|
||||
|
||||
@@ -17,30 +17,35 @@
|
||||
*/
|
||||
|
||||
import { Box, Button, Slide, Tooltip } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MeeloInfiniteQueryFn } from "../../api/use-query";
|
||||
import type { IllustratedResource } from "../../models/illustration";
|
||||
import type { ItemSize } from "../../utils/layout";
|
||||
import type { EmptyStateProps } from "../empty-state";
|
||||
import Fade from "../fade";
|
||||
import { GoBackTopIcon } from "../icons";
|
||||
import { MeeloInfiniteQueryFn } from "../../api/use-query";
|
||||
import { WideLoadingComponent } from "../loading/loading";
|
||||
import InfiniteGrid from "./infinite-grid";
|
||||
import InfiniteList from "./infinite-list";
|
||||
import { useEffect, useState } from "react";
|
||||
import Fade from "../fade";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IllustratedResource } from "../../models/illustration";
|
||||
import { parentScrollableDivId } from "./infinite-scroll";
|
||||
|
||||
export type InfiniteViewProps<ItemType> = {
|
||||
view: "list" | "grid";
|
||||
itemSize: ItemSize;
|
||||
query: MeeloInfiniteQueryFn<ItemType>;
|
||||
renderListItem: (item: ItemType | undefined) => JSX.Element;
|
||||
renderGridItem: (item: ItemType | undefined) => JSX.Element;
|
||||
emptyState?: Partial<EmptyStateProps>;
|
||||
};
|
||||
|
||||
const ScrollToTopButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const [backToTopVisible, setBackToTopVisible] = useState(false);
|
||||
const handleScroll = () => {
|
||||
const position = document.getElementById(parentScrollableDivId)
|
||||
?.scrollTop;
|
||||
const position = document.getElementById(
|
||||
parentScrollableDivId,
|
||||
)?.scrollTop;
|
||||
|
||||
setBackToTopVisible((position ?? 0) > window.innerHeight);
|
||||
};
|
||||
@@ -90,7 +95,7 @@ const InfiniteView = <ItemType extends IllustratedResource>(
|
||||
return (
|
||||
<>
|
||||
<ScrollToTopButton />
|
||||
{props.view.toLowerCase() == "list" ? (
|
||||
{props.view.toLowerCase() === "list" ? (
|
||||
<InfiniteList
|
||||
loader={() => <WideLoadingComponent />}
|
||||
query={props.query}
|
||||
@@ -105,6 +110,7 @@ const InfiniteView = <ItemType extends IllustratedResource>(
|
||||
) : (
|
||||
<InfiniteGrid
|
||||
query={props.query}
|
||||
itemSize={props.itemSize}
|
||||
loader={() => <WideLoadingComponent />}
|
||||
render={(item) => (
|
||||
<Fade in>
|
||||
|
||||
@@ -23,9 +23,9 @@ import {
|
||||
DialogTitle,
|
||||
Grid,
|
||||
} from "@mui/material";
|
||||
import Library from "../models/library";
|
||||
import { HookTextField, useHookForm } from "mui-react-hook-form-plus";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Library from "../models/library";
|
||||
|
||||
type LibraryFormFields = Pick<Library, "name" | "path">;
|
||||
|
||||
@@ -77,6 +77,8 @@ const LibraryForm = (props: LibraryFormProps) => {
|
||||
textFieldProps={{
|
||||
fullWidth: true,
|
||||
label: t("pathOfLibrary"),
|
||||
helperText:
|
||||
"Path should be relative to the 'DATA_DIR' variable. Use './' if the library path is 'DATA_DIR' ",
|
||||
}}
|
||||
gridProps={{}}
|
||||
rules={{
|
||||
|
||||
@@ -16,15 +16,17 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AlbumWithRelations } from "../../models/album";
|
||||
import AlbumContextualMenu from "../contextual-menu/album-contextual-menu";
|
||||
import { AlbumIcon } from "../icons";
|
||||
import Illustration from "../illustration";
|
||||
import ListItem from "./item";
|
||||
import AlbumContextualMenu from "../contextual-menu/album-contextual-menu";
|
||||
import { AlbumWithRelations } from "../../models/album";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type AlbumItemProps = {
|
||||
album: AlbumWithRelations<"artist" | "illustration"> | undefined;
|
||||
formatSubtitle?: (album: AlbumWithRelations<"artist">) => string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -32,7 +34,7 @@ type AlbumItemProps = {
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
const AlbumItem = ({ album, formatSubtitle }: AlbumItemProps) => {
|
||||
const AlbumItem = ({ album, formatSubtitle, onClick }: AlbumItemProps) => {
|
||||
const artist = album?.artist;
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -42,15 +44,17 @@ const AlbumItem = ({ album, formatSubtitle }: AlbumItemProps) => {
|
||||
<Illustration
|
||||
illustration={album?.illustration}
|
||||
quality="low"
|
||||
fallback={<AlbumIcon />}
|
||||
/>
|
||||
}
|
||||
onClick={onClick}
|
||||
href={album ? `/albums/${album.slug}` : undefined}
|
||||
title={album?.name}
|
||||
secondTitle={
|
||||
album
|
||||
? formatSubtitle?.call(this, album) ??
|
||||
? (formatSubtitle?.call(this, album) ??
|
||||
artist?.name ??
|
||||
t("compilation")
|
||||
t("compilation"))
|
||||
: undefined
|
||||
}
|
||||
trailing={album && <AlbumContextualMenu album={album} />}
|
||||
|
||||
@@ -16,13 +16,14 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { ArtistWithRelations } from "../../models/artist";
|
||||
import ListItem from "./item";
|
||||
import ArtistContextualMenu from "../contextual-menu/artist-contextual-menu";
|
||||
import type { ArtistWithRelations } from "../../models/artist";
|
||||
import ArtistAvatar from "../artist-avatar";
|
||||
import ArtistContextualMenu from "../contextual-menu/artist-contextual-menu";
|
||||
import ListItem from "./item";
|
||||
|
||||
type ArtistItemProps = {
|
||||
artist: ArtistWithRelations<"illustration"> | undefined;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -30,7 +31,7 @@ type ArtistItemProps = {
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
const ArtistItem = ({ artist }: ArtistItemProps) => {
|
||||
const ArtistItem = ({ artist, onClick }: ArtistItemProps) => {
|
||||
return (
|
||||
<ListItem
|
||||
icon={
|
||||
@@ -40,6 +41,7 @@ const ArtistItem = ({ artist }: ArtistItemProps) => {
|
||||
/>
|
||||
}
|
||||
secondTitle={null}
|
||||
onClick={onClick}
|
||||
href={artist ? `/artists/${artist.slug}` : undefined}
|
||||
title={artist?.name}
|
||||
trailing={artist && <ArtistContextualMenu artist={artist} />}
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { RequireExactlyOne } from "type-fest";
|
||||
import Link from "next/link";
|
||||
|
||||
type ListItemProps = {
|
||||
@@ -35,10 +34,9 @@ type ListItemProps = {
|
||||
title: string | undefined;
|
||||
secondTitle: string | undefined | null;
|
||||
trailing?: JSX.Element;
|
||||
} & RequireExactlyOne<{
|
||||
href: string | undefined;
|
||||
onClick: (() => void) | undefined;
|
||||
}>;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const textStyle = {
|
||||
whiteSpace: "nowrap",
|
||||
@@ -54,7 +52,7 @@ const secondaryTextStyle = {
|
||||
};
|
||||
|
||||
const ListItem = (props: ListItemProps) => {
|
||||
const theme = useTheme();
|
||||
const _theme = useTheme();
|
||||
|
||||
return (
|
||||
<MUIListItem disablePadding secondaryAction={props.trailing}>
|
||||
|
||||
Loaded 100 of 582 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user