Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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:
|
||||
- "*"
|
||||
- "*"
|
||||
@@ -1,9 +1,9 @@
|
||||
name: "Front"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main, v2 ]
|
||||
branches: [ main, v3 ]
|
||||
push:
|
||||
branches: [ main, v2 ]
|
||||
branches: [ main, v3 ]
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
@@ -169,4 +169,4 @@ jobs:
|
||||
context: ./front
|
||||
push: true
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-front:edge' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -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@v3
|
||||
with:
|
||||
name: coverage-report-scanner
|
||||
path: ./scanner/coverage.out
|
||||
Analysis:
|
||||
needs: [ Tests ]
|
||||
name: "Static Analysis"
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && always() }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Downlad Coverage artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: coverage-report-scanner
|
||||
path: ./scanner
|
||||
- name: Run SonarCloud Scan
|
||||
uses: SonarSource/sonarcloud-github-action@master
|
||||
with:
|
||||
projectBaseDir: ./scanner
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_SCANNER }}
|
||||
Dockerize:
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
|
||||
needs: [ Build ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./scanner
|
||||
push: false
|
||||
Publish:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
arthichaud/meelo-scanner
|
||||
ghcr.io/${{ github.repository }}-scanner
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./scanner
|
||||
push: true
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-scanner:edge' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -1,9 +1,9 @@
|
||||
name: "Server"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main, v2 ]
|
||||
branches: [ main, v3 ]
|
||||
push:
|
||||
branches: [ main, v2 ]
|
||||
branches: [ main, v3 ]
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
@@ -77,7 +77,6 @@ jobs:
|
||||
if: ${{ needs.changes.outputs.server == 'true' && always() }}
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
REDIS_HOST: "redis"
|
||||
DATABASE_URL: "postgresql://test:test@postgres:5432/test?schema=public"
|
||||
JWT_SIGNATURE: "12345"
|
||||
GENIUS_ACCESS_TOKEN: "${{ secrets.GENIUS_ACCESS_TOKEN }}"
|
||||
@@ -97,15 +96,6 @@ jobs:
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7.0-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.5
|
||||
ports:
|
||||
@@ -208,4 +198,4 @@ jobs:
|
||||
context: ./server
|
||||
push: true
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-server:edge' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -1,65 +1,82 @@
|
||||

