Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
e3e5abb1b4 | ||
|
|
c3bd9b44fd | ||
|
|
21bb5586b7 | ||
|
|
06e24586db | ||
|
|
84f6868390 | ||
|
|
b44fba44a3 | ||
|
|
b1a585cec4 | ||
|
|
16a1ad0810 | ||
|
|
160513e82b | ||
|
|
6f6e03bda5 | ||
|
|
19ef1cc6b8 | ||
|
|
a609f1058e | ||
|
|
b0e6a361bb | ||
|
|
9f05114be2 | ||
|
|
bbbe62f880 | ||
|
|
023c74d1c2 | ||
|
|
07da1f5428 | ||
|
|
e3255cb428 | ||
|
|
c81ed9dd2e | ||
|
|
45afd3b36b | ||
|
|
2b5875b146 | ||
|
|
3176bc5c36 | ||
|
|
87efc74920 | ||
|
|
e5fc7a8428 | ||
|
|
aced630093 | ||
|
|
bf28a74155 | ||
|
|
dbf75b8d20 | ||
|
|
a2e351c670 | ||
|
|
bb147d567f | ||
|
|
4e82669f4b | ||
|
|
31a4ae5b9a | ||
|
|
606ad93395 | ||
|
|
b078ba20a4 | ||
|
|
26dd43c2ee | ||
|
|
87d82b3928 | ||
|
|
93771c4551 | ||
|
|
a5d151470e | ||
|
|
7d65700a70 | ||
|
|
5f58e26314 | ||
|
|
f2dfd6903c | ||
|
|
e00d551389 | ||
|
|
55e0831e67 | ||
|
|
c6cf808bea | ||
|
|
0f25f7f282 | ||
|
|
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 | ||
|
|
06247901a7 | ||
|
|
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 | ||
|
|
2c6f0cbc8b | ||
|
|
ef235fc264 | ||
|
|
aae10672dd | ||
|
|
3ebac555fa | ||
|
|
75788e478b | ||
|
|
9a70d27793 | ||
|
|
7532e199b5 | ||
|
|
5581c69cfa | ||
|
|
155e3ca9cb | ||
|
|
73ba1ca398 | ||
|
|
527ca4df5d | ||
|
|
27b2b9d813 | ||
|
|
ae44cd5574 | ||
|
|
31831e25ce | ||
|
|
ba9fc80f82 | ||
|
|
061b890a6a | ||
|
|
78991fb145 | ||
|
|
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 | ||
|
|
8f77c7e7ed | ||
|
|
db6d04817d | ||
|
|
1cd4024f63 | ||
|
|
da6fdc7f91 | ||
|
|
cd5f262435 | ||
|
|
b9917027c6 |
No files matched your search
+14
-3
@@ -7,21 +7,32 @@ 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=
|
||||
# The root path of your libraries (on the host machine)
|
||||
DATA_DIR=
|
||||
#################### Anonymous Access
|
||||
# Set to 1 if you want to allow anonymous request
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
name: 'Cache (Matcher)'
|
||||
description: 'Cache for Matcher'
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- run: apk add --update --no-progress lsb-release python3 py3-pip
|
||||
shell: sh
|
||||
- run: pip3 install -r matcher/requirements.txt
|
||||
shell: sh
|
||||
@@ -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
|
||||
@@ -13,4 +13,4 @@ runs:
|
||||
./server/node_modules
|
||||
./server/dist
|
||||
key: server
|
||||
restore-keys: server
|
||||
restore-keys: server
|
||||
+8
-23
@@ -1,43 +1,28 @@
|
||||
# Source: https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes
|
||||
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- dependencies
|
||||
categories:
|
||||
- title: Web App
|
||||
labels:
|
||||
- Front
|
||||
- Layout
|
||||
- title: Server
|
||||
labels:
|
||||
- Back
|
||||
- title: Exciting New Features 🎉
|
||||
labels:
|
||||
- Feature
|
||||
- Basic
|
||||
- title: Improvement, Optmizations & Enhancements 👍
|
||||
- title: Improvements, Optmizations & Enhancements 👍
|
||||
labels:
|
||||
- Enhancement/Optimization
|
||||
- Layout
|
||||
- title: Fixes & Patches 🩹
|
||||
labels:
|
||||
- Bug
|
||||
- Quick Fix
|
||||
- title: Security 🛡
|
||||
labels:
|
||||
- Security
|
||||
- Logs
|
||||
- Fixes
|
||||
- title: Documentation 📖
|
||||
labels:
|
||||
- Documentation
|
||||
- title: CI/CD ⚙
|
||||
- title: Security 🛡
|
||||
labels:
|
||||
- CD/CD
|
||||
- title: Codebase-related 📑
|
||||
- Security
|
||||
- title: Deployment ⚙
|
||||
labels:
|
||||
- Coding Style
|
||||
- Testing
|
||||
- Architecture
|
||||
- CI/CD
|
||||
- title: Other Changes 🔀
|
||||
labels:
|
||||
- "*"
|
||||
- "*"
|
||||
+11
-22
@@ -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 ]
|
||||
|
||||
@@ -42,11 +42,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 +58,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 +75,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,7 +87,7 @@ 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
|
||||
@@ -169,4 +158,4 @@ jobs:
|
||||
context: ./front
|
||||
push: true
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-front:edge' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -0,0 +1,131 @@
|
||||
name: "Matcher"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main, v3 ]
|
||||
push:
|
||||
branches: [ main, v3 ]
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./matcher
|
||||
|
||||
jobs:
|
||||
Changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
matcher: ${{ steps.filter.outputs.matcher }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
matcher:
|
||||
- 'matcher/**'
|
||||
- '.github/workflows/matcher.yml'
|
||||
Typecheck:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
|
||||
container: python:3.14.0a2-alpine3.21
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-matcher
|
||||
- uses: jakebailey/pyright-action@v2
|
||||
with:
|
||||
working-directory: './matcher/matcher'
|
||||
Lint:
|
||||
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
|
||||
container: python:3.14.0a2-alpine3.21
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Typecheck ]
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-matcher
|
||||
- run: pip install ruff
|
||||
- run: ruff check matcher
|
||||
Tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Typecheck ]
|
||||
container: python:3.14.0a2-alpine3.21
|
||||
if: ${{ needs.changes.outputs.matcher == 'true' && always() }}
|
||||
env:
|
||||
GENIUS_ACCESS_TOKEN: "${{ secrets.GENIUS_ACCESS_TOKEN }}"
|
||||
DISCOGS_ACCESS_TOKEN: "${{ secrets.DISCOGS_ACCESS_TOKEN }}"
|
||||
CI: "1"
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-matcher
|
||||
- run: python3 -m tests
|
||||
Dockerize:
|
||||
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
|
||||
needs: [ Typecheck ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Typecheck Docker
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./matcher
|
||||
push: false
|
||||
Publish:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.matcher == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
arthichaud/meelo-matcher
|
||||
ghcr.io/${{ github.repository }}-matcher
|
||||
- name: Typecheck and push Docker images
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./matcher
|
||||
push: true
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-matcher:edge' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -0,0 +1,155 @@
|
||||
name: "Scanner"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main, v3 ]
|
||||
push:
|
||||
branches: [ main, v3 ]
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: ./scanner
|
||||
|
||||
jobs:
|
||||
Changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
scanner: ${{ steps.filter.outputs.scanner }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
scanner:
|
||||
- 'scanner/**'
|
||||
- '.github/workflows/scanner.yml'
|
||||
Build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/cache-scanner
|
||||
- name: Install Dependencies and Build
|
||||
run: go build -o scanner ./app
|
||||
Format:
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Build ]
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/cache-scanner
|
||||
- uses: Jerome1337/gofmt-action@v1.0.5
|
||||
with:
|
||||
gofmt-path: './scanner'
|
||||
gofmt-flags: '-l'
|
||||
Tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Build ]
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && always() }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/cache-scanner
|
||||
- run: sudo apt-get update -y && sudo apt-get install -y ffmpeg
|
||||
- name: Install Coverage Report
|
||||
run: go install github.com/jandelgado/gcov2lcov@latest
|
||||
- name: Run tests
|
||||
run: go test ./... -coverprofile=coverage.out
|
||||
- name: Upload Unit Tests artifact
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@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/sonarcloud-github-action@master
|
||||
with:
|
||||
projectBaseDir: ./scanner
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_SCANNER }}
|
||||
Dockerize:
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
|
||||
needs: [ Build ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./scanner
|
||||
push: false
|
||||
Publish:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
arthichaud/meelo-scanner
|
||||
ghcr.io/${{ github.repository }}-scanner
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./scanner
|
||||
push: true
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-scanner:edge' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -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 ]
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
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 +42,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 +58,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 +85,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 +104,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,7 +116,7 @@ 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
|
||||
@@ -208,4 +187,4 @@ jobs:
|
||||
context: ./server
|
||||
push: true
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-server:edge' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -1,65 +1,86 @@
|
||||

|
||||
# <img alt="Icon" src="./front/public/banner.png" width="150px">
|
||||
|
||||
## Your music, your files, your experience
|
||||
Welcome! Meelo is a self-hosted music server and web app. It works similarly to Plex, Jellyfin, Koel and Black Candy, but focuses on flexibility, browsing and listening experiences. Actually, Meelo is designed for music collectors. What does it mean? Scroll down to see our killer features :arrow_heading_down:
|
||||
|
||||
While the software wants to be a flexible as possible, it requires a little bit of setup. However, once everything is ready, you will just have to sit back, relax, and enjoy your music. :sunglasses:
|
||||
|
||||

|
||||
|
||||
## :star: Features
|
||||
|
||||
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 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!
|
||||
- ... and Rare tracks
|
||||
- And show them on the artist's page, so that these rare gems do not get lost and forgotten!
|
||||
- Automatic 'featuring' and duet detection
|
||||
- Albums have _releases_
|
||||
- Meaning that you can have multiple versions of a single album
|
||||
- But only its _main_ version would appear on browsing pages
|
||||
- Of course, you can still access its other versions, directly from the album's page
|
||||
- Songs have _tracks_
|
||||
- It is pretty much the same as for albums and releases
|
||||
- No duplicated songs when browsing your library!
|
||||
- Song Versions
|
||||
- Browse Songs [grouped by version](./assets/examples/song-groups.png)
|
||||
- Album and Song types
|
||||
- Finding instrumental songs or live recordings has never been this simple!
|
||||
- Filter Songs that are exclusive to an album (compilations only)
|
||||
- Supports all formats!
|
||||
- Thanks to transcoding and the way we parse files, any audio and video format is virtually supported
|
||||
- (Note: Transcoding is only used when the file format is not supported in the browser)
|
||||
- Flexible metadata parsing
|
||||
- Use either the embedded metadata or the file's name (or both!) to extract metadata
|
||||
- Also works with album covers!
|
||||
- Get genres, descriptions, and ratings using MusicBrainz, Genius, Wikipedia and many more providers!
|
||||
- Download lyrics
|
||||
|
||||
Keep track of the upcoming features [here](https://github.com/Arthi-chaud/Meelo/issues)
|
||||
|
||||
## :book: How to get started
|
||||
|
||||
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.
|
||||
|
||||
## :desktop_computer: Live Demo
|
||||
|
||||
We are actively working on a public demo, so that you can try out Meelo. Stay tuned...
|
||||
|
||||
## :camera: Screenshots
|
||||
|
||||
<details>
|
||||
<summary>Unfold to see what the web application looks like!</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/48141241-25ec-4570-a4f1-696c0cdfcaaa
|
||||
|
||||
---
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
More screenshots [here](./assets/examples/)
|
||||
|
||||
</details>
|
||||
|
||||
## :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!
|
||||
|
||||
: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).
|
||||
|
||||
---
|
||||
|
||||
Get ready to make the most out of you music collection!
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=arthi-chaud_Meelo-back)
|
||||
[](https://sonarcloud.io/summary/new_code?id=arthi-chaud_Meelo-back)
|
||||
|
||||
Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
This project is made by and for music collectors. Its goal is to have a clean, organized, easy and accessible way to store and serve files.
|
||||
|
||||
Get started! Look over [here](https://arthi-chaud.github.io/Meelo/) for setup instructions
|
||||
|
||||
## Features
|
||||
|
||||
- Files organized in *Libraries*
|
||||
- Scan for new files, and remove deleted files
|
||||
- Music Organization close to a Music Collector's collection
|
||||
- Artist
|
||||
- Album
|
||||
- Releases
|
||||
- Songs
|
||||
- Primary and secondary artists
|
||||
- Tracks
|
||||
- Versions
|
||||
- Access, stream, and download songs from your server
|
||||
- Music Videos
|
||||
- B-Sides
|
||||
- Identifies 'Rare' Tracks in your collection
|
||||
- Playlists
|
||||
- Metadata scan using:
|
||||
- Embedded tags (including bitrate)
|
||||
- Path of file, using customizable Regular Expressions
|
||||
- Either using one of the two, or the two combined
|
||||
- Album Typing
|
||||
- Studio Recording, Single, Live Album, Video Album, Soundtrack Album, ...
|
||||
- Song Typing
|
||||
- Original Version, Demo, Instrumental, Remix, Acoustic, Edit, Clean, ...
|
||||
- ... And allow filtering based on items' types
|
||||
- Embedded and inline artwork file handling
|
||||
- External Metadata Providers (Optional)
|
||||
- Genius, MusicBrainz, Wikipedia, Discogs, Metacritic, Allmusic
|
||||
- Automated Lyrics download
|
||||
- User management
|
||||
- Multi-language support
|
||||
- English
|
||||
- French
|
||||
|
||||
Keep track of the upcoming features with the [project's issues](https://github.com/Arthi-chaud/Meelo/issues)
|
||||
|
||||
## Why Meelo could be what you are looking for
|
||||
|
||||
[Plex](https://www.plex.tv/fr/), [Beets](https://github.com/beetbox/beets), [Koel](https://github.com/koel/koel), as well as iTunes are awesome solutions if you have a 'simple' music collection, but they are not tailored for music collectors, who usually have multiple versions of a single album, multiple files for one single song (from multiple album releases). Therefore, their library ends up clustered and unpleasant to browse and use.
|
||||
|
||||
## The philosophy behind Meelo
|
||||
|
||||
The idea behind Meelo is to have a pleasant way to browse and enjoy your collection and making it the *best* one possible.
|
||||
|
||||
The *best* music collection is a collection that is fully useable in any music server (like one mentioned above). The only way to reach this goal is to make the music collection independent metadata-wise, i.e. having all the metadata embedded in the files themselves.
|
||||
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.
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||
|
||||
More screenshots [here](./assets/examples/)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
+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
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
-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
|
||||
@@ -76,10 +126,18 @@ services:
|
||||
cpus: 1
|
||||
environment:
|
||||
- GOCODER_SAFE_PATH=${INTERNAL_DATA_DIR}
|
||||
- POSTGRES_USER=${POSTGRES_USER}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- POSTGRES_DB=${POSTGRES_DB}
|
||||
- POSTGRES_SCHEMA=gocoder
|
||||
- POSTGRES_SERVER=db
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- transcoder_cache:/cache
|
||||
profiles: ['', 'cpu']
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
db:
|
||||
image: postgres:alpine3.16
|
||||
healthcheck:
|
||||
@@ -89,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:
|
||||
+68
-11
@@ -10,6 +10,8 @@ services:
|
||||
condition: service_healthy
|
||||
meilisearch:
|
||||
condition: service_healthy
|
||||
mq:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}
|
||||
@@ -18,14 +20,33 @@ services:
|
||||
environment:
|
||||
- TRANSCODER_URL=http://transcoder:7666
|
||||
- MEILI_HOST=http://meilisearch:7700
|
||||
- REDIS_HOST=redis
|
||||
- POSTGRES_HOST=db
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?schema=public
|
||||
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
scanner:
|
||||
image: ghcr.io/arthi-chaud/meelo-scanner:latest
|
||||
expose:
|
||||
- 8133
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- localhost:8133"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
environment:
|
||||
- API_URL=http://server:4000
|
||||
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
|
||||
- INTERNAL_DATA_DIR=${INTERNAL_DATA_DIR}
|
||||
- API_KEYS=${API_KEYS}
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
|
||||
front:
|
||||
image: ghcr.io/arthi-chaud/meelo-front:latest
|
||||
expose:
|
||||
@@ -33,16 +54,28 @@ services:
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
scanner:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- PUBLIC_SERVER_URL=${PUBLIC_SERVER_URL}
|
||||
- PUBLIC_SERVER_URL=${PUBLIC_URL}/api
|
||||
- SSR_SERVER_URL=http://server:4000
|
||||
redis:
|
||||
image: redis:7.0-alpine
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli","ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
- PUBLIC_SCANNER_URL=${PUBLIC_URL}/scanner
|
||||
- SSR_SCANNER_URL=http://scanner:8133
|
||||
matcher:
|
||||
image: ghcr.io/arthi-chaud/meelo-matcher:latest
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
mq:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- API_URL=http://server:4000
|
||||
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
|
||||
- API_KEYS=${API_KEYS}
|
||||
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
|
||||
volumes:
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
|
||||
db:
|
||||
image: postgres:alpine3.14
|
||||
healthcheck:
|
||||
@@ -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
|
||||
@@ -76,10 +110,31 @@ services:
|
||||
- 7666
|
||||
environment:
|
||||
- GOCODER_SAFE_PATH=${INTERNAL_DATA_DIR}
|
||||
- POSTGRES_USER=${POSTGRES_USER}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- POSTGRES_DB=${POSTGRES_DB}
|
||||
- POSTGRES_SCHEMA=gocoder
|
||||
- POSTGRES_SERVER=db
|
||||
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
|
||||
@@ -94,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:
|
||||
@@ -102,3 +158,4 @@ volumes:
|
||||
data:
|
||||
search:
|
||||
transcoder_cache:
|
||||
rabbitmq_data:
|
||||
+67
-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,24 +36,66 @@ 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}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- POSTGRES_DB=${POSTGRES_DB}
|
||||
- POSTGRES_SCHEMA=gocoder
|
||||
- POSTGRES_SERVER=db
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- transcoder_cache:/cache
|
||||
@@ -78,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
|
||||
@@ -96,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:
|
||||
@@ -104,3 +162,4 @@ volumes:
|
||||
data:
|
||||
search:
|
||||
transcoder_cache:
|
||||
rabbitmq_data:
|
||||
@@ -41,6 +41,10 @@ In this section, we list examples that would be successfully parsed by Meelo
|
||||
- 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
|
||||
|
||||
|
||||
@@ -30,11 +30,13 @@ If a provider is disabled, no call to its service will be done.
|
||||
|
||||
| Provider Name | Key | Additional Properties | Artist Description | Artist Illustration | Album Description | Album Rating | Song Description | Song Lyrics | Release Identification | Album Genres |
|
||||
|---------------|---------------|--------------------------------------------------------------------------------------------|--------------------|---------------------|-------------------|--------------|------------------|-------------|------------------------|--------------|
|
||||
| Genius | `genius` | `apiKey`: Access Token, you can get it [here](https://genius.com/api-clients) | x | x | x | | | x | | |
|
||||
| Genius | `genius` | `apiKey`: Access Token, you can get it [here](https://genius.com/api-clients) | x | x | x | | x | x | | |
|
||||
| MusicBrainz | `musicbrainz` | None | | | | | | | | x |
|
||||
| AllMusic | `allmusic` | None | | | x | x | | | | |
|
||||
| MetaCritic | `metacritic` | None | | | x | x | | | | |
|
||||
| Wikipedia | `wikipedia` | None | x | | x | | x | | | |
|
||||
| Discogs | `discogs` | `apiKey`: Access Token, you can get it [here](https://www.discogs.com/settings/developers) | x | | x | | | | x | x |
|
||||
| Discogs | `discogs` | `apiKey`: Access Token, you can get it [here](https://www.discogs.com/settings/developers) | x | | | | | | x | x |
|
||||
|
||||
**Warning**: Meelo heavily relies on Wikidata to optimize the metadata fetching. It is used if, and only if the MusicBrainz Provider is enabled. It is not possible to use one without the other.
|
||||
**Warning**: Meelo heavily relies on Wikidata to optimize the metadata fetching. It is used if, and only if the MusicBrainz Provider is enabled. It is not possible to use one without the other.
|
||||
|
||||
*Note*: If MusicBrainz is enabled, we might make requests to [AcoustID](https://acoustid.org)'s API to match songs using audio fingerprints (No data will be submitted).
|
||||
@@ -5,4 +5,4 @@ Meelo is easy to setup! But you'll need the following things before continuing
|
||||
- Have you music library ready (see [*this step*](../prepare-music-library))
|
||||
- Know how `Docker` and `docker-compose` work
|
||||
- Know how to create a `.env` file
|
||||
- Know how to manipulate Regular Expressions (Especially the ECMAScript flavor)
|
||||
- Know how to manipulate Regular Expressions (Especially the Golang flavor)
|
||||
@@ -16,6 +16,7 @@ All fields are **required**
|
||||
- `Track`: The name of the track
|
||||
- `Genre`: The genre of the material
|
||||
- `DiscogsID`: The Discogs ID of the parent release. A Discogs ID should take the form of a digit-only string. This ID can be found in the URL of the release's Discogs page or in the top-right corner of that page (displayed as `[rXXXXXXXX]`)
|
||||
- *Note*: **If you do not use embedded data**, most regex groups are optional. Only `Track` and (`AlbumArtist` or `Artist`) are required for the file to be accepted by the scanner.
|
||||
- `metadata`: Configuration of the **metadata extraction** system:
|
||||
- `source`: The **primary metadata source** to use:
|
||||
- `embedded`: Using the embedded tags
|
||||
@@ -30,3 +31,5 @@ All fields are **required**
|
||||
- `useID3CompTag`: If true, use iTunes' non-standard ID3 tags for compilations.
|
||||
|
||||
*Note*: The provided Regular Expression uses the iTunes file architecture.
|
||||
|
||||
*Note*: Regular Expression must use the Golang Flavour.
|
||||
@@ -11,12 +11,14 @@ Meelo supports the following song types:
|
||||
- Acoustic
|
||||
- Remix
|
||||
- Instrumental
|
||||
- Acapella
|
||||
- A Cappella
|
||||
- Edit
|
||||
- Of the original version.
|
||||
- If the song is an edit of a remix, it will be categorised as 'Remix'
|
||||
- Clean
|
||||
- Demo
|
||||
- Medley
|
||||
- Non-Music (e.g. Interviews)
|
||||
|
||||
## Type recognition
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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*",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+6
-14
@@ -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"
|
||||
},
|
||||
@@ -24,7 +24,7 @@
|
||||
"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",
|
||||
@@ -33,7 +33,7 @@
|
||||
"iconsax-react": "^0.0.8",
|
||||
"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",
|
||||
@@ -46,6 +46,7 @@
|
||||
"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",
|
||||
@@ -54,6 +55,7 @@
|
||||
"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",
|
||||
@@ -62,17 +64,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" {
|
||||
|
||||
+302
-165
@@ -16,57 +16,76 @@
|
||||
* 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 User, { type UserSortingKeys } from "../models/user";
|
||||
import {
|
||||
type VideoInclude,
|
||||
type VideoSortingKeys,
|
||||
type VideoType,
|
||||
VideoWithRelations,
|
||||
} from "../models/video";
|
||||
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 type { SortingParameters } from "../utils/sorting";
|
||||
import type { InfiniteQuery, Query } from "./use-query";
|
||||
|
||||
const AuthenticationResponse = yup.object({
|
||||
access_token: yup.string().required(),
|
||||
@@ -74,8 +93,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[]> = {
|
||||
@@ -89,11 +106,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<{
|
||||
@@ -102,6 +125,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
|
||||
@@ -112,7 +136,7 @@ export default class API {
|
||||
includes
|
||||
? Object.entries(includes)
|
||||
.filter(
|
||||
([key, value]) =>
|
||||
([_, value]) =>
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
value !== "view",
|
||||
@@ -123,6 +147,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;
|
||||
|
||||
/**
|
||||
@@ -149,7 +175,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,
|
||||
@@ -169,12 +195,15 @@ 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(),
|
||||
pending_tasks: yup
|
||||
.array(yup.string().required())
|
||||
.required(),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
@@ -189,7 +218,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),
|
||||
@@ -215,7 +244,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,
|
||||
@@ -228,7 +257,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: {},
|
||||
@@ -255,7 +284,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",
|
||||
@@ -318,25 +347,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -346,12 +364,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -361,12 +381,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -374,11 +396,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -391,10 +415,12 @@ export default class API {
|
||||
resourceSlugOrId: number | string,
|
||||
): Promise<void> {
|
||||
return API.fetch({
|
||||
route: `/tasks/refresh-metadata`,
|
||||
errorMessage: "Library refresh failed",
|
||||
method: "POST",
|
||||
route: "/refresh",
|
||||
errorMessage: "Metadata Refresh request failed",
|
||||
otherParameters: { [parentResourceType]: resourceSlugOrId },
|
||||
parameters: {},
|
||||
service: Service.Scanner,
|
||||
emptyResponse: true,
|
||||
});
|
||||
}
|
||||
@@ -404,7 +430,7 @@ export default class API {
|
||||
libraryPath: string,
|
||||
): Promise<Library> {
|
||||
return API.fetch({
|
||||
route: "/libraries/new",
|
||||
route: "/libraries",
|
||||
data: { name: libraryName, path: libraryPath },
|
||||
errorMessage: "Library Creation Failed",
|
||||
parameters: {},
|
||||
@@ -448,13 +474,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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -462,13 +488,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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -476,13 +502,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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -490,13 +516,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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -504,17 +530,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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -523,15 +549,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -540,15 +566,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -575,7 +615,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: {
|
||||
@@ -616,7 +656,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 },
|
||||
@@ -645,7 +685,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,
|
||||
@@ -684,7 +724,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 },
|
||||
@@ -695,18 +735,52 @@ export default class API {
|
||||
};
|
||||
}
|
||||
|
||||
static getSongGroups<I extends SongInclude | never = never>(
|
||||
filter: {
|
||||
library?: Identifier;
|
||||
genre?: Identifier;
|
||||
artist?: Identifier;
|
||||
query?: string;
|
||||
type?: SongType;
|
||||
},
|
||||
sort?: SortingParameters<typeof SongGroupSortingKeys>,
|
||||
include?: I[],
|
||||
): InfiniteQuery<SongGroupWithRelations<I>> {
|
||||
return {
|
||||
key: [
|
||||
"song-groups",
|
||||
...API.formatObject(filter),
|
||||
...API.formatObject(sort),
|
||||
...API.formatIncludeKeys(include),
|
||||
],
|
||||
exec: (pagination) =>
|
||||
API.fetch({
|
||||
route: "/song-groups",
|
||||
errorMessage: "Songs could not be loaded",
|
||||
parameters: { pagination: pagination, include, sort },
|
||||
otherParameters: { ...filter },
|
||||
validator: PaginatedResponse(
|
||||
SongGroupWithRelations(include ?? []),
|
||||
),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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?: VideoType;
|
||||
query?: string;
|
||||
},
|
||||
sort?: SortingParameters<typeof SongSortingKeys>,
|
||||
sort?: SortingParameters<typeof VideoSortingKeys>,
|
||||
include?: I[],
|
||||
): InfiniteQuery<VideoWithRelations<I>> {
|
||||
return {
|
||||
@@ -718,9 +792,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 ?? []),
|
||||
@@ -749,13 +827,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>> {
|
||||
@@ -768,7 +861,7 @@ export default class API {
|
||||
],
|
||||
exec: () =>
|
||||
API.fetch({
|
||||
route: `/tracks/master/${songSlugOrId}`,
|
||||
route: `/tracks/master/song/${songSlugOrId}`,
|
||||
parameters: { include },
|
||||
validator: TrackWithRelations(include ?? []),
|
||||
}),
|
||||
@@ -844,14 +937,42 @@ export default class API {
|
||||
key: ["search", query],
|
||||
exec: () =>
|
||||
API.fetch({
|
||||
route: `/search/${query}`,
|
||||
route: "/search",
|
||||
errorMessage: "Search failed",
|
||||
parameters: {},
|
||||
otherParameters: { query },
|
||||
customValidator: SearchResultTransformer,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
static getSearchHistory(): Query<SearchResult[]> {
|
||||
return {
|
||||
key: ["search-history-items"],
|
||||
exec: () =>
|
||||
API.fetch({
|
||||
route: "/search/history",
|
||||
errorMessage: "Getting Search History failed",
|
||||
parameters: {},
|
||||
customValidator: SearchResultTransformer,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search artists, albums and songs, all at once
|
||||
*/
|
||||
static saveSearchHistoryEntry(resource: SaveSearchItem): Promise<void> {
|
||||
return API.fetch({
|
||||
method: "POST",
|
||||
route: "/search/history",
|
||||
errorMessage: "Saving Search History failed",
|
||||
parameters: {},
|
||||
data: resource,
|
||||
emptyResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the User object of the authentified user
|
||||
* @returns A query to a User object
|
||||
@@ -863,7 +984,7 @@ export default class API {
|
||||
key: ["user", accessToken ?? ""],
|
||||
exec: () =>
|
||||
API.fetch({
|
||||
route: `/users/me`,
|
||||
route: "/users/me",
|
||||
parameters: {},
|
||||
validator: User,
|
||||
}),
|
||||
@@ -881,7 +1002,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),
|
||||
@@ -969,7 +1090,7 @@ export default class API {
|
||||
],
|
||||
exec: (pagination) =>
|
||||
API.fetch({
|
||||
route: `/tracks`,
|
||||
route: "/tracks",
|
||||
otherParameters: filter,
|
||||
parameters: { pagination: pagination, include, sort },
|
||||
validator: PaginatedResponse(
|
||||
@@ -1007,6 +1128,7 @@ export default class API {
|
||||
*/
|
||||
static getReleaseTracklist<I extends SongInclude | never = never>(
|
||||
slugOrId: string | number,
|
||||
exclusiveOnly = false,
|
||||
include?: I[],
|
||||
): InfiniteQuery<TracklistItemWithRelations<I>> {
|
||||
return {
|
||||
@@ -1014,12 +1136,14 @@ export default class API {
|
||||
"release",
|
||||
slugOrId,
|
||||
"tracklist",
|
||||
exclusiveOnly ? "exclusives" : "",
|
||||
...API.formatIncludeKeys(include),
|
||||
],
|
||||
exec: (pagination) =>
|
||||
API.fetch({
|
||||
route: `/releases/${slugOrId.toString()}/tracklist`,
|
||||
parameters: { include, pagination },
|
||||
otherParameters: { exclusive: exclusiveOnly },
|
||||
validator: PaginatedResponse(
|
||||
TracklistItemWithRelations(include ?? []),
|
||||
),
|
||||
@@ -1083,7 +1207,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,
|
||||
@@ -1110,6 +1234,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,
|
||||
@@ -1120,6 +1290,7 @@ export default class API {
|
||||
validator,
|
||||
customValidator,
|
||||
emptyResponse,
|
||||
service,
|
||||
}: FetchParameters<Keys, ReturnType>): Promise<ReturnType> {
|
||||
const accessToken = store.getState().user.accessToken;
|
||||
const header = {
|
||||
@@ -1127,7 +1298,7 @@ export default class API {
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
this.buildURL(route, parameters, otherParameters),
|
||||
API.buildURL(route, parameters, otherParameters, service),
|
||||
{
|
||||
method: method ?? "GET",
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
@@ -1176,11 +1347,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1189,20 +1359,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`, {});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1211,7 +1381,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`, {});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1223,10 +1393,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,
|
||||
});
|
||||
@@ -1280,51 +1450,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[]>(
|
||||
@@ -1340,11 +1477,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) {
|
||||
@@ -1363,7 +1500,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(",")}`;
|
||||
@@ -1371,7 +1508,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) {
|
||||
|
||||
@@ -19,15 +19,18 @@
|
||||
/* eslint-disable no-restricted-imports */
|
||||
|
||||
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 +65,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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,10 +16,10 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { LogoutIcon } from "../icons";
|
||||
import store from "../../state/store";
|
||||
import { unsetAccessToken } from "../../state/userSlice";
|
||||
import Action from "./action";
|
||||
import { LogoutIcon } from "../icons";
|
||||
import type Action from "./action";
|
||||
|
||||
export const LogoutAction: Action = {
|
||||
label: "logout",
|
||||
|
||||
@@ -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,6 +16,8 @@
|
||||
* 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,
|
||||
@@ -25,8 +27,7 @@ import {
|
||||
SettingsIcon,
|
||||
SongIcon,
|
||||
} from "../icons";
|
||||
import { NextRouter } from "next/router";
|
||||
import Action from "./action";
|
||||
import type Action from "./action";
|
||||
|
||||
export const GoToSongLyricsAction = (
|
||||
songIdentifier: string | number,
|
||||
@@ -76,10 +77,16 @@ export const GoToReleaseAction = (
|
||||
|
||||
export const GoToReleaseAsyncAction = (
|
||||
router: NextRouter,
|
||||
albumIdentifier: () => PromiseLike<number | string>,
|
||||
albumIdentifier: () => PromiseLike<number | string | null>,
|
||||
): Action => ({
|
||||
onClick: () =>
|
||||
albumIdentifier().then((id) => router.push(`/releases/${id}`)),
|
||||
albumIdentifier().then((id) => {
|
||||
if (id === null) {
|
||||
toast.error("This resource is not attached to any album.");
|
||||
} else {
|
||||
router.push(`/releases/${id}`);
|
||||
}
|
||||
}),
|
||||
label: "goToAlbum",
|
||||
icon: <AlbumIcon />,
|
||||
});
|
||||
|
||||
@@ -16,7 +16,23 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Action from "./action";
|
||||
import {
|
||||
Button,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} 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 toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "react-query";
|
||||
import API from "../../api/api";
|
||||
import type { MeeloInfiniteQueryFn, QueryClient } from "../../api/use-query";
|
||||
import type { PlayerActions, TrackState } from "../../contexts/player";
|
||||
import type Playlist from "../../models/playlist";
|
||||
import type { PlaylistWithRelations } from "../../models/playlist";
|
||||
import {
|
||||
AddItemToPlaylistIcon,
|
||||
AddToPlaylistIcon,
|
||||
@@ -24,26 +40,11 @@ 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>,
|
||||
@@ -129,7 +130,6 @@ export const CreatePlaylistAction = (queryClient: QueryClient): 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(() => {
|
||||
@@ -155,7 +155,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(() => {
|
||||
@@ -261,7 +260,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,11 +16,11 @@
|
||||
* 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 toast from "react-hot-toast";
|
||||
import { Translator } from "../../i18n/i18n";
|
||||
import API from "../../api/api";
|
||||
import type { Translator } from "../../i18n/i18n";
|
||||
import { MetadataRefreshIcon } from "../icons";
|
||||
import type Action from "./action";
|
||||
|
||||
const RefreshMetadataAction = (
|
||||
t: Translator,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* 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 { 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.getState().user.user?.admin !== true,
|
||||
onClick: () =>
|
||||
confirm({
|
||||
title: <br />,
|
||||
description: (
|
||||
<ResourceTypeForm
|
||||
defaultValue={resource.type}
|
||||
values={types}
|
||||
onSelect={(type) =>
|
||||
toast
|
||||
.promise(onSelect(type), {
|
||||
loading: null,
|
||||
success: "Update successful!",
|
||||
error: "Update failed...",
|
||||
})
|
||||
.then(() => {
|
||||
queryClient.client.invalidateQueries(
|
||||
"songs",
|
||||
);
|
||||
queryClient.client.invalidateQueries(
|
||||
"release",
|
||||
);
|
||||
queryClient.client.invalidateQueries(
|
||||
"tracks",
|
||||
);
|
||||
queryClient.client.invalidateQueries(
|
||||
"videos",
|
||||
);
|
||||
queryClient.client.invalidateQueries(
|
||||
"bsides",
|
||||
);
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
cancellationButtonProps: { sx: { display: "none" } },
|
||||
confirmationText: "OK",
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const ChangeSongType = (
|
||||
s: Song,
|
||||
client: QueryClient,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
) =>
|
||||
ChangeResourceType(
|
||||
s,
|
||||
SongType.filter((t) => t !== "Unknown"),
|
||||
"changeSongType",
|
||||
client,
|
||||
(newType: SongType) =>
|
||||
API.updateSong(s.id, { type: newType }).then((res) => {
|
||||
client.client.invalidateQueries("songs");
|
||||
client.client.invalidateQueries("release");
|
||||
client.client.invalidateQueries("tracks");
|
||||
client.client.invalidateQueries("videos");
|
||||
client.client.invalidateQueries("bsides");
|
||||
return res;
|
||||
}),
|
||||
confirm,
|
||||
);
|
||||
|
||||
const ChangeAlbumType = (
|
||||
a: Album,
|
||||
client: QueryClient,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
) =>
|
||||
ChangeResourceType(
|
||||
a,
|
||||
AlbumType,
|
||||
"changeAlbumType",
|
||||
client,
|
||||
(newType: AlbumType) =>
|
||||
API.updateAlbum(a.id, { type: newType }).then((res) => {
|
||||
client.client.invalidateQueries("albums");
|
||||
return res;
|
||||
}),
|
||||
confirm,
|
||||
);
|
||||
|
||||
const ChangeVideoType = (
|
||||
v: Video,
|
||||
client: QueryClient,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
) =>
|
||||
ChangeResourceType(
|
||||
v,
|
||||
VideoType,
|
||||
"changeVideoType",
|
||||
client,
|
||||
(newType: VideoType) =>
|
||||
API.updateVideo(v.id, { type: newType }).then((res) => {
|
||||
client.client.invalidateQueries("videos");
|
||||
return res;
|
||||
}),
|
||||
confirm,
|
||||
);
|
||||
|
||||
export { ChangeSongType, ChangeAlbumType, ChangeVideoType };
|
||||
@@ -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,12 +16,12 @@
|
||||
* 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>,
|
||||
@@ -41,6 +41,6 @@ export const ShowMasterTrackFileInfoAction = (
|
||||
label: "moreInfo",
|
||||
onClick: () =>
|
||||
queryClient
|
||||
.fetchQuery(API.getMasterTrack(songId))
|
||||
.fetchQuery(API.getSongMasterTrack(songId))
|
||||
.then((track) => openTrackFileInfoModal(confirm, track.id)),
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { QueryClient } from "../../api/use-query";
|
||||
import Action from "./action";
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import store from "../../state/store";
|
||||
import { Chip, Grid } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import API from "../../api/api";
|
||||
import { useMutation } from "react-query";
|
||||
import { EditIcon } from "../icons";
|
||||
import Song, { SongType } from "../../models/song";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const SongTypeForm = (props: {
|
||||
defaultValue: SongType;
|
||||
onSelect: (type: SongType) => void;
|
||||
}) => {
|
||||
const [currentType, setType] = useState(props.defaultValue);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid container spacing={2} justifyContent="center">
|
||||
{SongType.filter((type) => type != "Unknown").map((type) => (
|
||||
<Grid item key={type}>
|
||||
<Chip
|
||||
label={t(type)}
|
||||
variant={
|
||||
type == currentType ? "filled" : "outlined"
|
||||
}
|
||||
onClick={() => {
|
||||
setType(type);
|
||||
props.onSelect(type);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ChangeSongType = (
|
||||
song: Song,
|
||||
queryClient: QueryClient,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
): Action => {
|
||||
const { t } = useTranslation();
|
||||
const mutation = useMutation((newType: SongType) => {
|
||||
return API.updateSong(song.id, newType)
|
||||
.then(() => {
|
||||
toast.success("Update successful!");
|
||||
queryClient.client.invalidateQueries("songs");
|
||||
queryClient.client.invalidateQueries("release");
|
||||
queryClient.client.invalidateQueries("tracks");
|
||||
queryClient.client.invalidateQueries("videos");
|
||||
queryClient.client.invalidateQueries("bsides");
|
||||
})
|
||||
.catch((error: Error) => toast.error(error.message));
|
||||
});
|
||||
|
||||
return {
|
||||
label: "changeSongType",
|
||||
icon: <EditIcon />,
|
||||
disabled: store.getState().user.user?.admin !== true,
|
||||
onClick: () =>
|
||||
confirm({
|
||||
title: t("changeSongType"),
|
||||
description: (
|
||||
<SongTypeForm
|
||||
defaultValue={song.type}
|
||||
onSelect={(type) => mutation.mutate(type)}
|
||||
/>
|
||||
),
|
||||
cancellationButtonProps: { sx: { display: "none" } },
|
||||
confirmationText: t("done"),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export default ChangeSongType;
|
||||
@@ -16,21 +16,21 @@
|
||||
* 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 { UpdateIllustrationIcon } from "../icons";
|
||||
import type Action from "./action";
|
||||
|
||||
type IllustrationUpdateFormType = {
|
||||
onSubmit: (newUrl: string) => void;
|
||||
@@ -83,17 +83,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,7 +100,7 @@ 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"));
|
||||
});
|
||||
@@ -121,23 +120,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>;
|
||||
@@ -52,11 +52,10 @@ const AdminGrid = <DataType extends Resource>({
|
||||
|
||||
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 +67,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,9 @@
|
||||
*/
|
||||
|
||||
import { Avatar } from "@mui/material";
|
||||
import type { ComponentProps } from "react";
|
||||
import type IllustrationModel from "../models/illustration";
|
||||
import Illustration from "./illustration";
|
||||
import IllustrationModel from "../models/illustration";
|
||||
import { ComponentProps } from "react";
|
||||
|
||||
const ArtistAvatar = (props: {
|
||||
illustration: IllustrationModel | undefined | null;
|
||||
|
||||
@@ -20,10 +20,10 @@ import { Button, Divider, Grid } from "@mui/material";
|
||||
import { HookTextField, useHookForm } from "mui-react-hook-form-plus";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch } from "react-redux";
|
||||
import API from "../../api/api";
|
||||
import { setAccessToken } from "../../state/userSlice";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Authentication form
|
||||
@@ -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) {
|
||||
@@ -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,18 +16,18 @@
|
||||
* 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 { useQuery as useReactQuery } from "react-query";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import API from "../../api/api";
|
||||
import { prepareMeeloQuery } from "../../api/use-query";
|
||||
import type { RootState } from "../../state/store";
|
||||
import { setUserProfile } from "../../state/userSlice";
|
||||
import ModalPage from "../modal-page";
|
||||
import ThemedImage from "../themed-image";
|
||||
import AuthenticationForm from "./authentication-form";
|
||||
|
||||
const AuthenticationWall = (props: { children: any }) => {
|
||||
const accessToken = useSelector(
|
||||
@@ -49,7 +49,7 @@ const AuthenticationWall = (props: { children: any }) => {
|
||||
if (accessToken && status.data?.id && !status.error) {
|
||||
setAuthenticationStatus(true);
|
||||
}
|
||||
if (status.error || accessToken?.valueOf() == undefined) {
|
||||
if (status.error || accessToken?.valueOf() === undefined) {
|
||||
setAuthenticationStatus(false);
|
||||
}
|
||||
}, [accessToken, status, authentified]);
|
||||
|
||||
@@ -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,23 +16,23 @@
|
||||
* 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 { useSelector } from "react-redux";
|
||||
import API from "../../api/api";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import type { ReleaseWithRelations } from "../../models/release";
|
||||
import type { RootState } from "../../state/store";
|
||||
import { DownloadReleaseAction } from "../actions/download";
|
||||
import { GoToAlbumAction, GoToArtistAction } from "../actions/link";
|
||||
import { RefreshReleaseMetadataAction } from "../actions/refresh-metadata";
|
||||
import { ChangeAlbumType } from "../actions/resource-type";
|
||||
import { ShareReleaseAction } from "../actions/share";
|
||||
import { UpdateReleaseIllustrationAction } from "../actions/update-illustration";
|
||||
import { MasterIcon, UpgradeIcon } from "../icons";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
|
||||
type ReleaseContextualMenuProps = {
|
||||
release: ReleaseWithRelations<"album">;
|
||||
@@ -40,13 +40,15 @@ type ReleaseContextualMenuProps = {
|
||||
|
||||
const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
|
||||
const userIsAdmin = useSelector(
|
||||
(state: RootState) => state.user.user?.admin == true,
|
||||
(state: RootState) => state.user.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,9 +16,12 @@
|
||||
* 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 { usePlayerContext } from "../../contexts/player";
|
||||
import type Artist from "../../models/artist";
|
||||
import type { TrackWithRelations } from "../../models/track";
|
||||
import { DownloadAction } from "../actions/download";
|
||||
import {
|
||||
GoToArtistAction,
|
||||
@@ -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,7 +48,7 @@ type ReleaseTrackContextualMenuProps = {
|
||||
};
|
||||
|
||||
const ReleaseTrackContextualMenu = (props: ReleaseTrackContextualMenuProps) => {
|
||||
const songSlug = props.track.song.slug;
|
||||
const songSlug = props.track.song?.slug;
|
||||
const confirm = useConfirm();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
@@ -59,25 +59,37 @@ const ReleaseTrackContextualMenu = (props: ReleaseTrackContextualMenuProps) => {
|
||||
onSelect={props.onSelect}
|
||||
actions={[
|
||||
[GoToArtistAction(props.artist.slug)],
|
||||
[GoToSongLyricsAction(songSlug)],
|
||||
songSlug ? [GoToSongLyricsAction(songSlug)] : [],
|
||||
[
|
||||
PlayNextAction(async () => props, playNext),
|
||||
PlayAfterAction(async () => props, playAfter),
|
||||
],
|
||||
[AddToPlaylistAction(props.track.song.id, queryClient)],
|
||||
props.track.songId
|
||||
? [AddToPlaylistAction(props.track.songId, queryClient)]
|
||||
: [],
|
||||
songSlug
|
||||
? [
|
||||
GoToSongVersionAction(songSlug),
|
||||
GoToRelatedTracksAction(songSlug),
|
||||
]
|
||||
: [],
|
||||
[
|
||||
GoToSongVersionAction(songSlug),
|
||||
GoToRelatedTracksAction(songSlug),
|
||||
],
|
||||
[
|
||||
ChangeSongType(props.track.song, queryClient, confirm),
|
||||
...(props.track.song
|
||||
? [
|
||||
ChangeSongType(
|
||||
props.track.song,
|
||||
queryClient,
|
||||
confirm,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
UpdateTrackIllustrationAction(queryClient, props.track.id),
|
||||
RefreshTrackMetadataAction(props.track.id, t),
|
||||
],
|
||||
[ShowTrackFileInfoAction(confirm, props.track.id)],
|
||||
[
|
||||
DownloadAction(confirm, props.track.sourceFileId, t),
|
||||
ShareSongAction(songSlug, t),
|
||||
...(songSlug ? [ShareSongAction(songSlug, t)] : []),
|
||||
],
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -16,10 +16,14 @@
|
||||
* 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 { usePlayerContext } from "../../contexts/player";
|
||||
import type { SongWithRelations } from "../../models/song";
|
||||
import { DownloadAsyncAction } from "../actions/download";
|
||||
import {
|
||||
GoToArtistAction,
|
||||
@@ -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">;
|
||||
@@ -58,7 +58,7 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
|
||||
const { playNext, playAfter } = usePlayerContext();
|
||||
const getMasterTrack = () =>
|
||||
queryClient.fetchQuery(
|
||||
API.getMasterTrack(songSlug, ["release", "illustration"]),
|
||||
API.getSongMasterTrack(songSlug, ["release", "illustration"]),
|
||||
);
|
||||
const router = useRouter();
|
||||
const confirm = useConfirm();
|
||||
@@ -76,7 +76,8 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
|
||||
GoToArtistAction(props.song.artist.slug),
|
||||
GoToReleaseAsyncAction(
|
||||
router,
|
||||
async () => (await getMasterTrack()).release.slug,
|
||||
async () =>
|
||||
(await getMasterTrack()).release?.slug ?? null,
|
||||
),
|
||||
],
|
||||
[GoToSongLyricsAction(songSlug)],
|
||||
|
||||
@@ -16,51 +16,52 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { MasterIcon } from "../icons";
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { useConfirm } from "material-ui-confirm";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import type { TrackWithRelations } from "../../models/track";
|
||||
import type { RootState } from "../../state/store";
|
||||
import type Action from "../actions/action";
|
||||
import { DownloadAction } from "../actions/download";
|
||||
import {
|
||||
GoToArtistAction,
|
||||
GoToReleaseAction,
|
||||
GoToSongLyricsAction,
|
||||
} from "../actions/link";
|
||||
import { GoToReleaseAction, GoToSongLyricsAction } from "../actions/link";
|
||||
import {
|
||||
AddToPlaylistAction,
|
||||
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,
|
||||
(state: RootState) => state.user.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,
|
||||
@@ -69,7 +70,9 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { playNext, playAfter } = usePlayerContext();
|
||||
const masterMutation = useMutation(async () => {
|
||||
return API.setTrackAsMaster(props.track.id)
|
||||
return API.updateSong(props.track.songId!, {
|
||||
masterTrackId: props.track.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t("trackSetAsMaster"));
|
||||
queryClient.client.invalidateQueries();
|
||||
@@ -81,28 +84,41 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
|
||||
<ContextualMenu
|
||||
onSelect={props.onSelect}
|
||||
actions={[
|
||||
props.isVideo
|
||||
? [
|
||||
GoToArtistAction(props.track.song.artistId),
|
||||
GoToReleaseAction(props.track.releaseId),
|
||||
]
|
||||
: [GoToReleaseAction(props.track.releaseId)],
|
||||
[GoToSongLyricsAction(props.track.song.slug)],
|
||||
[
|
||||
props.track.releaseId
|
||||
? GoToReleaseAction(props.track.releaseId)
|
||||
: undefined,
|
||||
].filter((a): a is Action => a !== undefined),
|
||||
props.track.song
|
||||
? [GoToSongLyricsAction(props.track.song.slug)]
|
||||
: [],
|
||||
[
|
||||
PlayNextAction(getPlayNextProps, playNext),
|
||||
PlayAfterAction(getPlayNextProps, playAfter),
|
||||
],
|
||||
[AddToPlaylistAction(props.track.songId, queryClient)],
|
||||
props.track.songId
|
||||
? [AddToPlaylistAction(props.track.songId, queryClient)]
|
||||
: [],
|
||||
props.track.songId
|
||||
? [
|
||||
{
|
||||
label: "setAsMaster",
|
||||
disabled: isMaster || !userIsAdmin,
|
||||
icon: <MasterIcon />,
|
||||
onClick: () => masterMutation.mutate(),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
[
|
||||
{
|
||||
label: "setAsMaster",
|
||||
disabled: isMaster || !userIsAdmin,
|
||||
icon: <MasterIcon />,
|
||||
onClick: () => masterMutation.mutate(),
|
||||
},
|
||||
],
|
||||
[
|
||||
ChangeSongType(props.track.song, queryClient, confirm),
|
||||
...(props.track.song
|
||||
? [
|
||||
ChangeSongType(
|
||||
props.track.song,
|
||||
queryClient,
|
||||
confirm,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
UpdateTrackIllustrationAction(queryClient, props.track.id),
|
||||
RefreshTrackMetadataAction(props.track.id, t),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import API from "../../api/api";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import type { VideoWithRelations } from "../../models/video";
|
||||
import type Action from "../actions/action";
|
||||
import { DownloadAction } from "../actions/download";
|
||||
import {
|
||||
GoToArtistAction,
|
||||
GoToReleaseAction,
|
||||
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();
|
||||
const { playNext, playAfter } = usePlayerContext();
|
||||
|
||||
return (
|
||||
<ContextualMenu
|
||||
onSelect={props.onSelect}
|
||||
actions={[
|
||||
[
|
||||
GoToArtistAction(props.video.artistId),
|
||||
props.video.master.releaseId
|
||||
? GoToReleaseAction(props.video.master.releaseId)
|
||||
: undefined,
|
||||
].filter((a): a is Action => a !== undefined),
|
||||
props.video.songId
|
||||
? [GoToSongLyricsAction(props.video.songId)]
|
||||
: [],
|
||||
[
|
||||
PlayNextAction(getPlayNextProps, playNext),
|
||||
PlayAfterAction(getPlayNextProps, playAfter),
|
||||
],
|
||||
props.video.songId
|
||||
? [AddToPlaylistAction(props.video.songId, queryClient)]
|
||||
: [],
|
||||
[
|
||||
ChangeVideoType(props.video, queryClient, confirm),
|
||||
UpdateTrackIllustrationAction(
|
||||
queryClient,
|
||||
props.video.master.id,
|
||||
),
|
||||
RefreshTrackMetadataAction(props.video.master.id, t),
|
||||
],
|
||||
[ShowTrackFileInfoAction(confirm, props.video.master.id)],
|
||||
[DownloadAction(confirm, props.video.master.sourceFileId, t)],
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoContextualMenu;
|
||||
@@ -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,30 @@ 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 { type LayoutOption, getLayoutParams } from "../../utils/layout";
|
||||
import parseQueryParam from "../../utils/parse-query-param";
|
||||
import { type Order, getOrderParams } from "../../utils/sorting";
|
||||
import type Action from "../actions/action";
|
||||
import Fade from "../fade";
|
||||
import { AscIcon, DescIcon, GridIcon, ListIcon } from "../icons";
|
||||
import type Option from "./option";
|
||||
import OptionButton from "./option-button";
|
||||
|
||||
export type Toggle = {
|
||||
name: string;
|
||||
label: TranslationKey;
|
||||
defaultValue?: boolean;
|
||||
};
|
||||
|
||||
export type OptionState<
|
||||
SortingKeys extends readonly string[],
|
||||
@@ -60,6 +66,7 @@ type ControllerProps<
|
||||
> = {
|
||||
options?: Options;
|
||||
actions?: Action[];
|
||||
toggles?: Toggle[];
|
||||
disableSorting?: true;
|
||||
sortingKeys: SortingKeys;
|
||||
defaultSortingKey?: SortingKeys[number];
|
||||
@@ -81,7 +88,7 @@ const Controls = <
|
||||
>(
|
||||
props: ControllerProps<SortingKeys, Options, Values>,
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
const _theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const librariesQuery = useReactInfiniteQuery({
|
||||
...prepareMeeloInfiniteQuery(API.getLibraries),
|
||||
@@ -118,18 +125,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];
|
||||
});
|
||||
}
|
||||
for (const option of props.toggles ?? []) {
|
||||
// @ts-ignore
|
||||
baseOptions[option.name] =
|
||||
(parseQueryParam(props.router?.query[option.name], [
|
||||
"false",
|
||||
"true",
|
||||
]) ?? "false") === "true";
|
||||
}
|
||||
return baseOptions;
|
||||
},
|
||||
);
|
||||
@@ -148,9 +162,10 @@ const Controls = <
|
||||
value,
|
||||
}: {
|
||||
name: string;
|
||||
value: string | null;
|
||||
value: boolean | string | null;
|
||||
}) => {
|
||||
setOptionState({ ...optionsState, [name]: value });
|
||||
value = value?.toString() ?? null;
|
||||
if (props.router) {
|
||||
const path = props.router.asPath.split("?")[0];
|
||||
const params = new URLSearchParams(
|
||||
@@ -209,14 +224,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,
|
||||
@@ -226,7 +241,7 @@ const Controls = <
|
||||
name,
|
||||
value:
|
||||
libraries.find(
|
||||
(lib) => lib.name == value,
|
||||
(lib) => lib.name === value,
|
||||
)?.slug ?? null,
|
||||
});
|
||||
}
|
||||
@@ -240,7 +255,7 @@ const Controls = <
|
||||
optionsState.sortBy as TranslationKey,
|
||||
)}`,
|
||||
icon:
|
||||
optionsState.order == "desc" ? (
|
||||
optionsState.order === "desc" ? (
|
||||
<DescIcon />
|
||||
) : (
|
||||
<AscIcon />
|
||||
@@ -271,7 +286,7 @@ const Controls = <
|
||||
options: [
|
||||
{
|
||||
...option,
|
||||
// eslint-disable-next-line max-len
|
||||
|
||||
currentValue:
|
||||
optionsState[
|
||||
option.name as keyof typeof optionsState
|
||||
@@ -283,6 +298,22 @@ const Controls = <
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{props.toggles?.map((toggle) => (
|
||||
<Button
|
||||
key={toggle.name}
|
||||
onClick={() => {
|
||||
updateOptionState({
|
||||
name: toggle.name,
|
||||
value:
|
||||
!optionsState[
|
||||
toggle.name as keyof OptionState<SortingKeys>
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t(toggle.label)}
|
||||
</Button>
|
||||
))}
|
||||
{props.disableLayoutToggle !== true && (
|
||||
<Tooltip title={t("changeLayout")}>
|
||||
<Button
|
||||
@@ -290,13 +321,13 @@ const Controls = <
|
||||
updateOptionState({
|
||||
name: "view",
|
||||
value:
|
||||
optionsState.view == "grid"
|
||||
optionsState.view === "grid"
|
||||
? "list"
|
||||
: "grid",
|
||||
})
|
||||
}
|
||||
>
|
||||
{optionsState.view == "grid" ? (
|
||||
{optionsState.view === "grid" ? (
|
||||
<ListIcon />
|
||||
) : (
|
||||
<GridIcon />
|
||||
@@ -308,16 +339,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} />]
|
||||
: [],
|
||||
),
|
||||
|
||||
+10
-10
@@ -17,35 +17,35 @@
|
||||
*/
|
||||
|
||||
import { Box, Button, Skeleton } from "@mui/material";
|
||||
import ExternalId from "../models/external-id";
|
||||
import Illustration from "./illustration";
|
||||
import Link from "next/link";
|
||||
import type { ExternalMetadataSource } from "../models/external-metadata";
|
||||
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"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{externalId?.provider.name ?? <Skeleton width="50px" />}
|
||||
{source?.providerName ?? <Skeleton width="50px" />}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (externalId?.url) {
|
||||
if (source?.url) {
|
||||
return (
|
||||
<Link
|
||||
href={externalId.url ?? undefined}
|
||||
href={source.url ?? undefined}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
@@ -56,4 +56,4 @@ const ExternalIdBadge = ({ externalId }: ExternalIdBadgeProps) => {
|
||||
return badge;
|
||||
};
|
||||
|
||||
export default ExternalIdBadge;
|
||||
export default ExternalMetadataBadge;
|
||||
@@ -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 type { AlbumWithRelations } from "../../models/album";
|
||||
import type { AlbumExternalMetadata } from "../../models/external-metadata";
|
||||
import getYear from "../../utils/getYear";
|
||||
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
|
||||
|
||||
@@ -17,17 +17,17 @@
|
||||
*/
|
||||
|
||||
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 API from "../api/api";
|
||||
import whiteIllustrationFallback from "../../public/icon.png";
|
||||
import type { RequireExactlyOne } from "type-fest";
|
||||
import blackIllustrationFallback from "../../public/icon-black.png";
|
||||
import { RequireExactlyOne } from "type-fest";
|
||||
import IllustrationModel from "../models/illustration";
|
||||
import whiteIllustrationFallback from "../../public/icon.png";
|
||||
import API from "../api/api";
|
||||
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 +67,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 +80,7 @@ const Illustration = (props: IllustrationProps) => {
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={"illustration-" + url}
|
||||
key={`illustration-${url}`}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
@@ -124,7 +124,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,12 +141,12 @@ 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),
|
||||
}}
|
||||
@@ -196,17 +196,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("?") ? "&" : "?"
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
*/
|
||||
|
||||
import { Grid } from "@mui/material";
|
||||
import Resource from "../../models/resource";
|
||||
import InfiniteScroll from "./infinite-scroll";
|
||||
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 InfiniteGridProps<T extends Resource> = Omit<
|
||||
@@ -40,7 +40,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,
|
||||
@@ -59,9 +58,9 @@ const InfiniteGrid = <T extends IllustratedResource>(
|
||||
item
|
||||
xs={6}
|
||||
sm={3}
|
||||
md={12 / 5}
|
||||
// No MD because we get more space when sidebar disappears
|
||||
lg={2}
|
||||
xl={1.2}
|
||||
xl={12 / 8}
|
||||
key={`item-${index}`}
|
||||
>
|
||||
{props.render(item)}
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
*/
|
||||
|
||||
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<
|
||||
@@ -41,7 +41,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 +53,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,19 @@
|
||||
* 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 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 };
|
||||
|
||||
@@ -70,7 +70,7 @@ 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
|
||||
@@ -79,10 +79,10 @@ const InfiniteAlbumView = (
|
||||
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 +95,14 @@ const InfiniteAlbumView = (
|
||||
}
|
||||
renderListItem={(item) => (
|
||||
<AlbumItem
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
album={item}
|
||||
formatSubtitle={props.formatSubtitle}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={(item) => (
|
||||
<AlbumTile
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
album={item}
|
||||
formatSubtitle={props.formatSubtitle}
|
||||
/>
|
||||
|
||||
@@ -18,12 +18,15 @@
|
||||
|
||||
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 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,7 +46,7 @@ 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
|
||||
@@ -62,8 +65,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,17 @@
|
||||
|
||||
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 { 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,7 +50,7 @@ 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
|
||||
@@ -69,8 +69,18 @@ const InfinitePlaylistView = (
|
||||
view: "grid",
|
||||
})
|
||||
}
|
||||
renderListItem={(item) => <PlaylistItem playlist={item} />}
|
||||
renderGridItem={(item) => <PlaylistTile playlist={item} />}
|
||||
renderListItem={(item) => (
|
||||
<PlaylistItem
|
||||
playlist={item}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={(item) => (
|
||||
<PlaylistTile
|
||||
playlist={item}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+6
-5
@@ -16,11 +16,11 @@
|
||||
* 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";
|
||||
|
||||
type InfiniteResourceViewProps<
|
||||
ResourceType extends Resource,
|
||||
@@ -31,6 +31,7 @@ type InfiniteResourceViewProps<
|
||||
options: OptionState<SortingKeys> & AdditionalQueryParams,
|
||||
) => ReturnType<MeeloInfiniteQueryFn<ResourceType>>;
|
||||
light?: boolean;
|
||||
onItemClick?: (item: ResourceType) => void;
|
||||
disableSorting?: true;
|
||||
defaultLayout?: LayoutOption;
|
||||
initialSortingOrder?: Order;
|
||||
|
||||
@@ -17,27 +17,33 @@
|
||||
*/
|
||||
|
||||
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 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 { type PlayerActions, usePlayerContext } from "../../../contexts/player";
|
||||
import {
|
||||
SongSortingKeys,
|
||||
SongType,
|
||||
type SongWithRelations,
|
||||
} from "../../../models/song";
|
||||
import {
|
||||
SongGroupSortingKeys,
|
||||
type SongGroupWithRelations,
|
||||
} from "../../../models/song-group";
|
||||
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;
|
||||
// if true, show song groups instead of songs
|
||||
groups?: boolean;
|
||||
random?: number;
|
||||
};
|
||||
|
||||
@@ -57,7 +63,7 @@ const playSongsAction = (
|
||||
const songs = res.pages.flatMap(({ items }) => items);
|
||||
let i = 0;
|
||||
for (const song of songs) {
|
||||
if (i == 0) {
|
||||
if (i === 0) {
|
||||
playTrack({
|
||||
track: {
|
||||
...song.master,
|
||||
@@ -89,10 +95,18 @@ const InfiniteSongView = <
|
||||
typeof SongSortingKeys,
|
||||
AdditionalProps
|
||||
> &
|
||||
Pick<ComponentProps<typeof SongItem<T>>, "formatSubtitle"> & {
|
||||
Pick<ComponentProps<typeof SongItem<T>>, "subtitles"> & {
|
||||
disableShuffle?: boolean;
|
||||
groupsQuery?: (
|
||||
p: OptionState<typeof SongGroupSortingKeys> & AdditionalProps,
|
||||
) => InfiniteQuery<
|
||||
SongGroupWithRelations<
|
||||
"artist" | "featuring" | "master" | "illustration"
|
||||
>
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const disableGroupingVersions = !props.groupsQuery;
|
||||
const router = useRouter();
|
||||
const [options, setOptions] =
|
||||
useState<OptionState<typeof SongSortingKeys, AdditionalProps>>();
|
||||
@@ -101,10 +115,11 @@ const InfiniteSongView = <
|
||||
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),
|
||||
library: options?.library ?? null,
|
||||
} as const;
|
||||
const shuffleAction = {
|
||||
@@ -147,35 +162,76 @@ const InfiniteSongView = <
|
||||
name: "type",
|
||||
values: [
|
||||
"All",
|
||||
...SongType.filter((type) => type != "Unknown"),
|
||||
...SongType.filter((type) => type !== "Unknown"),
|
||||
],
|
||||
currentValue: options?.type,
|
||||
},
|
||||
]}
|
||||
actions={[
|
||||
playAction,
|
||||
...(props.disableShuffle !== true ? [shuffleAction] : []),
|
||||
...(props.disableShuffle !== true && !options?.groups
|
||||
? [shuffleAction]
|
||||
: []),
|
||||
]}
|
||||
disableSorting={props.disableSorting}
|
||||
toggles={
|
||||
disableGroupingVersions
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: "groups",
|
||||
label: options?.groups
|
||||
? "showAllSongs"
|
||||
: "groupVersions",
|
||||
},
|
||||
]
|
||||
}
|
||||
disableSorting={options?.groups || props.disableSorting}
|
||||
onChange={setOptions}
|
||||
sortingKeys={SongSortingKeys}
|
||||
sortingKeys={
|
||||
options?.groups ? SongGroupSortingKeys : SongSortingKeys
|
||||
}
|
||||
defaultSortingOrder={props.initialSortingOrder}
|
||||
defaultSortingKey={props.initialSortingField}
|
||||
router={props.light == true ? undefined : router}
|
||||
router={props.light === true ? undefined : router}
|
||||
disableLayoutToggle
|
||||
defaultLayout={"list"}
|
||||
/>
|
||||
<InfiniteView
|
||||
view={options?.view ?? "list"}
|
||||
query={() => props.query(query)}
|
||||
renderListItem={(item) => (
|
||||
<SongItem
|
||||
song={item}
|
||||
formatSubtitle={props.formatSubtitle}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={(item) => <></>}
|
||||
/>
|
||||
{options?.groups ? (
|
||||
<InfiniteView
|
||||
view={"list"}
|
||||
query={() => {
|
||||
return props.groupsQuery!({
|
||||
...query,
|
||||
sortBy: "name",
|
||||
});
|
||||
}}
|
||||
renderListItem={(item) => (
|
||||
<SongGroupItem
|
||||
song={item}
|
||||
subtitles={props.subtitles}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={() => <></>}
|
||||
/>
|
||||
) : (
|
||||
<InfiniteView
|
||||
view={"list"}
|
||||
query={() => {
|
||||
return props.query({
|
||||
...query,
|
||||
sortBy: query.sortBy,
|
||||
});
|
||||
}}
|
||||
renderListItem={(item) => (
|
||||
<SongItem
|
||||
song={item}
|
||||
subtitles={props.subtitles}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={() => <></>}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,17 +16,20 @@
|
||||
* 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 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,7 +44,7 @@ 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"}
|
||||
/>
|
||||
@@ -55,8 +58,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,66 +17,158 @@
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { ComponentProps, useState } from "react";
|
||||
import { SongSortingKeys } 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 { type ComponentProps, useState } from "react";
|
||||
import {
|
||||
type InfiniteQuery,
|
||||
type QueryClient,
|
||||
prepareMeeloInfiniteQuery,
|
||||
useQueryClient,
|
||||
} from "../../../api/use-query";
|
||||
import { type PlayerActions, usePlayerContext } from "../../../contexts/player";
|
||||
import {
|
||||
VideoSortingKeys,
|
||||
VideoType,
|
||||
type VideoWithRelations,
|
||||
} from "../../../models/video";
|
||||
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 { PaginationParameters } from "../../../models/pagination";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import type InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
|
||||
const InfiniteVideoView = <T extends VideoWithRelations<"artist">>(
|
||||
props: InfiniteResourceViewProps<T, typeof SongSortingKeys> &
|
||||
const playVideosAction = (
|
||||
emptyPlaylist: PlayerActions["emptyPlaylist"],
|
||||
playTrack: PlayerActions["playTrack"],
|
||||
playAfter: PlayerActions["playAfter"],
|
||||
queryClient: QueryClient,
|
||||
query: () => InfiniteQuery<
|
||||
VideoWithRelations<"artist" | "master" | "illustration">
|
||||
>,
|
||||
) => {
|
||||
emptyPlaylist();
|
||||
queryClient.client
|
||||
.fetchInfiniteQuery(prepareMeeloInfiniteQuery(query))
|
||||
.then(async (res) => {
|
||||
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);
|
||||
} else {
|
||||
playAfter(video);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
type AdditionalProps = {
|
||||
type?: VideoType;
|
||||
random?: number;
|
||||
};
|
||||
|
||||
const InfiniteVideoView = <
|
||||
T extends VideoWithRelations<"artist" | "illustration" | "master">,
|
||||
>(
|
||||
props: InfiniteResourceViewProps<
|
||||
T,
|
||||
typeof VideoSortingKeys,
|
||||
AdditionalProps
|
||||
> &
|
||||
Omit<ComponentProps<typeof VideoTile>, "video">,
|
||||
) => {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [options, setOptions] =
|
||||
useState<OptionState<typeof SongSortingKeys>>();
|
||||
|
||||
useState<OptionState<typeof VideoSortingKeys, AdditionalProps>>();
|
||||
const query = {
|
||||
type:
|
||||
// @ts-ignore
|
||||
options?.type === "All" ? undefined : (options?.type as VideoType),
|
||||
sortBy: options?.sortBy ?? props.initialSortingField ?? "name",
|
||||
order: options?.order ?? props.initialSortingOrder ?? "asc",
|
||||
view: "grid",
|
||||
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),
|
||||
);
|
||||
},
|
||||
} as const;
|
||||
const shuffleAction = {
|
||||
label: "shuffle",
|
||||
icon: <ShuffleIcon />,
|
||||
onClick: () => {
|
||||
playVideosAction(
|
||||
emptyPlaylist,
|
||||
playTrack,
|
||||
playAfter,
|
||||
queryClient,
|
||||
() =>
|
||||
props.query({
|
||||
...query,
|
||||
random: Math.floor(Math.random() * 10000),
|
||||
}),
|
||||
);
|
||||
},
|
||||
} as const;
|
||||
return (
|
||||
<>
|
||||
<Controls
|
||||
options={[
|
||||
{
|
||||
label: (options?.type as VideoType) ?? "All",
|
||||
name: "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
|
||||
view={options?.view ?? "grid"}
|
||||
query={() => {
|
||||
const { key, exec } = props.query({
|
||||
view: options?.view ?? "grid",
|
||||
library: options?.library ?? null,
|
||||
sortBy:
|
||||
options?.sortBy ??
|
||||
props.initialSortingField ??
|
||||
"name",
|
||||
order:
|
||||
options?.order ??
|
||||
props.initialSortingOrder ??
|
||||
"asc",
|
||||
return props.query({
|
||||
...query,
|
||||
sortBy: query.sortBy,
|
||||
});
|
||||
return {
|
||||
key,
|
||||
exec: (pagination: PaginationParameters) =>
|
||||
exec(pagination).then((page) => {
|
||||
return {
|
||||
...page,
|
||||
items: page.items.map((item) => ({
|
||||
...item,
|
||||
illustration: item.track.illustration,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}}
|
||||
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,11 +17,13 @@
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
export const parentScrollableDivId = "scrollableDiv" as const;
|
||||
@@ -74,7 +76,7 @@ export type Page<T> = {
|
||||
* @returns a dynamic list component
|
||||
*/
|
||||
const InfiniteScroll = <T extends Resource>(props: InfiniteScrollProps<T>) => {
|
||||
const { isFetching, data, hasNextPage, fetchNextPage, isFetchingNextPage } =
|
||||
const { data, hasNextPage, fetchNextPage, isFetchingNextPage } =
|
||||
useInfiniteQuery(props.query);
|
||||
|
||||
return (
|
||||
@@ -97,7 +99,7 @@ const InfiniteScroll = <T extends Resource>(props: InfiniteScrollProps<T>) => {
|
||||
data === undefined
|
||||
? generateArray(3)
|
||||
: [
|
||||
...data.pages.map((page) => page.items).flat(),
|
||||
...data.pages.flatMap((page) => page.items),
|
||||
...(isFetchingNextPage ? generateArray(3) : []),
|
||||
],
|
||||
)}
|
||||
|
||||
@@ -17,15 +17,15 @@
|
||||
*/
|
||||
|
||||
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 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> = {
|
||||
@@ -39,8 +39,9 @@ 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 +91,7 @@ const InfiniteView = <ItemType extends IllustratedResource>(
|
||||
return (
|
||||
<>
|
||||
<ScrollToTopButton />
|
||||
{props.view.toLowerCase() == "list" ? (
|
||||
{props.view.toLowerCase() === "list" ? (
|
||||
<InfiniteList
|
||||
loader={() => <WideLoadingComponent />}
|
||||
query={props.query}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
IconButton,
|
||||
List,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useKeyboardBinding,
|
||||
useKeyboardBindingContext,
|
||||
} from "../contexts/keybindings";
|
||||
import { CloseIcon } from "./icons";
|
||||
|
||||
export const KeyboardBindingModal = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
useKeyboardBinding(
|
||||
{
|
||||
key: "?",
|
||||
description: "openModalShortcutDescription",
|
||||
handler: () => setIsOpen((x) => !x),
|
||||
},
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
sx={{ zIndex: "tooltip" }}
|
||||
fullWidth
|
||||
onClose={() => setIsOpen(false)}
|
||||
>
|
||||
<DialogTitle>{t("keyboadBindings")}</DialogTitle>
|
||||
<IconButton
|
||||
onClick={() => setIsOpen(false)}
|
||||
sx={() => ({
|
||||
position: "absolute",
|
||||
right: 8,
|
||||
top: 8,
|
||||
})}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<DialogContent>
|
||||
<KeyboardBindingModalContent />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const KeyboardBindingModalContent = () => {
|
||||
const { t } = useTranslation();
|
||||
const { bindings } = useKeyboardBindingContext();
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<List>
|
||||
{bindings.map((binding) => (
|
||||
<Grid container key={binding.key} spacing={2}>
|
||||
<Grid item xs={4} sx={{ textAlign: "center" }}>
|
||||
<pre>
|
||||
<code
|
||||
style={{
|
||||
backgroundColor: theme.palette.divider,
|
||||
borderColor: theme.palette.divider,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
padding: 6,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
}}
|
||||
>
|
||||
{binding.key}
|
||||
</code>
|
||||
</pre>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
xs={8}
|
||||
sx={{
|
||||
textOverflow: "ellipsis",
|
||||
alignContent: "center",
|
||||
}}
|
||||
>
|
||||
{t(binding.description)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
@@ -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">;
|
||||
|
||||
|
||||
@@ -16,15 +16,16 @@
|
||||
* 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 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 +33,7 @@ type AlbumItemProps = {
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
const AlbumItem = ({ album, formatSubtitle }: AlbumItemProps) => {
|
||||
const AlbumItem = ({ album, formatSubtitle, onClick }: AlbumItemProps) => {
|
||||
const artist = album?.artist;
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -44,13 +45,14 @@ const AlbumItem = ({ album, formatSubtitle }: AlbumItemProps) => {
|
||||
quality="low"
|
||||
/>
|
||||
}
|
||||
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}>
|
||||
|
||||
@@ -16,14 +16,15 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { PlaylistWithRelations } from "../../models/playlist";
|
||||
import PlaylistContextualMenu from "../contextual-menu/playlist-contextual-menu";
|
||||
import { PlaylistIcon } from "../icons";
|
||||
import { PlaylistWithRelations } from "../../models/playlist";
|
||||
import Illustration from "../illustration";
|
||||
import ListItem from "./item";
|
||||
import PlaylistContextualMenu from "../contextual-menu/playlist-contextual-menu";
|
||||
|
||||
type PlaylistItemProps = {
|
||||
playlist: PlaylistWithRelations<"illustration"> | undefined;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -31,7 +32,7 @@ type PlaylistItemProps = {
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
const PlaylistItem = ({ playlist }: PlaylistItemProps) => {
|
||||
const PlaylistItem = ({ playlist, onClick }: PlaylistItemProps) => {
|
||||
return (
|
||||
<ListItem
|
||||
icon={
|
||||
@@ -45,6 +46,7 @@ const PlaylistItem = ({ playlist }: PlaylistItemProps) => {
|
||||
secondTitle={null}
|
||||
href={playlist ? `/playlists/${playlist.slug}` : undefined}
|
||||
title={playlist?.name}
|
||||
onClick={onClick}
|
||||
trailing={
|
||||
playlist && <PlaylistContextualMenu playlist={playlist} />
|
||||
}
|
||||
|
||||
@@ -17,19 +17,20 @@
|
||||
*/
|
||||
|
||||
import { Grid } from "@mui/material";
|
||||
import { ReleaseWithRelations } from "../../models/release";
|
||||
import { Star } from "iconsax-react";
|
||||
import type { ReleaseWithRelations } from "../../models/release";
|
||||
import getYear from "../../utils/getYear";
|
||||
import ReleaseContextualMenu from "../contextual-menu/release-contextual-menu";
|
||||
import Illustration from "../illustration";
|
||||
import ListItem from "./item";
|
||||
import getYear from "../../utils/getYear";
|
||||
import { Star } from "iconsax-react";
|
||||
|
||||
type ReleaseItemProps = {
|
||||
release: ReleaseWithRelations<"album" | "illustration">;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const ReleaseItem = ({ release }: ReleaseItemProps) => {
|
||||
const isMaster = release.id == release.album.masterId;
|
||||
const ReleaseItem = ({ release, onClick }: ReleaseItemProps) => {
|
||||
const isMaster = release.id === release.album.masterId;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
@@ -42,6 +43,7 @@ const ReleaseItem = ({ release }: ReleaseItemProps) => {
|
||||
}
|
||||
href={`/releases/${release.slug}`}
|
||||
title={release.name}
|
||||
onClick={onClick}
|
||||
secondTitle={getYear(release.releaseDate)?.toString()}
|
||||
trailing={
|
||||
<Grid
|
||||
|
||||
@@ -16,15 +16,15 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { SongWithRelations } from "../../models/song";
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import type { SongWithRelations } from "../../models/song";
|
||||
import type { SongGroupWithRelations } from "../../models/song-group";
|
||||
import formatArtists from "../../utils/formatArtists";
|
||||
import SongContextualMenu from "../contextual-menu/song-contextual-menu";
|
||||
import { SongIcon } from "../icons";
|
||||
import Illustration from "../illustration";
|
||||
import ListItem from "./item";
|
||||
import SongContextualMenu from "../contextual-menu/song-contextual-menu";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SongIcon } from "../icons";
|
||||
import formatArtists from "../../utils/formatArtists";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
|
||||
type SongItemProps<
|
||||
T extends SongWithRelations<
|
||||
@@ -32,9 +32,48 @@ type SongItemProps<
|
||||
>,
|
||||
> = {
|
||||
song: T | undefined;
|
||||
formatSubtitle?: (song: T) => Promise<string>;
|
||||
onClick?: () => void;
|
||||
subtitles?: ((
|
||||
song: SongWithRelations<
|
||||
"artist" | "featuring" | "master" | "illustration"
|
||||
>,
|
||||
) => Promise<string | null>)[];
|
||||
};
|
||||
|
||||
export const SongGroupItem = <
|
||||
T extends SongGroupWithRelations<
|
||||
"artist" | "featuring" | "master" | "illustration"
|
||||
>,
|
||||
>({
|
||||
song,
|
||||
subtitles,
|
||||
}: SongItemProps<T>) => {
|
||||
return (
|
||||
<SongItem
|
||||
song={
|
||||
song
|
||||
? { ...song, id: song.songId, groupId: song.id }
|
||||
: undefined
|
||||
}
|
||||
subtitles={[
|
||||
...(subtitles
|
||||
? subtitles
|
||||
: [
|
||||
async (
|
||||
s: SongWithRelations<"artist" | "featuring">,
|
||||
) => defaultSubtitle(s),
|
||||
]),
|
||||
...(song && (song.versionCount ?? 0) > 1
|
||||
? [async () => `${song.versionCount} Versions`]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const defaultSubtitle = (s: SongWithRelations<"artist" | "featuring">) =>
|
||||
formatArtists(s.artist, s.featuring);
|
||||
|
||||
/**
|
||||
* Item for a list of songs
|
||||
* @param props
|
||||
@@ -46,22 +85,33 @@ const SongItem = <
|
||||
>,
|
||||
>({
|
||||
song,
|
||||
formatSubtitle,
|
||||
subtitles,
|
||||
onClick,
|
||||
}: SongItemProps<T>) => {
|
||||
const artist = song?.artist;
|
||||
const { playTrack } = usePlayerContext();
|
||||
const queryClient = useQueryClient();
|
||||
const [subtitle, setSubtitle] = useState(
|
||||
formatSubtitle
|
||||
const [subtitle, setSubtitle] = useState<string | null | undefined>(
|
||||
subtitles?.length
|
||||
? ((<br />) as unknown as string)
|
||||
: song
|
||||
? formatArtists(song.artist, song.featuring)
|
||||
? defaultSubtitle(song)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (formatSubtitle && song) {
|
||||
formatSubtitle(song).then((newSub) => setSubtitle(newSub));
|
||||
if (subtitles && song) {
|
||||
Promise.allSettled(subtitles.map((s) => s(song))).then((r) =>
|
||||
setSubtitle(
|
||||
r
|
||||
.map(
|
||||
(s) =>
|
||||
(s as PromiseFulfilledResult<string | null>)
|
||||
.value,
|
||||
)
|
||||
.filter((s): s is string => s !== null)
|
||||
.join(" • ") || null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
@@ -77,14 +127,16 @@ const SongItem = <
|
||||
onClick={
|
||||
song &&
|
||||
artist &&
|
||||
(() =>
|
||||
(() => {
|
||||
onClick?.();
|
||||
playTrack({
|
||||
artist,
|
||||
track: {
|
||||
...song.master,
|
||||
illustration: song.illustration,
|
||||
},
|
||||
}))
|
||||
});
|
||||
})
|
||||
}
|
||||
secondTitle={subtitle}
|
||||
trailing={song && <SongContextualMenu song={song} />}
|
||||
|
||||
@@ -16,18 +16,21 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Grid } from "@mui/material";
|
||||
import API from "../../api/api";
|
||||
import { TrackWithRelations } from "../../models/track";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import type { TrackWithRelations } from "../../models/track";
|
||||
import TrackContextualMenu from "../contextual-menu/track-contextual-menu";
|
||||
import { MasterIcon, TrackIcon } from "../icons";
|
||||
import Illustration from "../illustration";
|
||||
import ListItem from "./item";
|
||||
import TrackContextualMenu from "../contextual-menu/track-contextual-menu";
|
||||
import { Grid } from "@mui/material";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import { MasterIcon, TrackIcon } from "../icons";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
|
||||
type TrackItemProps = {
|
||||
track: TrackWithRelations<"release" | "song" | "illustration"> | undefined;
|
||||
track:
|
||||
| TrackWithRelations<"video" | "release" | "song" | "illustration">
|
||||
| undefined;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -35,10 +38,10 @@ type TrackItemProps = {
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
const TrackItem = ({ track }: TrackItemProps) => {
|
||||
const TrackItem = ({ track, onClick }: TrackItemProps) => {
|
||||
const release = track?.release;
|
||||
const { playTrack } = usePlayerContext();
|
||||
const isMaster = track ? track.song.masterId == track.id : false;
|
||||
const isMaster = track ? track.song?.masterId === track.id : false;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return (
|
||||
@@ -52,19 +55,24 @@ const TrackItem = ({ track }: TrackItemProps) => {
|
||||
}
|
||||
onClick={
|
||||
track &&
|
||||
release &&
|
||||
(() =>
|
||||
(() => {
|
||||
onClick?.();
|
||||
queryClient
|
||||
.fetchQuery(API.getSong(track.songId, ["artist"]))
|
||||
.then((song) => {
|
||||
.fetchQuery(
|
||||
track.song
|
||||
? API.getArtist(track.song.artistId)
|
||||
: API.getArtist(track.video!.artistId),
|
||||
)
|
||||
.then((artist) => {
|
||||
playTrack({
|
||||
artist: song.artist,
|
||||
artist,
|
||||
track,
|
||||
});
|
||||
}))
|
||||
});
|
||||
})
|
||||
}
|
||||
title={track?.name}
|
||||
secondTitle={release?.name}
|
||||
secondTitle={release?.name ?? null}
|
||||
trailing={
|
||||
<Grid
|
||||
container
|
||||
@@ -72,7 +80,7 @@ const TrackItem = ({ track }: TrackItemProps) => {
|
||||
sx={{ justifyContent: "flex-end", flexWrap: "nowrap" }}
|
||||
>
|
||||
<Grid item sx={{ display: "flex", alignItems: "center" }}>
|
||||
{isMaster ? <MasterIcon /> : undefined}
|
||||
{track?.song && isMaster ? <MasterIcon /> : undefined}
|
||||
</Grid>
|
||||
<Grid item>
|
||||
{track && <TrackContextualMenu track={track} />}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import type { VideoWithRelations } from "../../models/video";
|
||||
import formatArtists from "../../utils/formatArtists";
|
||||
import VideoContextualMenu from "../contextual-menu/video-contextual-menu";
|
||||
import { VideoIcon } from "../icons";
|
||||
import Illustration from "../illustration";
|
||||
import ListItem from "./item";
|
||||
|
||||
type VideoType = VideoWithRelations<"artist" | "master" | "illustration">;
|
||||
|
||||
type VideoItemProps<T extends VideoType> = {
|
||||
video: T | undefined;
|
||||
onClick?: () => void;
|
||||
subtitles?: ((video: VideoType) => Promise<string | null>)[];
|
||||
};
|
||||
|
||||
const defaultSubtitle = (s: VideoType) => formatArtists(s.artist);
|
||||
|
||||
/**
|
||||
* Item for a list of videos
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
const VideoItem = <T extends VideoType>({
|
||||
video,
|
||||
subtitles,
|
||||
onClick,
|
||||
}: VideoItemProps<T>) => {
|
||||
const artist = video?.artist;
|
||||
const { playTrack } = usePlayerContext();
|
||||
const [subtitle, setSubtitle] = useState<string | null | undefined>(
|
||||
subtitles?.length
|
||||
? ((<br />) as unknown as string)
|
||||
: video
|
||||
? defaultSubtitle(video)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (subtitles && video) {
|
||||
Promise.allSettled(subtitles.map((s) => s(video))).then((r) =>
|
||||
setSubtitle(
|
||||
r
|
||||
.map(
|
||||
(s) =>
|
||||
(s as PromiseFulfilledResult<string | null>)
|
||||
.value,
|
||||
)
|
||||
.filter((s): s is string => s !== null)
|
||||
.join(" • ") || null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<ListItem
|
||||
icon={
|
||||
<Illustration
|
||||
illustration={video?.illustration}
|
||||
fallback={<VideoIcon />}
|
||||
quality="low"
|
||||
/>
|
||||
}
|
||||
title={video?.name}
|
||||
onClick={
|
||||
video &&
|
||||
artist &&
|
||||
(() => {
|
||||
onClick?.();
|
||||
playTrack({
|
||||
artist,
|
||||
track: {
|
||||
...video.master,
|
||||
illustration: video.illustration,
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
secondTitle={subtitle}
|
||||
trailing={video && <VideoContextualMenu video={video} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoItem;
|
||||
@@ -16,9 +16,9 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Box, useTheme } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Bars } from "react-loader-spinner";
|
||||
import { Box, useTheme } from "@mui/material";
|
||||
import Fade from "../fade";
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,7 +40,7 @@ const LyricsBox = (props: LyricsProps) => {
|
||||
<Box flexDirection="column">
|
||||
{props.lyrics !== undefined
|
||||
? props.lyrics.map((lyric, index) => {
|
||||
if (lyric.length == 0) {
|
||||
if (lyric.length === 0) {
|
||||
return <br key={index} />;
|
||||
}
|
||||
const hasTitle =
|
||||
@@ -70,7 +70,7 @@ const LyricsBox = (props: LyricsProps) => {
|
||||
);
|
||||
})
|
||||
: generateArray(20).map((_, index) =>
|
||||
index % 5 == 0 ? (
|
||||
index % 5 === 0 ? (
|
||||
<br key={index} />
|
||||
) : (
|
||||
<Skeleton
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
|
||||
import { Box, IconButton, Paper, Slide } from "@mui/material";
|
||||
import { useRouter } from "next/router";
|
||||
import { CloseIcon } from "./icons";
|
||||
import { useEffect, useState } from "react";
|
||||
import { isClientSideRendering } from "../utils/is-ssr";
|
||||
import { CloseIcon } from "./icons";
|
||||
|
||||
type ModalPageProps = {
|
||||
disposable?: boolean;
|
||||
@@ -92,7 +92,7 @@ const ModalPage = (props: ModalPageProps) => {
|
||||
<CloseIcon
|
||||
style={{
|
||||
display:
|
||||
props.disposable == true
|
||||
props.disposable === true
|
||||
? undefined
|
||||
: "none",
|
||||
}}
|
||||
|
||||
@@ -17,24 +17,24 @@
|
||||
*/
|
||||
|
||||
import { Box, Button, Grid } from "@mui/material";
|
||||
import { ComponentProps, Fragment } from "react";
|
||||
import Link from "next/link";
|
||||
import { type ComponentProps, Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UseInfiniteQueryResult, UseQueryResult } from "react-query";
|
||||
import { TranslationKey } from "../i18n/i18n";
|
||||
import { AlbumWithRelations } from "../models/album";
|
||||
import Artist from "../models/artist";
|
||||
import { SongWithRelations } from "../models/song";
|
||||
import { VideoWithRelations } from "../models/video";
|
||||
import type { UseInfiniteQueryResult, UseQueryResult } from "react-query";
|
||||
import type { TranslationKey } from "../i18n/i18n";
|
||||
import type { AlbumWithRelations } from "../models/album";
|
||||
import type Artist from "../models/artist";
|
||||
import type { SongWithRelations } from "../models/song";
|
||||
import type { VideoWithRelations } from "../models/video";
|
||||
import { generateArray } from "../utils/gen-list";
|
||||
import getYear from "../utils/getYear";
|
||||
import Fade from "./fade";
|
||||
import { MoreIcon } from "./icons";
|
||||
import SectionHeader from "./section-header";
|
||||
import SongGrid from "./song-grid";
|
||||
import TileRow from "./tile-row";
|
||||
import AlbumTile from "./tile/album-tile";
|
||||
import VideoTile from "./tile/video-tile";
|
||||
import Fade from "./fade";
|
||||
import Link from "next/link";
|
||||
|
||||
export const SectionPadding = 4;
|
||||
|
||||
@@ -134,7 +134,7 @@ export const AlbumListPageSection = (
|
||||
: undefined
|
||||
}
|
||||
formatSubtitle={
|
||||
props.subtitleIs == "artistName"
|
||||
props.subtitleIs === "artistName"
|
||||
? undefined
|
||||
: (albumItem) =>
|
||||
getYear(
|
||||
@@ -148,7 +148,12 @@ export const AlbumListPageSection = (
|
||||
};
|
||||
|
||||
export const VideoListPageSection = (
|
||||
props: Omit<PageSectionProps<VideoWithRelations<"artist">>, "child"> &
|
||||
props: Omit<
|
||||
PageSectionProps<
|
||||
VideoWithRelations<"artist" | "master" | "illustration">
|
||||
>,
|
||||
"child"
|
||||
> &
|
||||
Omit<ComponentProps<typeof VideoTile>, "video">,
|
||||
) => {
|
||||
return (
|
||||
@@ -172,7 +177,7 @@ const PageSection = <T,>(props: PageSectionProps<T>) => {
|
||||
const firstPage = props.query?.data?.pages.at(0)?.items ?? props.items;
|
||||
return (
|
||||
<>
|
||||
{firstPage?.length != 0 && (
|
||||
{firstPage?.length !== 0 && (
|
||||
<>
|
||||
<SectionHeader
|
||||
heading={
|
||||
|
||||
@@ -16,6 +16,35 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
ButtonBase,
|
||||
Container,
|
||||
Divider,
|
||||
Grid,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
type ComponentProps,
|
||||
type LegacyRef,
|
||||
useCallback,
|
||||
useState,
|
||||
} from "react";
|
||||
import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd";
|
||||
import API from "../../api/api";
|
||||
import { useQuery } from "../../api/use-query";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import type Artist from "../../models/artist";
|
||||
import type { TrackWithRelations } from "../../models/track";
|
||||
import formatArtists from "../../utils/formatArtists";
|
||||
import formatDuration from "../../utils/formatDuration";
|
||||
import ReleaseTrackContextualMenu from "../contextual-menu/release-track-contextual-menu";
|
||||
import {
|
||||
CloseIcon,
|
||||
ContextualMenuIcon,
|
||||
@@ -30,35 +59,10 @@ import {
|
||||
RewindIcon,
|
||||
TrackIcon,
|
||||
} from "../icons";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
ButtonBase,
|
||||
Container,
|
||||
Divider,
|
||||
Grid,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import Illustration from "../illustration";
|
||||
import { ComponentProps, LegacyRef, useCallback, useState } from "react";
|
||||
import PlayerSlider from "./controls/slider";
|
||||
import API from "../../api/api";
|
||||
import { useQuery } from "../../api/use-query";
|
||||
import LyricsBox from "../lyrics";
|
||||
import { TrackWithRelations } from "../../models/track";
|
||||
import Artist from "../../models/artist";
|
||||
import Link from "next/link";
|
||||
import ReleaseTrackContextualMenu from "../contextual-menu/release-track-contextual-menu";
|
||||
import ListItem from "../list-item/item";
|
||||
import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd";
|
||||
import formatDuration from "../../utils/formatDuration";
|
||||
import formatArtists from "../../utils/formatArtists";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import LyricsBox from "../lyrics";
|
||||
import PlayerSlider from "./controls/slider";
|
||||
|
||||
const parentSongQuery = (id: number) =>
|
||||
API.getSong(id, ["artist", "lyrics", "featuring"]);
|
||||
@@ -112,7 +116,10 @@ const PreviousButton = (props: Omit<ControlButtonProps, "icon">) => (
|
||||
<ControlButton {...props} icon={<RewindIcon />} />
|
||||
);
|
||||
const MinimizedPlayerControls = (props: PlayerControlsProps) => {
|
||||
const parentSong = useQuery(parentSongQuery, props.track?.songId);
|
||||
const parentSong = useQuery(
|
||||
parentSongQuery,
|
||||
props.track?.songId ?? undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
@@ -229,14 +236,14 @@ const MinimizedPlayerControls = (props: PlayerControlsProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Panels = ["lyrics", "playlist"] as const;
|
||||
|
||||
const ExpandedPlayerControls = (
|
||||
props: PlayerControlsProps & { videoRef: LegacyRef<HTMLVideoElement> },
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const parentSong = useQuery(parentSongQuery, props.track?.songId);
|
||||
const parentSong = useQuery(
|
||||
parentSongQuery,
|
||||
props.track?.songId ?? undefined,
|
||||
);
|
||||
const { playlist, cursor, reorder, skipTrack } = usePlayerContext();
|
||||
const [selectedTab, selectTab] = useState<"player" | "lyrics" | "playlist">(
|
||||
"player",
|
||||
@@ -349,7 +356,9 @@ const ExpandedPlayerControls = (
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{props.track?.type == "Video" ? (
|
||||
{props.track?.type === "Video" ? (
|
||||
// biome-ignore lint/a11y/useMediaCaption: No caption available
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: TODO?
|
||||
<video
|
||||
playsInline
|
||||
id="videoPlayer"
|
||||
@@ -397,7 +406,7 @@ const ExpandedPlayerControls = (
|
||||
justifyContent: "end",
|
||||
}}
|
||||
>
|
||||
{props.track?.type == "Video" && (
|
||||
{props.track?.type === "Video" && (
|
||||
<IconButton onClick={requestFullscreen}>
|
||||
<FullscreenIcon size={18} />
|
||||
</IconButton>
|
||||
@@ -414,7 +423,13 @@ const ExpandedPlayerControls = (
|
||||
>
|
||||
{props.artist && props.track ? (
|
||||
<Link
|
||||
href={`/releases/${props.track.releaseId}`}
|
||||
href={
|
||||
props.track.releaseId
|
||||
? `/releases/${props.track.releaseId}`
|
||||
: props.track.songId
|
||||
? `/songs/${props.track.songId}/lyrics`
|
||||
: {}
|
||||
}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
@@ -422,6 +437,7 @@ const ExpandedPlayerControls = (
|
||||
>
|
||||
<Button
|
||||
onClick={() =>
|
||||
props.track &&
|
||||
props.onExpand(false)
|
||||
}
|
||||
sx={{
|
||||
@@ -446,14 +462,14 @@ const ExpandedPlayerControls = (
|
||||
</Grid>
|
||||
<Grid item xs={1}>
|
||||
{
|
||||
props.track &&
|
||||
parentSong.data &&
|
||||
props.artist ? (
|
||||
props.track && props.artist ? (
|
||||
<ReleaseTrackContextualMenu
|
||||
artist={props.artist}
|
||||
track={{
|
||||
...props.track,
|
||||
song: parentSong.data,
|
||||
song: props.track.songId
|
||||
? (parentSong.data ?? null)
|
||||
: null,
|
||||
}}
|
||||
onSelect={() =>
|
||||
props.onExpand(false)
|
||||
@@ -533,7 +549,7 @@ const ExpandedPlayerControls = (
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{selectedTab == "lyrics" && (
|
||||
{selectedTab === "lyrics" && (
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
@@ -556,7 +572,7 @@ const ExpandedPlayerControls = (
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{selectedTab == "playlist" && (
|
||||
{selectedTab === "playlist" && (
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
@@ -647,7 +663,10 @@ const ExpandedPlayerControls = (
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
<Divider variant="middle" />
|
||||
<Divider
|
||||
key={`divider-${index}`}
|
||||
variant="middle"
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
@@ -673,7 +692,7 @@ const ExpandedPlayerControls = (
|
||||
<IconButton
|
||||
key={index}
|
||||
disabled={
|
||||
tabName == "lyrics" && !parentSong.data?.lyrics
|
||||
tabName === "lyrics" && !parentSong.data?.lyrics
|
||||
}
|
||||
style={{
|
||||
transition: "color 0.2s ease",
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
*/
|
||||
|
||||
import { Grid, Slider } from "@mui/material";
|
||||
import { type MutableRefObject, useEffect, useState } from "react";
|
||||
import DurationComponent from "./duration";
|
||||
import { MutableRefObject, useEffect, useState } from "react";
|
||||
|
||||
type PlayerSliderProps = {
|
||||
onSlide: (newProgress: number) => void;
|
||||
@@ -72,7 +72,7 @@ const PlayerSlider = (props: PlayerSliderProps) => {
|
||||
value={
|
||||
props.duration && progress !== null
|
||||
? (progress * 100) /
|
||||
(props.duration == 0
|
||||
(props.duration === 0
|
||||
? progress
|
||||
: props.duration)
|
||||
: 0
|
||||
|
||||
@@ -24,18 +24,19 @@ import {
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { LegacyRef, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import API from "../../api/api";
|
||||
import { RootState } from "../../state/store";
|
||||
import { ExpandedPlayerControls, MinimizedPlayerControls } from "./controls";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { DrawerBreakpoint } from "../scaffold/scaffold";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useReadLocalStorage } from "usehooks-ts";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import Hls from "hls.js";
|
||||
import { type LegacyRef, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useReadLocalStorage } from "usehooks-ts";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import API from "../../api/api";
|
||||
import { useKeyboardBinding } from "../../contexts/keybindings";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import type { RootState } from "../../state/store";
|
||||
import { DrawerBreakpoint } from "../scaffold/scaffold";
|
||||
import { ExpandedPlayerControls, MinimizedPlayerControls } from "./controls";
|
||||
|
||||
const Player = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -74,11 +75,11 @@ const Player = () => {
|
||||
useReadLocalStorage("allow_notifs");
|
||||
const play = () => {
|
||||
// Do nothing if empty playlist
|
||||
if (playlist.length == 0) {
|
||||
if (playlist.length === 0) {
|
||||
return;
|
||||
}
|
||||
// If playlist but cursor to -1
|
||||
if (currentTrack == undefined) {
|
||||
if (currentTrack === undefined) {
|
||||
skipTrack();
|
||||
}
|
||||
player.current?.play();
|
||||
@@ -100,7 +101,7 @@ const Player = () => {
|
||||
return;
|
||||
}
|
||||
// If first track, disable player
|
||||
if (cursor == 0) {
|
||||
if (cursor === 0) {
|
||||
pause();
|
||||
}
|
||||
playPreviousTrack();
|
||||
@@ -120,12 +121,14 @@ const Player = () => {
|
||||
progress.current = player.current!.currentTime;
|
||||
};
|
||||
player.current!.onended = () => {
|
||||
API.setSongAsPlayed(currentTrack.track.songId);
|
||||
if (currentTrack.track.songId) {
|
||||
API.setSongAsPlayed(currentTrack.track.songId);
|
||||
}
|
||||
progress.current = null;
|
||||
skipTrack();
|
||||
};
|
||||
player.current!.onpause = () => {
|
||||
if (player.current!.ended == false) {
|
||||
if (player.current!.ended === false) {
|
||||
setPlaying(false);
|
||||
}
|
||||
};
|
||||
@@ -141,7 +144,7 @@ const Player = () => {
|
||||
// Sometimes, an error can be caused by a track change while the `play` promise is being resolved
|
||||
// But this does not seem to cause any malfunction
|
||||
// That's why we do that filtering
|
||||
const errcode = err["code"];
|
||||
const errcode = err.code;
|
||||
|
||||
if (!errcode) {
|
||||
return;
|
||||
@@ -156,7 +159,7 @@ const Player = () => {
|
||||
} else {
|
||||
setUseTranscoding(true);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
// biome-ignore lint/suspicious/noConsole: For debug
|
||||
console.error(err);
|
||||
break;
|
||||
case 19: // Network error
|
||||
@@ -182,6 +185,14 @@ const Player = () => {
|
||||
window.removeEventListener("blur", onBlur);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
window.onbeforeunload = () => {
|
||||
if (playing) {
|
||||
return t("leaveWillStopPlaybackAlert");
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
}, [playing]);
|
||||
useEffect(() => {
|
||||
if (useTranscoding && currentTrack) {
|
||||
if (!Hls.isSupported()) {
|
||||
@@ -199,10 +210,10 @@ const Player = () => {
|
||||
hls.current!.loadSource(streamURL);
|
||||
hls.current!.attachMedia(player.current);
|
||||
hls.current!.on(Hls.Events.ERROR, (err, data) => {
|
||||
// eslint-disable-next-line no-console
|
||||
// biome-ignore lint/suspicious/noConsole: For debug
|
||||
console.error(err, data);
|
||||
});
|
||||
hls.current!.on(Hls.Events.MEDIA_ATTACHED, function () {
|
||||
hls.current!.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
startPlayback(true);
|
||||
});
|
||||
}
|
||||
@@ -212,8 +223,40 @@ const Player = () => {
|
||||
pause();
|
||||
playTracks({ tracks: [] });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userIsAuthentified]);
|
||||
useKeyboardBinding(
|
||||
{
|
||||
key: "p",
|
||||
description: "openClosePlayer",
|
||||
handler: () => setExpanded((v) => !v),
|
||||
},
|
||||
[expanded],
|
||||
);
|
||||
useKeyboardBinding(
|
||||
{
|
||||
key: "esc",
|
||||
description: "closePlayer",
|
||||
handler: () => setExpanded(false),
|
||||
},
|
||||
[expanded],
|
||||
);
|
||||
useKeyboardBinding(
|
||||
{
|
||||
key: "space",
|
||||
description: "playPauseWhenExpanded",
|
||||
handler: () => {
|
||||
if (!expanded) {
|
||||
return;
|
||||
}
|
||||
if (playing) {
|
||||
pause();
|
||||
} else {
|
||||
play();
|
||||
}
|
||||
},
|
||||
},
|
||||
[expanded, playing, pause, play],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (player.current) {
|
||||
player.current.onpause = null;
|
||||
@@ -238,7 +281,7 @@ const Player = () => {
|
||||
setDuration(currentTrack.track.duration ?? undefined);
|
||||
const newIllustrationURL = currentTrack.track.illustration?.url;
|
||||
|
||||
if (currentTrack.track.type == "Audio") {
|
||||
if (currentTrack.track.type === "Audio") {
|
||||
player.current = audioPlayer.current ?? undefined;
|
||||
} else {
|
||||
player.current = videoPlayer.current;
|
||||
@@ -265,7 +308,7 @@ const Player = () => {
|
||||
if (
|
||||
typeof Notification !== "undefined" &&
|
||||
!windowFocused &&
|
||||
Notification.permission == "granted" &&
|
||||
Notification.permission === "granted" &&
|
||||
allowNotifications
|
||||
) {
|
||||
try {
|
||||
@@ -277,7 +320,6 @@ const Player = () => {
|
||||
body: currentTrack.artist.name,
|
||||
}),
|
||||
);
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {}
|
||||
}
|
||||
} else {
|
||||
@@ -288,7 +330,6 @@ const Player = () => {
|
||||
setDuration(undefined);
|
||||
setPlaying(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentTrack]);
|
||||
const playerBgColor = useMemo(() => {
|
||||
const themePaperColor = `rgba(${theme.vars.palette.background.defaultChannel} / 0.75)`;
|
||||
@@ -325,7 +366,7 @@ const Player = () => {
|
||||
return (
|
||||
<>
|
||||
<Grow
|
||||
in={playlist.length != 0 || player.current != undefined}
|
||||
in={playlist.length !== 0 || player.current !== undefined}
|
||||
unmountOnExit
|
||||
>
|
||||
<Box sx={{ height: 58 }} />
|
||||
@@ -339,7 +380,7 @@ const Player = () => {
|
||||
direction="up"
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
in={playlist.length != 0 || player.current != undefined}
|
||||
in={playlist.length !== 0 || player.current !== undefined}
|
||||
>
|
||||
<Box sx={{ padding: 1, zIndex: "modal", width: "100%" }}>
|
||||
<Paper
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { ArtistWithRelations } from "../../models/artist";
|
||||
import type { ArtistWithRelations } from "../../models/artist";
|
||||
import ArtistAvatar from "../artist-avatar";
|
||||
import ArtistContextualMenu from "../contextual-menu/artist-contextual-menu";
|
||||
import RelationPageHeader from "./relation-page-header";
|
||||
import ArtistAvatar from "../artist-avatar";
|
||||
|
||||
type ArtistRelationPageHeaderProps = {
|
||||
artist: ArtistWithRelations<"illustration"> | undefined;
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { RequireExactlyOne } from "type-fest";
|
||||
import { SongWithRelations } from "../../models/song";
|
||||
import type { RequireExactlyOne } from "type-fest";
|
||||
import type { SongWithRelations } from "../../models/song";
|
||||
import formatArtists from "../../utils/formatArtists";
|
||||
import SongContextualMenu from "../contextual-menu/song-contextual-menu";
|
||||
import Illustration from "../illustration";
|
||||
import RelationPageHeader from "./relation-page-header";
|
||||
import formatArtists from "../../utils/formatArtists";
|
||||
|
||||
type SongRelationPageHeaderProps = RequireExactlyOne<{
|
||||
song:
|
||||
|
||||
@@ -31,30 +31,31 @@ import {
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import Release from "../models/release";
|
||||
import { TrackWithRelations } from "../models/track";
|
||||
import Tracklist from "../models/tracklist";
|
||||
import Artist from "../models/artist";
|
||||
import formatDuration from "../utils/formatDuration";
|
||||
import ReleaseTrackContextualMenu from "./contextual-menu/release-track-contextual-menu";
|
||||
import { SongWithRelations } from "../models/song";
|
||||
import { ContextualMenuIcon, VideoIcon } from "./icons";
|
||||
import formatArtists from "../utils/formatArtists";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { generateArray } from "../utils/gen-list";
|
||||
import { TrackState, usePlayerContext } from "../contexts/player";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Audio } from "react-loader-spinner";
|
||||
import type { RequireAtLeastOne } from "type-fest";
|
||||
import { type TrackState, usePlayerContext } from "../contexts/player";
|
||||
import type Artist from "../models/artist";
|
||||
import type Release from "../models/release";
|
||||
import type { SongWithRelations } from "../models/song";
|
||||
import type { TrackWithRelations } from "../models/track";
|
||||
import type Tracklist from "../models/tracklist";
|
||||
import type { VideoWithRelations } from "../models/video";
|
||||
import formatArtists from "../utils/formatArtists";
|
||||
import formatDuration from "../utils/formatDuration";
|
||||
import { generateArray } from "../utils/gen-list";
|
||||
import ReleaseTrackContextualMenu from "./contextual-menu/release-track-contextual-menu";
|
||||
import { ContextualMenuIcon, VideoIcon } from "./icons";
|
||||
|
||||
type TrackType = TrackWithRelations<"illustration"> &
|
||||
RequireAtLeastOne<{
|
||||
song: SongWithRelations<"artist" | "featuring">;
|
||||
video: VideoWithRelations<"artist">;
|
||||
}>;
|
||||
type ReleaseTracklistProps = {
|
||||
mainArtist: Artist | undefined | null;
|
||||
tracklist:
|
||||
| Tracklist<
|
||||
TrackWithRelations<"illustration"> & {
|
||||
song: SongWithRelations<"artist" | "featuring">;
|
||||
}
|
||||
>
|
||||
| undefined;
|
||||
tracklist: Tracklist<TrackType> | undefined;
|
||||
release: Release | undefined;
|
||||
};
|
||||
|
||||
@@ -84,10 +85,12 @@ const ReleaseTrackList = ({
|
||||
const flatTracklist = tracklist
|
||||
? Array.from(Object.values(tracklist)).flat()
|
||||
: undefined;
|
||||
const formatTracksubtitle = (
|
||||
song: SongWithRelations<"artist" | "featuring">,
|
||||
) => {
|
||||
if (song.artistId === mainArtist?.id && song.featuring?.length === 0) {
|
||||
const formatTracksubtitle = (song: {
|
||||
artistId: number;
|
||||
artist: Artist;
|
||||
featuring?: Artist[];
|
||||
}) => {
|
||||
if (song.artistId === mainArtist?.id && !song.featuring?.length) {
|
||||
return undefined;
|
||||
}
|
||||
return formatArtists(song.artist, song.featuring);
|
||||
@@ -113,169 +116,181 @@ const ReleaseTrackList = ({
|
||||
)
|
||||
}
|
||||
>
|
||||
{(
|
||||
disc[1] as (
|
||||
| (TrackWithRelations<"illustration"> & {
|
||||
song: SongWithRelations<
|
||||
"artist" | "featuring"
|
||||
>;
|
||||
})
|
||||
| undefined
|
||||
)[]
|
||||
).map((currentTrack, index) => (
|
||||
<Fragment key={index}>
|
||||
<ListItem
|
||||
dense={
|
||||
mainArtist === undefined
|
||||
? false
|
||||
: currentTrack
|
||||
? currentTrack.song.artistId !=
|
||||
mainArtist?.id
|
||||
: false
|
||||
}
|
||||
disablePadding
|
||||
disableGutters
|
||||
secondaryAction={
|
||||
currentTrack ? (
|
||||
<ReleaseTrackContextualMenu
|
||||
track={currentTrack}
|
||||
artist={currentTrack.song.artist}
|
||||
/>
|
||||
) : (
|
||||
<IconButton disabled>
|
||||
<ContextualMenuIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
>
|
||||
<ListItemButton
|
||||
onClick={
|
||||
flatTracklist &&
|
||||
currentTrack &&
|
||||
release &&
|
||||
(() =>
|
||||
playTracks({
|
||||
tracks: flatTracklist.map(
|
||||
(flatTrack) => ({
|
||||
track: flatTrack,
|
||||
release,
|
||||
artist: flatTrack.song
|
||||
.artist,
|
||||
}),
|
||||
),
|
||||
cursor: flatTracklist.findIndex(
|
||||
(flatTrack) =>
|
||||
flatTrack.id ==
|
||||
currentTrack.id,
|
||||
),
|
||||
}))
|
||||
{(disc[1] as (TrackType | undefined)[]).map(
|
||||
(currentTrack, index) => (
|
||||
<Fragment key={index}>
|
||||
<ListItem
|
||||
dense={
|
||||
mainArtist === undefined
|
||||
? false
|
||||
: currentTrack
|
||||
? (currentTrack.song ??
|
||||
currentTrack.video)!
|
||||
.artistId !==
|
||||
mainArtist?.id
|
||||
: false
|
||||
}
|
||||
sx={{
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{currentTrack ? (
|
||||
currentTrack.id ===
|
||||
currentlyPlayingTrack?.track.id ? (
|
||||
<PlayingIcon />
|
||||
) : (
|
||||
<Typography color="text.disabled">
|
||||
{currentTrack.trackIndex}
|
||||
</Typography>
|
||||
)
|
||||
) : (
|
||||
<Skeleton
|
||||
width="20px"
|
||||
sx={{ color: "text.disabled" }}
|
||||
/>
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
currentTrack?.name ?? (
|
||||
<Skeleton width="120px" />
|
||||
)
|
||||
}
|
||||
primaryTypographyProps={{
|
||||
fontSize: "medium",
|
||||
}}
|
||||
secondary={
|
||||
mainArtist === undefined
|
||||
? null
|
||||
: currentTrack
|
||||
? formatTracksubtitle(
|
||||
currentTrack.song,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
secondaryTypographyProps={{
|
||||
fontSize: "small",
|
||||
color: "text.disabled",
|
||||
}}
|
||||
/>
|
||||
{currentTrack?.isBonus && (
|
||||
<Typography
|
||||
sx={{
|
||||
display: {
|
||||
xs: "none",
|
||||
sm: "block",
|
||||
},
|
||||
}}
|
||||
color="text.disabled"
|
||||
>
|
||||
{t("bonusTrack")}
|
||||
</Typography>
|
||||
)}
|
||||
{currentTrack?.isRemastered && (
|
||||
<Typography
|
||||
color="text.disabled"
|
||||
sx={{
|
||||
display: {
|
||||
xs: "none",
|
||||
sm: "block",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t("remastered")}
|
||||
</Typography>
|
||||
)}
|
||||
{currentTrack?.type == "Video" && (
|
||||
<Icon
|
||||
sx={{
|
||||
marginLeft: 2,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<VideoIcon
|
||||
color={
|
||||
theme.palette.text.disabled
|
||||
disablePadding
|
||||
disableGutters
|
||||
secondaryAction={
|
||||
currentTrack ? (
|
||||
<ReleaseTrackContextualMenu
|
||||
track={{
|
||||
...currentTrack,
|
||||
song:
|
||||
currentTrack.song ??
|
||||
null,
|
||||
}}
|
||||
artist={
|
||||
(currentTrack.song ??
|
||||
currentTrack.video)!
|
||||
.artist
|
||||
}
|
||||
/>
|
||||
</Icon>
|
||||
)}
|
||||
<Typography
|
||||
color="text.disabled"
|
||||
) : (
|
||||
<IconButton disabled>
|
||||
<ContextualMenuIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
>
|
||||
<ListItemButton
|
||||
onClick={
|
||||
flatTracklist &&
|
||||
currentTrack &&
|
||||
release &&
|
||||
(() =>
|
||||
playTracks({
|
||||
tracks: flatTracklist.map(
|
||||
(flatTrack) => ({
|
||||
track: flatTrack,
|
||||
release,
|
||||
artist: (flatTrack.song ??
|
||||
flatTrack.video)!
|
||||
.artist,
|
||||
}),
|
||||
),
|
||||
cursor: flatTracklist.findIndex(
|
||||
(flatTrack) =>
|
||||
flatTrack.id ===
|
||||
currentTrack.id,
|
||||
),
|
||||
}))
|
||||
}
|
||||
sx={{
|
||||
marginLeft: 2,
|
||||
overflow: "unset",
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
}}
|
||||
>
|
||||
{currentTrack ? (
|
||||
formatDuration(
|
||||
currentTrack.duration,
|
||||
)
|
||||
) : (
|
||||
<Skeleton width="30px" />
|
||||
<ListItemIcon>
|
||||
{currentTrack ? (
|
||||
currentTrack.id ===
|
||||
currentlyPlayingTrack?.track
|
||||
.id ? (
|
||||
<PlayingIcon />
|
||||
) : (
|
||||
<Typography color="text.disabled">
|
||||
{
|
||||
currentTrack.trackIndex
|
||||
}
|
||||
</Typography>
|
||||
)
|
||||
) : (
|
||||
<Skeleton
|
||||
width="20px"
|
||||
sx={{
|
||||
color: "text.disabled",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
currentTrack?.name ?? (
|
||||
<Skeleton width="120px" />
|
||||
)
|
||||
}
|
||||
primaryTypographyProps={{
|
||||
fontSize: "medium",
|
||||
}}
|
||||
secondary={
|
||||
mainArtist === undefined
|
||||
? null
|
||||
: currentTrack
|
||||
? formatTracksubtitle(
|
||||
(currentTrack.song ??
|
||||
currentTrack.video)!,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
secondaryTypographyProps={{
|
||||
fontSize: "small",
|
||||
color: "text.disabled",
|
||||
}}
|
||||
/>
|
||||
{currentTrack?.isBonus && (
|
||||
<Typography
|
||||
sx={{
|
||||
display: {
|
||||
xs: "none",
|
||||
sm: "block",
|
||||
},
|
||||
}}
|
||||
color="text.disabled"
|
||||
>
|
||||
{t("bonusTrack")}
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
<Divider variant="inset" />
|
||||
</Fragment>
|
||||
))}
|
||||
{currentTrack?.isRemastered && (
|
||||
<Typography
|
||||
color="text.disabled"
|
||||
sx={{
|
||||
display: {
|
||||
xs: "none",
|
||||
sm: "block",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t("remastered")}
|
||||
</Typography>
|
||||
)}
|
||||
{currentTrack?.type === "Video" && (
|
||||
<Icon
|
||||
sx={{
|
||||
marginLeft: 2,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<VideoIcon
|
||||
color={
|
||||
theme.palette.text
|
||||
.disabled
|
||||
}
|
||||
/>
|
||||
</Icon>
|
||||
)}
|
||||
<Typography
|
||||
color="text.disabled"
|
||||
sx={{
|
||||
marginLeft: 2,
|
||||
overflow: "unset",
|
||||
}}
|
||||
>
|
||||
{currentTrack ? (
|
||||
formatDuration(
|
||||
currentTrack.duration,
|
||||
)
|
||||
) : (
|
||||
<Skeleton width="30px" />
|
||||
)}
|
||||
</Typography>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
<Divider variant="inset" />
|
||||
</Fragment>
|
||||
),
|
||||
)}
|
||||
</List>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -17,17 +17,16 @@
|
||||
*/
|
||||
|
||||
import { Box, Link, Skeleton } from "@mui/material";
|
||||
import ExternalId from "../models/external-id";
|
||||
import { useState } from "react";
|
||||
import { capitalCase } from "change-case";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { CommonExternalMetadata } from "../models/external-metadata";
|
||||
import { generateArray } from "../utils/gen-list";
|
||||
|
||||
type Props = {
|
||||
externalDescription: ExternalId | undefined;
|
||||
externalMetadata: CommonExternalMetadata | undefined;
|
||||
};
|
||||
|
||||
const ResourceDescriptionExpandable = ({ externalDescription }: Props) => {
|
||||
const ResourceDescriptionExpandable = ({ externalMetadata }: Props) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const maxLine = 5;
|
||||
const smallBoxStyle = {
|
||||
@@ -42,27 +41,13 @@ const ResourceDescriptionExpandable = ({ externalDescription }: Props) => {
|
||||
|
||||
return (
|
||||
<Box id="description" sx={isExpanded ? bigBoxStyle : smallBoxStyle}>
|
||||
{externalDescription
|
||||
? externalDescription.description
|
||||
: generateArray(5).map((_, index) => <Skeleton key={index} />)}
|
||||
{isExpanded && (
|
||||
<>
|
||||
{" Source: "}
|
||||
<Link
|
||||
href={externalDescription?.url ?? undefined}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{externalDescription ? (
|
||||
capitalCase(externalDescription?.provider.name)
|
||||
) : (
|
||||
<Skeleton width={50} />
|
||||
)}
|
||||
</Link>
|
||||
{"."}
|
||||
</>
|
||||
)}{" "}
|
||||
{externalDescription && (
|
||||
{externalMetadata?.description ||
|
||||
(externalMetadata?.description === undefined &&
|
||||
generateArray(5).map((_, index) => (
|
||||
<Skeleton key={index} />
|
||||
)))}
|
||||
|
||||
{externalMetadata?.description && (
|
||||
<Link
|
||||
href="#description"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Action from "../actions/action";
|
||||
import { GoToSearchAction, GoToSettingsAction } from "../actions/link";
|
||||
import type Action from "../actions/action";
|
||||
import { LogoutAction } from "../actions/auth";
|
||||
import { GoToSearchAction, GoToSettingsAction } from "../actions/link";
|
||||
|
||||
/**
|
||||
* Collections of actions that are accessible from appbar and drawer
|
||||
|
||||
@@ -31,7 +31,14 @@ import {
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import type {} from "@mui/material/themeCssVarsAugmentation";
|
||||
import { deepmerge } from "@mui/utils";
|
||||
import type { IconProps } from "iconsax-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RootGradientBackground } from "../../utils/gradient-background";
|
||||
import {
|
||||
AlbumIcon,
|
||||
ArtistIcon,
|
||||
@@ -40,17 +47,10 @@ import {
|
||||
SongIcon,
|
||||
VideoIcon,
|
||||
} from "../icons";
|
||||
import Link from "next/link";
|
||||
import Player from "../player/player";
|
||||
import { useRouter } from "next/router";
|
||||
import { IconProps } from "iconsax-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import scaffoldActions from "./actions";
|
||||
import type {} from "@mui/material/themeCssVarsAugmentation";
|
||||
import ThemedImage from "../themed-image";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RootGradientBackground } from "../../utils/gradient-background";
|
||||
import { parentScrollableDivId } from "../infinite/infinite-scroll";
|
||||
import Player from "../player/player";
|
||||
import ThemedImage from "../themed-image";
|
||||
import scaffoldActions from "./actions";
|
||||
|
||||
/**
|
||||
* Array of possible item types
|
||||
@@ -172,7 +172,7 @@ const Drawer = ({
|
||||
{primaryItems.map((item) => {
|
||||
const path = `/${item}`;
|
||||
const isSelected =
|
||||
path == router.asPath.split("?")[0];
|
||||
path === router.asPath.split("?")[0];
|
||||
const Icon = (props: IconProps) =>
|
||||
getPrimaryTypeIcon(item, props);
|
||||
|
||||
@@ -222,7 +222,7 @@ const Drawer = ({
|
||||
let item = (
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
action.onClick && action.onClick();
|
||||
action.onClick?.();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
@@ -241,7 +241,7 @@ const Drawer = ({
|
||||
{t(action.label)}
|
||||
</Typography>
|
||||
}
|
||||
></ListItemText>
|
||||
/>
|
||||
</ListItemButton>
|
||||
);
|
||||
|
||||
@@ -294,34 +294,25 @@ const BottomNavigation = (props: { onDrawerOpen: () => void }) => {
|
||||
>
|
||||
{primaryItems.slice(0, 3).map((item) => {
|
||||
const path = `/${item}`;
|
||||
const isSelected = path == router.asPath.split("?")[0];
|
||||
const isSelected = path === router.asPath.split("?")[0];
|
||||
const Icon = (pr: IconProps) => getPrimaryTypeIcon(item, pr);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={path}
|
||||
<BottomNavigationAction
|
||||
href={path}
|
||||
LinkComponent={Link}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<BottomNavigationAction
|
||||
key={item}
|
||||
showLabel
|
||||
icon={
|
||||
<Icon
|
||||
variant={isSelected ? "Bold" : "Outline"}
|
||||
/>
|
||||
}
|
||||
label={t(item)}
|
||||
/>
|
||||
</Link>
|
||||
key={item}
|
||||
icon={
|
||||
<Icon variant={isSelected ? "Bold" : "Outline"} />
|
||||
}
|
||||
label={t(item)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<BottomNavigationAction
|
||||
showLabel
|
||||
sx={{ flex: 1 }}
|
||||
icon={<BurgerIcon />}
|
||||
onClick={props.onDrawerOpen}
|
||||
|
||||
Loaded 100 of 557 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user