Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af758b495f | ||
|
|
4a80b9f015 | ||
|
|
f1d22087ce | ||
|
|
1d3bdca573 | ||
|
|
0050d9f276 | ||
|
|
f73fe379d2 | ||
|
|
ed8120be4c | ||
|
|
c2330a61d5 | ||
|
|
b8955bc50a | ||
|
|
c3308a6949 | ||
|
|
b1cc5cead1 | ||
|
|
d457656273 | ||
|
|
81e384263f | ||
|
|
3c03af1a13 | ||
|
|
bf871060b9 | ||
|
|
cf2e1a8993 | ||
|
|
48dfbab10f | ||
|
|
8db41e250b | ||
|
|
b376b76922 | ||
|
|
cd578f1edd | ||
|
|
4c2b0dab2e | ||
|
|
12128d36e7 | ||
|
|
73aa611fca | ||
|
|
45b91a92fa | ||
|
|
391daf9de1 | ||
|
|
c63e7c87b8 | ||
|
|
f0966819cc | ||
|
|
50513adc7d | ||
|
|
7ddf4a1766 | ||
|
|
abc3c317d5 | ||
|
|
69e1d87a4d | ||
|
|
7e2d7f964c | ||
|
|
d1b1f9b1c6 | ||
|
|
f026ec4be1 | ||
|
|
5eb7289370 | ||
|
|
8f7a945cb6 | ||
|
|
034b4571bf | ||
|
|
332181f2c6 | ||
|
|
a9fba09d40 | ||
|
|
ee34160d65 | ||
|
|
e843eee6d0 | ||
|
|
dee5b9e956 | ||
|
|
9cb5c8b9d0 | ||
|
|
5c003f0fdf | ||
|
|
ed1937c400 | ||
|
|
a8fd10dc96 | ||
|
|
7e1be3220e | ||
|
|
598db88384 | ||
|
|
35536c4b0e | ||
|
|
0ee5e70f67 | ||
|
|
2c0621312d | ||
|
|
9426ba2614 | ||
|
|
8ae808c8fe | ||
|
|
f3338fe7d3 | ||
|
|
76dcea872c | ||
|
|
0301a0911e | ||
|
|
f2c65644b0 | ||
|
|
c667d79372 | ||
|
|
2dd6c4d501 | ||
|
|
f07fc8e830 | ||
|
|
13fe63b31b | ||
|
|
4833b2b102 | ||
|
|
15c73b371a | ||
|
|
daf5491e63 | ||
|
|
b21f20f581 | ||
|
|
b3951dc8e9 | ||
|
|
e09f0adb7c | ||
|
|
806f61eb15 | ||
|
|
680fa9881d | ||
|
|
a27731a454 | ||
|
|
f0cb0e5fd3 | ||
|
|
1241a77ffe | ||
|
|
3f0ec49009 | ||
|
|
7e6c45134b | ||
|
|
fc25501e72 | ||
|
|
185bc814d2 | ||
|
|
13682dafcd | ||
|
|
4e4269c5a7 | ||
|
|
bf0be67c88 | ||
|
|
df2efa9383 | ||
|
|
4fd1bdd439 | ||
|
|
71a51dd0b5 | ||
|
|
e971112a6d | ||
|
|
233da57c77 | ||
|
|
84d3b2c9ae | ||
|
|
6ac4f53163 | ||
|
|
2817be8b76 | ||
|
|
181d3b4ab7 | ||
|
|
ca8c3041a6 | ||
|
|
e0a8edc877 | ||
|
|
08c9e9c6fd | ||
|
|
44f110110c | ||
|
|
89cd8b3916 | ||
|
|
e9899006dc | ||
|
|
f3dc731f54 | ||
|
|
c0e313ce84 | ||
|
|
f1dd211ad5 | ||
|
|
5bd4480d82 | ||
|
|
c4f7ae5b7d | ||
|
|
52c9fd40b5 | ||
|
|
43b99cea41 | ||
|
|
88fd5c14fb | ||
|
|
17408db2c4 | ||
|
|
6982cec8cd | ||
|
|
ca034ed6b2 | ||
|
|
27bac2330c | ||
|
|
b449f35fa6 | ||
|
|
22d24b4626 | ||
|
|
91a6ea199e | ||
|
|
f3f3294430 | ||
|
|
59d91fd699 | ||
|
|
37bab7b974 | ||
|
|
5c4335673c | ||
|
|
2cc7826690 | ||
|
|
29e77c3b80 | ||
|
|
90d38f0385 | ||
|
|
d0be9d29ea | ||
|
|
c8e9a1862d | ||
|
|
a3849909b4 | ||
|
|
4f244fba75 | ||
|
|
113657bd74 | ||
|
|
cad591175f | ||
|
|
f75f848fee | ||
|
|
4264a4d7f9 | ||
|
|
116e3c39b5 | ||
|
|
82381e3dbf | ||
|
|
e57ee3a7f2 | ||
|
|
b1e5a66317 | ||
|
|
6742145ee3 | ||
|
|
2454427d03 | ||
|
|
99eeb4ce14 | ||
|
|
c7f4c32d9f | ||
|
|
3a0ac5a3ba | ||
|
|
98a357c7b3 | ||
|
|
0bd6a41aa2 | ||
|
|
49fe79b24f | ||
|
|
8cd500aade | ||
|
|
ef945bf270 | ||
|
|
64932d6cd6 | ||
|
|
24dcc6a130 | ||
|
|
dc91ea47db | ||
|
|
fa641637cd | ||
|
|
a1faa5f631 | ||
|
|
deabd537a1 | ||
|
|
adca86f7af | ||
|
|
7dbbce2e7c | ||
|
|
14eb6b5d79 | ||
|
|
b127a2c3df | ||
|
|
7740028509 | ||
|
|
fd466eecbc | ||
|
|
8962e29d87 | ||
|
|
521d004d15 | ||
|
|
ad5395f9af | ||
|
|
ddd09c0e2c | ||
|
|
44f942d1c4 | ||
|
|
7f22679807 | ||
|
|
c6b81a71a3 | ||
|
|
b431ca1daf | ||
|
|
19536cb065 | ||
|
|
e493990870 | ||
|
|
5126848b13 | ||
|
|
324e0aad89 | ||
|
|
b747737b92 | ||
|
|
2f281d393c | ||
|
|
bad57d1de6 | ||
|
|
249aff7c3a | ||
|
|
87414962e4 | ||
|
|
bfa06bc090 | ||
|
|
87a5a025ed | ||
|
|
d198c29928 | ||
|
|
e559b060e8 | ||
|
|
81dfb8af0f | ||
|
|
14cb6553d8 | ||
|
|
6a60b6f67c | ||
|
|
4733ccda69 | ||
|
|
a02261b85f | ||
|
|
7079e7d309 | ||
|
|
73e61cf3bf | ||
|
|
0a0e5b83b7 | ||
|
|
4ae1f720f4 | ||
|
|
6faffbc3e6 | ||
|
|
5d31682a53 | ||
|
|
3413214748 | ||
|
|
35ea0ced98 | ||
|
|
c3cb7e62e5 | ||
|
|
715c44c72c | ||
|
|
fce3e7b002 | ||
|
|
e01b5e6605 | ||
|
|
65c6416e72 | ||
|
|
34d0baba7f | ||
|
|
a30b08414f | ||
|
|
183b0194a5 | ||
|
|
a0806656fc | ||
|
|
97069f7b00 | ||
|
|
34068aa91f | ||
|
|
92fd1b893a | ||
|
|
bce866d313 | ||
|
|
5258c346d0 | ||
|
|
674231a8d2 | ||
|
|
0e97b7e02b | ||
|
|
ca29621862 | ||
|
|
480f1c5e38 | ||
|
|
e44036d22b | ||
|
|
994d6b014d | ||
|
|
39ed73b0ce | ||
|
|
ee177f8e84 | ||
|
|
16ad15c52c | ||
|
|
47128ba779 | ||
|
|
474e540c1d | ||
|
|
514eaa7483 | ||
|
|
4fcacb4517 | ||
|
|
81c5325642 | ||
|
|
5efd84a87e | ||
|
|
2955358ab5 | ||
|
|
f89f4d6161 | ||
|
|
ca3222081a | ||
|
|
fa0e2bf467 | ||
|
|
31c8390e9a | ||
|
|
34b4eeed9a | ||
|
|
17e5c2ee29 | ||
|
|
e2381e5c16 | ||
|
|
78edab369a | ||
|
|
fabcd85d1c | ||
|
|
5d71934bb6 | ||
|
|
9ed01011f3 | ||
|
|
2f42faa360 | ||
|
|
cd0bef0ea5 | ||
|
|
3ea52a2a26 | ||
|
|
37d0824a49 | ||
|
|
91694a65c3 | ||
|
|
380bd58a71 | ||
|
|
ded547bc2c | ||
|
|
19ab129d79 | ||
|
|
316bd4a41b | ||
|
|
165a3d57fa | ||
|
|
b27cf50528 | ||
|
|
449c84ba30 | ||
|
|
f5b0c2aa1b | ||
|
|
12bf6a2bac | ||
|
|
88b850eca3 | ||
|
|
91117ac549 | ||
|
|
6d5319dcb6 | ||
|
|
8f259f3922 | ||
|
|
37daf43798 | ||
|
|
cd4e0b1323 | ||
|
|
a8414de1e1 | ||
|
|
be32062865 | ||
|
|
a712a7fec7 | ||
|
|
3d9ae11bba | ||
|
|
d2ed0413e5 | ||
|
|
928585b4be | ||
|
|
8f679bfb88 | ||
|
|
761b3b751b | ||
|
|
31facf0caa | ||
|
|
994623c355 | ||
|
|
58ad63f82f | ||
|
|
a50ab158de | ||
|
|
96455e7bd4 | ||
|
|
28a5fdab6d | ||
|
|
96731d9380 | ||
|
|
059ef0d2ba | ||
|
|
9e610b8800 | ||
|
|
5ee42742f8 | ||
|
|
77f9e92211 | ||
|
|
109d71ca86 | ||
|
|
bb4a36f460 | ||
|
|
5ae31c7a17 | ||
|
|
60713c43ee | ||
|
|
ebd346cff8 | ||
|
|
1f8473eb33 | ||
|
|
e94d62fd66 | ||
|
|
6b6d63c6c1 | ||
|
|
31fb09c492 | ||
|
|
572b498936 | ||
|
|
ab31c7606f | ||
|
|
4a72d190ec | ||
|
|
9efc8b5213 | ||
|
|
ab59d06867 | ||
|
|
4c1e50262c | ||
|
|
27880c17a4 | ||
|
|
cd37d66dbc | ||
|
|
28f1c40ea2 | ||
|
|
f4eca20180 | ||
|
|
f0cc1bd121 | ||
|
|
5b6a106366 | ||
|
|
132ba3b530 | ||
|
|
cc6b580ba9 | ||
|
|
58f5051fbd | ||
|
|
ac52d0953b | ||
|
|
4076aa19bf | ||
|
|
09198b98c5 | ||
|
|
6fe6caf51b | ||
|
|
026f82a8b6 | ||
|
|
096198c550 | ||
|
|
9489c1f01d | ||
|
|
488fd35bd4 | ||
|
|
8f1248a33c | ||
|
|
6afb405b85 | ||
|
|
a6f1a18a1e | ||
|
|
fac3a330e2 | ||
|
|
2833abd05a | ||
|
|
089c91a230 | ||
|
|
7ae872ff25 | ||
|
|
e00016af3f | ||
|
|
b7f796d05b | ||
|
|
073f3669ee | ||
|
|
c3d95208bc | ||
|
|
60ce04e82f | ||
|
|
428b86753a | ||
|
|
c157966038 | ||
|
|
d7004a038f | ||
|
|
17788fa198 | ||
|
|
59b6c3b1e4 | ||
|
|
14c7b2e2ed | ||
|
|
99aed8631b | ||
|
|
c2838b4216 | ||
|
|
31ff4f4291 | ||
|
|
32b7840b16 | ||
|
|
2913255da0 | ||
|
|
6704598a94 | ||
|
|
e98931316c | ||
|
|
4f3a876538 | ||
|
|
91adc8a8f8 | ||
|
|
ba626af255 | ||
|
|
fc234e7361 | ||
|
|
3b751f80cb | ||
|
|
964e3b1825 | ||
|
|
b79349980f | ||
|
|
ffb749d75d | ||
|
|
fb5da9a3a3 | ||
|
|
69696e163e | ||
|
|
7e0a91d6ef | ||
|
|
3794d51ad2 | ||
|
|
e3e2ac3626 | ||
|
|
cca2d5ea08 | ||
|
|
dea0d0f93e | ||
|
|
35617f7591 | ||
|
|
a3ee2b4cbf | ||
|
|
e436b0e973 | ||
|
|
17469baa99 | ||
|
|
e9b24cc76f | ||
|
|
0372932f9c | ||
|
|
d55c1f5ad9 | ||
|
|
49dff2eb5f | ||
|
|
c3d7d8f67f | ||
|
|
8162ca6797 | ||
|
|
6fa8ac8728 | ||
|
|
7e053505be | ||
|
|
f0836b4235 | ||
|
|
e19962b325 | ||
|
|
0f9ab87252 | ||
|
|
e20a7c0d85 | ||
|
|
36b58bd40d | ||
|
|
1ae6932c5e | ||
|
|
37c845fe97 | ||
|
|
b4d84a5fa1 | ||
|
|
5254395231 | ||
|
|
7b555dd5ef | ||
|
|
d67fe9f712 | ||
|
|
9c79b6f269 | ||
|
|
f61b9890f6 | ||
|
|
b7a698d694 | ||
|
|
9f5b1c1771 | ||
|
|
84455dad97 | ||
|
|
dd30ba764a | ||
|
|
403fe6f2fc | ||
|
|
9aaf67a3d5 | ||
|
|
b439c9d3fb | ||
|
|
9948fd9ddd | ||
|
|
328121cbd0 | ||
|
|
2acb315a9a | ||
|
|
597979224c | ||
|
|
4cf452e85a | ||
|
|
3b75fc4390 | ||
|
|
fb7fb0487a | ||
|
|
acb3a95659 | ||
|
|
1d97374198 | ||
|
|
5a7d450535 | ||
|
|
e33cf292cc | ||
|
|
3d3c83880d | ||
|
|
d2c28e859f | ||
|
|
c750111f4a | ||
|
|
fbad084f64 | ||
|
|
cca102361d | ||
|
|
1fdb23d389 | ||
|
|
fc46804545 | ||
|
|
5868f55a78 | ||
|
|
79e16b499e | ||
|
|
b72b087e6b | ||
|
|
4e3831d01b | ||
|
|
cbf008372a | ||
|
|
eb08e8ea82 | ||
|
|
552e0e8b8a | ||
|
|
5f47dc4a97 | ||
|
|
a3212b8a81 | ||
|
|
cd62c4727f | ||
|
|
7f49c46212 | ||
|
|
b29ae4a714 | ||
|
|
7d3d3e9c63 | ||
|
|
d42cd733ee | ||
|
|
9ab7765aff | ||
|
|
97c9a22255 | ||
|
|
ce38413017 | ||
|
|
0f5f03f02f | ||
|
|
a360961a54 | ||
|
|
053136d45d | ||
|
|
55c667226e | ||
|
|
ad4e27fd90 | ||
|
|
b0d4aeeabe | ||
|
|
ef44875eb1 | ||
|
|
e7cf70eb5b | ||
|
|
b651b5cb19 | ||
|
|
7c2e077224 | ||
|
|
af922a7024 | ||
|
|
5191fb566f | ||
|
|
311996b721 | ||
|
|
8bfcc415a1 | ||
|
|
9bec858d67 | ||
|
|
207f11a6e6 | ||
|
|
b10b60597e | ||
|
|
d26c0463d5 | ||
|
|
eeb51a18c5 | ||
|
|
b3b8010104 | ||
|
|
3ca8c1c90f | ||
|
|
456e159df3 | ||
|
|
f62d81d931 | ||
|
|
833b6149c2 | ||
|
|
8941f476f0 | ||
|
|
7360cb3f04 | ||
|
|
5b5f0da20d | ||
|
|
a30f56e8c7 | ||
|
|
3eb85143fb | ||
|
|
8d2e77b2ff | ||
|
|
a0159d939d | ||
|
|
28e02ddb0f | ||
|
|
cae9088438 | ||
|
|
f82b8fbabb | ||
|
|
6ef8988a69 | ||
|
|
78005b7b5a | ||
|
|
84d8126b78 | ||
|
|
93d8bfe1f4 | ||
|
|
900f836ed1 | ||
|
|
a2ea4afd6c | ||
|
|
f0e969aef9 | ||
|
|
a70415cbd0 | ||
|
|
f3d976c255 | ||
|
|
dacb17db75 | ||
|
|
83c5ab7987 | ||
|
|
7b9bca4bb2 | ||
|
|
52a81b32a0 | ||
|
|
73bb5bf440 | ||
|
|
7cd87e89dc | ||
|
|
40fa954293 | ||
|
|
eae3419158 | ||
|
|
e5a8382478 | ||
|
|
2698ff50cd | ||
|
|
483449a6d2 | ||
|
|
f64e81eff9 | ||
|
|
e42731c0e7 | ||
|
|
eead3856c3 | ||
|
|
097021bf99 | ||
|
|
68206e7ee0 | ||
|
|
2487fa3881 | ||
|
|
ccbb9a6a96 | ||
|
|
9b288e911b | ||
|
|
f6b14bee63 | ||
|
|
ee6f8e763e | ||
|
|
ce5f077722 | ||
|
|
5f2302366b | ||
|
|
cc29b2d613 | ||
|
|
be85753ed0 | ||
|
|
fb929ca0d9 | ||
|
|
e8d6309c52 | ||
|
|
3dee0699bd | ||
|
|
ca5868f5e3 | ||
|
|
04ef469dbe | ||
|
|
aea5e1e161 | ||
|
|
b5a019ae75 | ||
|
|
2b58f53af7 | ||
|
|
5a3548cb6c | ||
|
|
dd3845026c | ||
|
|
7bb77e9538 | ||
|
|
33acdfe0d5 | ||
|
|
2976c05833 | ||
|
|
7ea240c1f8 | ||
|
|
a5082243e6 | ||
|
|
00fc457cca | ||
|
|
18b98012c0 | ||
|
|
ecc51ac6f0 | ||
|
|
a9bc1b8151 | ||
|
|
e3b030d6f2 | ||
|
|
52e6b6b57d | ||
|
|
c65b202111 | ||
|
|
cc2d69f1f0 | ||
|
|
a57aa8c3c0 | ||
|
|
771e8867c6 | ||
|
|
528cd64269 | ||
|
|
ac766d4070 | ||
|
|
009b34997a | ||
|
|
3a636e2c3c | ||
|
|
0af23c0a45 | ||
|
|
f7acf4b4d4 | ||
|
|
c036c8f123 | ||
|
|
983cc3a629 | ||
|
|
8810b04073 | ||
|
|
0017e7fcb4 | ||
|
|
2e83324a58 | ||
|
|
d368f8a332 | ||
|
|
6f1f9cc084 | ||
|
|
72dd8e302c | ||
|
|
6ef9808ca6 | ||
|
|
13635e57b4 | ||
|
|
f86025a0d8 | ||
|
|
0d29db2df1 | ||
|
|
7d93263c0c | ||
|
|
b525f240a5 | ||
|
|
89cd012a7c | ||
|
|
7e92c2b43b | ||
|
|
49664306d3 | ||
|
|
a9764ff197 | ||
|
|
d185e3c21d | ||
|
|
07ec640757 | ||
|
|
22d61732cf | ||
|
|
e3d3dd3619 | ||
|
|
6a080c69a5 | ||
|
|
c84c6948f3 | ||
|
|
92df81dd89 | ||
|
|
5f286b1eb0 | ||
|
|
10d419dc29 | ||
|
|
4e2c097d22 | ||
|
|
374688e135 | ||
|
|
ede743b97f | ||
|
|
986a24545d | ||
|
|
31f5699f17 | ||
|
|
f6233bdd5c | ||
|
|
8dd2384801 | ||
|
|
a369a2316e | ||
|
|
e80fb98ba1 | ||
|
|
601aa49789 | ||
|
|
b730326130 | ||
|
|
3c58225483 | ||
|
|
ee0fb392a9 | ||
|
|
d5f2bdb527 | ||
|
|
230f84245d | ||
|
|
bb9925c616 | ||
|
|
aea005baad | ||
|
|
bc301f685a | ||
|
|
b96a744336 | ||
|
|
2ce5ad7d56 | ||
|
|
f662a7aceb | ||
|
|
dfff5a43c4 | ||
|
|
9b4d1de727 | ||
|
|
70c959bdeb | ||
|
|
7bd8010137 | ||
|
|
cd78bd408a | ||
|
|
1de13e7354 | ||
|
|
eda1529a6a | ||
|
|
ed680064d7 | ||
|
|
4d5bf293f5 | ||
|
|
3f633803f4 | ||
|
|
a3095b4dfa | ||
|
|
385ca37848 | ||
|
|
ec02d780ea | ||
|
|
ecdb82782f | ||
|
|
ace1d78505 | ||
|
|
87dc8a3f66 | ||
|
|
e8b6db6ed5 | ||
|
|
7aa484b1e4 | ||
|
|
988a2e38f0 | ||
|
|
af359358d5 | ||
|
|
fd3e5c9e5f | ||
|
|
bae28fb94a | ||
|
|
8964086a61 | ||
|
|
e0c2711fac | ||
|
|
27146f2bb0 | ||
|
|
e7e89a4baf | ||
|
|
315807aca2 | ||
|
|
7817a871bf | ||
|
|
fb22c1cee7 | ||
|
|
961a9f0036 | ||
|
|
81f4f3aecb | ||
|
|
f2eabb94c8 | ||
|
|
1ba71dbf15 | ||
|
|
a29cf5f7da | ||
|
|
95bf08c3fb | ||
|
|
cde9df4d81 | ||
|
|
30d3f1600f | ||
|
|
c2d28d1dc8 | ||
|
|
8387805f86 | ||
|
|
c555b0063f | ||
|
|
c741a6122a | ||
|
|
c1c474e2e8 | ||
|
|
9da35dc7ff | ||
|
|
640653bed3 | ||
|
|
93662b802b | ||
|
|
549eff117b | ||
|
|
12dea95ccb | ||
|
|
945188928e | ||
|
|
82b169718f | ||
|
|
becfd85476 | ||
|
|
05cb71f3e3 | ||
|
|
55a929dee8 | ||
|
|
e7d7d20997 | ||
|
|
4d09f9e519 | ||
|
|
103725bc15 | ||
|
|
340a17165c | ||
|
|
f58c3b1081 | ||
|
|
a0623fdeff | ||
|
|
ba16ff1c14 | ||
|
|
788a86331d | ||
|
|
568e8996d4 | ||
|
|
4f232ac245 | ||
|
|
8ce73e9920 | ||
|
|
a45d42bb95 | ||
|
|
1258de0571 | ||
|
|
6f0a651b53 | ||
|
|
8c0f341632 | ||
|
|
b122a48cba | ||
|
|
a7eb3514fc | ||
|
|
54514bb178 | ||
|
|
693f617957 | ||
|
|
a3fa306ac1 | ||
|
|
a7162352ec | ||
|
|
51de0d1a03 | ||
|
|
43f5d9f973 | ||
|
|
a1e1060ae3 | ||
|
|
3063fad0d2 | ||
|
|
4ad434a71c | ||
|
|
8be291d9d2 | ||
|
|
9b07d2ecbc |
No files matched your search
@@ -1,3 +1,10 @@
|
||||
#################### Version
|
||||
# The version of meelo to use (edge, latest, vX.X.X)
|
||||
# See available tags here: https://hub.docker.com/r/arthichaud/meelo-front/tags
|
||||
# The default value is 'latest'
|
||||
# Please read the changelog before changing this value
|
||||
# https://github.com/Arthi-chaud/Meelo/releases
|
||||
TAG=
|
||||
#################### Database
|
||||
# The port on the host where meelo will be accessible
|
||||
PORT=
|
||||
@@ -20,6 +27,9 @@ DATA_DIR=
|
||||
# Set to 1 if you want to allow anonymous request
|
||||
# This will not affect front-end behaviour
|
||||
ALLOW_ANONYMOUS=
|
||||
# If set to 0, users will not be able to create accounts
|
||||
# Do not set this to 0 if you haven't created the first admin account yet
|
||||
ENABLE_USER_REGISTRATION=1
|
||||
#################### Web app
|
||||
# Public URL to access Meelo, without trailing slash
|
||||
# Example: http://localhost:5000
|
||||
@@ -33,7 +43,17 @@ MEILI_MASTER_KEY=
|
||||
# A coma-separated list of random strings used for internal authentication
|
||||
# Example: API_KEYS=azertyuip,qwertyuiop
|
||||
API_KEYS=
|
||||
#################### Scrobblers
|
||||
# All values here are optional
|
||||
# Fill in the values according to what scrobblers you want to enable
|
||||
## LastFM:
|
||||
## Values obtained when creating an API account
|
||||
## https://www.last.fm/api/account/create
|
||||
LASTFM_API_KEY=
|
||||
LASTFM_API_SECRET=
|
||||
## ListenBrainz:
|
||||
### No configuration needed
|
||||
#################### Internal
|
||||
# Do not change this
|
||||
INTERNAL_DATA_DIR=/data
|
||||
INTERNAL_CONFIG_DIR=/config
|
||||
INTERNAL_CONFIG_DIR=/config
|
||||
@@ -3,14 +3,19 @@ description: 'Cache for Front'
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Add build dependencies to container
|
||||
shell: sh
|
||||
run: apk add --update --no-progress tar
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4.0.2
|
||||
with:
|
||||
node-version: 22.x
|
||||
- name: Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
./front/node_modules
|
||||
./front/.next
|
||||
key: front
|
||||
restore-keys: front
|
||||
./front/apps/web/.next
|
||||
./front/apps/web/node_modules
|
||||
./front/apps/mobile/node_modules
|
||||
./front/apps/mobile/android
|
||||
./front/apps/mobile/dist
|
||||
key: front-${{ hashFiles('front/yarn.lock') }}
|
||||
restore-keys: front-
|
||||
@@ -3,7 +3,18 @@ description: 'Cache for Matcher'
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- run: apk add --update --no-progress lsb-release python3 py3-pip
|
||||
- run: apk add --update --no-progress lsb-release python3 py3-pip tar
|
||||
shell: sh
|
||||
- run: mkdir ./pip-cache
|
||||
shell: sh
|
||||
- name: Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
./pip-cache
|
||||
key: matcher-${{ hashFiles('matcher/requirements.txt') }}
|
||||
restore-keys: matcher-
|
||||
- run: pip3 install -r matcher/requirements.txt
|
||||
shell: sh
|
||||
env:
|
||||
PIP_CACHE_DIR: ./pip-cache
|
||||
@@ -6,10 +6,11 @@ runs:
|
||||
- run: |
|
||||
cd scanner
|
||||
go install github.com/swaggo/swag/cmd/swag@latest
|
||||
~/go/bin/swag init -d app -o ./app/docs
|
||||
$GOPATH/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
|
||||
cache-dependency-path: scanner/go.sum
|
||||
go-version: '1.24.1'
|
||||
@@ -12,5 +12,5 @@ runs:
|
||||
path: |
|
||||
./server/node_modules
|
||||
./server/dist
|
||||
key: server
|
||||
restore-keys: server
|
||||
key: server-${{ hashFiles('server/yarn.lock') }}
|
||||
restore-keys: server-
|
||||
@@ -0,0 +1,69 @@
|
||||
name: 'Dockerisation'
|
||||
description: 'Build using Docker, and publish if necessary'
|
||||
inputs:
|
||||
docker-username:
|
||||
description: 'Username to publish Docker image under'
|
||||
required: true
|
||||
docker-token:
|
||||
description: 'Token to publish Docker image'
|
||||
required: true
|
||||
github-token:
|
||||
description: 'GitHub Token'
|
||||
required: true
|
||||
image-suffix:
|
||||
description: "Suffix to 'meelo-'"
|
||||
required: true
|
||||
publish:
|
||||
description: 'Publish to registry'
|
||||
default: 'false'
|
||||
context:
|
||||
description: 'Directory of the Dockerfile'
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Get Tag
|
||||
shell: bash
|
||||
if: ${{ github.event_name == 'release' }}
|
||||
run : echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
|
||||
- name: Get Commit
|
||||
shell: bash
|
||||
if: ${{ github.event_name != 'release' }}
|
||||
run : echo "VERSION=$(git rev-parse --short ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }})" >> $GITHUB_ENV
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ inputs.docker-username }}
|
||||
password: ${{ inputs.docker-token }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ inputs.github-token }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
arthichaud/meelo-${{ inputs.image-suffix }}
|
||||
ghcr.io/${{ github.repository }}-${{ inputs.image-suffix }}
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ${{ inputs.context }}
|
||||
platforms: ${{ (inputs.publish == 'true' || inputs.publish == true) && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
|
||||
push: ${{ inputs.publish == 'true' || inputs.publish == true }}
|
||||
cache-from: type=gha
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || format('arthichaud/meelo-{0}:edge', inputs.image-suffix) }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -0,0 +1,40 @@
|
||||
# https://github.com/marketplace/actions/labeler#usage
|
||||
Documentation:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['README.md', '**/README.md']
|
||||
|
||||
Server:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['server/**', '.github/workflows/server.*', '.github/actions/cache-server/**']
|
||||
|
||||
scanner:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['scanner/**', '.github/workflows/scanner.*', '.github/actions/cache-scanner/**']
|
||||
|
||||
Matcher:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['matcher/**', '.github/workflows/matcher.*', '.github/actions/cache-matcher/**']
|
||||
|
||||
Front (Web):
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['front/apps/web/**', '.github/workflows/front-web.*']
|
||||
|
||||
Front (Mobile):
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['front/apps/mobile/**', '.github/workflows/front-mobile.*']
|
||||
|
||||
Prisma Schema:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['server/prisma/**']
|
||||
|
||||
Translation:
|
||||
- changed-files:
|
||||
- all-globs-to-any-file: ['front/translations/**']
|
||||
|
||||
CI/CD:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['.github/workflows/**', '.github/actions/**', '**/Dockerfile', 'docker-compose.prod.yml']
|
||||
|
||||
DX:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['**/README.md']
|
||||
@@ -1,40 +0,0 @@
|
||||
name: Documentation
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
Changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
docs: ${{ steps.filter.outputs.docs }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
docs:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/documentation.yml'
|
||||
- 'assets/**'
|
||||
Build:
|
||||
needs: changes
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.changes.outputs.docs == 'true' }}
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v3
|
||||
- name: Deploy docs
|
||||
uses: mhausenblas/mkdocs-deploy-gh-pages@master
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CONFIG_FILE: docs/mkdocs.yml
|
||||
REQUIREMENTS: docs/requirements.txt
|
||||
@@ -0,0 +1,117 @@
|
||||
name: "Front (Mobile)"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
push:
|
||||
branches: [ main ]
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./front/apps/mobile
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
Changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
front: ${{ steps.filter.outputs.front }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
front:
|
||||
- 'front/apps/mobile/**'
|
||||
- 'front/package.json'
|
||||
- 'front/translations/**'
|
||||
- 'front/packages/**'
|
||||
- '.github/workflows/front-mobile.yml'
|
||||
- '.github/actions/cache-front/**'
|
||||
# TODO Does not work, reports errors about unknown props
|
||||
# Typecheck:
|
||||
# needs: changes
|
||||
# if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - name: Check out the repo
|
||||
# uses: actions/checkout@v3
|
||||
# - name: Restore Dependencies
|
||||
# uses: ./.github/actions/cache-front
|
||||
# - name: Install Dependencies
|
||||
# run: yarn workspaces focus mobile
|
||||
# - name: Run tsc
|
||||
# run: yarn workspace mobile tsc
|
||||
Biome:
|
||||
needs: changes
|
||||
# Note: We don't need Biome to be run, however, since Build needs it
|
||||
# We have no choice but to run it anyway :(
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-front
|
||||
- name: Install Dependencies
|
||||
run: yarn workspaces focus -A
|
||||
- name: Run Linter
|
||||
run: yarn run -T biome ci . ../../packages
|
||||
Build:
|
||||
needs: [ Biome ]
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Get Tag
|
||||
shell: bash
|
||||
if: ${{ github.event_name == 'release' }}
|
||||
run : echo "EXPO_PUBLIC_BUILD_COMMIT=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
|
||||
- name: Get Commit
|
||||
shell: bash
|
||||
if: ${{ github.event_name != 'release' }}
|
||||
run : echo "EXPO_PUBLIC_BUILD_COMMIT=$(git rev-parse --short ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }})" >> $GITHUB_ENV
|
||||
- run: echo "APK_NAME=${{ github.event_name == 'pull_request' && 'meelo-preview.apk' || 'meelo.apk' }}" >> $GITHUB_ENV
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-front
|
||||
- name: Install Dependencies
|
||||
run: yarn workspaces focus -A
|
||||
# https://github.com/thomasread99/expo-workflows/blob/cd3a0efc7ff3cfa83dec9b03f7bb078d78f46b88/android/android-apk-build.yml#L19C7-L23C34
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
- name: Setup EAS
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
- name: Build
|
||||
run: |
|
||||
eas build --platform android --profile ${{ github.event_name == 'pull_request' && 'preview' || 'production' }} --local --non-interactive && mv *.apk ${{ env.APK_NAME }}
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
if-no-files-found: error
|
||||
name: ${{ env.APK_NAME }}
|
||||
path: front/apps/mobile/${{ env.APK_NAME }}
|
||||
- name: Add APK to release
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: ${{ github.event_name == 'release' }}
|
||||
with:
|
||||
files: |
|
||||
./front/apps/mobile/${{ env.APK_NAME }}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
name: "Front (Web)"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
push:
|
||||
branches: [ main ]
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./front/apps/web
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
Changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
front: ${{ steps.filter.outputs.front }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
front:
|
||||
- 'front/apps/web/**'
|
||||
- 'front/package.json'
|
||||
- 'front/translations/**'
|
||||
- 'front/packages/**'
|
||||
- '.github/workflows/front-web.yml'
|
||||
- '.github/actions/cache-front/**'
|
||||
- '.github/actions/dockerize/**'
|
||||
Build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-front
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
- name: Build
|
||||
run: yarn build:web
|
||||
Biome:
|
||||
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Build ]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-front
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
- name: Run Linter
|
||||
run: yarn run -T biome ci . ../../packages
|
||||
Analysis:
|
||||
needs: [ Build ]
|
||||
name: "Static Analysis"
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.changes.outputs.front == 'true' && always() }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
# - name: Downlad Coverage artifact
|
||||
# uses: actions/download-artifact@v4
|
||||
# with:
|
||||
# name: coverage-report-front
|
||||
# path: ./front/coverage
|
||||
- name: Run SonarCloud Scan
|
||||
uses: SonarSource/sonarqube-scan-action@master
|
||||
with:
|
||||
projectBaseDir: ./front
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_FRONT }}
|
||||
Dockerize:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: ./front
|
||||
image-suffix: front
|
||||
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
|
||||
# Tests:
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: [ Build ]
|
||||
# if: ${{ needs.changes.outputs.front == 'true' && always() }}
|
||||
# steps:
|
||||
# - name: Check out the repo
|
||||
# uses: actions/checkout@v3
|
||||
# - name: Restore Dependencies
|
||||
# uses: ./.github/actions/cache-front
|
||||
# - name: Install Dependencies
|
||||
# run: yarn install
|
||||
# - name: Run unit tests with coverage
|
||||
# run: yarn test:cov
|
||||
# - name: Upload Unit Tests artifact
|
||||
# if: ${{ always() }}
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: coverage-report-front
|
||||
# path: ./front/coverage
|
||||
@@ -1,161 +0,0 @@
|
||||
name: "Front"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main, v3 ]
|
||||
push:
|
||||
branches: [ main, v3 ]
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./front
|
||||
|
||||
jobs:
|
||||
Changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
front: ${{ steps.filter.outputs.front }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
front:
|
||||
- 'front/**'
|
||||
- '.github/workflows/front.yml'
|
||||
Build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
|
||||
container: node:18-alpine3.18
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-front
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
- name: Build
|
||||
run: yarn build
|
||||
Biome:
|
||||
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
|
||||
container: node:22-alpine3.20
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Build ]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-front
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
- name: Run Linter
|
||||
run: yarn run biome ci .
|
||||
Tests:
|
||||
runs-on: ubuntu-latest
|
||||
container: node:19-alpine
|
||||
needs: [ Build ]
|
||||
if: ${{ needs.changes.outputs.front == 'true' && always() }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-front
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
- name: Run unit tests with coverage
|
||||
run: yarn test:cov
|
||||
- name: Upload Unit Tests artifact
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report-front
|
||||
path: ./front/coverage
|
||||
Analysis:
|
||||
needs: [ Tests ]
|
||||
name: "Static Analysis"
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.changes.outputs.front == 'true' && always() }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Downlad Coverage artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-report-front
|
||||
path: ./front/coverage
|
||||
- name: Run SonarCloud Scan
|
||||
uses: SonarSource/sonarcloud-github-action@master
|
||||
with:
|
||||
projectBaseDir: ./front
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_FRONT }}
|
||||
Dockerize:
|
||||
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
|
||||
needs: [ Build ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./front
|
||||
push: false
|
||||
Publish:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
arthichaud/meelo-front
|
||||
ghcr.io/${{ github.repository }}-front
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./front
|
||||
push: true
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-front:edge' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -11,6 +11,10 @@ defaults:
|
||||
run:
|
||||
working-directory: ./matcher
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
Changes:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -28,6 +32,8 @@ jobs:
|
||||
matcher:
|
||||
- 'matcher/**'
|
||||
- '.github/workflows/matcher.yml'
|
||||
- '.github/actions/cache-matcher/**'
|
||||
- '.github/actions/dockerize/**'
|
||||
Typecheck:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
|
||||
@@ -69,63 +75,21 @@ jobs:
|
||||
uses: ./.github/actions/cache-matcher
|
||||
- run: python3 -m tests
|
||||
Dockerize:
|
||||
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
|
||||
needs: [ Typecheck ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Typecheck Docker
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./matcher
|
||||
push: false
|
||||
Publish:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.matcher == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.matcher == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
arthichaud/meelo-matcher
|
||||
ghcr.io/${{ github.repository }}-matcher
|
||||
- name: Typecheck and push Docker images
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: ./matcher
|
||||
push: true
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-matcher:edge' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
image-suffix: matcher
|
||||
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.matcher == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# https://github.com/marketplace/actions/labeler#create-workflow
|
||||
name: "Pull Request Labeler"
|
||||
on:
|
||||
- pull_request_target
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/labeler@v5
|
||||
with:
|
||||
configuration-path: .github/pr_labeler.yml
|
||||
@@ -12,6 +12,10 @@ defaults:
|
||||
shell: bash
|
||||
working-directory: ./scanner
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
Changes:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -29,8 +33,11 @@ jobs:
|
||||
scanner:
|
||||
- 'scanner/**'
|
||||
- '.github/workflows/scanner.yml'
|
||||
- '.github/actions/cache-scanner/**'
|
||||
- '.github/actions/dockerize/**'
|
||||
Build:
|
||||
needs: changes
|
||||
container: ghcr.io/hybridgroup/opencv:4.12.0
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -39,10 +46,11 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/cache-scanner
|
||||
- name: Install Dependencies and Build
|
||||
run: go build -o scanner ./app
|
||||
run: go build -buildvcs=false -o scanner ./app
|
||||
Format:
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/hybridgroup/opencv:4.12.0
|
||||
needs: [ Build ]
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
@@ -55,6 +63,7 @@ jobs:
|
||||
gofmt-flags: '-l'
|
||||
Tests:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/hybridgroup/opencv:4.12.0
|
||||
needs: [ Build ]
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && always() }}
|
||||
steps:
|
||||
@@ -62,7 +71,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/cache-scanner
|
||||
- run: sudo apt-get update -y && sudo apt-get install -y ffmpeg
|
||||
- run: apt-get update -y && apt-get install -y ffmpeg
|
||||
- name: Install Coverage Report
|
||||
run: go install github.com/jandelgado/gcov2lcov@latest
|
||||
- name: Run tests
|
||||
@@ -86,70 +95,28 @@ jobs:
|
||||
name: coverage-report-scanner
|
||||
path: ./scanner
|
||||
- name: Run SonarCloud Scan
|
||||
uses: SonarSource/sonarcloud-github-action@master
|
||||
uses: SonarSource/sonarqube-scan-action@master
|
||||
with:
|
||||
projectBaseDir: ./scanner
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_SCANNER }}
|
||||
Dockerize:
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
|
||||
needs: [ Build ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./scanner
|
||||
push: false
|
||||
Publish:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
arthichaud/meelo-scanner
|
||||
ghcr.io/${{ github.repository }}-scanner
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: ./scanner
|
||||
push: true
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-scanner:edge' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
image-suffix: scanner
|
||||
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
|
||||
@@ -11,6 +11,10 @@ defaults:
|
||||
run:
|
||||
working-directory: ./server
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
Changes:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -28,6 +32,8 @@ jobs:
|
||||
server:
|
||||
- 'server/**'
|
||||
- '.github/workflows/server.yml'
|
||||
- '.github/actions/cache-server/**'
|
||||
- '.github/actions/dockerize/**'
|
||||
Build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
|
||||
@@ -121,70 +127,27 @@ jobs:
|
||||
name: coverage-report-server
|
||||
path: ./server/coverage
|
||||
- name: Run SonarCloud Scan
|
||||
uses: SonarSource/sonarcloud-github-action@master
|
||||
uses: SonarSource/sonarqube-scan-action@master
|
||||
with:
|
||||
projectBaseDir: ./server
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_BACK }}
|
||||
Dockerize:
|
||||
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
|
||||
needs: [ Build ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./server
|
||||
push: false
|
||||
Publish:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.server == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.server == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
arthichaud/meelo-server
|
||||
ghcr.io/${{ github.repository }}-server
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: ./server
|
||||
push: true
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-server:edge' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
image-suffix: server
|
||||
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.server == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
@@ -1,10 +1,15 @@
|
||||
# <img alt="Icon" src="./front/public/banner.png" width="150px">
|
||||
<div align="center" style="display: flex; justify-content: center; align-items: center;">
|
||||
<img width="500" src="https://github.com/user-attachments/assets/8d9b147f-aad3-4cd7-898b-01e58f65dd7c" />
|
||||
</div>
|
||||
|
||||
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:
|
||||
**<div align="center">[Features](#star-features) • [Setup](#-setup) • [Screenshots](#camera-screenshots) • [Wiki](https://github.com/Arthi-chaud/Meelo/wiki)</div>**
|
||||
|
||||
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:
|
||||
<img src="https://github.com/user-attachments/assets/4a489a25-3cf7-4281-a9c1-cf9d41351e69" />
|
||||
|
||||

|
||||
|
||||
Welcome! Meelo is a self-hosted music server. 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 aims 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
|
||||
|
||||
@@ -25,62 +30,83 @@ Meelo has all the basic features you might expect from a music player/server (pl
|
||||
- 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)
|
||||
- Songs have _versions_
|
||||
- See an example [here](./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)
|
||||
- Filter Songs that are exclusive to an album (for compilation albums 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
|
||||
- Get genres, descriptions, and ratings using MusicBrainz, Genius, Wikipedia and many other providers!
|
||||
- Push Scrobbles to ListenBrainz and LastFM!
|
||||
- Download (synced) lyrics or get them from embedded metadata and `.lrc` files
|
||||
|
||||
Keep track of the upcoming features [here](https://github.com/Arthi-chaud/Meelo/issues)
|
||||
|
||||
## :book: How to get started
|
||||
<img src="https://github.com/user-attachments/assets/7e1fe7df-638c-42f1-978d-e1cb59f91324" />
|
||||
|
||||
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.
|
||||
## 🔧 Setup
|
||||
|
||||
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.
|
||||
:point_right: To get started, follow the dedicated [wiki](https://github.com/Arthi-chaud/Meelo/wiki)
|
||||
|
||||
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.
|
||||
|
||||
> [!NOTE]
|
||||
> Meelo is shipped though Docker images. You might need to know a bit about Regexes.
|
||||
|
||||
## :iphone: Android App
|
||||
|
||||
Meelo now has an Android app :tada:. However, it is still in alpha and lacks some important features.
|
||||
|
||||
Go [here](https://github.com/Arthi-chaud/Meelo/discussions/1130) for more information.
|
||||
You can find the known issues and the list of missing/upcoming features [here](https://github.com/Arthi-chaud/Meelo/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22Front%20(Mobile)%22)
|
||||
|
||||
While the app is in alpha, we recommend using the web app. With its responsive design, it works perfectly on mobile devices.
|
||||
|
||||
## :camera: Screenshots
|
||||
|
||||
[](https://github.com/user-attachments/assets/48141241-25ec-4570-a4f1-696c0cdfcaaa)
|
||||
|
||||
:point_right: You can find more screenshots of the UI [here](./docs/screenshots/)
|
||||
|
||||
>[!NOTE]
|
||||
> Some screenshots may be outdated and may not show the state of the latest version of the app. Some minor details may change (e.g. logo, spacing, etc.)
|
||||
</details>
|
||||
|
||||
## :desktop_computer: Live Demo
|
||||
|
||||
We are actively working on a public demo, so that you can try out Meelo. Stay tuned...
|
||||
|
||||
## :camera: Screenshots
|
||||
## 🌐 Translation
|
||||
|
||||
<details>
|
||||
<summary>Unfold to see what the web application looks like!</summary>
|
||||
Meelo uses [Weblate](https://weblate.org/en-gb/) to handle translations.
|
||||
Feel free to contribute [here](https://hosted.weblate.org/engage/meelo/)!
|
||||
|
||||
https://github.com/user-attachments/assets/48141241-25ec-4570-a4f1-696c0cdfcaaa
|
||||
[](https://hosted.weblate.org/engage/meelo/)
|
||||
|
||||
---
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
More screenshots [here](./assets/examples/)
|
||||
|
||||
</details>
|
||||
Any questions about this? Open a discussion [here](https://github.com/Arthi-chaud/Meelo/discussions/categories/translation)!
|
||||
|
||||
## :handshake: Contributing and Bugs reporting
|
||||
|
||||
:hammer: Would you like to contribute to Meelo? Feel free to [open a pull request](https://github.com/Arthi-chaud/Meelo/compare).
|
||||
|
||||
:thinking: Is there a feature you would like to have? [Open an issue](https://github.com/Arthi-chaud/Meelo/issues/new/choose), and we will be happy to discuss it!
|
||||
|
||||
:wrench: Need help with the setup process? Open a discussion [here](https://github.com/Arthi-chaud/Meelo/discussions/categories/need-help-with-setting-up).
|
||||
|
||||
:bug: Have you encountered a bug? Ugh we don't like 'em here! Report it by [opening an issue](https://github.com/Arthi-chaud/Meelo/issues/new/choose).
|
||||
|
||||
:hammer: Would you like to contribute to Meelo? Feel free to [open a pull request](https://github.com/Arthi-chaud/Meelo/compare).
|
||||
|
||||
---
|
||||
|
||||
Get ready to make the most out of you music collection!
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=arthi-chaud_Meelo-back)
|
||||
[](https://sonarcloud.io/summary/new_code?id=arthi-chaud_Meelo-back)
|
||||

|
||||
|
||||
<img src="https://github.com/user-attachments/assets/4e7a161f-180a-4f95-89b1-482421018a61" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.1.3/schema.json",
|
||||
"vcs": {
|
||||
"enabled": false,
|
||||
"clientKind": "git",
|
||||
@@ -7,15 +7,25 @@
|
||||
"defaultBranch": "main"
|
||||
},
|
||||
"files": {
|
||||
"ignore": ["**/.yarn/**", "**/dist/**", "**/package.json"]
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/.yarn/**",
|
||||
"!**/dist/**",
|
||||
"!**/package.json",
|
||||
"!**/.expo/**"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"indentWidth": 4
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -73,12 +73,12 @@ services:
|
||||
scanner:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./front/src:/app/src
|
||||
- ./front/public:/app/public
|
||||
- ./front/next.config.js:/app/next.config.js
|
||||
- ./front/apps/web/:/app/apps/web/
|
||||
- ./front/packages:/app/packages
|
||||
- ./front/translations:/app/translations
|
||||
- ./front/assets:/app/assets
|
||||
- ./front/package.json:/app/package.json
|
||||
- ./front/tsconfig.json:/app/tsconfig.json
|
||||
- ./front/yarn.lock:/app/yarn.lock
|
||||
- ./front/tsconfig.base.json:/app/tsconfig.base.json
|
||||
- front_deps:/app/node_modules
|
||||
environment:
|
||||
# This can be commented out. see front/next.config.js
|
||||
@@ -89,6 +89,8 @@ services:
|
||||
matcher:
|
||||
build:
|
||||
context: ./matcher
|
||||
args:
|
||||
- VERSION=dev
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
server:
|
||||
@@ -134,7 +136,6 @@ services:
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- transcoder_cache:/cache
|
||||
profiles: ['', 'cpu']
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# This compose allows to setup the whole project in the production environment
|
||||
services:
|
||||
server:
|
||||
image: ghcr.io/arthi-chaud/meelo-server:latest
|
||||
image: arthichaud/meelo-server:${TAG:-latest}
|
||||
expose:
|
||||
- 4000
|
||||
restart: on-failure
|
||||
@@ -28,7 +28,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
scanner:
|
||||
image: ghcr.io/arthi-chaud/meelo-scanner:latest
|
||||
image: arthichaud/meelo-scanner:${TAG:-latest}
|
||||
expose:
|
||||
- 8133
|
||||
depends_on:
|
||||
@@ -48,7 +48,7 @@ services:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
|
||||
front:
|
||||
image: ghcr.io/arthi-chaud/meelo-front:latest
|
||||
image: arthichaud/meelo-front:${TAG:-latest}
|
||||
expose:
|
||||
- 3000
|
||||
depends_on:
|
||||
@@ -62,7 +62,7 @@ services:
|
||||
- PUBLIC_SCANNER_URL=${PUBLIC_URL}/scanner
|
||||
- SSR_SCANNER_URL=http://scanner:8133
|
||||
matcher:
|
||||
image: ghcr.io/arthi-chaud/meelo-matcher:latest
|
||||
image: arthichaud/meelo-matcher:${TAG:-latest}
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
server:
|
||||
@@ -121,7 +121,6 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
profiles: ['', 'cpu']
|
||||
mq:
|
||||
image: rabbitmq:4.0-alpine
|
||||
environment:
|
||||
|
||||
@@ -3,6 +3,8 @@ services:
|
||||
server:
|
||||
build:
|
||||
context: ./server
|
||||
args:
|
||||
- VERSION=local
|
||||
expose:
|
||||
- 4000
|
||||
restart: on-failure
|
||||
@@ -31,6 +33,8 @@ services:
|
||||
front:
|
||||
build:
|
||||
context: ./front
|
||||
args:
|
||||
- VERSION=local
|
||||
expose:
|
||||
- 3000
|
||||
depends_on:
|
||||
@@ -46,6 +50,8 @@ services:
|
||||
scanner:
|
||||
build:
|
||||
context: ./scanner
|
||||
args:
|
||||
- VERSION=local
|
||||
expose:
|
||||
- 8133
|
||||
depends_on:
|
||||
@@ -67,6 +73,8 @@ services:
|
||||
matcher:
|
||||
build:
|
||||
context: ./matcher
|
||||
args:
|
||||
- VERSION=local
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
server:
|
||||
@@ -99,7 +107,6 @@ services:
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- transcoder_cache:/cache
|
||||
profiles: ['', 'cpu']
|
||||
db:
|
||||
image: postgres:alpine3.14
|
||||
healthcheck:
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
site/
|
||||
@@ -1,45 +0,0 @@
|
||||
site_name: Meelo - User Documentation
|
||||
docs_dir: './src'
|
||||
repo_url: https://github.com/Arthi-chaud/Meelo
|
||||
nav:
|
||||
- Setup:
|
||||
- TLDR: setup/tldr.md
|
||||
- Requirements: setup/requirements.md
|
||||
- Prepare Music Library: setup/prepare-music-library.md
|
||||
- Create Configuration Folder: setup/create-conf-folder.md
|
||||
- Prepare Your Settings: setup/settings.md
|
||||
- Configure Providers: setup/providers.md
|
||||
- Environment Variables: setup/env.md
|
||||
- Docker: setup/docker.md
|
||||
- Start: setup/startup.md
|
||||
- Create First Library: setup/create-library.md
|
||||
- Album Types: album-types.md
|
||||
- Song Types: song-types.md
|
||||
- Featuring Artists: featuring-artists.md
|
||||
theme:
|
||||
name: material
|
||||
language: en
|
||||
palette:
|
||||
scheme: default
|
||||
titles_only: true
|
||||
icon:
|
||||
repo: fontawesome/brands/github
|
||||
font:
|
||||
text: Noto Sans
|
||||
logo: ./icon.png
|
||||
favicon: ./favicon.ico
|
||||
features:
|
||||
- navigation.instant
|
||||
- navigation.tracking
|
||||
- navigation.tabs
|
||||
- navigation.tabs.sticky
|
||||
- navigation.sections
|
||||
- navigation.indexes
|
||||
- toc.integrate
|
||||
- navigation.top
|
||||
extra_css:
|
||||
- stylesheets/extra.css
|
||||
plugins:
|
||||
- search
|
||||
markdown_extensions:
|
||||
- mdx_truly_sane_lists
|
||||
@@ -1,2 +0,0 @@
|
||||
mkdocs-material
|
||||
mdx_truly_sane_lists
|
||||
|
After Width: | Height: | Size: 794 KiB |
|
After Width: | Height: | Size: 654 KiB |
|
After Width: | Height: | Size: 930 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 389 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 776 KiB |
|
After Width: | Height: | Size: 650 KiB |
|
After Width: | Height: | Size: 815 KiB |
|
After Width: | Height: | Size: 726 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 5.3 MiB |
@@ -1,42 +0,0 @@
|
||||
# Meelo's Album Types
|
||||
|
||||
In Music Apps, we usually call any song container an *Album*. While this is technically incorrect, many music streaming services categorizes these *Albums* in sub-categories: Albums, Singles, Live, Compilations, etc.
|
||||
|
||||
## Available Types
|
||||
|
||||
Meelo supports this categorization, and the following album types:
|
||||
|
||||
- Studio Recording
|
||||
- What we would usually call *Albums*
|
||||
- Live Recording
|
||||
- Compilation
|
||||
- Single
|
||||
- Soundtrack
|
||||
- Remix Album
|
||||
- Collections that would mainly have remixes of songs by a single artist, while not being a Single
|
||||
- Video Album
|
||||
- Any kind of collection that would only contain video tracks
|
||||
|
||||
## Type recognition
|
||||
|
||||
Meelo tries to be as independent of user interactions as possible. Therefore, type recognition is done automatically, when a new album is registered.
|
||||
|
||||
For now, Type recognition is solely based on the Album's title. Keywords to recognize an album's type were inspired by the way other music streaming services would categorize an album. Therefore, if your music collection's naming convention work a similar way to Spotify, Apple Music, ..., there might not be a lot to change if you want to fully enjoy this feature. However, if you consider that one of the following ways to recognize a type is incomplete or incorrect, feel free to [open an issue](https://github.com/Arthi-chaud/Meelo/issues).
|
||||
|
||||
### Keywords
|
||||
|
||||
| Album Type | Keywords |
|
||||
|---|---|
|
||||
| Studio Album | See note below |
|
||||
| Compilations | - `best of`<br>- `hits`<br>- `greatest hits`<br>- `singles`<br>- `collection`<br>+ See note below |
|
||||
| Live Recording | - '` tour`' (Notice the leading whitespace)<br>- `live from`<br>- `live at`<br>- `unplugged`<br>- `(live)` |
|
||||
| Single | - `- single`<br>- `- ep`<br>- `(remixes)` |
|
||||
| Video Album | - `music videos`<br>- `the video`<br>- `dvd` |
|
||||
| Remix Album | - `remix album`<br>- `mixes`<br>- `the remixes`<br>- `remixed`<br>- `remixes`<br>- `best mixes` |
|
||||
| Soundtrack | - `soundtrack`<br>- `from the motion picture`<br>- `music from and inspired by` |
|
||||
|
||||
Notes:
|
||||
|
||||
- Keywords are case-insensitive.
|
||||
- Any album that would not fall into the other categories will be considered to be a Compilation IF the album is a 'compilation' album (see metadata)
|
||||
- Any album that would not fall into the other categories will be considered to be a Studio Recording
|
||||
@@ -1 +0,0 @@
|
||||
../../front/public/banner-black.png
|
||||
@@ -1 +0,0 @@
|
||||
../../front/public/favicon.ico
|
||||
@@ -1,53 +0,0 @@
|
||||
# Meelo's Featuring Artist Management
|
||||
|
||||
Meelo is now able to parse and recognise featuring artists on a song, based on:
|
||||
|
||||
- Its name
|
||||
- Its artist field (from the metadata)
|
||||
|
||||
It does not leverage the album artist.
|
||||
|
||||
For example, a song named 'My Song (feat. Another Artist)' by 'Primary Artist & Other Artist' would be parsed to 'My Song' by artist 'Primary artist', featuring 'Other Artist' and 'Another Artist'.
|
||||
|
||||
Therefore, each featuring artist will be added to the database, and treated like any other artist.
|
||||
|
||||
Parsing works with `()`, `[]` and `-` as delimiters.
|
||||
|
||||
## Examples
|
||||
|
||||
In this section, we list examples that would be successfully parsed by Meelo
|
||||
|
||||
- 'My Song (feat. B)' by 'A'
|
||||
- Name: 'My Song'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B'
|
||||
- 'My Song (Remix) [feat. B]' by 'A'
|
||||
- Name: 'My Song (Remix)'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B'
|
||||
- 'My Song' by 'A & B'
|
||||
- Name: 'My Song'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B'
|
||||
- 'My Song' by 'A, B & C'
|
||||
- Name: 'My Song'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B', 'C'
|
||||
- 'My Song - With D' by 'A, B & C'
|
||||
- Name: 'My Song'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B', 'C', 'D'
|
||||
- 'My Song - Version With Drums' by 'A'
|
||||
- Name: 'My Song - Version With Drums'
|
||||
- Artist: 'A'
|
||||
- Featuring: None
|
||||
- 'My Song' by 'A Vs. B'
|
||||
- Name: 'My Song'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B'
|
||||
|
||||
## Special Case: Artist is already known
|
||||
|
||||
By default, we use '&' to find and split a list of artist. However, this behaviour changes if an artist containing '&' already exists.
|
||||
|
||||
For example, if we had an *album* artist named 'A & B' (since album artist are not parsed as much as song artists), a song named 'My Song (feat. A & B)' would be parsed as 'My Song', with featuring artist 'A & B'.
|
||||
@@ -1 +0,0 @@
|
||||
../../front/public/icon.png
|
||||
@@ -1,3 +0,0 @@
|
||||

|
||||
|
||||
# Welcome to Meelo's User Documentation
|
||||
@@ -1,7 +0,0 @@
|
||||
# Create a configuration folder
|
||||
|
||||
Meelo is runnable through a Docker container. But it needs a way to be parameterized and persist some data.
|
||||
|
||||
Therefore, it is recommended that you create a dedicated folder on your computer for Meelo.
|
||||
|
||||
For the sake of clarity, in this documentation, we'll refer to this folder as `Meelo`.
|
||||
@@ -1,8 +0,0 @@
|
||||
# Create your first library
|
||||
|
||||
After creating the admin account (from Web App),
|
||||
Visit the `/settings/libraries`, and click `Create Library` to create your first library.
|
||||
|
||||
Once your library is created, you should run a scan to collect the related files. To do so, click the *Scan* button.
|
||||
|
||||
Your Meelo server is ready to be used! Visit the `/` route on your favourite browser to enjoy your favourite music :).
|
||||
@@ -1,5 +0,0 @@
|
||||
# Docker and Docker-compose
|
||||
|
||||
In your `Meelo` folder, copy this [docker-compose file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/docker-compose.prod.yml) as `docker-compose.yml`. It can be used as-is.
|
||||
|
||||
Additionally, you should copy [nginx configuration file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/nginx.conf.template) as is in your `Meelo` folder.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Environment variables
|
||||
|
||||
In your `Meelo` directory, copy this [`.env.example`](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/.env.example) as `.env` and fill the variables
|
||||
@@ -1,18 +0,0 @@
|
||||
# Prepare your music library
|
||||
|
||||
The toughest part is to have an structured music library. The way files are organised are up to you! But here is a recommended architecture:
|
||||
|
||||
```tree
|
||||
Library's Folder
|
||||
│
|
||||
Artist 1
|
||||
│ │
|
||||
│ └───Album 1
|
||||
│ │ 01 Track 1.m4a
|
||||
│ │ 02 Track 2.m4a
|
||||
| | cover.jpg
|
||||
│ │ ...
|
||||
│ ...
|
||||
```
|
||||
|
||||
Meelo provides two ways of collecting metadata: using embedded tags, or using the path of the file. If a metadata source does not provide a specific field, you can use the other as a fallback. (You will configure this behaviour in the next sections). But before going any further, you should decide which metadata collection method to use.
|
||||
@@ -1,42 +0,0 @@
|
||||
# Providers
|
||||
|
||||
## Enabling Providers
|
||||
|
||||
In the `settings.json`, ff the `provider` object does not contain a provider's settings, this provider will be disabled.
|
||||
|
||||
Providers can also be disabled explicitly by setting the Provider's settings' field `enabled` at `false`. For example:
|
||||
|
||||
```json
|
||||
// In settings.json
|
||||
{
|
||||
//... Other properties
|
||||
"providers": {
|
||||
"provider1": {}, // Provider 1 is enabled
|
||||
// Provider 2 was not specified, it is therefore disabled
|
||||
"provider3": {
|
||||
"enabled": true // Provider 3 is enabled
|
||||
},
|
||||
"provider4": {
|
||||
"enabled": false, // Provider 4 is disabled
|
||||
"apiKey": "" // Other property of provider 4
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If a provider is disabled, no call to its service will be done.
|
||||
|
||||
## Providers' properties and features
|
||||
|
||||
| Provider Name | Key | Additional Properties | Artist Description | Artist Illustration | Album Description | Album Rating | Song Description | Song Lyrics | Release Identification | Album Genres |
|
||||
|---------------|---------------|--------------------------------------------------------------------------------------------|--------------------|---------------------|-------------------|--------------|------------------|-------------|------------------------|--------------|
|
||||
| Genius | `genius` | `apiKey`: Access Token, you can get it [here](https://genius.com/api-clients) | x | x | x | | x | x | | |
|
||||
| MusicBrainz | `musicbrainz` | None | | | | | | | | x |
|
||||
| AllMusic | `allmusic` | None | | | x | x | | | | |
|
||||
| MetaCritic | `metacritic` | None | | | x | x | | | | |
|
||||
| Wikipedia | `wikipedia` | None | x | | x | | x | | | |
|
||||
| Discogs | `discogs` | `apiKey`: Access Token, you can get it [here](https://www.discogs.com/settings/developers) | x | | | | | | x | x |
|
||||
|
||||
**Warning**: Meelo heavily relies on Wikidata to optimize the metadata fetching. It is used if, and only if the MusicBrainz Provider is enabled. It is not possible to use one without the other.
|
||||
|
||||
*Note*: If MusicBrainz is enabled, we might make requests to [AcoustID](https://acoustid.org)'s API to match songs using audio fingerprints (No data will be submitted).
|
||||
@@ -1,8 +0,0 @@
|
||||
# Requirements
|
||||
|
||||
Meelo is easy to setup! But you'll need the following things before continuing
|
||||
|
||||
- Have you music library ready (see [*this step*](../prepare-music-library))
|
||||
- Know how `Docker` and `docker-compose` work
|
||||
- Know how to create a `.env` file
|
||||
- Know how to manipulate Regular Expressions (Especially the Golang flavor)
|
||||
@@ -1,35 +0,0 @@
|
||||
# Prepare your settings
|
||||
|
||||
In your `Meelo` folder, download and fill [this `settings.json` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/settings.json)
|
||||
|
||||
All fields are **required**
|
||||
|
||||
- `trackRegex`: Probably the most important setting for Meelo. This field takes an array of strings, each string being a Regular Expression string. The RegExp will be used to find the files to import (It will be matched against the **absolute path** of the file). As mentioned before, the path of the file can be used as a metadata source. Therefore, **the Regular Expression accepts groups to find metadata**:
|
||||
- `AlbumArtist`: The artist of the parent album.
|
||||
- Special Case: If it equals `Compilations`, related album will be considered as compilation albums (with no main artist, like soundtrack albums)
|
||||
- `Artist`: The artist of the track. If it is not present, it will use the `AlbumArtist` field.
|
||||
- `Release`: Usually no present in a standard file architecture; the name of the *release*. When it is not present, the `Album` field will be used.
|
||||
- `Album`: The name of the album of the material. This field will then be parsed to group related album releases.
|
||||
- `Year`: The Year of the release of the material
|
||||
- `Disc`: The Index of the disc the track is on.
|
||||
- `Index`: The index of the track in a playlist/on a disc.
|
||||
- `Track`: The name of the track
|
||||
- `Genre`: The genre of the material
|
||||
- `DiscogsID`: The Discogs ID of the parent release. A Discogs ID should take the form of a digit-only string. This ID can be found in the URL of the release's Discogs page or in the top-right corner of that page (displayed as `[rXXXXXXXX]`)
|
||||
- *Note*: **If you do not use embedded data**, most regex groups are optional. Only `Track` and (`AlbumArtist` or `Artist`) are required for the file to be accepted by the scanner.
|
||||
- `metadata`: Configuration of the **metadata extraction** system:
|
||||
- `source`: The **primary metadata source** to use:
|
||||
- `embedded`: Using the embedded tags
|
||||
- `path`: Using the Regular Expression's groups.
|
||||
- `order`: Define the missing metadata fallback behaviour. *Note*: if this method is enabled, it will consider 'cover.*' file in the same directory as the media file as an illustration candidate.
|
||||
- `only`: If a field is missing from the primary metadata source, will not use the other method to get it.
|
||||
- `preferred`: If a field is missing from the primary metadata source, will try to use the other method to get it.
|
||||
- `useExternalProviderGenres`: Set to `true` if you want genres from external metadata providers to be used. These genres will be used alongside with embedded genres (if parsing embedded metadata is enabled)
|
||||
- `providers`: Please refer to [*Providers*](../providers)
|
||||
- `compilations`:
|
||||
- `artists`: Optional field. If an album artist in this list, their albums will be considered to be compilation albums.
|
||||
- `useID3CompTag`: If true, use iTunes' non-standard ID3 tags for compilations.
|
||||
|
||||
*Note*: The provided Regular Expression uses the iTunes file architecture.
|
||||
|
||||
*Note*: Regular Expression must use the Golang Flavour.
|
||||
@@ -1,16 +0,0 @@
|
||||
# Start Meelo
|
||||
|
||||
Once these three files are ready, run the following command from your `Meelo` folder:
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Two things can prevent Meelo from starting normally:
|
||||
|
||||
- A bad `settings.json` file
|
||||
- In that case, a message should tell you what went wrong
|
||||
- Connection to database failed
|
||||
- In that case, you should have a red message telling you a query failed. To fix, simply restart the server
|
||||
@@ -1,12 +0,0 @@
|
||||
# TLDR
|
||||
|
||||
Don't feel like reading? Here's how to setup Meelo quickly
|
||||
|
||||
- Download and fill [this `docker-compose` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/docker-compose.prod.yml)
|
||||
- Download and fill [this `nginx` configuration file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/nginx.conf.template)
|
||||
- Download and fill [this `.env` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/.env.example)
|
||||
- Download and fill [this `settings.json` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/settings.json)
|
||||
|
||||
You should be ready to go! If something goes wrong, please take the time to read the `Setup` section of this documentation.
|
||||
|
||||
Enjoy!
|
||||
@@ -1,31 +0,0 @@
|
||||
# Meelo's Album Types
|
||||
|
||||
It is possible to filter songs based on their *type*.
|
||||
|
||||
## Available Types
|
||||
|
||||
Meelo supports the following song types:
|
||||
|
||||
- Original
|
||||
- Live
|
||||
- Acoustic
|
||||
- Remix
|
||||
- Instrumental
|
||||
- A Cappella
|
||||
- Edit
|
||||
- Of the original version.
|
||||
- If the song is an edit of a remix, it will be categorised as 'Remix'
|
||||
- Clean
|
||||
- Demo
|
||||
- Medley
|
||||
- Non-Music (e.g. Interviews)
|
||||
|
||||
## Type recognition
|
||||
|
||||
Songs' type recognition is solely based on keywords present in their name. For example:
|
||||
|
||||
- 'My Song (Remix)' would be typed as 'Remix'
|
||||
- 'My Song (Live)' would be recognised as a Live rendition
|
||||
- 'My Song (Rough Mix)' would be defined as 'Demo'
|
||||
- 'My Song (Instrumental Mix)' would be an 'Instrumental'
|
||||
- 'My Song (Instrumental Break Down Mix)' would be a 'Remix'
|
||||
@@ -1,3 +0,0 @@
|
||||
:root {
|
||||
--md-primary-fg-color: #2F2C2B;
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
node_modules/
|
||||
.next/
|
||||
apps/web/.next/
|
||||
@@ -1,83 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"plugin:prettier/recommended",
|
||||
"plugin:react/recommended",
|
||||
"../.eslintrc.json"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": ["./tsconfig.json"]
|
||||
},
|
||||
"plugins": ["react", "@typescript-eslint", "unused-imports", "import"],
|
||||
"ignorePatterns": ["node_modules/", "jest.config.js", "__tests__"],
|
||||
"rules": {
|
||||
"react/prop-types": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/sort-prop-types": "warn",
|
||||
"react/jsx-props-no-multi-spaces": "warn",
|
||||
"react/jsx-max-props-per-line": [
|
||||
"error",
|
||||
{ "maximum": 3, "when": "multiline" }
|
||||
],
|
||||
"react/jsx-key": "error",
|
||||
"react/jsx-indent-props": ["warn", "tab"],
|
||||
"react/jsx-curly-spacing": [
|
||||
"warn",
|
||||
{ "when": "never", "children": false }
|
||||
],
|
||||
"react/jsx-indent": ["warn", "tab"],
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "react-query",
|
||||
"importNames": [
|
||||
"useQueryClient",
|
||||
"useQuery",
|
||||
"useInfiniteQuery",
|
||||
"useQueries"
|
||||
],
|
||||
"message": "Use wrapper functions provided by src/api/query"
|
||||
},
|
||||
{
|
||||
"name": "@mui/material",
|
||||
"importNames": ["Link"],
|
||||
"message": "Use Link from 'next/router"
|
||||
},
|
||||
{
|
||||
"name": "@mui/material",
|
||||
"importNames": ["Modal"],
|
||||
"message": "Use 'confirm' from 'material-ui-confirm'"
|
||||
},
|
||||
{
|
||||
"name": "@mui/material",
|
||||
"importNames": ["ThemeProvider"],
|
||||
"message": "Use wrapper from theme/provider"
|
||||
},
|
||||
{
|
||||
"name": "format-duration",
|
||||
"message": "Use wrapper"
|
||||
},
|
||||
{
|
||||
"name": "@mui/material",
|
||||
"importNames": ["Fade"],
|
||||
"message": "Use wrapper from 'components/fade'"
|
||||
},
|
||||
{
|
||||
"name": "@mui/icons-material",
|
||||
"message": "Use icons in 'src/components/icons'"
|
||||
}
|
||||
],
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["@mui/icons-material/*"],
|
||||
"message": "Use icons in 'src/components/icons'"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
diff --git a/cpp/core/HybridFunction.hpp b/cpp/core/HybridFunction.hpp
|
||||
index efcea05a248540806cfefd04efb44920ed941228..d8604ffb8cf4c4d6ed42d8238f8012a9d6ca0bd7 100644
|
||||
--- a/cpp/core/HybridFunction.hpp
|
||||
+++ b/cpp/core/HybridFunction.hpp
|
||||
@@ -8,8 +8,7 @@
|
||||
#pragma once
|
||||
|
||||
namespace margelo::nitro {
|
||||
-template <typename T, typename Enable>
|
||||
-struct JSIConverter;
|
||||
+template <typename T, typename Enable> struct JSIConverter;
|
||||
} // namespace margelo::nitro
|
||||
|
||||
#include "CountTrailingOptionals.hpp"
|
||||
@@ -23,13 +22,17 @@ struct JSIConverter;
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
|
||||
+#ifdef ANDROID
|
||||
+#include <fbjni/fbjni.h>
|
||||
+#endif
|
||||
+
|
||||
namespace margelo::nitro {
|
||||
|
||||
using namespace facebook;
|
||||
|
||||
/**
|
||||
- * Represents the kind of a function - it can be either a normal function ("METHOD"),
|
||||
- * or a property ("GETTER" + "SETTER")
|
||||
+ * Represents the kind of a function - it can be either a normal function
|
||||
+ * ("METHOD"), or a property ("GETTER" + "SETTER")
|
||||
*/
|
||||
enum class FunctionKind { METHOD, GETTER, SETTER };
|
||||
|
||||
@@ -44,76 +47,108 @@ private:
|
||||
|
||||
public:
|
||||
// getters
|
||||
- inline const std::string& getName() const {
|
||||
- return _name;
|
||||
- }
|
||||
- inline size_t getParamCount() const {
|
||||
- return _paramCount;
|
||||
- }
|
||||
- inline const jsi::HostFunctionType& getHostFunction() const {
|
||||
+ inline const std::string &getName() const { return _name; }
|
||||
+ inline size_t getParamCount() const { return _paramCount; }
|
||||
+ inline const jsi::HostFunctionType &getHostFunction() const {
|
||||
return _function;
|
||||
}
|
||||
|
||||
public:
|
||||
// functions
|
||||
- inline jsi::Function toJSFunction(jsi::Runtime& runtime) const {
|
||||
- return jsi::Function::createFromHostFunction(runtime, jsi::PropNameID::forUtf8(runtime, _name), static_cast<unsigned int>(_paramCount),
|
||||
- _function);
|
||||
+ inline jsi::Function toJSFunction(jsi::Runtime &runtime) const {
|
||||
+ return jsi::Function::createFromHostFunction(
|
||||
+ runtime, jsi::PropNameID::forUtf8(runtime, _name),
|
||||
+ static_cast<unsigned int>(_paramCount), _function);
|
||||
}
|
||||
|
||||
private:
|
||||
- HybridFunction(jsi::HostFunctionType&& function, size_t paramCount, const std::string& name)
|
||||
+ HybridFunction(jsi::HostFunctionType &&function, size_t paramCount,
|
||||
+ const std::string &name)
|
||||
: _function(std::move(function)), _paramCount(paramCount), _name(name) {}
|
||||
|
||||
public:
|
||||
/**
|
||||
* Create a new `HybridFunction` that can be called from JS.
|
||||
* This performs proper JSI -> C++ conversion using `JSIConverter<T>`,
|
||||
- * and assumes that the object this is called on has a proper `this` configured.
|
||||
- * The object's `this` needs to be a `NativeState`.
|
||||
+ * and assumes that the object this is called on has a proper `this`
|
||||
+ * configured. The object's `this` needs to be a `NativeState`.
|
||||
*/
|
||||
template <typename THybrid, typename ReturnType, typename... Args>
|
||||
- static inline HybridFunction createHybridFunction(const std::string& name, ReturnType (THybrid::*method)(Args...), FunctionKind kind) {
|
||||
- jsi::HostFunctionType hostFunction = [name, method, kind](/* JS Runtime */ jsi::Runtime& runtime,
|
||||
- /* HybridObject */ const jsi::Value& thisValue,
|
||||
- /* JS arguments */ const jsi::Value* args,
|
||||
- /* argument size */ size_t count) -> jsi::Value {
|
||||
- // 1. Get actual `HybridObject` instance from `thisValue` (it's stored as `NativeState`)
|
||||
- std::shared_ptr<THybrid> hybridInstance = getHybridObjectNativeState<THybrid>(runtime, thisValue, kind, name);
|
||||
-
|
||||
- // 2. Make sure the given arguments match, either with a static size, or with potentially optional arguments size.
|
||||
+ static inline HybridFunction
|
||||
+ createHybridFunction(const std::string &name,
|
||||
+ ReturnType (THybrid::*method)(Args...),
|
||||
+ FunctionKind kind) {
|
||||
+ jsi::HostFunctionType hostFunction =
|
||||
+ [name, method, kind](/* JS Runtime */ jsi::Runtime &runtime,
|
||||
+ /* HybridObject */ const jsi::Value &thisValue,
|
||||
+ /* JS arguments */ const jsi::Value *args,
|
||||
+ /* argument size */ size_t count) -> jsi::Value {
|
||||
+ // 1. Get actual `HybridObject` instance from `thisValue` (it's stored as
|
||||
+ // `NativeState`)
|
||||
+ std::shared_ptr<THybrid> hybridInstance =
|
||||
+ getHybridObjectNativeState<THybrid>(runtime, thisValue, kind, name);
|
||||
+
|
||||
+ // 2. Make sure the given arguments match, either with a static size, or
|
||||
+ // with potentially optional arguments size.
|
||||
constexpr size_t optionalArgsCount = trailing_optionals_count_v<Args...>;
|
||||
constexpr size_t maxArgsCount = sizeof...(Args);
|
||||
constexpr size_t minArgsCount = maxArgsCount - optionalArgsCount;
|
||||
bool isWithinArgsRange = (count >= minArgsCount && count <= maxArgsCount);
|
||||
if (!isWithinArgsRange) [[unlikely]] {
|
||||
// invalid amount of arguments passed!
|
||||
- std::string funcName = getHybridFuncFullName<THybrid>(kind, name, hybridInstance.get());
|
||||
+ std::string funcName =
|
||||
+ getHybridFuncFullName<THybrid>(kind, name, hybridInstance.get());
|
||||
if constexpr (minArgsCount == maxArgsCount) {
|
||||
- // min and max args length is the same, so we don't have any optional parameters. fixed count
|
||||
- throw jsi::JSError(runtime, "`" + funcName + "` expected " + std::to_string(maxArgsCount) + " arguments, but received " +
|
||||
+ // min and max args length is the same, so we don't have any optional
|
||||
+ // parameters. fixed count
|
||||
+ throw jsi::JSError(runtime, "`" + funcName + "` expected " +
|
||||
+ std::to_string(maxArgsCount) +
|
||||
+ " arguments, but received " +
|
||||
std::to_string(count) + "!");
|
||||
} else {
|
||||
- // min and max args length are different, so we have optional parameters - variable length arguments.
|
||||
- throw jsi::JSError(runtime, "`" + funcName + "` expected between " + std::to_string(minArgsCount) + " and " +
|
||||
- std::to_string(maxArgsCount) + " arguments, but received " + std::to_string(count) + "!");
|
||||
+ // min and max args length are different, so we have optional
|
||||
+ // parameters - variable length arguments.
|
||||
+ throw jsi::JSError(runtime, "`" + funcName + "` expected between " +
|
||||
+ std::to_string(minArgsCount) +
|
||||
+ " and " +
|
||||
+ std::to_string(maxArgsCount) +
|
||||
+ " arguments, but received " +
|
||||
+ std::to_string(count) + "!");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
- // 3. Actually call the method with JSI values as arguments and return a JSI value again.
|
||||
- // Internally, this method converts the JSI values to C++ values using `JSIConverter<T>`.
|
||||
- return callMethod(hybridInstance.get(), method, runtime, args, count, std::index_sequence_for<Args...>{});
|
||||
- } catch (const std::exception& exception) {
|
||||
- // Some exception was thrown - add method name information and re-throw as `JSError`.
|
||||
- std::string funcName = getHybridFuncFullName<THybrid>(kind, name, hybridInstance.get());
|
||||
+ // 3. Actually call the method with JSI values as arguments and return a
|
||||
+ // JSI value again.
|
||||
+ // Internally, this method converts the JSI values to C++ values
|
||||
+ // using `JSIConverter<T>`.
|
||||
+ return callMethod(hybridInstance.get(), method, runtime, args, count,
|
||||
+ std::index_sequence_for<Args...>{});
|
||||
+ } catch (const std::exception &exception) {
|
||||
+ // Some exception was thrown - add method name information and re-throw
|
||||
+ // as `JSError`.
|
||||
+ std::string funcName =
|
||||
+ getHybridFuncFullName<THybrid>(kind, name, hybridInstance.get());
|
||||
+ std::string message = exception.what();
|
||||
+ throw jsi::JSError(runtime, funcName + ": " + message);
|
||||
+#ifdef ANDROID
|
||||
+#pragma clang diagnostic push
|
||||
+#pragma clang diagnostic ignored "-Wexceptions"
|
||||
+ } catch (const jni::JniException &exception) {
|
||||
+ std::string funcName =
|
||||
+ getHybridFuncFullName<THybrid>(kind, name, hybridInstance.get());
|
||||
std::string message = exception.what();
|
||||
throw jsi::JSError(runtime, funcName + ": " + message);
|
||||
+#pragma clang diagnostic pop
|
||||
+#endif
|
||||
} catch (...) {
|
||||
- // Some unknown exception was thrown - add method name information and re-throw as `JSError`.
|
||||
- std::string funcName = getHybridFuncFullName<THybrid>(kind, name, hybridInstance.get());
|
||||
+ // Some unknown exception was thrown - add method name information and
|
||||
+ // re-throw as `JSError`.
|
||||
+ std::string funcName =
|
||||
+ getHybridFuncFullName<THybrid>(kind, name, hybridInstance.get());
|
||||
std::string errorName = TypeInfo::getCurrentExceptionName();
|
||||
- throw jsi::JSError(runtime, "`" + funcName + "` threw an unknown " + errorName + " error.");
|
||||
+ throw jsi::JSError(runtime, "`" + funcName + "` threw an unknown " +
|
||||
+ errorName + " error.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -122,46 +157,64 @@ public:
|
||||
|
||||
/**
|
||||
* Create a new `HybridFunction` that can be called from JS.
|
||||
- * Unlike `createHybridFunction(...)`, this method does **not** perform any argument parsing or size checking.
|
||||
- * It is a raw-, untyped JSI method, and the user is expected to manually handle arguments and return values.
|
||||
+ * Unlike `createHybridFunction(...)`, this method does **not** perform any
|
||||
+ * argument parsing or size checking. It is a raw-, untyped JSI method, and
|
||||
+ * the user is expected to manually handle arguments and return values.
|
||||
*/
|
||||
template <typename Derived>
|
||||
- static inline HybridFunction createRawHybridFunction(const std::string& name, size_t expectedArgumentsCount,
|
||||
- jsi::Value (Derived::*method)(jsi::Runtime& runtime, const jsi::Value& thisArg,
|
||||
- const jsi::Value* args, size_t count)) {
|
||||
- jsi::HostFunctionType hostFunction = [name, method](/* JS Runtime */ jsi::Runtime& runtime,
|
||||
- /* HybridObject */ const jsi::Value& thisValue,
|
||||
- /* JS arguments */ const jsi::Value* args,
|
||||
- /* argument size */ size_t count) -> jsi::Value {
|
||||
- // 1. Get actual `HybridObject` instance from `thisValue` (it's stored as `NativeState`)
|
||||
- std::shared_ptr<Derived> hybridInstance = getHybridObjectNativeState<Derived>(runtime, thisValue, FunctionKind::METHOD, name);
|
||||
-
|
||||
- // 2. Call the raw JSI method using raw JSI Values. Exceptions are also expected to be handled by the user.
|
||||
- Derived* pointer = hybridInstance.get();
|
||||
+ static inline HybridFunction createRawHybridFunction(
|
||||
+ const std::string &name, size_t expectedArgumentsCount,
|
||||
+ jsi::Value (Derived::*method)(jsi::Runtime &runtime,
|
||||
+ const jsi::Value &thisArg,
|
||||
+ const jsi::Value *args, size_t count)) {
|
||||
+ jsi::HostFunctionType hostFunction =
|
||||
+ [name, method](/* JS Runtime */ jsi::Runtime &runtime,
|
||||
+ /* HybridObject */ const jsi::Value &thisValue,
|
||||
+ /* JS arguments */ const jsi::Value *args,
|
||||
+ /* argument size */ size_t count) -> jsi::Value {
|
||||
+ // 1. Get actual `HybridObject` instance from `thisValue` (it's stored as
|
||||
+ // `NativeState`)
|
||||
+ std::shared_ptr<Derived> hybridInstance =
|
||||
+ getHybridObjectNativeState<Derived>(runtime, thisValue,
|
||||
+ FunctionKind::METHOD, name);
|
||||
+
|
||||
+ // 2. Call the raw JSI method using raw JSI Values. Exceptions are also
|
||||
+ // expected to be handled by the user.
|
||||
+ Derived *pointer = hybridInstance.get();
|
||||
return (pointer->*method)(runtime, thisValue, args, count);
|
||||
};
|
||||
|
||||
- return HybridFunction(std::move(hostFunction), expectedArgumentsCount, name);
|
||||
+ return HybridFunction(std::move(hostFunction), expectedArgumentsCount,
|
||||
+ name);
|
||||
}
|
||||
|
||||
private:
|
||||
/**
|
||||
- * Calls the given method on the given instance with the given `jsi::Value` arguments by converting them to the desired target types.
|
||||
- * The given method's return value will be converted to a `jsi::Value` again.
|
||||
+ * Calls the given method on the given instance with the given `jsi::Value`
|
||||
+ * arguments by converting them to the desired target types. The given
|
||||
+ * method's return value will be converted to a `jsi::Value` again.
|
||||
*/
|
||||
- template <typename Derived, typename ReturnType, typename... Args, size_t... Is>
|
||||
- static inline jsi::Value callMethod(Derived* obj, ReturnType (Derived::*method)(Args...), jsi::Runtime& runtime, const jsi::Value* args,
|
||||
- size_t argsSize, std::index_sequence<Is...>) {
|
||||
+ template <typename Derived, typename ReturnType, typename... Args,
|
||||
+ size_t... Is>
|
||||
+ static inline jsi::Value
|
||||
+ callMethod(Derived *obj, ReturnType (Derived::*method)(Args...),
|
||||
+ jsi::Runtime &runtime, const jsi::Value *args, size_t argsSize,
|
||||
+ std::index_sequence<Is...>) {
|
||||
static const jsi::Value defaultValue;
|
||||
|
||||
if constexpr (std::is_void_v<ReturnType>) {
|
||||
// It's a void method.
|
||||
- (obj->*method)(JSIConverter<std::decay_t<Args>>::fromJSI(runtime, Is < argsSize ? args[Is] : defaultValue)...);
|
||||
+ (obj->*method)(JSIConverter<std::decay_t<Args>>::fromJSI(
|
||||
+ runtime, Is < argsSize ? args[Is] : defaultValue)...);
|
||||
return jsi::Value::undefined();
|
||||
} else {
|
||||
- // It's returning some C++ type, we need to convert that to a JSI value now.
|
||||
- ReturnType result = (obj->*method)(JSIConverter<std::decay_t<Args>>::fromJSI(runtime, Is < argsSize ? args[Is] : defaultValue)...);
|
||||
- return JSIConverter<ReturnType>::toJSI(runtime, std::forward<ReturnType>(result));
|
||||
+ // It's returning some C++ type, we need to convert that to a JSI value
|
||||
+ // now.
|
||||
+ ReturnType result =
|
||||
+ (obj->*method)(JSIConverter<std::decay_t<Args>>::fromJSI(
|
||||
+ runtime, Is < argsSize ? args[Is] : defaultValue)...);
|
||||
+ return JSIConverter<ReturnType>::toJSI(runtime,
|
||||
+ std::forward<ReturnType>(result));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,20 +223,24 @@ private:
|
||||
* Get the `NativeState` of the given `value`.
|
||||
*/
|
||||
template <typename THybrid>
|
||||
- static inline std::shared_ptr<THybrid> getHybridObjectNativeState(jsi::Runtime& runtime, const jsi::Value& value,
|
||||
- [[maybe_unused]] FunctionKind funcKind,
|
||||
- [[maybe_unused]] const std::string& funcName) {
|
||||
+ static inline std::shared_ptr<THybrid>
|
||||
+ getHybridObjectNativeState(jsi::Runtime &runtime, const jsi::Value &value,
|
||||
+ [[maybe_unused]] FunctionKind funcKind,
|
||||
+ [[maybe_unused]] const std::string &funcName) {
|
||||
// 1. Convert jsi::Value to jsi::Object
|
||||
#ifdef NITRO_DEBUG
|
||||
if (!value.isObject()) [[unlikely]] {
|
||||
- throw jsi::JSError(runtime, "Cannot " + getHybridFuncDebugInfo<THybrid>(funcKind, funcName) +
|
||||
- " - `this` is not bound! Suggestions:\n"
|
||||
- "- Did you accidentally destructure the `HybridObject`? (`const { " +
|
||||
- funcName +
|
||||
- " } = ...`)\n"
|
||||
- "- Did you call `dispose()` on the `HybridObject` before?"
|
||||
- "- Did you accidentally call `" +
|
||||
- funcName + "` on the prototype directly?");
|
||||
+ throw jsi::JSError(
|
||||
+ runtime,
|
||||
+ "Cannot " + getHybridFuncDebugInfo<THybrid>(funcKind, funcName) +
|
||||
+ " - `this` is not bound! Suggestions:\n"
|
||||
+ "- Did you accidentally destructure the `HybridObject`? (`const "
|
||||
+ "{ " +
|
||||
+ funcName +
|
||||
+ " } = ...`)\n"
|
||||
+ "- Did you call `dispose()` on the `HybridObject` before?"
|
||||
+ "- Did you accidentally call `" +
|
||||
+ funcName + "` on the prototype directly?");
|
||||
}
|
||||
#endif
|
||||
jsi::Object object = value.getObject(runtime);
|
||||
@@ -191,33 +248,42 @@ private:
|
||||
// 2. Check if it even has any kind of `NativeState`
|
||||
#ifdef NITRO_DEBUG
|
||||
if (!object.hasNativeState(runtime)) [[unlikely]] {
|
||||
- throw jsi::JSError(runtime, "Cannot " + getHybridFuncDebugInfo<THybrid>(funcKind, funcName) +
|
||||
- " - `this` does not have a NativeState! Suggestions:\n"
|
||||
- "- Did you accidentally destructure the `HybridObject`? (`const { " +
|
||||
- funcName +
|
||||
- " } = ...`)\n"
|
||||
- "- Did you call `dispose()` on the `HybridObject` before?"
|
||||
- "- Did you accidentally call `" +
|
||||
- funcName + "` on the prototype directly?");
|
||||
+ throw jsi::JSError(
|
||||
+ runtime,
|
||||
+ "Cannot " + getHybridFuncDebugInfo<THybrid>(funcKind, funcName) +
|
||||
+ " - `this` does not have a NativeState! Suggestions:\n"
|
||||
+ "- Did you accidentally destructure the `HybridObject`? (`const "
|
||||
+ "{ " +
|
||||
+ funcName +
|
||||
+ " } = ...`)\n"
|
||||
+ "- Did you call `dispose()` on the `HybridObject` before?"
|
||||
+ "- Did you accidentally call `" +
|
||||
+ funcName + "` on the prototype directly?");
|
||||
}
|
||||
#endif
|
||||
|
||||
// 3. Get `NativeState` from the jsi::Object and check if it is non-null
|
||||
- std::shared_ptr<jsi::NativeState> nativeState = object.getNativeState(runtime);
|
||||
+ std::shared_ptr<jsi::NativeState> nativeState =
|
||||
+ object.getNativeState(runtime);
|
||||
#ifdef NITRO_DEBUG
|
||||
if (nativeState == nullptr) [[unlikely]] {
|
||||
- throw jsi::JSError(runtime, "Cannot " + getHybridFuncDebugInfo<THybrid>(funcKind, funcName) +
|
||||
- " - `this`'s `NativeState` is `null`, "
|
||||
- "did you accidentally call `dispose()` on this object?");
|
||||
+ throw jsi::JSError(
|
||||
+ runtime, "Cannot " +
|
||||
+ getHybridFuncDebugInfo<THybrid>(funcKind, funcName) +
|
||||
+ " - `this`'s `NativeState` is `null`, "
|
||||
+ "did you accidentally call `dispose()` on this object?");
|
||||
}
|
||||
#endif
|
||||
|
||||
// 4. Try casting it to our desired target type.
|
||||
- std::shared_ptr<THybrid> hybridInstance = std::dynamic_pointer_cast<THybrid>(nativeState);
|
||||
+ std::shared_ptr<THybrid> hybridInstance =
|
||||
+ std::dynamic_pointer_cast<THybrid>(nativeState);
|
||||
#ifdef NITRO_DEBUG
|
||||
if (hybridInstance == nullptr) [[unlikely]] {
|
||||
- throw jsi::JSError(runtime, "Cannot " + getHybridFuncDebugInfo<THybrid>(funcKind, funcName) +
|
||||
- " - `this` has a NativeState, but it's the wrong type!");
|
||||
+ throw jsi::JSError(
|
||||
+ runtime, "Cannot " +
|
||||
+ getHybridFuncDebugInfo<THybrid>(funcKind, funcName) +
|
||||
+ " - `this` has a NativeState, but it's the wrong type!");
|
||||
}
|
||||
#endif
|
||||
return hybridInstance;
|
||||
@@ -225,28 +291,33 @@ private:
|
||||
|
||||
private:
|
||||
template <typename THybrid>
|
||||
- static inline std::string getHybridFuncFullName(FunctionKind kind, const std::string& registrationName,
|
||||
- THybrid* hybridInstance = nullptr) {
|
||||
- std::string typeName = hybridInstance != nullptr ? hybridInstance->getName() : TypeInfo::getFriendlyTypename<THybrid>(true);
|
||||
+ static inline std::string
|
||||
+ getHybridFuncFullName(FunctionKind kind, const std::string ®istrationName,
|
||||
+ THybrid *hybridInstance = nullptr) {
|
||||
+ std::string typeName = hybridInstance != nullptr
|
||||
+ ? hybridInstance->getName()
|
||||
+ : TypeInfo::getFriendlyTypename<THybrid>(true);
|
||||
switch (kind) {
|
||||
- case FunctionKind::METHOD:
|
||||
- return typeName + "." + registrationName + "(...)";
|
||||
- case FunctionKind::GETTER:
|
||||
- case FunctionKind::SETTER:
|
||||
- return typeName + "." + registrationName;
|
||||
+ case FunctionKind::METHOD:
|
||||
+ return typeName + "." + registrationName + "(...)";
|
||||
+ case FunctionKind::GETTER:
|
||||
+ case FunctionKind::SETTER:
|
||||
+ return typeName + "." + registrationName;
|
||||
}
|
||||
}
|
||||
template <typename THybrid>
|
||||
- static inline std::string getHybridFuncDebugInfo(FunctionKind kind, const std::string& registrationName,
|
||||
- THybrid* hybridInstance = nullptr) {
|
||||
- auto funcName = getHybridFuncFullName<THybrid>(kind, registrationName, hybridInstance);
|
||||
+ static inline std::string
|
||||
+ getHybridFuncDebugInfo(FunctionKind kind, const std::string ®istrationName,
|
||||
+ THybrid *hybridInstance = nullptr) {
|
||||
+ auto funcName =
|
||||
+ getHybridFuncFullName<THybrid>(kind, registrationName, hybridInstance);
|
||||
switch (kind) {
|
||||
- case FunctionKind::METHOD:
|
||||
- return "call hybrid function `" + funcName + "`";
|
||||
- case FunctionKind::GETTER:
|
||||
- return "get hybrid property `" + funcName + "`";
|
||||
- case FunctionKind::SETTER:
|
||||
- return "set hybrid property `" + funcName + "`";
|
||||
+ case FunctionKind::METHOD:
|
||||
+ return "call hybrid function `" + funcName + "`";
|
||||
+ case FunctionKind::GETTER:
|
||||
+ return "get hybrid property `" + funcName + "`";
|
||||
+ case FunctionKind::SETTER:
|
||||
+ return "set hybrid property `" + funcName + "`";
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,36 +1,67 @@
|
||||
ARG VERSION=unknown
|
||||
# Source : https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile
|
||||
FROM node:18-alpine3.18 AS base
|
||||
FROM base AS builder
|
||||
ARG VERSION
|
||||
|
||||
ENV NEXT_PUBLIC_VERSION=$VERSION
|
||||
ENV VERSION=$VERSION
|
||||
WORKDIR /app
|
||||
COPY ./*.json ./.yarnrc.yml ./*.lock ./
|
||||
|
||||
# Yarn stuff
|
||||
COPY ./.yarnrc.yml ./*.lock ./
|
||||
COPY ./.yarn ./.yarn
|
||||
RUN yarn workspaces focus
|
||||
|
||||
COPY ./next.config.js .
|
||||
## To Provide static assets at build time
|
||||
COPY ./public ./public
|
||||
COPY ./src ./src
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
RUN yarn build
|
||||
# Package.json + tsconfig
|
||||
## Root
|
||||
COPY package.json tsconfig.base.json ./
|
||||
## Apps
|
||||
COPY apps/web/*.json apps/web/
|
||||
## Libs
|
||||
COPY packages/*.json packages/
|
||||
COPY packages/api/*.json packages/api/
|
||||
COPY packages/models/*.json packages/models/
|
||||
COPY packages/state/*.json packages/state/
|
||||
COPY packages/ui/*.json packages/ui/
|
||||
COPY packages/utils/*.json packages/utils/
|
||||
|
||||
FROM base as runner
|
||||
# Install Libraries
|
||||
RUN yarn workspaces focus -A
|
||||
|
||||
# NextJS
|
||||
COPY ./apps/web/ apps/web/
|
||||
COPY ./packages/ packages/
|
||||
COPY ./translations/ translations/
|
||||
COPY ./assets/* apps/web/public/
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
RUN yarn build:web
|
||||
|
||||
######
|
||||
|
||||
FROM base AS runner
|
||||
ARG VERSION
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NEXT_PUBLIC_VERSION=$VERSION
|
||||
ENV VERSION=$VERSION
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone/apps/web ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./public
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD node server.js
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,12 +1,21 @@
|
||||
FROM node:18-alpine3.18
|
||||
WORKDIR /app
|
||||
COPY ./*.json ./
|
||||
COPY ./*.lock ./
|
||||
COPY ./.yarnrc.yml ./
|
||||
COPY ./.yarn /app/.yarn
|
||||
ENV VERSION=dev
|
||||
ENV NEXT_PUBLIC_VERSION=dev
|
||||
|
||||
COPY ./.yarnrc.yml ./*.lock ./
|
||||
COPY ./.yarn ./.yarn
|
||||
COPY package.json tsconfig.base.json ./
|
||||
COPY apps/web/*.json apps/web/
|
||||
COPY packages/*.json packages/
|
||||
COPY packages/api/*.json packages/api/
|
||||
COPY packages/models/*.json packages/models/
|
||||
COPY packages/state/*.json packages/state/
|
||||
COPY packages/utils/*.json packages/utils/
|
||||
|
||||
RUN yarn install
|
||||
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
CMD yarn && yarn run start:dev
|
||||
CMD yarn && cd apps/web && yarn run start:dev
|
||||
@@ -1,28 +0,0 @@
|
||||
import userReducer, {
|
||||
setAccessToken,
|
||||
setUserProfile,
|
||||
unsetAccessToken,
|
||||
} from "../../src/state/userSlice";
|
||||
|
||||
describe("User Slice", () => {
|
||||
let state: ReturnType<typeof userReducer> | undefined = undefined;
|
||||
|
||||
it("Should set the access token", () => {
|
||||
state = userReducer({}, setAccessToken("1234"));
|
||||
|
||||
expect(state).toStrictEqual({ accessToken: "1234" });
|
||||
});
|
||||
|
||||
it("Should set the user profile", () => {
|
||||
const user = { id: 1, name: "user", admin: true, enabled: true };
|
||||
state = userReducer(state, setUserProfile(user));
|
||||
|
||||
expect(state).toStrictEqual({ accessToken: "1234", user });
|
||||
});
|
||||
|
||||
it("Should unset the access token, and user profile", () => {
|
||||
state = userReducer(state, unsetAccessToken());
|
||||
|
||||
expect(state.accessToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
android
|
||||
*.apk
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { ExpoConfig } from "expo/config";
|
||||
|
||||
const IS_DEV = process.env.APP_VARIANT === "development";
|
||||
|
||||
export const expo: ExpoConfig = {
|
||||
name: IS_DEV ? "Meelo (Dev)" : "Meelo",
|
||||
slug: "meelo",
|
||||
version: "1.0.0",
|
||||
icon: "./assets/icon-black.png",
|
||||
scheme: "mobile",
|
||||
userInterfaceStyle: "automatic",
|
||||
newArchEnabled: true,
|
||||
android: {
|
||||
icon: "./assets/icon-black.png",
|
||||
edgeToEdgeEnabled: true,
|
||||
package: IS_DEV ? "dev.arthichaud.meelo.dev" : "dev.artichaud.meelo",
|
||||
},
|
||||
plugins: [
|
||||
"expo-router",
|
||||
"expo-screen-orientation",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
image: "./assets/icon-black.png",
|
||||
resizeMode: "contain",
|
||||
imageWidth: 200,
|
||||
backgroundColor: "#ffffff",
|
||||
dark: {
|
||||
image: "./assets/icon-white.png",
|
||||
backgroundColor: "#242120",
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
android: {
|
||||
usesCleartextTraffic: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"expo-asset",
|
||||
{
|
||||
assets: [
|
||||
"./assets/banner1_white.png",
|
||||
"./assets/banner1_black.png",
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
// https://docs.thewidlarzgroup.com/react-native-video/docs/v6/other/expo
|
||||
"react-native-video",
|
||||
{
|
||||
enableNotificationControls: true,
|
||||
enableBackgroundAudio: true,
|
||||
//TODO Picture in picture
|
||||
},
|
||||
],
|
||||
],
|
||||
experiments: {
|
||||
typedRoutes: true,
|
||||
},
|
||||
extra: {
|
||||
router: {},
|
||||
eas: {
|
||||
projectId: "b8d8dcdb-a905-4674-9c62-db4256e0d94f",
|
||||
},
|
||||
},
|
||||
ios: {
|
||||
bundleIdentifier: "com.arthichaud.meelo",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
../../../assets/banner1-black.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../assets/banner1-white.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../assets/icon-black.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../assets/icon-white.png
|
||||
@@ -0,0 +1,15 @@
|
||||
module.exports = (api) => {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
plugins: [
|
||||
[
|
||||
"react-native-unistyles/plugin",
|
||||
{
|
||||
root: "src",
|
||||
},
|
||||
],
|
||||
"react-native-reanimated/plugin", // Always keep last
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 10.2.2"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"env": {
|
||||
"APP_VARIANT": "development"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"env": {
|
||||
"APP_VARIANT": "preview"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
},
|
||||
"env": {
|
||||
"APP_VARIANT": "production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import "expo-router/entry";
|
||||
import "./src/theme";
|
||||
@@ -0,0 +1,52 @@
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const {
|
||||
wrapWithReanimatedMetroConfig,
|
||||
} = require("react-native-reanimated/metro-config");
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
const defaultConfig = getDefaultConfig(__dirname);
|
||||
const path = require("node:path");
|
||||
|
||||
const ALIASES = {
|
||||
"iconsax-react": "iconsax-react-nativejs",
|
||||
};
|
||||
|
||||
const projectRoot = __dirname;
|
||||
|
||||
function addMonorepoSupport(config) {
|
||||
const workspaceRoot = path.resolve(projectRoot, "../..");
|
||||
|
||||
return {
|
||||
...config,
|
||||
watchFolders: [...config.watchFolders, workspaceRoot],
|
||||
resolver: {
|
||||
...config.resolver,
|
||||
nodeModulesPaths: [
|
||||
...config.resolver.nodeModulesPaths,
|
||||
path.resolve(projectRoot, "node_modules"),
|
||||
path.resolve(workspaceRoot, "node_modules"),
|
||||
],
|
||||
disableHierarchicalLookup: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = wrapWithReanimatedMetroConfig(
|
||||
addMonorepoSupport({
|
||||
...defaultConfig,
|
||||
resolver: {
|
||||
...defaultConfig.resolver,
|
||||
resolveRequest: (context, moduleName, platform) => {
|
||||
// Ensure you call the default resolver.
|
||||
return context.resolveRequest(
|
||||
context,
|
||||
ALIASES[moduleName] ?? moduleName,
|
||||
platform,
|
||||
);
|
||||
},
|
||||
requireCycleIgnorePatterns: [
|
||||
...defaultConfig.resolver.requireCycleIgnorePatterns,
|
||||
/.*/,
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "mobile",
|
||||
"main": "index.ts",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"start": "APP_VARIANT=development expo start",
|
||||
"android": "APP_VARIANT=development expo run:android",
|
||||
"android:prebuild": "APP_VARIANT=development expo prebuild -p android",
|
||||
"ios": "expo run:ios"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/rubik": "^0.3.0",
|
||||
"@gorhom/bottom-sheet": "^5.1.8",
|
||||
"@react-native-community/checkbox": "^0.5.20",
|
||||
"@shopify/flash-list": "^1.8.3",
|
||||
"@shopify/react-native-skia": "^2.1.1",
|
||||
"@tanstack/react-query": "^5.77.0",
|
||||
"api": "workspace:^",
|
||||
"expo": "~53.0.9",
|
||||
"expo-asset": "~11.1.5",
|
||||
"expo-blur": "^14.1.5",
|
||||
"expo-build-properties": "^0.14.6",
|
||||
"expo-constants": "~17.1.6",
|
||||
"expo-dev-client": "~5.1.8",
|
||||
"expo-device": "^7.1.4",
|
||||
"expo-font": "~13.3.1",
|
||||
"expo-linking": "~7.1.7",
|
||||
"expo-router": "~5.1.4",
|
||||
"expo-screen-orientation": "^8.1.7",
|
||||
"expo-splash-screen": "~0.30.8",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-system-ui": "^5.0.7",
|
||||
"expo-web-browser": "^14.2.0",
|
||||
"i18next": "^23.7.16",
|
||||
"iconsax-react-nativejs": "^0.0.8",
|
||||
"infinite-controls": "workspace:^",
|
||||
"intl-pluralrules": "^2.0.1",
|
||||
"jotai": "^2.12.5",
|
||||
"lodash": "^4.17.21",
|
||||
"models": "workspace:^",
|
||||
"react": "19.0.0",
|
||||
"react-hook-form": "^7.58.0",
|
||||
"react-i18next": "^12.2.2",
|
||||
"react-native": "0.79.5",
|
||||
"react-native-awesome-slider": "^2.9.0",
|
||||
"react-native-blurhash": "^2.1.1",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.6.1",
|
||||
"react-native-fast-image": "^8.6.3",
|
||||
"react-native-gesture-handler": "^2.27.2",
|
||||
"react-native-keyboard-controller": "^1.17.5",
|
||||
"react-native-mmkv": "^3.3.0",
|
||||
"react-native-nitro-modules": "patch:react-native-nitro-modules@npm%3A0.27.5#~/.yarn/patches/react-native-nitro-modules-npm-0.27.5-40fe65af71.patch",
|
||||
"react-native-reanimated": "^3.17.3",
|
||||
"react-native-safe-area-context": "^5.4.1",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"react-native-svg": "^15.12.0",
|
||||
"react-native-unistyles": "3.0.9",
|
||||
"react-native-video": "^7.0.0-alpha.3",
|
||||
"state": "workspace:^",
|
||||
"toastify-react-native": "^7.2.3",
|
||||
"type-fest": "^4.33.0",
|
||||
"ui": "workspace:^",
|
||||
"utils": "workspace:^"
|
||||
},
|
||||
"installConfig": {
|
||||
"hoistingLimits": "workspaces"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/lodash": "^4",
|
||||
"@types/react": "~19.0.10",
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"packageManager": "yarn@4.4.0"
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { BottomSheetFlatList, useBottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Toast } from "toastify-react-native";
|
||||
import type { AddToPlaylistPayload } from "@/api";
|
||||
import { getPlaylists } from "@/api/queries";
|
||||
import { AddIcon, PlaylistIcon } from "@/ui/icons";
|
||||
import { generateArray } from "@/utils/gen-list";
|
||||
import { useQuery, useQueryClient } from "~/api";
|
||||
import { useModal } from "~/components/bottom-modal-sheet";
|
||||
import type { ContextMenuItem } from "~/components/context-menu";
|
||||
import { ListItem } from "~/components/item/list-item";
|
||||
import { PlaylistItem } from "~/components/item/resource/playlist";
|
||||
import { Divider } from "~/primitives/divider";
|
||||
import { Text } from "~/primitives/text";
|
||||
import { useCreatePlaylistFormModal } from "./playlist/create-update";
|
||||
|
||||
export const useAddToPlaylistAction = (
|
||||
payload?: AddToPlaylistPayload,
|
||||
): ContextMenuItem => {
|
||||
const content = useCallback(() => {
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
return <ChoosePlaylistModal payload={payload} />;
|
||||
}, [payload]);
|
||||
const { openModal } = useModal({
|
||||
content,
|
||||
onDismiss: () => {},
|
||||
});
|
||||
return {
|
||||
label: "actions.addToPlaylist.label",
|
||||
icon: PlaylistIcon,
|
||||
onPress: openModal,
|
||||
nestedModal: true,
|
||||
};
|
||||
};
|
||||
|
||||
const ChoosePlaylistModal = ({
|
||||
payload,
|
||||
}: {
|
||||
payload: AddToPlaylistPayload;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { dismiss } = useBottomSheetModal();
|
||||
const addToPlaylist = useMutation({
|
||||
mutationFn: (playlistId: number) =>
|
||||
queryClient.api
|
||||
.addToPlaylist(payload, playlistId)
|
||||
.then(() => {
|
||||
Toast.success(t("toasts.playlist.addedToPlaylist"));
|
||||
dismiss();
|
||||
queryClient.client.invalidateQueries({
|
||||
queryKey: ["playlists"],
|
||||
});
|
||||
})
|
||||
.catch((e) => Toast.error(t(e.message ?? ""))),
|
||||
});
|
||||
const { openFormModal } = useCreatePlaylistFormModal(undefined, (p) =>
|
||||
addToPlaylist.mutate(p.id),
|
||||
);
|
||||
const { data: playlists } = useQuery(() => {
|
||||
const q = getPlaylists({ changeable: true }, { sortBy: "name" }, [
|
||||
"illustration",
|
||||
]);
|
||||
return {
|
||||
key: [...q.key, "full"],
|
||||
exec: (api) => () => q.exec(api)({ pageSize: 100 }),
|
||||
};
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
return (
|
||||
<BottomSheetFlatList
|
||||
style={styles.root}
|
||||
contentContainerStyle={styles.list}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
<Text
|
||||
content={t("actions.addToPlaylist.modalTitle")}
|
||||
variant="h4"
|
||||
/>
|
||||
<ListItem
|
||||
title={t("actions.new")}
|
||||
illustration={null}
|
||||
illustrationProps={{ fallbackIcon: AddIcon }}
|
||||
subtitle={null}
|
||||
onPress={openFormModal}
|
||||
/>
|
||||
<Divider h />
|
||||
</>
|
||||
}
|
||||
data={playlists?.items ?? generateArray(3)}
|
||||
renderItem={({ item: playlist }) => (
|
||||
<PlaylistItem
|
||||
playlist={playlist}
|
||||
onPress={() =>
|
||||
playlist && addToPlaylist.mutate(playlist.id)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme, rt) => ({
|
||||
root: {
|
||||
height: (2 * rt.screen.height) / 3,
|
||||
width: "100%",
|
||||
},
|
||||
list: { paddingBottom: theme.gap(2) },
|
||||
}));
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { Href } from "expo-router";
|
||||
import type { QueryClient } from "@/api/hook";
|
||||
import { getReleaseTracklist } from "@/api/queries";
|
||||
import { transformPage } from "@/api/query";
|
||||
import {
|
||||
playAfterAtom,
|
||||
playFromInfiniteQuery,
|
||||
playNextAtom,
|
||||
playTrackAtom,
|
||||
type TrackState,
|
||||
} from "@/state/player";
|
||||
import { store } from "@/state/store";
|
||||
import {
|
||||
AlbumIcon,
|
||||
ArtistIcon,
|
||||
EditIcon,
|
||||
type Icon as IconType,
|
||||
InfoIcon,
|
||||
LyricsIcon,
|
||||
PlayAfterIcon,
|
||||
PlayIcon,
|
||||
PlayNextIcon,
|
||||
RelatedTracksIcon,
|
||||
SongIcon,
|
||||
VideoIcon,
|
||||
} from "@/ui/icons";
|
||||
export type Action = {
|
||||
icon: IconType;
|
||||
label: TranslationKey;
|
||||
onPress?: () => void;
|
||||
href?: Href;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const GoToArtist = (artistId: string | number): Action => ({
|
||||
label: "actions.goToArtist",
|
||||
icon: ArtistIcon,
|
||||
href: `/artists/${artistId}`,
|
||||
});
|
||||
|
||||
export const GoToArtistAlbums = (artistId: string | number): Action => ({
|
||||
label: "actions.artist.seeAlbums",
|
||||
icon: AlbumIcon,
|
||||
href: `/albums?artist=${artistId}`,
|
||||
});
|
||||
|
||||
export const GoToArtistSongs = (artistId: string | number): Action => ({
|
||||
label: "actions.artist.seeSongs",
|
||||
icon: SongIcon,
|
||||
href: `/songs?artist=${artistId}`,
|
||||
});
|
||||
|
||||
export const GoToRelease = (releaseId: string | number): Action => ({
|
||||
label: "actions.album.goToAlbum",
|
||||
icon: AlbumIcon,
|
||||
href: `/releases/${releaseId}`,
|
||||
});
|
||||
|
||||
export const ChangeType = (label: TranslationKey, onPress: () => void) => ({
|
||||
label,
|
||||
onPress,
|
||||
nestedModal: true,
|
||||
icon: EditIcon,
|
||||
});
|
||||
|
||||
export const Play = (track: TrackState): Action => ({
|
||||
label: "actions.playback.play",
|
||||
icon: PlayIcon,
|
||||
onPress: () => store.set(playTrackAtom, track),
|
||||
});
|
||||
|
||||
export const PlayNext = (track: TrackState): Action => ({
|
||||
label: "actions.playback.playNext",
|
||||
icon: PlayNextIcon,
|
||||
onPress: () => store.set(playNextAtom, track),
|
||||
});
|
||||
|
||||
export const PlayAfter = (track: TrackState): Action => ({
|
||||
label: "actions.playback.playAfter",
|
||||
icon: PlayAfterIcon,
|
||||
onPress: () => store.set(playAfterAtom, track),
|
||||
});
|
||||
|
||||
export const PlayReleaseAction = (
|
||||
releaseId: string | number,
|
||||
queryClient: QueryClient,
|
||||
) =>
|
||||
({
|
||||
icon: PlayIcon,
|
||||
label: "actions.playback.play",
|
||||
onPress: () => {
|
||||
const query = getReleaseTracklist(releaseId, false, [
|
||||
"artist",
|
||||
"illustration",
|
||||
]);
|
||||
store.set(
|
||||
playFromInfiniteQuery,
|
||||
transformPage(query, ({ song, video, ...track }) => ({
|
||||
artist: (song ?? video)!.artist,
|
||||
track,
|
||||
id: track.id,
|
||||
})),
|
||||
queryClient,
|
||||
);
|
||||
},
|
||||
}) satisfies Action;
|
||||
|
||||
export const GoToLyrics = (songId: string | number): Action => ({
|
||||
label: "actions.song.seeLyrics",
|
||||
href: `/songs/${songId}?tab=lyrics`,
|
||||
icon: LyricsIcon,
|
||||
});
|
||||
|
||||
export const GoToSongInfo = (songId: string | number): Action => ({
|
||||
label: "actions.song.seeSongInfo",
|
||||
href: `/songs/${songId}?tab=infos`,
|
||||
icon: InfoIcon,
|
||||
});
|
||||
|
||||
export const GoToSongVideos = (songId: string | number): Action => ({
|
||||
label: "actions.song.seeRelatedVideos",
|
||||
href: `/videos?song=${songId}`,
|
||||
icon: VideoIcon,
|
||||
});
|
||||
export const GoToSongVersions = (songId: string | number): Action => ({
|
||||
label: "actions.song.seeOtherVersions",
|
||||
href: `/songs?versionsOf=${songId}`,
|
||||
icon: SongIcon,
|
||||
});
|
||||
|
||||
export const GoToRelatedTracks = (songId: string | number): Action => ({
|
||||
label: "actions.song.seeRelatedTracks",
|
||||
href: `/tracks?song=${songId}`,
|
||||
icon: RelatedTracksIcon,
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Toast } from "toastify-react-native";
|
||||
import type Album from "@/models/album";
|
||||
import type Release from "@/models/release";
|
||||
import type Song from "@/models/song";
|
||||
import type Track from "@/models/track";
|
||||
import { MasterIcon } from "@/ui/icons";
|
||||
import { useQueryClient } from "~/api";
|
||||
|
||||
export const useSetReleaseAsMaster = (
|
||||
release: Release | undefined,
|
||||
album: Album | undefined,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const setAsMaster = useMutation({
|
||||
mutationFn: () =>
|
||||
queryClient.api.updateAlbum(release!.albumId, {
|
||||
masterReleaseId: release?.id,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
Toast.success(t("toasts.releaseSetAsMaster"));
|
||||
queryClient.client.invalidateQueries({ queryKey: ["releases"] });
|
||||
queryClient.client.invalidateQueries({ queryKey: ["albums"] });
|
||||
queryClient.client.invalidateQueries({ queryKey: [release?.id] });
|
||||
queryClient.client.invalidateQueries({ queryKey: [release?.slug] });
|
||||
queryClient.client.invalidateQueries({
|
||||
queryKey: [album?.id],
|
||||
});
|
||||
queryClient.client.invalidateQueries({
|
||||
queryKey: [album?.slug],
|
||||
});
|
||||
},
|
||||
});
|
||||
return useMemo(
|
||||
() =>
|
||||
SetAsMaster(
|
||||
() => setAsMaster.mutateAsync(),
|
||||
!release || !album || release.id === album.masterId,
|
||||
),
|
||||
[setAsMaster, release],
|
||||
);
|
||||
};
|
||||
|
||||
export const useSetSongTrackAsMaster = (
|
||||
track: Track | undefined,
|
||||
song: Song | undefined,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const setAsMaster = useMutation({
|
||||
mutationFn: () =>
|
||||
queryClient.api.updateSong(song!.id, {
|
||||
masterTrackId: track?.id,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
Toast.success(t("toasts.trackSetAsMaster"));
|
||||
queryClient.client.invalidateQueries({ queryKey: ["tracks"] });
|
||||
queryClient.client.invalidateQueries({ queryKey: ["songs"] });
|
||||
queryClient.client.invalidateQueries({ queryKey: ["releases"] });
|
||||
queryClient.client.invalidateQueries({ queryKey: [song?.id] });
|
||||
queryClient.client.invalidateQueries({ queryKey: [song?.slug] });
|
||||
queryClient.client.invalidateQueries({
|
||||
queryKey: [track?.id],
|
||||
});
|
||||
queryClient.client.invalidateQueries({
|
||||
queryKey: [track?.releaseId],
|
||||
});
|
||||
},
|
||||
});
|
||||
return useMemo(
|
||||
() =>
|
||||
SetAsMaster(
|
||||
() => setAsMaster.mutateAsync(),
|
||||
!track || !song || track.id === song.masterId,
|
||||
),
|
||||
[setAsMaster, track],
|
||||
);
|
||||
};
|
||||
|
||||
const SetAsMaster = (onPress: () => void, disabled: boolean) => ({
|
||||
onPress,
|
||||
disabled,
|
||||
label: "actions.setAsMaster" as const,
|
||||
icon: MasterIcon,
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback } from "react";
|
||||
import type Playlist from "@/models/playlist";
|
||||
import { AddIcon } from "@/ui/icons";
|
||||
import { useModal } from "~/components/bottom-modal-sheet";
|
||||
import { CreateUpdatePlaylistForm } from "~/components/playlist/create-update";
|
||||
import type { Action } from "..";
|
||||
|
||||
export const useUpdatePlaylistFormModal = (
|
||||
playlist?: Playlist,
|
||||
afterSave?: (playlist: Playlist) => void,
|
||||
) => {
|
||||
const content = useCallback(() => {
|
||||
return (
|
||||
<CreateUpdatePlaylistForm
|
||||
existingPlaylist={playlist}
|
||||
afterSave={afterSave}
|
||||
/>
|
||||
);
|
||||
}, []);
|
||||
const { openModal } = useModal({
|
||||
content,
|
||||
onDismiss: () => {},
|
||||
});
|
||||
return { openFormModal: openModal };
|
||||
};
|
||||
export const useCreatePlaylistFormModal = useUpdatePlaylistFormModal;
|
||||
|
||||
export const CreatePlaylist = (onPress: () => void): Action => ({
|
||||
label: "actions.new",
|
||||
icon: AddIcon,
|
||||
onPress,
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter } from "expo-router";
|
||||
import { DeleteIcon } from "@/ui/icons";
|
||||
import { useQueryClient } from "~/api";
|
||||
import type { Action } from "..";
|
||||
|
||||
export const useDeletePlaylistAction = (
|
||||
playlistId: number | undefined,
|
||||
): Action => {
|
||||
const queryClient = useQueryClient();
|
||||
const path = usePathname();
|
||||
const router = useRouter();
|
||||
const deletePlaylist = useMutation({
|
||||
mutationFn: async () =>
|
||||
playlistId &&
|
||||
queryClient.api.deletePlaylist(playlistId).then(() => {
|
||||
queryClient.client.invalidateQueries({
|
||||
queryKey: ["playlists"],
|
||||
});
|
||||
if (path.startsWith(`/playlists/`)) router.dismiss();
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
label: "actions.delete",
|
||||
icon: DeleteIcon,
|
||||
onPress: () => deletePlaylist.mutate(),
|
||||
};
|
||||
};
|
||||
|
||||
export const useDeletePlaylistEntryAction = (
|
||||
entryId: number | undefined,
|
||||
): Action => {
|
||||
const queryClient = useQueryClient();
|
||||
const deletePlaylistEntry = useMutation({
|
||||
mutationFn: async () =>
|
||||
entryId &&
|
||||
queryClient.api.deletePlaylistEntry(entryId).then(() => {
|
||||
queryClient.client.invalidateQueries({
|
||||
queryKey: ["playlists"],
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
label: "actions.deleteFromPlaylist",
|
||||
icon: DeleteIcon,
|
||||
onPress: () => deletePlaylistEntry.mutate(),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from "react";
|
||||
import { Share } from "react-native";
|
||||
import { ShareIcon } from "@/ui/icons";
|
||||
import type { Action } from "~/actions";
|
||||
import { useAPI } from "~/api";
|
||||
|
||||
export const useShareCallback = () => {
|
||||
const api = useAPI();
|
||||
|
||||
return useCallback(
|
||||
(route: string) =>
|
||||
Share.share({ url: `${api.urls.api.replace("/api", "")}${route}` }),
|
||||
[api],
|
||||
);
|
||||
};
|
||||
|
||||
export const ShareAction = (callback: () => void): Action => ({
|
||||
label: "actions.share",
|
||||
icon: ShareIcon,
|
||||
onPress: callback,
|
||||
});
|
||||
|
||||
export const useShareAlbumAction = (albumId: string | number | undefined) => {
|
||||
const c = useShareCallback();
|
||||
if (!albumId) {
|
||||
return undefined;
|
||||
}
|
||||
return ShareAction(() => c(`/albums/${albumId}`));
|
||||
};
|
||||
|
||||
export const useSharePlaylistAction = (
|
||||
playlistId: string | number | undefined,
|
||||
) => {
|
||||
const c = useShareCallback();
|
||||
if (!playlistId) {
|
||||
return undefined;
|
||||
}
|
||||
return ShareAction(() => c(`/playlists/${playlistId}`));
|
||||
};
|
||||
|
||||
export const useShareArtistAction = (artistId: string | number | undefined) => {
|
||||
const c = useShareCallback();
|
||||
if (!artistId) {
|
||||
return undefined;
|
||||
}
|
||||
return ShareAction(() => c(`/artists/${artistId}`));
|
||||
};
|
||||
|
||||
export const useShareSongAction = (songId: string | number | undefined) => {
|
||||
const c = useShareCallback();
|
||||
if (!songId) {
|
||||
return undefined;
|
||||
}
|
||||
return ShareAction(() => c(`/songs/${songId}`));
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useCallback } from "react";
|
||||
import { InfoIcon } from "@/ui/icons";
|
||||
import { useModal } from "~/components/bottom-modal-sheet";
|
||||
import type { ContextMenuItem } from "~/components/context-menu";
|
||||
import { TrackInfo } from "~/components/track-info";
|
||||
|
||||
export const useTrackInfoModal = (trackId: number | null | undefined) => {
|
||||
const content = useCallback(() => {
|
||||
if (!trackId) return null;
|
||||
return <TrackInfo trackId={trackId} />;
|
||||
}, [trackId]);
|
||||
const { openModal } = useModal({
|
||||
content,
|
||||
onDismiss: () => {},
|
||||
});
|
||||
return { openTrackInfoModal: openModal };
|
||||
};
|
||||
|
||||
export const SeeTrackInfo = (openModal: () => void): ContextMenuItem => ({
|
||||
label: "actions.track.seeTrackInfo",
|
||||
icon: InfoIcon,
|
||||
nestedModal: true,
|
||||
onPress: openModal,
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useBottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import { useCallback } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { CheckIcon, UpdateIllustrationIcon } from "@/ui/icons";
|
||||
import { useModal } from "~/components/bottom-modal-sheet";
|
||||
import type { ContextMenuItem } from "~/components/context-menu";
|
||||
import { Button } from "~/primitives/button";
|
||||
import { TextInput } from "~/primitives/text_input";
|
||||
|
||||
export const useUpdateIllustrationAction = (
|
||||
onConfirm: (illustrationUrl: string) => void,
|
||||
): ContextMenuItem => {
|
||||
const { dismiss } = useBottomSheetModal();
|
||||
const onFormCompletion = (illustrationUrl: string) => {
|
||||
onConfirm(illustrationUrl);
|
||||
dismiss();
|
||||
};
|
||||
const content = useCallback(() => {
|
||||
return <UpdateIllustrationForm onConfirm={onFormCompletion} />;
|
||||
}, [onConfirm]);
|
||||
const { openModal } = useModal({
|
||||
content,
|
||||
onDismiss: () => {},
|
||||
});
|
||||
|
||||
return {
|
||||
label: "actions.changeIllutration",
|
||||
onPress: openModal,
|
||||
icon: UpdateIllustrationIcon,
|
||||
};
|
||||
};
|
||||
|
||||
const UpdateIllustrationForm = ({
|
||||
onConfirm,
|
||||
}: {
|
||||
onConfirm: (illustrationUrl: string) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const defaultValues = { url: "" };
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm({ defaultValues });
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
rules={{
|
||||
required: true,
|
||||
}}
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<TextInput
|
||||
placeholder={t("form.illustration.url")}
|
||||
textContentType="URL"
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
error={errors.url?.message}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
icon={CheckIcon}
|
||||
title={t("form.confirm")}
|
||||
onPress={handleSubmit(({ url }) => onConfirm(url))}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: { gap: theme.gap(2), width: "100%", alignItems: "center" },
|
||||
}));
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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 { useAtom } from "jotai";
|
||||
import API from "@/api";
|
||||
import {
|
||||
mkUseInfiniteQuery,
|
||||
mkUseQueries,
|
||||
mkUseQuery,
|
||||
mkUseQueryClient,
|
||||
} from "@/api/hook";
|
||||
import type { InfiniteQueryFn, QueryFn } from "@/api/query";
|
||||
import type Resource from "@/models/resource";
|
||||
import { store } from "@/state/store";
|
||||
import { accessTokenAtom, instanceUrlAtom } from "~/state/user";
|
||||
|
||||
export const useAPI = () => {
|
||||
const [accessToken] = useAtom(accessTokenAtom);
|
||||
const [instanceUrl] = useAtom(instanceUrlAtom);
|
||||
return getAPI_(accessToken ?? null, instanceUrl ?? "");
|
||||
};
|
||||
|
||||
// Get API instance using atom to resolve access token
|
||||
export const getAPI = () => {
|
||||
const accessToken = store.get(accessTokenAtom);
|
||||
const instanceUrl = store.get(instanceUrlAtom);
|
||||
return getAPI_(accessToken ?? null, instanceUrl ?? "");
|
||||
};
|
||||
|
||||
export const getAPI_ = (accessToken: string | null, instanceUrl: string) => {
|
||||
instanceUrl = instanceUrl.replace(/\/$/, "");
|
||||
const api = new API(accessToken, {
|
||||
api: `${instanceUrl}/api`,
|
||||
illustration: `${instanceUrl}/api`,
|
||||
scanner: `${instanceUrl}/scanner`,
|
||||
});
|
||||
return api;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for the react-query's *useQuery*
|
||||
*/
|
||||
export const useQuery = <
|
||||
ReturnType,
|
||||
Params extends any[],
|
||||
Transformed = ReturnType,
|
||||
>(
|
||||
query: QueryFn<ReturnType, Transformed, Params>,
|
||||
...queryParams: Partial<Params>
|
||||
) => {
|
||||
return mkUseQuery(getAPI, query, ...queryParams);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for the react-query's *useQueries*
|
||||
*/
|
||||
export const useQueries = <ReturnType, Params extends any[][]>(
|
||||
...queries: readonly Parameters<
|
||||
typeof useQuery<ReturnType, Params[number], ReturnType>
|
||||
>[]
|
||||
) => {
|
||||
return mkUseQueries<ReturnType, Params>(useAPI, ...(queries as any));
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for the react-query's *useInfiniteQuery*
|
||||
*/
|
||||
export const useInfiniteQuery = <
|
||||
ReturnType extends Resource,
|
||||
Params extends any[],
|
||||
TransformedType = ReturnType,
|
||||
>(
|
||||
query: InfiniteQueryFn<ReturnType, TransformedType, Params>,
|
||||
...queryParams: Partial<Params>
|
||||
) => {
|
||||
return mkUseInfiniteQuery(useAPI, query, ...queryParams);
|
||||
};
|
||||
|
||||
export const useQueryClient = () => mkUseQueryClient(getAPI);
|
||||
@@ -0,0 +1,83 @@
|
||||
import { type Href, useRouter } from "expo-router";
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
AlbumIcon,
|
||||
ArtistIcon,
|
||||
CompilationIcon,
|
||||
type Icon,
|
||||
PlaylistIcon,
|
||||
SongIcon,
|
||||
VideoIcon,
|
||||
} from "@/ui/icons";
|
||||
import { ListItem } from "~/components/item/list-item";
|
||||
import { SafeView } from "~/components/safe-view";
|
||||
import { Divider } from "~/primitives/divider";
|
||||
|
||||
const tabs: { title: TranslationKey; href: Href; icon: Icon }[] = [
|
||||
{
|
||||
title: "models.artist_plural",
|
||||
href: "/(protected)/(browse)/artists",
|
||||
icon: ArtistIcon,
|
||||
},
|
||||
{
|
||||
title: "models.album_plural",
|
||||
href: "/(protected)/(browse)/albums",
|
||||
icon: AlbumIcon,
|
||||
},
|
||||
|
||||
{
|
||||
title: "models.song_plural",
|
||||
href: "/(protected)/(browse)/songs",
|
||||
icon: SongIcon,
|
||||
},
|
||||
|
||||
{
|
||||
title: "models.video_plural",
|
||||
href: "/(protected)/(browse)/videos",
|
||||
icon: VideoIcon,
|
||||
},
|
||||
{
|
||||
title: "nav.compilations",
|
||||
href: "/(protected)/(browse)/albums?compilations=true",
|
||||
icon: CompilationIcon,
|
||||
},
|
||||
|
||||
{
|
||||
title: "models.playlist_plural",
|
||||
href: "/(protected)/(browse)/playlists",
|
||||
icon: PlaylistIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export default function BrowseList() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SafeView style={[styles.root]}>
|
||||
{tabs.map(({ title, href, icon }) => (
|
||||
<Fragment key={title}>
|
||||
<ListItem
|
||||
illustration={null}
|
||||
illustrationProps={{ fallbackIcon: icon }}
|
||||
title={t(title)}
|
||||
subtitle={null}
|
||||
onPress={() => router.navigate(href)}
|
||||
/>
|
||||
<Divider h withInsets />
|
||||
</Fragment>
|
||||
))}
|
||||
</SafeView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
padding: theme.gap(0.5),
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* 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 { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { getAlbums, getArtists, getReleases, getSongs } from "@/api/queries";
|
||||
import type { AlbumSortingKey } from "@/models/album";
|
||||
import type { ArtistSortingKey } from "@/models/artist";
|
||||
import type { SongSortingKey } from "@/models/song";
|
||||
import { useInfiniteQuery } from "~/api";
|
||||
import { useSetKeyIllustration } from "~/components/background-gradient";
|
||||
import { AlbumTile } from "~/components/item/resource/album";
|
||||
import { ArtistTile } from "~/components/item/resource/artist";
|
||||
import ReleaseTile from "~/components/item/resource/release";
|
||||
import { Row } from "~/components/row";
|
||||
import { SafeScrollView } from "~/components/safe-view";
|
||||
import { SongGrid } from "~/components/song-grid";
|
||||
import type { Sorting } from "~/utils/sorting";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
main: { paddingTop: theme.gap(2), gap: theme.gap(2) },
|
||||
lastSection: {
|
||||
paddingBottom: theme.gap(1),
|
||||
},
|
||||
title: { paddingLeft: theme.gap(2) },
|
||||
}));
|
||||
|
||||
export default function Root() {
|
||||
const { t } = useTranslation();
|
||||
const newlyAddedAlbums = useInfiniteQuery(() =>
|
||||
getAlbums({}, { sortBy: "addDate", order: "desc" }, [
|
||||
"illustration",
|
||||
"artist",
|
||||
]),
|
||||
);
|
||||
const newlyAddedArtists = useInfiniteQuery(() =>
|
||||
getArtists({}, { sortBy: "addDate", order: "desc" }, ["illustration"]),
|
||||
);
|
||||
|
||||
const latestAlbums = useInfiniteQuery(() =>
|
||||
getAlbums({}, { sortBy: "releaseDate", order: "desc" }, [
|
||||
"illustration",
|
||||
"artist",
|
||||
]),
|
||||
);
|
||||
|
||||
const newlyAddedReleases = useInfiniteQuery(() =>
|
||||
getReleases({}, { sortBy: "addDate", order: "desc" }, [
|
||||
"illustration",
|
||||
"album",
|
||||
]),
|
||||
);
|
||||
useSetKeyIllustration(newlyAddedAlbums.items?.at(0));
|
||||
const topSongs = useInfiniteQuery(() =>
|
||||
getSongs({}, { sortBy: "userPlayCount", order: "desc" }, [
|
||||
"artist",
|
||||
"featuring",
|
||||
"master",
|
||||
"illustration",
|
||||
]),
|
||||
);
|
||||
const sync = useCallback(
|
||||
<T,>(q: T) => {
|
||||
if (
|
||||
!!newlyAddedAlbums.data &&
|
||||
!!newlyAddedArtists.data &&
|
||||
!!latestAlbums.data &&
|
||||
!!topSongs.data &&
|
||||
!!newlyAddedReleases.data
|
||||
) {
|
||||
return q;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[
|
||||
newlyAddedAlbums.data,
|
||||
newlyAddedArtists.data,
|
||||
latestAlbums.data,
|
||||
topSongs.data,
|
||||
newlyAddedReleases.data,
|
||||
],
|
||||
);
|
||||
return (
|
||||
<SafeScrollView contentContainerStyle={[styles.main]}>
|
||||
<Row
|
||||
header={t("home.newlyAddedAlbums")}
|
||||
items={sync(newlyAddedAlbums.items)}
|
||||
render={(album) => {
|
||||
return <AlbumTile album={album} subtitle="artistName" />;
|
||||
}}
|
||||
seeMore={
|
||||
newlyAddedAlbums.hasNextPage
|
||||
? {
|
||||
pathname: "/albums",
|
||||
params: {
|
||||
sort: "addDate",
|
||||
order: "desc",
|
||||
} satisfies Sorting<AlbumSortingKey>,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Row
|
||||
header={t("home.newlyAddedArtists")}
|
||||
items={sync(newlyAddedArtists.items)}
|
||||
render={(artist) => {
|
||||
return <ArtistTile artist={artist} />;
|
||||
}}
|
||||
seeMore={
|
||||
newlyAddedArtists.hasNextPage
|
||||
? {
|
||||
pathname: "/artists",
|
||||
params: {
|
||||
sort: "addDate",
|
||||
order: "desc",
|
||||
} satisfies Sorting<ArtistSortingKey>,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{/* TODO Featured albums */}
|
||||
|
||||
<Row
|
||||
header={t("home.latestAlbums")}
|
||||
items={sync(latestAlbums.items)}
|
||||
render={(album) => {
|
||||
return <AlbumTile album={album} subtitle="artistName" />;
|
||||
}}
|
||||
seeMore={
|
||||
latestAlbums.hasNextPage
|
||||
? {
|
||||
pathname: "/albums",
|
||||
params: {
|
||||
sort: "releaseDate",
|
||||
order: "desc",
|
||||
} satisfies Sorting<AlbumSortingKey>,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Row
|
||||
header={t("home.newlyAddedReleases")}
|
||||
items={sync(newlyAddedReleases.items)}
|
||||
render={(release) => {
|
||||
return <ReleaseTile release={release} />;
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* TODO Genres*/}
|
||||
|
||||
<SongGrid
|
||||
seeMore={
|
||||
topSongs.hasNextPage
|
||||
? {
|
||||
pathname: "/songs",
|
||||
params: {
|
||||
sort: "userPlayCount",
|
||||
order: "desc",
|
||||
} satisfies Sorting<SongSortingKey>,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
hideIfEmpty
|
||||
header={t("home.mostPlayedSongs")}
|
||||
songs={sync(topSongs.data?.pages.at(0)?.items)}
|
||||
subtitle={() => "artists"}
|
||||
style={styles.lastSection}
|
||||
/>
|
||||
</SafeScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { type ComponentProps, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Icon as MeeloIcon } from "~/components/meelo";
|
||||
import { BackButton, HeaderBackground } from "~/components/navigation";
|
||||
|
||||
export const unstable_settings = {
|
||||
home: {
|
||||
initialRouteName: "index",
|
||||
},
|
||||
};
|
||||
|
||||
const SharedRoutes: {
|
||||
name: string;
|
||||
options: { headerTitle: TranslationKey | null };
|
||||
}[] = [
|
||||
{ name: "artists/index", options: { headerTitle: "models.artist_plural" } },
|
||||
{ name: "artists/[id]", options: { headerTitle: null } },
|
||||
{ name: "albums/index", options: { headerTitle: "models.album_plural" } },
|
||||
{ name: "songs/index", options: { headerTitle: "models.song_plural" } },
|
||||
{ name: "videos/index", options: { headerTitle: "models.video_plural" } },
|
||||
{ name: "tracks/index", options: { headerTitle: "models.track_plural" } },
|
||||
{
|
||||
name: "playlists/index",
|
||||
options: { headerTitle: "models.playlist_plural" },
|
||||
},
|
||||
|
||||
{ name: "playlists/[id]", options: { headerTitle: null } },
|
||||
{ name: "songs/[id]", options: { headerTitle: null } },
|
||||
{ name: "releases/[id]", options: { headerTitle: null } },
|
||||
];
|
||||
|
||||
export default function Layout({ segment }: { segment: string }) {
|
||||
const { t } = useTranslation();
|
||||
// Cant memo-ise this, we need this to be updated when theme changes
|
||||
const screenOptions = {
|
||||
animation: "none", //TODO slide_from_right without flicker
|
||||
animationTypeForReplace: "pop",
|
||||
contentStyle: [styles.screen],
|
||||
// headerStyle: styles.header,
|
||||
headerTitleStyle: styles.headerTitle,
|
||||
headerLeft: () => <BackButton />,
|
||||
headerBackground: () => <HeaderBackground />,
|
||||
headerTransparent: true,
|
||||
headerTintColor: styles.headerTitle.color,
|
||||
} satisfies ComponentProps<typeof Stack>["screenOptions"];
|
||||
const screenProps = useMemo(() => {
|
||||
switch (segment) {
|
||||
case "(home)": {
|
||||
return {
|
||||
options: {
|
||||
headerTitle: t("nav.home"),
|
||||
headerLeft: () => <MeeloIcon style={styles.icon} />,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "(browse)": {
|
||||
return {
|
||||
options: {
|
||||
headerTitle: t("nav.browse"),
|
||||
// TODO IDK why we canGoBack when we are at the browse's index
|
||||
headerLeft: () => <View />,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "(search)": {
|
||||
return {
|
||||
options: {
|
||||
headerTitle: t("nav.search"),
|
||||
// TODO IDK why we canGoBack when we are at the browse's index
|
||||
headerLeft: () => <View />,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [segment]);
|
||||
return (
|
||||
<Stack screenOptions={screenOptions}>
|
||||
<Stack.Screen name="index" {...screenProps} />
|
||||
{SharedRoutes.map(
|
||||
({ name, options: { headerTitle, ...options } }, idx) => (
|
||||
<Stack.Screen
|
||||
key={idx}
|
||||
name={name}
|
||||
options={{
|
||||
headerTitle: headerTitle ? t(headerTitle) : "",
|
||||
...options,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
screen: {
|
||||
backgroundColor: "transparent",
|
||||
flex: 1,
|
||||
},
|
||||
icon: { marginRight: theme.gap(2) },
|
||||
headerTitle: {
|
||||
color: theme.colors.text.primary,
|
||||
...theme.fontStyles.regular,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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 { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getAlbums, getArtist } from "@/api/queries";
|
||||
import { AlbumSortingKeys, AlbumType } from "@/models/album";
|
||||
import { albumTypeToTranslationKey } from "@/models/utils";
|
||||
import { useQuery } from "~/api";
|
||||
import {
|
||||
useLibraryFiltersControl,
|
||||
useTypeFiltersControl,
|
||||
} from "~/components/infinite/controls/filters";
|
||||
import { useLayoutControl } from "~/components/infinite/controls/layout";
|
||||
import { useSortControl } from "~/components/infinite/controls/sort";
|
||||
import { InfiniteView } from "~/components/infinite/view";
|
||||
import { AlbumItem, AlbumTile } from "~/components/item/resource/album";
|
||||
import { ArtistHeader } from "~/components/resource-header";
|
||||
|
||||
export default function AlbumBrowseView() {
|
||||
const { t } = useTranslation();
|
||||
const { compilations, artist: artistId } = useLocalSearchParams<{
|
||||
compilations?: "true";
|
||||
artist?: string;
|
||||
}>();
|
||||
const [{ layout }, layoutControl] = useLayoutControl({
|
||||
defaultLayout: "grid",
|
||||
enableToggle: true,
|
||||
});
|
||||
const [{ sort, order }, sortControl] = useSortControl({
|
||||
sortingKeys: AlbumSortingKeys,
|
||||
translate: (s) => `browsing.controls.sort.${s}`,
|
||||
});
|
||||
const [libraries, libraryFilterControl] = useLibraryFiltersControl();
|
||||
const [types, albumTypeFilterControl] = useTypeFiltersControl({
|
||||
types: AlbumType,
|
||||
translate: (t) => albumTypeToTranslationKey(t, false),
|
||||
});
|
||||
const { data: artist } = useQuery(
|
||||
(artistId) => getArtist(artistId, ["illustration"]),
|
||||
artistId,
|
||||
);
|
||||
const Item = layout === "list" ? AlbumItem : AlbumTile;
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerTitle: t(
|
||||
compilations
|
||||
? "nav.compilations"
|
||||
: "models.album_plural",
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<InfiniteView
|
||||
layout={layout}
|
||||
header={artistId ? <ArtistHeader artist={artist} /> : undefined}
|
||||
controls={{
|
||||
layout: layoutControl,
|
||||
sort: sortControl,
|
||||
filters: [libraryFilterControl, albumTypeFilterControl],
|
||||
}}
|
||||
query={getAlbums(
|
||||
{
|
||||
library: libraries,
|
||||
type: types,
|
||||
artist: compilations ? "compilations" : artistId,
|
||||
},
|
||||
{ sortBy: sort ?? "name", order: order ?? "asc" },
|
||||
["artist", "illustration"],
|
||||
)}
|
||||
render={(album) => (
|
||||
<Item
|
||||
album={album}
|
||||
subtitle={artistId ? "year" : "artistName"}
|
||||
illustrationProps={{}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
getAlbums,
|
||||
getArtist,
|
||||
getArtistExternalMetadata,
|
||||
getSongs,
|
||||
getVideos,
|
||||
} from "@/api/queries";
|
||||
import { type AlbumSortingKey, AlbumType } from "@/models/album";
|
||||
import { albumTypeToTranslationKey } from "@/models/utils";
|
||||
import type { VideoSortingKey, VideoWithRelations } from "@/models/video";
|
||||
import { VideoTypeIsExtra } from "@/models/video";
|
||||
import { playTracksAtom } from "@/state/player";
|
||||
import { useInfiniteQuery, useQuery } from "~/api";
|
||||
import { useSetKeyIllustration } from "~/components/background-gradient";
|
||||
import {
|
||||
ExternalMetadataDescriptionSection,
|
||||
ExternalMetadataSourcesSection,
|
||||
} from "~/components/external-metadata";
|
||||
import { AlbumTile } from "~/components/item/resource/album";
|
||||
import { VideoTile } from "~/components/item/resource/video";
|
||||
import { ArtistHeader } from "~/components/resource-header";
|
||||
import { Row } from "~/components/row";
|
||||
import { SafeScrollView } from "~/components/safe-view";
|
||||
import { SongGrid } from "~/components/song-grid";
|
||||
import type { Sorting } from "~/utils/sorting";
|
||||
|
||||
const albumTypeQuery = (albumType: AlbumType, artistId: string) =>
|
||||
getAlbums(
|
||||
{ artist: artistId, type: [albumType] },
|
||||
{ sortBy: "releaseDate", order: "desc" },
|
||||
["illustration", "artist"],
|
||||
);
|
||||
|
||||
// Note: we show the 'seeMore' button for song grids and video rows iff there's more items than ShowSeeMoreThreshold
|
||||
// since the user may want to shuffle them through the dedicated page
|
||||
|
||||
const ShowSeeMoreThreshold = 3;
|
||||
|
||||
export default function ArtistView() {
|
||||
const playTracks = useSetAtom(playTracksAtom);
|
||||
const { id: artistId } = useLocalSearchParams<{ id: string }>();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: artist } = useQuery(() =>
|
||||
getArtist(artistId, ["illustration"]),
|
||||
);
|
||||
const topSongs = useInfiniteQuery(() =>
|
||||
getSongs(
|
||||
{ artist: artistId },
|
||||
{ sortBy: "totalPlayCount", order: "desc" },
|
||||
["artist", "featuring", "master", "illustration"],
|
||||
),
|
||||
);
|
||||
|
||||
const rareSongs = useInfiniteQuery(() =>
|
||||
getSongs({ rare: artistId }, { sortBy: "releaseDate", order: "desc" }, [
|
||||
"artist",
|
||||
"featuring",
|
||||
"master",
|
||||
"illustration",
|
||||
]),
|
||||
);
|
||||
const videos = useInfiniteQuery(() =>
|
||||
getVideos({ artist: artistId }, { sortBy: "addDate", order: "desc" }, [
|
||||
"artist",
|
||||
"master",
|
||||
"illustration",
|
||||
]),
|
||||
);
|
||||
const relatedAlbums = useInfiniteQuery(() =>
|
||||
getAlbums(
|
||||
{ appearance: artistId },
|
||||
{ sortBy: "releaseDate", order: "desc" },
|
||||
["artist", "illustration"],
|
||||
),
|
||||
);
|
||||
const { data: externalMetadata } = useQuery(
|
||||
(artistId) => getArtistExternalMetadata(artistId),
|
||||
artistId,
|
||||
);
|
||||
const { musicVideos, liveVideos, extras } = useMemo(() => {
|
||||
return {
|
||||
musicVideos: videos.items?.filter(
|
||||
(video) =>
|
||||
!VideoTypeIsExtra(video.type) && video.type !== "Live",
|
||||
),
|
||||
liveVideos: videos.items?.filter((video) => video.type === "Live"),
|
||||
extras: videos.items?.filter((video) =>
|
||||
VideoTypeIsExtra(video.type),
|
||||
),
|
||||
};
|
||||
}, [videos]);
|
||||
const onVideoPress = useCallback(
|
||||
(
|
||||
videoId: number,
|
||||
items: VideoWithRelations<"artist" | "master" | "illustration">[],
|
||||
) => {
|
||||
const tracks = items.map(({ master, illustration, artist }) => ({
|
||||
track: { ...master, illustration },
|
||||
artist,
|
||||
}));
|
||||
const cursor = items.findIndex(({ id }) => id === videoId);
|
||||
playTracks({ tracks, cursor });
|
||||
},
|
||||
[],
|
||||
);
|
||||
useSetKeyIllustration(artist ?? undefined);
|
||||
return (
|
||||
<SafeScrollView>
|
||||
<ArtistHeader artist={artist} />
|
||||
<SongGrid
|
||||
header={t("artist.topSongs")}
|
||||
parentArtistId={artist?.id}
|
||||
style={styles.section}
|
||||
seeMore={
|
||||
(topSongs.items?.length ?? 0) >= ShowSeeMoreThreshold
|
||||
? {
|
||||
pathname: "/songs",
|
||||
params: { artist: artistId },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
songs={topSongs.data?.pages.at(0)?.items}
|
||||
subtitle={
|
||||
!topSongs.data
|
||||
? null
|
||||
: (song) =>
|
||||
song.artistId.toString() === artistId &&
|
||||
song.featuring.length === 0
|
||||
? null
|
||||
: "artists"
|
||||
}
|
||||
/>
|
||||
{AlbumType.map((albumType) => (
|
||||
<AlbumTypeRow
|
||||
key={albumType}
|
||||
artistId={artistId}
|
||||
type={albumType}
|
||||
/>
|
||||
))}
|
||||
|
||||
<SongGrid
|
||||
hideIfEmpty
|
||||
header={t("artist.rareSongs")}
|
||||
seeMore={
|
||||
(rareSongs.items?.length ?? 0) > ShowSeeMoreThreshold
|
||||
? { pathname: "/songs", params: { rare: artistId } }
|
||||
: undefined
|
||||
}
|
||||
style={styles.section}
|
||||
songs={rareSongs.data?.pages.at(0)?.items}
|
||||
parentArtistId={artist?.id}
|
||||
subtitle={
|
||||
!rareSongs.data
|
||||
? null
|
||||
: (song) =>
|
||||
song.artistId.toString() === artistId &&
|
||||
song.featuring.length === 0
|
||||
? null
|
||||
: "artists"
|
||||
}
|
||||
/>
|
||||
|
||||
{(
|
||||
[
|
||||
{ label: "musicVideos", items: musicVideos },
|
||||
{ label: "livePerformances", items: liveVideos },
|
||||
{ label: "extras", items: extras },
|
||||
] as const
|
||||
).map(({ label, items }) => (
|
||||
<Row
|
||||
hideIfEmpty
|
||||
key={label}
|
||||
items={items}
|
||||
style={styles.section}
|
||||
header={t(`browsing.sections.${label}`)}
|
||||
render={(item) => (
|
||||
<VideoTile
|
||||
video={item}
|
||||
onPress={() =>
|
||||
item && items && onVideoPress(item.id, items)
|
||||
}
|
||||
subtitle="duration"
|
||||
illustrationProps={{
|
||||
normalizedThumbnail: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
seeMore={
|
||||
(items?.length ?? 0) > ShowSeeMoreThreshold
|
||||
? {
|
||||
pathname: "/videos",
|
||||
params: {
|
||||
artist: artistId,
|
||||
sort: "name",
|
||||
order: "asc",
|
||||
} satisfies Sorting<VideoSortingKey> & {
|
||||
artist: string;
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Row
|
||||
hideIfEmpty
|
||||
style={styles.section}
|
||||
header={t("artist.appearsOn")}
|
||||
items={relatedAlbums.items}
|
||||
render={(album) => (
|
||||
<AlbumTile album={album} subtitle="artistName" />
|
||||
)}
|
||||
/>
|
||||
|
||||
{externalMetadata !== null && (
|
||||
<>
|
||||
<ExternalMetadataDescriptionSection
|
||||
externalMetadata={externalMetadata}
|
||||
style={styles.section}
|
||||
/>
|
||||
<ExternalMetadataSourcesSection
|
||||
externalMetadata={externalMetadata}
|
||||
style={styles.section}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SafeScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const AlbumTypeRow = ({
|
||||
type,
|
||||
artistId,
|
||||
}: {
|
||||
type: AlbumType;
|
||||
artistId: string;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const query = useInfiniteQuery(() => albumTypeQuery(type, artistId));
|
||||
return (
|
||||
<Row
|
||||
hideIfEmpty
|
||||
style={styles.section}
|
||||
header={t(albumTypeToTranslationKey(type, true))}
|
||||
items={query.items}
|
||||
render={(item) => <AlbumTile album={item} subtitle="year" />}
|
||||
seeMore={
|
||||
query.hasNextPage
|
||||
? {
|
||||
pathname: "/albums",
|
||||
params: {
|
||||
artist: artistId,
|
||||
sort: "releaseDate",
|
||||
order: "desc",
|
||||
type: type,
|
||||
} satisfies Sorting<AlbumSortingKey> & {
|
||||
type: AlbumType;
|
||||
artist: string;
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
},
|
||||
section: {
|
||||
paddingBottom: theme.gap(1),
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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 { getArtists } from "@/api/queries";
|
||||
import { ArtistSortingKeys } from "@/models/artist";
|
||||
import { useLibraryFiltersControl } from "~/components/infinite/controls/filters";
|
||||
import { useLayoutControl } from "~/components/infinite/controls/layout";
|
||||
import { useSortControl } from "~/components/infinite/controls/sort";
|
||||
import { InfiniteView } from "~/components/infinite/view";
|
||||
import { ArtistItem, ArtistTile } from "~/components/item/resource/artist";
|
||||
|
||||
export default function ArtistBrowseView() {
|
||||
const [{ layout }, layoutControl] = useLayoutControl({
|
||||
defaultLayout: "list",
|
||||
enableToggle: true,
|
||||
});
|
||||
const [{ sort, order }, sortControl] = useSortControl({
|
||||
sortingKeys: ArtistSortingKeys,
|
||||
translate: (s) => `browsing.controls.sort.${s}`,
|
||||
});
|
||||
const [libraries, libraryFilterControl] = useLibraryFiltersControl();
|
||||
const Item = layout === "list" ? ArtistItem : ArtistTile;
|
||||
return (
|
||||
<InfiniteView
|
||||
layout={layout}
|
||||
controls={{
|
||||
layout: layoutControl,
|
||||
sort: sortControl,
|
||||
filters: [libraryFilterControl],
|
||||
}}
|
||||
query={getArtists(
|
||||
{ library: libraries },
|
||||
{ sortBy: sort ?? "name", order: order ?? "asc" },
|
||||
["illustration"],
|
||||
)}
|
||||
render={(artist) => <Item artist={artist} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||