|
||||
# <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>
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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 |
+84
-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
|
||||
@@ -76,10 +125,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 +146,22 @@ services:
|
||||
retries: 5
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
mq:
|
||||
image: rabbitmq:4.0-alpine
|
||||
environment:
|
||||
- RABBITMQ_NODENAME=meelo
|
||||
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
|
||||
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
|
||||
healthcheck:
|
||||
test: rabbitmq-diagnostics -q ping
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
volumes:
|
||||
search:
|
||||
transcoder_cache:
|
||||
transcoder_cache:
|
||||
front_deps:
|
||||
server_deps:
|
||||
rabbitmq_data:
|
||||
+66
-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:
|
||||
@@ -76,10 +109,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
|
||||
@@ -102,3 +156,4 @@ volumes:
|
||||
data:
|
||||
search:
|
||||
transcoder_cache:
|
||||
rabbitmq_data:
|
||||
+66
-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
|
||||
@@ -82,6 +125,19 @@ services:
|
||||
test: ["CMD", "wget", "--no-verbose", "--spider", "http://0.0.0.0:7700/health"]
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
mq:
|
||||
image: rabbitmq:4.0-alpine
|
||||
environment:
|
||||
- RABBITMQ_NODENAME=meelo
|
||||
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
|
||||
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
|
||||
healthcheck:
|
||||
test: rabbitmq-diagnostics -q ping
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
nginx:
|
||||
restart: on-failure
|
||||
image: nginx:1.24.0-alpine
|
||||
@@ -96,6 +152,7 @@ services:
|
||||
- PORT=5000
|
||||
- FRONT_URL=http://front:3000
|
||||
- SERVER_URL=http://server:4000
|
||||
- SCANNER_URL=http://scanner:8133
|
||||
volumes:
|
||||
- ./nginx.conf.template:/etc/nginx/templates/meelo.conf.template:ro
|
||||
volumes:
|
||||
@@ -104,3 +161,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
|
||||
|
||||
|
||||
@@ -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*",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+248
-112
@@ -41,7 +41,12 @@ import {
|
||||
SongType,
|
||||
SongWithRelations,
|
||||
} from "../models/song";
|
||||
import { VideoWithRelations } from "../models/video";
|
||||
import {
|
||||
VideoInclude,
|
||||
VideoSortingKeys,
|
||||
VideoType,
|
||||
VideoWithRelations,
|
||||
} from "../models/video";
|
||||
import {
|
||||
TrackInclude,
|
||||
TrackSortingKeys,
|
||||
@@ -50,7 +55,6 @@ import {
|
||||
} from "../models/track";
|
||||
import { TracklistItemWithRelations } from "../models/tracklist";
|
||||
import { SortingParameters } from "../utils/sorting";
|
||||
import LibraryTaskResponse from "../models/library-task-response";
|
||||
import { ResourceNotFound } from "../exceptions";
|
||||
import User, { UserSortingKeys } from "../models/user";
|
||||
import store from "../state/store";
|
||||
@@ -65,8 +69,21 @@ import Playlist, {
|
||||
PlaylistWithRelations,
|
||||
} from "../models/playlist";
|
||||
import { isSSR } from "../utils/is-ssr";
|
||||
import { ActiveTask, Task } from "../models/task";
|
||||
import { SearchResult, SearchResultTransformer } from "../models/search";
|
||||
import { TaskResponse } from "../models/task";
|
||||
import {
|
||||
AlbumExternalMetadata,
|
||||
ArtistExternalMetadata,
|
||||
SongExternalMetadata,
|
||||
} from "../models/external-metadata";
|
||||
import {
|
||||
SaveSearchItem,
|
||||
SearchResult,
|
||||
SearchResultTransformer,
|
||||
} from "../models/search";
|
||||
import {
|
||||
SongGroupSortingKeys,
|
||||
SongGroupWithRelations,
|
||||
} from "../models/song-group";
|
||||
|
||||
const AuthenticationResponse = yup.object({
|
||||
access_token: yup.string().required(),
|
||||
@@ -74,8 +91,6 @@ const AuthenticationResponse = yup.object({
|
||||
|
||||
type Identifier = number | string;
|
||||
|
||||
type NullableIdentifier = Identifier | null;
|
||||
|
||||
type AuthenticationResponse = yup.InferType<typeof AuthenticationResponse>;
|
||||
|
||||
type QueryParameters<Keys extends readonly string[]> = {
|
||||
@@ -89,11 +104,18 @@ type AuthenticationInput = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-shadow
|
||||
enum Service {
|
||||
API,
|
||||
Scanner,
|
||||
}
|
||||
|
||||
type FetchParameters<Keys extends readonly string[], ReturnType> = {
|
||||
route: string;
|
||||
parameters: QueryParameters<Keys>;
|
||||
otherParameters?: any;
|
||||
errorMessage?: string;
|
||||
service?: Service;
|
||||
data?: Record<string, any>;
|
||||
method?: "GET" | "PUT" | "POST" | "DELETE";
|
||||
} & RequireExactlyOne<{
|
||||
@@ -123,6 +145,8 @@ export default class API {
|
||||
private static isDev = () => process.env.NODE_ENV === "development";
|
||||
private static SSR_API_URL =
|
||||
process.env.SSR_SERVER_URL ?? process.env.PUBLIC_SERVER_URL!;
|
||||
private static SSR_SCANNER_URL =
|
||||
process.env.SSR_SCANNER_URL ?? process.env.PUBLIC_SCANNER_URL!;
|
||||
static defaultPageSize = 35;
|
||||
|
||||
/**
|
||||
@@ -149,7 +173,7 @@ export default class API {
|
||||
*/
|
||||
static async register(credentials: AuthenticationInput): Promise<User> {
|
||||
return API.fetch({
|
||||
route: "/users/new",
|
||||
route: "/users",
|
||||
data: {
|
||||
name: credentials.username,
|
||||
password: credentials.password,
|
||||
@@ -172,9 +196,12 @@ export default class API {
|
||||
route: `/tasks`,
|
||||
errorMessage: "Tasks could not be loaded",
|
||||
parameters: {},
|
||||
service: Service.Scanner,
|
||||
validator: yup.object({
|
||||
active: ActiveTask.required().nullable(),
|
||||
pending: yup.array(Task).required(),
|
||||
current_task: yup.string().required().nullable(),
|
||||
pending_tasks: yup
|
||||
.array(yup.string().required())
|
||||
.required(),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
@@ -228,7 +255,7 @@ export default class API {
|
||||
|
||||
static async createPlaylist(playlistName: string): Promise<Playlist> {
|
||||
return API.fetch({
|
||||
route: "/playlists/new",
|
||||
route: "/playlists",
|
||||
data: { name: playlistName },
|
||||
errorMessage: "Playlist Creation Failed",
|
||||
parameters: {},
|
||||
@@ -255,7 +282,7 @@ export default class API {
|
||||
entriesIds: number[],
|
||||
): Promise<void> {
|
||||
return API.fetch({
|
||||
route: `/playlists/${playlistSlugOrId}/reorder`,
|
||||
route: `/playlists/${playlistSlugOrId}/entries/reorder`,
|
||||
data: { entryIds: entriesIds },
|
||||
parameters: {},
|
||||
method: "PUT",
|
||||
@@ -318,25 +345,14 @@ export default class API {
|
||||
* Calls for a task to clean all libraries
|
||||
* @returns A object with the status of the task
|
||||
*/
|
||||
static async cleanLibraries(): Promise<LibraryTaskResponse> {
|
||||
static async cleanLibraries(): Promise<TaskResponse> {
|
||||
return API.fetch({
|
||||
route: `/tasks/clean`,
|
||||
route: `/clean`,
|
||||
errorMessage: "Library clean failed",
|
||||
parameters: {},
|
||||
validator: LibraryTaskResponse,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls for a task to fetch external metadata
|
||||
* @returns A object with the status of the task
|
||||
*/
|
||||
static async fetchExternalMetadata(): Promise<LibraryTaskResponse> {
|
||||
return API.fetch({
|
||||
route: `/tasks/fetch-external-metadata`,
|
||||
errorMessage: "Fetch failed",
|
||||
parameters: {},
|
||||
validator: LibraryTaskResponse,
|
||||
service: Service.Scanner,
|
||||
method: "POST",
|
||||
validator: TaskResponse,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -346,12 +362,14 @@ export default class API {
|
||||
*/
|
||||
static async scanLibrary(
|
||||
librarySlugOrId: number | string,
|
||||
): Promise<LibraryTaskResponse> {
|
||||
): Promise<TaskResponse> {
|
||||
return API.fetch({
|
||||
route: `/tasks/scan/${librarySlugOrId}`,
|
||||
route: `/scan/${librarySlugOrId}`,
|
||||
errorMessage: "Library scan failed",
|
||||
parameters: {},
|
||||
validator: LibraryTaskResponse,
|
||||
service: Service.Scanner,
|
||||
method: "POST",
|
||||
validator: TaskResponse,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -361,12 +379,14 @@ export default class API {
|
||||
*/
|
||||
static async cleanLibrary(
|
||||
librarySlugOrId: number | string,
|
||||
): Promise<LibraryTaskResponse> {
|
||||
): Promise<TaskResponse> {
|
||||
return API.fetch({
|
||||
route: `/tasks/clean/${librarySlugOrId}`,
|
||||
route: `/clean/${librarySlugOrId}`,
|
||||
errorMessage: "Library clean failed",
|
||||
parameters: {},
|
||||
validator: LibraryTaskResponse,
|
||||
service: Service.Scanner,
|
||||
method: "POST",
|
||||
validator: TaskResponse,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -374,11 +394,13 @@ export default class API {
|
||||
* Calls for a task to scan all libraries
|
||||
* @returns the status of the task
|
||||
*/
|
||||
static scanLibraries(): Promise<LibraryTaskResponse> {
|
||||
return API.fetch<LibraryTaskResponse, []>({
|
||||
route: `/tasks/scan`,
|
||||
static scanLibraries(): Promise<TaskResponse> {
|
||||
return API.fetch<TaskResponse, []>({
|
||||
route: `/scan`,
|
||||
parameters: {},
|
||||
validator: LibraryTaskResponse,
|
||||
service: Service.Scanner,
|
||||
method: "POST",
|
||||
validator: TaskResponse,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -391,10 +413,12 @@ export default class API {
|
||||
resourceSlugOrId: number | string,
|
||||
): Promise<void> {
|
||||
return API.fetch({
|
||||
route: `/tasks/refresh-metadata`,
|
||||
errorMessage: "Library refresh failed",
|
||||
method: "POST",
|
||||
route: `/refresh`,
|
||||
errorMessage: "Metadata Refresh request failed",
|
||||
otherParameters: { [parentResourceType]: resourceSlugOrId },
|
||||
parameters: {},
|
||||
service: Service.Scanner,
|
||||
emptyResponse: true,
|
||||
});
|
||||
}
|
||||
@@ -404,7 +428,7 @@ export default class API {
|
||||
libraryPath: string,
|
||||
): Promise<Library> {
|
||||
return API.fetch({
|
||||
route: "/libraries/new",
|
||||
route: "/libraries",
|
||||
data: { name: libraryName, path: libraryPath },
|
||||
errorMessage: "Library Creation Failed",
|
||||
parameters: {},
|
||||
@@ -448,13 +472,13 @@ export default class API {
|
||||
* Update Artist Illustration
|
||||
*/
|
||||
static async updateArtistIllustration(
|
||||
artistSlugOrId: number | string,
|
||||
artistId: number,
|
||||
illustrationUrl: string,
|
||||
): Promise<unknown> {
|
||||
return API.updateResourceIllustration(
|
||||
artistSlugOrId,
|
||||
artistId,
|
||||
illustrationUrl,
|
||||
"artists",
|
||||
"artist",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -462,13 +486,13 @@ export default class API {
|
||||
* Update Release Illustration
|
||||
*/
|
||||
static async updateReleaseIllustration(
|
||||
releaseSlugOrId: number | string,
|
||||
releaseId: number,
|
||||
illustrationUrl: string,
|
||||
): Promise<unknown> {
|
||||
return API.updateResourceIllustration(
|
||||
releaseSlugOrId,
|
||||
releaseId,
|
||||
illustrationUrl,
|
||||
"releases",
|
||||
"release",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -476,13 +500,13 @@ export default class API {
|
||||
* Update Track Illustration
|
||||
*/
|
||||
static async updateTrackIllustration(
|
||||
trackSlugOrId: number | string,
|
||||
trackId: number,
|
||||
illustrationUrl: string,
|
||||
): Promise<unknown> {
|
||||
return API.updateResourceIllustration(
|
||||
trackSlugOrId,
|
||||
trackId,
|
||||
illustrationUrl,
|
||||
"tracks",
|
||||
"track",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -490,13 +514,13 @@ export default class API {
|
||||
* Update Track Illustration
|
||||
*/
|
||||
static async updatePlaylistIllustration(
|
||||
playlistSlugOrId: number | string,
|
||||
playlistId: number,
|
||||
illustrationUrl: string,
|
||||
): Promise<unknown> {
|
||||
return API.updateResourceIllustration(
|
||||
playlistSlugOrId,
|
||||
playlistId,
|
||||
illustrationUrl,
|
||||
"playlists",
|
||||
"playlist",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -504,17 +528,17 @@ export default class API {
|
||||
* Update Resourse Illustration
|
||||
*/
|
||||
private static async updateResourceIllustration(
|
||||
resourceSlugOrId: number | string,
|
||||
resourceId: number,
|
||||
illustrationUrl: string,
|
||||
resourceType: "artists" | "releases" | "tracks" | "playlists",
|
||||
resourceType: "artist" | "release" | "track" | "playlist",
|
||||
): Promise<unknown> {
|
||||
return API.fetch({
|
||||
route: `/${resourceType}/${resourceSlugOrId}/illustration`,
|
||||
route: `/illustrations/url`,
|
||||
errorMessage: "Update Illustration Failed",
|
||||
method: "POST",
|
||||
parameters: {},
|
||||
emptyResponse: true,
|
||||
data: { url: illustrationUrl },
|
||||
data: { url: illustrationUrl, [`${resourceType}Id`]: resourceId },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -523,15 +547,15 @@ export default class API {
|
||||
*/
|
||||
static async updateAlbum(
|
||||
albumSlugOrId: number | string,
|
||||
newType: AlbumType,
|
||||
): Promise<unknown> {
|
||||
dto: Partial<{ type: AlbumType; masterReleaseId: number }>,
|
||||
): Promise<void> {
|
||||
return API.fetch({
|
||||
route: `/albums/${albumSlugOrId}`,
|
||||
errorMessage: "Update Album Failed",
|
||||
method: "POST",
|
||||
method: "PUT",
|
||||
parameters: {},
|
||||
emptyResponse: true,
|
||||
data: { type: newType },
|
||||
data: dto,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -540,15 +564,29 @@ export default class API {
|
||||
*/
|
||||
static async updateSong(
|
||||
songSlugOrId: number | string,
|
||||
newType: SongType,
|
||||
dto: Partial<{ type: SongType; masterTrackId: number }>,
|
||||
): Promise<void> {
|
||||
return API.fetch({
|
||||
route: `/songs/${songSlugOrId}`,
|
||||
errorMessage: "Update Song Failed",
|
||||
method: "POST",
|
||||
method: "PUT",
|
||||
parameters: {},
|
||||
emptyResponse: true,
|
||||
data: { type: newType },
|
||||
data: dto,
|
||||
});
|
||||
}
|
||||
|
||||
static async updateVideo(
|
||||
videoSlugOrId: number | string,
|
||||
dto: Partial<{ type: VideoType; masterTrackId: number }>,
|
||||
): Promise<void> {
|
||||
return API.fetch({
|
||||
route: `/videos/${videoSlugOrId}`,
|
||||
errorMessage: "Update Video Failed",
|
||||
method: "PUT",
|
||||
parameters: {},
|
||||
emptyResponse: true,
|
||||
data: dto,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -695,18 +733,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 {
|
||||
@@ -720,7 +792,11 @@ export default class API {
|
||||
API.fetch({
|
||||
route: `/videos`,
|
||||
errorMessage: "Videos could not be loaded",
|
||||
parameters: { pagination: pagination, include, sort },
|
||||
parameters: {
|
||||
pagination: pagination,
|
||||
include,
|
||||
sort,
|
||||
},
|
||||
otherParameters: filter,
|
||||
validator: PaginatedResponse(
|
||||
VideoWithRelations(include ?? []),
|
||||
@@ -749,13 +825,28 @@ export default class API {
|
||||
};
|
||||
}
|
||||
|
||||
static getVideo<I extends VideoInclude | never = never>(
|
||||
videoSlugOrId: string | number,
|
||||
include?: I[],
|
||||
): Query<VideoWithRelations<I>> {
|
||||
return {
|
||||
key: ["video", videoSlugOrId, ...API.formatIncludeKeys(include)],
|
||||
exec: () =>
|
||||
API.fetch({
|
||||
route: `/videos/${videoSlugOrId}`,
|
||||
parameters: { include },
|
||||
validator: VideoWithRelations(include ?? []),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the master track of a song
|
||||
* @param songSlugOrId the identifier of a song
|
||||
* @param include the fields to include in the fetched item
|
||||
* @returns a Query for a Track
|
||||
*/
|
||||
static getMasterTrack<I extends TrackInclude | never = never>(
|
||||
static getSongMasterTrack<I extends TrackInclude | never = never>(
|
||||
songSlugOrId: string | number,
|
||||
include?: I[],
|
||||
): Query<TrackWithRelations<I>> {
|
||||
@@ -768,7 +859,7 @@ export default class API {
|
||||
],
|
||||
exec: () =>
|
||||
API.fetch({
|
||||
route: `/tracks/master/${songSlugOrId}`,
|
||||
route: `/tracks/master/song/${songSlugOrId}`,
|
||||
parameters: { include },
|
||||
validator: TrackWithRelations(include ?? []),
|
||||
}),
|
||||
@@ -844,14 +935,42 @@ export default class API {
|
||||
key: ["search", query],
|
||||
exec: () =>
|
||||
API.fetch({
|
||||
route: `/search/${query}`,
|
||||
route: `/search`,
|
||||
errorMessage: "Search failed",
|
||||
parameters: {},
|
||||
otherParameters: { query },
|
||||
customValidator: SearchResultTransformer,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
static getSearchHistory(): Query<SearchResult[]> {
|
||||
return {
|
||||
key: ["search-history-items"],
|
||||
exec: () =>
|
||||
API.fetch({
|
||||
route: `/search/history`,
|
||||
errorMessage: "Getting Search History failed",
|
||||
parameters: {},
|
||||
customValidator: SearchResultTransformer,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search artists, albums and songs, all at once
|
||||
*/
|
||||
static saveSearchHistoryEntry(resource: SaveSearchItem): Promise<void> {
|
||||
return API.fetch({
|
||||
method: "POST",
|
||||
route: `/search/history`,
|
||||
errorMessage: "Saving Search History failed",
|
||||
parameters: {},
|
||||
data: resource,
|
||||
emptyResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the User object of the authentified user
|
||||
* @returns A query to a User object
|
||||
@@ -1007,6 +1126,7 @@ export default class API {
|
||||
*/
|
||||
static getReleaseTracklist<I extends SongInclude | never = never>(
|
||||
slugOrId: string | number,
|
||||
exclusiveOnly: boolean = false,
|
||||
include?: I[],
|
||||
): InfiniteQuery<TracklistItemWithRelations<I>> {
|
||||
return {
|
||||
@@ -1014,12 +1134,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 ?? []),
|
||||
),
|
||||
@@ -1110,6 +1232,52 @@ export default class API {
|
||||
};
|
||||
}
|
||||
|
||||
static getArtistExternalMetadata(
|
||||
slugOrId: string | number,
|
||||
): Query<ArtistExternalMetadata | null> {
|
||||
return API.getResourceExternalMetadata(
|
||||
slugOrId,
|
||||
"artist",
|
||||
ArtistExternalMetadata,
|
||||
);
|
||||
}
|
||||
|
||||
static getSongExternalMetadata(
|
||||
slugOrId: string | number,
|
||||
): Query<SongExternalMetadata | null> {
|
||||
return API.getResourceExternalMetadata(
|
||||
slugOrId,
|
||||
"song",
|
||||
SongExternalMetadata,
|
||||
);
|
||||
}
|
||||
static getAlbumExternalMetadata(
|
||||
slugOrId: string | number,
|
||||
): Query<AlbumExternalMetadata | null> {
|
||||
return API.getResourceExternalMetadata(
|
||||
slugOrId,
|
||||
"album",
|
||||
AlbumExternalMetadata,
|
||||
);
|
||||
}
|
||||
|
||||
static getResourceExternalMetadata<T, V extends yup.Schema<T>>(
|
||||
resourceSlugOrId: string | number,
|
||||
resourceType: "artist" | "album" | "song",
|
||||
validator: V,
|
||||
): Query<T | null> {
|
||||
return {
|
||||
key: [resourceType, resourceSlugOrId, "external-metadata"],
|
||||
exec: () =>
|
||||
API.fetch({
|
||||
route: `/external-metadata?${resourceType}=${resourceSlugOrId}`,
|
||||
errorMessage: "Metadata could not be loaded",
|
||||
parameters: {},
|
||||
validator: validator,
|
||||
}).catch(() => null),
|
||||
};
|
||||
}
|
||||
|
||||
private static async fetch<ReturnType, Keys extends readonly string[]>({
|
||||
route,
|
||||
parameters,
|
||||
@@ -1120,6 +1288,7 @@ export default class API {
|
||||
validator,
|
||||
customValidator,
|
||||
emptyResponse,
|
||||
service,
|
||||
}: FetchParameters<Keys, ReturnType>): Promise<ReturnType> {
|
||||
const accessToken = store.getState().user.accessToken;
|
||||
const header = {
|
||||
@@ -1127,7 +1296,7 @@ export default class API {
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
this.buildURL(route, parameters, otherParameters),
|
||||
this.buildURL(route, parameters, otherParameters, service),
|
||||
{
|
||||
method: method ?? "GET",
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
@@ -1223,10 +1392,10 @@ export default class API {
|
||||
playlistId: number,
|
||||
): Promise<unknown> {
|
||||
return API.fetch({
|
||||
route: `/playlists/entries/new`,
|
||||
route: `/playlists/${playlistId}/entries`,
|
||||
errorMessage: "Failed to add song to playlist",
|
||||
parameters: {},
|
||||
data: { songId, playlistId },
|
||||
data: { songId },
|
||||
method: "POST",
|
||||
emptyResponse: true,
|
||||
});
|
||||
@@ -1280,51 +1449,18 @@ export default class API {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a release as master
|
||||
* @param releaseSlugOrId
|
||||
* @returns
|
||||
*/
|
||||
static async setReleaseAsMaster(
|
||||
releaseSlugOrId: string | number,
|
||||
): Promise<unknown> {
|
||||
return API.fetch({
|
||||
route: `/releases/${releaseSlugOrId}/master`,
|
||||
errorMessage: "Release update failed",
|
||||
parameters: {},
|
||||
method: "PUT",
|
||||
validator: yup.mixed(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a track as master
|
||||
* @param trackSlugOrId
|
||||
* @returns
|
||||
*/
|
||||
static async setTrackAsMaster(
|
||||
trackSlugOrId: string | number,
|
||||
): Promise<unknown> {
|
||||
return API.fetch({
|
||||
route: `/tracks/${trackSlugOrId}/master`,
|
||||
errorMessage: "Track update failed",
|
||||
parameters: {},
|
||||
method: "PUT",
|
||||
validator: yup.mixed(),
|
||||
});
|
||||
}
|
||||
|
||||
private static buildURL(
|
||||
route: string,
|
||||
parameters: QueryParameters<any>,
|
||||
otherParameters?: any,
|
||||
service: Service = Service.API,
|
||||
): string {
|
||||
const apiHost = isSSR() ? this.SSR_API_URL : "/api";
|
||||
const scannerHost = isSSR() ? this.SSR_SCANNER_URL : "/scanner";
|
||||
|
||||
return `${apiHost}${route}${this.formatQueryParameters(
|
||||
parameters,
|
||||
otherParameters,
|
||||
)}`;
|
||||
return `${
|
||||
service == Service.API ? apiHost : scannerHost
|
||||
}${route}${this.formatQueryParameters(parameters, otherParameters)}`;
|
||||
}
|
||||
|
||||
private static formatQueryParameters<Keys extends string[]>(
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { QueryClient } from "../../api/use-query";
|
||||
import Action from "../actions/action";
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import Album, { AlbumType } from "../../models/album";
|
||||
import store from "../../state/store";
|
||||
import { Chip, Grid } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import API from "../../api/api";
|
||||
import { useMutation } from "react-query";
|
||||
import { EditIcon } from "../icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const AlbumTypeForm = (props: {
|
||||
defaultValue: AlbumType;
|
||||
onSelect: (type: AlbumType) => void;
|
||||
}) => {
|
||||
const [currentType, setType] = useState(props.defaultValue);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid container spacing={2} justifyContent="center">
|
||||
{AlbumType.map((type) => (
|
||||
<Grid item key={type}>
|
||||
<Chip
|
||||
label={t(type)}
|
||||
variant={
|
||||
type == currentType ? "filled" : "outlined"
|
||||
}
|
||||
onClick={() => {
|
||||
setType(type);
|
||||
props.onSelect(type);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ChangeAlbumType = (
|
||||
album: Album,
|
||||
queryClient: QueryClient,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
): Action => {
|
||||
const { t } = useTranslation();
|
||||
const mutation = useMutation((newType: AlbumType) => {
|
||||
return API.updateAlbum(album.id, newType)
|
||||
.then(() => {
|
||||
queryClient.client.invalidateQueries("albums");
|
||||
})
|
||||
.catch((error: Error) => toast.error(error.message));
|
||||
});
|
||||
|
||||
return {
|
||||
label: "changeAlbumType",
|
||||
icon: <EditIcon />,
|
||||
disabled: store.getState().user.user?.admin !== true,
|
||||
onClick: () =>
|
||||
confirm({
|
||||
title: t("changeAlbumType"),
|
||||
description: (
|
||||
<AlbumTypeForm
|
||||
defaultValue={album.type}
|
||||
onSelect={(type) => mutation.mutate(type)}
|
||||
/>
|
||||
),
|
||||
cancellationButtonProps: { sx: { display: "none" } },
|
||||
confirmationText: t("done"),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export default ChangeAlbumType;
|
||||
@@ -18,18 +18,20 @@
|
||||
|
||||
import toast from "react-hot-toast";
|
||||
import API from "../../api/api";
|
||||
import LibraryTaskResponse from "../../models/library-task-response";
|
||||
import { TaskResponse } from "../../models/task";
|
||||
import Action from "./action";
|
||||
import { CleanIcon, MetadataRefreshIcon, ScanIcon } from "../icons";
|
||||
import { CleanIcon, ScanIcon } from "../icons";
|
||||
|
||||
/**
|
||||
* Using the resolved value of the task porimise, triggers an appropriate toast
|
||||
* @param task the returned promised from an API call to run a task
|
||||
*/
|
||||
const handleTask = <T extends LibraryTaskResponse>(task: Promise<T>) =>
|
||||
const handleTask = <T extends TaskResponse>(task: Promise<T>) =>
|
||||
task
|
||||
.then(({ status }) => toast.success(status))
|
||||
.catch(({ status }) => toast.error(status));
|
||||
.then((s) => toast.success(s.message))
|
||||
.catch((s) =>
|
||||
toast.error(s.message ?? s.status ?? "Task request failed"),
|
||||
);
|
||||
|
||||
export const ScanAllLibrariesAction: Action = {
|
||||
label: "scanLibraries",
|
||||
@@ -58,9 +60,3 @@ export const CleanLibraryAction = (
|
||||
icon: CleanAllLibrariesAction.icon,
|
||||
onClick: () => handleTask(API.cleanLibrary(librarySlugOrId)),
|
||||
});
|
||||
|
||||
export const FetchExternalMetadata: Action = {
|
||||
label: "fetchMetadata",
|
||||
icon: <MetadataRefreshIcon />,
|
||||
onClick: () => handleTask(API.fetchExternalMetadata()),
|
||||
};
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "../icons";
|
||||
import { NextRouter } from "next/router";
|
||||
import Action from "./action";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export const GoToSongLyricsAction = (
|
||||
songIdentifier: string | number,
|
||||
@@ -76,10 +77,16 @@ export const GoToReleaseAction = (
|
||||
|
||||
export const GoToReleaseAsyncAction = (
|
||||
router: NextRouter,
|
||||
albumIdentifier: () => PromiseLike<number | string>,
|
||||
albumIdentifier: () => PromiseLike<number | string | null>,
|
||||
): Action => ({
|
||||
onClick: () =>
|
||||
albumIdentifier().then((id) => router.push(`/releases/${id}`)),
|
||||
albumIdentifier().then((id) => {
|
||||
if (id === null) {
|
||||
toast.error("This resource is not attached to any album.");
|
||||
} else {
|
||||
router.push(`/releases/${id}`);
|
||||
}
|
||||
}),
|
||||
label: "goToAlbum",
|
||||
icon: <AlbumIcon />,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { QueryClient } from "../../api/use-query";
|
||||
import Action from "./action";
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import store from "../../state/store";
|
||||
import { Chip, Grid } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { EditIcon } from "../icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TranslationKey } from "../../i18n/i18n";
|
||||
import Song, { SongType } from "../../models/song";
|
||||
import API from "../../api/api";
|
||||
import Album, { AlbumType } from "../../models/album";
|
||||
import Video, { VideoType } from "../../models/video";
|
||||
|
||||
const ResourceTypeForm = <Enum extends TranslationKey>(props: {
|
||||
defaultValue?: Enum;
|
||||
values: readonly Enum[];
|
||||
onSelect: (type: Enum) => void;
|
||||
}) => {
|
||||
const [currentType, setType] = useState(props.defaultValue);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid container spacing={2} justifyContent="center">
|
||||
{props.values.map((type) => (
|
||||
<Grid item key={type}>
|
||||
<Chip
|
||||
label={t(type) as string}
|
||||
variant={
|
||||
type == currentType ? "filled" : "outlined"
|
||||
}
|
||||
onClick={() => {
|
||||
setType(type);
|
||||
props.onSelect(type);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ChangeResourceType = <
|
||||
T extends { type: TypeEnum },
|
||||
TypeEnum extends TranslationKey,
|
||||
>(
|
||||
resource: T,
|
||||
types: readonly TypeEnum[],
|
||||
label: TranslationKey,
|
||||
queryClient: QueryClient,
|
||||
onSelect: (newType: TypeEnum) => Promise<void>,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
): Action => {
|
||||
return {
|
||||
label: label,
|
||||
icon: <EditIcon />,
|
||||
disabled: store.getState().user.user?.admin !== true,
|
||||
onClick: () =>
|
||||
confirm({
|
||||
title: <br />,
|
||||
description: (
|
||||
<ResourceTypeForm
|
||||
defaultValue={resource.type}
|
||||
values={types}
|
||||
onSelect={(type) =>
|
||||
toast
|
||||
.promise(onSelect(type), {
|
||||
loading: null,
|
||||
success: "Update successful!",
|
||||
error: "Update failed...",
|
||||
})
|
||||
.then(() => {
|
||||
queryClient.client.invalidateQueries(
|
||||
"songs",
|
||||
);
|
||||
queryClient.client.invalidateQueries(
|
||||
"release",
|
||||
);
|
||||
queryClient.client.invalidateQueries(
|
||||
"tracks",
|
||||
);
|
||||
queryClient.client.invalidateQueries(
|
||||
"videos",
|
||||
);
|
||||
queryClient.client.invalidateQueries(
|
||||
"bsides",
|
||||
);
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
cancellationButtonProps: { sx: { display: "none" } },
|
||||
confirmationText: "OK",
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const ChangeSongType = (
|
||||
s: Song,
|
||||
client: QueryClient,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
) =>
|
||||
ChangeResourceType(
|
||||
s,
|
||||
SongType.filter((t) => t != "Unknown"),
|
||||
"changeSongType",
|
||||
client,
|
||||
(newType: SongType) =>
|
||||
API.updateSong(s.id, { type: newType }).then((res) => {
|
||||
client.client.invalidateQueries("songs");
|
||||
client.client.invalidateQueries("release");
|
||||
client.client.invalidateQueries("tracks");
|
||||
client.client.invalidateQueries("videos");
|
||||
client.client.invalidateQueries("bsides");
|
||||
return res;
|
||||
}),
|
||||
confirm,
|
||||
);
|
||||
|
||||
const ChangeAlbumType = (
|
||||
a: Album,
|
||||
client: QueryClient,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
) =>
|
||||
ChangeResourceType(
|
||||
a,
|
||||
AlbumType,
|
||||
"changeAlbumType",
|
||||
client,
|
||||
(newType: AlbumType) =>
|
||||
API.updateAlbum(a.id, { type: newType }).then((res) => {
|
||||
client.client.invalidateQueries("albums");
|
||||
return res;
|
||||
}),
|
||||
confirm,
|
||||
);
|
||||
|
||||
const ChangeVideoType = (
|
||||
v: Video,
|
||||
client: QueryClient,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
) =>
|
||||
ChangeResourceType(
|
||||
v,
|
||||
VideoType,
|
||||
"changeVideoType",
|
||||
client,
|
||||
(newType: VideoType) =>
|
||||
API.updateVideo(v.id, { type: newType }).then((res) => {
|
||||
client.client.invalidateQueries("videos");
|
||||
return res;
|
||||
}),
|
||||
confirm,
|
||||
);
|
||||
|
||||
export { ChangeSongType, ChangeAlbumType, ChangeVideoType };
|
||||
@@ -41,6 +41,6 @@ export const ShowMasterTrackFileInfoAction = (
|
||||
label: "moreInfo",
|
||||
onClick: () =>
|
||||
queryClient
|
||||
.fetchQuery(API.getMasterTrack(songId))
|
||||
.fetchQuery(API.getSongMasterTrack(songId))
|
||||
.then((track) => openTrackFileInfoModal(confirm, track.id)),
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { QueryClient } from "../../api/use-query";
|
||||
import Action from "./action";
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import store from "../../state/store";
|
||||
import { Chip, Grid } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import API from "../../api/api";
|
||||
import { useMutation } from "react-query";
|
||||
import { EditIcon } from "../icons";
|
||||
import Song, { SongType } from "../../models/song";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const SongTypeForm = (props: {
|
||||
defaultValue: SongType;
|
||||
onSelect: (type: SongType) => void;
|
||||
}) => {
|
||||
const [currentType, setType] = useState(props.defaultValue);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid container spacing={2} justifyContent="center">
|
||||
{SongType.filter((type) => type != "Unknown").map((type) => (
|
||||
<Grid item key={type}>
|
||||
<Chip
|
||||
label={t(type)}
|
||||
variant={
|
||||
type == currentType ? "filled" : "outlined"
|
||||
}
|
||||
onClick={() => {
|
||||
setType(type);
|
||||
props.onSelect(type);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ChangeSongType = (
|
||||
song: Song,
|
||||
queryClient: QueryClient,
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
): Action => {
|
||||
const { t } = useTranslation();
|
||||
const mutation = useMutation((newType: SongType) => {
|
||||
return API.updateSong(song.id, newType)
|
||||
.then(() => {
|
||||
toast.success("Update successful!");
|
||||
queryClient.client.invalidateQueries("songs");
|
||||
queryClient.client.invalidateQueries("release");
|
||||
queryClient.client.invalidateQueries("tracks");
|
||||
queryClient.client.invalidateQueries("videos");
|
||||
queryClient.client.invalidateQueries("bsides");
|
||||
})
|
||||
.catch((error: Error) => toast.error(error.message));
|
||||
});
|
||||
|
||||
return {
|
||||
label: "changeSongType",
|
||||
icon: <EditIcon />,
|
||||
disabled: store.getState().user.user?.admin !== true,
|
||||
onClick: () =>
|
||||
confirm({
|
||||
title: t("changeSongType"),
|
||||
description: (
|
||||
<SongTypeForm
|
||||
defaultValue={song.type}
|
||||
onSelect={(type) => mutation.mutate(type)}
|
||||
/>
|
||||
),
|
||||
cancellationButtonProps: { sx: { display: "none" } },
|
||||
confirmationText: t("done"),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export default ChangeSongType;
|
||||
@@ -83,7 +83,7 @@ const IllustrationUpdateForm = (props: IllustrationUpdateFormType) => {
|
||||
|
||||
const UpdateIllustrationAction = (
|
||||
queryClient: QueryClient,
|
||||
resourceId: number | string,
|
||||
resourceId: number,
|
||||
resourceType: "artist" | "track" | "release" | "playlist",
|
||||
): Action => {
|
||||
const textFieldId = `update-illustration-${resourceType}-${resourceId}`;
|
||||
@@ -121,23 +121,23 @@ const UpdateIllustrationAction = (
|
||||
|
||||
const UpdateArtistIllustrationAction = (
|
||||
queryClient: QueryClient,
|
||||
artistSlugOrId: number | string,
|
||||
) => UpdateIllustrationAction(queryClient, artistSlugOrId, "artist");
|
||||
artistId: number,
|
||||
) => UpdateIllustrationAction(queryClient, artistId, "artist");
|
||||
|
||||
const UpdateReleaseIllustrationAction = (
|
||||
queryClient: QueryClient,
|
||||
releaseSlugOrId: number | string,
|
||||
) => UpdateIllustrationAction(queryClient, releaseSlugOrId, "release");
|
||||
releaseId: number,
|
||||
) => UpdateIllustrationAction(queryClient, releaseId, "release");
|
||||
|
||||
const UpdateTrackIllustrationAction = (
|
||||
queryClient: QueryClient,
|
||||
trackSlugOrId: number | string,
|
||||
) => UpdateIllustrationAction(queryClient, trackSlugOrId, "track");
|
||||
trackId: number,
|
||||
) => UpdateIllustrationAction(queryClient, trackId, "track");
|
||||
|
||||
const UpdatePlaylistIllustrationAction = (
|
||||
queryClient: QueryClient,
|
||||
playlistSlugOrId: number | string,
|
||||
) => UpdateIllustrationAction(queryClient, playlistSlugOrId, "playlist");
|
||||
playlistId: number,
|
||||
) => UpdateIllustrationAction(queryClient, playlistId, "playlist");
|
||||
|
||||
export {
|
||||
UpdateArtistIllustrationAction,
|
||||
|
||||
@@ -24,7 +24,7 @@ import { ShareAlbumAction } from "../actions/share";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
import { AlbumWithRelations } from "../../models/album";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import ChangeAlbumType from "../actions/album-type";
|
||||
import { ChangeAlbumType } from "../actions/resource-type";
|
||||
import { RefreshAlbumMetadataAction } from "../actions/refresh-metadata";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
@@ -53,38 +53,41 @@ const ContextualMenu = (props: ContextualMenuProps) => {
|
||||
onClose={handleClose}
|
||||
style={{ zIndex: 99999 }}
|
||||
>
|
||||
{props.actions.map((actions, actionGroupIndex, allActions) =>
|
||||
[
|
||||
actions.map((action, actionIndex) => (
|
||||
<Box
|
||||
key={`${actionGroupIndex}/${actionIndex}`}
|
||||
onClick={() => {
|
||||
// If the action is NOT a dialog, close and unmount the menu.
|
||||
// If it is a dialog, we need to keep it mounted, so we have to keep it open
|
||||
if (!action.dialog) {
|
||||
handleClose();
|
||||
}
|
||||
props.onSelect && props.onSelect(action);
|
||||
}}
|
||||
>
|
||||
<ContextualMenuItem
|
||||
{...action}
|
||||
onDialogClose={handleClose}
|
||||
/>
|
||||
</Box>
|
||||
)),
|
||||
].concat(
|
||||
actionGroupIndex < allActions.length - 1
|
||||
? [
|
||||
<Divider
|
||||
key={actionGroupIndex}
|
||||
sx={{ marginY: 0.5 }}
|
||||
variant="middle"
|
||||
/>,
|
||||
]
|
||||
: [],
|
||||
),
|
||||
)}
|
||||
{props.actions
|
||||
.filter((actionList) => actionList.length > 0)
|
||||
.map((actions, actionGroupIndex, allActions) =>
|
||||
[
|
||||
actions.map((action, actionIndex) => (
|
||||
<Box
|
||||
key={`${actionGroupIndex}/${actionIndex}`}
|
||||
onClick={() => {
|
||||
// If the action is NOT a dialog, close and unmount the menu.
|
||||
// If it is a dialog, we need to keep it mounted, so we have to keep it open
|
||||
if (!action.dialog) {
|
||||
handleClose();
|
||||
}
|
||||
props.onSelect &&
|
||||
props.onSelect(action);
|
||||
}}
|
||||
>
|
||||
<ContextualMenuItem
|
||||
{...action}
|
||||
onDialogClose={handleClose}
|
||||
/>
|
||||
</Box>
|
||||
)),
|
||||
].concat(
|
||||
actionGroupIndex < allActions.length - 1
|
||||
? [
|
||||
<Divider
|
||||
key={actionGroupIndex}
|
||||
sx={{ marginY: 0.5 }}
|
||||
variant="middle"
|
||||
/>,
|
||||
]
|
||||
: [],
|
||||
),
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -48,7 +48,7 @@ const PlaylistContextualMenu = (props: PlaylistContextualMenuProps) => {
|
||||
[
|
||||
UpdatePlaylistIllustrationAction(
|
||||
queryClient,
|
||||
props.playlist.slug,
|
||||
props.playlist.id,
|
||||
),
|
||||
],
|
||||
[SharePlaylistAction(props.playlist.slug, t)],
|
||||
|
||||
@@ -30,7 +30,7 @@ import { DownloadReleaseAction } from "../actions/download";
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import { ReleaseWithRelations } from "../../models/release";
|
||||
import { UpdateReleaseIllustrationAction } from "../actions/update-illustration";
|
||||
import ChangeAlbumType from "../actions/album-type";
|
||||
import { ChangeAlbumType } from "../actions/resource-type";
|
||||
import { RefreshReleaseMetadataAction } from "../actions/refresh-metadata";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -46,7 +46,9 @@ const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
|
||||
const confirm = useConfirm();
|
||||
const { t } = useTranslation();
|
||||
const masterMutation = useMutation(async () => {
|
||||
return API.setReleaseAsMaster(props.release.id)
|
||||
return API.updateAlbum(props.release.albumId, {
|
||||
masterReleaseId: props.release.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t("releaseSetAsMaster"));
|
||||
queryClient.client.invalidateQueries();
|
||||
@@ -64,7 +66,12 @@ const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
|
||||
Promise.allSettled(
|
||||
tracks
|
||||
.reverse()
|
||||
.map((track) => API.setTrackAsMaster(track.id)),
|
||||
.filter((track) => track.songId != null)
|
||||
.map((track) =>
|
||||
API.updateSong(track.songId!, {
|
||||
masterTrackId: track.id,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.then(() => {
|
||||
toast.success(t("tracksUpdated"));
|
||||
|
||||
@@ -37,7 +37,7 @@ import { TrackWithRelations } from "../../models/track";
|
||||
import { UpdateTrackIllustrationAction } from "../actions/update-illustration";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import { RefreshTrackMetadataAction } from "../actions/refresh-metadata";
|
||||
import ChangeSongType from "../actions/song-type";
|
||||
import { ChangeSongType } from "../actions/resource-type";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
|
||||
@@ -48,7 +48,7 @@ type ReleaseTrackContextualMenuProps = {
|
||||
};
|
||||
|
||||
const ReleaseTrackContextualMenu = (props: ReleaseTrackContextualMenuProps) => {
|
||||
const songSlug = props.track.song.slug;
|
||||
const songSlug = props.track.song?.slug;
|
||||
const confirm = useConfirm();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
@@ -59,25 +59,37 @@ const ReleaseTrackContextualMenu = (props: ReleaseTrackContextualMenuProps) => {
|
||||
onSelect={props.onSelect}
|
||||
actions={[
|
||||
[GoToArtistAction(props.artist.slug)],
|
||||
[GoToSongLyricsAction(songSlug)],
|
||||
songSlug ? [GoToSongLyricsAction(songSlug)] : [],
|
||||
[
|
||||
PlayNextAction(async () => props, playNext),
|
||||
PlayAfterAction(async () => props, playAfter),
|
||||
],
|
||||
[AddToPlaylistAction(props.track.song.id, queryClient)],
|
||||
props.track.songId
|
||||
? [AddToPlaylistAction(props.track.songId, queryClient)]
|
||||
: [],
|
||||
songSlug
|
||||
? [
|
||||
GoToSongVersionAction(songSlug),
|
||||
GoToRelatedTracksAction(songSlug),
|
||||
]
|
||||
: [],
|
||||
[
|
||||
GoToSongVersionAction(songSlug),
|
||||
GoToRelatedTracksAction(songSlug),
|
||||
],
|
||||
[
|
||||
ChangeSongType(props.track.song, queryClient, confirm),
|
||||
...(props.track.song
|
||||
? [
|
||||
ChangeSongType(
|
||||
props.track.song,
|
||||
queryClient,
|
||||
confirm,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
UpdateTrackIllustrationAction(queryClient, props.track.id),
|
||||
RefreshTrackMetadataAction(props.track.id, t),
|
||||
],
|
||||
[ShowTrackFileInfoAction(confirm, props.track.id)],
|
||||
[
|
||||
DownloadAction(confirm, props.track.sourceFileId, t),
|
||||
ShareSongAction(songSlug, t),
|
||||
...(songSlug ? [ShareSongAction(songSlug, t)] : []),
|
||||
],
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -38,7 +38,7 @@ import { ShowMasterTrackFileInfoAction } from "../actions/show-track-info";
|
||||
import { SongWithRelations } from "../../models/song";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import { toast } from "react-hot-toast";
|
||||
import ChangeSongType from "../actions/song-type";
|
||||
import { ChangeSongType } from "../actions/resource-type";
|
||||
import { RefreshSongMetadataAction } from "../actions/refresh-metadata";
|
||||
import { DeleteIcon } from "../icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -58,7 +58,7 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
|
||||
const { playNext, playAfter } = usePlayerContext();
|
||||
const getMasterTrack = () =>
|
||||
queryClient.fetchQuery(
|
||||
API.getMasterTrack(songSlug, ["release", "illustration"]),
|
||||
API.getSongMasterTrack(songSlug, ["release", "illustration"]),
|
||||
);
|
||||
const router = useRouter();
|
||||
const confirm = useConfirm();
|
||||
@@ -76,7 +76,8 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
|
||||
GoToArtistAction(props.song.artist.slug),
|
||||
GoToReleaseAsyncAction(
|
||||
router,
|
||||
async () => (await getMasterTrack()).release.slug,
|
||||
async () =>
|
||||
(await getMasterTrack()).release?.slug ?? null,
|
||||
),
|
||||
],
|
||||
[GoToSongLyricsAction(songSlug)],
|
||||
|
||||
@@ -26,11 +26,7 @@ import { RootState } from "../../state/store";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import { DownloadAction } from "../actions/download";
|
||||
import {
|
||||
GoToArtistAction,
|
||||
GoToReleaseAction,
|
||||
GoToSongLyricsAction,
|
||||
} from "../actions/link";
|
||||
import { GoToReleaseAction, GoToSongLyricsAction } from "../actions/link";
|
||||
import {
|
||||
AddToPlaylistAction,
|
||||
PlayAfterAction,
|
||||
@@ -40,13 +36,13 @@ import { ShowTrackFileInfoAction } from "../actions/show-track-info";
|
||||
import { TrackWithRelations } from "../../models/track";
|
||||
import { UpdateTrackIllustrationAction } from "../actions/update-illustration";
|
||||
import { RefreshTrackMetadataAction } from "../actions/refresh-metadata";
|
||||
import ChangeSongType from "../actions/song-type";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import Action from "../actions/action";
|
||||
import { ChangeSongType } from "../actions/resource-type";
|
||||
|
||||
type TrackContextualMenuProps = {
|
||||
track: TrackWithRelations<"song" | "illustration">;
|
||||
isVideo?: true; // If true, will add 'go to artist' to menu
|
||||
track: TrackWithRelations<"video" | "song" | "illustration">;
|
||||
onSelect?: () => void;
|
||||
};
|
||||
|
||||
@@ -56,11 +52,16 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const isMaster = props.track.song.masterId == props.track.id;
|
||||
const isMaster = props.track.song?.masterId === props.track.id;
|
||||
const getPlayNextProps = () =>
|
||||
Promise.all([
|
||||
queryClient.fetchQuery(API.getArtist(props.track.song.artistId)),
|
||||
queryClient.fetchQuery(API.getRelease(props.track.releaseId)),
|
||||
queryClient.fetchQuery(
|
||||
API.getArtist(
|
||||
(props.track.song ?? props.track.video)!.artistId,
|
||||
),
|
||||
),
|
||||
props.track.releaseId &&
|
||||
queryClient.fetchQuery(API.getRelease(props.track.releaseId)),
|
||||
]).then(([artist, release]) => ({
|
||||
track: props.track,
|
||||
artist,
|
||||
@@ -69,7 +70,9 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { playNext, playAfter } = usePlayerContext();
|
||||
const masterMutation = useMutation(async () => {
|
||||
return API.setTrackAsMaster(props.track.id)
|
||||
return API.updateSong(props.track.songId!, {
|
||||
masterTrackId: props.track.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t("trackSetAsMaster"));
|
||||
queryClient.client.invalidateQueries();
|
||||
@@ -81,28 +84,41 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
|
||||
<ContextualMenu
|
||||
onSelect={props.onSelect}
|
||||
actions={[
|
||||
props.isVideo
|
||||
? [
|
||||
GoToArtistAction(props.track.song.artistId),
|
||||
GoToReleaseAction(props.track.releaseId),
|
||||
]
|
||||
: [GoToReleaseAction(props.track.releaseId)],
|
||||
[GoToSongLyricsAction(props.track.song.slug)],
|
||||
[
|
||||
props.track.releaseId
|
||||
? GoToReleaseAction(props.track.releaseId)
|
||||
: undefined,
|
||||
].filter((a): a is Action => a !== undefined),
|
||||
props.track.song
|
||||
? [GoToSongLyricsAction(props.track.song.slug)]
|
||||
: [],
|
||||
[
|
||||
PlayNextAction(getPlayNextProps, playNext),
|
||||
PlayAfterAction(getPlayNextProps, playAfter),
|
||||
],
|
||||
[AddToPlaylistAction(props.track.songId, queryClient)],
|
||||
props.track.songId
|
||||
? [AddToPlaylistAction(props.track.songId, queryClient)]
|
||||
: [],
|
||||
props.track.songId
|
||||
? [
|
||||
{
|
||||
label: "setAsMaster",
|
||||
disabled: isMaster || !userIsAdmin,
|
||||
icon: <MasterIcon />,
|
||||
onClick: () => masterMutation.mutate(),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
[
|
||||
{
|
||||
label: "setAsMaster",
|
||||
disabled: isMaster || !userIsAdmin,
|
||||
icon: <MasterIcon />,
|
||||
onClick: () => masterMutation.mutate(),
|
||||
},
|
||||
],
|
||||
[
|
||||
ChangeSongType(props.track.song, queryClient, confirm),
|
||||
...(props.track.song
|
||||
? [
|
||||
ChangeSongType(
|
||||
props.track.song,
|
||||
queryClient,
|
||||
confirm,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
UpdateTrackIllustrationAction(queryClient, props.track.id),
|
||||
RefreshTrackMetadataAction(props.track.id, t),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import API from "../../api/api";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import { DownloadAction } from "../actions/download";
|
||||
import {
|
||||
GoToArtistAction,
|
||||
GoToReleaseAction,
|
||||
GoToSongLyricsAction,
|
||||
} from "../actions/link";
|
||||
import {
|
||||
AddToPlaylistAction,
|
||||
PlayAfterAction,
|
||||
PlayNextAction,
|
||||
} from "../actions/playlist";
|
||||
import { ShowTrackFileInfoAction } from "../actions/show-track-info";
|
||||
import { UpdateTrackIllustrationAction } from "../actions/update-illustration";
|
||||
import { RefreshTrackMetadataAction } from "../actions/refresh-metadata";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import Action from "../actions/action";
|
||||
import { VideoWithRelations } from "../../models/video";
|
||||
import { ChangeVideoType } from "../actions/resource-type";
|
||||
|
||||
type VideoContextualMenuProps = {
|
||||
video: VideoWithRelations<"master" | "illustration">;
|
||||
onSelect?: () => void;
|
||||
};
|
||||
|
||||
const VideoContextualMenu = (props: VideoContextualMenuProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const getPlayNextProps = () =>
|
||||
Promise.all([
|
||||
queryClient.fetchQuery(API.getArtist(props.video.artistId)),
|
||||
props.video.master.releaseId &&
|
||||
queryClient.fetchQuery(
|
||||
API.getRelease(props.video.master.releaseId),
|
||||
),
|
||||
]).then(([artist, release]) => ({
|
||||
track: {
|
||||
...props.video.master,
|
||||
illustration: props.video.illustration,
|
||||
},
|
||||
artist,
|
||||
release,
|
||||
}));
|
||||
const { t } = useTranslation();
|
||||
const { playNext, playAfter } = usePlayerContext();
|
||||
|
||||
return (
|
||||
<ContextualMenu
|
||||
onSelect={props.onSelect}
|
||||
actions={[
|
||||
[
|
||||
GoToArtistAction(props.video.artistId),
|
||||
props.video.master.releaseId
|
||||
? GoToReleaseAction(props.video.master.releaseId)
|
||||
: undefined,
|
||||
].filter((a): a is Action => a !== undefined),
|
||||
props.video.songId
|
||||
? [GoToSongLyricsAction(props.video.songId)]
|
||||
: [],
|
||||
[
|
||||
PlayNextAction(getPlayNextProps, playNext),
|
||||
PlayAfterAction(getPlayNextProps, playAfter),
|
||||
],
|
||||
props.video.songId
|
||||
? [AddToPlaylistAction(props.video.songId, queryClient)]
|
||||
: [],
|
||||
[
|
||||
ChangeVideoType(props.video, queryClient, confirm),
|
||||
UpdateTrackIllustrationAction(
|
||||
queryClient,
|
||||
props.video.master.id,
|
||||
),
|
||||
RefreshTrackMetadataAction(props.video.master.id, t),
|
||||
],
|
||||
[ShowTrackFileInfoAction(confirm, props.video.master.id)],
|
||||
[DownloadAction(confirm, props.video.master.sourceFileId, t)],
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoContextualMenu;
|
||||
@@ -43,6 +43,12 @@ import Fade from "../fade";
|
||||
import globalLibrary from "../../utils/global-library";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type Toggle = {
|
||||
name: string;
|
||||
label: TranslationKey;
|
||||
defaultValue?: boolean;
|
||||
};
|
||||
|
||||
export type OptionState<
|
||||
SortingKeys extends readonly string[],
|
||||
AdditionalProps extends object = object,
|
||||
@@ -60,6 +66,7 @@ type ControllerProps<
|
||||
> = {
|
||||
options?: Options;
|
||||
actions?: Action[];
|
||||
toggles?: Toggle[];
|
||||
disableSorting?: true;
|
||||
sortingKeys: SortingKeys;
|
||||
defaultSortingKey?: SortingKeys[number];
|
||||
@@ -130,6 +137,15 @@ const Controls = <
|
||||
option.values,
|
||||
) ?? option.values[0];
|
||||
});
|
||||
props.toggles?.forEach((option) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
baseOptions[option.name] =
|
||||
(parseQueryParam(props.router?.query[option.name], [
|
||||
"false",
|
||||
"true",
|
||||
]) ?? "false") === "true";
|
||||
});
|
||||
return baseOptions;
|
||||
},
|
||||
);
|
||||
@@ -148,9 +164,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(
|
||||
@@ -283,6 +300,21 @@ 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
|
||||
|
||||
+9
-9
@@ -17,35 +17,35 @@
|
||||
*/
|
||||
|
||||
import { Box, Button, Skeleton } from "@mui/material";
|
||||
import ExternalId from "../models/external-id";
|
||||
import Illustration from "./illustration";
|
||||
import Link from "next/link";
|
||||
import { ExternalMetadataSource } from "../models/external-metadata";
|
||||
|
||||
type ExternalIdBadgeProps = {
|
||||
externalId: ExternalId | undefined;
|
||||
type ExternalMetadataBadgeProps = {
|
||||
source: ExternalMetadataSource | undefined;
|
||||
};
|
||||
|
||||
const ExternalIdBadge = ({ externalId }: ExternalIdBadgeProps) => {
|
||||
const ExternalMetadataBadge = ({ source }: ExternalMetadataBadgeProps) => {
|
||||
const badge = (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={
|
||||
<Box sx={{ width: 30 }}>
|
||||
<Illustration
|
||||
url={externalId?.provider.icon ?? undefined}
|
||||
url={source?.providerIcon ?? undefined}
|
||||
quality="original"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{externalId?.provider.name ?? <Skeleton width="50px" />}
|
||||
{source?.providerName ?? <Skeleton width="50px" />}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (externalId?.url) {
|
||||
if (source?.url) {
|
||||
return (
|
||||
<Link
|
||||
href={externalId.url ?? undefined}
|
||||
href={source.url ?? undefined}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
@@ -56,4 +56,4 @@ const ExternalIdBadge = ({ externalId }: ExternalIdBadgeProps) => {
|
||||
return badge;
|
||||
};
|
||||
|
||||
export default ExternalIdBadge;
|
||||
export default ExternalMetadataBadge;
|
||||
@@ -20,30 +20,23 @@ import { useTranslation } from "react-i18next";
|
||||
import { AlbumWithRelations } from "../../models/album";
|
||||
import getYear from "../../utils/getYear";
|
||||
import HighlightCard from "./highlight-card";
|
||||
import { AlbumExternalMetadata } from "../../models/external-metadata";
|
||||
|
||||
type AlbumHighlightCardProps = {
|
||||
album:
|
||||
| AlbumWithRelations<
|
||||
"artist" | "externalIds" | "genres" | "illustration"
|
||||
>
|
||||
| undefined;
|
||||
album: AlbumWithRelations<"artist" | "genres" | "illustration"> | undefined;
|
||||
externalMetadata: AlbumExternalMetadata | undefined;
|
||||
};
|
||||
const AlbumHighlightCard = ({ album }: AlbumHighlightCardProps) => {
|
||||
const AlbumHighlightCard = ({
|
||||
album,
|
||||
externalMetadata,
|
||||
}: AlbumHighlightCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<HighlightCard
|
||||
title={album?.name}
|
||||
headline={album?.name}
|
||||
body={
|
||||
album?.externalIds
|
||||
.filter(
|
||||
({ provider }) =>
|
||||
provider.name.toLowerCase() !== "discogs",
|
||||
)
|
||||
.map((id) => id.description)
|
||||
.filter((desc): desc is string => desc !== null)
|
||||
.sort((descA, descB) => descA.length - descB.length)
|
||||
.at(0) ||
|
||||
externalMetadata?.description ||
|
||||
[
|
||||
album ? album.artist?.name ?? t("compilation") : undefined,
|
||||
album ? getYear(album.releaseDate) : undefined,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -59,9 +59,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)}
|
||||
|
||||
@@ -95,12 +95,14 @@ const InfiniteAlbumView = (
|
||||
}
|
||||
renderListItem={(item) => (
|
||||
<AlbumItem
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
album={item}
|
||||
formatSubtitle={props.formatSubtitle}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={(item) => (
|
||||
<AlbumTile
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
album={item}
|
||||
formatSubtitle={props.formatSubtitle}
|
||||
/>
|
||||
|
||||
@@ -62,8 +62,18 @@ const InfiniteArtistView = (
|
||||
"asc",
|
||||
})
|
||||
}
|
||||
renderListItem={(item) => <ArtistItem artist={item} />}
|
||||
renderGridItem={(item) => <ArtistTile artist={item} />}
|
||||
renderListItem={(item) => (
|
||||
<ArtistItem
|
||||
artist={item}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={(item) => (
|
||||
<ArtistTile
|
||||
artist={item}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -69,8 +69,18 @@ const InfinitePlaylistView = (
|
||||
view: "grid",
|
||||
})
|
||||
}
|
||||
renderListItem={(item) => <PlaylistItem playlist={item} />}
|
||||
renderGridItem={(item) => <PlaylistTile playlist={item} />}
|
||||
renderListItem={(item) => (
|
||||
<PlaylistItem
|
||||
playlist={item}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={(item) => (
|
||||
<PlaylistTile
|
||||
playlist={item}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -31,6 +31,7 @@ type InfiniteResourceViewProps<
|
||||
options: OptionState<SortingKeys> & AdditionalQueryParams,
|
||||
) => ReturnType<MeeloInfiniteQueryFn<ResourceType>>;
|
||||
light?: boolean;
|
||||
onItemClick?: (item: ResourceType) => void;
|
||||
disableSorting?: true;
|
||||
defaultLayout?: LayoutOption;
|
||||
initialSortingOrder?: Order;
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
SongWithRelations,
|
||||
} from "../../../models/song";
|
||||
import Controls, { OptionState } from "../../controls/controls";
|
||||
import SongItem from "../../list-item/song-item";
|
||||
import SongItem, { SongGroupItem } from "../../list-item/song-item";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
import { PlayIcon, ShuffleIcon } from "../../icons";
|
||||
@@ -35,9 +35,15 @@ import {
|
||||
useQueryClient,
|
||||
} from "../../../api/use-query";
|
||||
import { PlayerActions, usePlayerContext } from "../../../contexts/player";
|
||||
import {
|
||||
SongGroupSortingKeys,
|
||||
SongGroupWithRelations,
|
||||
} from "../../../models/song-group";
|
||||
|
||||
type AdditionalProps = {
|
||||
type?: SongType;
|
||||
// if true, show song groups instead of songs
|
||||
groups?: boolean;
|
||||
random?: number;
|
||||
};
|
||||
|
||||
@@ -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>>();
|
||||
@@ -105,6 +119,7 @@ const InfiniteSongView = <
|
||||
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 = {
|
||||
@@ -154,28 +169,69 @@ const InfiniteSongView = <
|
||||
]}
|
||||
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}
|
||||
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={() => <></>}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ import InfiniteView from "../infinite-view";
|
||||
|
||||
const InfiniteTrackView = (
|
||||
props: InfiniteResourceViewProps<
|
||||
TrackWithRelations<"song" | "release" | "illustration">,
|
||||
TrackWithRelations<"video" | "song" | "release" | "illustration">,
|
||||
typeof TrackSortingKeys
|
||||
>,
|
||||
) => {
|
||||
@@ -55,7 +55,12 @@ const InfiniteTrackView = (
|
||||
order: options?.order ?? "asc",
|
||||
})
|
||||
}
|
||||
renderListItem={(item) => <TrackItem track={item} />}
|
||||
renderListItem={(item) => (
|
||||
<TrackItem
|
||||
track={item}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={(item) => <></>}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -18,65 +18,157 @@
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { ComponentProps, useState } from "react";
|
||||
import { SongSortingKeys } from "../../../models/song";
|
||||
import { VideoWithRelations } from "../../../models/video";
|
||||
import {
|
||||
VideoSortingKeys,
|
||||
VideoType,
|
||||
VideoWithRelations,
|
||||
} from "../../../models/video";
|
||||
import Controls, { OptionState } from "../../controls/controls";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
import VideoTile from "../../tile/video-tile";
|
||||
import { PaginationParameters } from "../../../models/pagination";
|
||||
import { PlayIcon, ShuffleIcon } from "../../icons";
|
||||
import { PlayerActions, usePlayerContext } from "../../../contexts/player";
|
||||
import {
|
||||
InfiniteQuery,
|
||||
QueryClient,
|
||||
prepareMeeloInfiniteQuery,
|
||||
useQueryClient,
|
||||
} from "../../../api/use-query";
|
||||
import VideoItem from "../../list-item/video-item";
|
||||
|
||||
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}
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -25,6 +25,7 @@ import { useTranslation } from "react-i18next";
|
||||
type AlbumItemProps = {
|
||||
album: AlbumWithRelations<"artist" | "illustration"> | undefined;
|
||||
formatSubtitle?: (album: AlbumWithRelations<"artist">) => string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -32,7 +33,7 @@ type AlbumItemProps = {
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
const AlbumItem = ({ album, formatSubtitle }: AlbumItemProps) => {
|
||||
const AlbumItem = ({ album, formatSubtitle, onClick }: AlbumItemProps) => {
|
||||
const artist = album?.artist;
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -44,6 +45,7 @@ const AlbumItem = ({ album, formatSubtitle }: AlbumItemProps) => {
|
||||
quality="low"
|
||||
/>
|
||||
}
|
||||
onClick={onClick}
|
||||
href={album ? `/albums/${album.slug}` : undefined}
|
||||
title={album?.name}
|
||||
secondTitle={
|
||||
|
||||
@@ -23,6 +23,7 @@ import ArtistAvatar from "../artist-avatar";
|
||||
|
||||
type ArtistItemProps = {
|
||||
artist: ArtistWithRelations<"illustration"> | undefined;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -30,7 +31,7 @@ type ArtistItemProps = {
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
const ArtistItem = ({ artist }: ArtistItemProps) => {
|
||||
const ArtistItem = ({ artist, onClick }: ArtistItemProps) => {
|
||||
return (
|
||||
<ListItem
|
||||
icon={
|
||||
@@ -40,6 +41,7 @@ const ArtistItem = ({ artist }: ArtistItemProps) => {
|
||||
/>
|
||||
}
|
||||
secondTitle={null}
|
||||
onClick={onClick}
|
||||
href={artist ? `/artists/${artist.slug}` : undefined}
|
||||
title={artist?.name}
|
||||
trailing={artist && <ArtistContextualMenu artist={artist} />}
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { RequireExactlyOne } from "type-fest";
|
||||
import Link from "next/link";
|
||||
|
||||
type ListItemProps = {
|
||||
@@ -35,10 +34,9 @@ type ListItemProps = {
|
||||
title: string | undefined;
|
||||
secondTitle: string | undefined | null;
|
||||
trailing?: JSX.Element;
|
||||
} & RequireExactlyOne<{
|
||||
href: string | undefined;
|
||||
onClick: (() => void) | undefined;
|
||||
}>;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const textStyle = {
|
||||
whiteSpace: "nowrap",
|
||||
|
||||
@@ -24,6 +24,7 @@ import PlaylistContextualMenu from "../contextual-menu/playlist-contextual-menu"
|
||||
|
||||
type PlaylistItemProps = {
|
||||
playlist: PlaylistWithRelations<"illustration"> | undefined;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -31,7 +32,7 @@ type PlaylistItemProps = {
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
const PlaylistItem = ({ playlist }: PlaylistItemProps) => {
|
||||
const PlaylistItem = ({ playlist, onClick }: PlaylistItemProps) => {
|
||||
return (
|
||||
<ListItem
|
||||
icon={
|
||||
@@ -45,6 +46,7 @@ const PlaylistItem = ({ playlist }: PlaylistItemProps) => {
|
||||
secondTitle={null}
|
||||
href={playlist ? `/playlists/${playlist.slug}` : undefined}
|
||||
title={playlist?.name}
|
||||
onClick={onClick}
|
||||
trailing={
|
||||
playlist && <PlaylistContextualMenu playlist={playlist} />
|
||||
}
|
||||
|
||||
@@ -26,9 +26,10 @@ import { Star } from "iconsax-react";
|
||||
|
||||
type ReleaseItemProps = {
|
||||
release: ReleaseWithRelations<"album" | "illustration">;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const ReleaseItem = ({ release }: ReleaseItemProps) => {
|
||||
const ReleaseItem = ({ release, onClick }: ReleaseItemProps) => {
|
||||
const isMaster = release.id == release.album.masterId;
|
||||
|
||||
return (
|
||||
@@ -42,6 +43,7 @@ const ReleaseItem = ({ release }: ReleaseItemProps) => {
|
||||
}
|
||||
href={`/releases/${release.slug}`}
|
||||
title={release.name}
|
||||
onClick={onClick}
|
||||
secondTitle={getYear(release.releaseDate)?.toString()}
|
||||
trailing={
|
||||
<Grid
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useEffect, useState } from "react";
|
||||
import { SongIcon } from "../icons";
|
||||
import formatArtists from "../../utils/formatArtists";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import { SongGroupWithRelations } from "../../models/song-group";
|
||||
|
||||
type SongItemProps<
|
||||
T extends SongWithRelations<
|
||||
@@ -32,9 +33,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 +86,34 @@ 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 +129,16 @@ const SongItem = <
|
||||
onClick={
|
||||
song &&
|
||||
artist &&
|
||||
(() =>
|
||||
(() => {
|
||||
onClick?.();
|
||||
playTrack({
|
||||
artist,
|
||||
track: {
|
||||
...song.master,
|
||||
illustration: song.illustration,
|
||||
},
|
||||
}))
|
||||
});
|
||||
})
|
||||
}
|
||||
secondTitle={subtitle}
|
||||
trailing={song && <SongContextualMenu song={song} />}
|
||||
|
||||
@@ -27,7 +27,10 @@ import { MasterIcon, TrackIcon } from "../icons";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
|
||||
type TrackItemProps = {
|
||||
track: TrackWithRelations<"release" | "song" | "illustration"> | undefined;
|
||||
track:
|
||||
| TrackWithRelations<"video" | "release" | "song" | "illustration">
|
||||
| undefined;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -35,10 +38,10 @@ type TrackItemProps = {
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
const TrackItem = ({ track }: TrackItemProps) => {
|
||||
const TrackItem = ({ track, onClick }: TrackItemProps) => {
|
||||
const release = track?.release;
|
||||
const { playTrack } = usePlayerContext();
|
||||
const isMaster = track ? track.song.masterId == track.id : false;
|
||||
const isMaster = track ? track.song?.masterId == track.id : false;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return (
|
||||
@@ -52,19 +55,24 @@ const TrackItem = ({ track }: TrackItemProps) => {
|
||||
}
|
||||
onClick={
|
||||
track &&
|
||||
release &&
|
||||
(() =>
|
||||
(() => {
|
||||
onClick?.();
|
||||
queryClient
|
||||
.fetchQuery(API.getSong(track.songId, ["artist"]))
|
||||
.then((song) => {
|
||||
.fetchQuery(
|
||||
track.song
|
||||
? API.getArtist(track.song.artistId)
|
||||
: API.getArtist(track.video!.artistId),
|
||||
)
|
||||
.then((artist) => {
|
||||
playTrack({
|
||||
artist: song.artist,
|
||||
artist,
|
||||
track,
|
||||
});
|
||||
}))
|
||||
});
|
||||
})
|
||||
}
|
||||
title={track?.name}
|
||||
secondTitle={release?.name}
|
||||
secondTitle={release?.name ?? null}
|
||||
trailing={
|
||||
<Grid
|
||||
container
|
||||
@@ -72,7 +80,7 @@ const TrackItem = ({ track }: TrackItemProps) => {
|
||||
sx={{ justifyContent: "flex-end", flexWrap: "nowrap" }}
|
||||
>
|
||||
<Grid item sx={{ display: "flex", alignItems: "center" }}>
|
||||
{isMaster ? <MasterIcon /> : undefined}
|
||||
{track?.song && isMaster ? <MasterIcon /> : undefined}
|
||||
</Grid>
|
||||
<Grid item>
|
||||
{track && <TrackContextualMenu track={track} />}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Illustration from "../illustration";
|
||||
import ListItem from "./item";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { VideoIcon } from "../icons";
|
||||
import formatArtists from "../../utils/formatArtists";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import { VideoWithRelations } from "../../models/video";
|
||||
import VideoContextualMenu from "../contextual-menu/video-contextual-menu";
|
||||
|
||||
type VideoType = VideoWithRelations<"artist" | "master" | "illustration">;
|
||||
|
||||
type VideoItemProps<T extends VideoType> = {
|
||||
video: T | undefined;
|
||||
onClick?: () => void;
|
||||
subtitles?: ((video: VideoType) => Promise<string | null>)[];
|
||||
};
|
||||
|
||||
const defaultSubtitle = (s: VideoType) => formatArtists(s.artist);
|
||||
|
||||
/**
|
||||
* Item for a list of videos
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
const VideoItem = <T extends VideoType>({
|
||||
video,
|
||||
subtitles,
|
||||
onClick,
|
||||
}: VideoItemProps<T>) => {
|
||||
const artist = video?.artist;
|
||||
const { playTrack } = usePlayerContext();
|
||||
const queryClient = useQueryClient();
|
||||
const [subtitle, setSubtitle] = useState<string | null | undefined>(
|
||||
subtitles?.length
|
||||
? ((<br />) as unknown as string)
|
||||
: video
|
||||
? defaultSubtitle(video)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (subtitles && video) {
|
||||
Promise.allSettled(subtitles.map((s) => s(video))).then((r) =>
|
||||
setSubtitle(
|
||||
r
|
||||
.map(
|
||||
(s) =>
|
||||
(s as PromiseFulfilledResult<string | null>)
|
||||
.value,
|
||||
)
|
||||
.filter((s): s is string => s !== null)
|
||||
.join(" • ") || null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<ListItem
|
||||
icon={
|
||||
<Illustration
|
||||
illustration={video?.illustration}
|
||||
fallback={<VideoIcon />}
|
||||
quality="low"
|
||||
/>
|
||||
}
|
||||
title={video?.name}
|
||||
onClick={
|
||||
video &&
|
||||
artist &&
|
||||
(() => {
|
||||
onClick?.();
|
||||
playTrack({
|
||||
artist,
|
||||
track: {
|
||||
...video.master,
|
||||
illustration: video.illustration,
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
secondTitle={subtitle}
|
||||
trailing={video && <VideoContextualMenu video={video} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoItem;
|
||||
@@ -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 (
|
||||
|
||||
@@ -57,7 +57,6 @@ import ListItem from "../list-item/item";
|
||||
import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd";
|
||||
import formatDuration from "../../utils/formatDuration";
|
||||
import formatArtists from "../../utils/formatArtists";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
|
||||
const parentSongQuery = (id: number) =>
|
||||
@@ -112,7 +111,10 @@ const PreviousButton = (props: Omit<ControlButtonProps, "icon">) => (
|
||||
<ControlButton {...props} icon={<RewindIcon />} />
|
||||
);
|
||||
const MinimizedPlayerControls = (props: PlayerControlsProps) => {
|
||||
const parentSong = useQuery(parentSongQuery, props.track?.songId);
|
||||
const parentSong = useQuery(
|
||||
parentSongQuery,
|
||||
props.track?.songId ?? undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
@@ -229,14 +231,14 @@ const MinimizedPlayerControls = (props: PlayerControlsProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Panels = ["lyrics", "playlist"] as const;
|
||||
|
||||
const ExpandedPlayerControls = (
|
||||
props: PlayerControlsProps & { videoRef: LegacyRef<HTMLVideoElement> },
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const parentSong = useQuery(parentSongQuery, props.track?.songId);
|
||||
const parentSong = useQuery(
|
||||
parentSongQuery,
|
||||
props.track?.songId ?? undefined,
|
||||
);
|
||||
const { playlist, cursor, reorder, skipTrack } = usePlayerContext();
|
||||
const [selectedTab, selectTab] = useState<"player" | "lyrics" | "playlist">(
|
||||
"player",
|
||||
@@ -414,7 +416,13 @@ const ExpandedPlayerControls = (
|
||||
>
|
||||
{props.artist && props.track ? (
|
||||
<Link
|
||||
href={`/releases/${props.track.releaseId}`}
|
||||
href={
|
||||
props.track.releaseId
|
||||
? `/releases/${props.track.releaseId}`
|
||||
: props.track.songId
|
||||
? `/songs/${props.track.songId}/lyrics`
|
||||
: {}
|
||||
}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
@@ -422,6 +430,7 @@ const ExpandedPlayerControls = (
|
||||
>
|
||||
<Button
|
||||
onClick={() =>
|
||||
props.track &&
|
||||
props.onExpand(false)
|
||||
}
|
||||
sx={{
|
||||
@@ -446,14 +455,14 @@ const ExpandedPlayerControls = (
|
||||
</Grid>
|
||||
<Grid item xs={1}>
|
||||
{
|
||||
props.track &&
|
||||
parentSong.data &&
|
||||
props.artist ? (
|
||||
props.track && props.artist ? (
|
||||
<ReleaseTrackContextualMenu
|
||||
artist={props.artist}
|
||||
track={{
|
||||
...props.track,
|
||||
song: parentSong.data,
|
||||
song: props.track.songId
|
||||
? parentSong.data ?? null
|
||||
: null,
|
||||
}}
|
||||
onSelect={() =>
|
||||
props.onExpand(false)
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useReadLocalStorage } from "usehooks-ts";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import Hls from "hls.js";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { useKeyboardBinding } from "../../contexts/keybindings";
|
||||
|
||||
const Player = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -120,7 +121,9 @@ const Player = () => {
|
||||
progress.current = player.current!.currentTime;
|
||||
};
|
||||
player.current!.onended = () => {
|
||||
API.setSongAsPlayed(currentTrack.track.songId);
|
||||
if (currentTrack.track.songId) {
|
||||
API.setSongAsPlayed(currentTrack.track.songId);
|
||||
}
|
||||
progress.current = null;
|
||||
skipTrack();
|
||||
};
|
||||
@@ -182,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()) {
|
||||
@@ -214,6 +225,39 @@ const Player = () => {
|
||||
}
|
||||
// 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;
|
||||
|
||||
@@ -45,16 +45,17 @@ import { Fragment, useMemo } from "react";
|
||||
import { generateArray } from "../utils/gen-list";
|
||||
import { TrackState, usePlayerContext } from "../contexts/player";
|
||||
import { Audio } from "react-loader-spinner";
|
||||
import { VideoWithRelations } from "../models/video";
|
||||
import { RequireAtLeastOne } from "type-fest";
|
||||
|
||||
type TrackType = TrackWithRelations<"illustration"> &
|
||||
RequireAtLeastOne<{
|
||||
song: SongWithRelations<"artist" | "featuring">;
|
||||
video: VideoWithRelations<"artist">;
|
||||
}>;
|
||||
type ReleaseTracklistProps = {
|
||||
mainArtist: Artist | undefined | null;
|
||||
tracklist:
|
||||
| Tracklist<
|
||||
TrackWithRelations<"illustration"> & {
|
||||
song: SongWithRelations<"artist" | "featuring">;
|
||||
}
|
||||
>
|
||||
| undefined;
|
||||
tracklist: Tracklist<TrackType> | undefined;
|
||||
release: Release | undefined;
|
||||
};
|
||||
|
||||
@@ -84,10 +85,12 @@ const ReleaseTrackList = ({
|
||||
const flatTracklist = tracklist
|
||||
? Array.from(Object.values(tracklist)).flat()
|
||||
: undefined;
|
||||
const formatTracksubtitle = (
|
||||
song: SongWithRelations<"artist" | "featuring">,
|
||||
) => {
|
||||
if (song.artistId === mainArtist?.id && song.featuring?.length === 0) {
|
||||
const formatTracksubtitle = (song: {
|
||||
artistId: number;
|
||||
artist: Artist;
|
||||
featuring?: Artist[];
|
||||
}) => {
|
||||
if (song.artistId === mainArtist?.id && !song.featuring?.length) {
|
||||
return undefined;
|
||||
}
|
||||
return formatArtists(song.artist, song.featuring);
|
||||
@@ -113,169 +116,181 @@ const ReleaseTrackList = ({
|
||||
)
|
||||
}
|
||||
>
|
||||
{(
|
||||
disc[1] as (
|
||||
| (TrackWithRelations<"illustration"> & {
|
||||
song: SongWithRelations<
|
||||
"artist" | "featuring"
|
||||
>;
|
||||
})
|
||||
| undefined
|
||||
)[]
|
||||
).map((currentTrack, index) => (
|
||||
<Fragment key={index}>
|
||||
<ListItem
|
||||
dense={
|
||||
mainArtist === undefined
|
||||
? false
|
||||
: currentTrack
|
||||
? currentTrack.song.artistId !=
|
||||
mainArtist?.id
|
||||
: false
|
||||
}
|
||||
disablePadding
|
||||
disableGutters
|
||||
secondaryAction={
|
||||
currentTrack ? (
|
||||
<ReleaseTrackContextualMenu
|
||||
track={currentTrack}
|
||||
artist={currentTrack.song.artist}
|
||||
/>
|
||||
) : (
|
||||
<IconButton disabled>
|
||||
<ContextualMenuIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
>
|
||||
<ListItemButton
|
||||
onClick={
|
||||
flatTracklist &&
|
||||
currentTrack &&
|
||||
release &&
|
||||
(() =>
|
||||
playTracks({
|
||||
tracks: flatTracklist.map(
|
||||
(flatTrack) => ({
|
||||
track: flatTrack,
|
||||
release,
|
||||
artist: flatTrack.song
|
||||
.artist,
|
||||
}),
|
||||
),
|
||||
cursor: flatTracklist.findIndex(
|
||||
(flatTrack) =>
|
||||
flatTrack.id ==
|
||||
currentTrack.id,
|
||||
),
|
||||
}))
|
||||
{(disc[1] as (TrackType | undefined)[]).map(
|
||||
(currentTrack, index) => (
|
||||
<Fragment key={index}>
|
||||
<ListItem
|
||||
dense={
|
||||
mainArtist === undefined
|
||||
? false
|
||||
: currentTrack
|
||||
? (currentTrack.song ??
|
||||
currentTrack.video)!
|
||||
.artistId !=
|
||||
mainArtist?.id
|
||||
: false
|
||||
}
|
||||
sx={{
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{currentTrack ? (
|
||||
currentTrack.id ===
|
||||
currentlyPlayingTrack?.track.id ? (
|
||||
<PlayingIcon />
|
||||
) : (
|
||||
<Typography color="text.disabled">
|
||||
{currentTrack.trackIndex}
|
||||
</Typography>
|
||||
)
|
||||
) : (
|
||||
<Skeleton
|
||||
width="20px"
|
||||
sx={{ color: "text.disabled" }}
|
||||
/>
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
currentTrack?.name ?? (
|
||||
<Skeleton width="120px" />
|
||||
)
|
||||
}
|
||||
primaryTypographyProps={{
|
||||
fontSize: "medium",
|
||||
}}
|
||||
secondary={
|
||||
mainArtist === undefined
|
||||
? null
|
||||
: currentTrack
|
||||
? formatTracksubtitle(
|
||||
currentTrack.song,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
secondaryTypographyProps={{
|
||||
fontSize: "small",
|
||||
color: "text.disabled",
|
||||
}}
|
||||
/>
|
||||
{currentTrack?.isBonus && (
|
||||
<Typography
|
||||
sx={{
|
||||
display: {
|
||||
xs: "none",
|
||||
sm: "block",
|
||||
},
|
||||
}}
|
||||
color="text.disabled"
|
||||
>
|
||||
{t("bonusTrack")}
|
||||
</Typography>
|
||||
)}
|
||||
{currentTrack?.isRemastered && (
|
||||
<Typography
|
||||
color="text.disabled"
|
||||
sx={{
|
||||
display: {
|
||||
xs: "none",
|
||||
sm: "block",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t("remastered")}
|
||||
</Typography>
|
||||
)}
|
||||
{currentTrack?.type == "Video" && (
|
||||
<Icon
|
||||
sx={{
|
||||
marginLeft: 2,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<VideoIcon
|
||||
color={
|
||||
theme.palette.text.disabled
|
||||
disablePadding
|
||||
disableGutters
|
||||
secondaryAction={
|
||||
currentTrack ? (
|
||||
<ReleaseTrackContextualMenu
|
||||
track={{
|
||||
...currentTrack,
|
||||
song:
|
||||
currentTrack.song ??
|
||||
null,
|
||||
}}
|
||||
artist={
|
||||
(currentTrack.song ??
|
||||
currentTrack.video)!
|
||||
.artist
|
||||
}
|
||||
/>
|
||||
</Icon>
|
||||
)}
|
||||
<Typography
|
||||
color="text.disabled"
|
||||
) : (
|
||||
<IconButton disabled>
|
||||
<ContextualMenuIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
>
|
||||
<ListItemButton
|
||||
onClick={
|
||||
flatTracklist &&
|
||||
currentTrack &&
|
||||
release &&
|
||||
(() =>
|
||||
playTracks({
|
||||
tracks: flatTracklist.map(
|
||||
(flatTrack) => ({
|
||||
track: flatTrack,
|
||||
release,
|
||||
artist: (flatTrack.song ??
|
||||
flatTrack.video)!
|
||||
.artist,
|
||||
}),
|
||||
),
|
||||
cursor: flatTracklist.findIndex(
|
||||
(flatTrack) =>
|
||||
flatTrack.id ==
|
||||
currentTrack.id,
|
||||
),
|
||||
}))
|
||||
}
|
||||
sx={{
|
||||
marginLeft: 2,
|
||||
overflow: "unset",
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
}}
|
||||
>
|
||||
{currentTrack ? (
|
||||
formatDuration(
|
||||
currentTrack.duration,
|
||||
)
|
||||
) : (
|
||||
<Skeleton width="30px" />
|
||||
<ListItemIcon>
|
||||
{currentTrack ? (
|
||||
currentTrack.id ===
|
||||
currentlyPlayingTrack?.track
|
||||
.id ? (
|
||||
<PlayingIcon />
|
||||
) : (
|
||||
<Typography color="text.disabled">
|
||||
{
|
||||
currentTrack.trackIndex
|
||||
}
|
||||
</Typography>
|
||||
)
|
||||
) : (
|
||||
<Skeleton
|
||||
width="20px"
|
||||
sx={{
|
||||
color: "text.disabled",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
currentTrack?.name ?? (
|
||||
<Skeleton width="120px" />
|
||||
)
|
||||
}
|
||||
primaryTypographyProps={{
|
||||
fontSize: "medium",
|
||||
}}
|
||||
secondary={
|
||||
mainArtist === undefined
|
||||
? null
|
||||
: currentTrack
|
||||
? formatTracksubtitle(
|
||||
(currentTrack.song ??
|
||||
currentTrack.video)!,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
secondaryTypographyProps={{
|
||||
fontSize: "small",
|
||||
color: "text.disabled",
|
||||
}}
|
||||
/>
|
||||
{currentTrack?.isBonus && (
|
||||
<Typography
|
||||
sx={{
|
||||
display: {
|
||||
xs: "none",
|
||||
sm: "block",
|
||||
},
|
||||
}}
|
||||
color="text.disabled"
|
||||
>
|
||||
{t("bonusTrack")}
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
<Divider variant="inset" />
|
||||
</Fragment>
|
||||
))}
|
||||
{currentTrack?.isRemastered && (
|
||||
<Typography
|
||||
color="text.disabled"
|
||||
sx={{
|
||||
display: {
|
||||
xs: "none",
|
||||
sm: "block",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t("remastered")}
|
||||
</Typography>
|
||||
)}
|
||||
{currentTrack?.type == "Video" && (
|
||||
<Icon
|
||||
sx={{
|
||||
marginLeft: 2,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<VideoIcon
|
||||
color={
|
||||
theme.palette.text
|
||||
.disabled
|
||||
}
|
||||
/>
|
||||
</Icon>
|
||||
)}
|
||||
<Typography
|
||||
color="text.disabled"
|
||||
sx={{
|
||||
marginLeft: 2,
|
||||
overflow: "unset",
|
||||
}}
|
||||
>
|
||||
{currentTrack ? (
|
||||
formatDuration(
|
||||
currentTrack.duration,
|
||||
)
|
||||
) : (
|
||||
<Skeleton width="30px" />
|
||||
)}
|
||||
</Typography>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
<Divider variant="inset" />
|
||||
</Fragment>
|
||||
),
|
||||
)}
|
||||
</List>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -17,17 +17,16 @@
|
||||
*/
|
||||
|
||||
import { Box, Link, Skeleton } from "@mui/material";
|
||||
import ExternalId from "../models/external-id";
|
||||
import { type CommonExternalMetadata } from "../models/external-metadata";
|
||||
import { useState } from "react";
|
||||
import { capitalCase } from "change-case";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { generateArray } from "../utils/gen-list";
|
||||
|
||||
type Props = {
|
||||
externalDescription: ExternalId | undefined;
|
||||
externalMetadata: CommonExternalMetadata | undefined;
|
||||
};
|
||||
|
||||
const ResourceDescriptionExpandable = ({ externalDescription }: Props) => {
|
||||
const ResourceDescriptionExpandable = ({ externalMetadata }: Props) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const maxLine = 5;
|
||||
const smallBoxStyle = {
|
||||
@@ -42,27 +41,13 @@ const ResourceDescriptionExpandable = ({ externalDescription }: Props) => {
|
||||
|
||||
return (
|
||||
<Box id="description" sx={isExpanded ? bigBoxStyle : smallBoxStyle}>
|
||||
{externalDescription
|
||||
? externalDescription.description
|
||||
: generateArray(5).map((_, index) => <Skeleton key={index} />)}
|
||||
{isExpanded && (
|
||||
<>
|
||||
{" Source: "}
|
||||
<Link
|
||||
href={externalDescription?.url ?? undefined}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{externalDescription ? (
|
||||
capitalCase(externalDescription?.provider.name)
|
||||
) : (
|
||||
<Skeleton width={50} />
|
||||
)}
|
||||
</Link>
|
||||
{"."}
|
||||
</>
|
||||
)}{" "}
|
||||
{externalDescription && (
|
||||
{externalMetadata?.description ||
|
||||
(externalMetadata?.description === undefined &&
|
||||
generateArray(5).map((_, index) => (
|
||||
<Skeleton key={index} />
|
||||
)))}
|
||||
|
||||
{externalMetadata?.description && (
|
||||
<Link
|
||||
href="#description"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
|
||||
@@ -298,30 +298,21 @@ const BottomNavigation = (props: { onDrawerOpen: () => void }) => {
|
||||
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}
|
||||
|
||||
@@ -41,7 +41,6 @@ import AdminGrid from "../admin-grid";
|
||||
import {
|
||||
CleanAllLibrariesAction,
|
||||
CleanLibraryAction,
|
||||
FetchExternalMetadata,
|
||||
ScanAllLibrariesAction,
|
||||
ScanLibraryAction,
|
||||
} from "../actions/library-task";
|
||||
@@ -51,7 +50,6 @@ import { ComponentProps, useMemo, useState } from "react";
|
||||
import LibraryForm from "../library-form";
|
||||
import { RefreshLibraryMetadataAction } from "../actions/refresh-metadata";
|
||||
import SectionHeader from "../section-header";
|
||||
import { capitalCase } from "change-case";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const actionButtonStyle = {
|
||||
@@ -102,25 +100,21 @@ const LibrariesSettings = () => {
|
||||
CleanAllLibrariesAction.onClick?.();
|
||||
},
|
||||
};
|
||||
const fetchMetadata = {
|
||||
...FetchExternalMetadata,
|
||||
onClick: () => {
|
||||
tasks.refetch();
|
||||
FetchExternalMetadata.onClick?.();
|
||||
},
|
||||
};
|
||||
const confirm = useConfirm();
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [libraryEdit, setLibraryEdit] = useState<Library | undefined>(); // If set, open modal to edit library
|
||||
const closeEditModal = () => setLibraryEdit(undefined);
|
||||
const closeCreateModal = () => setCreateModalOpen(false);
|
||||
const deletionMutation = useMutation((libraryId: number) =>
|
||||
API.deleteLibrary(libraryId)
|
||||
.then(() => {
|
||||
toast.success(t("libraryDeleted"));
|
||||
queryClient.client.invalidateQueries(["libraries"]);
|
||||
toast
|
||||
.promise(API.deleteLibrary(libraryId), {
|
||||
loading: t("deletingLibrary"),
|
||||
success: t("libraryDeleted"),
|
||||
error: t("libraryDeletionFail"),
|
||||
})
|
||||
.catch(() => toast.error(t("libraryDeletionFail"))),
|
||||
.then(() => {
|
||||
queryClient.client.invalidateQueries(["libraries"]);
|
||||
}),
|
||||
);
|
||||
const createMutation = useMutation(
|
||||
(createForm: { name: string; path: string }) =>
|
||||
@@ -243,19 +237,17 @@ const LibrariesSettings = () => {
|
||||
{t("createLibrary")}
|
||||
</Button>
|
||||
</Grid>
|
||||
{[cleanAllLibaries, scanAllLibaries, fetchMetadata].map(
|
||||
(action, index) => (
|
||||
<Grid item key={"Library-action-" + index}>
|
||||
<Button
|
||||
variant={index % 2 ? "contained" : "outlined"}
|
||||
startIcon={action.icon}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{t(action.label)}
|
||||
</Button>
|
||||
</Grid>
|
||||
),
|
||||
)}
|
||||
{[cleanAllLibaries, scanAllLibaries].map((action, index) => (
|
||||
<Grid item key={"Library-action-" + index}>
|
||||
<Button
|
||||
variant={index % 2 ? "contained" : "outlined"}
|
||||
startIcon={action.icon}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{t(action.label)}
|
||||
</Button>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
<Dialog
|
||||
open={libraryEdit != undefined}
|
||||
@@ -298,16 +290,9 @@ const LibrariesSettings = () => {
|
||||
<ListItemText
|
||||
primary={
|
||||
tasks.data ? (
|
||||
`${t("current")}: ${capitalCase(
|
||||
tasks.data.active?.name ?? t("none"),
|
||||
)}`
|
||||
) : (
|
||||
<Skeleton />
|
||||
)
|
||||
}
|
||||
secondary={
|
||||
tasks.data ? (
|
||||
tasks.data.active?.description
|
||||
`${t("current")}: ${
|
||||
tasks.data.current_task ?? t("none")
|
||||
}`
|
||||
) : (
|
||||
<Skeleton />
|
||||
)
|
||||
@@ -319,7 +304,7 @@ const LibrariesSettings = () => {
|
||||
primary={
|
||||
tasks.data ? (
|
||||
`${t("pending")}: ${
|
||||
tasks.data.pending.length || t("none")
|
||||
tasks.data.pending_tasks.length || t("none")
|
||||
}`
|
||||
) : (
|
||||
<Skeleton />
|
||||
@@ -327,13 +312,9 @@ const LibrariesSettings = () => {
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
{tasks.data?.pending.map((task, index) => (
|
||||
{tasks.data?.pending_tasks.map((task, index) => (
|
||||
<ListItem key={"task-" + index}>
|
||||
<ListItemText
|
||||
inset
|
||||
primary={capitalCase(task.name)}
|
||||
secondary={task.description}
|
||||
/>
|
||||
<ListItemText inset primary={task} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
@@ -158,6 +158,12 @@ const UserSettings = () => {
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<SectionHeader heading={t("keyboadBindings")} />
|
||||
<Grid container sx={SettingGroupStyle}>
|
||||
<Grid item xs={12}>
|
||||
{t("openKeyboarBindingsModalByTypingQuestionMark")}
|
||||
</Grid>
|
||||
</Grid>
|
||||
<SectionHeader heading={t("project")} />
|
||||
<p>
|
||||
<Star style={LinkIconStyle} />
|
||||
|
||||
@@ -28,9 +28,9 @@ const TileRow = (props: TileRowProps) => {
|
||||
const windowSize = {
|
||||
xs: props.windowSize?.xs ?? 2,
|
||||
sm: props.windowSize?.sm ?? 3,
|
||||
md: props.windowSize?.md ?? 5,
|
||||
lg: props.windowSize?.lg ?? 6,
|
||||
xl: props.windowSize?.xl ?? 8,
|
||||
md: props.windowSize?.md ?? 4,
|
||||
lg: props.windowSize?.lg ?? 5,
|
||||
xl: props.windowSize?.xl ?? 7,
|
||||
};
|
||||
const width = Object.entries(windowSize).reduce(
|
||||
(prev, [key, size]) => ({ ...prev, [key]: 100 / size + "%" }),
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useTranslation } from "react-i18next";
|
||||
const AlbumTile = (props: {
|
||||
album: AlbumWithRelations<"artist" | "illustration"> | undefined;
|
||||
formatSubtitle?: (album: AlbumWithRelations<"artist">) => string;
|
||||
onClick?: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -41,6 +42,7 @@ const AlbumTile = (props: {
|
||||
props.album?.artist?.name ??
|
||||
t("compilation")
|
||||
}
|
||||
onClick={props.onClick}
|
||||
href={props.album ? `/albums/${props.album.slug}` : undefined}
|
||||
secondaryHref={
|
||||
props.album
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Box } from "@mui/material";
|
||||
|
||||
const ArtistTile = (props: {
|
||||
artist: ArtistWithRelations<"illustration"> | undefined;
|
||||
onClick?: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<Tile
|
||||
@@ -31,6 +32,7 @@ const ArtistTile = (props: {
|
||||
contextualMenu={
|
||||
props.artist && <ArtistContextualMenu artist={props.artist} />
|
||||
}
|
||||
onClick={props.onClick}
|
||||
subtitle={null}
|
||||
title={props.artist?.name}
|
||||
href={props.artist && `/artists/${props.artist.slug}`}
|
||||
|
||||
@@ -24,8 +24,10 @@ import { PlaylistIcon } from "../icons";
|
||||
|
||||
const PlaylistTile = ({
|
||||
playlist,
|
||||
onClick,
|
||||
}: {
|
||||
playlist: PlaylistWithRelations<"illustration"> | undefined;
|
||||
onClick?: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<Tile
|
||||
@@ -34,6 +36,7 @@ const PlaylistTile = ({
|
||||
}
|
||||
title={playlist?.name}
|
||||
subtitle={null}
|
||||
onClick={onClick}
|
||||
href={playlist ? `/playlists/${playlist.slug}` : undefined}
|
||||
illustration={
|
||||
<Illustration
|
||||
|
||||
@@ -25,6 +25,7 @@ import ReleaseContextualMenu from "../contextual-menu/release-contextual-menu";
|
||||
const ReleaseTile = (props: {
|
||||
release: ReleaseWithRelations<"album" | "illustration"> | undefined;
|
||||
formatSubtitle?: (release: ReleaseWithRelations<"album">) => string;
|
||||
onClick?: () => void;
|
||||
}) => {
|
||||
const yearFormat = props.release
|
||||
? getYear(props.release.releaseDate)?.toString() ?? ""
|
||||
@@ -36,6 +37,7 @@ const ReleaseTile = (props: {
|
||||
<ReleaseContextualMenu release={props.release} />
|
||||
)
|
||||
}
|
||||
onClick={props.onClick}
|
||||
title={props.release?.name}
|
||||
subtitle={
|
||||
props.release
|
||||
|
||||
@@ -21,21 +21,28 @@ import Illustration from "../illustration";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import API from "../../api/api";
|
||||
import formatDuration from "../../utils/formatDuration";
|
||||
import TrackContextualMenu from "../contextual-menu/track-contextual-menu";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import Video, { VideoWithRelations } from "../../models/video";
|
||||
import { VideoWithRelations } from "../../models/video";
|
||||
import VideoContextualMenu from "../contextual-menu/video-contextual-menu";
|
||||
|
||||
type VideoTileProps =
|
||||
type VideoTileProps = (
|
||||
| {
|
||||
video: Video | undefined;
|
||||
video: VideoWithRelations<"master" | "illustration"> | undefined;
|
||||
subtitle: "duration";
|
||||
}
|
||||
| {
|
||||
video: VideoWithRelations<"artist"> | undefined;
|
||||
video:
|
||||
| VideoWithRelations<"artist" | "master" | "illustration">
|
||||
| undefined;
|
||||
subtitle: "artist";
|
||||
};
|
||||
}
|
||||
) & { onClick?: () => void };
|
||||
|
||||
const VideoTile = ({ video, subtitle: subtitleType }: VideoTileProps) => {
|
||||
const VideoTile = ({
|
||||
video,
|
||||
subtitle: subtitleType,
|
||||
onClick,
|
||||
}: VideoTileProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { playTrack } = usePlayerContext();
|
||||
const { subtitle, secondaryHref } = !video
|
||||
@@ -44,7 +51,7 @@ const VideoTile = ({ video, subtitle: subtitleType }: VideoTileProps) => {
|
||||
subtitle:
|
||||
subtitleType == "artist"
|
||||
? video.artist.name
|
||||
: formatDuration(video.track.duration),
|
||||
: formatDuration(video.master.duration),
|
||||
secondaryHref:
|
||||
subtitleType == "artist"
|
||||
? `/artists/${video.artist.slug}`
|
||||
@@ -53,25 +60,23 @@ const VideoTile = ({ video, subtitle: subtitleType }: VideoTileProps) => {
|
||||
|
||||
return (
|
||||
<Tile
|
||||
contextualMenu={
|
||||
video && (
|
||||
<TrackContextualMenu
|
||||
isVideo
|
||||
track={{ ...video.track, song: video }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
contextualMenu={video && <VideoContextualMenu video={video} />}
|
||||
onClick={
|
||||
video
|
||||
? () =>
|
||||
? () => {
|
||||
onClick?.();
|
||||
queryClient
|
||||
.fetchQuery(API.getArtist(video.artistId))
|
||||
.then((artist) =>
|
||||
playTrack({
|
||||
track: video.track,
|
||||
track: {
|
||||
...video.master,
|
||||
illustration: video.illustration,
|
||||
},
|
||||
artist: artist,
|
||||
}),
|
||||
)
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
title={video?.name}
|
||||
@@ -81,7 +86,7 @@ const VideoTile = ({ video, subtitle: subtitleType }: VideoTileProps) => {
|
||||
<Illustration
|
||||
quality="medium"
|
||||
aspectRatio={16 / 9}
|
||||
illustration={video?.track.illustration}
|
||||
illustration={video?.illustration}
|
||||
imgProps={{ objectFit: "cover" }}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* 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 {
|
||||
DependencyList,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { TranslationKey } from "../i18n/i18n";
|
||||
import { useKey as _useKey } from "react-use";
|
||||
import { Handler } from "react-use/lib/useKey";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
type BindingKey = "esc" | "?" | "/" | "s" | "p" | "space";
|
||||
|
||||
const bindingKeyToUseKeyParam = (bindingKey: BindingKey): string => {
|
||||
switch (bindingKey) {
|
||||
case "esc":
|
||||
return "Escape";
|
||||
case "space":
|
||||
return " ";
|
||||
default:
|
||||
return bindingKey;
|
||||
}
|
||||
};
|
||||
|
||||
type Binding = {
|
||||
key: BindingKey;
|
||||
description: TranslationKey;
|
||||
};
|
||||
|
||||
type BindingsState = {
|
||||
bindings: Binding[];
|
||||
};
|
||||
|
||||
type KeyboardBindingsActions = {
|
||||
addBinding: (binding: BindingKey, description: TranslationKey) => void;
|
||||
removeBinding: (binding: BindingKey) => void;
|
||||
};
|
||||
|
||||
const KeyboardBindings = createContext<BindingsState & KeyboardBindingsActions>(
|
||||
{
|
||||
bindings: [],
|
||||
addBinding: () => {},
|
||||
removeBinding: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
const useKey = (...p: Parameters<typeof _useKey>) => {
|
||||
return _useKey(
|
||||
p[0],
|
||||
(e) => {
|
||||
const tagName = document.activeElement?.tagName.toLowerCase();
|
||||
if (tagName == "input" || tagName == "textarea") {
|
||||
return;
|
||||
}
|
||||
p[1]?.(e);
|
||||
},
|
||||
p[2],
|
||||
p[3],
|
||||
);
|
||||
};
|
||||
|
||||
export const KeyboardBindingsProvider = (props: {
|
||||
children: JSX.Element[];
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const [{ bindings }, setBindings] = useState<BindingsState>({
|
||||
bindings: [
|
||||
{ key: "/", description: "goToSearchPage" },
|
||||
{ key: "s", description: "goToSettingsPage" },
|
||||
],
|
||||
});
|
||||
useKey(bindingKeyToUseKeyParam("/"), () => router.push("/search"));
|
||||
useKey(bindingKeyToUseKeyParam("s"), () => router.push("/settings"));
|
||||
|
||||
return (
|
||||
<KeyboardBindings.Provider
|
||||
value={{
|
||||
bindings,
|
||||
addBinding: (newBindingKey, description) => {
|
||||
setBindings((state) => {
|
||||
const bindingsCopy = Array.of(...state.bindings);
|
||||
const oldBinding = bindingsCopy.find(
|
||||
(b) => b.key == newBindingKey,
|
||||
);
|
||||
if (!oldBinding) {
|
||||
bindingsCopy.push({
|
||||
key: newBindingKey,
|
||||
description: description,
|
||||
});
|
||||
return { bindings: bindingsCopy };
|
||||
}
|
||||
if (oldBinding.description == description) {
|
||||
return state;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
"Keyboard binding is probably getting duplicated.",
|
||||
{ newBindingKey, description },
|
||||
);
|
||||
oldBinding.description = description;
|
||||
return { bindings: bindingsCopy };
|
||||
});
|
||||
},
|
||||
removeBinding: (bindingKey) =>
|
||||
setBindings((state) => {
|
||||
return {
|
||||
bindings: state.bindings.filter(
|
||||
(b) => b.key !== bindingKey,
|
||||
),
|
||||
};
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</KeyboardBindings.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useKeyboardBindingContext = () => useContext(KeyboardBindings);
|
||||
|
||||
export const useKeyboardBinding = (
|
||||
binding: Binding & { handler: Handler },
|
||||
deps: DependencyList = [],
|
||||
) => {
|
||||
const keyboardContext = useContext(KeyboardBindings);
|
||||
|
||||
useKey(
|
||||
bindingKeyToUseKeyParam(binding.key),
|
||||
(e) => binding.handler(e),
|
||||
{},
|
||||
[...deps],
|
||||
);
|
||||
useEffect(() => {
|
||||
keyboardContext.addBinding(binding.key, binding.description);
|
||||
return () => {
|
||||
keyboardContext.removeBinding(binding.key);
|
||||
};
|
||||
}, []);
|
||||
return {};
|
||||
};
|
||||
@@ -32,6 +32,7 @@
|
||||
"disc": "Disc",
|
||||
"otherAlbumReleases": "Other releases of the same album",
|
||||
"musicVideos": "Music Videos",
|
||||
"livePerformances": "Live Performances",
|
||||
"playlistReorderSuccess": "Playlist Reordered!",
|
||||
"playlistReorderFail": "Playlist Reordering Failed",
|
||||
"playlistItemDeletionSuccess": "Deletion Successful",
|
||||
@@ -41,6 +42,7 @@
|
||||
"artists": "Artists",
|
||||
"albums": "Albums",
|
||||
"album": "Album",
|
||||
"video": "Video",
|
||||
"songs": "Songs",
|
||||
"song": "Song",
|
||||
"videos": "Videos",
|
||||
@@ -57,15 +59,12 @@
|
||||
"path": "Path",
|
||||
"registrationDate": "Registration Date",
|
||||
"noLyricsFound": "No Lyrics Found",
|
||||
|
||||
"changeAlbumType": "Change Album Type",
|
||||
|
||||
"libraryDeleted": "Library deleted",
|
||||
"libraryDeletionFail": "Deleting library failed",
|
||||
"libraryCreated": "Library created",
|
||||
"libraryCreationFail": "Creating library failed",
|
||||
"libraryUpdated": "Library updated",
|
||||
|
||||
"nameOfLibrary": "Name of the library",
|
||||
"nameIsRequired": "Name is required",
|
||||
"pathOfLibrary": "Path of the library",
|
||||
@@ -73,29 +72,24 @@
|
||||
"goBackHome": "Go Back Home",
|
||||
"oops": "Oops",
|
||||
"by": "by",
|
||||
|
||||
"warning": "Warning",
|
||||
"downloadWarning": "Downloading copyrighted material you don't own is not authorized. Please proceed if, and only if, you own the original content.",
|
||||
"download": "Download",
|
||||
"compilation": "Compilation",
|
||||
|
||||
"clean": "Clean",
|
||||
"refresh": "Refresh",
|
||||
"scan": "Scan",
|
||||
"refreshMetadata": "Refresh Metadata",
|
||||
|
||||
"createLibrary": "Create Library",
|
||||
|
||||
"deletingLibrary": "Deleting library...",
|
||||
"deleteLibrary": "Delete Library",
|
||||
"deleteLibraryAction": "Delete a Library",
|
||||
"deleteLibraryWarning": "You are about to delete a library. This can not be undone",
|
||||
|
||||
"deleteUser": "Delete User",
|
||||
"deleteUserWarning": "You are about to delete a user. This can not be undone.",
|
||||
"userDeleted": "User deleted",
|
||||
"userDeletionFail": "User deletion failed",
|
||||
"userUpdateFail": "Updating user failed",
|
||||
|
||||
"userNowEnabled": "User is now enabled",
|
||||
"userNowDisabled": "User is now disabled",
|
||||
"userNowAdmin": "User is now an admin and can run administrative tasks",
|
||||
@@ -103,9 +97,9 @@
|
||||
"you": "You",
|
||||
"admin": "Admin",
|
||||
"enabled": "Enabled",
|
||||
|
||||
"trackSetAsMaster": "Track set as Master",
|
||||
|
||||
"showAllTrack": "Show all tracks",
|
||||
"showOnlyExclusiveTracks": "Show only exclusive tracks",
|
||||
"All": "All",
|
||||
"StudioRecording": "Studio Recording",
|
||||
"Single": "Single",
|
||||
@@ -114,7 +108,14 @@
|
||||
"Soundtrack": "Soundtrack",
|
||||
"RemixAlbum": "Remix Album",
|
||||
"VideoAlbum": "Video Album",
|
||||
|
||||
"MusicVideo": "Music Video",
|
||||
"LyricsVideo": "Lyric Video",
|
||||
"BehindTheScenes": "Behind The Scenes",
|
||||
"Interview": "Interview",
|
||||
"Advert": "Advert",
|
||||
"PhotoGallery": "Photo Gallery",
|
||||
"Documentary": "Documentary",
|
||||
"Other": "Other",
|
||||
"pluralStudioRecording": "Studio Albums",
|
||||
"pluralSingle": "Singles",
|
||||
"pluralLiveRecording": "Live Albums",
|
||||
@@ -122,12 +123,11 @@
|
||||
"pluralSoundtrack": "Soundtrack Albums",
|
||||
"pluralRemixAlbum": "Remixes",
|
||||
"pluralVideoAlbum": "Video Albums",
|
||||
|
||||
"groupVersions": "Group Versions",
|
||||
"showAllSongs": "Show All Versions",
|
||||
"onThisAlbum": "Featured Artists",
|
||||
|
||||
"backToTop": "Back To Top",
|
||||
"changeLayout": "Change layout",
|
||||
|
||||
"sortBy": "Sort By",
|
||||
"order": "Order",
|
||||
"asc": "Asc",
|
||||
@@ -144,18 +144,14 @@
|
||||
"bitrate": "Bitrate",
|
||||
"trackIndex": "Track Index",
|
||||
"discIndex": "Disc Index",
|
||||
|
||||
"allLibraries": "All Libraries",
|
||||
|
||||
"setAsMaster": "Set as Master",
|
||||
"setAllTracksAsMaster": "Set all tracks as Master",
|
||||
"deleteFromPlaylist": "Delete from Playlist",
|
||||
"changeIllutration": "Change Illustration",
|
||||
"releaseSetAsMaster": "Release set as master",
|
||||
"tracksUpdated": "Tracks successfully updated",
|
||||
|
||||
"librariesLoadFail": "Libraries could not be loaded",
|
||||
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm",
|
||||
@@ -191,12 +187,12 @@
|
||||
"darkMode": "Dark Mode",
|
||||
"autoMode": "Auto Mode",
|
||||
"logout": "Logout",
|
||||
|
||||
"appearsOn": "Appears On",
|
||||
"featuredOnPlaylists": "Featured On",
|
||||
"bonusTracks": "Bonus Tracks",
|
||||
"relatedAlbums": "Related Albums",
|
||||
"changeSongType": "Change Song Type",
|
||||
"changeVideoType": "Change Video Type",
|
||||
"Original": "Original",
|
||||
"Remix": "Remix",
|
||||
"Live": "Live",
|
||||
@@ -206,12 +202,10 @@
|
||||
"Clean": "Clean",
|
||||
"Demo": "Demo",
|
||||
"Unknown": "Unknown",
|
||||
"Acapella": "Acapella",
|
||||
"Acappella": "A Cappella",
|
||||
"Medley": "Medley/Megamix",
|
||||
|
||||
"refreshMetadataStarted": "Refreshing Metadata... (You might need to refresh the page)",
|
||||
"refreshMetadataFailed": "Oops... Refreshing Metadata Failed",
|
||||
|
||||
"more": "More",
|
||||
"networkError": "There seem to be some network issues...",
|
||||
"playbackError": "The track's format is not supported by this browser. Skipping...",
|
||||
@@ -240,11 +234,19 @@
|
||||
"encounteredABug": "You encountered a bug?",
|
||||
"openAnIssue": "Open an issue on GitHub!",
|
||||
"readTheDoc": "Read the user documentation",
|
||||
|
||||
"en": "English",
|
||||
"fr": "French",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"featuredAlbums": "Featured Albums",
|
||||
"rareSongs": "Rare Songs"
|
||||
"rareSongs": "Rare Songs",
|
||||
"leaveWillStopPlaybackAlert": "Leaving the website will stop playback.",
|
||||
"keyboadBindings": "Keyboard Shortcuts",
|
||||
"openKeyboarBindingsModalByTypingQuestionMark": "Access Keyboard Bindings list by typing '?'",
|
||||
"openModalShortcutDescription": "Open / Close this modal",
|
||||
"goToSearchPage": "Go to Search page",
|
||||
"goToSettingsPage": "Go to Settings",
|
||||
"openClosePlayer": "Open / Close Player",
|
||||
"closePlayer": "Close Player",
|
||||
"playPauseWhenExpanded": "Play / Pause (When player is open)"
|
||||
}
|
||||
@@ -30,6 +30,7 @@
|
||||
"disc": "Disque",
|
||||
"otherAlbumReleases": "Autres disques du même album",
|
||||
"musicVideos": "Clips Vidéos",
|
||||
"livePerformances": "Performances Live",
|
||||
"playlistReorderSuccess": "Playlist mise à jour!",
|
||||
"playlistReorderFail": "Echec de la mise à jour de la playlist",
|
||||
"topSongs": "Meilleurs Titres",
|
||||
@@ -47,7 +48,6 @@
|
||||
"extension": "Extension",
|
||||
"path": "Chemin",
|
||||
"registrationDate": "Date d'ajout",
|
||||
|
||||
"nameOfLibrary": "Nom de la bibliothèque",
|
||||
"nameIsRequired": "Le nom est obligatoire",
|
||||
"pathOfLibrary": "Chemin de la bibliothèque",
|
||||
@@ -56,12 +56,11 @@
|
||||
"oops": "Oups",
|
||||
"by": "de",
|
||||
"noLyricsFound": "Aucune Parole Trouvée",
|
||||
|
||||
"warning": "Attention",
|
||||
"downloadWarning": "Il est interdit de télécharger du contenu sans en posséder l'original. Ne téléchagez pas si vous ne le posséder pas légalement.",
|
||||
"download": "Télécharger",
|
||||
|
||||
"compilation": "Compilation",
|
||||
"deletingLibrary": "Suppression de la bibliothèque",
|
||||
"libraryDeleted": "Bibliothèque Supprimée",
|
||||
"libraryDeletionFail": "Echec de l'effacement de la Bibliothèque",
|
||||
"libraryCreated": "Bibliothèque créée",
|
||||
@@ -75,7 +74,6 @@
|
||||
"deleteLibrary": "Supprimer la Bibliothèque",
|
||||
"deleteLibraryAction": "Supprimer une Bibliothèque",
|
||||
"deleteLibraryWarning": "Vous êtes sur le point de supprimer une bibliothèque. Il sera impossible de revenir en arrière.",
|
||||
|
||||
"deleteUser": "Supprimer Compte",
|
||||
"deleteUserWarning": "Vous allez supprimer un compte utilisateur. Cela ne pourra pas être annulé.",
|
||||
"userDeleted": "Compte Supprimé",
|
||||
@@ -88,7 +86,6 @@
|
||||
"you": "Vous",
|
||||
"admin": "Administrateur",
|
||||
"enabled": "Activé",
|
||||
|
||||
"All": "Tous Types",
|
||||
"StudioRecording": "Album Studio",
|
||||
"Single": "Single",
|
||||
@@ -97,6 +94,14 @@
|
||||
"Soundtrack": "Bande Originale",
|
||||
"RemixAlbum": "Album de Remixes",
|
||||
"VideoAlbum": "Collection de Vidéos",
|
||||
"MusicVideo": "Clip Video",
|
||||
"LyricsVideo": "Paroles",
|
||||
"BehindTheScenes": "Making Of",
|
||||
"Interview": "Interview",
|
||||
"Advert": "Teaser",
|
||||
"PhotoGallery": "Galerie Photo",
|
||||
"Documentary": "Documentaire",
|
||||
"Other": "Autre",
|
||||
"pluralStudioRecording": "Albums",
|
||||
"pluralSingle": "Singles",
|
||||
"pluralLiveRecording": "Albums Live",
|
||||
@@ -104,13 +109,14 @@
|
||||
"pluralSoundtrack": "Bandes Originales",
|
||||
"pluralRemixAlbum": "Remixes",
|
||||
"pluralVideoAlbum": "Albums Vidéos",
|
||||
|
||||
"groupVersions": "Grouper les versions",
|
||||
"showAllSongs": "Afficher toutes les versions",
|
||||
"backToTop": "Retour en haut",
|
||||
"changeLayout": "Changer la disposition",
|
||||
|
||||
"showAllTrack": "Afficher toutes les morceaux",
|
||||
"showOnlyExclusiveTracks": "Afficher seulement les morceaux exclusifs",
|
||||
"changeAlbumType": "Changer le Type de l'Album",
|
||||
"onThisAlbum": "Artistes Invités",
|
||||
|
||||
"sortBy": "Trier par",
|
||||
"order": "Ordre",
|
||||
"asc": "Asc",
|
||||
@@ -130,6 +136,7 @@
|
||||
"artist": "Artiste",
|
||||
"artists": "Artistes",
|
||||
"album": "Album",
|
||||
"video": "Vidéo",
|
||||
"songs": "Morceaux",
|
||||
"song": "Titre",
|
||||
"search": "Rechercher",
|
||||
@@ -180,13 +187,12 @@
|
||||
"darkMode": "Thème Sombre",
|
||||
"autoMode": "Thème Automatique",
|
||||
"logout": "Se Déconnecter",
|
||||
|
||||
"appearsOn": "Apparaît sur",
|
||||
"featuredOnPlaylists": "Apparaît dans",
|
||||
"bonusTracks": "Pistes Bonus",
|
||||
"relatedAlbums": "Albums Associés",
|
||||
"changeSongType": "Changer le type",
|
||||
|
||||
"changeVideoType": "Change le type",
|
||||
"Original": "Version Originale",
|
||||
"Remix": "Remix",
|
||||
"Live": "Version Live",
|
||||
@@ -196,9 +202,8 @@
|
||||
"Clean": "Version Clean",
|
||||
"Demo": "Maquette",
|
||||
"Unknown": "Type Inconnu",
|
||||
"Acapella": "Acapella",
|
||||
"Acappella": "A Cappella",
|
||||
"Medley": "Medley/Megamix",
|
||||
|
||||
"refreshMetadataStarted": "Mise à jour des métadonées... (Rechargez la page pour actualiser)",
|
||||
"refreshMetadataFailed": "Oups... La mise à jour des données a échoué.",
|
||||
"more": "Plus",
|
||||
@@ -229,11 +234,19 @@
|
||||
"encounteredABug": "Vous avez rencontré un problème?",
|
||||
"openAnIssue": "Ouvrir une issue sur GitHub!",
|
||||
"readTheDoc": "Accéder à la documentation utilisateur",
|
||||
|
||||
"en": "Anglais",
|
||||
"fr": "Français",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"featuredAlbums": "En Vedette",
|
||||
"rareSongs": "Pistes Inédites"
|
||||
"rareSongs": "Pistes Inédites",
|
||||
"leaveWillStopPlaybackAlert": "Quitter le site arrêtera la lecture du morceau.",
|
||||
"keyboadBindings": "Raccourcis Clavier",
|
||||
"openKeyboarBindingsModalByTypingQuestionMark": "Ouvrez la liste des raccourcis clavier avec la touche '?'",
|
||||
"openModalShortcutDescription": "Ouvrir / Fermer cette modale",
|
||||
"goToSearchPage": "Rechercher",
|
||||
"goToSettingsPage": "Accéder aux réglages",
|
||||
"openClosePlayer": "Ouvrir / Fermer le lecteur",
|
||||
"closePlayer": "Fermer le lecteur",
|
||||
"playPauseWhenExpanded": "Pauser / Jouer (Quand le lecteur est ouvert)"
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import * as yup from "yup";
|
||||
import Artist from "./artist";
|
||||
import Illustration from "./illustration";
|
||||
import Resource from "./resource";
|
||||
import ExternalId from "./external-id";
|
||||
import Genre from "./genre";
|
||||
|
||||
export const AlbumType = [
|
||||
@@ -71,7 +70,7 @@ type Album = yup.InferType<typeof Album>;
|
||||
|
||||
export default Album;
|
||||
|
||||
export type AlbumInclude = "artist" | "externalIds" | "genres" | "illustration";
|
||||
export type AlbumInclude = "artist" | "genres" | "illustration";
|
||||
|
||||
const AlbumWithRelations = <Selection extends AlbumInclude | never = never>(
|
||||
relation: Selection[],
|
||||
@@ -82,15 +81,6 @@ const AlbumWithRelations = <Selection extends AlbumInclude | never = never>(
|
||||
artist: Artist.required().nullable(),
|
||||
genres: yup.array(Genre.required()).required(),
|
||||
illustration: Illustration.required().nullable(),
|
||||
externalIds: yup
|
||||
.array(
|
||||
ExternalId.required().concat(
|
||||
yup.object({
|
||||
rating: yup.number().required().nullable(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.required(),
|
||||
})
|
||||
.pick(relation),
|
||||
);
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import ExternalId from "./external-id";
|
||||
import Illustration from "./illustration";
|
||||
import Resource from "./resource";
|
||||
import * as yup from "yup";
|
||||
@@ -37,7 +36,7 @@ const Artist = Resource.concat(
|
||||
|
||||
type Artist = yup.InferType<typeof Artist>;
|
||||
|
||||
export type ArtistInclude = "externalIds" | "illustration";
|
||||
export type ArtistInclude = "illustration";
|
||||
|
||||
const ArtistWithRelations = <Selection extends ArtistInclude | never = never>(
|
||||
relation: Selection[],
|
||||
@@ -45,7 +44,6 @@ const ArtistWithRelations = <Selection extends ArtistInclude | never = never>(
|
||||
Artist.concat(
|
||||
yup
|
||||
.object({
|
||||
externalIds: yup.array(ExternalId.required()).required(),
|
||||
illustration: Illustration.required().nullable(),
|
||||
})
|
||||
.pick(relation),
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as yup from "yup";
|
||||
|
||||
const ExternalId = yup.object({
|
||||
/**
|
||||
* Info about the provider
|
||||
*/
|
||||
provider: yup
|
||||
.object({
|
||||
/**
|
||||
* Name of the Provider
|
||||
*/
|
||||
name: yup.string().required(),
|
||||
/**
|
||||
* URL to the Homepage of the provider
|
||||
*/
|
||||
homepage: yup.string().required(),
|
||||
/**
|
||||
* API-relative route to the provider's icon
|
||||
*/
|
||||
icon: yup.string().required(),
|
||||
})
|
||||
.required(),
|
||||
/**
|
||||
* Value of the External Identifier
|
||||
*/
|
||||
value: yup.string().required(),
|
||||
/**
|
||||
* Description of the resource, from the External Identifier
|
||||
*/
|
||||
description: yup.string().defined().strict(true).nullable(),
|
||||
/**
|
||||
* URL to the related resource
|
||||
*/
|
||||
url: yup.string().required().nullable(),
|
||||
});
|
||||
|
||||
type ExternalId = yup.InferType<typeof ExternalId>;
|
||||
|
||||
export default ExternalId;
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as yup from "yup";
|
||||
|
||||
const ExternalMetadataSource = yup.object({
|
||||
url: yup.string().required(),
|
||||
providerName: yup.string().required(),
|
||||
providerId: yup.number().required(),
|
||||
providerIcon: yup.string().required(),
|
||||
});
|
||||
|
||||
const CommonExternalMetadata = yup.object({
|
||||
description: yup.string().required().nullable(),
|
||||
sources: yup.array(ExternalMetadataSource.required()).required(),
|
||||
});
|
||||
|
||||
const ArtistExternalMetadata = CommonExternalMetadata.concat(
|
||||
yup.object({
|
||||
artistId: yup.number().required(),
|
||||
}),
|
||||
);
|
||||
const SongExternalMetadata = CommonExternalMetadata.concat(
|
||||
yup.object({
|
||||
songId: yup.number().required(),
|
||||
}),
|
||||
);
|
||||
const ReleaseExternalMetadata = CommonExternalMetadata.concat(
|
||||
yup.object({
|
||||
releaseId: yup.number().required(),
|
||||
}),
|
||||
);
|
||||
|
||||
const AlbumExternalMetadata = CommonExternalMetadata.concat(
|
||||
yup.object({
|
||||
albumId: yup.number().required(),
|
||||
rating: yup.number().nullable(),
|
||||
}),
|
||||
);
|
||||
|
||||
type CommonExternalMetadata = yup.InferType<typeof CommonExternalMetadata>;
|
||||
type ArtistExternalMetadata = yup.InferType<typeof ArtistExternalMetadata>;
|
||||
type AlbumExternalMetadata = yup.InferType<typeof AlbumExternalMetadata>;
|
||||
type SongExternalMetadata = yup.InferType<typeof SongExternalMetadata>;
|
||||
type ReleaseExternalMetadata = yup.InferType<typeof CommonExternalMetadata>;
|
||||
type ExternalMetadataSource = yup.InferType<typeof ExternalMetadataSource>;
|
||||
|
||||
export {
|
||||
CommonExternalMetadata,
|
||||
ArtistExternalMetadata,
|
||||
AlbumExternalMetadata,
|
||||
SongExternalMetadata,
|
||||
ReleaseExternalMetadata,
|
||||
ExternalMetadataSource,
|
||||
};
|
||||
@@ -25,10 +25,6 @@ const File = Resource.concat(
|
||||
* Path of the track, relative to the parent library
|
||||
*/
|
||||
path: yup.string().required(),
|
||||
/**
|
||||
* MD5 checksum of the file
|
||||
*/
|
||||
md5Checksum: yup.string().required(),
|
||||
/**
|
||||
* Date of the file registration
|
||||
*/
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
import * as yup from "yup";
|
||||
import Album from "./album";
|
||||
import Resource from "./resource";
|
||||
import ExternalId from "./external-id";
|
||||
import Illustration from "./illustration";
|
||||
|
||||
/**
|
||||
@@ -58,7 +57,7 @@ type Release = yup.InferType<typeof Release>;
|
||||
|
||||
export default Release;
|
||||
|
||||
export type ReleaseInclude = "album" | "externalIds" | "illustration";
|
||||
export type ReleaseInclude = "album" | "illustration";
|
||||
|
||||
const ReleaseWithRelations = <Selection extends ReleaseInclude | never = never>(
|
||||
relation: Selection[],
|
||||
@@ -68,7 +67,6 @@ const ReleaseWithRelations = <Selection extends ReleaseInclude | never = never>(
|
||||
.object({
|
||||
album: Album.required(),
|
||||
illustration: Illustration.required().nullable(),
|
||||
externalIds: yup.array(ExternalId.required()).required(),
|
||||
})
|
||||
.pick(relation),
|
||||
);
|
||||
|
||||
@@ -20,13 +20,18 @@ import { RequireExactlyOne } from "type-fest";
|
||||
import { SongWithRelations } from "./song";
|
||||
import { ArtistWithRelations } from "./artist";
|
||||
import { AlbumWithRelations } from "./album";
|
||||
import { VideoWithRelations } from "./video";
|
||||
|
||||
export type SearchResult = RequireExactlyOne<{
|
||||
song: SongWithRelations<"artist" | "featuring" | "illustration" | "master">;
|
||||
album: AlbumWithRelations<"artist" | "illustration">;
|
||||
artist: ArtistWithRelations<"illustration">;
|
||||
video: VideoWithRelations<"illustration" | "master" | "artist">;
|
||||
}>;
|
||||
|
||||
export type SaveSearchItem = Partial<
|
||||
Record<"songId" | "albumId" | "artistId" | "videoId", number>
|
||||
>;
|
||||
export const SearchResultTransformer = (
|
||||
results: unknown,
|
||||
): Promise<SearchResult[]> => {
|
||||
@@ -36,6 +41,15 @@ export const SearchResultTransformer = (
|
||||
return Promise.all(
|
||||
results.map(async (result) => {
|
||||
if ("groupId" in result) {
|
||||
if ("songId" in result) {
|
||||
return {
|
||||
video: await VideoWithRelations([
|
||||
"artist",
|
||||
"illustration",
|
||||
"master",
|
||||
] as const).validate(result),
|
||||
};
|
||||
}
|
||||
return {
|
||||
song: await SongWithRelations([
|
||||
"artist",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as yup from "yup";
|
||||
import Song, { SongInclude, SongRelations } from "./song";
|
||||
|
||||
/**
|
||||
* Abstract data model, instanciated by tracks
|
||||
*/
|
||||
const SongGroup = Song.concat(
|
||||
yup.object({
|
||||
versionCount: yup.number().required(),
|
||||
songId: yup.number().required(),
|
||||
}),
|
||||
);
|
||||
|
||||
type SongGroup = yup.InferType<typeof SongGroup>;
|
||||
|
||||
type SongGroupInclude = SongInclude;
|
||||
|
||||
const SongGroupRelations = SongRelations;
|
||||
|
||||
const SongGroupWithRelations = <
|
||||
Selection extends SongGroupInclude | never = never,
|
||||
>(
|
||||
relation: Selection[],
|
||||
) => SongGroup.concat(SongGroupRelations.pick(relation));
|
||||
|
||||
type SongGroupWithRelations<
|
||||
Selection extends SongGroupInclude | never = never,
|
||||
> = yup.InferType<ReturnType<typeof SongGroupWithRelations<Selection>>>;
|
||||
|
||||
export default SongGroup;
|
||||
export const SongGroupSortingKeys = ["name"] as const;
|
||||
export { type SongGroupInclude, SongGroupWithRelations, SongGroupRelations };
|
||||
@@ -21,7 +21,6 @@ import Artist from "./artist";
|
||||
import Illustration from "./illustration";
|
||||
import Lyrics from "./lyrics";
|
||||
import Resource from "./resource";
|
||||
import ExternalId from "./external-id";
|
||||
import Track from "./track";
|
||||
|
||||
export const SongType = [
|
||||
@@ -34,9 +33,9 @@ export const SongType = [
|
||||
"Clean",
|
||||
"Demo",
|
||||
"Unknown",
|
||||
"Acapella",
|
||||
"NonMusic",
|
||||
"Acappella",
|
||||
"Medley",
|
||||
"NonMusic",
|
||||
] as const;
|
||||
export type SongType = (typeof SongType)[number];
|
||||
|
||||
@@ -61,6 +60,7 @@ const Song = Resource.concat(
|
||||
* The ID of the master track
|
||||
*/
|
||||
masterId: yup.number().required().nullable(),
|
||||
groupId: yup.number().required(),
|
||||
/**
|
||||
* Type of song
|
||||
*/
|
||||
@@ -73,7 +73,6 @@ type Song = yup.InferType<typeof Song>;
|
||||
type SongInclude =
|
||||
| "artist"
|
||||
| "lyrics"
|
||||
| "externalIds"
|
||||
| "featuring"
|
||||
| "master"
|
||||
| "illustration";
|
||||
@@ -83,7 +82,6 @@ const SongRelations = yup.object({
|
||||
master: yup.lazy(() => Track.required()),
|
||||
featuring: yup.array(Artist.required()).required(),
|
||||
lyrics: Lyrics.required().nullable(),
|
||||
externalIds: yup.array(ExternalId.required()).required(),
|
||||
illustration: Illustration.required().nullable(),
|
||||
});
|
||||
|
||||
|
||||
@@ -23,13 +23,15 @@ const Task = yup.object({
|
||||
description: yup.string().required(),
|
||||
});
|
||||
|
||||
const ActiveTask = Task.concat(
|
||||
yup.object({
|
||||
data: yup.mixed(),
|
||||
}),
|
||||
);
|
||||
|
||||
type Task = yup.InferType<typeof Task>;
|
||||
type ActiveTask = yup.InferType<typeof ActiveTask>;
|
||||
|
||||
export { Task, ActiveTask };
|
||||
const TaskResponse = yup.object({
|
||||
/**
|
||||
* Status of the task
|
||||
*/
|
||||
message: yup.string().required(),
|
||||
});
|
||||
|
||||
type TaskResponse = yup.InferType<typeof TaskResponse>;
|
||||
|
||||
export { Task, TaskResponse };
|
||||
@@ -21,6 +21,7 @@ import Illustration from "./illustration";
|
||||
import Release from "./release";
|
||||
import Resource from "./resource";
|
||||
import Song from "./song";
|
||||
import Video from "./video";
|
||||
|
||||
export const TrackType = ["Audio", "Video"] as const;
|
||||
export type TrackType = (typeof TrackType)[number];
|
||||
@@ -33,11 +34,15 @@ const Track = Resource.concat(
|
||||
/**
|
||||
* Unique identifier of the parent song
|
||||
*/
|
||||
songId: yup.number().required(),
|
||||
songId: yup.number().required().nullable(),
|
||||
/**
|
||||
* Unique identifier of the parent video
|
||||
*/
|
||||
videoId: yup.number().required().nullable(),
|
||||
/**
|
||||
* Unique identifier of the parent release
|
||||
*/
|
||||
releaseId: yup.number().required(),
|
||||
releaseId: yup.number().required().nullable(),
|
||||
/**
|
||||
* Title of the track
|
||||
*/
|
||||
@@ -82,11 +87,12 @@ type Track = yup.InferType<typeof Track>;
|
||||
|
||||
export default Track;
|
||||
|
||||
export type TrackInclude = "song" | "release" | "illustration";
|
||||
export type TrackInclude = "song" | "release" | "illustration" | "video";
|
||||
|
||||
const TrackRelations = yup.object({
|
||||
song: yup.lazy(() => Song.required()),
|
||||
release: Release.required(),
|
||||
song: yup.lazy(() => Song.required().nullable()),
|
||||
video: yup.lazy(() => Video.required().nullable()),
|
||||
release: Release.required().nullable(),
|
||||
illustration: Illustration.required().nullable(),
|
||||
});
|
||||
|
||||
|
||||
@@ -19,11 +19,13 @@
|
||||
import Song, { SongInclude, SongRelations } from "./song";
|
||||
import { TrackWithRelations } from "./track";
|
||||
import * as yup from "yup";
|
||||
import Video, { VideoRelations } from "./video";
|
||||
|
||||
const TracklistItem = TrackWithRelations(["illustration"])
|
||||
.concat(
|
||||
yup.object({
|
||||
song: Song.required(),
|
||||
song: Song.required().nullable(),
|
||||
video: Video.required().nullable(),
|
||||
}),
|
||||
)
|
||||
.required();
|
||||
@@ -34,7 +36,14 @@ const TracklistItemWithRelations = <Selection extends SongInclude | never>(
|
||||
TrackWithRelations(["illustration"])
|
||||
.concat(
|
||||
yup.object({
|
||||
song: Song.concat(SongRelations.pick(selection)),
|
||||
song: Song.concat(SongRelations.pick(selection)).nullable(),
|
||||
video: Video.concat(
|
||||
VideoRelations.pick(
|
||||
selection.includes("artist" as Selection)
|
||||
? ["artist"]
|
||||
: [],
|
||||
),
|
||||
).nullable(),
|
||||
}),
|
||||
)
|
||||
.required();
|
||||
|
||||
@@ -16,24 +16,69 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Song, { SongInclude, SongRelations } from "./song";
|
||||
import { TrackWithRelations } from "./track";
|
||||
import Artist from "./artist";
|
||||
import Illustration from "./illustration";
|
||||
import Resource from "./resource";
|
||||
import Song from "./song";
|
||||
import Track from "./track";
|
||||
import * as yup from "yup";
|
||||
|
||||
const Video = Song.concat(
|
||||
export const VideoType = [
|
||||
"MusicVideo",
|
||||
"LyricsVideo",
|
||||
"Live",
|
||||
"BehindTheScenes",
|
||||
"Interview",
|
||||
"Advert",
|
||||
"PhotoGallery",
|
||||
"Documentary",
|
||||
"Other",
|
||||
] as const;
|
||||
|
||||
export const VideoTypeIsExtra = (vType: VideoType) =>
|
||||
!["MusicVideo", "LyricsVideo", "Live"].includes(vType);
|
||||
|
||||
export type VideoType = (typeof VideoType)[number];
|
||||
|
||||
const Video = Resource.concat(
|
||||
yup.object({
|
||||
track: TrackWithRelations(["illustration"]).required(),
|
||||
id: yup.number().required(),
|
||||
slug: yup.string().required(),
|
||||
name: yup.string().required(),
|
||||
nameSlug: yup.string().required(),
|
||||
artistId: yup.number().required(),
|
||||
songId: yup.number().required().nullable(),
|
||||
groupId: yup.number().required().nullable(),
|
||||
registeredAt: yup.date().required(),
|
||||
type: yup.mixed<VideoType>().oneOf(VideoType).required(),
|
||||
}),
|
||||
);
|
||||
|
||||
type Video = yup.InferType<typeof Video>;
|
||||
|
||||
const VideoWithRelations = <Selection extends SongInclude | never = never>(
|
||||
relation: Selection[],
|
||||
) => Video.concat(SongRelations.pick(relation));
|
||||
export type VideoInclude = "artist" | "song" | "illustration" | "master";
|
||||
export const VideoRelations = yup.object({
|
||||
artist: Artist.required(),
|
||||
song: yup.lazy(() => Song.required().nullable()),
|
||||
master: yup.lazy(() => Track.required()),
|
||||
illustration: Illustration.required().nullable(),
|
||||
});
|
||||
|
||||
type VideoWithRelations<Selection extends SongInclude | never = never> =
|
||||
const VideoWithRelations = <Selection extends VideoInclude | never = never>(
|
||||
relation: Selection[],
|
||||
) => Video.concat(VideoRelations.pick(relation));
|
||||
|
||||
type VideoWithRelations<Selection extends VideoInclude | never = never> =
|
||||
yup.InferType<ReturnType<typeof VideoWithRelations<Selection>>>;
|
||||
|
||||
export default Video;
|
||||
export const videoTypeIsExtra = (type: VideoType) =>
|
||||
[
|
||||
"BehindTheScenes",
|
||||
"Interview",
|
||||
"Advert",
|
||||
"PhotoGallery",
|
||||
"Documentary",
|
||||
].includes(type);
|
||||
export const VideoSortingKeys = ["name", "artistName", "addDate"] as const;
|
||||
export { VideoWithRelations };
|
||||
@@ -58,6 +58,8 @@ import { UserAccessTokenCookieKey } from "../utils/cookieKeys";
|
||||
import { setAccessToken } from "../state/userSlice";
|
||||
import API from "../api/api";
|
||||
import { deepmerge } from "@mui/utils";
|
||||
import { KeyboardBindingsProvider } from "../contexts/keybindings";
|
||||
import { KeyboardBindingModal } from "../components/keyboard-bindings-modal";
|
||||
|
||||
// Client-side cache, shared for the whole session of the user in the browser.
|
||||
const clientSideEmotionCache = createEmotionCache();
|
||||
@@ -133,13 +135,16 @@ function MyApp({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PlayerContextProvider>
|
||||
<Scaffold>
|
||||
<Component
|
||||
{...pageProps}
|
||||
/>
|
||||
</Scaffold>
|
||||
</PlayerContextProvider>
|
||||
<KeyboardBindingsProvider>
|
||||
<KeyboardBindingModal />
|
||||
<PlayerContextProvider>
|
||||
<Scaffold>
|
||||
<Component
|
||||
{...pageProps}
|
||||
/>
|
||||
</Scaffold>
|
||||
</PlayerContextProvider>
|
||||
</KeyboardBindingsProvider>
|
||||
</ErrorBoundary>
|
||||
</AuthenticationWall>
|
||||
</Hydrate>
|
||||
|
||||
@@ -23,7 +23,6 @@ import { useInfiniteQuery, useQuery } from "../../../api/use-query";
|
||||
import getSlugOrId from "../../../utils/getSlugOrId";
|
||||
import { GetPropsTypesFrom, Page } from "../../../ssr";
|
||||
import SectionHeader from "../../../components/section-header";
|
||||
import ExternalIdBadge from "../../../components/external-id-badge";
|
||||
import ResourceDescriptionExpandable from "../../../components/resource-description-expandable";
|
||||
import ArtistRelationPageHeader from "../../../components/relation-page-header/artist-relation-page-header";
|
||||
import { AlbumType } from "../../../models/album";
|
||||
@@ -39,6 +38,8 @@ import {
|
||||
VideoListPageSection,
|
||||
} from "../../../components/page-section";
|
||||
import { Head } from "../../../components/head";
|
||||
import ExternalMetadataBadge from "../../../components/external-metadata-badge";
|
||||
import { VideoTypeIsExtra } from "../../../models/video";
|
||||
|
||||
// Number of Song item in the 'Top Song' section
|
||||
const SongListSize = 6;
|
||||
@@ -60,8 +61,8 @@ const latestAlbumsQuery = AlbumType.map((type) => ({
|
||||
const videosQuery = (artistSlugOrId: string | number) =>
|
||||
API.getVideos(
|
||||
{ artist: artistSlugOrId },
|
||||
{ sortBy: "totalPlayCount", order: "desc" },
|
||||
["artist"],
|
||||
{ sortBy: "addDate", order: "desc" },
|
||||
["artist", "master", "illustration"],
|
||||
);
|
||||
|
||||
const topSongsQuery = (artistSlugOrId: string | number) =>
|
||||
@@ -79,8 +80,10 @@ const rareSongsQuery = (artistSlugOrId: string | number) =>
|
||||
);
|
||||
|
||||
const artistQuery = (artistSlugOrId: string | number) =>
|
||||
API.getArtist(artistSlugOrId, ["externalIds", "illustration"]);
|
||||
API.getArtist(artistSlugOrId, ["illustration"]);
|
||||
|
||||
const externalMetadataQuery = (artistSlugOrId: string | number) =>
|
||||
API.getArtistExternalMetadata(artistSlugOrId);
|
||||
const appearanceQuery = (artistSlugOrId: string | number) =>
|
||||
API.getAlbums(
|
||||
{ appearance: artistSlugOrId },
|
||||
@@ -93,7 +96,10 @@ const prepareSSR = (context: NextPageContext) => {
|
||||
|
||||
return {
|
||||
additionalProps: { artistIdentifier },
|
||||
queries: [artistQuery(artistIdentifier)],
|
||||
queries: [
|
||||
artistQuery(artistIdentifier),
|
||||
externalMetadataQuery(artistIdentifier),
|
||||
],
|
||||
infiniteQueries: [
|
||||
rareSongsQuery(artistIdentifier),
|
||||
videosQuery(artistIdentifier),
|
||||
@@ -119,16 +125,20 @@ const ArtistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
const topSongs = useInfiniteQuery(topSongsQuery, artistIdentifier);
|
||||
const rareSongs = useInfiniteQuery(rareSongsQuery, artistIdentifier);
|
||||
const appearances = useInfiniteQuery(appearanceQuery, artistIdentifier);
|
||||
const externalIdWithDescription = artist.data?.externalIds
|
||||
.filter(({ provider }) => provider.name.toLowerCase() !== "discogs")
|
||||
.find(({ description }) => description !== null);
|
||||
const { musicVideos, extras } = useMemo(() => {
|
||||
const externalMetadata = useQuery(externalMetadataQuery, artistIdentifier);
|
||||
const { musicVideos, liveVideos, extras } = useMemo(() => {
|
||||
const firstPage = videos.data?.pages.at(0)?.items;
|
||||
return {
|
||||
musicVideos:
|
||||
firstPage?.filter((video) => video.type != "NonMusic") ?? [],
|
||||
firstPage?.filter(
|
||||
(video) =>
|
||||
!VideoTypeIsExtra(video.type) && video.type != "Live",
|
||||
) ?? [],
|
||||
liveVideos:
|
||||
firstPage?.filter((video) => video.type == "Live") ?? [],
|
||||
extras:
|
||||
firstPage?.filter((video) => video.type == "NonMusic") ?? [],
|
||||
firstPage?.filter((video) => VideoTypeIsExtra(video.type)) ??
|
||||
[],
|
||||
};
|
||||
}, [videos]);
|
||||
const { GradientBackground } = useGradientBackground(
|
||||
@@ -139,9 +149,7 @@ const ArtistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Head
|
||||
title={artist.data?.name}
|
||||
description={
|
||||
externalIdWithDescription?.description ?? undefined
|
||||
}
|
||||
description={externalMetadata.data?.description ?? undefined}
|
||||
/>
|
||||
<GradientBackground />
|
||||
<ArtistRelationPageHeader artist={artist.data} />
|
||||
@@ -189,6 +197,7 @@ const ArtistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
/>
|
||||
{[
|
||||
{ label: "topVideos", items: musicVideos } as const,
|
||||
{ label: "livePerformances", items: liveVideos } as const,
|
||||
{ label: "extras", items: extras } as const,
|
||||
].map(
|
||||
({ label, items }) =>
|
||||
@@ -212,7 +221,7 @@ const ArtistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
subtitleIs="artistName"
|
||||
query={appearances}
|
||||
/>
|
||||
{externalIdWithDescription && (
|
||||
{externalMetadata.data?.description && (
|
||||
<>
|
||||
<Divider sx={{ marginBottom: SectionPadding }} />
|
||||
<SectionHeader heading={t("about")} />
|
||||
@@ -221,12 +230,13 @@ const ArtistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
sx={{ paddingBottom: 4, paddingTop: 3 }}
|
||||
>
|
||||
<ResourceDescriptionExpandable
|
||||
externalDescription={externalIdWithDescription}
|
||||
externalMetadata={externalMetadata.data}
|
||||
/>
|
||||
</Container>
|
||||
</>
|
||||
)}
|
||||
{(!artist.data || artist.data.externalIds.length != 0) && (
|
||||
{(externalMetadata.data === undefined ||
|
||||
externalMetadata.data?.sources.length) && (
|
||||
<>
|
||||
<Divider />
|
||||
<Grid
|
||||
@@ -239,12 +249,14 @@ const ArtistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
<SectionHeader heading={t("externalLinks")} />
|
||||
</Grid>
|
||||
{(
|
||||
artist.data?.externalIds.filter(
|
||||
externalMetadata.data?.sources.filter(
|
||||
({ url }) => url !== null,
|
||||
) ?? generateArray(2)
|
||||
).map((externalId, index) => (
|
||||
).map((externalSource, index) => (
|
||||
<Grid item key={index}>
|
||||
<ExternalIdBadge externalId={externalId} />
|
||||
<ExternalMetadataBadge
|
||||
source={externalSource}
|
||||
/>
|
||||
</Grid>
|
||||
)) ?? []}
|
||||
</Grid>
|
||||
|
||||
@@ -69,8 +69,9 @@ const prepareSSR = async (
|
||||
artistQuery(artistIdentifier),
|
||||
...songs.pages
|
||||
.flatMap(({ items }) => items)
|
||||
.filter(({ master }) => master.releaseId)
|
||||
.map(({ master }) =>
|
||||
API.getRelease(master.releaseId, ["album"]),
|
||||
API.getRelease(master.releaseId!, ["album"]),
|
||||
),
|
||||
],
|
||||
infiniteQueries: [],
|
||||
@@ -83,10 +84,12 @@ const ArtistSongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
const router = useRouter();
|
||||
const isRareSongs = isRareSongsPage(router);
|
||||
const queryClient = useQueryClient();
|
||||
const getTrackReleaseName = (track: Track) =>
|
||||
queryClient
|
||||
.fetchQuery(API.getRelease(track.releaseId))
|
||||
.then((release) => release.name);
|
||||
const getTrackReleaseName = (track: Track): Promise<string | null> =>
|
||||
track.releaseId
|
||||
? queryClient
|
||||
.fetchQuery(API.getRelease(track.releaseId))
|
||||
.then((release) => release.name)
|
||||
: Promise.resolve(null);
|
||||
const artistIdentifier =
|
||||
props?.artistIdentifier ?? getSlugOrId(router.query);
|
||||
const artist = useQuery(artistQuery, artistIdentifier);
|
||||
@@ -123,9 +126,29 @@ const ArtistSongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
["artist", "featuring", "master", "illustration"],
|
||||
)
|
||||
}
|
||||
formatSubtitle={(song) =>
|
||||
getTrackReleaseName(song.master).then((name) => name)
|
||||
groupsQuery={
|
||||
isRareSongsPage(router)
|
||||
? undefined
|
||||
: ({ sortBy, order, library, type }) =>
|
||||
API.getSongGroups(
|
||||
{
|
||||
type,
|
||||
artist: artistIdentifier,
|
||||
library: library ?? undefined,
|
||||
},
|
||||
{ sortBy, order },
|
||||
[
|
||||
"artist",
|
||||
"featuring",
|
||||
"master",
|
||||
"illustration",
|
||||
],
|
||||
)
|
||||
}
|
||||
subtitles={[
|
||||
(song) =>
|
||||
getTrackReleaseName(song.master).then((name) => name),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ import getSlugOrId from "../../../utils/getSlugOrId";
|
||||
import ArtistRelationPageHeader from "../../../components/relation-page-header/artist-relation-page-header";
|
||||
import { GetPropsTypesFrom, Page } from "../../../ssr";
|
||||
import InfiniteVideoView from "../../../components/infinite/infinite-resource-view/infinite-video-view";
|
||||
import { SongSortingKeys } from "../../../models/song";
|
||||
import { VideoSortingKeys } from "../../../models/video";
|
||||
import { getOrderParams, getSortingFieldParams } from "../../../utils/sorting";
|
||||
import { useQuery } from "../../../api/use-query";
|
||||
import { NextPageContext } from "next";
|
||||
@@ -36,7 +36,10 @@ const artistQuery = (identifier: string | number) =>
|
||||
const prepareSSR = (context: NextPageContext) => {
|
||||
const artistIdentifier = getSlugOrId(context.query);
|
||||
const order = getOrderParams(context.query.order) ?? "asc";
|
||||
const sortBy = getSortingFieldParams(context.query.sortBy, SongSortingKeys);
|
||||
const sortBy = getSortingFieldParams(
|
||||
context.query.sortBy,
|
||||
VideoSortingKeys,
|
||||
);
|
||||
|
||||
return {
|
||||
additionalProps: { artistIdentifier, order, sortBy },
|
||||
@@ -45,7 +48,7 @@ const prepareSSR = (context: NextPageContext) => {
|
||||
API.getVideos(
|
||||
{ artist: artistIdentifier },
|
||||
{ sortBy: sortBy, order: order },
|
||||
["artist", "featuring"],
|
||||
["artist", "master", "illustration"],
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -73,14 +76,16 @@ const ArtistSongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
<InfiniteVideoView
|
||||
initialSortingField={props?.sortBy}
|
||||
initialSortingOrder={props?.order}
|
||||
query={({ sortBy, order, library }) =>
|
||||
query={({ sortBy, order, library, random, type }) =>
|
||||
API.getVideos(
|
||||
{
|
||||
type,
|
||||
artist: artistIdentifier,
|
||||
random,
|
||||
library: library ?? undefined,
|
||||
},
|
||||
{ sortBy, order },
|
||||
["artist", "featuring"],
|
||||
["artist", "master", "illustration"],
|
||||
)
|
||||
}
|
||||
subtitle="duration"
|
||||
|
||||
@@ -19,7 +19,12 @@
|
||||
import { Box, Grid, Stack } from "@mui/material";
|
||||
import API from "../api/api";
|
||||
import { GetPropsTypesFrom, Page } from "../ssr";
|
||||
import { useInfiniteQuery } from "../api/use-query";
|
||||
import {
|
||||
prepareMeeloInfiniteQuery,
|
||||
useInfiniteQuery,
|
||||
useQueries,
|
||||
useQuery,
|
||||
} from "../api/use-query";
|
||||
import SectionHeader from "../components/section-header";
|
||||
import TileRow from "../components/tile-row";
|
||||
import AlbumTile from "../components/tile/album-tile";
|
||||
@@ -33,6 +38,9 @@ import { useTranslation } from "react-i18next";
|
||||
import { generateArray } from "../utils/gen-list";
|
||||
import { getRandomNumber } from "../utils/random";
|
||||
import { useGradientBackground } from "../utils/gradient-background";
|
||||
import { QueryClient } from "react-query";
|
||||
import { NextPageContext } from "next";
|
||||
import { AlbumExternalMetadata } from "../models/external-metadata";
|
||||
|
||||
const newlyAddedAlbumsQuery = API.getAlbums(
|
||||
{},
|
||||
@@ -68,7 +76,6 @@ const albumRecommendations = (seed: number) =>
|
||||
API.getAlbums({ random: seed, type: "StudioRecording" }, undefined, [
|
||||
"artist",
|
||||
"genres",
|
||||
"externalIds",
|
||||
"illustration",
|
||||
]);
|
||||
|
||||
@@ -93,21 +100,28 @@ const HomePageSection = <T,>(props: {
|
||||
);
|
||||
};
|
||||
|
||||
const prepareSSR = () => {
|
||||
const prepareSSR = async (_: NextPageContext, queryClient: QueryClient) => {
|
||||
const seed = Math.floor(Math.random() * 10000000);
|
||||
const albumRecs = await queryClient
|
||||
.fetchInfiniteQuery(
|
||||
prepareMeeloInfiniteQuery(() => albumRecommendations(seed)),
|
||||
)
|
||||
.then((res) => res.pages.at(0)?.items);
|
||||
|
||||
return {
|
||||
additionalProps: {
|
||||
blurhashIndex: Math.random(),
|
||||
recommendationSeed: seed,
|
||||
},
|
||||
queries: albumRecs?.map((album) =>
|
||||
API.getAlbumExternalMetadata(album.id),
|
||||
),
|
||||
infiniteQueries: [
|
||||
newlyAddedArtistsQuery,
|
||||
newestAlbumsQuery,
|
||||
newlyAddedAlbumsQuery,
|
||||
newlyAddedReleasesQuery,
|
||||
mostListenedSongsQuery,
|
||||
albumRecommendations(seed),
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -119,6 +133,21 @@ const HomePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
albumRecommendations,
|
||||
props?.recommendationSeed ?? seed,
|
||||
);
|
||||
const featuredAlbumsExternalMetadata = useQueries(
|
||||
...(featuredAlbums.data?.pages
|
||||
.at(0)
|
||||
?.items.map(
|
||||
(
|
||||
album,
|
||||
): Parameters<
|
||||
typeof useQuery<
|
||||
AlbumExternalMetadata | null,
|
||||
Parameters<typeof API.getAlbumExternalMetadata>
|
||||
>
|
||||
> => [API.getAlbumExternalMetadata, album.id],
|
||||
) ?? []),
|
||||
);
|
||||
|
||||
const newlyAddedAlbums = useInfiniteQuery(() => newlyAddedAlbumsQuery);
|
||||
const newlyAddedArtists = useInfiniteQuery(() => newlyAddedArtistsQuery);
|
||||
const newlyAddedReleases = useInfiniteQuery(() => newlyAddedReleasesQuery);
|
||||
@@ -128,9 +157,9 @@ const HomePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
const tileRowWindowSize = {
|
||||
xs: 3,
|
||||
sm: 4,
|
||||
md: 5,
|
||||
md: 4,
|
||||
lg: 6,
|
||||
xl: 10,
|
||||
xl: 8,
|
||||
};
|
||||
const queries = [
|
||||
newlyAddedAlbums,
|
||||
@@ -195,7 +224,16 @@ const HomePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
xl={4}
|
||||
key={index}
|
||||
>
|
||||
<AlbumHighlightCard album={album} />
|
||||
<AlbumHighlightCard
|
||||
album={album}
|
||||
externalMetadata={
|
||||
featuredAlbumsExternalMetadata.find(
|
||||
({ data }) =>
|
||||
data?.albumId ===
|
||||
album?.id,
|
||||
)?.data ?? undefined
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
@@ -22,6 +22,7 @@ import RelationPageHeader from "../../../components/relation-page-header/relatio
|
||||
import { GetPropsTypesFrom, Page } from "../../../ssr";
|
||||
import getSlugOrId from "../../../utils/getSlugOrId";
|
||||
import {
|
||||
Query,
|
||||
prepareMeeloQuery,
|
||||
useQueries,
|
||||
useQuery,
|
||||
@@ -89,7 +90,16 @@ const prepareSSR = async (
|
||||
additionalProps: { playlistIdentifier },
|
||||
queries: [
|
||||
playlistQuery(playlistIdentifier),
|
||||
...entries.map((entry) => API.getRelease(entry.master.releaseId)),
|
||||
...entries
|
||||
.map((entry) =>
|
||||
entry.master.releaseId
|
||||
? API.getRelease(entry.master.releaseId)
|
||||
: undefined,
|
||||
)
|
||||
.filter(
|
||||
(promise): promise is Query<Release> =>
|
||||
promise !== undefined,
|
||||
),
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -219,13 +229,15 @@ const PlaylistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
const playlist = useQuery(playlistQuery, playlistIdentifier);
|
||||
const entriesQuery = useQuery(playlistEntriesQuery, playlistIdentifier);
|
||||
const masterTracksReleaseQueries = useQueries(
|
||||
...(entriesQuery.data?.map(
|
||||
({
|
||||
master,
|
||||
}): Parameters<
|
||||
typeof useQuery<Release, Parameters<typeof API.getRelease>>
|
||||
> => [API.getRelease, master.releaseId],
|
||||
) ?? []),
|
||||
...(entriesQuery.data
|
||||
?.filter(({ master }) => master.releaseId)
|
||||
.map(
|
||||
({
|
||||
master,
|
||||
}): Parameters<
|
||||
typeof useQuery<Release, Parameters<typeof API.getRelease>>
|
||||
> => [API.getRelease, master.releaseId ?? undefined],
|
||||
) ?? []),
|
||||
);
|
||||
const reorderMutation = useMutation((reorderedEntries: number[]) => {
|
||||
return API.reorderPlaylist(playlistIdentifier, reorderedEntries)
|
||||
@@ -240,7 +252,11 @@ const PlaylistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
const releases = masterTracksReleaseQueries.map((query) => query.data);
|
||||
const resolvedReleases = releases.filter((data) => data !== undefined);
|
||||
|
||||
if (resolvedReleases.length !== entriesQuery.data?.length) {
|
||||
if (
|
||||
resolvedReleases.length !==
|
||||
entriesQuery.data?.filter(({ master }) => master.releaseId != null)
|
||||
.length
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Divider,
|
||||
Grid,
|
||||
IconButton,
|
||||
ListItemButton,
|
||||
ListSubheader,
|
||||
Rating,
|
||||
Skeleton,
|
||||
@@ -33,7 +34,7 @@ import { useRouter } from "next/router";
|
||||
import API from "../../api/api";
|
||||
import Illustration from "../../components/illustration";
|
||||
import formatDuration from "../../utils/formatDuration";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { PlayIcon, ShuffleIcon } from "../../components/icons";
|
||||
import {
|
||||
@@ -48,7 +49,6 @@ import { GetPropsTypesFrom, Page } from "../../ssr";
|
||||
import ReleaseContextualMenu from "../../components/contextual-menu/release-contextual-menu";
|
||||
import TileRow from "../../components/tile-row";
|
||||
import VideoTile from "../../components/tile/video-tile";
|
||||
import ExternalIdBadge from "../../components/external-id-badge";
|
||||
import ArtistTile from "../../components/tile/artist-tile";
|
||||
import PlaylistTile from "../../components/tile/playlist-tile";
|
||||
import ReleaseTile from "../../components/tile/release-tile";
|
||||
@@ -60,7 +60,7 @@ import ResourceDescriptionExpandable from "../../components/resource-description
|
||||
import { Star1 } from "iconsax-react";
|
||||
import GenreButton from "../../components/genre-button";
|
||||
import { SongWithRelations } from "../../models/song";
|
||||
import Video from "../../models/video";
|
||||
import { VideoTypeIsExtra, VideoWithRelations } from "../../models/video";
|
||||
import { useAccentColor } from "../../utils/accent-color";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { generateArray } from "../../utils/gen-list";
|
||||
@@ -71,11 +71,16 @@ import { useGradientBackground } from "../../utils/gradient-background";
|
||||
import Tracklist, { TracklistItemWithRelations } from "../../models/tracklist";
|
||||
import { Head } from "../../components/head";
|
||||
import { useThemedSxValue } from "../../utils/themed-sx-value";
|
||||
import { parentScrollableDivId } from "../../components/infinite/infinite-scroll";
|
||||
import ExternalMetadataBadge from "../../components/external-metadata-badge";
|
||||
|
||||
const releaseQuery = (releaseIdentifier: string | number) =>
|
||||
API.getRelease(releaseIdentifier, ["album", "externalIds", "illustration"]);
|
||||
const releaseTracklistQuery = (releaseIdentifier: number | string) => {
|
||||
const query = API.getReleaseTracklist(releaseIdentifier, [
|
||||
API.getRelease(releaseIdentifier, ["album", "illustration"]);
|
||||
const releaseTracklistQuery = (
|
||||
releaseIdentifier: number | string,
|
||||
exclusiveOnly: boolean,
|
||||
) => {
|
||||
const query = API.getReleaseTracklist(releaseIdentifier, exclusiveOnly, [
|
||||
"artist",
|
||||
"featuring",
|
||||
]);
|
||||
@@ -102,7 +107,9 @@ const releaseTracklistQuery = (releaseIdentifier: number | string) => {
|
||||
};
|
||||
};
|
||||
const albumQuery = (albumId: number) =>
|
||||
API.getAlbum(albumId, ["externalIds", "genres", "artist"]);
|
||||
API.getAlbum(albumId, ["genres", "artist"]);
|
||||
const externalMetadataQuery = (albumIdentifier: string | number) =>
|
||||
API.getAlbumExternalMetadata(albumIdentifier);
|
||||
const artistsOnAlbumQuery = (albumId: number) => {
|
||||
const query = API.getArtists({ album: albumId }, undefined, [
|
||||
"illustration",
|
||||
@@ -122,7 +129,8 @@ const releaseBSidesQuery = (releaseId: number) =>
|
||||
"master",
|
||||
"illustration",
|
||||
]);
|
||||
const albumVideosQuery = (albumId: number) => API.getVideos({ album: albumId });
|
||||
const albumVideosQuery = (albumId: number) =>
|
||||
API.getVideos({ album: albumId }, undefined, ["master", "illustration"]);
|
||||
const relatedAlbumsQuery = (albumId: number) =>
|
||||
API.getAlbums({ related: albumId }, { sortBy: "releaseDate" }, [
|
||||
"artist",
|
||||
@@ -147,9 +155,10 @@ const prepareSSR = async (
|
||||
return {
|
||||
additionalProps: { releaseIdentifier },
|
||||
queries: [
|
||||
releaseTracklistQuery(releaseIdentifier),
|
||||
releaseTracklistQuery(releaseIdentifier, false),
|
||||
albumQuery(release.albumId),
|
||||
artistsOnAlbumQuery(release.albumId),
|
||||
externalMetadataQuery(release.albumId),
|
||||
],
|
||||
infiniteQueries: [
|
||||
albumGenreQuery(release.albumId),
|
||||
@@ -187,14 +196,22 @@ const RelatedContentSection = (props: RelatedContentSectionProps) => {
|
||||
const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [showOnlyExclusive, setShowOnlyExclusive] = useState(false);
|
||||
const releaseIdentifier =
|
||||
props?.releaseIdentifier ?? getSlugOrId(router.query);
|
||||
const theme = useTheme();
|
||||
const { playTracks } = usePlayerContext();
|
||||
const release = useQuery(releaseQuery, releaseIdentifier);
|
||||
const externalMetadata = useQuery(
|
||||
externalMetadataQuery,
|
||||
release.data?.albumId,
|
||||
);
|
||||
const artistId = useMemo(() => release.data?.album?.artistId, [release]);
|
||||
const album = useQuery(albumQuery, release.data?.albumId);
|
||||
const tracklistQuery = useQuery(releaseTracklistQuery, releaseIdentifier);
|
||||
const tracklistQuery = useQuery(
|
||||
(rId) => releaseTracklistQuery(rId, showOnlyExclusive),
|
||||
releaseIdentifier,
|
||||
);
|
||||
const albumGenres = useInfiniteQuery(
|
||||
albumGenreQuery,
|
||||
release.data?.albumId,
|
||||
@@ -231,7 +248,7 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
() =>
|
||||
(bSidesQuery.data?.pages.at(0)?.items ?? []).reduce(
|
||||
(prev, current) => {
|
||||
if (current.type === "NonMusic") {
|
||||
if (["NonMusic", "Medley"].includes(current.type)) {
|
||||
return {
|
||||
bSides: prev.bSides,
|
||||
extras: prev.extras.concat(current),
|
||||
@@ -251,28 +268,6 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
),
|
||||
[bSidesQuery.data],
|
||||
);
|
||||
const { videos, videoExtras } = useMemo(
|
||||
() =>
|
||||
(albumVideos.data?.pages.at(0)?.items ?? []).reduce(
|
||||
(prev, current) => {
|
||||
if (current.type === "NonMusic") {
|
||||
return {
|
||||
videos: prev.videos,
|
||||
videoExtras: prev.videoExtras.concat(current),
|
||||
};
|
||||
}
|
||||
return {
|
||||
videos: prev.videos.concat(current),
|
||||
videoExtras: prev.videoExtras,
|
||||
};
|
||||
},
|
||||
{ videos: [], videoExtras: [] } as Record<
|
||||
"videos" | "videoExtras",
|
||||
Video[]
|
||||
>,
|
||||
),
|
||||
[albumVideos.data],
|
||||
);
|
||||
const [tracks, totalDuration, trackList] = useMemo(() => {
|
||||
if (tracklistQuery.data) {
|
||||
const discMap = tracklistQuery.data;
|
||||
@@ -290,35 +285,77 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
}
|
||||
return [[], undefined, undefined];
|
||||
}, [tracklistQuery.data]);
|
||||
const { videos, liveVideos, videoExtras } = useMemo(
|
||||
() =>
|
||||
(albumVideos.data?.pages.at(0)?.items ?? [])
|
||||
.map((video) => {
|
||||
const videoIndex = tracks.findIndex(
|
||||
(track) =>
|
||||
(track.song ?? track.video)!.groupId ==
|
||||
video.groupId,
|
||||
);
|
||||
return [
|
||||
video,
|
||||
videoIndex == -1 ? tracks.length : videoIndex,
|
||||
] as const;
|
||||
})
|
||||
|
||||
.sort(
|
||||
([v1, i1], [v2, i2]) =>
|
||||
i1 - i2 || v1.slug.localeCompare(v2.slug),
|
||||
)
|
||||
.map(([video, tracklistIndex], _, videosWithIndexes) => {
|
||||
if (album.data?.type === "Single") {
|
||||
return [video, tracklistIndex] as const;
|
||||
}
|
||||
const firstVideoOfSameGroup = videosWithIndexes.find(
|
||||
([__, i]) => i == tracklistIndex,
|
||||
)!;
|
||||
return [
|
||||
video,
|
||||
firstVideoOfSameGroup[0].id == video.id
|
||||
? tracklistIndex
|
||||
: 10000 + tracklistIndex,
|
||||
] as const;
|
||||
})
|
||||
.sort(([_, i1], [__, i2]) => i1 - i2)
|
||||
.map(([v, _]) => v)
|
||||
.reduce(
|
||||
(prev, current) => {
|
||||
if (VideoTypeIsExtra(current.type)) {
|
||||
return {
|
||||
videos: prev.videos,
|
||||
liveVideos: prev.liveVideos,
|
||||
videoExtras: prev.videoExtras.concat(current),
|
||||
};
|
||||
} else if (current.type === "Live") {
|
||||
return {
|
||||
videos: prev.videos,
|
||||
liveVideos: prev.liveVideos.concat(current),
|
||||
videoExtras: prev.videoExtras,
|
||||
};
|
||||
}
|
||||
return {
|
||||
videos: prev.videos.concat(current),
|
||||
liveVideos: prev.liveVideos,
|
||||
videoExtras: prev.videoExtras,
|
||||
};
|
||||
},
|
||||
{
|
||||
videos: [],
|
||||
videoExtras: [],
|
||||
liveVideos: [],
|
||||
} as Record<
|
||||
"videos" | "videoExtras" | "liveVideos",
|
||||
VideoWithRelations<"master" | "illustration">[]
|
||||
>,
|
||||
),
|
||||
[albumVideos.data, tracks],
|
||||
);
|
||||
const illustration = useMemo(
|
||||
() => release.data?.illustration,
|
||||
[release.data],
|
||||
);
|
||||
const externalIdWithDescription = useMemo(
|
||||
() =>
|
||||
album.data?.externalIds
|
||||
.filter(
|
||||
({ provider }) => provider.name.toLowerCase() !== "discogs",
|
||||
)
|
||||
.find(({ description }) => description !== null),
|
||||
[album.data],
|
||||
);
|
||||
const externalIds = useMemo(() => {
|
||||
if (album.data === undefined || release.data === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return [...album.data.externalIds, ...release.data.externalIds];
|
||||
}, [album.data, release.data]);
|
||||
|
||||
const albumRating = useMemo(() => {
|
||||
return album.data
|
||||
? album.data.externalIds
|
||||
.map(({ rating }) => rating)
|
||||
.filter((rating) => rating !== null)
|
||||
.sort()
|
||||
.at(-1) ?? null
|
||||
: undefined;
|
||||
}, [album.data]);
|
||||
const accentColor = useAccentColor(illustration);
|
||||
const { GradientBackground } = useGradientBackground(illustration?.colors);
|
||||
const ratingColor = useThemedSxValue(
|
||||
@@ -331,6 +368,10 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
accentColor?.light,
|
||||
accentColor?.dark,
|
||||
);
|
||||
useEffect(() => {
|
||||
//for some unknown reason, this state is persisted between release pages?!
|
||||
setShowOnlyExclusive(false);
|
||||
}, [router.asPath]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -453,14 +494,14 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
<Skeleton width={"100px"} />
|
||||
)}
|
||||
</Typography>
|
||||
{albumRating && (
|
||||
{externalMetadata.data?.rating && (
|
||||
<Rating
|
||||
sx={{
|
||||
paddingLeft: 1.5,
|
||||
...ratingColor,
|
||||
}}
|
||||
readOnly
|
||||
value={albumRating / 20}
|
||||
value={externalMetadata.data.rating / 20}
|
||||
icon={
|
||||
<Star1
|
||||
size={18}
|
||||
@@ -509,7 +550,9 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
artist: artists.data.find(
|
||||
(artist) =>
|
||||
artist.id ==
|
||||
track.song.artistId,
|
||||
(track.song ??
|
||||
track.video)!
|
||||
.artistId,
|
||||
)!,
|
||||
release: release.data,
|
||||
}),
|
||||
@@ -596,7 +639,13 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
</Fade>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xl lg={hasGenres ? 9 : true} xs={12}>
|
||||
<Grid
|
||||
item
|
||||
xl
|
||||
lg={hasGenres ? 9 : true}
|
||||
xs={12}
|
||||
id={"release-tracklist"}
|
||||
>
|
||||
<ReleaseTrackList
|
||||
mainArtist={albumArtist}
|
||||
tracklist={
|
||||
@@ -608,7 +657,8 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
discKey,
|
||||
discTracks.map((discTrack) => ({
|
||||
...discTrack,
|
||||
song: discTrack.song,
|
||||
song: discTrack.song!,
|
||||
video: discTrack.video!,
|
||||
})),
|
||||
]),
|
||||
)
|
||||
@@ -616,6 +666,37 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
}
|
||||
release={release.data}
|
||||
/>
|
||||
{release.data?.album.type === "Compilation" &&
|
||||
/* It does not make sense to show the button if the album is from 'Various Artists' */
|
||||
albumArtist !== null && (
|
||||
<>
|
||||
<ListItemButton
|
||||
sx={{
|
||||
textAlign: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
fontWeight: "bolder",
|
||||
}}
|
||||
onClick={() => {
|
||||
setShowOnlyExclusive((v) => !v);
|
||||
document
|
||||
.getElementById(
|
||||
parentScrollableDivId,
|
||||
)
|
||||
?.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
showOnlyExclusive
|
||||
? "showAllTrack"
|
||||
: "showOnlyExclusiveTracks",
|
||||
)}
|
||||
</ListItemButton>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
<RelatedContentSection
|
||||
@@ -658,22 +739,30 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
}
|
||||
/>
|
||||
</RelatedContentSection>
|
||||
<RelatedContentSection
|
||||
display={videos !== undefined && videos.length != 0}
|
||||
title={t("musicVideos")}
|
||||
>
|
||||
<TileRow
|
||||
tiles={
|
||||
videos?.map((video, videoIndex) => (
|
||||
<VideoTile
|
||||
key={videoIndex}
|
||||
video={video}
|
||||
subtitle="duration"
|
||||
/>
|
||||
)) ?? []
|
||||
{[
|
||||
[videos, "musicVideos"] as const,
|
||||
[liveVideos, "livePerformances"] as const,
|
||||
].map(([videoList, sectionLabel]) => (
|
||||
<RelatedContentSection
|
||||
key={`videos-${sectionLabel}`}
|
||||
display={
|
||||
videoList !== undefined && videoList.length != 0
|
||||
}
|
||||
/>
|
||||
</RelatedContentSection>
|
||||
title={t(sectionLabel)}
|
||||
>
|
||||
<TileRow
|
||||
tiles={
|
||||
videoList?.map((video, videoIndex) => (
|
||||
<VideoTile
|
||||
key={videoIndex}
|
||||
video={video}
|
||||
subtitle="duration"
|
||||
/>
|
||||
)) ?? []
|
||||
}
|
||||
/>
|
||||
</RelatedContentSection>
|
||||
))}
|
||||
<RelatedContentSection
|
||||
display={extras.length > 0 || videoExtras.length > 0}
|
||||
title={t("extras")}
|
||||
@@ -752,28 +841,32 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
}
|
||||
/>
|
||||
</RelatedContentSection>
|
||||
{externalIdWithDescription && (
|
||||
{externalMetadata.data?.description && (
|
||||
<RelatedContentSection display title={t("about")}>
|
||||
<Box sx={{ paddingBottom: 2 }}>
|
||||
<ResourceDescriptionExpandable
|
||||
externalDescription={externalIdWithDescription}
|
||||
externalMetadata={externalMetadata.data}
|
||||
/>
|
||||
</Box>
|
||||
</RelatedContentSection>
|
||||
)}
|
||||
<RelatedContentSection
|
||||
display={
|
||||
externalIds === undefined || externalIds.length > 0
|
||||
externalMetadata.data === undefined ||
|
||||
(externalMetadata.data?.sources.length ?? 0) > 0
|
||||
}
|
||||
title={t("externalLinks")}
|
||||
>
|
||||
<Grid container spacing={1}>
|
||||
{(
|
||||
externalIds?.filter(({ url }) => url !== null) ??
|
||||
generateArray(2)
|
||||
).map((externalId, index) => (
|
||||
externalMetadata.data?.sources.filter(
|
||||
({ url }) => url !== null,
|
||||
) ?? generateArray(2)
|
||||
).map((externalSource, index) => (
|
||||
<Grid item key={index}>
|
||||
<ExternalIdBadge externalId={externalId} />
|
||||
<ExternalMetadataBadge
|
||||
source={externalSource}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
@@ -30,10 +30,20 @@ import InfiniteArtistView from "../../components/infinite/infinite-resource-view
|
||||
import InfiniteAlbumView from "../../components/infinite/infinite-resource-view/infinite-album-view";
|
||||
import InfiniteSongView from "../../components/infinite/infinite-resource-view/infinite-song-view";
|
||||
import InfiniteView from "../../components/infinite/infinite-view";
|
||||
import { toInfiniteQuery, transformPage } from "../../api/use-query";
|
||||
import {
|
||||
Query,
|
||||
toInfiniteQuery,
|
||||
transformPage,
|
||||
useQueryClient,
|
||||
} from "../../api/use-query";
|
||||
import AlbumItem from "../../components/list-item/album-item";
|
||||
import SongItem from "../../components/list-item/song-item";
|
||||
import ArtistItem from "../../components/list-item/artist-item";
|
||||
import { useMutation } from "react-query";
|
||||
import { SaveSearchItem, SearchResult } from "../../models/search";
|
||||
import formatArtists from "../../utils/formatArtists";
|
||||
import InfiniteVideoView from "../../components/infinite/infinite-resource-view/infinite-video-view";
|
||||
import VideoItem from "../../components/list-item/video-item";
|
||||
|
||||
const prepareSSR = (context: NextPageContext) => {
|
||||
const searchQuery = context.query.query?.at(0) ?? null;
|
||||
@@ -41,26 +51,47 @@ const prepareSSR = (context: NextPageContext) => {
|
||||
|
||||
return {
|
||||
additionalProps: { searchQuery, type },
|
||||
infiniteQueries: searchQuery
|
||||
? [
|
||||
API.getArtists({ query: searchQuery }, undefined, [
|
||||
"illustration",
|
||||
]),
|
||||
API.getAlbums({ query: searchQuery }, undefined, [
|
||||
"artist",
|
||||
"illustration",
|
||||
]),
|
||||
API.getSongs({ query: searchQuery }, undefined, [
|
||||
"artist",
|
||||
"featuring",
|
||||
"master",
|
||||
"illustration",
|
||||
]),
|
||||
]
|
||||
: [],
|
||||
infiniteQueries: [
|
||||
searchResultQueryToInfinite(API.getSearchHistory()),
|
||||
...(searchQuery
|
||||
? [
|
||||
API.getArtists({ query: searchQuery }, undefined, [
|
||||
"illustration",
|
||||
]),
|
||||
API.getAlbums({ query: searchQuery }, undefined, [
|
||||
"artist",
|
||||
"illustration",
|
||||
]),
|
||||
API.getSongs({ query: searchQuery }, undefined, [
|
||||
"artist",
|
||||
"featuring",
|
||||
"master",
|
||||
"illustration",
|
||||
]),
|
||||
API.getVideos({ query: searchQuery }, undefined, [
|
||||
"artist",
|
||||
"master",
|
||||
"illustration",
|
||||
]),
|
||||
searchResultQueryToInfinite(API.searchAll(searchQuery)),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const searchResultQueryToInfinite = (q: Query<SearchResult[]>) => {
|
||||
return transformPage(toInfiniteQuery(q), (item, index) => ({
|
||||
...item,
|
||||
id: index,
|
||||
illustration:
|
||||
item.song?.illustration ??
|
||||
item.artist?.illustration ??
|
||||
item.album?.illustration ??
|
||||
null,
|
||||
}));
|
||||
};
|
||||
|
||||
const buildSearchUrl = (
|
||||
query: string | undefined,
|
||||
type: string | undefined,
|
||||
@@ -68,7 +99,7 @@ const buildSearchUrl = (
|
||||
return "/search/" + (query ?? "") + (type ? `?t=${type}` : "");
|
||||
};
|
||||
|
||||
const tabs = ["all", "artist", "album", "song"] as const;
|
||||
const tabs = ["all", "artist", "album", "song", "video"] as const;
|
||||
|
||||
const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
const router = useRouter();
|
||||
@@ -83,8 +114,25 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
(newTab) => buildSearchUrl(query, newTab),
|
||||
...tabs,
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
const [inputValue, setInputValue] = useState(query);
|
||||
const [debounceId, setDebounceId] = useState<NodeJS.Timeout>();
|
||||
const saveSearch = useMutation((dto: SaveSearchItem) => {
|
||||
return (
|
||||
API.saveSearchHistoryEntry(dto)
|
||||
.then(() => {
|
||||
// Sometimes, it refreshes to fast, and shifts the history
|
||||
// before openning a page (for artists) is done
|
||||
setTimeout(() => {
|
||||
queryClient.client.invalidateQueries(
|
||||
API.getSearchHistory().key,
|
||||
);
|
||||
}, 500);
|
||||
})
|
||||
// eslint-disable-next-line no-console
|
||||
.catch((error: Error) => console.error(error))
|
||||
);
|
||||
});
|
||||
useEffect(() => {
|
||||
if (debounceId) {
|
||||
clearTimeout(debounceId);
|
||||
@@ -100,7 +148,7 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
},
|
||||
);
|
||||
setDebounceId(undefined);
|
||||
}, 500),
|
||||
}, 400),
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inputValue]);
|
||||
@@ -124,10 +172,14 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
sx={{ display: "flex", justifyContent: "center", paddingY: 2 }}
|
||||
>
|
||||
<TextField
|
||||
id="outlined-basic"
|
||||
label="Search"
|
||||
variant="outlined"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Escape") {
|
||||
(document.activeElement as any)?.blur();
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
value: inputValue,
|
||||
startAdornment: (
|
||||
@@ -156,27 +208,26 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
))}
|
||||
</Tabs>
|
||||
<Box sx={{ paddingBottom: 2 }} />
|
||||
{query && selectedTab == "all" && (
|
||||
{selectedTab == "all" && (
|
||||
<InfiniteView
|
||||
view="list"
|
||||
query={() =>
|
||||
transformPage(
|
||||
toInfiniteQuery(API.searchAll(query)),
|
||||
(item, index) => ({
|
||||
...item,
|
||||
id: index,
|
||||
illustration:
|
||||
item.song?.illustration ??
|
||||
item.artist?.illustration ??
|
||||
item.album?.illustration ??
|
||||
null,
|
||||
}),
|
||||
searchResultQueryToInfinite(
|
||||
query
|
||||
? API.searchAll(query)
|
||||
: API.getSearchHistory(),
|
||||
)
|
||||
}
|
||||
renderGridItem={(item) => <></>}
|
||||
renderListItem={(item) =>
|
||||
!item || item.album ? (
|
||||
<AlbumItem
|
||||
onClick={() =>
|
||||
item &&
|
||||
saveSearch.mutate({
|
||||
albumId: item.album.id,
|
||||
})
|
||||
}
|
||||
album={item?.album}
|
||||
formatSubtitle={(album) =>
|
||||
`${t("album")} • ${
|
||||
@@ -186,19 +237,51 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
/>
|
||||
) : item.song ? (
|
||||
<SongItem
|
||||
song={item.song}
|
||||
formatSubtitle={async (song) =>
|
||||
`${t("song")} • ${song.artist.name}`
|
||||
onClick={() =>
|
||||
saveSearch.mutate({ songId: item.song.id })
|
||||
}
|
||||
song={item.song}
|
||||
subtitles={[
|
||||
async (song) =>
|
||||
`${t("song")} • ${formatArtists(
|
||||
song.artist,
|
||||
song.featuring,
|
||||
)}`,
|
||||
]}
|
||||
/>
|
||||
) : item.video ? (
|
||||
<VideoItem
|
||||
onClick={() =>
|
||||
saveSearch.mutate({
|
||||
videoId: item.video.id,
|
||||
})
|
||||
}
|
||||
video={item.video}
|
||||
subtitles={[
|
||||
async (video) =>
|
||||
`${t("video")} • ${formatArtists(
|
||||
video.artist,
|
||||
)}`,
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<ArtistItem artist={item.artist} />
|
||||
<ArtistItem
|
||||
artist={item.artist}
|
||||
onClick={() =>
|
||||
saveSearch.mutate({
|
||||
artistId: item.artist.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{query && selectedTab == "artist" && (
|
||||
<InfiniteArtistView
|
||||
onItemClick={(item) =>
|
||||
item && saveSearch.mutate({ artistId: item.id })
|
||||
}
|
||||
query={({ library }) =>
|
||||
API.getArtists(
|
||||
{
|
||||
@@ -213,6 +296,9 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
)}
|
||||
{query && selectedTab == "album" && (
|
||||
<InfiniteAlbumView
|
||||
onItemClick={(item) =>
|
||||
item && saveSearch.mutate({ albumId: item.id })
|
||||
}
|
||||
defaultAlbumType={null}
|
||||
query={({ library, type: newType }) =>
|
||||
API.getAlbums(
|
||||
@@ -229,6 +315,9 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
)}
|
||||
{query && selectedTab == "song" && (
|
||||
<InfiniteSongView
|
||||
onItemClick={(item) =>
|
||||
item && saveSearch.mutate({ songId: item.id })
|
||||
}
|
||||
query={({ library, type: newType }) =>
|
||||
API.getSongs(
|
||||
{
|
||||
@@ -242,6 +331,26 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{query && selectedTab == "video" && (
|
||||
<InfiniteVideoView
|
||||
onItemClick={(item) =>
|
||||
item && saveSearch.mutate({ videoId: item.id })
|
||||
}
|
||||
subtitle="artist"
|
||||
query={({ library, type: newType }) =>
|
||||
API.getVideos(
|
||||
{
|
||||
query: encodeURIComponent(query),
|
||||
type: newType,
|
||||
library: library ?? undefined,
|
||||
},
|
||||
undefined,
|
||||
["artist", "master", "illustration"],
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import API from "../../../api/api";
|
||||
import { GetPropsTypesFrom, Page } from "../../../ssr";
|
||||
import getSlugOrId from "../../../utils/getSlugOrId";
|
||||
import {
|
||||
prepareMeeloQuery,
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
@@ -38,7 +39,6 @@ import LyricsBox from "../../../components/lyrics";
|
||||
import SongRelationPageHeader from "../../../components/relation-page-header/song-relation-page-header";
|
||||
import InfiniteSongView from "../../../components/infinite/infinite-resource-view/infinite-song-view";
|
||||
import InfiniteTrackView from "../../../components/infinite/infinite-resource-view/infinite-track-view";
|
||||
import ExternalIdBadge from "../../../components/external-id-badge";
|
||||
import { PlayIcon } from "../../../components/icons";
|
||||
import GenreButton from "../../../components/genre-button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -50,22 +50,33 @@ import { Head } from "../../../components/head";
|
||||
import { useThemedSxValue } from "../../../utils/themed-sx-value";
|
||||
import { useAccentColor } from "../../../utils/accent-color";
|
||||
import { useTabRouter } from "../../../components/tab-router";
|
||||
import ExternalMetadataBadge from "../../../components/external-metadata-badge";
|
||||
import InfiniteVideoView from "../../../components/infinite/infinite-resource-view/infinite-video-view";
|
||||
import { QueryClient } from "react-query";
|
||||
|
||||
const prepareSSR = (context: NextPageContext) => {
|
||||
const externalMetadataQuery = (songIdentifier: string | number) =>
|
||||
API.getSongExternalMetadata(songIdentifier);
|
||||
|
||||
const prepareSSR = async (
|
||||
context: NextPageContext,
|
||||
queryClient: QueryClient,
|
||||
) => {
|
||||
const songIdentifier = getSlugOrId(context.query);
|
||||
|
||||
return {
|
||||
additionalProps: { songIdentifier },
|
||||
queries: [
|
||||
const song = await queryClient.fetchQuery(
|
||||
prepareMeeloQuery(() =>
|
||||
API.getSong(songIdentifier, [
|
||||
"artist",
|
||||
"externalIds",
|
||||
"featuring",
|
||||
"lyrics",
|
||||
"master",
|
||||
"illustration",
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
additionalProps: { songIdentifier },
|
||||
queries: [externalMetadataQuery(songIdentifier)],
|
||||
infiniteQueries: [
|
||||
API.getGenres({ song: songIdentifier }),
|
||||
API.getSongs(
|
||||
@@ -73,16 +84,22 @@ const prepareSSR = (context: NextPageContext) => {
|
||||
{ sortBy: "name", order: "asc" },
|
||||
["artist", "featuring", "master", "illustration"],
|
||||
),
|
||||
|
||||
API.getVideos(
|
||||
{ group: song.groupId },
|
||||
{ sortBy: "name", order: "asc" },
|
||||
["artist", "master", "illustration"],
|
||||
),
|
||||
API.getTracks(
|
||||
{ song: songIdentifier },
|
||||
{ sortBy: "name", order: "asc" },
|
||||
["release", "song", "illustration"],
|
||||
["release", "song", "illustration", "video"],
|
||||
),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const tabs = ["lyrics", "versions", "tracks", "more"] as const;
|
||||
const tabs = ["lyrics", "versions", "videos", "tracks", "more"] as const;
|
||||
|
||||
const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
const { selectedTab, selectTab } = useTabRouter(
|
||||
@@ -98,13 +115,13 @@ const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
const song = useQuery(() =>
|
||||
API.getSong(songIdentifier, [
|
||||
"artist",
|
||||
"externalIds",
|
||||
"featuring",
|
||||
"lyrics",
|
||||
"master",
|
||||
"illustration",
|
||||
]),
|
||||
);
|
||||
const externalMetadata = useQuery(externalMetadataQuery, songIdentifier);
|
||||
const genres = useInfiniteQuery(API.getGenres, { song: songIdentifier });
|
||||
const { GradientBackground } = useGradientBackground(
|
||||
song.data?.illustration?.colors,
|
||||
@@ -189,7 +206,8 @@ const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
{(!song.data || song.data.externalIds.length != 0) && (
|
||||
{(song.data === undefined ||
|
||||
externalMetadata.data?.sources.length) && (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
@@ -203,22 +221,19 @@ const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
{`${t("externalLinks")}: `}
|
||||
</Typography>
|
||||
{(
|
||||
song.data?.externalIds.filter(
|
||||
externalMetadata.data?.sources.filter(
|
||||
({ url }) => url !== null,
|
||||
) ?? generateArray(2)
|
||||
).map((externalId, index) => (
|
||||
<ExternalIdBadge
|
||||
).map((source, index) => (
|
||||
<ExternalMetadataBadge
|
||||
key={index}
|
||||
externalId={externalId}
|
||||
source={source}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<Typography variant="body1" sx={{ paddingTop: 2 }}>
|
||||
{song.data?.externalIds
|
||||
.map(({ description }) => description)
|
||||
.filter((desc) => desc !== null)
|
||||
.at(0)}
|
||||
{externalMetadata.data?.description}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
@@ -270,6 +285,31 @@ const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedTab == "videos" && (
|
||||
<>
|
||||
<Head
|
||||
title={
|
||||
song.data &&
|
||||
`${song.data.name} (${t("videos")})`
|
||||
}
|
||||
/>
|
||||
<InfiniteVideoView
|
||||
query={({ library, sortBy, order, type }) =>
|
||||
API.getVideos(
|
||||
{
|
||||
library: library ?? undefined,
|
||||
type,
|
||||
group: song.data?.groupId,
|
||||
},
|
||||
{ sortBy, order },
|
||||
["artist", "master", "illustration"],
|
||||
)
|
||||
}
|
||||
subtitle="duration"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{selectedTab == "tracks" && (
|
||||
<>
|
||||
<Head
|
||||
@@ -283,7 +323,12 @@ const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
API.getTracks(
|
||||
{ song: songIdentifier },
|
||||
{ sortBy, order },
|
||||
["release", "song", "illustration"],
|
||||
[
|
||||
"release",
|
||||
"song",
|
||||
"illustration",
|
||||
"video",
|
||||
],
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -58,7 +58,21 @@ const LibrarySongsPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
initialSortingOrder={props?.order}
|
||||
query={({ sortBy, order, type, library, random }) =>
|
||||
API.getSongs(
|
||||
{ type, library: library ?? undefined, random },
|
||||
{
|
||||
type,
|
||||
library: library ?? undefined,
|
||||
random,
|
||||
},
|
||||
{ sortBy, order },
|
||||
["artist", "featuring", "master", "illustration"],
|
||||
)
|
||||
}
|
||||
groupsQuery={({ sortBy, order, library, type }) =>
|
||||
API.getSongGroups(
|
||||
{
|
||||
type,
|
||||
library: library ?? undefined,
|
||||
},
|
||||
{ sortBy, order },
|
||||
["artist", "featuring", "master", "illustration"],
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import React from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { SongSortingKeys } from "../../models/song";
|
||||
import { VideoSortingKeys } from "../../models/video";
|
||||
import API from "../../api/api";
|
||||
import { getOrderParams, getSortingFieldParams } from "../../utils/sorting";
|
||||
import { GetPropsTypesFrom, Page } from "../../ssr";
|
||||
@@ -29,12 +29,19 @@ import { Head } from "../../components/head";
|
||||
|
||||
const prepareSSR = (context: NextPageContext) => {
|
||||
const order = getOrderParams(context.query.order) ?? "asc";
|
||||
const sortBy = getSortingFieldParams(context.query.sortBy, SongSortingKeys);
|
||||
const sortBy = getSortingFieldParams(
|
||||
context.query.sortBy,
|
||||
VideoSortingKeys,
|
||||
);
|
||||
|
||||
return {
|
||||
additionalProps: { sortBy, order },
|
||||
infiniteQueries: [
|
||||
API.getVideos({}, { sortBy, order }, ["artist", "featuring"]),
|
||||
API.getVideos({}, { sortBy, order }, [
|
||||
"artist",
|
||||
"master",
|
||||
"illustration",
|
||||
]),
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -51,11 +58,11 @@ const LibraryVideosPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
<InfiniteVideoView
|
||||
initialSortingField={props?.sortBy}
|
||||
initialSortingOrder={props?.order}
|
||||
query={({ library, sortBy, order }) =>
|
||||
query={({ library, sortBy, order, random, type }) =>
|
||||
API.getVideos(
|
||||
{ library: library ?? undefined },
|
||||
{ library: library ?? undefined, random, type },
|
||||
{ sortBy, order },
|
||||
["artist", "featuring"],
|
||||
["artist", "master", "illustration"],
|
||||
)
|
||||
}
|
||||
subtitle="artist"
|
||||
|
||||
Loaded 100 of 438 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user