Compare commits
576
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8524aa5a8 | ||
|
|
73e37dbecd | ||
|
|
16478678b5 | ||
|
|
7f51145915 | ||
|
|
28a7c4a98c | ||
|
|
2d0867e68a | ||
|
|
bc6ca3651f | ||
|
|
c4cf5af7f1 | ||
|
|
fcf3093ad8 | ||
|
|
405df082cc | ||
|
|
0cba32f776 | ||
|
|
0fa033faff | ||
|
|
75d0c0817e | ||
|
|
4b147d9ee3 | ||
|
|
5a0f82b77b | ||
|
|
4d943e068e | ||
|
|
5e819d7676 | ||
|
|
0e3576922f | ||
|
|
ab388e6ad6 | ||
|
|
c8e9c7790e | ||
|
|
c8fe28b07b | ||
|
|
87c3f97529 | ||
|
|
302dd1e0f8 | ||
|
|
421fc929e8 | ||
|
|
c4ab5dc388 | ||
|
|
920dcc6779 | ||
|
|
430c711ff2 | ||
|
|
cfdc61a4b8 | ||
|
|
0f28e19c1d | ||
|
|
7316e355ee | ||
|
|
b39e55fd87 | ||
|
|
cb046bba9e | ||
|
|
b9a40186b1 | ||
|
|
fd0a39b879 | ||
|
|
cc7afe4326 | ||
|
|
72cbc18a48 | ||
|
|
84408b842b | ||
|
|
339fcb56ad | ||
|
|
74cf534be5 | ||
|
|
89820c1377 | ||
|
|
2fe26b15dd | ||
|
|
2cb560fda4 | ||
|
|
d8530b9209 | ||
|
|
5049b0ba91 | ||
|
|
1b98a75c0e | ||
|
|
cbbde95773 | ||
|
|
26bb8a50a3 | ||
|
|
bf19bb31f9 | ||
|
|
55cc803708 | ||
|
|
805e521b1d | ||
|
|
f8d431a0d7 | ||
|
|
7b72db8a24 | ||
|
|
b61ff1ebec | ||
|
|
8870c65c6f | ||
|
|
5df5e635f3 | ||
|
|
d5e3cadc43 | ||
|
|
505ae05c53 | ||
|
|
a7929c0ac9 | ||
|
|
4d39008eab | ||
|
|
5efd59826b | ||
|
|
8970f29410 | ||
|
|
523318fc06 | ||
|
|
ba531b4f61 | ||
|
|
3d51f08e0b | ||
|
|
2660c124d5 | ||
|
|
d7199b4d1d | ||
|
|
c60704080b | ||
|
|
1e3e46cfdc | ||
|
|
93d4220f3a | ||
|
|
8a748f2b3a | ||
|
|
2138a4a890 | ||
|
|
94a1981a4c | ||
|
|
2272a401bb | ||
|
|
3ab43a2ef6 | ||
|
|
79698f2265 | ||
|
|
94f80c03c5 | ||
|
|
d98d01ab20 | ||
|
|
6be5c43e6d | ||
|
|
8deb762a52 | ||
|
|
fe21c19810 | ||
|
|
6e86c3d2c5 | ||
|
|
aca4b73fac | ||
|
|
078f7ac6f6 | ||
|
|
b168df6ca6 | ||
|
|
8d178884d4 | ||
|
|
cd2131ef6f | ||
|
|
d6bff15f11 | ||
|
|
b82fca0bc2 | ||
|
|
4fbd4fa81d | ||
|
|
68b5709d88 | ||
|
|
434b2c1305 | ||
|
|
9064ef687c | ||
|
|
c93f3d352c | ||
|
|
a9bfcbbe2f | ||
|
|
717e939038 | ||
|
|
dd1cb8a5d1 | ||
|
|
7c9399a342 | ||
|
|
a3ec9d19d3 | ||
|
|
5f2b5bd5a2 | ||
|
|
3bb4e66ad0 | ||
|
|
fbb102ffbd | ||
|
|
76f7535ffa | ||
|
|
7fcf0645da | ||
|
|
50618865e7 | ||
|
|
fe2f6a785a | ||
|
|
53fc24d4bc | ||
|
|
ff5fa501af | ||
|
|
6e9883018a | ||
|
|
a985ff4fb3 | ||
|
|
63018a7c72 | ||
|
|
c5748e9765 | ||
|
|
23e30d96fc | ||
|
|
3f8d517ee3 | ||
|
|
2662abe460 | ||
|
|
1b6d7ecb42 | ||
|
|
b60a87adb5 | ||
|
|
5fa901c2e6 | ||
|
|
8975e43729 | ||
|
|
1bdd7ec9f1 | ||
|
|
964dfc809f | ||
|
|
8cea298b2a | ||
|
|
fb4a8ccc0e | ||
|
|
14ee1dc45d | ||
|
|
1320b12699 | ||
|
|
aa20e70671 | ||
|
|
a4ca1f32d3 | ||
|
|
58eb670fed | ||
|
|
7b1b81c45a | ||
|
|
ea9bf07126 | ||
|
|
11789e78fe | ||
|
|
96c75a32c8 | ||
|
|
a963e2c9af | ||
|
|
4251d292e1 | ||
|
|
3f5e01f910 | ||
|
|
c14db5a711 | ||
|
|
5ae9955bd0 | ||
|
|
31032bd8e8 | ||
|
|
5696683859 | ||
|
|
f1756468bc | ||
|
|
7861600081 | ||
|
|
dc1ba89a6d | ||
|
|
389b3b9244 | ||
|
|
10766bbb47 | ||
|
|
7ae6c0a7a2 | ||
|
|
478b90d75c | ||
|
|
29a7fed6a5 | ||
|
|
c5b2b71eea | ||
|
|
aca7238020 | ||
|
|
356e5c1101 | ||
|
|
ca04a81e81 | ||
|
|
92a06a3c15 | ||
|
|
c418844178 | ||
|
|
5658dd24ff | ||
|
|
b5700614c1 | ||
|
|
56e06ccd5f | ||
|
|
98e6dfc4c4 | ||
|
|
b57949a6bc | ||
|
|
a26f4b43fd | ||
|
|
58681b5ac4 | ||
|
|
611d9cd436 | ||
|
|
cf8ac706b6 | ||
|
|
89569736b7 | ||
|
|
d867a60126 | ||
|
|
f54ebec0eb | ||
|
|
a58464d523 | ||
|
|
57ef2c6053 | ||
|
|
6412a657e7 | ||
|
|
9132e67371 | ||
|
|
105681e1db | ||
|
|
e728b333a9 | ||
|
|
b4b9e886e8 | ||
|
|
0d34a40931 | ||
|
|
7e697e423b | ||
|
|
5585f4fa83 | ||
|
|
04eab6c6d6 | ||
|
|
4646b6c2a4 | ||
|
|
4d490dfc0f | ||
|
|
a48b9840b8 | ||
|
|
55fc8ab9d4 | ||
|
|
74066ea57d | ||
|
|
2f98982d83 | ||
|
|
b75c97a696 | ||
|
|
787664f97b | ||
|
|
c1a9485d24 | ||
|
|
78cc2f7248 | ||
|
|
496a5f4a0b | ||
|
|
637e1ae583 | ||
|
|
4dd46a9a09 | ||
|
|
62b0ab37a9 | ||
|
|
df1cd5f1e9 | ||
|
|
29c220a1cf | ||
|
|
0afb365427 | ||
|
|
ed7fca5366 | ||
|
|
76812d1eeb | ||
|
|
9cd916c098 | ||
|
|
68ec3a2aae | ||
|
|
712f130e1d | ||
|
|
8e561f5b2a | ||
|
|
a59fa3a548 | ||
|
|
8c62faa822 | ||
|
|
75c08f2a6c | ||
|
|
33cb3c9680 | ||
|
|
9e4388332f | ||
|
|
470d24c9ca | ||
|
|
2691c40da8 | ||
|
|
dd3150e3d4 | ||
|
|
7c62f8a8e8 | ||
|
|
ca24632fac | ||
|
|
589864e48f | ||
|
|
cea0dbffa4 | ||
|
|
b4385d9028 | ||
|
|
7682de545d | ||
|
|
49130705e3 | ||
|
|
1e77394a4d | ||
|
|
b3eff65000 | ||
|
|
9826c1c718 | ||
|
|
e277ea0bc5 | ||
|
|
2540a3a7da | ||
|
|
08597f6464 | ||
|
|
d4a66e4320 | ||
|
|
e10c24cad0 | ||
|
|
97daa13ebd | ||
|
|
05514a1c6e | ||
|
|
987caaa729 | ||
|
|
c56eb7e7f3 | ||
|
|
7ca6d278a4 | ||
|
|
a40f612700 | ||
|
|
ba7ee2c59e | ||
|
|
3d20be6e95 | ||
|
|
0570852cac | ||
|
|
2507d98c55 | ||
|
|
8adf24c716 | ||
|
|
2dc544b2c0 | ||
|
|
4fed9bd059 | ||
|
|
aac9224c2d | ||
|
|
1bd6f18c5e | ||
|
|
daec1a8d05 | ||
|
|
64e1f3903e | ||
|
|
4a802e44a3 | ||
|
|
b1618a39c4 | ||
|
|
3f4f248221 | ||
|
|
41dccbcecd | ||
|
|
10d385b90a | ||
|
|
043a2523ad | ||
|
|
fe71473a3f | ||
|
|
953fb46f7b | ||
|
|
6233123b02 | ||
|
|
911467f0ef | ||
|
|
90be0c6538 | ||
|
|
4ba20d741e | ||
|
|
ef7ebff9f6 | ||
|
|
f891002528 | ||
|
|
6cbcc18aab | ||
|
|
bbc5651ca5 | ||
|
|
7e8fed9bd7 | ||
|
|
cfa5bdc6ff | ||
|
|
1050372634 | ||
|
|
5f10be4791 | ||
|
|
f90d76fad9 | ||
|
|
4069b36202 | ||
|
|
c5b0ee3e48 | ||
|
|
37e0a4a4bf | ||
|
|
3baac742d0 | ||
|
|
73826b9d08 | ||
|
|
11ed8b40ff | ||
|
|
2cecd118ed | ||
|
|
cc1c1c204f | ||
|
|
af805a172f | ||
|
|
1438d9bf1a | ||
|
|
135cea6c76 | ||
|
|
02e093d254 | ||
|
|
d1cba7b846 | ||
|
|
e98798894e | ||
|
|
b4b08aa3fd | ||
|
|
a10426b3f1 | ||
|
|
f101ce69a0 | ||
|
|
cb7aa32567 | ||
|
|
92aec73b9d | ||
|
|
79119fe3fa | ||
|
|
26f2c92bec | ||
|
|
1e21c6dad9 | ||
|
|
2208453775 | ||
|
|
d05dc74f90 | ||
|
|
fc298ac1dd | ||
|
|
597fc7387a | ||
|
|
054160d68b | ||
|
|
815aca64bc | ||
|
|
095088fb65 | ||
|
|
c4aea1f7a1 | ||
|
|
4ebcd69eeb | ||
|
|
877f2c42a4 | ||
|
|
7ac105f113 | ||
|
|
71c0bfa418 | ||
|
|
1711012541 | ||
|
|
d889aa63c5 | ||
|
|
5905f56e84 | ||
|
|
5fe0f6ff32 | ||
|
|
2abca7f08d | ||
|
|
b372028e0a | ||
|
|
e4830ad73f | ||
|
|
9b866cb5f1 | ||
|
|
0f776078f3 | ||
|
|
dd41d23144 | ||
|
|
c544a6c78c | ||
|
|
0b4e61b3ab | ||
|
|
1f531ea70e | ||
|
|
458c20335e | ||
|
|
54484d9fd4 | ||
|
|
5c1edc0dc7 | ||
|
|
bbc115ca84 | ||
|
|
c04d79beb0 | ||
|
|
e34347e450 | ||
|
|
7aa5350484 | ||
|
|
1769df12d5 | ||
|
|
7f439b168b | ||
|
|
5cca9a80dd | ||
|
|
fabd26e42f | ||
|
|
047d5da319 | ||
|
|
72a7d13f6d | ||
|
|
dadeac3571 | ||
|
|
757c74b899 | ||
|
|
b9d14412e6 | ||
|
|
539fad1d22 | ||
|
|
46c7d5dbc7 | ||
|
|
e11db5263b | ||
|
|
fe8c2c4244 | ||
|
|
5b2f402a9d | ||
|
|
b28ea023d2 | ||
|
|
e7e854959f | ||
|
|
e13bbd4782 | ||
|
|
3653a350a7 | ||
|
|
d8127230a7 | ||
|
|
a9789deecb | ||
|
|
f6bab8c0d8 | ||
|
|
3e03fbf078 | ||
|
|
695f4378c6 | ||
|
|
c4f5e6754c | ||
|
|
a37dcc1b45 | ||
|
|
b1010b0bb5 | ||
|
|
6e4b8fad56 | ||
|
|
93ee8be0cd | ||
|
|
6e5755039f | ||
|
|
97855d9094 | ||
|
|
ee3e457dde | ||
|
|
b3ff1b6516 | ||
|
|
4386d4eb7e | ||
|
|
c84e29ed54 | ||
|
|
db38740139 | ||
|
|
bf3b5ceedb | ||
|
|
3b98a05c3b | ||
|
|
f2933caa90 | ||
|
|
c7fcf2d289 | ||
|
|
3be31a6378 | ||
|
|
638e446bef | ||
|
|
7a931d2d6f | ||
|
|
fcc81e2a74 | ||
|
|
fa3a687cc5 | ||
|
|
61c07c8190 | ||
|
|
fc109ca384 | ||
|
|
882c35d2fa | ||
|
|
d1c6e0ae0b | ||
|
|
108e63c7c2 | ||
|
|
43c5061fac | ||
|
|
c89645564d | ||
|
|
48305ff052 | ||
|
|
809f16530b | ||
|
|
c1d57098b1 | ||
|
|
be8fbbbcdc | ||
|
|
6da1e8d5cd | ||
|
|
e70ccb8e58 | ||
|
|
8d87d59889 | ||
|
|
0e30167526 | ||
|
|
06c13cf522 | ||
|
|
d4eac95ced | ||
|
|
e1e1c161db | ||
|
|
42122c65fc | ||
|
|
9f8abb2b6c | ||
|
|
11af429ecd | ||
|
|
b11e945a29 | ||
|
|
7c44e6aa73 | ||
|
|
842b1ffd08 | ||
|
|
aba042b8b3 | ||
|
|
539899741d | ||
|
|
ceda236e9e | ||
|
|
ad9080be66 | ||
|
|
ba9b744e5d | ||
|
|
1e06f60a1b | ||
|
|
584a8833c2 | ||
|
|
1bd8bb4bd6 | ||
|
|
66a2ccabd5 | ||
|
|
b87b4a7dd5 | ||
|
|
ab86ef95c1 | ||
|
|
7c2fffe8ac | ||
|
|
79d442d957 | ||
|
|
1b68ea7231 | ||
|
|
20c0c33cde | ||
|
|
bbcb9e22ab | ||
|
|
a5a05c1ed4 | ||
|
|
f7969c875b | ||
|
|
31b35bd681 | ||
|
|
8b46547b20 | ||
|
|
9e68e12903 | ||
|
|
21f4855cc6 | ||
|
|
4ad6e8a20f | ||
|
|
53067358df | ||
|
|
098f97473d | ||
|
|
acec097773 | ||
|
|
48662d6adf | ||
|
|
51dd877a44 | ||
|
|
b931958b16 | ||
|
|
d387708e3a | ||
|
|
2fa364a813 | ||
|
|
c85039ddc4 | ||
|
|
0d5a6df27b | ||
|
|
07eaff5791 | ||
|
|
7894126f97 | ||
|
|
874b0d5a81 | ||
|
|
f694571e1a | ||
|
|
9000e563c4 | ||
|
|
b3879c1baa | ||
|
|
49edd2b69f | ||
|
|
1deab7731b | ||
|
|
76b5c0b49d | ||
|
|
2f7f996faf | ||
|
|
258f3850ed | ||
|
|
d52b55bc27 | ||
|
|
73d20c63f6 | ||
|
|
af75e277c4 | ||
|
|
59eb243540 | ||
|
|
b8c2de705d | ||
|
|
d60daf6272 | ||
|
|
689144eae4 | ||
|
|
663495c941 | ||
|
|
5c75b611f0 | ||
|
|
868af6dc49 | ||
|
|
e003c186b8 | ||
|
|
f62cf057d2 | ||
|
|
80f44422a0 | ||
|
|
91ec85760e | ||
|
|
0ac921f748 | ||
|
|
a4128d0953 | ||
|
|
7215eebb5a | ||
|
|
d2b83524cf | ||
|
|
214537697e | ||
|
|
8cda85e562 | ||
|
|
8ad203c974 | ||
|
|
7f0266d914 | ||
|
|
d0e6eaaee8 | ||
|
|
5761f6da69 | ||
|
|
275ba57c0c | ||
|
|
ef483f0ef3 | ||
|
|
8662819def | ||
|
|
4b0d94b742 | ||
|
|
01cedd52ba | ||
|
|
aee2226127 | ||
|
|
c4d99eec66 | ||
|
|
c01a0b2032 | ||
|
|
01361a3efe | ||
|
|
760cb9fc40 | ||
|
|
e6c68581f6 | ||
|
|
b83206252a | ||
|
|
0ca4923a51 | ||
|
|
adb5b8277a | ||
|
|
8a07644a91 | ||
|
|
95704c2753 | ||
|
|
001ec2dfdb | ||
|
|
dc039d8a3e | ||
|
|
bc3736ce1b | ||
|
|
747989f9dc | ||
|
|
aef2031cf5 | ||
|
|
32582b6019 | ||
|
|
697eed4e58 | ||
|
|
c6f919ee89 | ||
|
|
b39de0d870 | ||
|
|
4e3302c144 | ||
|
|
7709fe737f | ||
|
|
3132b139f7 | ||
|
|
cd22a140f3 | ||
|
|
1b5c482ecf | ||
|
|
8e864421a8 | ||
|
|
dae911d3eb | ||
|
|
744ce1ae96 | ||
|
|
8e678435f4 | ||
|
|
c4fae8b5e6 | ||
|
|
a3a09e45c3 | ||
|
|
8431ed04c1 | ||
|
|
c791113fbd | ||
|
|
497f6a561f | ||
|
|
83aeeeb82a | ||
|
|
fb34f19076 | ||
|
|
89b380ee0c | ||
|
|
cf5286a319 | ||
|
|
255eb35fd0 | ||
|
|
97f832530a | ||
|
|
ecb544bbd2 | ||
|
|
f79183ec24 | ||
|
|
7223da54b4 | ||
|
|
27d68986c5 | ||
|
|
da4b76f499 | ||
|
|
44b088380b | ||
|
|
537296ec4c | ||
|
|
c6c5515ebd | ||
|
|
7ffeeb464a | ||
|
|
db1ae00557 | ||
|
|
0aa6280643 | ||
|
|
0b471db6aa | ||
|
|
773a4e727c | ||
|
|
1c62339879 | ||
|
|
54d2f946c8 | ||
|
|
4ec4b50c40 | ||
|
|
27ec670e3a | ||
|
|
678419814a | ||
|
|
6ab9a3d2c2 | ||
|
|
b93c0996bf | ||
|
|
0d96879544 | ||
|
|
1c0a253beb | ||
|
|
2ed2974ab4 | ||
|
|
8306211f49 | ||
|
|
4a78455947 | ||
|
|
32d4c21dd7 | ||
|
|
68ef19dd6e | ||
|
|
8be0f86560 | ||
|
|
57557ec51a | ||
|
|
d38c0cf4a8 | ||
|
|
3c09bb89dd | ||
|
|
4a17b21f19 | ||
|
|
b781374691 | ||
|
|
b97b2f7fb5 | ||
|
|
61f4435cc2 | ||
|
|
c378c7a0dd | ||
|
|
03cbbae48a | ||
|
|
9ba67654aa | ||
|
|
79a633e5e0 | ||
|
|
db0f11d27e | ||
|
|
e02e1c5bc3 | ||
|
|
a10c8238f8 | ||
|
|
61709736bc | ||
|
|
7ce9a8371c | ||
|
|
1e1ea989e9 | ||
|
|
2720ea30df | ||
|
|
e0a48da5b5 | ||
|
|
9800fb29bf | ||
|
|
d7a24d2704 | ||
|
|
7a6819d923 | ||
|
|
5775fcf908 | ||
|
|
93c7c37283 | ||
|
|
54e82f407f | ||
|
|
d0a39b2824 | ||
|
|
954101548a | ||
|
|
23852709cb | ||
|
|
d1fd6fb9a0 | ||
|
|
67186476ce | ||
|
|
34b2a30738 | ||
|
|
69f7cbeb05 | ||
|
|
3632c0d278 | ||
|
|
eb9bac6cf9 | ||
|
|
34c213ef5a | ||
|
|
574b0a05e2 | ||
|
|
771bfbb270 | ||
|
|
5f1bcf93c6 | ||
|
|
b77b56859e | ||
|
|
e28532a1d9 | ||
|
|
fcc1f563e5 | ||
|
|
b7299322c0 | ||
|
|
582bd9c005 | ||
|
|
94de61c187 | ||
|
|
782fee4ffd | ||
|
|
b8598d37d7 | ||
|
|
80c957dec1 | ||
|
|
e95574f801 | ||
|
|
9ba073ec64 | ||
|
|
2549dc7a78 | ||
|
|
199ff3f104 | ||
|
|
cd8b8cbd0b | ||
|
|
a75f2c2087 | ||
|
|
306248b11c |
+9
-2
@@ -4,14 +4,21 @@
|
|||||||
{
|
{
|
||||||
"name": "musify-web",
|
"name": "musify-web",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["--prefix", "web-player", "run", "dev", "--", "--port", "3000"],
|
"runtimeArgs": ["--prefix", "web-player", "run", "dev"],
|
||||||
"port": 3000
|
"port": 3000,
|
||||||
|
"autoPort": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "musify-api",
|
"name": "musify-api",
|
||||||
"runtimeExecutable": "dotnet",
|
"runtimeExecutable": "dotnet",
|
||||||
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Api"],
|
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Api"],
|
||||||
"port": 5111
|
"port": 5111
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "musify-streaming-gateway",
|
||||||
|
"runtimeExecutable": "dotnet",
|
||||||
|
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.StreamingGateway"],
|
||||||
|
"port": 8081
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
name: CI/CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['**']
|
||||||
|
pull_request:
|
||||||
|
branches: ['**']
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: production-deploy
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend-tests:
|
||||||
|
name: BackEnd
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore Musify.slnx
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: dotnet build Musify.slnx --no-restore -c Release
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: dotnet test Musify.slnx --no-build -c Release
|
||||||
|
|
||||||
|
web-player-checks:
|
||||||
|
name: WebPlayer
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: web-player
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: web-player/package-lock.json
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- name: Type-check
|
||||||
|
run: npm run check
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
# Runs on the production host itself, through the existing self-hosted
|
||||||
|
# Gitea Actions runner (act_runner), which also carries a "production"
|
||||||
|
# label registered in native (non-docker) mode so its steps see the host's
|
||||||
|
# real Docker daemon directly (Docker socket + a bind-mounted deploy
|
||||||
|
# directory — see deploy/README.md for how that runner is set up).
|
||||||
|
#
|
||||||
|
# DEPLOY_PATH is the persistent clone already on that host (the one whose
|
||||||
|
# deploy/.env.prod is filled in and matches SharedServices/.env.prod) —
|
||||||
|
# deploy reuses it instead of a fresh checkout, since .env.prod is
|
||||||
|
# untracked and only lives there.
|
||||||
|
deploy:
|
||||||
|
name: Deploy
|
||||||
|
needs: [backend-tests, web-player-checks]
|
||||||
|
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
|
||||||
|
runs-on: [production]
|
||||||
|
timeout-minutes: 20
|
||||||
|
env:
|
||||||
|
DEPLOY_PATH: /home/ubuntu/deploys/musify
|
||||||
|
COMPOSE: docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml
|
||||||
|
steps:
|
||||||
|
- name: Pull latest master
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git config --global --add safe.directory "$DEPLOY_PATH"
|
||||||
|
cd "$DEPLOY_PATH"
|
||||||
|
git fetch origin master
|
||||||
|
git reset --hard origin/master
|
||||||
|
|
||||||
|
- name: Generate stream-ticket keys
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$DEPLOY_PATH"
|
||||||
|
$COMPOSE run --rm --no-deps keys-init
|
||||||
|
|
||||||
|
# The migrations are baked into the migrator image, so it is rebuilt on
|
||||||
|
# every deploy (plain `up` would reuse the stale image and re-apply the
|
||||||
|
# old migrations). `run --rm` starts wait-for-postgres, blocks until the
|
||||||
|
# migration finishes and propagates its exit code, so a failed migration
|
||||||
|
# aborts the deploy before the new API is started against an old schema.
|
||||||
|
- name: Migrate database
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$DEPLOY_PATH"
|
||||||
|
$COMPOSE build migrate
|
||||||
|
$COMPOSE run --rm migrate
|
||||||
|
|
||||||
|
# Both jobs verify themselves through the public auth./s3. URLs, served
|
||||||
|
# by the shared edge nginx (Infrastructure's edge/), and are idempotent.
|
||||||
|
- name: Create OIDC app and S3 bucket
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$DEPLOY_PATH"
|
||||||
|
$COMPOSE up -d seaweedfs-init zitadel-init
|
||||||
|
for job in musify-seaweedfs-init musify-zitadel-init; do
|
||||||
|
code=$(docker wait "$job")
|
||||||
|
if [ "$code" != "0" ]; then
|
||||||
|
docker logs "$job"
|
||||||
|
echo "$job exited with $code"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Build and start apps
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$DEPLOY_PATH"
|
||||||
|
$COMPOSE --profile apps up -d --build api worker gateway web-player
|
||||||
+3
-1
@@ -7,6 +7,9 @@ obj/
|
|||||||
*.userosscache
|
*.userosscache
|
||||||
.vs/
|
.vs/
|
||||||
artifacts/
|
artifacts/
|
||||||
|
TestResults/
|
||||||
|
*.trx
|
||||||
|
*.coverage
|
||||||
|
|
||||||
# Rider / VS
|
# Rider / VS
|
||||||
.idea/
|
.idea/
|
||||||
@@ -25,5 +28,4 @@ Thumbs.db
|
|||||||
# Deploy secrets and runtime state
|
# Deploy secrets and runtime state
|
||||||
deploy/keys/
|
deploy/keys/
|
||||||
deploy/secrets/
|
deploy/secrets/
|
||||||
deploy/nginx/certs/
|
|
||||||
deploy/zitadel/.output/
|
deploy/zitadel/.output/
|
||||||
|
|||||||
@@ -1,57 +1,55 @@
|
|||||||
# Musify
|
# Musify
|
||||||
|
|
||||||
Music streaming platform: upload your own tracks (or import them from YouTube),
|
A music streaming app. Upload your own tracks, organize them into playlists
|
||||||
process them asynchronously (audio transcoded to `.m4a`, thumbnails generated)
|
and albums, and stream them back with a web player built around your
|
||||||
and stream them without the bytes ever going through the API.
|
listening habits: auto-generated mixes, a home feed, search, likes, and an
|
||||||
|
Explore page for finding something new.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **Upload and organize your own music**: tracks, albums, cover art, artist credits.
|
||||||
|
- **Playlists**, likes, and a "recently played" history.
|
||||||
|
- **Mixes**, playlists generated automatically from what you actually listen to.
|
||||||
|
- **Explore**, genre browsing and search across tracks, albums, playlists and people.
|
||||||
|
- **Real streaming**, not just file downloads: seek, queue, shuffle, repeat.
|
||||||
|
- **Accounts via OIDC login**, with an optional anonymous listening mode.
|
||||||
|
- Light and dark themes, and a layout that works on both desktop and mobile.
|
||||||
|
|
||||||
|
## How it's built
|
||||||
|
|
||||||
|
**Backend**: .NET 10, split into a REST API, a background worker, and a
|
||||||
|
dedicated streaming gateway.
|
||||||
|
- Clean architecture (Domain / Application / Infrastructure) with CQRS (Mediator) and `ErrorOr` results.
|
||||||
|
- PostgreSQL via EF Core for metadata. RabbitMQ and MassTransit handle async processing (audio transcoding, thumbnails) with saga-based rollback.
|
||||||
|
- SeaweedFS (S3-compatible) for file storage. Uploads and downloads go straight between the client and storage, never through the API.
|
||||||
|
- ffmpeg for audio transcoding, ImageSharp for thumbnails.
|
||||||
|
- A YARP reverse proxy that streams the audio and checks a signed, short-lived ticket on every request.
|
||||||
|
- Zitadel for login (OIDC/OAuth2).
|
||||||
|
|
||||||
|
**Web client**: SvelteKit 2 on Svelte 5 (runes), TypeScript, Tailwind 4.
|
||||||
|
The session lives on the server, so the backend access token never reaches the browser.
|
||||||
|
|
||||||
|
**Infrastructure**: Musify's own apps run from Docker Compose (dev and
|
||||||
|
production overlays). Postgres, RabbitMQ, SeaweedFS, Zitadel and Jaeger are
|
||||||
|
shared infrastructure that lives in a separate `Infrastructure` git
|
||||||
|
repository, used by Musify and by other projects that need the same OIDC
|
||||||
|
provider and object storage.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/ .NET 10 — Domain / Application / Infrastructure / Api / Worker / StreamingGateway / Tests
|
backend/ .NET solution: Api, Worker, StreamingGateway, and the shared Domain/Application/Infrastructure
|
||||||
web-player/ SvelteKit 2 + Svelte 5 + Tailwind 4 web client
|
web-player/ the SvelteKit web client
|
||||||
deploy/ Docker Compose stacks — self-contained, no wrapper scripts
|
deploy/ Docker Compose stacks for dev and production
|
||||||
docs/ What each piece does and why (in Spanish)
|
docs/ architecture notes, one file per area
|
||||||
```
|
```
|
||||||
|
|
||||||
```sh
|
Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger are not part of this
|
||||||
dotnet test backend/Musify.slnx # backend/Tests/ — Docker running (Testcontainers: Postgres, SeaweedFS)
|
layout. They live in the separate `Infrastructure` repository instead.
|
||||||
```
|
|
||||||
|
|
||||||
## Run it
|
## Digging deeper
|
||||||
|
|
||||||
Development (Docker Desktop):
|
[docs/](docs/README.md) covers how each piece works and why: architecture,
|
||||||
|
authentication, storage, streaming, media processing, and the local dev
|
||||||
```sh
|
setup. [deploy/README.md](deploy/README.md) has the full instructions for
|
||||||
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
|
running the stack, in development or on a production server.
|
||||||
```
|
|
||||||
|
|
||||||
Infrastructure plus stream-ticket keys, database migrations and Zitadel OIDC
|
|
||||||
provisioning — all as one-shot jobs baked into the compose file. Apps run from
|
|
||||||
the IDE by default; add `--profile apps up -d --build` as a second command to
|
|
||||||
run them in Docker instead.
|
|
||||||
|
|
||||||
Production, on a server with a domain:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cp deploy/.env.prod.example deploy/.env.prod # fill in the domain and secrets
|
|
||||||
docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d
|
|
||||||
docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
Idempotent — run the same commands again to deploy an update. See
|
|
||||||
[deploy/README.md](deploy/README.md) for the details, the port map and the
|
|
||||||
production prerequisites.
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
| Service | Port (dev) | Role |
|
|
||||||
|---|---|---|
|
|
||||||
| `Musify.Api` | 5111 | REST API, issues presigned upload URLs and stream tickets |
|
|
||||||
| `Musify.StreamingGateway` | 8081 | YARP proxy that validates the stream ticket and serves the audio |
|
|
||||||
| `Musify.Worker` | — | MassTransit consumers: transcoding, thumbnails, YouTube downloads |
|
|
||||||
| `web-player` | 5173 / 3000 | Web client (SvelteKit, server-side OIDC session) |
|
|
||||||
| Zitadel | 8080 | Identity (OIDC/OAuth2) |
|
|
||||||
| PostgreSQL / RabbitMQ / SeaweedFS / Jaeger | 59000 / 5672 / 8333 / 16686 | Metadata, messaging, object storage, traces |
|
|
||||||
|
|
||||||
More in [docs/](docs/README.md): [architecture](docs/architecture.md),
|
|
||||||
[authentication](docs/authentication.md), [storage](docs/storage.md),
|
|
||||||
[streaming](docs/streaming.md), [media processing](docs/media-processing.md),
|
|
||||||
[development](docs/development.md).
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
|
||||||
|
[*.{csproj,props,targets,slnx,xml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.json]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[**/Migrations/**.cs]
|
||||||
|
generated_code = true
|
||||||
|
|
||||||
|
[*.cs]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
csharp_style_namespace_declarations = block_scoped:warning
|
||||||
|
csharp_using_directive_placement = outside_namespace:warning
|
||||||
|
dotnet_sort_system_directives_first = true
|
||||||
|
dotnet_separate_import_directive_groups = false
|
||||||
|
dotnet_diagnostic.IDE0005.severity = warning
|
||||||
|
|
||||||
|
dotnet_style_require_accessibility_modifiers = always:warning
|
||||||
|
csharp_style_var_for_built_in_types = true:suggestion
|
||||||
|
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||||
|
csharp_style_var_elsewhere = true:suggestion
|
||||||
|
csharp_prefer_braces = when_multiline:warning
|
||||||
|
csharp_style_prefer_primary_constructors = true:suggestion
|
||||||
|
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
|
||||||
|
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
|
||||||
|
|
||||||
|
# Naming. Roslyn applies the first matching rule in file order, so the specific rules come first.
|
||||||
|
# Private fields are _camelCase whatever their modifiers (static, readonly, const).
|
||||||
|
dotnet_naming_rule.private_fields.symbols = private_fields
|
||||||
|
dotnet_naming_rule.private_fields.style = underscore_camel_case
|
||||||
|
dotnet_naming_rule.private_fields.severity = warning
|
||||||
|
dotnet_naming_symbols.private_fields.applicable_kinds = field
|
||||||
|
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
|
||||||
|
dotnet_naming_style.underscore_camel_case.capitalization = camel_case
|
||||||
|
dotnet_naming_style.underscore_camel_case.required_prefix = _
|
||||||
|
|
||||||
|
# Non-private fields (constants, static readonly, etc.) are PascalCase.
|
||||||
|
dotnet_naming_rule.other_fields.symbols = other_fields
|
||||||
|
dotnet_naming_rule.other_fields.style = pascal_case
|
||||||
|
dotnet_naming_rule.other_fields.severity = warning
|
||||||
|
dotnet_naming_symbols.other_fields.applicable_kinds = field
|
||||||
|
dotnet_naming_symbols.other_fields.applicable_accessibilities = public, internal, protected, protected_internal, private_protected
|
||||||
|
dotnet_naming_style.pascal_case.capitalization = pascal_case
|
||||||
|
|
||||||
|
dotnet_naming_rule.interfaces.symbols = interfaces
|
||||||
|
dotnet_naming_rule.interfaces.style = interface_style
|
||||||
|
dotnet_naming_rule.interfaces.severity = warning
|
||||||
|
dotnet_naming_symbols.interfaces.applicable_kinds = interface
|
||||||
|
dotnet_naming_style.interface_style.capitalization = pascal_case
|
||||||
|
dotnet_naming_style.interface_style.required_prefix = I
|
||||||
|
|
||||||
|
dotnet_naming_rule.types_and_members.symbols = types_and_members
|
||||||
|
dotnet_naming_rule.types_and_members.style = pascal_case
|
||||||
|
dotnet_naming_rule.types_and_members.severity = warning
|
||||||
|
dotnet_naming_symbols.types_and_members.applicable_kinds = class, struct, enum, delegate, property, method, event
|
||||||
|
dotnet_naming_symbols.types_and_members.applicable_accessibilities = *
|
||||||
|
|
||||||
|
# CA1848/CA1873 would turn every log call into a [LoggerMessage] source-generated method; CA1716 flags the
|
||||||
|
# EF-style IDatabase.Set<T>() and the Musify.Application.Shared namespace, whose names are deliberate.
|
||||||
|
dotnet_diagnostic.CA1848.severity = suggestion
|
||||||
|
dotnet_diagnostic.CA1873.severity = suggestion
|
||||||
|
dotnet_diagnostic.CA1716.severity = suggestion
|
||||||
|
# The routing slip builders are stateless services resolved from DI; they stay instance members.
|
||||||
|
dotnet_diagnostic.CA1822.severity = suggestion
|
||||||
|
|
||||||
|
# Test names follow Method_Scenario_Expected.
|
||||||
|
[Tests/**.cs]
|
||||||
|
dotnet_diagnostic.CA1707.severity = none
|
||||||
|
# xUnit collection fixtures are conventionally named ...Collection.
|
||||||
|
dotnet_diagnostic.CA1711.severity = none
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Musify: backend
|
||||||
|
|
||||||
|
.NET 10 solution (`Musify.slnx`): `Core/` (Domain, Application, Infrastructure), `Hosts/` (Api, Worker, StreamingGateway) and `Tests/`. Architecture and flows are in `../docs/`.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Run from `backend/`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dotnet build Musify.slnx # warnings are errors, including unused usings (IDE0005)
|
||||||
|
dotnet test Musify.slnx # Infrastructure and Api tests need Docker (Testcontainers)
|
||||||
|
dotnet format Musify.slnx --verify-no-changes
|
||||||
|
```
|
||||||
|
|
||||||
|
- Read the totals of `dotnet test` (`total:`, `succeeded:`) before trusting it; don't chain it with `&&` into a commit.
|
||||||
|
- EF migrations: `dotnet ef migrations add <Name> --project Core/Musify.Infrastructure --startup-project Core/Musify.Infrastructure` (set `Database__ConnectionString` to any value). A model change needs a migration; the entity model lives in `ModelConfiguration.Apply`, which the in-memory test database reuses.
|
||||||
|
- An HTTP contract change (routes, DTOs, responses) needs the web-player schema regenerated (`web-player/src/lib/api/schema.d.ts`, from `/openapi/v1.json`) and `npm run check` clean.
|
||||||
|
- A MassTransit message change (events, routing slip arguments, process kinds) means the queues must be drained before deploying; note it in the commit.
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
- Block-scoped namespaces (`namespace X { }`; file-scoped `namespace X;` is not allowed), usings outside the namespace and sorted (`.editorconfig` enforces it).
|
||||||
|
- Private fields are `_camelCase` whatever their modifiers (including `static readonly` and `const`); non-private constants and static fields, types, methods and properties are PascalCase; interfaces start with `I`; explicit accessibility. Enforced by `.editorconfig` (IDE1006).
|
||||||
|
- `ArgumentNullException.ThrowIfNull(x)` without `nameof`.
|
||||||
|
- Use `PlayList` (not `Playlist`) in code; configuration POCOs end in `Configuration`; injected services are named after their type (`storageService`).
|
||||||
|
- User-facing strings are in English (the web-player owns translation).
|
||||||
|
- Trim stored names; normalized columns go through `TextNormalizer.Normalize`.
|
||||||
|
- Format ids and numbers with `CultureInfo.InvariantCulture`; put `CancellationToken` last.
|
||||||
|
|
||||||
|
## Application layer
|
||||||
|
|
||||||
|
- One file per use case, `<Feature>/<UseCase>.cs`, holding the record and its handler; responses go in `Responses/`.
|
||||||
|
- Errors come from `AppErrors` (`NotFound`, `Forbidden` = 403 for "not the owner", `Conflict`). `Error.Unauthorized` (401) only means "not signed in". The success value is `Result.Success`; the API maps it to 204.
|
||||||
|
- Handlers that cannot fail return plain types. Lists are paginated through `PageRequest` / `ToPaginatedAsync`, ordered with an `Id` tiebreak, and public reads return only `LifeCycleStatus.Active` rows.
|
||||||
|
- Owned-entity operations go through `FindOwnedAsync` (non-active entities are not found).
|
||||||
|
- No try/catch around `SaveChangesAsync` or `PublishAsync` in handlers: the outbox makes publish + save atomic. Infrastructure either handles an error or lets it propagate; never log and rethrow.
|
||||||
|
- List query records keep `int PageNumber, int PageSize`; the API binds them through `PageQuery` and validates them with `PageQueryValidator`.
|
||||||
|
|
||||||
|
## API layer
|
||||||
|
|
||||||
|
- Routes: `{id:guid}` for the resource, `{trackId:guid}` for sub-resources, `{id:long}` / `{userId:long}` for user ids.
|
||||||
|
- Every endpoint returns `IResult` (`Results.Ok` when the handler cannot fail, `ToHttpResult()` otherwise).
|
||||||
|
- Validation limits live in `Validators/Limits.cs`; the database column length wins when a limit disagrees with the web-player. Upload allow-lists are in `Validators/Uploads.cs`.
|
||||||
|
- `.WithSummary(...)` is sentence case without a trailing period.
|
||||||
|
|
||||||
|
## Infrastructure and hosts
|
||||||
|
|
||||||
|
- MassTransit routing slips are built from `RoutingSlips.Create` / `AddStep` / `TrackFaults`; activities for the three owner types share generic bases (`MarkLifeCycleActivity<T>`, `DeleteEntityActivity<T>`, `UpdatePicturesActivity<T>`).
|
||||||
|
- Recurring work is Hangfire (`RecurringJobsRegistrar`); intervals are in seconds and rounded up to whole minutes.
|
||||||
|
- The Worker calls `AddApplication` and validates its service container on build, so any handler dependency must be registered there. Shared config sections in its `appsettings.json` mirror the API's.
|
||||||
|
- The StreamingGateway references neither Application nor Infrastructure and keeps its own options.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- xUnit v3; pass `TestContext.Current.CancellationToken` to methods that take a token (analyzer xUnit1051).
|
||||||
|
- Test names are `Method_Scenario_Expected`.
|
||||||
|
- Application tests use the in-memory SQLite `TestDatabase`; Infrastructure and Api tests use Testcontainers (Postgres, SeaweedFS). MassTransit sagas and consumers are tested on the MassTransit test harness.
|
||||||
|
- `Infrastructure` exposes its internals to `Musify.Infrastructure.Tests`.
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
|
||||||
{
|
|
||||||
public record AddTrackToAlbumCommand(long UserId, Guid AlbumId, Guid TrackId)
|
|
||||||
: ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class AddTrackToAlbumCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<AddTrackToAlbumCommandHandler> logger)
|
|
||||||
: ICommandHandler<AddTrackToAlbumCommand, ErrorOr<Success>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var album = await database.UserAlbums
|
|
||||||
.AsNoTracking()
|
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
|
||||||
if (album is null)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (album.OwnerUserId != request.UserId)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var trackExists = await database.Tracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
|
|
||||||
if (!trackExists)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
|
||||||
return Error.NotFound(description: $"Track {request.TrackId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var localTrack = await database.LocalTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
|
|
||||||
if (localTrack is not null && localTrack.OwnerUserId != request.UserId)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var alreadyAdded = await database.AlbumHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.TrackId == request.TrackId, cancellationToken);
|
|
||||||
if (alreadyAdded)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Track {TrackId} already in album {AlbumId}", request.TrackId, request.AlbumId);
|
|
||||||
return Error.Conflict(description: "Track is already in the album.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var albumTracksQuery = database.AlbumHasTracks
|
|
||||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
|
|
||||||
|
|
||||||
var hasTracks = await albumTracksQuery.AnyAsync(cancellationToken);
|
|
||||||
var nextTrackNumber = hasTracks
|
|
||||||
? await albumTracksQuery.MaxAsync(albumTrack => albumTrack.TrackNumber, cancellationToken) + 1
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
|
|
||||||
{
|
|
||||||
AlbumId = request.AlbumId,
|
|
||||||
TrackId = request.TrackId,
|
|
||||||
TrackNumber = nextTrackNumber
|
|
||||||
}, cancellationToken);
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Added track {TrackId} to album {AlbumId} as number {TrackNumber}",
|
|
||||||
request.TrackId, request.AlbumId, nextTrackNumber);
|
|
||||||
|
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Albums
|
||||||
|
{
|
||||||
|
/// <summary>Appends your own tracks to an album in the given order, skipping the ones it already has.</summary>
|
||||||
|
public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList<Guid> TrackIds)
|
||||||
|
: ICommand<ErrorOr<Success>>;
|
||||||
|
|
||||||
|
public class AddTracksToAlbumCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<AddTracksToAlbumCommandHandler> logger)
|
||||||
|
: ICommandHandler<AddTracksToAlbumCommand, ErrorOr<Success>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||||
|
if (album.IsError)
|
||||||
|
return album.Errors;
|
||||||
|
|
||||||
|
var requestedIds = request.TrackIds.Distinct().ToList();
|
||||||
|
|
||||||
|
var tracks = await database.Tracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.Select(track => new { track.Id, track.OwnerUserId })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var missing = requestedIds.FirstOrDefault(id => tracks.All(track => track.Id != id));
|
||||||
|
if (missing != Guid.Empty)
|
||||||
|
return AppErrors.NotFound("Track", missing);
|
||||||
|
|
||||||
|
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
|
||||||
|
if (foreign != null)
|
||||||
|
return AppErrors.Forbidden("Track", foreign.Id);
|
||||||
|
|
||||||
|
var alreadyInAlbum = await database.AlbumHasTracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(link => link.AlbumId == request.AlbumId && requestedIds.Contains(link.TrackId))
|
||||||
|
.Select(link => link.TrackId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var toAdd = requestedIds.Where(id => !alreadyInAlbum.Contains(id)).ToList();
|
||||||
|
if (toAdd.Count == 0)
|
||||||
|
return Result.Success;
|
||||||
|
|
||||||
|
// Album track numbers are user-visible, so they start at 1.
|
||||||
|
var nextTrackNumber = (await database.AlbumHasTracks
|
||||||
|
.Where(link => link.AlbumId == request.AlbumId)
|
||||||
|
.MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1;
|
||||||
|
|
||||||
|
foreach (var trackId in toAdd)
|
||||||
|
{
|
||||||
|
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
|
||||||
|
{
|
||||||
|
AlbumId = request.AlbumId,
|
||||||
|
TrackId = trackId,
|
||||||
|
TrackNumber = nextTrackNumber++
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved = await database.TrySaveChangesAsync(
|
||||||
|
AppErrors.Conflict("Album.ConcurrentChange", "The album changed while adding tracks. Try again."), cancellationToken);
|
||||||
|
if (saved.IsError)
|
||||||
|
return saved;
|
||||||
|
|
||||||
|
logger.LogInformation("Added {Count} tracks to album {AlbumId}", toAdd.Count, request.AlbumId);
|
||||||
|
|
||||||
|
return Result.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,15 @@ using ErrorOr;
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Events;
|
||||||
|
using Musify.Application.Pictures;
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Services;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
@@ -26,9 +27,7 @@ namespace Musify.Application.Albums
|
|||||||
IDatabase database,
|
IDatabase database,
|
||||||
UploadIntentValidator uploadIntentValidator,
|
UploadIntentValidator uploadIntentValidator,
|
||||||
ILogger<CreateAlbumCommandHandler> logger,
|
ILogger<CreateAlbumCommandHandler> logger,
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
AlbumConfiguration albumConfiguration)
|
||||||
AlbumConfiguration albumConfiguration,
|
|
||||||
UploadIntentConfiguration uploadIntentConfiguration)
|
|
||||||
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken)
|
||||||
@@ -37,92 +36,38 @@ namespace Musify.Application.Albums
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
|
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
|
||||||
if (!userExists)
|
if (!userExists)
|
||||||
{
|
return AppErrors.NotFound("User", request.UserId);
|
||||||
logger.LogWarning("User {UserId} not found", request.UserId);
|
|
||||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync(
|
var prepared = await PictureSourceChange.PrepareAsync(
|
||||||
uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken);
|
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
|
||||||
if (pictureIntent.IsError)
|
if (prepared.IsError)
|
||||||
return pictureIntent.Errors;
|
return prepared.Errors;
|
||||||
|
|
||||||
var intent = pictureIntent.Value;
|
var picture = prepared.Value;
|
||||||
var title = request.Title.Trim();
|
var title = request.Title.Trim();
|
||||||
|
|
||||||
var album = new UserAlbum
|
var album = new Album
|
||||||
{
|
{
|
||||||
OwnerUserId = request.UserId,
|
OwnerUserId = request.UserId,
|
||||||
Title = title,
|
Title = title,
|
||||||
NormalizedTitle = title.ToUpperInvariant(),
|
NormalizedTitle = TextNormalizer.Normalize(title),
|
||||||
Description = request.Description,
|
Description = request.Description,
|
||||||
ReleaseYear = request.ReleaseYear,
|
ReleaseYear = request.ReleaseYear,
|
||||||
Pictures = new AlbumPictures
|
Pictures = picture.Pictures
|
||||||
{
|
|
||||||
OriginalName = intent.ObjectName,
|
|
||||||
SmallName = intent.ObjectName,
|
|
||||||
MediumName = intent.ObjectName,
|
|
||||||
LargeName = intent.ObjectName
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await database.UserAlbums.AddAsync(album, cancellationToken);
|
await database.Albums.AddAsync(album, cancellationToken);
|
||||||
|
|
||||||
var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName);
|
await eventBus.PublishAsync(
|
||||||
var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken);
|
new CreateAlbumResourcesEvent(
|
||||||
if (publishResult.IsError)
|
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||||
return publishResult.Errors;
|
cancellationToken);
|
||||||
|
|
||||||
try
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to save album for user {UserId}", request.UserId);
|
|
||||||
return Error.Failure(description: $"Failed to create album for user {request.UserId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId);
|
logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId);
|
||||||
|
|
||||||
return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
|
return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ErrorOr<Success>> PublishCreateAlbumEventAsync(
|
|
||||||
Guid albumId,
|
|
||||||
UploadIntent pictureIntent,
|
|
||||||
string finalPictureKey,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await eventBus.PublishAsync(
|
|
||||||
new CreateAlbumResourcesEvent(
|
|
||||||
albumId,
|
|
||||||
pictureIntent.Id,
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
pictureIntent.Key,
|
|
||||||
finalPictureKey,
|
|
||||||
new ImageSize(
|
|
||||||
albumConfiguration.Routes.SmallPicturesPath,
|
|
||||||
albumConfiguration.PicturesSizes.SmallPictureWidth,
|
|
||||||
albumConfiguration.PicturesSizes.SmallPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
albumConfiguration.Routes.MediumPicturesPath,
|
|
||||||
albumConfiguration.PicturesSizes.MediumPictureWidth,
|
|
||||||
albumConfiguration.PicturesSizes.MediumPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
albumConfiguration.Routes.LargePicturesPath,
|
|
||||||
albumConfiguration.PicturesSizes.LargePictureWidth,
|
|
||||||
albumConfiguration.PicturesSizes.LargePictureHeight)),
|
|
||||||
cancellationToken);
|
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to publish create album event for album {AlbumId}", albumId);
|
|
||||||
return Error.Failure(description: $"Failed to publish create album event for album {albumId}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Events;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
@@ -11,40 +13,25 @@ namespace Musify.Application.Albums
|
|||||||
|
|
||||||
public class DeleteAlbumCommandHandler(
|
public class DeleteAlbumCommandHandler(
|
||||||
IDatabase database,
|
IDatabase database,
|
||||||
|
IEventBus eventBus,
|
||||||
ILogger<DeleteAlbumCommandHandler> logger)
|
ILogger<DeleteAlbumCommandHandler> logger)
|
||||||
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
|
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var album = await database.UserAlbums
|
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
if (found.IsError)
|
||||||
if (album is null)
|
return found.Errors;
|
||||||
{
|
|
||||||
logger.LogDebug("Album {AlbumId} not found", request.AlbumId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (album.OwnerUserId != request.UserId)
|
var album = found.Value;
|
||||||
{
|
album.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||||
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
database.UserAlbums.Remove(album);
|
await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken);
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
try
|
logger.LogInformation("Marked album {AlbumId} as removing", request.AlbumId);
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to delete album {AlbumId}", request.AlbumId);
|
|
||||||
return Error.Failure(description: $"Failed to delete album {request.AlbumId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogInformation("Deleted album {AlbumId} for user {UserId}", request.AlbumId, request.UserId);
|
return Result.Success;
|
||||||
|
|
||||||
return new Success();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
@@ -14,25 +15,16 @@ namespace Musify.Application.Albums
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var album = await database.UserAlbums
|
var album = await database.Albums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
.Active()
|
||||||
.Where(a => a.Id == request.Id)
|
.Where(a => a.Id == request.Id)
|
||||||
.Select(a => new
|
.SelectResponse()
|
||||||
{
|
|
||||||
Album = a,
|
|
||||||
TrackCount = a.AlbumTracks.Count,
|
|
||||||
CoverTrackIds = a.AlbumTracks
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (album is null)
|
return album == null
|
||||||
return Error.NotFound();
|
? AppErrors.NotFound("Album", request.Id)
|
||||||
|
: album;
|
||||||
return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using MimeMapping;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
|
||||||
{
|
|
||||||
public record GetAlbumCoverQuery(Guid AlbumId, string Size)
|
|
||||||
: IQuery<ErrorOr<AlbumCoverLocation>>;
|
|
||||||
|
|
||||||
public record AlbumCoverLocation(string Bucket, string Key, string ContentType);
|
|
||||||
|
|
||||||
public class GetAlbumCoverQueryHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
|
||||||
AlbumConfiguration albumConfiguration)
|
|
||||||
: IQueryHandler<GetAlbumCoverQuery, ErrorOr<AlbumCoverLocation>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<AlbumCoverLocation>> Handle(GetAlbumCoverQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var pictures = await database.Albums.AsNoTracking()
|
|
||||||
.Where(a => a.Id == request.AlbumId)
|
|
||||||
.Select(a => a.Pictures)
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (pictures is null)
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
var routes = albumConfiguration.Routes;
|
|
||||||
var (name, key) = request.Size.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
|
|
||||||
"large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
|
|
||||||
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
|
|
||||||
};
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
var contentType = MimeUtility.GetMimeMapping(name);
|
|
||||||
return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? BuildKey(Func<string, string> builder, string? name)
|
|
||||||
=> string.IsNullOrEmpty(name) ? null : builder(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
@@ -23,29 +21,19 @@ namespace Musify.Application.Albums
|
|||||||
{
|
{
|
||||||
var albumExists = await database.Albums
|
var albumExists = await database.Albums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
.Active()
|
||||||
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
|
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
|
||||||
if (!albumExists)
|
if (!albumExists)
|
||||||
return Error.NotFound();
|
return AppErrors.NotFound("Album", request.AlbumId);
|
||||||
|
|
||||||
var tracksQuery = database.AlbumHasTracks
|
return await database.AlbumHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(albumTrack => ((ExternalTrack)albumTrack.Track).TrackArtists)
|
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
|
||||||
.ThenInclude(trackArtist => trackArtist.Artist)
|
|
||||||
.Include(albumTrack => ((LocalTrack)albumTrack.Track).Owner)
|
|
||||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
|
|
||||||
|
|
||||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
|
||||||
|
|
||||||
var pagedEntities = await tracksQuery
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||||
.Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count })
|
.ThenBy(albumTrack => albumTrack.Id)
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
.Select(albumTrack => albumTrack.Track)
|
||||||
|
.SelectResponse()
|
||||||
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
pagedEntities.Select(entry => TrackApplicationResponse.FromEntity(entry.Track, entry.ListensCount)),
|
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
|
||||||
|
|
||||||
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,113 +1,34 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize, string? YoutubeContinuationToken = null)
|
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
|
||||||
: IQuery<ErrorOr<AlbumsSearchResponse>>;
|
: IQuery<PaginatedResponse<AlbumApplicationResponse>>;
|
||||||
|
|
||||||
public class GetAlbumsQueryHandler(
|
public class GetAlbumsQueryHandler(IDatabase database)
|
||||||
IDatabase database,
|
: IQueryHandler<GetAlbumsQuery, PaginatedResponse<AlbumApplicationResponse>>
|
||||||
IYouTubeMusicService youTubeMusicService,
|
|
||||||
ILogger<GetAlbumsQueryHandler> logger)
|
|
||||||
: IQueryHandler<GetAlbumsQuery, ErrorOr<AlbumsSearchResponse>>
|
|
||||||
{
|
{
|
||||||
// Mirrors GetTracksQueryHandler's threshold: a title shorter than this is too likely to
|
public async ValueTask<PaginatedResponse<AlbumApplicationResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
|
||||||
// still be mid-keystroke to justify a live call to the external search API.
|
|
||||||
private const int MinTitleLengthForYouTubeSearch = 2;
|
|
||||||
|
|
||||||
public async ValueTask<ErrorOr<AlbumsSearchResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
var albumsQuery = database.Albums
|
var albumsQuery = database.Albums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AsQueryable();
|
.Active();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Title))
|
if (!string.IsNullOrWhiteSpace(request.Title))
|
||||||
{
|
{
|
||||||
var normalizedTitle = request.Title.Trim().ToUpperInvariant();
|
var normalizedTitle = TextNormalizer.Normalize(request.Title);
|
||||||
albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle));
|
albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle));
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalCount = await albumsQuery.CountAsync(cancellationToken);
|
return await albumsQuery
|
||||||
|
|
||||||
var pagedEntities = await albumsQuery
|
|
||||||
.OrderByDescending(album => album.CreatedAt)
|
.OrderByDescending(album => album.CreatedAt)
|
||||||
.Select(album => new
|
.ThenBy(album => album.Id)
|
||||||
{
|
.SelectResponse()
|
||||||
Album = album,
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
TrackCount = album.AlbumTracks.Count,
|
|
||||||
CoverTrackIds = album.AlbumTracks
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
|
||||||
|
|
||||||
var localItems = pagedEntities
|
|
||||||
.Select(entry => AlbumSearchItemResponse.FromAlbum(
|
|
||||||
AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var knownExternalIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
foreach (var entry in pagedEntities)
|
|
||||||
{
|
|
||||||
if (entry.Album is ExternalAlbum { Source: TrackSource.YouTube } external)
|
|
||||||
knownExternalIds.Add(external.ExternalId);
|
|
||||||
}
|
|
||||||
|
|
||||||
var youtubeItems = new List<AlbumSearchItemResponse>();
|
|
||||||
string? nextContinuationToken = null;
|
|
||||||
var youtubeUnavailable = false;
|
|
||||||
|
|
||||||
if (request.Title?.Trim().Length >= MinTitleLengthForYouTubeSearch)
|
|
||||||
(youtubeItems, nextContinuationToken, youtubeUnavailable) =
|
|
||||||
await SearchYouTubeAsync(request.Title, request.YoutubeContinuationToken, knownExternalIds, cancellationToken);
|
|
||||||
|
|
||||||
return AlbumsSearchResponse.Create(
|
|
||||||
[.. localItems, .. youtubeItems],
|
|
||||||
pagedEntities.PageNumber,
|
|
||||||
pagedEntities.PageSize,
|
|
||||||
pagedEntities.PageCount,
|
|
||||||
pagedEntities.TotalItemCount,
|
|
||||||
pagedEntities.HasPreviousPage,
|
|
||||||
pagedEntities.HasNextPage,
|
|
||||||
nextContinuationToken,
|
|
||||||
youtubeUnavailable);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<(List<AlbumSearchItemResponse> Items, string? NextContinuationToken, bool Unavailable)> SearchYouTubeAsync(
|
|
||||||
string title,
|
|
||||||
string? continuationToken,
|
|
||||||
HashSet<string> knownExternalIds,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var searchResult = await youTubeMusicService.SearchAlbumsAsync(title, continuationToken ?? string.Empty, cancellationToken);
|
|
||||||
if (searchResult.IsError)
|
|
||||||
{
|
|
||||||
logger.LogInformation(
|
|
||||||
"YouTube album search unavailable while combining album results: {Error}", searchResult.FirstError.Description);
|
|
||||||
return ([], null, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
var items = searchResult.Value.Items
|
|
||||||
.Where(album => knownExternalIds.Add(album.AlbumId))
|
|
||||||
.Select(AlbumSearchItemResponse.FromYouTubeAlbum)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var nextToken = string.IsNullOrEmpty(searchResult.Value.ContinuationToken)
|
|
||||||
? null
|
|
||||||
: searchResult.Value.ContinuationToken;
|
|
||||||
|
|
||||||
return (items, nextToken, false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
@@ -21,33 +19,16 @@ namespace Musify.Application.Albums
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
|
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
|
||||||
if (!userExists)
|
if (!userExists)
|
||||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
return AppErrors.NotFound("User", request.UserId);
|
||||||
|
|
||||||
var albumsQuery = database.UserAlbums
|
return await database.Albums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(album => album.OwnerUserId == request.UserId);
|
.Active()
|
||||||
|
.Where(album => album.OwnerUserId == request.UserId)
|
||||||
var totalCount = await albumsQuery.CountAsync(cancellationToken);
|
|
||||||
|
|
||||||
var pagedEntities = await albumsQuery
|
|
||||||
.OrderByDescending(album => album.CreatedAt)
|
.OrderByDescending(album => album.CreatedAt)
|
||||||
.Select(album => new
|
.ThenBy(album => album.Id)
|
||||||
{
|
.SelectResponse()
|
||||||
Album = album,
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
TrackCount = album.AlbumTracks.Count,
|
|
||||||
CoverTrackIds = album.AlbumTracks
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
|
||||||
|
|
||||||
var pagedAlbums = new StaticPagedList<AlbumApplicationResponse>(
|
|
||||||
pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)),
|
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
|
||||||
|
|
||||||
return PaginatedResponse<AlbumApplicationResponse>.FromPagedList(pagedAlbums);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
@@ -7,54 +6,46 @@ using Musify.Application.Contracts;
|
|||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
|
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
|
||||||
: IQuery<ErrorOr<IReadOnlyList<AlbumApplicationResponse> >>;
|
: IQuery<IReadOnlyList<AlbumApplicationResponse>>;
|
||||||
|
|
||||||
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
|
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetRecentlyListenedAlbumsQuery, ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>
|
: IQueryHandler<GetRecentlyListenedAlbumsQuery, IReadOnlyList<AlbumApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
|
public async ValueTask<IReadOnlyList<AlbumApplicationResponse>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var listenedTrackIds = database.ListeningHistories
|
var listens = database.ListeningHistories
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(history => history.UserId == request.UserId);
|
.Where(history => history.UserId == request.UserId && history.IsCounted);
|
||||||
|
|
||||||
var ranked = await database.AlbumHasTracks
|
var ranked = await database.AlbumHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
|
||||||
.Join(
|
.Join(
|
||||||
listenedTrackIds,
|
listens,
|
||||||
albumTrack => albumTrack.TrackId,
|
albumTrack => albumTrack.TrackId,
|
||||||
history => history.TrackId,
|
history => history.TrackId,
|
||||||
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt } )
|
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt })
|
||||||
.GroupBy(entry => entry.AlbumId)
|
.GroupBy(entry => entry.AlbumId)
|
||||||
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) } )
|
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) })
|
||||||
.OrderByDescending(entry => entry.LastListenedAt)
|
.OrderByDescending(entry => entry.LastListenedAt)
|
||||||
|
.ThenBy(entry => entry.AlbumId)
|
||||||
.Take(request.Limit)
|
.Take(request.Limit)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
if (ranked.Count == 0)
|
if (ranked.Count == 0)
|
||||||
return new List<AlbumApplicationResponse>();
|
return [];
|
||||||
|
|
||||||
var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
|
var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
|
||||||
|
|
||||||
var albums = await database.Albums
|
var albumsById = await database.Albums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(album => albumIds.Contains(album.Id))
|
.Where(album => albumIds.Contains(album.Id))
|
||||||
.Select(album => new
|
.SelectResponse()
|
||||||
{
|
.ToDictionaryAsync(album => album.Id, cancellationToken);
|
||||||
Album = album,
|
|
||||||
TrackCount = album.AlbumTracks.Count,
|
|
||||||
CoverTrackIds = album.AlbumTracks
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
|
||||||
.ToList()
|
|
||||||
} )
|
|
||||||
.ToDictionaryAsync(entry => entry.Album.Id, cancellationToken);
|
|
||||||
|
|
||||||
return ranked
|
return ranked
|
||||||
.Where(entry => albums.ContainsKey(entry.AlbumId))
|
.Where(entry => albumsById.ContainsKey(entry.AlbumId))
|
||||||
.Select(entry => albums[entry.AlbumId])
|
.Select(entry => albumsById[entry.AlbumId])
|
||||||
.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds))
|
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
@@ -16,20 +17,9 @@ namespace Musify.Application.Albums
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var album = await database.UserAlbums
|
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||||
.AsNoTracking()
|
if (album.IsError)
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
return album.Errors;
|
||||||
if (album is null)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (album.OwnerUserId != request.UserId)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var albumTracks = await database.AlbumHasTracks
|
var albumTracks = await database.AlbumHasTracks
|
||||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
|
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
|
||||||
@@ -37,11 +27,8 @@ namespace Musify.Application.Albums
|
|||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId);
|
var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId);
|
||||||
if (link is null)
|
if (link == null)
|
||||||
{
|
return Error.NotFound("Album.TrackNotFound", "Track is not in the album.");
|
||||||
logger.LogInformation("Track {TrackId} not in album {AlbumId}", request.TrackId, request.AlbumId);
|
|
||||||
return Error.NotFound(description: "Track is not in the album.");
|
|
||||||
}
|
|
||||||
|
|
||||||
database.AlbumHasTracks.Remove(link);
|
database.AlbumHasTracks.Remove(link);
|
||||||
|
|
||||||
@@ -53,7 +40,7 @@ namespace Musify.Application.Albums
|
|||||||
|
|
||||||
logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId);
|
logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId);
|
||||||
|
|
||||||
return new Success();
|
return Result.Success;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Pictures.Responses;
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Services;
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
@@ -16,90 +12,14 @@ namespace Musify.Application.Albums
|
|||||||
string FileType,
|
string FileType,
|
||||||
string ContentType,
|
string ContentType,
|
||||||
long? ExpectedSizeBytes = null)
|
long? ExpectedSizeBytes = null)
|
||||||
: ICommand<ErrorOr<AlbumPictureUploadResponse>>;
|
: ICommand<ErrorOr<PictureUploadResponse>>;
|
||||||
|
|
||||||
public class RequestAlbumPictureUploadCommandHandler(
|
public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration)
|
||||||
IDatabase database,
|
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<PictureUploadResponse>>
|
||||||
UploadIntentValidator uploadIntentValidator,
|
|
||||||
IStorageService storageService,
|
|
||||||
ILogger<RequestAlbumPictureUploadCommandHandler> logger,
|
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
|
||||||
AlbumConfiguration albumConfiguration,
|
|
||||||
UploadIntentConfiguration uploadIntentConfiguration)
|
|
||||||
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<AlbumPictureUploadResponse>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<AlbumPictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) =>
|
||||||
{
|
await issuer.IssuePictureAsync(
|
||||||
var userExists = await database.Users
|
request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration,
|
||||||
.AsNoTracking()
|
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
|
||||||
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
|
|
||||||
if (!userExists)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} not found", request.UserId);
|
|
||||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var effectiveSizeBytes = request.ExpectedSizeBytes
|
|
||||||
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
|
||||||
|
|
||||||
var objectName = $"{Guid.NewGuid() }.{request.FileType.TrimStart('.').ToLowerInvariant() }";
|
|
||||||
|
|
||||||
var tempKey = albumConfiguration.Routes.BuildTempPicturePath(
|
|
||||||
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var uploadUrl = await storageService.GetUploadUrlAsync(
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
tempKey,
|
|
||||||
request.ContentType,
|
|
||||||
TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
await using var transaction = await database.BeginTransactionAsync(
|
|
||||||
System.Data.IsolationLevel.Serializable, cancellationToken);
|
|
||||||
|
|
||||||
var quotaCheck = await uploadIntentValidator.CheckQuotaAsync(
|
|
||||||
uploadIntentConfiguration,
|
|
||||||
request.UserId, effectiveSizeBytes, 1, cancellationToken);
|
|
||||||
if (quotaCheck.IsError)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
|
|
||||||
return quotaCheck.Errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
var intent = new UploadIntent
|
|
||||||
{
|
|
||||||
UserId = request.UserId,
|
|
||||||
Bucket = storageConfiguration.Bucket,
|
|
||||||
Key = tempKey,
|
|
||||||
ObjectName = objectName,
|
|
||||||
ContentType = request.ContentType,
|
|
||||||
ExpectedSizeBytes = effectiveSizeBytes,
|
|
||||||
Purpose = UploadIntentPurpose.AlbumPicture,
|
|
||||||
ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
|
|
||||||
};
|
|
||||||
|
|
||||||
await database.UploadIntents.AddAsync(intent, cancellationToken);
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
await transaction.CommitAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (AlbumPicture, temp key: {Key} )", intent.Id, request.UserId, tempKey);
|
|
||||||
|
|
||||||
return new AlbumPictureUploadResponse(
|
|
||||||
intent.Id,
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
tempKey,
|
|
||||||
objectName,
|
|
||||||
request.ContentType,
|
|
||||||
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
|
|
||||||
uploadUrl);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to generate album picture upload URL for user {UserId}", request.UserId);
|
|
||||||
return Error.Failure(description: "Failed to generate upload URL");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
namespace Musify.Application.Albums.Responses
|
namespace Musify.Application.Albums.Responses
|
||||||
{
|
{
|
||||||
// [JsonPolymorphic]/[JsonDerivedType] are required here, not optional: without them
|
|
||||||
// System.Text.Json serializes strictly by the declared CLR type at each call site
|
|
||||||
// (AlbumApplicationResponse, both as a bare return type and nested inside
|
|
||||||
// AlbumSearchItemResponse.Album), so an ExternalAlbumApplicationResponse instance would
|
|
||||||
// silently lose ExternalId/ThumbnailUrl on the wire. This also gives every response a
|
|
||||||
// "source" discriminator field for free, matching AlbumSearchItemResponse.Source.
|
|
||||||
[JsonPolymorphic(TypeDiscriminatorPropertyName = "source")]
|
|
||||||
[JsonDerivedType(typeof(AlbumApplicationResponse), typeDiscriminator: "Local")]
|
|
||||||
[JsonDerivedType(typeof(ExternalAlbumApplicationResponse), typeDiscriminator: "YouTube")]
|
|
||||||
public record AlbumApplicationResponse(
|
public record AlbumApplicationResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Title,
|
string Title,
|
||||||
@@ -26,10 +16,10 @@ namespace Musify.Application.Albums.Responses
|
|||||||
DateTime UpdatedAt,
|
DateTime UpdatedAt,
|
||||||
IReadOnlyList<Guid> CoverTrackIds)
|
IReadOnlyList<Guid> CoverTrackIds)
|
||||||
{
|
{
|
||||||
public const int CoverTrackCount = 4;
|
public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
|
||||||
|
|
||||||
public static AlbumApplicationResponse FromEntity(
|
public static AlbumApplicationResponse FromEntity(
|
||||||
UserAlbum album,
|
Album album,
|
||||||
int trackCount,
|
int trackCount,
|
||||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
IReadOnlyList<Guid>? coverTrackIds = null)
|
||||||
{
|
{
|
||||||
@@ -47,18 +37,5 @@ namespace Musify.Application.Albums.Responses
|
|||||||
album.UpdatedAt,
|
album.UpdatedAt,
|
||||||
coverTrackIds ?? []);
|
coverTrackIds ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static AlbumApplicationResponse FromEntity(
|
|
||||||
Album album,
|
|
||||||
int trackCount,
|
|
||||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
|
||||||
{
|
|
||||||
return album switch
|
|
||||||
{
|
|
||||||
ExternalAlbum externalAlbum => ExternalAlbumApplicationResponse.FromEntity(externalAlbum, trackCount, coverTrackIds),
|
|
||||||
UserAlbum userAlbum => FromEntity(userAlbum, trackCount, coverTrackIds),
|
|
||||||
_ => throw new NotSupportedException($"Unsupported album entity type '{album.GetType().Name}'.")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Albums.Responses
|
||||||
|
{
|
||||||
|
public static class AlbumProjections
|
||||||
|
{
|
||||||
|
public const int CoverTrackCount = 4;
|
||||||
|
|
||||||
|
public static IQueryable<Album> Active(this IQueryable<Album> query) =>
|
||||||
|
query.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active);
|
||||||
|
|
||||||
|
public static IQueryable<AlbumApplicationResponse> SelectResponse(this IQueryable<Album> query) =>
|
||||||
|
query.Select(album => new AlbumApplicationResponse(
|
||||||
|
album.Id,
|
||||||
|
album.Title,
|
||||||
|
album.Description,
|
||||||
|
album.ReleaseYear,
|
||||||
|
album.OwnerUserId,
|
||||||
|
album.AlbumTracks.Count,
|
||||||
|
album.Pictures!.SmallName,
|
||||||
|
album.Pictures!.MediumName,
|
||||||
|
album.Pictures!.LargeName,
|
||||||
|
album.CreatedAt,
|
||||||
|
album.UpdatedAt,
|
||||||
|
album.AlbumTracks
|
||||||
|
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||||
|
.Take(CoverTrackCount)
|
||||||
|
.Select(albumTrack => albumTrack.TrackId)
|
||||||
|
.ToList()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums.Responses
|
|
||||||
{
|
|
||||||
/// <summary>One item of a combined local/external album listing. Exactly one of
|
|
||||||
/// <see cref="Album"/> or <see cref="YouTubeAlbum"/> is populated: <see cref="Album"/> for
|
|
||||||
/// anything already in the catalog (locally created albums and external albums materialized
|
|
||||||
/// earlier), <see cref="YouTubeAlbum"/> for a live external search hit that hasn't been
|
|
||||||
/// materialized yet, so it has no album id.</summary>
|
|
||||||
public record AlbumSearchItemResponse(
|
|
||||||
TrackSource Source,
|
|
||||||
AlbumApplicationResponse? Album,
|
|
||||||
YouTubeAlbumResult? YouTubeAlbum)
|
|
||||||
{
|
|
||||||
public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) =>
|
|
||||||
new(album is ExternalAlbumApplicationResponse ? TrackSource.YouTube : TrackSource.Local, album, null);
|
|
||||||
|
|
||||||
public static AlbumSearchItemResponse FromYouTubeAlbum(YouTubeAlbumResult album) =>
|
|
||||||
new(TrackSource.YouTube, null, album);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
namespace Musify.Application.Albums.Responses
|
|
||||||
{
|
|
||||||
/// <summary>Result of a combined albums listing/search: a page of local catalog albums
|
|
||||||
/// (user-created and previously materialized external albums), plus, when a search term was
|
|
||||||
/// given, live external hits appended after them. Pagination metadata (<see cref="PageNumber"/>,
|
|
||||||
/// <see cref="PageSize"/>, <see cref="PageCount"/>, <see cref="TotalItemCount"/>) describes only
|
|
||||||
/// the local page — the external source has no stable total, so its continuation is tracked
|
|
||||||
/// separately via <see cref="NextYoutubeContinuationToken"/>.</summary>
|
|
||||||
public record AlbumsSearchResponse(
|
|
||||||
IReadOnlyList<AlbumSearchItemResponse> Items,
|
|
||||||
int PageNumber,
|
|
||||||
int PageSize,
|
|
||||||
int PageCount,
|
|
||||||
int TotalItemCount,
|
|
||||||
bool HasPreviousPage,
|
|
||||||
bool HasNextLocalPage,
|
|
||||||
string? NextYoutubeContinuationToken,
|
|
||||||
bool YoutubeUnavailable,
|
|
||||||
bool HasNextPage)
|
|
||||||
{
|
|
||||||
// HasNextPage is a real field (not a computed property) so it shows up as a required member
|
|
||||||
// of the generated OpenAPI schema, same as every other field here — a get-only property added
|
|
||||||
// outside the primary constructor gets reflected as optional instead, which is never true here.
|
|
||||||
public static AlbumsSearchResponse Create(
|
|
||||||
IReadOnlyList<AlbumSearchItemResponse> items,
|
|
||||||
int pageNumber,
|
|
||||||
int pageSize,
|
|
||||||
int pageCount,
|
|
||||||
int totalItemCount,
|
|
||||||
bool hasPreviousPage,
|
|
||||||
bool hasNextLocalPage,
|
|
||||||
string? nextYoutubeContinuationToken,
|
|
||||||
bool youtubeUnavailable) =>
|
|
||||||
new(items, pageNumber, pageSize, pageCount, totalItemCount, hasPreviousPage, hasNextLocalPage,
|
|
||||||
nextYoutubeContinuationToken, youtubeUnavailable,
|
|
||||||
HasNextPage: hasNextLocalPage || !string.IsNullOrEmpty(nextYoutubeContinuationToken));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums.Responses
|
|
||||||
{
|
|
||||||
public record ExternalAlbumApplicationResponse(
|
|
||||||
Guid Id,
|
|
||||||
string Title,
|
|
||||||
string? Description,
|
|
||||||
int? ReleaseYear,
|
|
||||||
long OwnerUserId,
|
|
||||||
int TrackCount,
|
|
||||||
string? SmallImageKeyName,
|
|
||||||
string? MediumImageKeyName,
|
|
||||||
string? LargeImageKeyName,
|
|
||||||
DateTime CreatedAt,
|
|
||||||
DateTime UpdatedAt,
|
|
||||||
IReadOnlyList<Guid> CoverTrackIds,
|
|
||||||
string ExternalId,
|
|
||||||
string? ThumbnailUrl)
|
|
||||||
: AlbumApplicationResponse(
|
|
||||||
Id, Title, Description, ReleaseYear, OwnerUserId, TrackCount,
|
|
||||||
SmallImageKeyName, MediumImageKeyName, LargeImageKeyName,
|
|
||||||
CreatedAt, UpdatedAt, CoverTrackIds)
|
|
||||||
{
|
|
||||||
public static ExternalAlbumApplicationResponse FromEntity(
|
|
||||||
ExternalAlbum album,
|
|
||||||
int trackCount,
|
|
||||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
|
||||||
{
|
|
||||||
return new ExternalAlbumApplicationResponse(
|
|
||||||
album.Id,
|
|
||||||
album.Title,
|
|
||||||
album.Description,
|
|
||||||
album.ReleaseYear,
|
|
||||||
0,
|
|
||||||
trackCount,
|
|
||||||
album.Pictures?.SmallName,
|
|
||||||
album.Pictures?.MediumName,
|
|
||||||
album.Pictures?.LargeName,
|
|
||||||
album.CreatedAt,
|
|
||||||
album.UpdatedAt,
|
|
||||||
coverTrackIds ?? [],
|
|
||||||
album.ExternalId,
|
|
||||||
album.ThumbnailUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,14 @@ using ErrorOr;
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Events;
|
||||||
|
using Musify.Application.Pictures;
|
||||||
|
using Musify.Application.Services;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
@@ -27,126 +27,48 @@ namespace Musify.Application.Albums
|
|||||||
IDatabase database,
|
IDatabase database,
|
||||||
UploadIntentValidator uploadIntentValidator,
|
UploadIntentValidator uploadIntentValidator,
|
||||||
ILogger<UpdateAlbumCommandHandler> logger,
|
ILogger<UpdateAlbumCommandHandler> logger,
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
AlbumConfiguration albumConfiguration)
|
||||||
AlbumConfiguration albumConfiguration,
|
|
||||||
UploadIntentConfiguration uploadIntentConfiguration)
|
|
||||||
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var album = await database.UserAlbums
|
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
if (found.IsError)
|
||||||
if (album is null)
|
return found.Errors;
|
||||||
{
|
|
||||||
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (album.OwnerUserId != request.UserId)
|
|
||||||
{
|
|
||||||
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
var album = found.Value;
|
||||||
var title = request.Title.Trim();
|
var title = request.Title.Trim();
|
||||||
|
|
||||||
album.Title = title;
|
album.Title = title;
|
||||||
album.NormalizedTitle = title.ToUpperInvariant();
|
album.NormalizedTitle = TextNormalizer.Normalize(title);
|
||||||
album.Description = request.Description;
|
album.Description = request.Description;
|
||||||
album.ReleaseYear = request.ReleaseYear;
|
album.ReleaseYear = request.ReleaseYear;
|
||||||
|
|
||||||
UploadIntent? pictureIntent = null;
|
if (request.NewPictureIntentId is { } intentId)
|
||||||
string? finalPictureKey = null;
|
|
||||||
|
|
||||||
if (request.NewPictureIntentId.HasValue)
|
|
||||||
{
|
{
|
||||||
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
var prepared = await PictureSourceChange.PrepareAsync(
|
||||||
uploadIntentConfiguration,
|
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
|
||||||
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
|
if (prepared.IsError)
|
||||||
if (validation.IsError)
|
return prepared.Errors;
|
||||||
return validation.Errors;
|
|
||||||
|
|
||||||
pictureIntent = validation.Value;
|
var picture = prepared.Value;
|
||||||
album.Pictures = new AlbumPictures
|
album.Pictures = picture.Pictures;
|
||||||
{
|
|
||||||
OriginalName = pictureIntent.ObjectName,
|
|
||||||
SmallName = pictureIntent.ObjectName,
|
|
||||||
MediumName = pictureIntent.ObjectName,
|
|
||||||
LargeName = pictureIntent.ObjectName
|
|
||||||
};
|
|
||||||
finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
|
||||||
}
|
|
||||||
|
|
||||||
database.UserAlbums.Update(album);
|
|
||||||
|
|
||||||
if (pictureIntent != null && finalPictureKey != null)
|
|
||||||
{
|
|
||||||
var publishResult = await PublishUpdateAlbumPictureSourceEventAsync(
|
|
||||||
album.Id, pictureIntent, finalPictureKey, cancellationToken);
|
|
||||||
if (publishResult.IsError)
|
|
||||||
return publishResult.Errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to update album {AlbumId}", request.AlbumId);
|
|
||||||
return Error.Failure(description: $"Failed to update album {request.AlbumId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var albumTracks = database.AlbumHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(albumTrack => albumTrack.AlbumId == album.Id);
|
|
||||||
|
|
||||||
var trackCount = await albumTracks.CountAsync(cancellationToken);
|
|
||||||
var coverTrackIds = await albumTracks
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Updated album {AlbumId}", album.Id);
|
|
||||||
return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<ErrorOr<Success>> PublishUpdateAlbumPictureSourceEventAsync(
|
|
||||||
Guid albumId,
|
|
||||||
UploadIntent pictureIntent,
|
|
||||||
string finalPictureKey,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await eventBus.PublishAsync(
|
await eventBus.PublishAsync(
|
||||||
new UpdateAlbumPictureSourceEvent(
|
new UpdateAlbumPictureSourceEvent(
|
||||||
albumId,
|
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||||
pictureIntent.Id,
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
pictureIntent.Key,
|
|
||||||
finalPictureKey,
|
|
||||||
new ImageSize(
|
|
||||||
albumConfiguration.Routes.SmallPicturesPath,
|
|
||||||
albumConfiguration.PicturesSizes.SmallPictureWidth,
|
|
||||||
albumConfiguration.PicturesSizes.SmallPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
albumConfiguration.Routes.MediumPicturesPath,
|
|
||||||
albumConfiguration.PicturesSizes.MediumPictureWidth,
|
|
||||||
albumConfiguration.PicturesSizes.MediumPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
albumConfiguration.Routes.LargePicturesPath,
|
|
||||||
albumConfiguration.PicturesSizes.LargePictureWidth,
|
|
||||||
albumConfiguration.PicturesSizes.LargePictureHeight)),
|
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to publish update album picture source event for album {AlbumId}", albumId);
|
|
||||||
return Error.Failure(description: $"Failed to publish update album picture source event for album {albumId}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Updated album {AlbumId}", album.Id);
|
||||||
|
|
||||||
|
return await database.Albums
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(a => a.Id == album.Id)
|
||||||
|
.SelectResponse()
|
||||||
|
.SingleAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,6 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
||||||
<PackageReference Include="MimeMapping" />
|
<PackageReference Include="MimeMapping" />
|
||||||
<PackageReference Include="X.PagedList" />
|
|
||||||
<PackageReference Include="X.PagedList.EF" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -3,26 +3,27 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Services;
|
||||||
|
|
||||||
namespace Musify.Application;
|
namespace Musify.Application
|
||||||
|
|
||||||
public static class ApplicationDependencyInjection
|
|
||||||
{
|
{
|
||||||
public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration)
|
public static class ApplicationDependencyInjection
|
||||||
{
|
{
|
||||||
services.AddMediator();
|
public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration)
|
||||||
services.AddScoped<UploadIntentValidator>();
|
{
|
||||||
services.AddScoped<TrackStreamIssuer>();
|
services.AddMediator();
|
||||||
services.AddScoped<YouTubeTrackProvisioner>();
|
services.AddScoped<UploadIntentValidator>();
|
||||||
|
services.AddScoped<UploadIntentIssuer>();
|
||||||
|
services.AddScoped<TrackStreamIssuer>();
|
||||||
|
|
||||||
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
|
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration);
|
||||||
services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName);
|
services.AddValidatedOptions<PlayListConfiguration>(configuration);
|
||||||
services.AddValidatedOptions<AlbumConfiguration>(configuration, AlbumConfiguration.SectionName);
|
services.AddValidatedOptions<AlbumConfiguration>(configuration);
|
||||||
services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfiguration.SectionName);
|
services.AddValidatedOptions<TrackConfiguration>(configuration);
|
||||||
services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName);
|
services.AddValidatedOptions<MixConfiguration>(configuration);
|
||||||
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration, StreamGatewayConfiguration.SectionName);
|
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration);
|
||||||
services.AddValidatedOptions<UploadIntentConfiguration>(configuration, UploadIntentConfiguration.SectionName);
|
services.AddValidatedOptions<UploadIntentConfiguration>(configuration);
|
||||||
services.AddValidatedOptions<PlaybackConfiguration>(configuration, PlaybackConfiguration.SectionName);
|
services.AddValidatedOptions<PlaybackConfiguration>(configuration);
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
[assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)]
|
[assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)]
|
||||||
|
|||||||
@@ -1,79 +1,15 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.Configuration
|
namespace Musify.Application.Configuration
|
||||||
{
|
{
|
||||||
public class AlbumConfiguration
|
public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
|
||||||
{
|
{
|
||||||
public const string SectionName = "Album";
|
public static string SectionName => "Album";
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public AlbumRoutes Routes { get; set; } = new AlbumRoutes();
|
public PictureRoutes Routes { get; set; } = new() { ParentFolder = "Albums" };
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public AlbumPicturesSizes PicturesSizes { get; set; } = new AlbumPicturesSizes();
|
public PictureSizes PicturesSizes { get; set; } = new();
|
||||||
}
|
|
||||||
|
|
||||||
public class AlbumRoutes
|
|
||||||
{
|
|
||||||
[Required]
|
|
||||||
public string UploadsFolder { get; set; } = "uploads";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string ParentFolders { get; set; } = "Albums";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string SmallPicturesFolder { get; set; } = "SmallPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string MediumPicturesFolder { get; set; } = "MediumPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string LargePicturesFolder { get; set; } = "LargePictures";
|
|
||||||
|
|
||||||
public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
|
|
||||||
|
|
||||||
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
|
|
||||||
|
|
||||||
public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder);
|
|
||||||
|
|
||||||
public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
|
|
||||||
|
|
||||||
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
|
|
||||||
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class AlbumPicturesSizes
|
|
||||||
{
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int SmallPictureWidth { get; set; } = 128;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int SmallPictureHeight { get; set; } = 128;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int MediumPictureWidth { get; set; } = 256;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int MediumPictureHeight { get; set; } = 256;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int LargePictureWidth { get; set; } = 512;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int LargePictureHeight { get; set; } = 512;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
|
|
||||||
namespace Musify.Application.Configuration
|
namespace Musify.Application.Configuration
|
||||||
{
|
{
|
||||||
public class ApplicationStorageConfiguration
|
public class ApplicationStorageConfiguration : IConfigurationOptions
|
||||||
{
|
{
|
||||||
public const string SectionName = "ApplicationStorage";
|
public static string SectionName => "ApplicationStorage";
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
[StringLength(128, MinimumLength = 1)]
|
[StringLength(128, MinimumLength = 1)]
|
||||||
|
|||||||
@@ -2,24 +2,29 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace Musify.Application.Configuration;
|
namespace Musify.Application.Configuration
|
||||||
|
|
||||||
public static class ConfigurationDependencyInjection
|
|
||||||
{
|
{
|
||||||
public static IServiceCollection AddValidatedOptions<T>(
|
public static class ConfigurationDependencyInjection
|
||||||
this IServiceCollection services,
|
|
||||||
IConfiguration configuration,
|
|
||||||
string sectionName)
|
|
||||||
where T : class
|
|
||||||
{
|
{
|
||||||
services
|
public static IServiceCollection AddValidatedOptions<T>(this IServiceCollection services, IConfiguration configuration)
|
||||||
.AddOptionsWithValidateOnStart<T>()
|
where T : class, IConfigurationOptions =>
|
||||||
.Bind(configuration.GetRequiredSection(sectionName))
|
services.AddValidatedOptions<T>(configuration, T.SectionName);
|
||||||
.ValidateDataAnnotations();
|
|
||||||
|
|
||||||
services.AddSingleton(serviceProvider =>
|
public static IServiceCollection AddValidatedOptions<T>(
|
||||||
serviceProvider.GetRequiredService<IOptions<T>>().Value);
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration,
|
||||||
|
string sectionName)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
services
|
||||||
|
.AddOptionsWithValidateOnStart<T>()
|
||||||
|
.Bind(configuration.GetRequiredSection(sectionName))
|
||||||
|
.ValidateDataAnnotations();
|
||||||
|
|
||||||
return services;
|
services.AddSingleton(serviceProvider =>
|
||||||
|
serviceProvider.GetRequiredService<IOptions<T>>().Value);
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ using Musify.Application.Configuration.Responses;
|
|||||||
|
|
||||||
namespace Musify.Application.Configuration
|
namespace Musify.Application.Configuration
|
||||||
{
|
{
|
||||||
/// <summary>Reads the parts of <see cref="PlaybackConfiguration"/> that are safe to expose to an
|
|
||||||
/// unauthenticated client, so it can decide whether to gate the app behind sign-in at all.</summary>
|
|
||||||
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
|
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
|
||||||
|
|
||||||
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
|
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Musify.Application.Configuration
|
||||||
|
{
|
||||||
|
public interface IConfigurationOptions
|
||||||
|
{
|
||||||
|
public static abstract string SectionName { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,20 +2,11 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
|
|
||||||
namespace Musify.Application.Configuration
|
namespace Musify.Application.Configuration
|
||||||
{
|
{
|
||||||
public class MixConfiguration
|
public class MixConfiguration : IConfigurationOptions
|
||||||
{
|
{
|
||||||
public const string SectionName = "Mix";
|
public static string SectionName => "Mix";
|
||||||
|
|
||||||
[Range(1, 20)]
|
|
||||||
public int ArtistMixCount { get; set; } = 3;
|
|
||||||
|
|
||||||
[Range(4, 100)]
|
[Range(4, 100)]
|
||||||
public int ItemsPerMix { get; set; } = 12;
|
public int ItemsPerMix { get; set; } = 12;
|
||||||
|
|
||||||
[Range(1, 10)]
|
|
||||||
public int SeedArtistCount { get; set; } = 3;
|
|
||||||
|
|
||||||
[Range(1, 100)]
|
|
||||||
public int YouTubeResultsPerSeed { get; set; } = 12;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Globalization;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
|
namespace Musify.Application.Configuration
|
||||||
|
{
|
||||||
|
public enum PictureSize
|
||||||
|
{
|
||||||
|
Small,
|
||||||
|
Medium,
|
||||||
|
Large
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PictureRoutes
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string UploadsFolder { get; set; } = "uploads";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string ParentFolder { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string SmallPicturesFolder { get; set; } = "SmallPictures";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string MediumPicturesFolder { get; set; } = "MediumPictures";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string LargePicturesFolder { get; set; } = "LargePictures";
|
||||||
|
|
||||||
|
public string FolderPath(PictureSize size) => StorageKey.Combine(ParentFolder, size switch
|
||||||
|
{
|
||||||
|
PictureSize.Small => SmallPicturesFolder,
|
||||||
|
PictureSize.Medium => MediumPicturesFolder,
|
||||||
|
PictureSize.Large => LargePicturesFolder,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(size), size, null)
|
||||||
|
});
|
||||||
|
|
||||||
|
public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName);
|
||||||
|
|
||||||
|
public string BuildOriginalPicturePath(long userId, string pictureName) =>
|
||||||
|
StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalPicturesFolder, pictureName);
|
||||||
|
|
||||||
|
public string BuildTempPath(string tempRootPrefix, long userId, string objectName) =>
|
||||||
|
StorageKey.Combine(tempRootPrefix, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, objectName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PictureSizes
|
||||||
|
{
|
||||||
|
[Range(1, 1024)]
|
||||||
|
public int SmallWidth { get; set; } = 128;
|
||||||
|
|
||||||
|
[Range(1, 1024)]
|
||||||
|
public int SmallHeight { get; set; } = 128;
|
||||||
|
|
||||||
|
[Range(1, 1024)]
|
||||||
|
public int MediumWidth { get; set; } = 256;
|
||||||
|
|
||||||
|
[Range(1, 1024)]
|
||||||
|
public int MediumHeight { get; set; } = 256;
|
||||||
|
|
||||||
|
[Range(1, 1024)]
|
||||||
|
public int LargeWidth { get; set; } = 512;
|
||||||
|
|
||||||
|
[Range(1, 1024)]
|
||||||
|
public int LargeHeight { get; set; } = 512;
|
||||||
|
|
||||||
|
public ImageSizes ToImageSizes(PictureRoutes routes) => new(
|
||||||
|
new ImageSize(routes.FolderPath(PictureSize.Small), SmallWidth, SmallHeight),
|
||||||
|
new ImageSize(routes.FolderPath(PictureSize.Medium), MediumWidth, MediumHeight),
|
||||||
|
new ImageSize(routes.FolderPath(PictureSize.Large), LargeWidth, LargeHeight));
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IPictureOwnerConfiguration
|
||||||
|
{
|
||||||
|
public PictureRoutes Routes { get; }
|
||||||
|
|
||||||
|
public PictureSizes PicturesSizes { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class PictureSizeParser
|
||||||
|
{
|
||||||
|
/// <summary>Parses the <c>size</c> query value; anything unknown means <see cref="PictureSize.Medium"/>.</summary>
|
||||||
|
public static PictureSize Parse(string? value) =>
|
||||||
|
Enum.TryParse<PictureSize>(value, ignoreCase: true, out var size) && Enum.IsDefined(size)
|
||||||
|
? size
|
||||||
|
: PictureSize.Medium;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,101 +1,15 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.Configuration
|
namespace Musify.Application.Configuration
|
||||||
{
|
{
|
||||||
public class PlayListConfiguration
|
public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
|
||||||
{
|
{
|
||||||
public const string SectionName = "PlayList";
|
public static string SectionName => "PlayList";
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public PlayListRoutes Routes { get; set; } = new PlayListRoutes();
|
public PictureRoutes Routes { get; set; } = new() { ParentFolder = "PlayLists" };
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes();
|
public PictureSizes PicturesSizes { get; set; } = new();
|
||||||
|
|
||||||
public bool SeedPresetPictures { get; set; } = true;
|
|
||||||
|
|
||||||
public bool OverwritePresetPictures { get; set; } = false;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
public class PlayListRoutes
|
|
||||||
{
|
|
||||||
[Required]
|
|
||||||
public string UploadsFolder { get; set; } = "uploads";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string ParentFolders { get; set; } = "PlayLists";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string SmallPicturesFolder { get; set; } = "SmallPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string MediumPicturesFolder { get; set; } = "MediumPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string LargePicturesFolder { get; set; } = "LargePictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string PresetLargePicture { get; set; } = "PresetLargePicture.webp";
|
|
||||||
|
|
||||||
public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
|
|
||||||
|
|
||||||
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
|
|
||||||
|
|
||||||
public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder);
|
|
||||||
|
|
||||||
public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
|
|
||||||
|
|
||||||
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolders, SmallPicturesFolder, PresetSmallPicture);
|
|
||||||
|
|
||||||
public string PresetMediumPicturePath => StorageKey.Combine(ParentFolders, MediumPicturesFolder, PresetMediumPicture);
|
|
||||||
|
|
||||||
public string PresetLargePicturePath => StorageKey.Combine(ParentFolders, LargePicturesFolder, PresetLargePicture);
|
|
||||||
|
|
||||||
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
|
|
||||||
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class PlayListPicturesSizes
|
|
||||||
{
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int SmallPictureWidth { get; set; } = 128;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int SmallPictureHeight { get; set; } = 128;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int MediumPictureWidth { get; set; } = 256;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int MediumPictureHeight { get; set; } = 256;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int LargePictureWidth { get; set; } = 512;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int LargePictureHeight { get; set; } = 512;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,28 +2,16 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
|
|
||||||
namespace Musify.Application.Configuration
|
namespace Musify.Application.Configuration
|
||||||
{
|
{
|
||||||
/// <summary>Controls whether listeners without a session can stream music at all, and if so,
|
|
||||||
/// how much of a track they get to hear before being cut off (a Spotify-style preview).</summary>
|
|
||||||
public sealed class PlaybackConfiguration
|
|
||||||
{
|
|
||||||
public const string SectionName = "Playback";
|
|
||||||
|
|
||||||
/// <summary>When false (the default), every stream endpoint requires a signed-in user, exactly
|
public sealed class PlaybackConfiguration : IConfigurationOptions
|
||||||
/// like before this setting existed. When true, an anonymous request is issued a stream ticket
|
{
|
||||||
/// too, capped by <see cref="AnonymousFragmentSeconds"/> if that is greater than zero.</summary>
|
public static string SectionName => "Playback";
|
||||||
|
|
||||||
public bool AllowAnonymousListening { get; set; }
|
public bool AllowAnonymousListening { get; set; }
|
||||||
|
|
||||||
/// <summary>How many seconds of a track an anonymous listener may hear, enforced by the
|
|
||||||
/// Streaming Gateway clamping the byte range it will serve for that ticket. Zero (the default)
|
|
||||||
/// means no cap: anonymous listeners hear the full track, same as a signed-in user. Ignored
|
|
||||||
/// when <see cref="AllowAnonymousListening"/> is false.</summary>
|
|
||||||
[Range(0, 3600)]
|
[Range(0, 3600)]
|
||||||
public int AnonymousFragmentSeconds { get; set; }
|
public int AnonymousFragmentSeconds { get; set; }
|
||||||
|
|
||||||
/// <summary>Bytes per second of the transcoded audio, used to turn <see cref="AnonymousFragmentSeconds"/>
|
|
||||||
/// into a byte cap for the stream ticket. Defaults to the app's own transcoder output (128 kbps
|
|
||||||
/// AAC, <c>AudioTranscoder:Ffmpeg:AudioBitrate</c>) — update this alongside that setting if it
|
|
||||||
/// ever changes, since the two aren't wired together.</summary>
|
|
||||||
[Range(1, int.MaxValue)]
|
[Range(1, int.MaxValue)]
|
||||||
public int EstimatedAudioBytesPerSecond { get; set; } = 16_000;
|
public int EstimatedAudioBytesPerSecond { get; set; } = 16_000;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-3
@@ -1,7 +1,5 @@
|
|||||||
namespace Musify.Application.Configuration.Responses
|
namespace Musify.Application.Configuration.Responses
|
||||||
{
|
{
|
||||||
/// <param name="AllowAnonymousListening">Whether listeners without a session can stream music at all.</param>
|
|
||||||
/// <param name="AnonymousFragmentSeconds">How many seconds of a track an anonymous listener may hear.
|
|
||||||
/// Zero means no cap. Meaningless when <paramref name="AllowAnonymousListening"/> is false.</param>
|
|
||||||
public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds);
|
public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
|
|
||||||
namespace Musify.Application.Configuration
|
namespace Musify.Application.Configuration
|
||||||
{
|
{
|
||||||
public class StreamGatewayConfiguration
|
public class StreamGatewayConfiguration : IConfigurationOptions
|
||||||
{
|
{
|
||||||
public const string SectionName = "StreamGateway";
|
public static string SectionName => "StreamGateway";
|
||||||
|
|
||||||
[Url]
|
[Url]
|
||||||
[Required]
|
[Required]
|
||||||
|
|||||||
@@ -1,38 +1,28 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Globalization;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.Configuration
|
namespace Musify.Application.Configuration
|
||||||
{
|
{
|
||||||
public class TrackConfiguration
|
public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
|
||||||
{
|
{
|
||||||
public const string SectionName = "Track";
|
public static string SectionName => "Track";
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public TrackRoutes Routes { get; set; } = new TrackRoutes();
|
public TrackRoutes Routes { get; set; } = new();
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public TrackPicturesSizes PicturesSizes { get; set; } = new TrackPicturesSizes();
|
public PictureSizes PicturesSizes { get; set; } = new();
|
||||||
|
|
||||||
|
PictureRoutes IPictureOwnerConfiguration.Routes => Routes;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TrackRoutes
|
public class TrackRoutes : PictureRoutes
|
||||||
{
|
{
|
||||||
[Required]
|
public TrackRoutes()
|
||||||
public string UploadsFolder { get; set; } = "uploads";
|
{
|
||||||
|
ParentFolder = "Tracks";
|
||||||
[Required]
|
}
|
||||||
public string ParentFolder { get; set; } = "Tracks";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string SmallPicturesFolder { get; set; } = "SmallPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string MediumPicturesFolder { get; set; } = "MediumPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string LargePicturesFolder { get; set; } = "LargePictures";
|
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
|
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
|
||||||
@@ -50,13 +40,7 @@ namespace Musify.Application.Configuration
|
|||||||
public string OriginalAudiosFolder { get; set; } = "OriginalAudios";
|
public string OriginalAudiosFolder { get; set; } = "OriginalAudios";
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string ProcessedAudioFolder { get; set; } = "ProcessedAudios";
|
public string ProcessedAudiosFolder { get; set; } = "ProcessedAudios";
|
||||||
|
|
||||||
public string SmallPicturesPath => StorageKey.Combine(ParentFolder, SmallPicturesFolder);
|
|
||||||
|
|
||||||
public string MediumPicturesPath => StorageKey.Combine(ParentFolder, MediumPicturesFolder);
|
|
||||||
|
|
||||||
public string LargePicturesPath => StorageKey.Combine(ParentFolder, LargePicturesFolder);
|
|
||||||
|
|
||||||
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture);
|
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture);
|
||||||
|
|
||||||
@@ -64,53 +48,10 @@ namespace Musify.Application.Configuration
|
|||||||
|
|
||||||
public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture);
|
public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture);
|
||||||
|
|
||||||
public string OriginalPicturesPath => StorageKey.Combine(ParentFolder, OriginalPicturesFolder);
|
public string BuildOriginalAudioPath(long userId, string audioName) =>
|
||||||
|
StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalAudiosFolder, audioName);
|
||||||
|
|
||||||
public string OriginalAudiosPath => StorageKey.Combine(ParentFolder, OriginalAudiosFolder);
|
public string BuildProcessedAudioPath(string folderName) =>
|
||||||
|
StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName);
|
||||||
public string ProcessedAudiosPath => StorageKey.Combine(ParentFolder, ProcessedAudioFolder);
|
|
||||||
|
|
||||||
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(OriginalPicturesPath, pictureName);
|
|
||||||
|
|
||||||
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalPicturesPath, pictureName);
|
|
||||||
|
|
||||||
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(SmallPicturesPath, pictureName);
|
|
||||||
|
|
||||||
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(MediumPicturesPath, pictureName);
|
|
||||||
|
|
||||||
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(LargePicturesPath, pictureName);
|
|
||||||
|
|
||||||
public string BuildOriginalAudioPath(string audioName) => StorageKey.Combine(OriginalAudiosPath, audioName);
|
|
||||||
|
|
||||||
public string BuildOriginalAudioPath(long userId, string audioName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalAudiosPath, audioName);
|
|
||||||
|
|
||||||
public string BuildProcessedAudioPath(string audioName) => StorageKey.Combine(ProcessedAudiosPath, audioName);
|
|
||||||
|
|
||||||
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
|
|
||||||
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
|
|
||||||
|
|
||||||
public string BuildTempAudioPath(string tempRootPrefix, long userId, string objectName) =>
|
|
||||||
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
public class TrackPicturesSizes
|
|
||||||
{
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int SmallPictureWidth { get; set; } = 128;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int SmallPictureHeight { get; set; } = 128;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int MediumPictureWidth { get; set; } = 256;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int MediumPictureHeight { get; set; } = 256;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int LargePictureWidth { get; set; } = 512;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int LargePictureHeight { get; set; } = 512;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
|
|
||||||
namespace Musify.Application.Configuration
|
namespace Musify.Application.Configuration
|
||||||
{
|
{
|
||||||
public class UploadIntentConfiguration
|
public class UploadIntentConfiguration : IConfigurationOptions
|
||||||
{
|
{
|
||||||
public const string SectionName = "UploadIntent";
|
public static string SectionName => "UploadIntent";
|
||||||
|
|
||||||
[Range(10, 3600)]
|
[Range(10, 3600)]
|
||||||
public int UploadUrlExpiresInSeconds { get; set; } = 120;
|
public int UploadUrlExpiresInSeconds { get; set; } = 120;
|
||||||
@@ -31,12 +31,12 @@ namespace Musify.Application.Configuration
|
|||||||
public int ExpiredIntentsRetentionDays { get; set; } = 7;
|
public int ExpiredIntentsRetentionDays { get; set; } = 7;
|
||||||
|
|
||||||
[Required, MinLength(1)]
|
[Required, MinLength(1)]
|
||||||
public string TemporalRootPrefix { get; set; } = "temporal";
|
public string TempRootPrefix { get; set; } = "temp";
|
||||||
|
|
||||||
[Range(1, 30)]
|
[Range(1, 30)]
|
||||||
public int TemporalUploadsRetentionDays { get; set; } = 3;
|
public int TempUploadsRetentionDays { get; set; } = 3;
|
||||||
|
|
||||||
[Range(60, 86400)]
|
[Range(60, 86400)]
|
||||||
public int TemporalCleanUpJobIntervalSeconds { get; set; } = 3600;
|
public int TempCleanupJobIntervalSeconds { get; set; } = 3600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ namespace Musify.Application.Contracts
|
|||||||
{
|
{
|
||||||
public interface IAudioTranscoderService
|
public interface IAudioTranscoderService
|
||||||
{
|
{
|
||||||
Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
|
public Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<bool> IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ErrorOr;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
@@ -5,44 +6,38 @@ namespace Musify.Application.Contracts
|
|||||||
{
|
{
|
||||||
public interface IDatabase
|
public interface IDatabase
|
||||||
{
|
{
|
||||||
DbSet<User> Users { get; }
|
public DbSet<User> Users { get; }
|
||||||
|
|
||||||
DbSet<PlayList> PlayLists { get; }
|
public DbSet<PlayList> PlayLists { get; }
|
||||||
|
|
||||||
DbSet<Album> Albums { get; }
|
public DbSet<Album> Albums { get; }
|
||||||
|
|
||||||
DbSet<UserAlbum> UserAlbums { get; }
|
public DbSet<AlbumHasTrack> AlbumHasTracks { get; }
|
||||||
|
|
||||||
DbSet<ExternalAlbum> ExternalAlbums { get; }
|
public DbSet<Track> Tracks { get; }
|
||||||
|
|
||||||
DbSet<AlbumHasTrack> AlbumHasTracks { get; }
|
public DbSet<PlayListHasTrack> PlayListHasTracks { get; }
|
||||||
|
|
||||||
DbSet<Track> Tracks { get; }
|
public DbSet<Mix> Mixes { get; }
|
||||||
|
|
||||||
DbSet<LocalTrack> LocalTracks { get; }
|
public DbSet<MixItem> MixItems { get; }
|
||||||
|
|
||||||
DbSet<ExternalTrack> ExternalTracks { get; }
|
public DbSet<UploadIntent> UploadIntents { get; }
|
||||||
|
|
||||||
DbSet<Artist> Artists { get; }
|
public DbSet<ListeningHistory> ListeningHistories { get; }
|
||||||
|
|
||||||
DbSet<TrackArtist> TrackArtists { get; }
|
public DbSet<TrackLike> TrackLikes { get; }
|
||||||
|
|
||||||
DbSet<UserHasTrack> UserHasTracks { get; }
|
public DbSet<TrackTag> TrackTags { get; }
|
||||||
|
|
||||||
DbSet<PlayListHasTrack> PlayListHasTracks { get; }
|
public DbSet<UserFollow> UserFollows { get; }
|
||||||
|
|
||||||
DbSet<Mix> Mixes { get; }
|
public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
||||||
|
|
||||||
DbSet<MixItem> MixItems { get; }
|
public DbSet<TEntity> Set<TEntity>() where TEntity : class;
|
||||||
|
|
||||||
DbSet<Upload> Uploads { get; }
|
public Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
DbSet<UploadIntent> UploadIntents { get; }
|
public Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken);
|
||||||
|
|
||||||
DbSet<ListeningHistory> ListeningHistories { get; }
|
|
||||||
|
|
||||||
Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ namespace Musify.Application.Contracts
|
|||||||
{
|
{
|
||||||
public interface IDatabaseTransaction : IAsyncDisposable
|
public interface IDatabaseTransaction : IAsyncDisposable
|
||||||
{
|
{
|
||||||
Task CommitAsync(CancellationToken cancellationToken = default);
|
public Task CommitAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ namespace Musify.Application.Contracts
|
|||||||
{
|
{
|
||||||
public interface IEventBus
|
public interface IEventBus
|
||||||
{
|
{
|
||||||
Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
|
public Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ namespace Musify.Application.Contracts
|
|||||||
{
|
{
|
||||||
public interface IPictureService
|
public interface IPictureService
|
||||||
{
|
{
|
||||||
Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
|
public Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,27 +2,24 @@ namespace Musify.Application.Contracts
|
|||||||
{
|
{
|
||||||
public interface IStorageService
|
public interface IStorageService
|
||||||
{
|
{
|
||||||
Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
/// <summary>Opens an object for reading, or returns null when it does not exist.</summary>
|
||||||
|
public Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
|
public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<string> GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken);
|
public Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, bool preventOverwrite = true, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, CancellationToken cancellationToken, bool preventOverwrite = true);
|
public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
|
public Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken);
|
public Task<IReadOnlyList<string>> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<IReadOnlyList<string>> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken);
|
public Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
public Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
|
||||||
|
|
||||||
Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
|
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken);
|
public IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
|
||||||
|
|
||||||
IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,7 @@ namespace Musify.Application.Contracts
|
|||||||
{
|
{
|
||||||
public interface IStreamTicketService
|
public interface IStreamTicketService
|
||||||
{
|
{
|
||||||
/// <param name="userId">Null for an anonymous listener — the ticket is issued without a
|
public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
|
||||||
/// <c>sub</c> claim.</param>
|
|
||||||
/// <param name="keyPrefix">The storage key (prefix) the ticket authorizes reading from.</param>
|
|
||||||
/// <param name="maxBytes">When set, the Streaming Gateway clamps the byte range it will serve
|
|
||||||
/// for this ticket to <c>[0, maxBytes)</c> — used for the anonymous preview fragment.</param>
|
|
||||||
StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public record StreamTicket(string Token, int ExpiresInSeconds);
|
public record StreamTicket(string Token, int ExpiresInSeconds);
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace Musify.Application.Contracts
|
|
||||||
{
|
|
||||||
public record YouTubeDownloadResult(int ExitCode, string StandardError);
|
|
||||||
|
|
||||||
public interface IYouTubeDownloaderService
|
|
||||||
{
|
|
||||||
Task<YouTubeDownloadResult> DownloadAudioAsync(string videoId, string destinationFilePath, CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
|
|
||||||
namespace Musify.Application.Contracts
|
|
||||||
{
|
|
||||||
public record YouTubeArtistRef(string? Id, string Name);
|
|
||||||
|
|
||||||
public record YouTubeSongResult(
|
|
||||||
string VideoId,
|
|
||||||
string Title,
|
|
||||||
string Artist,
|
|
||||||
string Album,
|
|
||||||
int DurationSeconds,
|
|
||||||
string ThumbnailUrl,
|
|
||||||
bool IsExplicit,
|
|
||||||
IReadOnlyList<YouTubeArtistRef> Artists);
|
|
||||||
|
|
||||||
public record YouTubeSearchResult(
|
|
||||||
IReadOnlyList<YouTubeSongResult> Items,
|
|
||||||
string ContinuationToken);
|
|
||||||
|
|
||||||
public record YouTubeAlbumResult(
|
|
||||||
string AlbumId,
|
|
||||||
string Title,
|
|
||||||
string Artist,
|
|
||||||
string ThumbnailUrl,
|
|
||||||
int? ReleaseYear,
|
|
||||||
bool IsSingle,
|
|
||||||
bool IsEp,
|
|
||||||
IReadOnlyList<YouTubeArtistRef> Artists);
|
|
||||||
|
|
||||||
public record YouTubeAlbumSearchResult(
|
|
||||||
IReadOnlyList<YouTubeAlbumResult> Items,
|
|
||||||
string ContinuationToken);
|
|
||||||
|
|
||||||
public record YouTubeAlbumTrack(
|
|
||||||
string VideoId,
|
|
||||||
string Title,
|
|
||||||
int DurationSeconds,
|
|
||||||
int TrackNumber,
|
|
||||||
bool IsExplicit);
|
|
||||||
|
|
||||||
public record YouTubeAlbumDetail(
|
|
||||||
YouTubeAlbumResult Album,
|
|
||||||
string Description,
|
|
||||||
int TotalDurationSeconds,
|
|
||||||
IReadOnlyList<YouTubeAlbumTrack> Tracks);
|
|
||||||
|
|
||||||
public record YouTubeStreamInfo(string Url, int ExpiresInSeconds);
|
|
||||||
|
|
||||||
public interface IYouTubeMusicService
|
|
||||||
{
|
|
||||||
Task<ErrorOr<YouTubeSearchResult>> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
Task<ErrorOr<YouTubeAlbumSearchResult>> SearchAlbumsAsync(string query, string continuationToken, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
Task<ErrorOr<YouTubeAlbumDetail>> GetAlbumAsync(string albumId, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
Task<ErrorOr<YouTubeStreamInfo>> GetAudioStreamAsync(string videoId, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
Task<ErrorOr<YouTubeSongResult>> GetSongAsync(string videoId, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
string ResolveArtworkUrl(string thumbnailUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,5 @@ namespace Musify.Application.Events
|
|||||||
string Bucket,
|
string Bucket,
|
||||||
string PictureSourceKey,
|
string PictureSourceKey,
|
||||||
string PictureDestinationKey,
|
string PictureDestinationKey,
|
||||||
ImageSize Small,
|
ImageSizes Sizes);
|
||||||
ImageSize Medium,
|
|
||||||
ImageSize Large);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,5 @@ namespace Musify.Application.Events
|
|||||||
string Bucket,
|
string Bucket,
|
||||||
string PictureSourceKey,
|
string PictureSourceKey,
|
||||||
string PictureDestinationKey,
|
string PictureDestinationKey,
|
||||||
ImageSize Small,
|
ImageSizes Sizes);
|
||||||
ImageSize Medium,
|
|
||||||
ImageSize Large);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,5 @@ namespace Musify.Application.Events
|
|||||||
string AudioSourceKey,
|
string AudioSourceKey,
|
||||||
string AudioDestinationKey,
|
string AudioDestinationKey,
|
||||||
string AudioProcessedFolderKey,
|
string AudioProcessedFolderKey,
|
||||||
ImageSize Small,
|
ImageSizes Sizes);
|
||||||
ImageSize Medium,
|
|
||||||
ImageSize Large);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Musify.Application.Events
|
||||||
|
{
|
||||||
|
public record DeleteAlbumEvent(
|
||||||
|
Guid AlbumId,
|
||||||
|
long UserId);
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.Events
|
|
||||||
{
|
|
||||||
public record DownloadYouTubeTrackEvent(
|
|
||||||
Guid TrackId,
|
|
||||||
string VideoId,
|
|
||||||
string ThumbnailUrl,
|
|
||||||
string Bucket,
|
|
||||||
string AudioProcessedFolderKey,
|
|
||||||
ImageSize Small,
|
|
||||||
ImageSize Medium,
|
|
||||||
ImageSize Large);
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,5 @@ namespace Musify.Application.Events
|
|||||||
Guid AlbumId,
|
Guid AlbumId,
|
||||||
string Bucket,
|
string Bucket,
|
||||||
string SourceKey,
|
string SourceKey,
|
||||||
ImageSize Small,
|
ImageSizes Sizes);
|
||||||
ImageSize Medium,
|
|
||||||
ImageSize Large);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,5 @@ namespace Musify.Application.Events
|
|||||||
string Bucket,
|
string Bucket,
|
||||||
string PictureSourceKey,
|
string PictureSourceKey,
|
||||||
string PictureDestinationKey,
|
string PictureDestinationKey,
|
||||||
ImageSize Small,
|
ImageSizes Sizes);
|
||||||
ImageSize Medium,
|
|
||||||
ImageSize Large);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.Events
|
namespace Musify.Application.Events
|
||||||
{
|
{
|
||||||
public record UpdatePlayListPictureEvent(
|
public record UpdatePlayListPictureEvent(
|
||||||
Guid PlayListId,
|
Guid PlayListId,
|
||||||
string Bucket,
|
string Bucket,
|
||||||
string SourceKey,
|
string SourceKey,
|
||||||
ImageSize Small,
|
ImageSizes Sizes);
|
||||||
ImageSize Medium,
|
}
|
||||||
ImageSize Large);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,7 +8,5 @@ namespace Musify.Application.Events
|
|||||||
string Bucket,
|
string Bucket,
|
||||||
string PictureSourceKey,
|
string PictureSourceKey,
|
||||||
string PictureDestinationKey,
|
string PictureDestinationKey,
|
||||||
ImageSize Small,
|
ImageSizes Sizes);
|
||||||
ImageSize Medium,
|
|
||||||
ImageSize Large);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Musify.Application.Events
|
namespace Musify.Application.Events
|
||||||
{
|
{
|
||||||
public record UpdateTrackAudioEvent(
|
public record UpdateTrackAudioEvent(
|
||||||
Guid TrackId,
|
Guid TrackId,
|
||||||
@@ -6,4 +6,4 @@
|
|||||||
string SourceKey,
|
string SourceKey,
|
||||||
string DestinationBucket,
|
string DestinationBucket,
|
||||||
string DestinationFolderKey);
|
string DestinationFolderKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.Events
|
namespace Musify.Application.Events
|
||||||
{
|
{
|
||||||
public record UpdateTrackPictureEvent(
|
public record UpdateTrackPictureEvent(
|
||||||
Guid TrackId,
|
Guid TrackId,
|
||||||
string Bucket,
|
string Bucket,
|
||||||
string SourceKey,
|
string SourceKey,
|
||||||
ImageSize Small,
|
ImageSizes Sizes);
|
||||||
ImageSize Medium,
|
}
|
||||||
ImageSize Large);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Mediator;
|
||||||
|
using Musify.Application.Genres.Responses;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Genres
|
||||||
|
{
|
||||||
|
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
|
||||||
|
|
||||||
|
public class GetAvailableGenresQueryHandler
|
||||||
|
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
|
||||||
|
{
|
||||||
|
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var all = Enum.GetValues<Genre>();
|
||||||
|
|
||||||
|
IReadOnlyList<AvailableGenreResponse> result = all
|
||||||
|
.Select(genre => new AvailableGenreResponse(
|
||||||
|
genre,
|
||||||
|
all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return ValueTask.FromResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Genres.Responses;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Genres
|
||||||
|
{
|
||||||
|
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
|
||||||
|
|
||||||
|
public class GetGenresQueryHandler(IDatabase database)
|
||||||
|
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var rows = await database.TrackTags
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.GroupBy(tag => tag.Tag)
|
||||||
|
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.OrderByDescending(row => row.TrackCount)
|
||||||
|
.ThenBy(row => row.Genre)
|
||||||
|
.Select(row => new GenreResponse(row.Genre, row.TrackCount))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Genres.Responses
|
||||||
|
{
|
||||||
|
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Genres.Responses
|
||||||
|
{
|
||||||
|
public record GenreResponse(Genre Genre, int TrackCount);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Application.Tracks.Responses;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Likes
|
||||||
|
{
|
||||||
|
public record GetLikedTracksQuery(
|
||||||
|
long UserId,
|
||||||
|
int PageNumber,
|
||||||
|
int PageSize)
|
||||||
|
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
|
||||||
|
|
||||||
|
public class GetLikedTracksQueryHandler(IDatabase database)
|
||||||
|
: IQueryHandler<GetLikedTracksQuery, PaginatedResponse<TrackApplicationResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await database.TrackLikes
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.OrderByDescending(like => like.CreatedAt)
|
||||||
|
.ThenBy(like => like.Id)
|
||||||
|
.Select(like => like.Track)
|
||||||
|
.SelectResponse()
|
||||||
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Likes
|
||||||
|
{
|
||||||
|
public record ToggleTrackLikeCommand(long UserId, Guid TrackId)
|
||||||
|
: ICommand<ErrorOr<bool>>;
|
||||||
|
|
||||||
|
public class ToggleTrackLikeCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<ToggleTrackLikeCommandHandler> logger)
|
||||||
|
: ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var trackExists = await database.Tracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
|
||||||
|
if (!trackExists)
|
||||||
|
return AppErrors.NotFound("Track", request.TrackId);
|
||||||
|
|
||||||
|
var existingLike = await database.TrackLikes
|
||||||
|
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
|
||||||
|
|
||||||
|
bool nowLiked;
|
||||||
|
if (existingLike != null)
|
||||||
|
{
|
||||||
|
database.TrackLikes.Remove(existingLike);
|
||||||
|
nowLiked = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await database.TrackLikes.AddAsync(new TrackLike
|
||||||
|
{
|
||||||
|
UserId = request.UserId,
|
||||||
|
TrackId = request.TrackId
|
||||||
|
}, cancellationToken);
|
||||||
|
nowLiked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A concurrent double click can hit the unique index; the outcome is already in place, so treat it as a no-op.
|
||||||
|
var saved = await database.TrySaveChangesAsync(
|
||||||
|
AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken);
|
||||||
|
if (saved.IsError)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
logger.LogInformation("User {UserId} {Action} track {TrackId}",
|
||||||
|
request.UserId, nowLiked ? "liked" : "unliked", request.TrackId);
|
||||||
|
|
||||||
|
return nowLiked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,287 +1,93 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Mixes
|
namespace Musify.Application.Mixes
|
||||||
{
|
{
|
||||||
public record GenerateMixesForUserCommand(long UserId)
|
public record GenerateMixesForUserCommand(long UserId)
|
||||||
: ICommand<ErrorOr<Success>>;
|
: ICommand;
|
||||||
|
|
||||||
public class GenerateMixesForUserCommandHandler(
|
public class GenerateMixesForUserCommandHandler(
|
||||||
IDatabase database,
|
IDatabase database,
|
||||||
IYouTubeMusicService youTubeMusicService,
|
|
||||||
MixConfiguration mixConfiguration,
|
MixConfiguration mixConfiguration,
|
||||||
ILogger<GenerateMixesForUserCommandHandler> logger)
|
ILogger<GenerateMixesForUserCommandHandler> logger)
|
||||||
: ICommandHandler<GenerateMixesForUserCommand, ErrorOr<Success>>
|
: ICommandHandler<GenerateMixesForUserCommand>
|
||||||
{
|
{
|
||||||
private sealed record SeedArtist(Guid Id, string Name);
|
private sealed record MixDraft(MixKind Kind, IReadOnlyList<Guid> TrackIds);
|
||||||
|
|
||||||
private sealed record LocalCandidate(Guid TrackId, string Title, string? Artist, double DurationSeconds);
|
public async ValueTask<Unit> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
|
||||||
|
|
||||||
private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList<MixItem> Items);
|
|
||||||
|
|
||||||
private const int TitleLength = 300;
|
|
||||||
private const int ArtistLength = 300;
|
|
||||||
private const int ThumbnailUrlLength = 512;
|
|
||||||
private const int MixTitleLength = 100;
|
|
||||||
|
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
var userId = request.UserId;
|
var userId = request.UserId;
|
||||||
|
|
||||||
|
var libraryTrackIds = await database.Tracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.Select(track => track.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (libraryTrackIds.Count == 0)
|
||||||
|
{
|
||||||
|
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
|
||||||
var historyTrackIds = await database.ListeningHistories
|
var historyTrackIds = await database.ListeningHistories
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(history => history.UserId == userId)
|
.Where(history => history.UserId == userId && history.IsCounted)
|
||||||
.Select(history => history.TrackId)
|
.Select(history => history.TrackId)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
if (historyTrackIds.Count == 0)
|
var historySet = new HashSet<Guid>(historyTrackIds);
|
||||||
|
var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList();
|
||||||
|
|
||||||
|
var drafts = new List<MixDraft>();
|
||||||
|
|
||||||
|
var discovery = Take(Shuffle(unheardTrackIds));
|
||||||
|
if (discovery.Count > 0)
|
||||||
{
|
{
|
||||||
logger.LogInformation("User {UserId} has no listening history yet, skipping mix generation", userId);
|
drafts.Add(new MixDraft(MixKind.Discovery, discovery));
|
||||||
return new Success();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var seedArtists = await GetSeedArtistsAsync(historyTrackIds, cancellationToken);
|
var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
|
||||||
|
if (daily.Count > 0)
|
||||||
if (seedArtists.Count == 0)
|
|
||||||
{
|
{
|
||||||
logger.LogInformation("User {UserId} has no artists to seed mixes from, skipping mix generation", userId);
|
drafts.Add(new MixDraft(MixKind.Daily, daily));
|
||||||
return new Success();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var knownVideoIds = await database.ExternalTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(track => track.Source == TrackSource.YouTube && historyTrackIds.Contains(track.Id))
|
|
||||||
.Select(track => track.ExternalId)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var excludedVideoIds = new HashSet<string>(knownVideoIds, StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
var youTubeByArtist = await SearchYouTubeAsync(seedArtists, excludedVideoIds, cancellationToken);
|
|
||||||
var localByArtist = await GetLocalCandidatesAsync(seedArtists, historyTrackIds, cancellationToken);
|
|
||||||
|
|
||||||
var drafts = BuildDrafts(seedArtists, localByArtist, youTubeByArtist);
|
|
||||||
|
|
||||||
if (drafts.Count == 0)
|
if (drafts.Count == 0)
|
||||||
{
|
{
|
||||||
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
|
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
|
||||||
return new Success();
|
return Unit.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ReplaceMixesAsync(userId, drafts, cancellationToken);
|
await ReplaceMixesAsync(userId, drafts, cancellationToken);
|
||||||
|
|
||||||
try
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to generate mixes for user {UserId}", userId);
|
|
||||||
return Error.Failure(description: $"Failed to generate mixes for user {userId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
|
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
|
||||||
return new Success();
|
return Unit.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<SeedArtist>> GetSeedArtistsAsync(
|
private List<Guid> Take(IEnumerable<Guid> trackIds) =>
|
||||||
IReadOnlyList<Guid> historyTrackIds,
|
[.. trackIds.Take(mixConfiguration.ItemsPerMix)];
|
||||||
CancellationToken cancellationToken)
|
|
||||||
|
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) =>
|
||||||
|
[.. trackIds.OrderBy(_ => Guid.NewGuid())];
|
||||||
|
|
||||||
|
private static List<Guid> Interleave(List<Guid> first, List<Guid> second)
|
||||||
{
|
{
|
||||||
var ranked = await database.TrackArtists
|
var merged = new List<Guid>(first.Count + second.Count);
|
||||||
.AsNoTracking()
|
|
||||||
.Where(trackArtist => historyTrackIds.Contains(trackArtist.TrackId))
|
|
||||||
.GroupBy(trackArtist => trackArtist.ArtistId)
|
|
||||||
.Select(group => new { ArtistId = group.Key, Listens = group.Count() } )
|
|
||||||
.OrderByDescending(entry => entry.Listens)
|
|
||||||
.Take(mixConfiguration.SeedArtistCount)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var artistIds = ranked.Select(entry => entry.ArtistId).ToList();
|
|
||||||
|
|
||||||
var names = await database.Artists
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(artist => artistIds.Contains(artist.Id))
|
|
||||||
.ToDictionaryAsync(artist => artist.Id, artist => artist.Name, cancellationToken);
|
|
||||||
|
|
||||||
return [.. ranked
|
|
||||||
.Where(entry => names.ContainsKey(entry.ArtistId))
|
|
||||||
.Select(entry => new SeedArtist(entry.ArtistId, names[entry.ArtistId])) ];
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Dictionary<Guid, List<MixItem>> > SearchYouTubeAsync(
|
|
||||||
IReadOnlyList<SeedArtist> seedArtists,
|
|
||||||
HashSet<string> excludedVideoIds,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var byArtist = new Dictionary<Guid, List<MixItem>>();
|
|
||||||
|
|
||||||
foreach (var seed in seedArtists)
|
|
||||||
{
|
|
||||||
var searchResult = await youTubeMusicService.SearchSongsAsync(seed.Name, string.Empty, cancellationToken);
|
|
||||||
if (searchResult.IsError)
|
|
||||||
{
|
|
||||||
logger.LogWarning("YouTube Music search failed for seed artist {Artist}: {Error}",
|
|
||||||
seed.Name, searchResult.FirstError.Description);
|
|
||||||
byArtist[seed.Id] = [];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var songs = new List<MixItem>();
|
|
||||||
foreach (var song in searchResult.Value.Items.Take(mixConfiguration.YouTubeResultsPerSeed))
|
|
||||||
{
|
|
||||||
if (!excludedVideoIds.Add(song.VideoId))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
songs.Add(new MixItem
|
|
||||||
{
|
|
||||||
MixId = Guid.Empty,
|
|
||||||
Source = MixItemSource.YouTube,
|
|
||||||
VideoId = song.VideoId,
|
|
||||||
Title = Truncate(song.Title, TitleLength),
|
|
||||||
Artist = Truncate(song.Artist, ArtistLength),
|
|
||||||
ThumbnailUrl = Truncate(youTubeMusicService.ResolveArtworkUrl(song.ThumbnailUrl), ThumbnailUrlLength),
|
|
||||||
DurationSeconds = song.DurationSeconds,
|
|
||||||
IsExplicit = song.IsExplicit
|
|
||||||
} );
|
|
||||||
}
|
|
||||||
|
|
||||||
byArtist[seed.Id] = songs;
|
|
||||||
}
|
|
||||||
|
|
||||||
return byArtist;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Dictionary<Guid, List<MixItem>> > GetLocalCandidatesAsync(
|
|
||||||
IReadOnlyList<SeedArtist> seedArtists,
|
|
||||||
IReadOnlyList<Guid> historyTrackIds,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var artistIds = seedArtists.Select(seed => seed.Id).ToList();
|
|
||||||
|
|
||||||
var links = await database.TrackArtists
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(trackArtist => artistIds.Contains(trackArtist.ArtistId) && !historyTrackIds.Contains(trackArtist.TrackId))
|
|
||||||
.Select(trackArtist => new { trackArtist.ArtistId, trackArtist.TrackId } )
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var trackIds = links.Select(link => link.TrackId).Distinct().ToList();
|
|
||||||
|
|
||||||
var candidates = await LoadCandidatesAsync(trackIds, cancellationToken);
|
|
||||||
|
|
||||||
var byArtist = new Dictionary<Guid, List<MixItem>>();
|
|
||||||
foreach (var seed in seedArtists)
|
|
||||||
{
|
|
||||||
byArtist[seed.Id] = [.. links
|
|
||||||
.Where(link => link.ArtistId == seed.Id)
|
|
||||||
.Select(link => candidates.GetValueOrDefault(link.TrackId))
|
|
||||||
.Where(candidate => candidate is not null)
|
|
||||||
.Select(candidate => ToMixItem(candidate!))];
|
|
||||||
}
|
|
||||||
|
|
||||||
return byArtist;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Dictionary<Guid, LocalCandidate>> LoadCandidatesAsync(
|
|
||||||
IReadOnlyList<Guid> trackIds,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (trackIds.Count == 0)
|
|
||||||
return [];
|
|
||||||
|
|
||||||
var tracks = await database.Tracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
|
||||||
.Include(track => ((ExternalTrack)track).TrackArtists)
|
|
||||||
.ThenInclude(trackArtist => trackArtist.Artist)
|
|
||||||
.Include(track => ((LocalTrack)track).Owner)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
.Select(track => TrackApplicationResponse.FromEntity(track, 0))
|
|
||||||
.ToDictionary(
|
|
||||||
response => response.Id,
|
|
||||||
response => new LocalCandidate(response.Id, response.Title, response.Artist, response.Duration));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MixItem ToMixItem(LocalCandidate candidate) => new()
|
|
||||||
{
|
|
||||||
MixId = Guid.Empty,
|
|
||||||
Source = MixItemSource.Musify,
|
|
||||||
TrackId = candidate.TrackId,
|
|
||||||
Title = candidate.Title,
|
|
||||||
Artist = candidate.Artist,
|
|
||||||
DurationSeconds = candidate.DurationSeconds
|
|
||||||
};
|
|
||||||
|
|
||||||
private List<MixDraft> BuildDrafts(
|
|
||||||
IReadOnlyList<SeedArtist> seedArtists,
|
|
||||||
IReadOnlyDictionary<Guid, List<MixItem>> localByArtist,
|
|
||||||
IReadOnlyDictionary<Guid, List<MixItem>> youTubeByArtist)
|
|
||||||
{
|
|
||||||
var drafts = new List<MixDraft>();
|
|
||||||
|
|
||||||
var everyYouTube = seedArtists.SelectMany(seed => youTubeByArtist[seed.Id]).ToList();
|
|
||||||
var everyLocal = seedArtists.SelectMany(seed => localByArtist[seed.Id]).ToList();
|
|
||||||
|
|
||||||
var discovery = Take(Shuffle(everyYouTube));
|
|
||||||
if (discovery.Count > 0)
|
|
||||||
{
|
|
||||||
drafts.Add(new MixDraft(
|
|
||||||
"Descubrimiento",
|
|
||||||
"Canciones nuevas que todavía no has escuchado.",
|
|
||||||
discovery));
|
|
||||||
}
|
|
||||||
|
|
||||||
var daily = Take(Interleave(Shuffle(everyLocal), Shuffle(everyYouTube)));
|
|
||||||
if (daily.Count > 0)
|
|
||||||
{
|
|
||||||
drafts.Add(new MixDraft(
|
|
||||||
"Tu mezcla diaria",
|
|
||||||
"Lo que más escuchas, con alguna sorpresa nueva.",
|
|
||||||
daily));
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var seed in seedArtists.Take(mixConfiguration.ArtistMixCount))
|
|
||||||
{
|
|
||||||
var items = Take(Interleave(Shuffle(localByArtist[seed.Id]), Shuffle(youTubeByArtist[seed.Id])));
|
|
||||||
if (items.Count == 0)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
drafts.Add(new MixDraft(
|
|
||||||
Truncate(seed.Name, MixTitleLength),
|
|
||||||
$"Canciones de {seed.Name} y de artistas parecidos.",
|
|
||||||
items));
|
|
||||||
}
|
|
||||||
|
|
||||||
return drafts;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<MixItem> Take(IEnumerable<MixItem> items) =>
|
|
||||||
[.. items.Take(mixConfiguration.ItemsPerMix)];
|
|
||||||
|
|
||||||
private static string Truncate(string value, int maxLength) =>
|
|
||||||
value.Length <= maxLength ? value : value[..maxLength];
|
|
||||||
|
|
||||||
private static List<MixItem> Shuffle(IEnumerable<MixItem> items) =>
|
|
||||||
[.. items.OrderBy(_ => Guid.NewGuid())];
|
|
||||||
|
|
||||||
private static List<MixItem> Interleave(IReadOnlyList<MixItem> first, IReadOnlyList<MixItem> second)
|
|
||||||
{
|
|
||||||
var merged = new List<MixItem>(first.Count + second.Count);
|
|
||||||
for (var index = 0; index < Math.Max(first.Count, second.Count); index++)
|
for (var index = 0; index < Math.Max(first.Count, second.Count); index++)
|
||||||
{
|
{
|
||||||
if (index < first.Count)
|
if (index < first.Count)
|
||||||
merged.Add(first[index] );
|
merged.Add(first[index]);
|
||||||
if (index < second.Count)
|
if (index < second.Count)
|
||||||
merged.Add(second[index] );
|
merged.Add(second[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return merged;
|
return merged;
|
||||||
@@ -295,12 +101,6 @@ namespace Musify.Application.Mixes
|
|||||||
|
|
||||||
if (previousMixes.Count > 0)
|
if (previousMixes.Count > 0)
|
||||||
{
|
{
|
||||||
var previousIds = previousMixes.Select(mix => mix.Id).ToList();
|
|
||||||
var previousItems = await database.MixItems
|
|
||||||
.Where(item => previousIds.Contains(item.MixId))
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
database.MixItems.RemoveRange(previousItems);
|
|
||||||
database.Mixes.RemoveRange(previousMixes);
|
database.Mixes.RemoveRange(previousMixes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,28 +110,19 @@ namespace Musify.Application.Mixes
|
|||||||
var mix = new Mix
|
var mix = new Mix
|
||||||
{
|
{
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
Title = draft.Title,
|
Kind = draft.Kind,
|
||||||
Subtitle = draft.Subtitle,
|
|
||||||
Position = position
|
Position = position
|
||||||
};
|
};
|
||||||
|
|
||||||
await database.Mixes.AddAsync(mix, cancellationToken);
|
await database.Mixes.AddAsync(mix, cancellationToken);
|
||||||
|
|
||||||
for (var itemPosition = 0; itemPosition < draft.Items.Count; itemPosition++)
|
for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++)
|
||||||
{
|
{
|
||||||
var item = draft.Items[itemPosition];
|
|
||||||
await database.MixItems.AddAsync(new MixItem
|
await database.MixItems.AddAsync(new MixItem
|
||||||
{
|
{
|
||||||
MixId = mix.Id,
|
MixId = mix.Id,
|
||||||
Position = itemPosition,
|
Position = itemPosition,
|
||||||
Source = item.Source,
|
TrackId = draft.TrackIds[itemPosition]
|
||||||
TrackId = item.TrackId,
|
|
||||||
VideoId = item.VideoId,
|
|
||||||
Title = item.Title,
|
|
||||||
Artist = item.Artist,
|
|
||||||
ThumbnailUrl = item.ThumbnailUrl,
|
|
||||||
DurationSeconds = item.DurationSeconds,
|
|
||||||
IsExplicit = item.IsExplicit
|
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Mixes.Responses;
|
using Musify.Application.Mixes.Responses;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.Mixes
|
namespace Musify.Application.Mixes
|
||||||
{
|
{
|
||||||
@@ -19,12 +20,13 @@ namespace Musify.Application.Mixes
|
|||||||
.Include(mix => mix.Items)
|
.Include(mix => mix.Items)
|
||||||
.SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
|
.SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
|
||||||
|
|
||||||
if (mix is null)
|
if (mix == null)
|
||||||
return Error.NotFound(description: $"Mix {request.MixId} was not found");
|
return AppErrors.NotFound("Mix", request.MixId);
|
||||||
|
|
||||||
var items = mix.Items.OrderBy(item => item.Position).ToList();
|
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
|
||||||
|
var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken);
|
||||||
|
|
||||||
return MixApplicationResponse.FromEntity(mix, items.Count, items);
|
return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
@@ -7,31 +6,46 @@ using Musify.Application.Mixes.Responses;
|
|||||||
namespace Musify.Application.Mixes
|
namespace Musify.Application.Mixes
|
||||||
{
|
{
|
||||||
public record GetMixesByUserIdQuery(long UserId)
|
public record GetMixesByUserIdQuery(long UserId)
|
||||||
: IQuery<ErrorOr<IReadOnlyList<MixApplicationResponse>>>;
|
: IQuery<IReadOnlyList<MixApplicationResponse>>;
|
||||||
|
|
||||||
public class GetMixesByUserIdQueryHandler(IDatabase database)
|
public class GetMixesByUserIdQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetMixesByUserIdQuery, ErrorOr<IReadOnlyList<MixApplicationResponse>>>
|
: IQueryHandler<GetMixesByUserIdQuery, IReadOnlyList<MixApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<IReadOnlyList<MixApplicationResponse>>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
|
public async ValueTask<IReadOnlyList<MixApplicationResponse>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var entries = await database.Mixes
|
var mixes = await database.Mixes
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(mix => mix.UserId == request.UserId)
|
.Where(mix => mix.UserId == request.UserId)
|
||||||
|
.Include(mix => mix.Items)
|
||||||
.OrderBy(mix => mix.Position)
|
.OrderBy(mix => mix.Position)
|
||||||
.Select(mix => new
|
.ThenBy(mix => mix.Id)
|
||||||
{
|
|
||||||
Mix = mix,
|
|
||||||
ItemCount = mix.Items.Count,
|
|
||||||
CoverItems = mix.Items
|
|
||||||
.OrderBy(item => item.Position)
|
|
||||||
.Take(MixApplicationResponse.CoverItemCount)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
return entries
|
var orderedItemsByMix = mixes.ToDictionary(
|
||||||
.Select(entry => MixApplicationResponse.FromEntity(entry.Mix, entry.ItemCount, entry.CoverItems))
|
mix => mix.Id,
|
||||||
|
mix => mix.Items.OrderBy(item => item.Position).ToList());
|
||||||
|
|
||||||
|
var coverItems = orderedItemsByMix.Values
|
||||||
|
.SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken))
|
||||||
|
.GroupBy(response => response.TrackId)
|
||||||
|
.ToDictionary(group => group.Key, group => group.First());
|
||||||
|
|
||||||
|
var responses = new List<MixApplicationResponse>(mixes.Count);
|
||||||
|
foreach (var mix in mixes)
|
||||||
|
{
|
||||||
|
var orderedItems = orderedItemsByMix[mix.Id];
|
||||||
|
var covers = orderedItems
|
||||||
|
.Take(MixApplicationResponse.CoverItemCount)
|
||||||
|
.Where(item => coverResponses.ContainsKey(item.TrackId))
|
||||||
|
.Select(item => coverResponses[item.TrackId])
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
responses.Add(new MixApplicationResponse(mix.Id, mix.Kind, orderedItems.Count, covers));
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Mixes.Responses;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Mixes
|
||||||
|
{
|
||||||
|
|
||||||
|
internal static class MixItemMapper
|
||||||
|
{
|
||||||
|
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
|
||||||
|
IDatabase database,
|
||||||
|
IReadOnlyList<MixItem> orderedItems,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList();
|
||||||
|
|
||||||
|
var tracksById = await database.Tracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(track => track.Owner)
|
||||||
|
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
|
||||||
|
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
|
||||||
|
|
||||||
|
return orderedItems
|
||||||
|
.Where(item => tracksById.ContainsKey(item.TrackId))
|
||||||
|
.Select(item =>
|
||||||
|
{
|
||||||
|
var entry = tracksById[item.TrackId];
|
||||||
|
return new MixItemApplicationResponse(
|
||||||
|
item.TrackId,
|
||||||
|
entry.Track.Title,
|
||||||
|
entry.Track.Owner.Name,
|
||||||
|
entry.Track.DurationSeconds,
|
||||||
|
entry.ListensCount);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,13 @@
|
|||||||
using Musify.Domain.Entities;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Mixes.Responses
|
namespace Musify.Application.Mixes.Responses
|
||||||
{
|
{
|
||||||
public record MixApplicationResponse(
|
public record MixApplicationResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Title,
|
MixKind Kind,
|
||||||
string? Subtitle,
|
|
||||||
int ItemCount,
|
int ItemCount,
|
||||||
IReadOnlyList<MixItemApplicationResponse> Items)
|
IReadOnlyList<MixItemApplicationResponse> Items)
|
||||||
{
|
{
|
||||||
public const int CoverItemCount = 4;
|
public const int CoverItemCount = 4;
|
||||||
|
|
||||||
public static MixApplicationResponse FromEntity(
|
|
||||||
Mix mix,
|
|
||||||
int itemCount,
|
|
||||||
IReadOnlyList<MixItem> items) =>
|
|
||||||
new(
|
|
||||||
mix.Id,
|
|
||||||
mix.Title,
|
|
||||||
mix.Subtitle,
|
|
||||||
itemCount,
|
|
||||||
[.. items.Select(MixItemApplicationResponse.FromEntity)]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,9 @@
|
|||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Mixes.Responses
|
namespace Musify.Application.Mixes.Responses
|
||||||
{
|
{
|
||||||
public record MixItemApplicationResponse(
|
public record MixItemApplicationResponse(
|
||||||
MixItemSource Source,
|
Guid TrackId,
|
||||||
Guid? TrackId,
|
|
||||||
string? VideoId,
|
|
||||||
string Title,
|
string Title,
|
||||||
string? Artist,
|
string? Artist,
|
||||||
string? ThumbnailUrl,
|
|
||||||
double DurationSeconds,
|
double DurationSeconds,
|
||||||
bool IsExplicit)
|
int ListensCount);
|
||||||
{
|
|
||||||
public static MixItemApplicationResponse FromEntity(MixItem item) =>
|
|
||||||
new(
|
|
||||||
item.Source,
|
|
||||||
item.TrackId,
|
|
||||||
item.VideoId,
|
|
||||||
item.Title ?? string.Empty,
|
|
||||||
item.Artist,
|
|
||||||
item.ThumbnailUrl,
|
|
||||||
item.DurationSeconds,
|
|
||||||
item.IsExplicit);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MimeMapping;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Pictures
|
||||||
|
{
|
||||||
|
public enum CoverOwner
|
||||||
|
{
|
||||||
|
Track,
|
||||||
|
Album,
|
||||||
|
PlayList
|
||||||
|
}
|
||||||
|
|
||||||
|
public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium)
|
||||||
|
: IQuery<ErrorOr<CoverLocation>>;
|
||||||
|
|
||||||
|
public record CoverLocation(string Bucket, string Key, string ContentType);
|
||||||
|
|
||||||
|
public class GetCoverQueryHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ApplicationStorageConfiguration storageConfiguration,
|
||||||
|
TrackConfiguration trackConfiguration,
|
||||||
|
AlbumConfiguration albumConfiguration,
|
||||||
|
PlayListConfiguration playListConfiguration)
|
||||||
|
: IQueryHandler<GetCoverQuery, ErrorOr<CoverLocation>>
|
||||||
|
{
|
||||||
|
private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large);
|
||||||
|
|
||||||
|
public async ValueTask<ErrorOr<CoverLocation>> Handle(GetCoverQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (source, routes) = request.Owner switch
|
||||||
|
{
|
||||||
|
CoverOwner.Track => (await database.Tracks.AsNoTracking()
|
||||||
|
.Where(t => t.Id == request.Id && t.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.Select(t => new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName))
|
||||||
|
.SingleOrDefaultAsync(cancellationToken), trackConfiguration.Routes),
|
||||||
|
CoverOwner.Album => (await database.Albums.AsNoTracking()
|
||||||
|
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null)
|
||||||
|
.Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName))
|
||||||
|
.SingleOrDefaultAsync(cancellationToken), albumConfiguration.Routes),
|
||||||
|
_ => (await database.PlayLists.AsNoTracking()
|
||||||
|
.Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active && p.Pictures != null)
|
||||||
|
.Select(p => new CoverSource(p.OwnerUserId, p.Pictures!.OriginalName, p.Pictures.SmallName, p.Pictures.MediumName, p.Pictures.LargeName))
|
||||||
|
.SingleOrDefaultAsync(cancellationToken), playListConfiguration.Routes)
|
||||||
|
};
|
||||||
|
|
||||||
|
var entity = request.Owner.ToString();
|
||||||
|
if (source == null)
|
||||||
|
return AppErrors.NotFound($"{entity}Cover", request.Id);
|
||||||
|
|
||||||
|
var resizedName = request.Size switch
|
||||||
|
{
|
||||||
|
PictureSize.Small => source.Small,
|
||||||
|
PictureSize.Large => source.Large,
|
||||||
|
_ => source.Medium
|
||||||
|
};
|
||||||
|
|
||||||
|
// The resized files only exist once processing has finished; until then serve the original.
|
||||||
|
var (name, key) = !string.IsNullOrEmpty(resizedName)
|
||||||
|
? (resizedName, routes.BuildPicturePath(request.Size, resizedName))
|
||||||
|
: !string.IsNullOrEmpty(source.Original)
|
||||||
|
? (source.Original, routes.BuildOriginalPicturePath(source.OwnerUserId, source.Original))
|
||||||
|
: (null, null);
|
||||||
|
|
||||||
|
if (name == null || key == null)
|
||||||
|
return AppErrors.NotFound($"{entity}Cover", request.Id);
|
||||||
|
|
||||||
|
return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
|
using Musify.Application.Services;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Pictures
|
||||||
|
{
|
||||||
|
/// <summary>What an album or playlist needs to switch to a freshly uploaded picture.</summary>
|
||||||
|
public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes);
|
||||||
|
|
||||||
|
public static class PictureSourceChange
|
||||||
|
{
|
||||||
|
public static async Task<ErrorOr<PreparedPicture>> PrepareAsync(
|
||||||
|
UploadIntentValidator uploadIntentValidator,
|
||||||
|
Guid intentId,
|
||||||
|
long userId,
|
||||||
|
UploadIntentPurpose purpose,
|
||||||
|
IPictureOwnerConfiguration owner,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var validation = await uploadIntentValidator.ValidateAndLoadAsync(intentId, userId, purpose, cancellationToken);
|
||||||
|
if (validation.IsError)
|
||||||
|
return validation.Errors;
|
||||||
|
|
||||||
|
var intent = validation.Value;
|
||||||
|
|
||||||
|
return new PreparedPicture(
|
||||||
|
intent,
|
||||||
|
EntityPictures.Pending(intent.ObjectName),
|
||||||
|
owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName),
|
||||||
|
owner.PicturesSizes.ToImageSizes(owner.Routes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
namespace Musify.Application.Albums.Responses
|
namespace Musify.Application.Pictures.Responses
|
||||||
{
|
{
|
||||||
public record AlbumPictureUploadResponse(
|
public record PictureUploadResponse(
|
||||||
Guid IntentId,
|
Guid IntentId,
|
||||||
string Bucket,
|
string Bucket,
|
||||||
string Key,
|
string Key,
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
|
||||||
{
|
|
||||||
public record AddTrackToPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
|
|
||||||
: ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class AddTrackToPlayListCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<AddTrackToPlayListCommandHandler> logger)
|
|
||||||
: ICommandHandler<AddTrackToPlayListCommand, ErrorOr<Success>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var playList = await database.PlayLists
|
|
||||||
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
|
||||||
if (playList is null)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playList.UserId != request.UserId)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var trackExists = await database.Tracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
|
|
||||||
if (!trackExists)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
|
||||||
return Error.NotFound(description: $"Track {request.TrackId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var alreadyAdded = await database.PlayListHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
|
|
||||||
if (alreadyAdded)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Track {TrackId} already in playlist {PlayListId}", request.TrackId, request.PlayListId);
|
|
||||||
return Error.Conflict(description: "Track is already in the playlist.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var playListTracksQuery = database.PlayListHasTracks
|
|
||||||
.Where(plt => plt.PlayListId == request.PlayListId);
|
|
||||||
|
|
||||||
var hasTracks = await playListTracksQuery.AnyAsync(cancellationToken);
|
|
||||||
var nextPosition = hasTracks
|
|
||||||
? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
|
|
||||||
{
|
|
||||||
PlayListId = request.PlayListId,
|
|
||||||
TrackId = request.TrackId,
|
|
||||||
Position = nextPosition
|
|
||||||
}, cancellationToken);
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Added track {TrackId} to playlist {PlayListId} at position {Position}",
|
|
||||||
request.TrackId, request.PlayListId, nextPosition);
|
|
||||||
|
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.PlayLists
|
||||||
|
{
|
||||||
|
/// <summary>Appends tracks to a playlist in the given order, skipping the ones it already has.</summary>
|
||||||
|
public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList<Guid> TrackIds)
|
||||||
|
: ICommand<ErrorOr<Success>>;
|
||||||
|
|
||||||
|
public class AddTracksToPlayListCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<AddTracksToPlayListCommandHandler> logger)
|
||||||
|
: ICommandHandler<AddTracksToPlayListCommand, ErrorOr<Success>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||||
|
if (playList.IsError)
|
||||||
|
return playList.Errors;
|
||||||
|
|
||||||
|
var requestedIds = request.TrackIds.Distinct().ToList();
|
||||||
|
|
||||||
|
var existingIds = await database.Tracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.Select(track => track.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var missing = requestedIds.FirstOrDefault(id => !existingIds.Contains(id));
|
||||||
|
if (missing != Guid.Empty)
|
||||||
|
return AppErrors.NotFound("Track", missing);
|
||||||
|
|
||||||
|
var alreadyInPlayList = await database.PlayListHasTracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(link => link.PlayListId == request.PlayListId && requestedIds.Contains(link.TrackId))
|
||||||
|
.Select(link => link.TrackId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var toAdd = requestedIds.Where(id => !alreadyInPlayList.Contains(id)).ToList();
|
||||||
|
if (toAdd.Count == 0)
|
||||||
|
return Result.Success;
|
||||||
|
|
||||||
|
// Playlist positions are internal indexes, so they start at 0.
|
||||||
|
var nextPosition = (await database.PlayListHasTracks
|
||||||
|
.Where(link => link.PlayListId == request.PlayListId)
|
||||||
|
.MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1;
|
||||||
|
|
||||||
|
foreach (var trackId in toAdd)
|
||||||
|
{
|
||||||
|
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
|
||||||
|
{
|
||||||
|
PlayListId = request.PlayListId,
|
||||||
|
TrackId = trackId,
|
||||||
|
Position = nextPosition++
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved = await database.TrySaveChangesAsync(
|
||||||
|
AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken);
|
||||||
|
if (saved.IsError)
|
||||||
|
return saved;
|
||||||
|
|
||||||
|
logger.LogInformation("Added {Count} tracks to playlist {PlayListId}", toAdd.Count, request.PlayListId);
|
||||||
|
|
||||||
|
return Result.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Application.Services;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
|
||||||
{
|
|
||||||
public record AddYouTubeTrackToPlayListCommand(
|
|
||||||
long UserId,
|
|
||||||
Guid PlayListId,
|
|
||||||
string VideoId)
|
|
||||||
: ICommand<ErrorOr<TrackApplicationResponse>>;
|
|
||||||
|
|
||||||
public class AddYouTubeTrackToPlayListCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
IEventBus eventBus,
|
|
||||||
IYouTubeMusicService youTubeMusicService,
|
|
||||||
YouTubeTrackProvisioner provisioner,
|
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
|
||||||
TrackConfiguration trackConfiguration,
|
|
||||||
ILogger<AddYouTubeTrackToPlayListCommandHandler> logger)
|
|
||||||
: ICommandHandler<AddYouTubeTrackToPlayListCommand, ErrorOr<TrackApplicationResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(AddYouTubeTrackToPlayListCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var playList = await database.PlayLists.AsNoTracking()
|
|
||||||
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
|
||||||
if (playList is null)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playList.UserId != request.UserId)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var provisionResult = await provisioner.GetOrCreateAsync(request.VideoId, cancellationToken);
|
|
||||||
if (provisionResult.IsError)
|
|
||||||
return provisionResult.Errors;
|
|
||||||
|
|
||||||
var track = provisionResult.Value.Track;
|
|
||||||
|
|
||||||
var failed = track.Audio.IsFailed;
|
|
||||||
var neverQueued = !track.Audio.DownloadRequested && !track.Audio.IsProcessed;
|
|
||||||
if (failed || neverQueued)
|
|
||||||
{
|
|
||||||
if (failed)
|
|
||||||
{
|
|
||||||
track.Audio.TranscodeStatus = ProcessingStatus.Pending;
|
|
||||||
track.Pictures.ProcessingStatus = ProcessingStatus.Pending;
|
|
||||||
track.LifeCycleStatus = LifeCycleStatus.Active;
|
|
||||||
track.Audio.RetryCount += 1;
|
|
||||||
track.Audio.LastRetryAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
track.Audio.DownloadRequested = true;
|
|
||||||
|
|
||||||
var thumbnailUrl = await ResolveThumbnailUrlAsync(request.VideoId, provisionResult.Value.Song, cancellationToken);
|
|
||||||
if (thumbnailUrl.IsError)
|
|
||||||
return thumbnailUrl.Errors;
|
|
||||||
|
|
||||||
await PublishDownloadEventAsync(track.Id, request.VideoId, thumbnailUrl.Value, cancellationToken);
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
logger.LogInformation("Queued YouTube track {VideoId} for download", request.VideoId);
|
|
||||||
}
|
|
||||||
|
|
||||||
var alreadyAdded = await database.PlayListHasTracks.AsNoTracking()
|
|
||||||
.AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == track.Id, cancellationToken);
|
|
||||||
if (alreadyAdded)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Track {TrackId} already in playlist {PlayListId}", track.Id, request.PlayListId);
|
|
||||||
return Error.Conflict(description: "Track is already in the playlist.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var playListTracksQuery = database.PlayListHasTracks
|
|
||||||
.Where(plt => plt.PlayListId == request.PlayListId);
|
|
||||||
|
|
||||||
var hasTracks = await playListTracksQuery.AnyAsync(cancellationToken);
|
|
||||||
var nextPosition = hasTracks
|
|
||||||
? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
|
|
||||||
{
|
|
||||||
PlayListId = request.PlayListId,
|
|
||||||
TrackId = track.Id,
|
|
||||||
Position = nextPosition
|
|
||||||
}, cancellationToken);
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Added YouTube track {VideoId} ({TrackId}) to playlist {PlayListId} at position {Position}",
|
|
||||||
request.VideoId, track.Id, request.PlayListId, nextPosition);
|
|
||||||
|
|
||||||
var listensCount = await database.ListeningHistories.AsNoTracking()
|
|
||||||
.CountAsync(lh => lh.TrackId == track.Id, cancellationToken);
|
|
||||||
|
|
||||||
return TrackApplicationResponse.FromEntity(track, listensCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<ErrorOr<string>> ResolveThumbnailUrlAsync(string videoId, YouTubeSongResult? song, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(song?.ThumbnailUrl))
|
|
||||||
return youTubeMusicService.ResolveArtworkUrl(song.ThumbnailUrl);
|
|
||||||
|
|
||||||
var songResult = await youTubeMusicService.GetSongAsync(videoId, cancellationToken);
|
|
||||||
if (songResult.IsError)
|
|
||||||
return songResult.Errors;
|
|
||||||
|
|
||||||
return youTubeMusicService.ResolveArtworkUrl(songResult.Value.ThumbnailUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task PublishDownloadEventAsync(Guid trackId, string videoId, string thumbnailUrl, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
|
|
||||||
|
|
||||||
await eventBus.PublishAsync(
|
|
||||||
new DownloadYouTubeTrackEvent(
|
|
||||||
trackId,
|
|
||||||
videoId,
|
|
||||||
thumbnailUrl,
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
audioProcessedFolderKey,
|
|
||||||
new ImageSize(
|
|
||||||
trackConfiguration.Routes.SmallPicturesPath,
|
|
||||||
trackConfiguration.PicturesSizes.SmallPictureWidth,
|
|
||||||
trackConfiguration.PicturesSizes.SmallPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
trackConfiguration.Routes.MediumPicturesPath,
|
|
||||||
trackConfiguration.PicturesSizes.MediumPictureWidth,
|
|
||||||
trackConfiguration.PicturesSizes.MediumPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
trackConfiguration.Routes.LargePicturesPath,
|
|
||||||
trackConfiguration.PicturesSizes.LargePictureWidth,
|
|
||||||
trackConfiguration.PicturesSizes.LargePictureHeight)),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,13 +3,14 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Events;
|
using Musify.Application.Events;
|
||||||
|
using Musify.Application.Pictures;
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Services;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
namespace Musify.Application.PlayLists
|
||||||
{
|
{
|
||||||
@@ -17,7 +18,8 @@ namespace Musify.Application.PlayLists
|
|||||||
long UserId,
|
long UserId,
|
||||||
string Name,
|
string Name,
|
||||||
string? Description,
|
string? Description,
|
||||||
Guid? PictureIntentId)
|
Guid? PictureIntentId,
|
||||||
|
PlayListVisibility Visibility = PlayListVisibility.Private)
|
||||||
: ICommand<ErrorOr<PlayListApplicationResponse>>;
|
: ICommand<ErrorOr<PlayListApplicationResponse>>;
|
||||||
|
|
||||||
public class CreatePlayListCommandHandler(
|
public class CreatePlayListCommandHandler(
|
||||||
@@ -25,126 +27,53 @@ namespace Musify.Application.PlayLists
|
|||||||
IDatabase database,
|
IDatabase database,
|
||||||
UploadIntentValidator uploadIntentValidator,
|
UploadIntentValidator uploadIntentValidator,
|
||||||
ILogger<CreatePlayListCommandHandler> logger,
|
ILogger<CreatePlayListCommandHandler> logger,
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
PlayListConfiguration playListConfiguration)
|
||||||
PlayListConfiguration playListConfiguration,
|
|
||||||
UploadIntentConfiguration uploadIntentConfiguration)
|
|
||||||
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
||||||
{
|
{
|
||||||
private sealed record ResolvedPlayListPictures(PlayListPictures Pictures, UploadIntent? Intent);
|
|
||||||
|
|
||||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var userExists = await database.Users
|
var userExists = await database.Users
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
|
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
|
||||||
if (!userExists)
|
if (!userExists)
|
||||||
|
return AppErrors.NotFound("User", request.UserId);
|
||||||
|
|
||||||
|
PreparedPicture? picture = null;
|
||||||
|
if (request.PictureIntentId is { } intentId)
|
||||||
{
|
{
|
||||||
logger.LogWarning("User {UserId} not found", request.UserId);
|
var prepared = await PictureSourceChange.PrepareAsync(
|
||||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
|
||||||
|
if (prepared.IsError)
|
||||||
|
return prepared.Errors;
|
||||||
|
|
||||||
|
picture = prepared.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
var picturesResult = await ResolvePicturesAsync(request, cancellationToken);
|
var name = request.Name.Trim();
|
||||||
if (picturesResult.IsError)
|
|
||||||
return picturesResult.Errors;
|
|
||||||
|
|
||||||
var resolvedPictures = picturesResult.Value;
|
|
||||||
|
|
||||||
var playList = new PlayList
|
var playList = new PlayList
|
||||||
{
|
{
|
||||||
UserId = request.UserId,
|
OwnerUserId = request.UserId,
|
||||||
Name = request.Name,
|
Name = name,
|
||||||
NormalizedName = request.Name.Trim().ToUpperInvariant(),
|
NormalizedName = TextNormalizer.Normalize(name),
|
||||||
Description = string.IsNullOrWhiteSpace(request.Description)? null: request.Description.Trim(),
|
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
||||||
Pictures = resolvedPictures.Pictures
|
Pictures = picture?.Pictures,
|
||||||
|
Visibility = request.Visibility
|
||||||
};
|
};
|
||||||
await database.PlayLists.AddAsync(playList, cancellationToken);
|
await database.PlayLists.AddAsync(playList, cancellationToken);
|
||||||
|
|
||||||
if (resolvedPictures.Intent != null)
|
if (picture != null)
|
||||||
{
|
|
||||||
var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, resolvedPictures.Pictures.OriginalName);
|
|
||||||
var publishResult = await PublishCreatePlayListEventAsync(playList.Id, resolvedPictures.Intent, finalPictureKey, cancellationToken);
|
|
||||||
if (publishResult.IsError)
|
|
||||||
return publishResult.Errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to save playlist for user {UserId}", request.UserId);
|
|
||||||
return Error.Failure(description: $"Failed to create playlist for user {request.UserId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
|
|
||||||
return PlayListApplicationResponse.FromEntity(playList);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask<ErrorOr<ResolvedPlayListPictures>> ResolvePicturesAsync(
|
|
||||||
CreatePlayListCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (!request.PictureIntentId.HasValue)
|
|
||||||
{
|
|
||||||
return new ResolvedPlayListPictures(new PlayListPictures
|
|
||||||
{
|
|
||||||
OriginalName = playListConfiguration.Routes.PresetOriginalPicture,
|
|
||||||
SmallName = playListConfiguration.Routes.PresetSmallPicture,
|
|
||||||
MediumName = playListConfiguration.Routes.PresetMediumPicture,
|
|
||||||
LargeName = playListConfiguration.Routes.PresetLargePicture
|
|
||||||
}, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
|
||||||
uploadIntentConfiguration, request.PictureIntentId.Value, request.UserId, cancellationToken);
|
|
||||||
|
|
||||||
if (validation.IsError)
|
|
||||||
return validation.Errors;
|
|
||||||
|
|
||||||
return new ResolvedPlayListPictures(new PlayListPictures
|
|
||||||
{
|
|
||||||
OriginalName = validation.Value.ObjectName,
|
|
||||||
SmallName = playListConfiguration.Routes.PresetSmallPicture,
|
|
||||||
MediumName = playListConfiguration.Routes.PresetMediumPicture,
|
|
||||||
LargeName = playListConfiguration.Routes.PresetLargePicture
|
|
||||||
}, validation.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task<ErrorOr<Success>> PublishCreatePlayListEventAsync(
|
|
||||||
Guid playListId,
|
|
||||||
UploadIntent pictureIntent,
|
|
||||||
string finalPictureKey,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
await eventBus.PublishAsync(
|
await eventBus.PublishAsync(
|
||||||
new CreatePlayListResourcesEvent(
|
new CreatePlayListResourcesEvent(
|
||||||
playListId,
|
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||||
pictureIntent.Id,
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
pictureIntent.Key,
|
|
||||||
finalPictureKey,
|
|
||||||
new ImageSize(
|
|
||||||
playListConfiguration.Routes.SmallPicturesPath,
|
|
||||||
playListConfiguration.PicturesSizes.SmallPictureWidth,
|
|
||||||
playListConfiguration.PicturesSizes.SmallPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
playListConfiguration.Routes.MediumPicturesPath,
|
|
||||||
playListConfiguration.PicturesSizes.MediumPictureWidth,
|
|
||||||
playListConfiguration.PicturesSizes.MediumPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
playListConfiguration.Routes.LargePicturesPath,
|
|
||||||
playListConfiguration.PicturesSizes.LargePictureWidth,
|
|
||||||
playListConfiguration.PicturesSizes.LargePictureHeight)),
|
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to publish create playlist event for playlist {PlayListId}", playListId);
|
|
||||||
return Error.Failure(description: $"Failed to publish create playlist event for playlist {playListId}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
|
||||||
|
|
||||||
|
return PlayListApplicationResponse.FromEntity(playList);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Events;
|
using Musify.Application.Events;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
namespace Musify.Application.PlayLists
|
||||||
@@ -19,44 +19,19 @@ namespace Musify.Application.PlayLists
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var playList = await database.PlayLists.SingleOrDefaultAsync(p => p.Id == request.PlayListId, cancellationToken);
|
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||||
if (playList is null)
|
if (found.IsError)
|
||||||
{
|
return found.Errors;
|
||||||
logger.LogDebug("Playlist {PlayListId} not found", request.PlayListId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playList.UserId != request.UserId)
|
|
||||||
{
|
|
||||||
logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
var playList = found.Value;
|
||||||
playList.LifeCycleStatus = LifeCycleStatus.Removing;
|
playList.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||||
|
|
||||||
try
|
await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken);
|
||||||
{
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
await eventBus.PublishAsync(
|
|
||||||
new DeletePlayListEvent(playList.Id, request.UserId),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to publish delete playlist event for playlist {PlayListId}", request.PlayListId);
|
|
||||||
return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
logger.LogInformation("Marked playlist {PlayListId} as removing", request.PlayListId);
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to mark playlist {PlayListId} as removing", request.PlayListId);
|
|
||||||
return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Success();
|
return Result.Success;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.PlayLists.Responses;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.PlayLists.Responses;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
namespace Musify.Application.PlayLists
|
||||||
{
|
{
|
||||||
public record GetPlayListByIdQuery(Guid Id)
|
public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null)
|
||||||
: IQuery<ErrorOr<PlayListApplicationResponse>>;
|
: IQuery<ErrorOr<PlayListApplicationResponse>>;
|
||||||
|
|
||||||
public class GetPlayListByIdQueryHandler(IDatabase database)
|
public class GetPlayListByIdQueryHandler(IDatabase database)
|
||||||
@@ -14,24 +15,17 @@ namespace Musify.Application.PlayLists
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var entry = await database.PlayLists
|
var playList = await database.PlayLists
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
.Active()
|
||||||
|
.VisibleTo(request.RequestingUserId)
|
||||||
.Where(p => p.Id == request.Id)
|
.Where(p => p.Id == request.Id)
|
||||||
.Select(p => new
|
.SelectResponse()
|
||||||
{
|
|
||||||
PlayList = p,
|
|
||||||
CoverTrackIds = p.PlayListTracks
|
|
||||||
.OrderBy(plt => plt.Position)
|
|
||||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(plt => plt.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (entry is null)
|
return playList == null
|
||||||
return Error.NotFound();
|
? AppErrors.NotFound("PlayList", request.Id)
|
||||||
|
: playList;
|
||||||
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using MimeMapping;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
|
||||||
{
|
|
||||||
public record GetPlayListCoverQuery(Guid PlayListId, string Size)
|
|
||||||
: IQuery<ErrorOr<PlayListCoverLocation>>;
|
|
||||||
|
|
||||||
public record PlayListCoverLocation(string Bucket, string Key, string ContentType);
|
|
||||||
|
|
||||||
public class GetPlayListCoverQueryHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
|
||||||
PlayListConfiguration playListConfiguration)
|
|
||||||
: IQueryHandler<GetPlayListCoverQuery, ErrorOr<PlayListCoverLocation>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<PlayListCoverLocation>> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var pictures = await database.PlayLists.AsNoTracking()
|
|
||||||
.Where(p => p.Id == request.PlayListId)
|
|
||||||
.Select(p => p.Pictures)
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (pictures is null)
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
var routes = playListConfiguration.Routes;
|
|
||||||
var (name, key) = request.Size.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
|
|
||||||
"large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
|
|
||||||
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
|
|
||||||
};
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
var contentType = MimeUtility.GetMimeMapping(name);
|
|
||||||
return new PlayListCoverLocation(storageConfiguration.Bucket, key, contentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? BuildKey(Func<string, string> builder, string? name)
|
|
||||||
=> string.IsNullOrEmpty(name) ? null : builder(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,50 +2,41 @@ using ErrorOr;
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.PlayLists.Responses;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.ValueObjects;
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
namespace Musify.Application.PlayLists
|
||||||
{
|
{
|
||||||
public record GetPlayListTracksQuery(
|
public record GetPlayListTracksQuery(
|
||||||
Guid PlayListId,
|
Guid PlayListId,
|
||||||
int PageNumber,
|
int PageNumber,
|
||||||
int PageSize)
|
int PageSize,
|
||||||
|
long? ViewerId = null)
|
||||||
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
||||||
|
|
||||||
public class GetPlayListTracksQueryHandler(IDatabase database)
|
public class GetPlayListTracksQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetPlayListTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>> >
|
: IQueryHandler<GetPlayListTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse> >> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var playListExists = await database.PlayLists
|
var playListVisible = await database.PlayLists
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(p => p.Id == request.PlayListId, cancellationToken);
|
.Active()
|
||||||
if (!playListExists)
|
.VisibleTo(request.ViewerId)
|
||||||
return Error.NotFound();
|
.AnyAsync(playList => playList.Id == request.PlayListId, cancellationToken);
|
||||||
|
if (!playListVisible)
|
||||||
|
return AppErrors.NotFound("PlayList", request.PlayListId);
|
||||||
|
|
||||||
var tracksQuery = database.PlayListHasTracks
|
return await database.PlayListHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(plt => ((ExternalTrack)plt.Track).TrackArtists)
|
.Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
.ThenInclude(trackArtist => trackArtist.Artist)
|
.OrderBy(playListTrack => playListTrack.Position)
|
||||||
.Include(plt => ((LocalTrack)plt.Track).Owner)
|
.ThenBy(playListTrack => playListTrack.Id)
|
||||||
.Where(plt => plt.PlayListId == request.PlayListId);
|
.Select(playListTrack => playListTrack.Track)
|
||||||
|
.SelectResponse()
|
||||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
|
|
||||||
var pagedEntities = await tracksQuery
|
|
||||||
.OrderBy(plt => plt.Position)
|
|
||||||
.Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count })
|
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
|
||||||
|
|
||||||
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
|
|
||||||
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
|
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
|
||||||
|
|
||||||
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.PlayLists.Responses;
|
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
using Mediator;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
|
||||||
{
|
|
||||||
public record GetPlayListsQuery(int PageNumber, int PageSize)
|
|
||||||
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>> >;
|
|
||||||
|
|
||||||
public class GetPlayListsQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetPlayListsQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse> >>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse> >> Handle(GetPlayListsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var totalCount = await database.PlayLists.CountAsync(cancellationToken);
|
|
||||||
|
|
||||||
var pagedEntities = await database.PlayLists
|
|
||||||
.AsNoTracking()
|
|
||||||
.OrderBy(p => p.Id)
|
|
||||||
.Select(p => new
|
|
||||||
{
|
|
||||||
PlayList = p,
|
|
||||||
CoverTrackIds = p.PlayListTracks
|
|
||||||
.OrderBy(plt => plt.Position)
|
|
||||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(plt => plt.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
|
||||||
|
|
||||||
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
|
||||||
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
|
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
|
||||||
|
|
||||||
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
namespace Musify.Application.PlayLists
|
||||||
{
|
{
|
||||||
@@ -14,45 +10,32 @@ namespace Musify.Application.PlayLists
|
|||||||
long UserId,
|
long UserId,
|
||||||
string? Name,
|
string? Name,
|
||||||
int PageNumber,
|
int PageNumber,
|
||||||
int PageSize)
|
int PageSize,
|
||||||
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>> >;
|
long? ViewerId = null)
|
||||||
|
: IQuery<PaginatedResponse<PlayListApplicationResponse>>;
|
||||||
|
|
||||||
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
|
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetPlayListsByUserIdQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse> > >
|
: IQueryHandler<GetPlayListsByUserIdQuery, PaginatedResponse<PlayListApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse> >> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
|
public async ValueTask<PaginatedResponse<PlayListApplicationResponse>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var playListsQuery = database.PlayLists
|
var playListsQuery = database.PlayLists
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(p => p.UserId == request.UserId);
|
.Active()
|
||||||
|
.VisibleTo(request.ViewerId)
|
||||||
|
.Where(playList => playList.OwnerUserId == request.UserId);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Name))
|
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||||
{
|
{
|
||||||
var normalizedName = request.Name.Trim().ToUpperInvariant();
|
var normalizedName = TextNormalizer.Normalize(request.Name);
|
||||||
playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName));
|
playListsQuery = playListsQuery.Where(playList => playList.NormalizedName.Contains(normalizedName));
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalCount = await playListsQuery.CountAsync(cancellationToken);
|
return await playListsQuery
|
||||||
|
.OrderBy(playList => playList.CreatedAt)
|
||||||
var pagedEntities = await playListsQuery
|
.ThenBy(playList => playList.Id)
|
||||||
.OrderBy(p => p.CreatedAt)
|
.SelectResponse()
|
||||||
.Where(p => p.LifeCycleStatus == LifeCycleStatus.Active)
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
.Select(p => new
|
|
||||||
{
|
|
||||||
PlayList = p,
|
|
||||||
CoverTrackIds = p.PlayListTracks
|
|
||||||
.OrderBy(plt => plt.Position)
|
|
||||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(plt => plt.TrackId)
|
|
||||||
.ToList()
|
|
||||||
} )
|
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
|
||||||
|
|
||||||
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
|
||||||
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
|
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
|
||||||
|
|
||||||
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
namespace Musify.Application.PlayLists
|
||||||
{
|
{
|
||||||
@@ -16,34 +17,30 @@ namespace Musify.Application.PlayLists
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var playList = await database.PlayLists
|
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||||
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
if (playList.IsError)
|
||||||
if (playList is null)
|
return playList.Errors;
|
||||||
{
|
|
||||||
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playList.UserId != request.UserId)
|
var playListTracks = await database.PlayListHasTracks
|
||||||
{
|
.Where(plt => plt.PlayListId == request.PlayListId)
|
||||||
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
|
.OrderBy(plt => plt.Position)
|
||||||
return Error.Unauthorized();
|
.ToListAsync(cancellationToken);
|
||||||
}
|
|
||||||
|
|
||||||
var link = await database.PlayListHasTracks
|
var link = playListTracks.SingleOrDefault(plt => plt.TrackId == request.TrackId);
|
||||||
.SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
|
if (link == null)
|
||||||
if (link is null)
|
return Error.NotFound("PlayList.TrackNotFound", "Track is not in the playlist.");
|
||||||
{
|
|
||||||
logger.LogInformation("Track {TrackId} not in playlist {PlayListId}", request.TrackId, request.PlayListId);
|
|
||||||
return Error.NotFound(description: "Track is not in the playlist.");
|
|
||||||
}
|
|
||||||
|
|
||||||
database.PlayListHasTracks.Remove(link);
|
database.PlayListHasTracks.Remove(link);
|
||||||
|
|
||||||
|
var position = 0;
|
||||||
|
foreach (var remaining in playListTracks.Where(plt => plt.Id != link.Id))
|
||||||
|
remaining.Position = position++;
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId);
|
logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId);
|
||||||
|
|
||||||
return new Success();
|
return Result.Success;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.Pictures.Responses;
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Services;
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
namespace Musify.Application.PlayLists
|
||||||
{
|
{
|
||||||
@@ -16,90 +12,14 @@ namespace Musify.Application.PlayLists
|
|||||||
string FileType,
|
string FileType,
|
||||||
string ContentType,
|
string ContentType,
|
||||||
long? ExpectedSizeBytes = null)
|
long? ExpectedSizeBytes = null)
|
||||||
: ICommand<ErrorOr<PlayListPictureUploadResponse>>;
|
: ICommand<ErrorOr<PictureUploadResponse>>;
|
||||||
|
|
||||||
public class RequestPlayListPictureUploadCommandHandler(
|
public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration)
|
||||||
IDatabase database,
|
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PictureUploadResponse>>
|
||||||
UploadIntentValidator uploadIntentValidator,
|
|
||||||
IStorageService storageService,
|
|
||||||
ILogger<RequestPlayListPictureUploadCommandHandler> logger,
|
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
|
||||||
PlayListConfiguration playListConfiguration,
|
|
||||||
UploadIntentConfiguration uploadIntentConfiguration)
|
|
||||||
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PlayListPictureUploadResponse>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PlayListPictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) =>
|
||||||
{
|
await issuer.IssuePictureAsync(
|
||||||
var userExists = await database.Users
|
request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration,
|
||||||
.AsNoTracking()
|
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
|
||||||
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
|
|
||||||
if (!userExists)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} not found", request.UserId);
|
|
||||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var effectiveSizeBytes = request.ExpectedSizeBytes
|
|
||||||
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
|
||||||
|
|
||||||
var objectName = $"{Guid.NewGuid() }.{request.FileType.TrimStart('.').ToLowerInvariant() }";
|
|
||||||
|
|
||||||
var tempKey = playListConfiguration.Routes.BuildTempPicturePath(
|
|
||||||
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var uploadUrl = await storageService.GetUploadUrlAsync(
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
tempKey,
|
|
||||||
request.ContentType,
|
|
||||||
TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
await using var transaction = await database.BeginTransactionAsync(
|
|
||||||
System.Data.IsolationLevel.Serializable, cancellationToken);
|
|
||||||
|
|
||||||
var quotaCheck = await uploadIntentValidator.CheckQuotaAsync(
|
|
||||||
uploadIntentConfiguration,
|
|
||||||
request.UserId, effectiveSizeBytes, 1, cancellationToken);
|
|
||||||
if (quotaCheck.IsError)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
|
|
||||||
return quotaCheck.Errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
var intent = new UploadIntent
|
|
||||||
{
|
|
||||||
UserId = request.UserId,
|
|
||||||
Bucket = storageConfiguration.Bucket,
|
|
||||||
Key = tempKey,
|
|
||||||
ObjectName = objectName,
|
|
||||||
ContentType = request.ContentType,
|
|
||||||
ExpectedSizeBytes = effectiveSizeBytes,
|
|
||||||
Purpose = UploadIntentPurpose.PlayListPicture,
|
|
||||||
ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
|
|
||||||
};
|
|
||||||
|
|
||||||
await database.UploadIntents.AddAsync(intent, cancellationToken);
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
await transaction.CommitAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (PlayListPicture, temp key: {Key} )", intent.Id, request.UserId, tempKey);
|
|
||||||
|
|
||||||
return new PlayListPictureUploadResponse(
|
|
||||||
intent.Id,
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
tempKey,
|
|
||||||
objectName,
|
|
||||||
request.ContentType,
|
|
||||||
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
|
|
||||||
uploadUrl);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to generate playlist picture upload URL for user {UserId}", request.UserId);
|
|
||||||
return Error.Failure(description: "Failed to generate upload URL");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists.Responses
|
namespace Musify.Application.PlayLists.Responses
|
||||||
{
|
{
|
||||||
@@ -9,15 +11,21 @@ namespace Musify.Application.PlayLists.Responses
|
|||||||
string? SmallImageKeyName,
|
string? SmallImageKeyName,
|
||||||
string? MediumImageKeyName,
|
string? MediumImageKeyName,
|
||||||
string? LargeImageKeyName,
|
string? LargeImageKeyName,
|
||||||
|
PlayListVisibility Visibility,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
DateTime UpdatedAt,
|
DateTime UpdatedAt,
|
||||||
IReadOnlyList<Guid> CoverTrackIds)
|
IReadOnlyList<Guid> CoverTrackIds,
|
||||||
|
long OwnerUserId,
|
||||||
|
int TrackCount,
|
||||||
|
double DurationSeconds)
|
||||||
{
|
{
|
||||||
public const int CoverTrackCount = 4;
|
public const int CoverTrackCount = PlayListProjections.CoverTrackCount;
|
||||||
|
|
||||||
public static PlayListApplicationResponse FromEntity(
|
public static PlayListApplicationResponse FromEntity(
|
||||||
PlayList playList,
|
PlayList playList,
|
||||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
IReadOnlyList<Guid>? coverTrackIds = null,
|
||||||
|
int trackCount = 0,
|
||||||
|
double durationSeconds = 0)
|
||||||
{
|
{
|
||||||
return new PlayListApplicationResponse(
|
return new PlayListApplicationResponse(
|
||||||
playList.Id,
|
playList.Id,
|
||||||
@@ -26,9 +34,13 @@ namespace Musify.Application.PlayLists.Responses
|
|||||||
playList.Pictures?.SmallName,
|
playList.Pictures?.SmallName,
|
||||||
playList.Pictures?.MediumName,
|
playList.Pictures?.MediumName,
|
||||||
playList.Pictures?.LargeName,
|
playList.Pictures?.LargeName,
|
||||||
|
playList.Visibility,
|
||||||
playList.CreatedAt,
|
playList.CreatedAt,
|
||||||
playList.UpdatedAt,
|
playList.UpdatedAt,
|
||||||
coverTrackIds ?? []);
|
coverTrackIds ?? [],
|
||||||
|
playList.OwnerUserId,
|
||||||
|
trackCount,
|
||||||
|
durationSeconds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
namespace Musify.Application.PlayLists.Responses
|
|
||||||
{
|
|
||||||
public record PlayListPictureUploadResponse(
|
|
||||||
Guid IntentId,
|
|
||||||
string Bucket,
|
|
||||||
string Key,
|
|
||||||
string PictureName,
|
|
||||||
string ContentType,
|
|
||||||
int ExpiresInSeconds,
|
|
||||||
string UploadUrl);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.PlayLists.Responses
|
||||||
|
{
|
||||||
|
public static class PlayListProjections
|
||||||
|
{
|
||||||
|
public const int CoverTrackCount = 4;
|
||||||
|
|
||||||
|
public static IQueryable<PlayList> Active(this IQueryable<PlayList> query) =>
|
||||||
|
query.Where(playList => playList.LifeCycleStatus == LifeCycleStatus.Active);
|
||||||
|
|
||||||
|
public static IQueryable<PlayList> VisibleTo(this IQueryable<PlayList> query, long? viewerId) =>
|
||||||
|
query.Where(playList => playList.Visibility == PlayListVisibility.Public || playList.OwnerUserId == viewerId);
|
||||||
|
|
||||||
|
public static IQueryable<PlayListApplicationResponse> SelectResponse(this IQueryable<PlayList> query) =>
|
||||||
|
query.Select(playList => new PlayListApplicationResponse(
|
||||||
|
playList.Id,
|
||||||
|
playList.Name,
|
||||||
|
playList.Description,
|
||||||
|
playList.Pictures!.SmallName,
|
||||||
|
playList.Pictures!.MediumName,
|
||||||
|
playList.Pictures!.LargeName,
|
||||||
|
playList.Visibility,
|
||||||
|
playList.CreatedAt,
|
||||||
|
playList.UpdatedAt,
|
||||||
|
playList.PlayListTracks
|
||||||
|
.OrderBy(playListTrack => playListTrack.Position)
|
||||||
|
.Take(CoverTrackCount)
|
||||||
|
.Select(playListTrack => playListTrack.TrackId)
|
||||||
|
.ToList(),
|
||||||
|
playList.OwnerUserId,
|
||||||
|
playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active),
|
||||||
|
playList.PlayListTracks
|
||||||
|
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.Sum(playListTrack => playListTrack.Track.DurationSeconds)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,13 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Events;
|
using Musify.Application.Events;
|
||||||
|
using Musify.Application.Pictures;
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Services;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
namespace Musify.Application.PlayLists
|
||||||
{
|
{
|
||||||
@@ -18,7 +18,8 @@ namespace Musify.Application.PlayLists
|
|||||||
Guid PlayListId,
|
Guid PlayListId,
|
||||||
string? NewName,
|
string? NewName,
|
||||||
string? NewDescription,
|
string? NewDescription,
|
||||||
Guid? NewPictureIntentId)
|
Guid? NewPictureIntentId,
|
||||||
|
PlayListVisibility? NewVisibility = null)
|
||||||
: ICommand<ErrorOr<PlayListApplicationResponse>>;
|
: ICommand<ErrorOr<PlayListApplicationResponse>>;
|
||||||
|
|
||||||
public class UpdatePlayListCommandHandler(
|
public class UpdatePlayListCommandHandler(
|
||||||
@@ -26,126 +27,55 @@ namespace Musify.Application.PlayLists
|
|||||||
IDatabase database,
|
IDatabase database,
|
||||||
UploadIntentValidator uploadIntentValidator,
|
UploadIntentValidator uploadIntentValidator,
|
||||||
ILogger<UpdatePlayListCommandHandler> logger,
|
ILogger<UpdatePlayListCommandHandler> logger,
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
PlayListConfiguration playListConfiguration)
|
||||||
PlayListConfiguration playListConfiguration,
|
|
||||||
UploadIntentConfiguration uploadIntentConfiguration)
|
|
||||||
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||||
if (playListEntity is null)
|
if (found.IsError)
|
||||||
{
|
return found.Errors;
|
||||||
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playListEntity.UserId != request.UserId)
|
var playList = found.Value;
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.NewName))
|
if (!string.IsNullOrWhiteSpace(request.NewName))
|
||||||
{
|
{
|
||||||
playListEntity.Name = request.NewName;
|
playList.Name = request.NewName.Trim();
|
||||||
playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant();
|
playList.NormalizedName = TextNormalizer.Normalize(request.NewName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.NewDescription))
|
// null means "unchanged"; a blank description clears it.
|
||||||
|
if (request.NewDescription != null)
|
||||||
|
playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim();
|
||||||
|
|
||||||
|
if (request.NewVisibility is { } visibility)
|
||||||
|
playList.Visibility = visibility;
|
||||||
|
|
||||||
|
if (request.NewPictureIntentId is { } intentId)
|
||||||
{
|
{
|
||||||
playListEntity.Description = request.NewDescription;
|
var prepared = await PictureSourceChange.PrepareAsync(
|
||||||
}
|
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
|
||||||
|
if (prepared.IsError)
|
||||||
|
return prepared.Errors;
|
||||||
|
|
||||||
UploadIntent? pictureIntent = null;
|
var picture = prepared.Value;
|
||||||
string? finalPictureKey = null;
|
playList.Pictures = picture.Pictures;
|
||||||
|
|
||||||
if (request.NewPictureIntentId.HasValue)
|
|
||||||
{
|
|
||||||
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
|
||||||
uploadIntentConfiguration,
|
|
||||||
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
|
|
||||||
if (validation.IsError)
|
|
||||||
return validation.Errors;
|
|
||||||
|
|
||||||
pictureIntent = validation.Value;
|
|
||||||
playListEntity.Pictures = new PlayListPictures
|
|
||||||
{
|
|
||||||
OriginalName = pictureIntent.ObjectName,
|
|
||||||
SmallName = playListConfiguration.Routes.PresetSmallPicture,
|
|
||||||
MediumName = playListConfiguration.Routes.PresetMediumPicture,
|
|
||||||
LargeName = playListConfiguration.Routes.PresetLargePicture
|
|
||||||
};
|
|
||||||
finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
|
||||||
}
|
|
||||||
|
|
||||||
database.PlayLists.Update(playListEntity);
|
|
||||||
|
|
||||||
if (pictureIntent != null&& finalPictureKey != null)
|
|
||||||
{
|
|
||||||
var publishResult = await PublishUpdatePlayListPictureSourceEventAsync(
|
|
||||||
playListEntity.Id, pictureIntent, finalPictureKey, cancellationToken);
|
|
||||||
if (publishResult.IsError)
|
|
||||||
return publishResult.Errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to update playlist {PlayListId}", playListEntity.Id);
|
|
||||||
return Error.Failure(description: $"Failed to update playlist {playListEntity.Id}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var coverTrackIds = await database.PlayListHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(plt => plt.PlayListId == playListEntity.Id)
|
|
||||||
.OrderBy(plt => plt.Position)
|
|
||||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(plt => plt.TrackId)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
|
|
||||||
return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync(
|
|
||||||
Guid playListId,
|
|
||||||
UploadIntent pictureIntent,
|
|
||||||
string finalPictureKey,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await eventBus.PublishAsync(
|
await eventBus.PublishAsync(
|
||||||
new UpdatePlayListPictureSourceEvent(
|
new UpdatePlayListPictureSourceEvent(
|
||||||
playListId,
|
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||||
pictureIntent.Id,
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
pictureIntent.Key,
|
|
||||||
finalPictureKey,
|
|
||||||
new ImageSize(
|
|
||||||
playListConfiguration.Routes.SmallPicturesPath,
|
|
||||||
playListConfiguration.PicturesSizes.SmallPictureWidth,
|
|
||||||
playListConfiguration.PicturesSizes.SmallPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
playListConfiguration.Routes.MediumPicturesPath,
|
|
||||||
playListConfiguration.PicturesSizes.MediumPictureWidth,
|
|
||||||
playListConfiguration.PicturesSizes.MediumPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
playListConfiguration.Routes.LargePicturesPath,
|
|
||||||
playListConfiguration.PicturesSizes.LargePictureWidth,
|
|
||||||
playListConfiguration.PicturesSizes.LargePictureHeight)),
|
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to publish update playlist picture source event for playlist {PlayListId}", playListId);
|
|
||||||
return Error.Failure(description: $"Failed to publish update playlist picture source event for playlist {playListId}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Updated playlist {PlayListId}", playList.Id);
|
||||||
|
|
||||||
|
return await database.PlayLists
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(p => p.Id == playList.Id)
|
||||||
|
.SelectResponse()
|
||||||
|
.SingleAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,36 @@
|
|||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Musify.Application.Services;
|
namespace Musify.Application.Services
|
||||||
|
|
||||||
public sealed class TrackStreamIssuer(
|
|
||||||
IDatabase database,
|
|
||||||
IStreamTicketService ticketService,
|
|
||||||
TrackConfiguration trackConfiguration,
|
|
||||||
StreamGatewayConfiguration streamGatewayConfiguration,
|
|
||||||
PlaybackConfiguration playbackConfiguration)
|
|
||||||
{
|
{
|
||||||
public async Task<TrackStreamResponse> IssueAsync(
|
/// <summary>Builds the stream URL and signed ticket for a processed track. It never touches the database.</summary>
|
||||||
Guid trackId,
|
public sealed class TrackStreamIssuer(
|
||||||
string audioFolderName,
|
IStreamTicketService ticketService,
|
||||||
long? userId,
|
TrackConfiguration trackConfiguration,
|
||||||
CancellationToken cancellationToken)
|
StreamGatewayConfiguration streamGatewayConfiguration,
|
||||||
|
PlaybackConfiguration playbackConfiguration)
|
||||||
{
|
{
|
||||||
var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName);
|
public TrackStreamResponse Issue(string audioFolderName, long? userId, Guid? listenId)
|
||||||
var keyPrefix = $"{folderPath}/";
|
|
||||||
|
|
||||||
var maxBytes = AnonymousFragmentBytes(userId);
|
|
||||||
var ticket = ticketService.IssueTicket(userId, keyPrefix, maxBytes);
|
|
||||||
|
|
||||||
var manifestUrl = string.Join('/',
|
|
||||||
streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'),
|
|
||||||
"media",
|
|
||||||
folderPath,
|
|
||||||
streamGatewayConfiguration.AudioFileName);
|
|
||||||
|
|
||||||
// Anonymous listeners have no User row to satisfy ListeningHistory's foreign key against, and
|
|
||||||
// their plays aren't "history" anyone can look back on anyway, so there's nothing to record.
|
|
||||||
if (userId is not null)
|
|
||||||
{
|
{
|
||||||
var newListeningHistory = new ListeningHistory
|
var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName);
|
||||||
{
|
var ticket = ticketService.IssueTicket(userId, $"{folderPath}/", AnonymousFragmentBytes(userId));
|
||||||
UserId = userId.Value,
|
|
||||||
TrackId = trackId,
|
var manifestUrl = string.Join('/',
|
||||||
};
|
streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'),
|
||||||
await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken);
|
"media",
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
folderPath,
|
||||||
|
streamGatewayConfiguration.AudioFileName);
|
||||||
|
|
||||||
|
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds);
|
private long? AnonymousFragmentBytes(long? userId)
|
||||||
}
|
{
|
||||||
|
if (userId != null || playbackConfiguration.AnonymousFragmentSeconds <= 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
private long? AnonymousFragmentBytes(long? userId)
|
return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond;
|
||||||
{
|
}
|
||||||
if (userId is not null || playbackConfiguration.AnonymousFragmentSeconds <= 0)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Pictures.Responses;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Services
|
||||||
|
{
|
||||||
|
public sealed record UploadRequest(
|
||||||
|
UploadIntentPurpose Purpose,
|
||||||
|
PictureRoutes Routes,
|
||||||
|
string FileType,
|
||||||
|
string ContentType,
|
||||||
|
long? ExpectedSizeBytes);
|
||||||
|
|
||||||
|
public sealed record IssuedUpload(
|
||||||
|
Guid IntentId,
|
||||||
|
string Key,
|
||||||
|
string ObjectName,
|
||||||
|
string ContentType,
|
||||||
|
string UploadUrl);
|
||||||
|
|
||||||
|
/// <summary>Issues pre-signed upload URLs and the upload intents that track them, checking the user quota first.</summary>
|
||||||
|
public sealed class UploadIntentIssuer(
|
||||||
|
IDatabase database,
|
||||||
|
IStorageService storageService,
|
||||||
|
UploadIntentValidator uploadIntentValidator,
|
||||||
|
ApplicationStorageConfiguration storageConfiguration,
|
||||||
|
UploadIntentConfiguration uploadIntentConfiguration,
|
||||||
|
ILogger<UploadIntentIssuer> logger)
|
||||||
|
{
|
||||||
|
public async Task<ErrorOr<IReadOnlyList<IssuedUpload>>> IssueAsync(
|
||||||
|
long userId,
|
||||||
|
IReadOnlyList<UploadRequest> requests,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userExists = await database.Users
|
||||||
|
.AsNoTracking()
|
||||||
|
.AnyAsync(user => user.Id == userId, cancellationToken);
|
||||||
|
if (!userExists)
|
||||||
|
return AppErrors.NotFound("User", userId);
|
||||||
|
|
||||||
|
var planned = requests
|
||||||
|
.Select(request =>
|
||||||
|
{
|
||||||
|
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
|
||||||
|
var key = request.Routes.BuildTempPath(uploadIntentConfiguration.TempRootPrefix, userId, objectName);
|
||||||
|
var size = request.ExpectedSizeBytes ?? DefaultSize(request.Purpose);
|
||||||
|
return (Request: request, ObjectName: objectName, Key: key, Size: size);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
await using var transaction = await database.BeginTransactionAsync(
|
||||||
|
System.Data.IsolationLevel.Serializable, cancellationToken);
|
||||||
|
|
||||||
|
var quota = await uploadIntentValidator.CheckQuotaAsync(
|
||||||
|
userId, planned.Sum(item => item.Size), planned.Count, cancellationToken);
|
||||||
|
if (quota.IsError)
|
||||||
|
return quota.Errors;
|
||||||
|
|
||||||
|
var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds);
|
||||||
|
var expiresAt = DateTime.UtcNow + expiresIn;
|
||||||
|
var issued = new List<IssuedUpload>(planned.Count);
|
||||||
|
|
||||||
|
foreach (var item in planned)
|
||||||
|
{
|
||||||
|
var uploadUrl = await storageService.GetUploadUrlAsync(
|
||||||
|
storageConfiguration.Bucket, item.Key, item.Request.ContentType, expiresIn, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
var intent = new UploadIntent
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
Bucket = storageConfiguration.Bucket,
|
||||||
|
Key = item.Key,
|
||||||
|
ObjectName = item.ObjectName,
|
||||||
|
ContentType = item.Request.ContentType,
|
||||||
|
ExpectedSizeBytes = item.Size,
|
||||||
|
Purpose = item.Request.Purpose,
|
||||||
|
ExpiresAt = expiresAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
await database.UploadIntents.AddAsync(intent, cancellationToken);
|
||||||
|
issued.Add(new IssuedUpload(intent.Id, item.Key, item.ObjectName, item.Request.ContentType, uploadUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Issued {Count} upload intents for user {UserId} ({Purposes})",
|
||||||
|
issued.Count, userId, string.Join('/', planned.Select(item => item.Request.Purpose)));
|
||||||
|
|
||||||
|
return issued;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ErrorOr<IssuedUpload>> IssueOneAsync(long userId, UploadRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var issued = await IssueAsync(userId, [request], cancellationToken);
|
||||||
|
return issued.IsError ? issued.Errors : issued.Value[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ErrorOr<PictureUploadResponse>> IssuePictureAsync(
|
||||||
|
long userId,
|
||||||
|
UploadIntentPurpose purpose,
|
||||||
|
IPictureOwnerConfiguration owner,
|
||||||
|
string fileType,
|
||||||
|
string contentType,
|
||||||
|
long? expectedSizeBytes,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var issued = await IssueOneAsync(
|
||||||
|
userId, new UploadRequest(purpose, owner.Routes, fileType, contentType, expectedSizeBytes), cancellationToken);
|
||||||
|
if (issued.IsError)
|
||||||
|
return issued.Errors;
|
||||||
|
|
||||||
|
return new PictureUploadResponse(
|
||||||
|
issued.Value.IntentId,
|
||||||
|
storageConfiguration.Bucket,
|
||||||
|
issued.Value.Key,
|
||||||
|
issued.Value.ObjectName,
|
||||||
|
issued.Value.ContentType,
|
||||||
|
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
|
||||||
|
issued.Value.UploadUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long DefaultSize(UploadIntentPurpose purpose) =>
|
||||||
|
purpose == UploadIntentPurpose.TrackAudio
|
||||||
|
? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes
|
||||||
|
: uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,63 +1,78 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Services;
|
namespace Musify.Application.Services
|
||||||
|
|
||||||
public sealed class UploadIntentValidator(
|
|
||||||
IDatabase database,
|
|
||||||
IStorageService storageService)
|
|
||||||
{
|
{
|
||||||
public async Task<ErrorOr<Success>> CheckQuotaAsync(
|
public sealed class UploadIntentValidator(
|
||||||
UploadIntentConfiguration config,
|
IDatabase database,
|
||||||
long userId,
|
IStorageService storageService,
|
||||||
long requiredBytes,
|
UploadIntentConfiguration config)
|
||||||
int requiredIntentCount,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
var activeIntents = await database.UploadIntents
|
public async Task<ErrorOr<Success>> CheckQuotaAsync(
|
||||||
.AsNoTracking()
|
long userId,
|
||||||
.Where(i => i.UserId == userId && i.Status == UploadIntentStatus.Issued)
|
long requiredBytes,
|
||||||
.ToListAsync(cancellationToken);
|
int requiredIntentCount,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var pictureDefault = config.DefaultExpectedPictureSizeBytes;
|
||||||
|
var audioDefault = config.DefaultExpectedAudioSizeBytes;
|
||||||
|
|
||||||
if (activeIntents.Count + requiredIntentCount > config.MaxActiveUploadIntentsPerUser)
|
var active = await database.UploadIntents
|
||||||
return Error.Validation(description: "Upload intent limit exceeded. Wait for existing uploads to complete or expire.");
|
.AsNoTracking()
|
||||||
|
.Where(intent => intent.UserId == userId && intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt > now)
|
||||||
|
.GroupBy(_ => 1)
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
Count = group.Count(),
|
||||||
|
Bytes = group.Sum(intent => intent.ExpectedSizeBytes
|
||||||
|
?? (intent.Purpose == UploadIntentPurpose.TrackAudio ? audioDefault : pictureDefault))
|
||||||
|
})
|
||||||
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
var activeBytes = activeIntents.Sum(i => i.ExpectedSizeBytes ?? config.DefaultExpectedPictureSizeBytes);
|
if ((active?.Count ?? 0) + requiredIntentCount > config.MaxActiveUploadIntentsPerUser)
|
||||||
if (activeBytes + requiredBytes > config.MaxActiveUploadBytesPerUser)
|
return Error.Validation(description: "Upload intent limit exceeded. Wait for existing uploads to complete or expire.");
|
||||||
return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire.");
|
|
||||||
|
|
||||||
return new Success();
|
if ((active?.Bytes ?? 0) + requiredBytes > config.MaxActiveUploadBytesPerUser)
|
||||||
}
|
return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire.");
|
||||||
|
|
||||||
public async Task<ErrorOr<UploadIntent>> ValidateAndLoadAsync(
|
return Result.Success;
|
||||||
UploadIntentConfiguration config,
|
}
|
||||||
Guid intentId,
|
|
||||||
long userId,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var intent = await database.UploadIntents
|
|
||||||
.FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken);
|
|
||||||
|
|
||||||
if (intent is null || intent.UserId != userId)
|
public async Task<ErrorOr<UploadIntent>> ValidateAndLoadAsync(
|
||||||
return Error.NotFound(description: "Upload intent not found or not accessible.");
|
Guid intentId,
|
||||||
|
long userId,
|
||||||
|
UploadIntentPurpose purpose,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var intent = await database.UploadIntents
|
||||||
|
.FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken);
|
||||||
|
|
||||||
if (intent.IsConsumed)
|
if (intent == null || intent.UserId != userId)
|
||||||
return Error.Conflict(description: "Upload intent has already been consumed.");
|
return AppErrors.NotFound("UploadIntent", intentId);
|
||||||
|
|
||||||
if (intent.IsExpired)
|
if (intent.Purpose != purpose)
|
||||||
return Error.Validation(description: "Upload intent has expired.");
|
return Error.Validation(description: "Upload intent was issued for a different purpose.");
|
||||||
|
|
||||||
var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken);
|
if (intent.IsConsumed)
|
||||||
if (metadata is null)
|
return AppErrors.Conflict("UploadIntent.Consumed", "Upload intent has already been consumed.");
|
||||||
return Error.NotFound(description: "Uploaded object not found in storage. Upload the file first.");
|
|
||||||
|
|
||||||
if (metadata.ContentLength > config.MaxUploadBytes)
|
if (intent.IsExpiredAt(DateTime.UtcNow))
|
||||||
return Error.Validation(description: $"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes.");
|
return Error.Validation(description: "Upload intent has expired.");
|
||||||
|
|
||||||
return intent;
|
var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken);
|
||||||
|
if (metadata == null)
|
||||||
|
return Error.NotFound("UploadIntent.ObjectMissing", "Uploaded object not found in storage. Upload the file first.");
|
||||||
|
|
||||||
|
if (metadata.ContentLength > config.MaxUploadBytes)
|
||||||
|
return Error.Validation(description: $"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes.");
|
||||||
|
|
||||||
|
return intent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,265 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Services;
|
|
||||||
|
|
||||||
public sealed record YouTubeTrackProvision(ExternalTrack Track, YouTubeSongResult? Song);
|
|
||||||
|
|
||||||
public sealed class YouTubeTrackProvisioner(
|
|
||||||
IDatabase database,
|
|
||||||
IYouTubeMusicService youTubeMusicService,
|
|
||||||
TrackConfiguration trackConfiguration,
|
|
||||||
ILogger<YouTubeTrackProvisioner> logger)
|
|
||||||
{
|
|
||||||
public Task<ExternalTrack?> FindAsync(string videoId, CancellationToken cancellationToken) =>
|
|
||||||
database.ExternalTracks
|
|
||||||
.Include(track => track.TrackArtists)
|
|
||||||
.ThenInclude(trackArtist => trackArtist.Artist)
|
|
||||||
.SingleOrDefaultAsync(t => t.Source == TrackSource.YouTube && t.ExternalId == videoId, cancellationToken);
|
|
||||||
|
|
||||||
public async Task<ErrorOr<YouTubeTrackProvision>> GetOrCreateAsync(string videoId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var existing = await FindAsync(videoId, cancellationToken);
|
|
||||||
if (existing is not null)
|
|
||||||
return new YouTubeTrackProvision(existing, Song: null);
|
|
||||||
|
|
||||||
var songResult = await youTubeMusicService.GetSongAsync(videoId, cancellationToken);
|
|
||||||
if (songResult.IsError)
|
|
||||||
return songResult.Errors;
|
|
||||||
|
|
||||||
var song = songResult.Value;
|
|
||||||
|
|
||||||
var artistRefs = song.Artists
|
|
||||||
.GroupBy(artistRef => artistRef.Id ?? artistRef.Name.ToUpperInvariant())
|
|
||||||
.Select(group => group.First())
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var artists = new List<Artist>();
|
|
||||||
foreach (var artistRef in artistRefs)
|
|
||||||
artists.Add(await GetOrCreateArtistAsync(artistRef, cancellationToken));
|
|
||||||
|
|
||||||
var title = Truncate(song.Title, 200);
|
|
||||||
var track = new ExternalTrack
|
|
||||||
{
|
|
||||||
Title = title,
|
|
||||||
NormalizedTitle = title.ToUpperInvariant(),
|
|
||||||
Source = TrackSource.YouTube,
|
|
||||||
ExternalId = videoId,
|
|
||||||
DurationSeconds = song.DurationSeconds,
|
|
||||||
Pictures = new TrackPictures
|
|
||||||
{
|
|
||||||
SmallName = trackConfiguration.Routes.PresetSmallPicture,
|
|
||||||
MediumName = trackConfiguration.Routes.PresetMediumPicture,
|
|
||||||
LargeName = trackConfiguration.Routes.PresetLargePicture,
|
|
||||||
ProcessingStatus = ProcessingStatus.Pending
|
|
||||||
},
|
|
||||||
Audio = new TrackAudio
|
|
||||||
{
|
|
||||||
DownloadRequested = false,
|
|
||||||
TranscodeStatus = ProcessingStatus.Pending
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await database.ExternalTracks.AddAsync(track, cancellationToken);
|
|
||||||
|
|
||||||
for (var position = 0; position < artists.Count; position++)
|
|
||||||
{
|
|
||||||
await database.TrackArtists.AddAsync(new TrackArtist
|
|
||||||
{
|
|
||||||
TrackId = track.Id,
|
|
||||||
ArtistId = artists[position].Id,
|
|
||||||
Position = position
|
|
||||||
}, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
logger.LogInformation("Created YouTube track {VideoId} ({TrackId})", videoId, track.Id);
|
|
||||||
return new YouTubeTrackProvision(track, Song: song);
|
|
||||||
}
|
|
||||||
catch (DbUpdateException)
|
|
||||||
{
|
|
||||||
database.ExternalTracks.Remove(track);
|
|
||||||
var refetched = await FindAsync(videoId, cancellationToken);
|
|
||||||
if (refetched is null)
|
|
||||||
{
|
|
||||||
logger.LogWarning("YouTube track {VideoId} creation raced but could not be refetched", videoId);
|
|
||||||
return Error.Conflict(description: "The track was just created by another request. Retry the operation.");
|
|
||||||
}
|
|
||||||
return new YouTubeTrackProvision(refetched, Song: null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task MaterializeAlbumAsync(string albumExternalId, YouTubeAlbumDetail detail, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var validTracks = detail.Tracks
|
|
||||||
.Where(track => !string.IsNullOrEmpty(track.VideoId))
|
|
||||||
.ToList();
|
|
||||||
if (validTracks.Count == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var album = await GetOrCreateExternalAlbumAsync(albumExternalId, detail.Album.Title, detail.Album.ThumbnailUrl, cancellationToken);
|
|
||||||
|
|
||||||
var videoIds = validTracks.Select(track => track.VideoId).ToList();
|
|
||||||
|
|
||||||
var tracksByVideoId = await database.ExternalTracks
|
|
||||||
.Where(track => track.Source == TrackSource.YouTube && videoIds.Contains(track.ExternalId))
|
|
||||||
.ToDictionaryAsync(track => track.ExternalId, cancellationToken);
|
|
||||||
|
|
||||||
var linkedTrackIds = (await database.AlbumHasTracks
|
|
||||||
.Where(albumTrack => albumTrack.AlbumId == album.Id)
|
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
|
||||||
.ToListAsync(cancellationToken))
|
|
||||||
.ToHashSet();
|
|
||||||
|
|
||||||
if (tracksByVideoId.Count == validTracks.Count && linkedTrackIds.Count >= validTracks.Count)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var artistRefs = detail.Album.Artists
|
|
||||||
.GroupBy(artistRef => artistRef.Id ?? artistRef.Name.ToUpperInvariant())
|
|
||||||
.Select(group => group.First())
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var albumArtists = new List<Artist>();
|
|
||||||
foreach (var artistRef in artistRefs)
|
|
||||||
albumArtists.Add(await GetOrCreateArtistAsync(artistRef, cancellationToken));
|
|
||||||
|
|
||||||
foreach (var albumTrack in validTracks)
|
|
||||||
{
|
|
||||||
if (!tracksByVideoId.TryGetValue(albumTrack.VideoId, out var track))
|
|
||||||
{
|
|
||||||
track = BuildStubTrack(albumTrack);
|
|
||||||
await database.ExternalTracks.AddAsync(track, cancellationToken);
|
|
||||||
|
|
||||||
for (var position = 0; position < albumArtists.Count; position++)
|
|
||||||
{
|
|
||||||
await database.TrackArtists.AddAsync(new TrackArtist
|
|
||||||
{
|
|
||||||
TrackId = track.Id,
|
|
||||||
ArtistId = albumArtists[position].Id,
|
|
||||||
Position = position
|
|
||||||
}, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
tracksByVideoId[albumTrack.VideoId] = track;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (linkedTrackIds.Contains(track.Id))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
|
|
||||||
{
|
|
||||||
AlbumId = album.Id,
|
|
||||||
TrackId = track.Id,
|
|
||||||
TrackNumber = albumTrack.TrackNumber
|
|
||||||
}, cancellationToken);
|
|
||||||
linkedTrackIds.Add(track.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
logger.LogInformation("Materialized YouTube album {AlbumId} ({TrackCount} tracks)", albumExternalId, validTracks.Count);
|
|
||||||
}
|
|
||||||
catch (DbUpdateException exception)
|
|
||||||
{
|
|
||||||
logger.LogWarning(exception, "Race while materializing YouTube album {AlbumId}", albumExternalId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private ExternalTrack BuildStubTrack(YouTubeAlbumTrack albumTrack)
|
|
||||||
{
|
|
||||||
var title = Truncate(albumTrack.Title, 200);
|
|
||||||
return new ExternalTrack
|
|
||||||
{
|
|
||||||
Title = title,
|
|
||||||
NormalizedTitle = title.ToUpperInvariant(),
|
|
||||||
Source = TrackSource.YouTube,
|
|
||||||
ExternalId = albumTrack.VideoId,
|
|
||||||
DurationSeconds = albumTrack.DurationSeconds,
|
|
||||||
Pictures = new TrackPictures
|
|
||||||
{
|
|
||||||
SmallName = trackConfiguration.Routes.PresetSmallPicture,
|
|
||||||
MediumName = trackConfiguration.Routes.PresetMediumPicture,
|
|
||||||
LargeName = trackConfiguration.Routes.PresetLargePicture,
|
|
||||||
ProcessingStatus = ProcessingStatus.Pending
|
|
||||||
},
|
|
||||||
Audio = new TrackAudio
|
|
||||||
{
|
|
||||||
DownloadRequested = false,
|
|
||||||
TranscodeStatus = ProcessingStatus.Pending
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Artist> GetOrCreateArtistAsync(YouTubeArtistRef reference, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var name = Truncate(reference.Name, 200);
|
|
||||||
var normalized = name.ToUpperInvariant();
|
|
||||||
var externalId = reference.Id ?? $"name:{normalized}";
|
|
||||||
|
|
||||||
var existing = await database.Artists.SingleOrDefaultAsync(a => a.ExternalId == externalId, cancellationToken);
|
|
||||||
if (existing is not null)
|
|
||||||
return existing;
|
|
||||||
|
|
||||||
var artist = new Artist
|
|
||||||
{
|
|
||||||
Name = name,
|
|
||||||
NormalizedName = normalized,
|
|
||||||
ExternalId = externalId
|
|
||||||
};
|
|
||||||
|
|
||||||
await database.Artists.AddAsync(artist, cancellationToken);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
return artist;
|
|
||||||
}
|
|
||||||
catch (DbUpdateException)
|
|
||||||
{
|
|
||||||
database.Artists.Remove(artist);
|
|
||||||
return await database.Artists.SingleAsync(a => a.ExternalId == externalId, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<ExternalAlbum> GetOrCreateExternalAlbumAsync(string externalId, string name, string? thumbnailUrl, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var existing = await database.ExternalAlbums
|
|
||||||
.SingleOrDefaultAsync(album => album.Source == TrackSource.YouTube && album.ExternalId == externalId, cancellationToken);
|
|
||||||
if (existing is not null)
|
|
||||||
return existing;
|
|
||||||
|
|
||||||
var title = Truncate(name, 200);
|
|
||||||
var album = new ExternalAlbum
|
|
||||||
{
|
|
||||||
Title = title,
|
|
||||||
NormalizedTitle = title.ToUpperInvariant(),
|
|
||||||
Source = TrackSource.YouTube,
|
|
||||||
ExternalId = externalId,
|
|
||||||
ThumbnailUrl = string.IsNullOrEmpty(thumbnailUrl) ? null : Truncate(thumbnailUrl, 512)
|
|
||||||
};
|
|
||||||
|
|
||||||
await database.ExternalAlbums.AddAsync(album, cancellationToken);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
return album;
|
|
||||||
}
|
|
||||||
catch (DbUpdateException)
|
|
||||||
{
|
|
||||||
database.ExternalAlbums.Remove(album);
|
|
||||||
return await database.ExternalAlbums.SingleAsync(a => a.Source == TrackSource.YouTube && a.ExternalId == externalId, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Truncate(string value, int maxLength) =>
|
|
||||||
value.Length <= maxLength ? value : value[..maxLength];
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
|
||||||
|
namespace Musify.Application.Shared
|
||||||
|
{
|
||||||
|
public static class AppErrors
|
||||||
|
{
|
||||||
|
public static Error NotFound(string entity, object id) =>
|
||||||
|
Error.NotFound($"{entity}.NotFound", $"{entity} {id} was not found.");
|
||||||
|
|
||||||
|
public static Error Forbidden(string entity, object id) =>
|
||||||
|
Error.Forbidden($"{entity}.Forbidden", $"You do not have permission to modify {entity} {id}.");
|
||||||
|
|
||||||
|
public static Error Conflict(string code, string description) =>
|
||||||
|
Error.Conflict(code, description);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
namespace Musify.Application.Shared
|
namespace Musify.Application.Shared
|
||||||
{
|
{
|
||||||
public record ImageSize(
|
public record ImageSize(
|
||||||
string SavePath,
|
string SavePath,
|
||||||
int Width,
|
int Width,
|
||||||
int Height);
|
int Height);
|
||||||
}
|
|
||||||
|
public record ImageSizes(
|
||||||
|
ImageSize Small,
|
||||||
|
ImageSize Medium,
|
||||||
|
ImageSize Large);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Domain.Abstractions;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Shared
|
||||||
|
{
|
||||||
|
public static class OwnershipExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Loads an active, tracked entity owned by <paramref name="userId"/>: NotFound when it is missing
|
||||||
|
/// or not active, Forbidden when someone else owns it.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<ErrorOr<T>> FindOwnedAsync<T>(
|
||||||
|
this DbSet<T> set, Guid id, long userId, CancellationToken cancellationToken)
|
||||||
|
where T : class, IOwnedEntity, IHasLifeCycle
|
||||||
|
{
|
||||||
|
var entity = await set.SingleOrDefaultAsync(
|
||||||
|
e => e.Id == id && e.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
|
||||||
|
|
||||||
|
if (entity == null)
|
||||||
|
return AppErrors.NotFound(typeof(T).Name, id);
|
||||||
|
|
||||||
|
return entity.OwnerUserId == userId
|
||||||
|
? entity
|
||||||
|
: AppErrors.Forbidden(typeof(T).Name, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
using X.PagedList;
|
|
||||||
|
|
||||||
namespace Musify.Application.Shared
|
|
||||||
{
|
|
||||||
public record PaginatedResponse<T>(
|
|
||||||
IReadOnlyCollection<T> Items,
|
|
||||||
int PageNumber,
|
|
||||||
int PageSize,
|
|
||||||
int PageCount,
|
|
||||||
int TotalItemCount,
|
|
||||||
bool HasNextPage,
|
|
||||||
bool HasPreviousPage)
|
|
||||||
{
|
|
||||||
public static PaginatedResponse<T> FromPagedList(IPagedList<T> pagedList)
|
|
||||||
{
|
|
||||||
return new PaginatedResponse<T>(
|
|
||||||
pagedList.ToArray(),
|
|
||||||
pagedList.PageNumber,
|
|
||||||
pagedList.PageSize,
|
|
||||||
pagedList.PageCount,
|
|
||||||
pagedList.TotalItemCount,
|
|
||||||
pagedList.HasNextPage,
|
|
||||||
pagedList.HasPreviousPage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Musify.Application.Shared
|
||||||
|
{
|
||||||
|
public record PageRequest(int PageNumber = 1, int PageSize = 20)
|
||||||
|
{
|
||||||
|
public const int MaxPageSize = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PaginatedResponse<T>(
|
||||||
|
IReadOnlyList<T> Items,
|
||||||
|
int PageNumber,
|
||||||
|
int PageSize,
|
||||||
|
int PageCount,
|
||||||
|
int TotalItemCount,
|
||||||
|
bool HasNextPage,
|
||||||
|
bool HasPreviousPage);
|
||||||
|
|
||||||
|
public static class PaginationExtensions
|
||||||
|
{
|
||||||
|
/// <summary>Counts and pages the same (already filtered, ordered and projected) query.</summary>
|
||||||
|
public static async Task<PaginatedResponse<T>> ToPaginatedAsync<T>(
|
||||||
|
this IQueryable<T> query, PageRequest page, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var pageNumber = Math.Max(page.PageNumber, 1);
|
||||||
|
var pageSize = Math.Clamp(page.PageSize, 1, PageRequest.MaxPageSize);
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync(cancellationToken);
|
||||||
|
var items = await query
|
||||||
|
.Skip((pageNumber - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var pageCount = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||||
|
|
||||||
|
return new PaginatedResponse<T>(
|
||||||
|
items,
|
||||||
|
pageNumber,
|
||||||
|
pageSize,
|
||||||
|
pageCount,
|
||||||
|
totalCount,
|
||||||
|
HasNextPage: pageNumber < pageCount,
|
||||||
|
HasPreviousPage: pageNumber > 1 && pageCount > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user