Compare commits
435
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63de050355 | ||
|
|
5046239c76 | ||
|
|
b46e3391a9 | ||
|
|
7d1bd1be35 | ||
|
|
3fb8328d3b | ||
|
|
20d01a0eb2 | ||
|
|
7ac1f7bad8 | ||
|
|
89bf44a48a | ||
|
|
3feef07cbf | ||
|
|
45f0a94758 | ||
|
|
4e28df8907 | ||
|
|
d02b1f9406 | ||
|
|
0a01a0feee | ||
|
|
d327d8c4f4 | ||
|
|
91d181d39f | ||
|
|
50b54031f3 | ||
|
|
ca2900acae | ||
|
|
6f6e3b9824 | ||
|
|
a9d466111c | ||
|
|
f913f6f788 | ||
|
|
0794be0557 | ||
|
|
70968b386c | ||
|
|
172a0bac61 | ||
|
|
81698cc039 | ||
|
|
f633a0c5c8 | ||
|
|
2385ec38e8 | ||
|
|
3c2981c7f5 | ||
|
|
808637396e | ||
|
|
611b2a72cd | ||
|
|
0b149f052e | ||
|
|
699b36c966 | ||
|
|
775b5a7a8a | ||
|
|
87d36722aa | ||
|
|
68aeebb746 | ||
|
|
3f7409b41f | ||
|
|
c06066d09e | ||
|
|
2924e91060 | ||
|
|
ae6a78fdbe | ||
|
|
cbeb52fc05 | ||
|
|
584109bf05 | ||
|
|
282a419bd0 | ||
|
|
76eac308a6 | ||
|
|
30cea988cf | ||
|
|
e87d379ddb | ||
|
|
90e842d084 | ||
|
|
2cb51f540e | ||
|
|
81c5578962 | ||
|
|
c624a1332f | ||
|
|
fe6b0b7268 | ||
|
|
78f7fe06cf | ||
|
|
07e1daf2ce | ||
|
|
dd15f885cb | ||
|
|
18f22b4c38 | ||
|
|
27f3c02915 | ||
|
|
22ed923a04 | ||
|
|
69468c9dbd | ||
|
|
67805062ca | ||
|
|
6490ae9822 | ||
|
|
ff6103cd58 | ||
|
|
3cbf64a191 | ||
|
|
be662048b1 | ||
|
|
8af7840f5c | ||
|
|
49b2fcb623 | ||
|
|
2946452a43 | ||
|
|
c302cfd314 | ||
|
|
71c45abcf1 | ||
|
|
ee49f67c8d | ||
|
|
6778a69098 | ||
|
|
fa12fb795d | ||
|
|
d683e22cb6 | ||
|
|
5a7755efa9 | ||
|
|
e7471ff7e5 | ||
|
|
a779a8bf73 | ||
|
|
6156101029 | ||
|
|
29d67e729c | ||
|
|
28e00ce0ca | ||
|
|
4d52877b35 | ||
|
|
f82d9b7c1b | ||
|
|
06b7a08bfa | ||
|
|
4b06226dd7 | ||
|
|
546aef6d83 | ||
|
|
02e76664ef | ||
|
|
c888c465d5 | ||
|
|
6f7ae9e5bb | ||
|
|
db71c4a331 | ||
|
|
c8bc26a5e0 | ||
|
|
74b8a1d351 | ||
|
|
d1915481e0 | ||
|
|
e2ef2da4c3 | ||
|
|
c3b32ad40c | ||
|
|
e09866716d | ||
|
|
99c4bb8408 | ||
|
|
72f42369a0 | ||
|
|
673689278d | ||
|
|
db63aabd9c | ||
|
|
aae45a5186 | ||
|
|
6e2f3a257a | ||
|
|
ca275fe7a8 | ||
|
|
cdf89dc632 | ||
|
|
fa45843079 | ||
|
|
eeec891870 | ||
|
|
05ed7aec2e | ||
|
|
7be5d22cb7 | ||
|
|
7532511e1f | ||
|
|
63f7253714 | ||
|
|
631941f690 | ||
|
|
b4894f8169 | ||
|
|
da96c8a112 | ||
|
|
62d30c2729 | ||
|
|
19f497c2d8 | ||
|
|
960565a968 | ||
|
|
82152beb93 | ||
|
|
03b1166307 | ||
|
|
53a3e80c05 | ||
|
|
8684067235 | ||
|
|
76ff77da7f | ||
|
|
6b69998865 | ||
|
|
8d19272f35 | ||
|
|
74dd1e8374 | ||
|
|
6d73fe88ab | ||
|
|
a6f976a065 | ||
|
|
560a943bab | ||
|
|
24595a17d6 | ||
|
|
2bd5ef729e | ||
|
|
87997d90a4 | ||
|
|
49b7d54e58 | ||
|
|
a59ce99051 | ||
|
|
e5614178c8 | ||
|
|
5614033294 | ||
|
|
17ce437571 | ||
|
|
3bbbd52d43 | ||
|
|
a1f7756fc9 | ||
|
|
461bae1b01 | ||
|
|
b0e4ec650a | ||
|
|
84ffb1eef6 | ||
|
|
126f767dca | ||
|
|
9e0c164e17 | ||
|
|
e31419b8c8 | ||
|
|
fe874b3608 | ||
|
|
9067e59f6f | ||
|
|
e68bf36481 | ||
|
|
e7fe2dd888 | ||
|
|
04ae571eee | ||
|
|
befd392971 | ||
|
|
32f3cbe5cf | ||
|
|
19a4299b71 | ||
|
|
87cdba6343 | ||
|
|
7d2681aaf6 | ||
|
|
2e866075a2 | ||
|
|
afac7d5dd5 | ||
|
|
f6ac22af32 | ||
|
|
8a637d71f0 | ||
|
|
209c02ded7 | ||
|
|
c9e083b1c3 | ||
|
|
46a6a9757a | ||
|
|
78caf588be | ||
|
|
937ad139e5 | ||
|
|
f10b0b800b | ||
|
|
5bb6a4a1bf | ||
|
|
e9e0b9ae9b | ||
|
|
a826c39003 | ||
|
|
4f9ae51971 | ||
|
|
2eaca66ec2 | ||
|
|
f155ccc912 | ||
|
|
6cc41d1d6f | ||
|
|
8c87d84f80 | ||
|
|
0b72363c33 | ||
|
|
a40f3da247 | ||
|
|
18d3aea853 | ||
|
|
9b34f2db84 | ||
|
|
812913156b | ||
|
|
927f965023 | ||
|
|
7623b4e80e | ||
|
|
fef788462d | ||
|
|
b9cd3533dc | ||
|
|
ac1c515dec | ||
|
|
5d2c9d3986 | ||
|
|
93fe2fb942 | ||
|
|
f58f6a0aa5 | ||
|
|
ac5fc49800 | ||
|
|
6a49b17e38 | ||
|
|
ea36816777 | ||
|
|
9613cd928d | ||
|
|
33ead1daab | ||
|
|
66cb970dd2 | ||
|
|
f8d4f83152 | ||
|
|
454bffe4ba | ||
|
|
0163da096e | ||
|
|
6bb31ad7c5 | ||
|
|
573539ea1b | ||
|
|
e15e587267 | ||
|
|
dece2c77e8 | ||
|
|
5aeca4deb1 | ||
|
|
45ce66a6ee | ||
|
|
a40de35a57 | ||
|
|
1d40abcab7 | ||
|
|
d6c269d80f | ||
|
|
431b56a072 | ||
|
|
c2258569a0 | ||
|
|
a5305b8ed8 | ||
|
|
8664bfb800 | ||
|
|
986941166c | ||
|
|
3545d71016 | ||
|
|
d08d82a483 | ||
|
|
423e06baf9 | ||
|
|
38f7094fb0 | ||
|
|
e802c8565d | ||
|
|
6e469dbb4a | ||
|
|
a3bf796f17 | ||
|
|
8f34ed4874 | ||
|
|
d9c8c17b21 | ||
|
|
f6d668d201 | ||
|
|
8ab4d27287 | ||
|
|
4b80c7dbb0 | ||
|
|
8c0519d125 | ||
|
|
27922f2d14 | ||
|
|
28426571e6 | ||
|
|
c3cdd3bc0f | ||
|
|
9d433868cc | ||
|
|
6829e769ed | ||
|
|
ca8750c3b4 | ||
|
|
ed7b00c2cc | ||
|
|
e3b7a0353e | ||
|
|
a8ff05892d | ||
|
|
47c3a431c2 | ||
|
|
b737d59cbe | ||
|
|
2978b69654 | ||
|
|
69eeefd58f | ||
|
|
30f57ff50e | ||
|
|
55c73cbb3d | ||
|
|
e59a5fa8ef | ||
|
|
bd010e0330 | ||
|
|
45999af9f8 | ||
|
|
ca4d6c5ed2 | ||
|
|
58fda0ba9a | ||
|
|
032906de6b | ||
|
|
5c5fab86a5 | ||
|
|
ba093e7fd5 | ||
|
|
842dd13efa | ||
|
|
55549818af | ||
|
|
868860fff9 | ||
|
|
3fea58cd72 | ||
|
|
03d9fa054f | ||
|
|
c1fc0ed1a1 | ||
|
|
253a67aabf | ||
|
|
e4452843e7 | ||
|
|
69cc73c374 | ||
|
|
9772879e67 | ||
|
|
bd92928366 | ||
|
|
46985bf062 | ||
|
|
834d7a25c1 | ||
|
|
dc1d3c2269 | ||
|
|
67d340fcaa | ||
|
|
47bff00268 | ||
|
|
f20391391a | ||
|
|
f9aaa1a1fe | ||
|
|
b6754a0e76 | ||
|
|
3313560e6c | ||
|
|
8455033e52 | ||
|
|
7cb1851b0d | ||
|
|
036dcf72a7 | ||
|
|
39be51304b | ||
|
|
335e1a785e | ||
|
|
5751b9ea08 | ||
|
|
5765982a4d | ||
|
|
ee18fe2ee1 | ||
|
|
fd41053030 | ||
|
|
af2cf03fb4 | ||
|
|
1efdbc237a | ||
|
|
be3f832e11 | ||
|
|
275c001f1f | ||
|
|
285668efc7 | ||
|
|
10124f0184 | ||
|
|
b6dea8b041 | ||
|
|
585af60371 | ||
|
|
4dbd87f5d9 | ||
|
|
05c53058da | ||
|
|
99d202f143 | ||
|
|
de66cb0f48 | ||
|
|
52a561d52a | ||
|
|
3b79b399ce | ||
|
|
64653f4fcd | ||
|
|
9a3e7d4bb5 | ||
|
|
9ecb16f888 | ||
|
|
036786bd59 | ||
|
|
f761405771 | ||
|
|
f268bc2294 | ||
|
|
130f23f841 | ||
|
|
d35e8eb5f7 | ||
|
|
94bf9c61a3 | ||
|
|
ffa3a73bb8 | ||
|
|
25accf3456 | ||
|
|
2769c7f38f | ||
|
|
6fa7c24516 | ||
|
|
e64aa4e90a | ||
|
|
4cb308b78d | ||
|
|
348f5cb813 | ||
|
|
deb4bfda90 | ||
|
|
7aef20fe71 | ||
|
|
5a00a2478e | ||
|
|
f2febb898e | ||
|
|
7160266763 | ||
|
|
1ed2ec8d93 | ||
|
|
0e65103af2 | ||
|
|
f6ac45cb79 | ||
|
|
1ecf4a872e | ||
|
|
fa6eaf5a2b | ||
|
|
57a5358701 | ||
|
|
b28edbbd3a | ||
|
|
be2bbea4cc | ||
|
|
4da960d9a1 | ||
|
|
e5099343d6 | ||
|
|
db78df7f0e | ||
|
|
3f10613df0 | ||
|
|
5aa8e1db60 | ||
|
|
049e4723cd | ||
|
|
9585d8f34c | ||
|
|
c30dfd6923 | ||
|
|
7a219e4dea | ||
|
|
c6e0c45a50 | ||
|
|
ce278dc935 | ||
|
|
093b84e660 | ||
|
|
795f8fdab0 | ||
|
|
b9f03eb03c | ||
|
|
9e4d5158e8 | ||
|
|
12aa104d80 | ||
|
|
164fb5f29a | ||
|
|
f92b1b93ea | ||
|
|
e983f5af4d | ||
|
|
b433060deb | ||
|
|
3e54b5db43 | ||
|
|
7d4265d47c | ||
|
|
2518101a52 | ||
|
|
4fa341d2c8 | ||
|
|
06ef70cb3b | ||
|
|
d2a1807883 | ||
|
|
ffaf9990df | ||
|
|
c78ec50036 | ||
|
|
b9ad1a87a8 | ||
|
|
bc9fdad098 | ||
|
|
32b83e84a6 | ||
|
|
075ff3c7b6 | ||
|
|
9b1ae1a7a1 | ||
|
|
7ffd63ed9a | ||
|
|
606fa26c3f | ||
|
|
0384de792b | ||
|
|
6c413f5174 | ||
|
|
b201a4a7af | ||
|
|
cbea2ad9dc | ||
|
|
d3dbde4fec | ||
|
|
3dea06b0cc | ||
|
|
6e06d7673d | ||
|
|
10a66c70c8 | ||
|
|
0355bd22a6 | ||
|
|
bf23224a51 | ||
|
|
01093abced | ||
|
|
22a65b7efa | ||
|
|
4f5230ddf6 | ||
|
|
510f52a034 | ||
|
|
0872b8df6f | ||
|
|
7a81341218 | ||
|
|
75cb09dbf2 | ||
|
|
d62d3441db | ||
|
|
f1b7191ccc | ||
|
|
788fb6fa5c | ||
|
|
fab98098c8 | ||
|
|
4e089c4da8 | ||
|
|
7c7f118a43 | ||
|
|
9eb6fa826e | ||
|
|
d771c6c0a7 | ||
|
|
ff64f1f136 | ||
|
|
4215fb483a | ||
|
|
d4af39f53b | ||
|
|
951e42d21a | ||
|
|
a98969ec3a | ||
|
|
2c3b84a233 | ||
|
|
801bf08337 | ||
|
|
1bff5e62ea | ||
|
|
df4f4bdf66 | ||
|
|
088016f986 | ||
|
|
32c1e3b842 | ||
|
|
285b93a9be | ||
|
|
8ce3e68b81 | ||
|
|
1bd8c8779d | ||
|
|
b34a711d2d | ||
|
|
93a975e9b5 | ||
|
|
62283bd403 | ||
|
|
eda9bef828 | ||
|
|
9756672160 | ||
|
|
af061bc6fc | ||
|
|
b5955fda5b | ||
|
|
17944b5344 | ||
|
|
dac5096998 | ||
|
|
eb822ff1bf | ||
|
|
031fdb7f46 | ||
|
|
0b8257a346 | ||
|
|
676910e462 | ||
|
|
18da740e08 | ||
|
|
3c21e0ea1a | ||
|
|
a036454220 | ||
|
|
535b6e457b | ||
|
|
4343542dce | ||
|
|
58b0974bec | ||
|
|
f3edcd0af4 | ||
|
|
a7203dff02 | ||
|
|
4387aa146e | ||
|
|
716558c085 | ||
|
|
e89fc5b45c | ||
|
|
9f90151fc5 | ||
|
|
cc720e2c08 | ||
|
|
4996fd019e | ||
|
|
d9cb715622 | ||
|
|
bc751a467b | ||
|
|
f5643c0ccd | ||
|
|
0c04ddf316 | ||
|
|
14ae57ead8 | ||
|
|
f41a6c2d77 | ||
|
|
28b46e3cc4 | ||
|
|
41b8cbfc9c | ||
|
|
4f74192cb6 | ||
|
|
9f14bb2c86 | ||
|
|
b1ac38d377 | ||
|
|
afcdaea4fd | ||
|
|
9147c2c799 | ||
|
|
463d1a6600 | ||
|
|
dee4bd53ab | ||
|
|
6ffec218b3 | ||
|
|
b1a0eeeb22 | ||
|
|
9816e23401 | ||
|
|
076f048efe | ||
|
|
92bd06751d | ||
|
|
63c89c1946 | ||
|
|
3a45cc27d1 | ||
|
|
82ddf7dea6 | ||
|
|
28719fe4d1 |
+2
-9
@@ -4,21 +4,14 @@
|
|||||||
{
|
{
|
||||||
"name": "musify-web",
|
"name": "musify-web",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["--prefix", "web-player", "run", "dev"],
|
"runtimeArgs": ["--prefix", "web-player", "run", "dev", "--", "--port", "3000"],
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,57 @@
|
|||||||
# Musify
|
# Musify
|
||||||
|
|
||||||
A music streaming app. Upload your own tracks, organize them into playlists
|
Music streaming platform: upload your own tracks (or import them from YouTube),
|
||||||
and albums, and stream them back with a web player built around your
|
process them asynchronously (audio transcoded to `.m4a`, thumbnails generated)
|
||||||
listening habits: auto-generated mixes, a home feed, search, likes, and an
|
and stream them without the bytes ever going through the API.
|
||||||
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 solution: Api, Worker, StreamingGateway, and the shared Domain/Application/Infrastructure
|
backend/ .NET 10 — Domain / Application / Infrastructure / Api / Worker / StreamingGateway / Tests
|
||||||
web-player/ the SvelteKit web client
|
web-player/ SvelteKit 2 + Svelte 5 + Tailwind 4 web client
|
||||||
deploy/ Docker Compose stacks for dev and production
|
deploy/ Docker Compose stacks — self-contained, no wrapper scripts
|
||||||
docs/ architecture notes, one file per area
|
docs/ What each piece does and why (in Spanish)
|
||||||
```
|
```
|
||||||
|
|
||||||
Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger are not part of this
|
```sh
|
||||||
layout. They live in the separate `Infrastructure` repository instead.
|
dotnet test backend/Musify.slnx # backend/Tests/ — Docker running (Testcontainers: Postgres, SeaweedFS)
|
||||||
|
```
|
||||||
|
|
||||||
## Digging deeper
|
## Run it
|
||||||
|
|
||||||
[docs/](docs/README.md) covers how each piece works and why: architecture,
|
Development (Docker Desktop):
|
||||||
authentication, storage, streaming, media processing, and the local dev
|
|
||||||
setup. [deploy/README.md](deploy/README.md) has the full instructions for
|
```sh
|
||||||
running the stack, in development or on a production server.
|
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
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).
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ namespace Musify.Application.Albums
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var album = await database.Albums
|
var album = await database.UserAlbums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
||||||
if (album == null)
|
if (album is null)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
|
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
|
||||||
return Error.NotFound();
|
return Error.NotFound();
|
||||||
@@ -32,16 +32,19 @@ namespace Musify.Application.Albums
|
|||||||
return Error.Unauthorized();
|
return Error.Unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
var track = await database.Tracks
|
var trackExists = await database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
|
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
|
||||||
if (track == null)
|
if (!trackExists)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
||||||
return Error.NotFound(description: $"Track {request.TrackId} not found");
|
return Error.NotFound(description: $"Track {request.TrackId} not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (track.OwnerUserId != request.UserId)
|
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);
|
logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId);
|
||||||
return Error.Unauthorized();
|
return Error.Unauthorized();
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ namespace Musify.Application.Albums
|
|||||||
var intent = pictureIntent.Value;
|
var intent = pictureIntent.Value;
|
||||||
var title = request.Title.Trim();
|
var title = request.Title.Trim();
|
||||||
|
|
||||||
var album = new Album
|
var album = new UserAlbum
|
||||||
{
|
{
|
||||||
OwnerUserId = request.UserId,
|
OwnerUserId = request.UserId,
|
||||||
Title = title,
|
Title = title,
|
||||||
@@ -66,7 +66,7 @@ namespace Musify.Application.Albums
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await database.Albums.AddAsync(album, cancellationToken);
|
await database.UserAlbums.AddAsync(album, cancellationToken);
|
||||||
|
|
||||||
var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName);
|
var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName);
|
||||||
var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken);
|
var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken);
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ namespace Musify.Application.Albums
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var album = await database.Albums
|
var album = await database.UserAlbums
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
||||||
if (album == null)
|
if (album is null)
|
||||||
{
|
{
|
||||||
logger.LogDebug("Album {AlbumId} not found", request.AlbumId);
|
logger.LogDebug("Album {AlbumId} not found", request.AlbumId);
|
||||||
return Error.NotFound();
|
return Error.NotFound();
|
||||||
@@ -30,7 +30,7 @@ namespace Musify.Application.Albums
|
|||||||
return Error.Unauthorized();
|
return Error.Unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
database.Albums.Remove(album);
|
database.UserAlbums.Remove(album);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ 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.Albums
|
var album = await database.UserAlbums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(a => a.Id == request.Id)
|
.Where(a => a.Id == request.Id)
|
||||||
.Select(a => new
|
.Select(a => new
|
||||||
@@ -29,7 +29,7 @@ namespace Musify.Application.Albums
|
|||||||
})
|
})
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (album == null)
|
if (album is null)
|
||||||
return Error.NotFound();
|
return Error.NotFound();
|
||||||
|
|
||||||
return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds);
|
return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ namespace Musify.Application.Albums
|
|||||||
.Select(a => a.Pictures)
|
.Select(a => a.Pictures)
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (pictures == null)
|
if (pictures is null)
|
||||||
return Error.NotFound();
|
return Error.NotFound();
|
||||||
|
|
||||||
var routes = albumConfiguration.Routes;
|
var routes = albumConfiguration.Routes;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
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;
|
||||||
using X.PagedList.EF;
|
using X.PagedList.EF;
|
||||||
|
|
||||||
@@ -28,8 +29,9 @@ namespace Musify.Application.Albums
|
|||||||
|
|
||||||
var tracksQuery = database.AlbumHasTracks
|
var tracksQuery = database.AlbumHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(albumTrack => albumTrack.Track.Owner)
|
.Include(albumTrack => ((ExternalTrack)albumTrack.Track).TrackArtists)
|
||||||
.Include(albumTrack => albumTrack.Track.Tags)
|
.ThenInclude(trackArtist => trackArtist.Artist)
|
||||||
|
.Include(albumTrack => ((LocalTrack)albumTrack.Track).Owner)
|
||||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
|
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
|
||||||
|
|
||||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
using ErrorOr;
|
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.Domain.ValueObjects;
|
||||||
using X.PagedList;
|
using X.PagedList;
|
||||||
using X.PagedList.EF;
|
using X.PagedList.EF;
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
|
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize, string? YoutubeContinuationToken = null)
|
||||||
: IQuery<ErrorOr<AlbumsSearchResponse>>;
|
: IQuery<ErrorOr<AlbumsSearchResponse>>;
|
||||||
|
|
||||||
public class GetAlbumsQueryHandler(IDatabase database)
|
public class GetAlbumsQueryHandler(
|
||||||
|
IDatabase database,
|
||||||
|
IYouTubeMusicService youTubeMusicService,
|
||||||
|
ILogger<GetAlbumsQueryHandler> logger)
|
||||||
: IQueryHandler<GetAlbumsQuery, ErrorOr<AlbumsSearchResponse>>
|
: IQueryHandler<GetAlbumsQuery, ErrorOr<AlbumsSearchResponse>>
|
||||||
{
|
{
|
||||||
|
// Mirrors GetTracksQueryHandler's threshold: a title shorter than this is too likely to
|
||||||
|
// 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)
|
public async ValueTask<ErrorOr<AlbumsSearchResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var albumsQuery = database.Albums
|
var albumsQuery = database.Albums
|
||||||
@@ -42,19 +52,62 @@ namespace Musify.Application.Albums
|
|||||||
})
|
})
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||||
|
|
||||||
var items = pagedEntities
|
var localItems = pagedEntities
|
||||||
.Select(entry => AlbumSearchItemResponse.FromAlbum(
|
.Select(entry => AlbumSearchItemResponse.FromAlbum(
|
||||||
AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)))
|
AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return new AlbumsSearchResponse(
|
var knownExternalIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
items,
|
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.PageNumber,
|
||||||
pagedEntities.PageSize,
|
pagedEntities.PageSize,
|
||||||
pagedEntities.PageCount,
|
pagedEntities.PageCount,
|
||||||
pagedEntities.TotalItemCount,
|
pagedEntities.TotalItemCount,
|
||||||
pagedEntities.HasPreviousPage,
|
pagedEntities.HasPreviousPage,
|
||||||
pagedEntities.HasNextPage);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ namespace Musify.Application.Albums
|
|||||||
if (!userExists)
|
if (!userExists)
|
||||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
return Error.NotFound(description: $"User {request.UserId} not found");
|
||||||
|
|
||||||
var albumsQuery = database.Albums
|
var albumsQuery = database.UserAlbums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(album => album.OwnerUserId == request.UserId);
|
.Where(album => album.OwnerUserId == request.UserId);
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ 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<ErrorOr<IReadOnlyList<AlbumApplicationResponse> >>;
|
||||||
|
|
||||||
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
|
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetRecentlyListenedAlbumsQuery, ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>
|
: IQueryHandler<GetRecentlyListenedAlbumsQuery, ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>
|
||||||
@@ -24,9 +24,9 @@ namespace Musify.Application.Albums
|
|||||||
listenedTrackIds,
|
listenedTrackIds,
|
||||||
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)
|
||||||
.Take(request.Limit)
|
.Take(request.Limit)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
@@ -48,7 +48,7 @@ namespace Musify.Application.Albums
|
|||||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
.Take(AlbumApplicationResponse.CoverTrackCount)
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
.Select(albumTrack => albumTrack.TrackId)
|
||||||
.ToList()
|
.ToList()
|
||||||
})
|
} )
|
||||||
.ToDictionaryAsync(entry => entry.Album.Id, cancellationToken);
|
.ToDictionaryAsync(entry => entry.Album.Id, cancellationToken);
|
||||||
|
|
||||||
return ranked
|
return ranked
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ 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.Albums
|
var album = await database.UserAlbums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
||||||
if (album == null)
|
if (album is null)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
|
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
|
||||||
return Error.NotFound();
|
return Error.NotFound();
|
||||||
@@ -37,7 +37,7 @@ 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 == null)
|
if (link is null)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Track {TrackId} not in album {AlbumId}", request.TrackId, request.AlbumId);
|
logger.LogInformation("Track {TrackId} not in album {AlbumId}", request.TrackId, request.AlbumId);
|
||||||
return Error.NotFound(description: "Track is not in the album.");
|
return Error.NotFound(description: "Track is not in the album.");
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ namespace Musify.Application.Albums
|
|||||||
var effectiveSizeBytes = request.ExpectedSizeBytes
|
var effectiveSizeBytes = request.ExpectedSizeBytes
|
||||||
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
||||||
|
|
||||||
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
|
var objectName = $"{Guid.NewGuid() }.{request.FileType.TrimStart('.').ToLowerInvariant() }";
|
||||||
|
|
||||||
var tempKey = albumConfiguration.Routes.BuildTempPicturePath(
|
var tempKey = albumConfiguration.Routes.BuildTempPicturePath(
|
||||||
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName);
|
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName);
|
||||||
@@ -84,7 +84,7 @@ namespace Musify.Application.Albums
|
|||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
await transaction.CommitAsync(cancellationToken);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
|
||||||
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (AlbumPicture, temp key: {Key})", intent.Id, request.UserId, tempKey);
|
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (AlbumPicture, temp key: {Key} )", intent.Id, request.UserId, tempKey);
|
||||||
|
|
||||||
return new AlbumPictureUploadResponse(
|
return new AlbumPictureUploadResponse(
|
||||||
intent.Id,
|
intent.Id,
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Application.Serialization;
|
|
||||||
|
|
||||||
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,
|
||||||
string? Description,
|
string? Description,
|
||||||
int? ReleaseYear,
|
int? ReleaseYear,
|
||||||
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
|
long OwnerUserId,
|
||||||
int TrackCount,
|
int TrackCount,
|
||||||
string? SmallImageKeyName,
|
string? SmallImageKeyName,
|
||||||
string? MediumImageKeyName,
|
string? MediumImageKeyName,
|
||||||
@@ -21,7 +29,7 @@ namespace Musify.Application.Albums.Responses
|
|||||||
public const int CoverTrackCount = 4;
|
public const int CoverTrackCount = 4;
|
||||||
|
|
||||||
public static AlbumApplicationResponse FromEntity(
|
public static AlbumApplicationResponse FromEntity(
|
||||||
Album album,
|
UserAlbum album,
|
||||||
int trackCount,
|
int trackCount,
|
||||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
IReadOnlyList<Guid>? coverTrackIds = null)
|
||||||
{
|
{
|
||||||
@@ -39,5 +47,18 @@ 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}'.")
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Albums.Responses
|
namespace Musify.Application.Albums.Responses
|
||||||
{
|
{
|
||||||
public record AlbumSearchItemResponse(AlbumApplicationResponse Album)
|
/// <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);
|
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,6 +1,11 @@
|
|||||||
namespace Musify.Application.Albums.Responses
|
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(
|
public record AlbumsSearchResponse(
|
||||||
IReadOnlyList<AlbumSearchItemResponse> Items,
|
IReadOnlyList<AlbumSearchItemResponse> Items,
|
||||||
int PageNumber,
|
int PageNumber,
|
||||||
@@ -8,5 +13,26 @@ namespace Musify.Application.Albums.Responses
|
|||||||
int PageCount,
|
int PageCount,
|
||||||
int TotalItemCount,
|
int TotalItemCount,
|
||||||
bool HasPreviousPage,
|
bool HasPreviousPage,
|
||||||
bool HasNextPage);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,9 +34,9 @@ namespace Musify.Application.Albums
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var album = await database.Albums
|
var album = await database.UserAlbums
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
||||||
if (album == null)
|
if (album is null)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
|
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
|
||||||
return Error.NotFound();
|
return Error.NotFound();
|
||||||
@@ -77,6 +77,8 @@ namespace Musify.Application.Albums
|
|||||||
finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
database.UserAlbums.Update(album);
|
||||||
|
|
||||||
if (pictureIntent != null && finalPictureKey != null)
|
if (pictureIntent != null && finalPictureKey != null)
|
||||||
{
|
{
|
||||||
var publishResult = await PublishUpdateAlbumPictureSourceEventAsync(
|
var publishResult = await PublishUpdateAlbumPictureSourceEventAsync(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public static class ApplicationDependencyInjection
|
|||||||
services.AddMediator();
|
services.AddMediator();
|
||||||
services.AddScoped<UploadIntentValidator>();
|
services.AddScoped<UploadIntentValidator>();
|
||||||
services.AddScoped<TrackStreamIssuer>();
|
services.AddScoped<TrackStreamIssuer>();
|
||||||
|
services.AddScoped<YouTubeTrackProvisioner>();
|
||||||
|
|
||||||
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
|
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
|
||||||
services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName);
|
services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName);
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ 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)
|
||||||
|
|||||||
@@ -6,7 +6,16 @@ namespace Musify.Application.Configuration
|
|||||||
{
|
{
|
||||||
public const string SectionName = "Mix";
|
public const 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ namespace Musify.Application.Configuration
|
|||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes();
|
public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes();
|
||||||
|
|
||||||
|
public bool SeedPresetPictures { get; set; } = true;
|
||||||
|
|
||||||
|
public bool OverwritePresetPictures { get; set; } = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PlayListRoutes
|
public class PlayListRoutes
|
||||||
@@ -34,6 +38,18 @@ namespace Musify.Application.Configuration
|
|||||||
[Required]
|
[Required]
|
||||||
public string LargePicturesFolder { get; set; } = "LargePictures";
|
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 SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
|
||||||
|
|
||||||
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
|
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
|
||||||
@@ -42,6 +58,12 @@ namespace Musify.Application.Configuration
|
|||||||
|
|
||||||
public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
|
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(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
|
||||||
|
|
||||||
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName);
|
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName);
|
||||||
|
|||||||
@@ -2,16 +2,28 @@ 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 sealed class PlaybackConfiguration
|
||||||
{
|
{
|
||||||
public const string SectionName = "Playback";
|
public const string SectionName = "Playback";
|
||||||
|
|
||||||
|
/// <summary>When false (the default), every stream endpoint requires a signed-in user, exactly
|
||||||
|
/// 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 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;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,22 @@ namespace Musify.Application.Contracts
|
|||||||
|
|
||||||
DbSet<Album> Albums { get; }
|
DbSet<Album> Albums { get; }
|
||||||
|
|
||||||
|
DbSet<UserAlbum> UserAlbums { get; }
|
||||||
|
|
||||||
|
DbSet<ExternalAlbum> ExternalAlbums { get; }
|
||||||
|
|
||||||
DbSet<AlbumHasTrack> AlbumHasTracks { get; }
|
DbSet<AlbumHasTrack> AlbumHasTracks { get; }
|
||||||
|
|
||||||
DbSet<Track> Tracks { get; }
|
DbSet<Track> Tracks { get; }
|
||||||
|
|
||||||
|
DbSet<LocalTrack> LocalTracks { get; }
|
||||||
|
|
||||||
|
DbSet<ExternalTrack> ExternalTracks { get; }
|
||||||
|
|
||||||
|
DbSet<Artist> Artists { get; }
|
||||||
|
|
||||||
|
DbSet<TrackArtist> TrackArtists { get; }
|
||||||
|
|
||||||
DbSet<UserHasTrack> UserHasTracks { get; }
|
DbSet<UserHasTrack> UserHasTracks { get; }
|
||||||
|
|
||||||
DbSet<PlayListHasTrack> PlayListHasTracks { get; }
|
DbSet<PlayListHasTrack> PlayListHasTracks { get; }
|
||||||
@@ -29,12 +41,6 @@ namespace Musify.Application.Contracts
|
|||||||
|
|
||||||
DbSet<ListeningHistory> ListeningHistories { get; }
|
DbSet<ListeningHistory> ListeningHistories { get; }
|
||||||
|
|
||||||
DbSet<TrackLike> TrackLikes { get; }
|
|
||||||
|
|
||||||
DbSet<TrackTag> TrackTags { get; }
|
|
||||||
|
|
||||||
DbSet<UserFollow> UserFollows { get; }
|
|
||||||
|
|
||||||
Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ namespace Musify.Application.Contracts
|
|||||||
{
|
{
|
||||||
public interface IStreamTicketService
|
public interface IStreamTicketService
|
||||||
{
|
{
|
||||||
|
/// <param name="userId">Null for an anonymous listener — the ticket is issued without a
|
||||||
|
/// <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);
|
StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Musify.Application.Contracts
|
||||||
|
{
|
||||||
|
public record YouTubeDownloadResult(int ExitCode, string StandardError);
|
||||||
|
|
||||||
|
public interface IYouTubeDownloaderService
|
||||||
|
{
|
||||||
|
Task<YouTubeDownloadResult> DownloadAudioAsync(string videoId, string destinationFilePath, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.Likes
|
|
||||||
{
|
|
||||||
public record GetLikedTracksQuery(
|
|
||||||
long UserId,
|
|
||||||
int PageNumber,
|
|
||||||
int PageSize)
|
|
||||||
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
|
||||||
|
|
||||||
public class GetLikedTracksQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetLikedTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var likesQuery = database.TrackLikes
|
|
||||||
.AsNoTracking()
|
|
||||||
.Include(like => like.Track.Owner)
|
|
||||||
.Include(like => like.Track.Tags)
|
|
||||||
.Where(like => like.UserId == request.UserId);
|
|
||||||
|
|
||||||
var totalCount = await likesQuery.CountAsync(cancellationToken);
|
|
||||||
|
|
||||||
var pagedEntities = await likesQuery
|
|
||||||
.OrderByDescending(like => like.CreatedAt)
|
|
||||||
.Select(like => new { like.Track, ListensCount = like.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,56 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
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, cancellationToken);
|
|
||||||
if (!trackExists)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
|
||||||
return Error.NotFound(description: $"Track {request.TrackId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("User {UserId} {Action} track {TrackId}",
|
|
||||||
request.UserId, nowLiked ? "liked" : "unliked", request.TrackId);
|
|
||||||
|
|
||||||
return nowLiked;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ 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;
|
||||||
|
|
||||||
@@ -14,28 +15,26 @@ namespace Musify.Application.Mixes
|
|||||||
|
|
||||||
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, ErrorOr<Success>>
|
||||||
{
|
{
|
||||||
private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList<Guid> TrackIds);
|
private sealed record SeedArtist(Guid Id, string Name);
|
||||||
|
|
||||||
|
private sealed record LocalCandidate(Guid TrackId, string Title, string? Artist, double DurationSeconds);
|
||||||
|
|
||||||
|
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)
|
public async ValueTask<ErrorOr<Success>> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var userId = request.UserId;
|
var userId = request.UserId;
|
||||||
|
|
||||||
var libraryTrackIds = await database.UserHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(userTrack => userTrack.UserId == userId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
|
||||||
.Select(userTrack => userTrack.TrackId)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (libraryTrackIds.Count == 0)
|
|
||||||
{
|
|
||||||
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
|
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
|
|
||||||
var historyTrackIds = await database.ListeningHistories
|
var historyTrackIds = await database.ListeningHistories
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(history => history.UserId == userId)
|
.Where(history => history.UserId == userId)
|
||||||
@@ -43,29 +42,33 @@ namespace Musify.Application.Mixes
|
|||||||
.Distinct()
|
.Distinct()
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var historySet = new HashSet<Guid>(historyTrackIds);
|
if (historyTrackIds.Count == 0)
|
||||||
var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList();
|
|
||||||
|
|
||||||
var drafts = new List<MixDraft>();
|
|
||||||
|
|
||||||
var discovery = Take(Shuffle(unheardTrackIds));
|
|
||||||
if (discovery.Count > 0)
|
|
||||||
{
|
{
|
||||||
drafts.Add(new MixDraft(
|
logger.LogInformation("User {UserId} has no listening history yet, skipping mix generation", userId);
|
||||||
"Descubrimiento",
|
return new Success();
|
||||||
"Canciones de tu biblioteca que todavía no has escuchado.",
|
|
||||||
discovery));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
|
var seedArtists = await GetSeedArtistsAsync(historyTrackIds, cancellationToken);
|
||||||
if (daily.Count > 0)
|
|
||||||
|
if (seedArtists.Count == 0)
|
||||||
{
|
{
|
||||||
drafts.Add(new MixDraft(
|
logger.LogInformation("User {UserId} has no artists to seed mixes from, skipping mix generation", userId);
|
||||||
"Tu mezcla diaria",
|
return new Success();
|
||||||
"Lo que más escuchas, con alguna sorpresa de tu biblioteca.",
|
|
||||||
daily));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -88,21 +91,197 @@ namespace Musify.Application.Mixes
|
|||||||
return new Success();
|
return new Success();
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Guid> Take(IEnumerable<Guid> trackIds) =>
|
private async Task<List<SeedArtist>> GetSeedArtistsAsync(
|
||||||
[.. trackIds.Take(mixConfiguration.ItemsPerMix)];
|
IReadOnlyList<Guid> historyTrackIds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) =>
|
|
||||||
[.. trackIds.OrderBy(_ => Guid.NewGuid())];
|
|
||||||
|
|
||||||
private static List<Guid> Interleave(IReadOnlyList<Guid> first, IReadOnlyList<Guid> second)
|
|
||||||
{
|
{
|
||||||
var merged = new List<Guid>(first.Count + second.Count);
|
var ranked = await database.TrackArtists
|
||||||
|
.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;
|
||||||
@@ -138,13 +317,21 @@ namespace Musify.Application.Mixes
|
|||||||
|
|
||||||
await database.Mixes.AddAsync(mix, cancellationToken);
|
await database.Mixes.AddAsync(mix, cancellationToken);
|
||||||
|
|
||||||
for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++)
|
for (var itemPosition = 0; itemPosition < draft.Items.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,
|
||||||
TrackId = draft.TrackIds[itemPosition]
|
Source = item.Source,
|
||||||
|
TrackId = item.TrackId,
|
||||||
|
VideoId = item.VideoId,
|
||||||
|
Title = item.Title,
|
||||||
|
Artist = item.Artist,
|
||||||
|
ThumbnailUrl = item.ThumbnailUrl,
|
||||||
|
DurationSeconds = item.DurationSeconds,
|
||||||
|
IsExplicit = item.IsExplicit
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,12 @@ 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 == null)
|
if (mix is null)
|
||||||
return Error.NotFound(description: $"Mix {request.MixId} was not found");
|
return Error.NotFound(description: $"Mix {request.MixId} was not found");
|
||||||
|
|
||||||
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
|
var items = mix.Items.OrderBy(item => item.Position).ToList();
|
||||||
var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken);
|
|
||||||
|
|
||||||
return new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, items.Count, items);
|
return MixApplicationResponse.FromEntity(mix, items.Count, items);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,24 +14,24 @@ namespace Musify.Application.Mixes
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<IReadOnlyList<MixApplicationResponse>>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<IReadOnlyList<MixApplicationResponse>>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var mixes = await database.Mixes
|
var entries = 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
|
||||||
|
{
|
||||||
|
Mix = mix,
|
||||||
|
ItemCount = mix.Items.Count,
|
||||||
|
CoverItems = mix.Items
|
||||||
|
.OrderBy(item => item.Position)
|
||||||
|
.Take(MixApplicationResponse.CoverItemCount)
|
||||||
|
.ToList()
|
||||||
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var responses = new List<MixApplicationResponse>(mixes.Count);
|
return entries
|
||||||
foreach (var mix in mixes)
|
.Select(entry => MixApplicationResponse.FromEntity(entry.Mix, entry.ItemCount, entry.CoverItems))
|
||||||
{
|
.ToList();
|
||||||
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
|
|
||||||
var coverItems = await MixItemMapper.ToResponsesAsync(
|
|
||||||
database, orderedItems.Take(MixApplicationResponse.CoverItemCount).ToList(), cancellationToken);
|
|
||||||
|
|
||||||
responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, coverItems));
|
|
||||||
}
|
|
||||||
|
|
||||||
return responses;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Mixes.Responses;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
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))
|
|
||||||
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count })
|
|
||||||
.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,3 +1,5 @@
|
|||||||
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
namespace Musify.Application.Mixes.Responses
|
namespace Musify.Application.Mixes.Responses
|
||||||
{
|
{
|
||||||
public record MixApplicationResponse(
|
public record MixApplicationResponse(
|
||||||
@@ -8,5 +10,16 @@ namespace Musify.Application.Mixes.Responses
|
|||||||
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,9 +1,27 @@
|
|||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Mixes.Responses
|
namespace Musify.Application.Mixes.Responses
|
||||||
{
|
{
|
||||||
public record MixItemApplicationResponse(
|
public record MixItemApplicationResponse(
|
||||||
Guid TrackId,
|
MixItemSource Source,
|
||||||
|
Guid? TrackId,
|
||||||
|
string? VideoId,
|
||||||
string Title,
|
string Title,
|
||||||
string? Artist,
|
string? Artist,
|
||||||
|
string? ThumbnailUrl,
|
||||||
double DurationSeconds,
|
double DurationSeconds,
|
||||||
int ListensCount);
|
bool IsExplicit)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ namespace Musify.Application.PlayLists
|
|||||||
{
|
{
|
||||||
var playList = await database.PlayLists
|
var playList = await database.PlayLists
|
||||||
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
||||||
if (playList == null)
|
if (playList is null)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
||||||
return Error.NotFound();
|
return Error.NotFound();
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,8 +17,7 @@ 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(
|
||||||
@@ -31,7 +30,7 @@ namespace Musify.Application.PlayLists
|
|||||||
UploadIntentConfiguration uploadIntentConfiguration)
|
UploadIntentConfiguration uploadIntentConfiguration)
|
||||||
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
||||||
{
|
{
|
||||||
private sealed record ResolvedPlayListPictures(PlayListPictures? Pictures, UploadIntent? Intent);
|
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)
|
||||||
{
|
{
|
||||||
@@ -55,15 +54,14 @@ namespace Musify.Application.PlayLists
|
|||||||
UserId = request.UserId,
|
UserId = request.UserId,
|
||||||
Name = request.Name,
|
Name = request.Name,
|
||||||
NormalizedName = request.Name.Trim().ToUpperInvariant(),
|
NormalizedName = request.Name.Trim().ToUpperInvariant(),
|
||||||
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
Description = string.IsNullOrWhiteSpace(request.Description)? null: request.Description.Trim(),
|
||||||
Pictures = resolvedPictures.Pictures,
|
Pictures = resolvedPictures.Pictures
|
||||||
Visibility = request.Visibility
|
|
||||||
};
|
};
|
||||||
await database.PlayLists.AddAsync(playList, cancellationToken);
|
await database.PlayLists.AddAsync(playList, cancellationToken);
|
||||||
|
|
||||||
if (resolvedPictures.Intent != null)
|
if (resolvedPictures.Intent != null)
|
||||||
{
|
{
|
||||||
var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, resolvedPictures.Pictures!.OriginalName);
|
var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, resolvedPictures.Pictures.OriginalName);
|
||||||
var publishResult = await PublishCreatePlayListEventAsync(playList.Id, resolvedPictures.Intent, finalPictureKey, cancellationToken);
|
var publishResult = await PublishCreatePlayListEventAsync(playList.Id, resolvedPictures.Intent, finalPictureKey, cancellationToken);
|
||||||
if (publishResult.IsError)
|
if (publishResult.IsError)
|
||||||
return publishResult.Errors;
|
return publishResult.Errors;
|
||||||
@@ -88,7 +86,13 @@ namespace Musify.Application.PlayLists
|
|||||||
{
|
{
|
||||||
if (!request.PictureIntentId.HasValue)
|
if (!request.PictureIntentId.HasValue)
|
||||||
{
|
{
|
||||||
return new ResolvedPlayListPictures(null, null);
|
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(
|
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||||
@@ -100,13 +104,13 @@ namespace Musify.Application.PlayLists
|
|||||||
return new ResolvedPlayListPictures(new PlayListPictures
|
return new ResolvedPlayListPictures(new PlayListPictures
|
||||||
{
|
{
|
||||||
OriginalName = validation.Value.ObjectName,
|
OriginalName = validation.Value.ObjectName,
|
||||||
SmallName = validation.Value.ObjectName,
|
SmallName = playListConfiguration.Routes.PresetSmallPicture,
|
||||||
MediumName = validation.Value.ObjectName,
|
MediumName = playListConfiguration.Routes.PresetMediumPicture,
|
||||||
LargeName = validation.Value.ObjectName
|
LargeName = playListConfiguration.Routes.PresetLargePicture
|
||||||
}, validation.Value);
|
}, validation.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ErrorOr<Success>> PublishCreatePlayListEventAsync(
|
async Task<ErrorOr<Success>> PublishCreatePlayListEventAsync(
|
||||||
Guid playListId,
|
Guid playListId,
|
||||||
UploadIntent pictureIntent,
|
UploadIntent pictureIntent,
|
||||||
string finalPictureKey,
|
string finalPictureKey,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ 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 playList = await database.PlayLists.SingleOrDefaultAsync(p => p.Id == request.PlayListId, cancellationToken);
|
||||||
if (playList == null)
|
if (playList is null)
|
||||||
{
|
{
|
||||||
logger.LogDebug("Playlist {PlayListId} not found", request.PlayListId);
|
logger.LogDebug("Playlist {PlayListId} not found", request.PlayListId);
|
||||||
return Error.NotFound();
|
return Error.NotFound();
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
namespace Musify.Application.PlayLists
|
||||||
{
|
{
|
||||||
public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null)
|
public record GetPlayListByIdQuery(Guid Id)
|
||||||
: IQuery<ErrorOr<PlayListApplicationResponse>>;
|
: IQuery<ErrorOr<PlayListApplicationResponse>>;
|
||||||
|
|
||||||
public class GetPlayListByIdQueryHandler(IDatabase database)
|
public class GetPlayListByIdQueryHandler(IDatabase database)
|
||||||
@@ -29,11 +28,7 @@ namespace Musify.Application.PlayLists
|
|||||||
})
|
})
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (entry == null)
|
if (entry is null)
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
if (entry.PlayList.Visibility == PlaylistVisibility.Private
|
|
||||||
&& entry.PlayList.UserId != request.RequestingUserId)
|
|
||||||
return Error.NotFound();
|
return Error.NotFound();
|
||||||
|
|
||||||
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds);
|
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ namespace Musify.Application.PlayLists
|
|||||||
.Select(p => p.Pictures)
|
.Select(p => p.Pictures)
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (pictures == null)
|
if (pictures is null)
|
||||||
return Error.NotFound();
|
return Error.NotFound();
|
||||||
|
|
||||||
var routes = playListConfiguration.Routes;
|
var routes = playListConfiguration.Routes;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
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;
|
||||||
using X.PagedList.EF;
|
using X.PagedList.EF;
|
||||||
|
|
||||||
@@ -16,9 +17,9 @@ namespace Musify.Application.PlayLists
|
|||||||
: 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 playListExists = await database.PlayLists
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -28,8 +29,9 @@ namespace Musify.Application.PlayLists
|
|||||||
|
|
||||||
var tracksQuery = database.PlayListHasTracks
|
var tracksQuery = database.PlayListHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(plt => plt.Track.Owner)
|
.Include(plt => ((ExternalTrack)plt.Track).TrackArtists)
|
||||||
.Include(plt => plt.Track.Tags)
|
.ThenInclude(trackArtist => trackArtist.Artist)
|
||||||
|
.Include(plt => ((LocalTrack)plt.Track).Owner)
|
||||||
.Where(plt => plt.PlayListId == request.PlayListId);
|
.Where(plt => plt.PlayListId == request.PlayListId);
|
||||||
|
|
||||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ using Musify.Application.Contracts;
|
|||||||
namespace Musify.Application.PlayLists
|
namespace Musify.Application.PlayLists
|
||||||
{
|
{
|
||||||
public record GetPlayListsQuery(int PageNumber, int PageSize)
|
public record GetPlayListsQuery(int PageNumber, int PageSize)
|
||||||
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
|
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>> >;
|
||||||
|
|
||||||
public class GetPlayListsQueryHandler(IDatabase database)
|
public class GetPlayListsQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetPlayListsQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
|
: IQueryHandler<GetPlayListsQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse> >>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse> >> Handle(GetPlayListsQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var totalCount = await database.PlayLists.CountAsync(cancellationToken);
|
var totalCount = await database.PlayLists.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -14,24 +14,18 @@ namespace Musify.Application.PlayLists
|
|||||||
long UserId,
|
long UserId,
|
||||||
string? Name,
|
string? Name,
|
||||||
int PageNumber,
|
int PageNumber,
|
||||||
int PageSize,
|
int PageSize)
|
||||||
bool OnlyPublic = false)
|
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>> >;
|
||||||
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
|
|
||||||
|
|
||||||
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
|
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetPlayListsByUserIdQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
|
: IQueryHandler<GetPlayListsByUserIdQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse> > >
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse> >> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var playListsQuery = database.PlayLists
|
var playListsQuery = database.PlayLists
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(p => p.UserId == request.UserId);
|
.Where(p => p.UserId == request.UserId);
|
||||||
|
|
||||||
if (request.OnlyPublic)
|
|
||||||
{
|
|
||||||
playListsQuery = playListsQuery.Where(p => p.Visibility == PlaylistVisibility.Public);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Name))
|
if (!string.IsNullOrEmpty(request.Name))
|
||||||
{
|
{
|
||||||
var normalizedName = request.Name.Trim().ToUpperInvariant();
|
var normalizedName = request.Name.Trim().ToUpperInvariant();
|
||||||
@@ -51,7 +45,7 @@ namespace Musify.Application.PlayLists
|
|||||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
.Take(PlayListApplicationResponse.CoverTrackCount)
|
||||||
.Select(plt => plt.TrackId)
|
.Select(plt => plt.TrackId)
|
||||||
.ToList()
|
.ToList()
|
||||||
})
|
} )
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||||
|
|
||||||
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ namespace Musify.Application.PlayLists
|
|||||||
{
|
{
|
||||||
var playList = await database.PlayLists
|
var playList = await database.PlayLists
|
||||||
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
||||||
if (playList == null)
|
if (playList is null)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
||||||
return Error.NotFound();
|
return Error.NotFound();
|
||||||
@@ -32,7 +32,7 @@ namespace Musify.Application.PlayLists
|
|||||||
|
|
||||||
var link = await database.PlayListHasTracks
|
var link = await database.PlayListHasTracks
|
||||||
.SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
|
.SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
|
||||||
if (link == null)
|
if (link is null)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Track {TrackId} not in playlist {PlayListId}", request.TrackId, request.PlayListId);
|
logger.LogInformation("Track {TrackId} not in playlist {PlayListId}", request.TrackId, request.PlayListId);
|
||||||
return Error.NotFound(description: "Track is not in the playlist.");
|
return Error.NotFound(description: "Track is not in the playlist.");
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ namespace Musify.Application.PlayLists
|
|||||||
var effectiveSizeBytes = request.ExpectedSizeBytes
|
var effectiveSizeBytes = request.ExpectedSizeBytes
|
||||||
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
||||||
|
|
||||||
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
|
var objectName = $"{Guid.NewGuid() }.{request.FileType.TrimStart('.').ToLowerInvariant() }";
|
||||||
|
|
||||||
var tempKey = playListConfiguration.Routes.BuildTempPicturePath(
|
var tempKey = playListConfiguration.Routes.BuildTempPicturePath(
|
||||||
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName);
|
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName);
|
||||||
@@ -84,7 +84,7 @@ namespace Musify.Application.PlayLists
|
|||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
await transaction.CommitAsync(cancellationToken);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
|
||||||
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (PlayListPicture, temp key: {Key})", intent.Id, request.UserId, tempKey);
|
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (PlayListPicture, temp key: {Key} )", intent.Id, request.UserId, tempKey);
|
||||||
|
|
||||||
return new PlayListPictureUploadResponse(
|
return new PlayListPictureUploadResponse(
|
||||||
intent.Id,
|
intent.Id,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists.Responses
|
namespace Musify.Application.PlayLists.Responses
|
||||||
{
|
{
|
||||||
@@ -10,7 +9,6 @@ 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)
|
||||||
@@ -28,7 +26,6 @@ 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 ?? []);
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ 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(
|
||||||
@@ -35,7 +34,7 @@ namespace Musify.Application.PlayLists
|
|||||||
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 playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
||||||
if (playListEntity == null)
|
if (playListEntity is null)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
||||||
return Error.NotFound();
|
return Error.NotFound();
|
||||||
@@ -58,11 +57,6 @@ namespace Musify.Application.PlayLists
|
|||||||
playListEntity.Description = request.NewDescription;
|
playListEntity.Description = request.NewDescription;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.NewVisibility.HasValue)
|
|
||||||
{
|
|
||||||
playListEntity.Visibility = request.NewVisibility.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
UploadIntent? pictureIntent = null;
|
UploadIntent? pictureIntent = null;
|
||||||
string? finalPictureKey = null;
|
string? finalPictureKey = null;
|
||||||
|
|
||||||
@@ -78,14 +72,16 @@ namespace Musify.Application.PlayLists
|
|||||||
playListEntity.Pictures = new PlayListPictures
|
playListEntity.Pictures = new PlayListPictures
|
||||||
{
|
{
|
||||||
OriginalName = pictureIntent.ObjectName,
|
OriginalName = pictureIntent.ObjectName,
|
||||||
SmallName = pictureIntent.ObjectName,
|
SmallName = playListConfiguration.Routes.PresetSmallPicture,
|
||||||
MediumName = pictureIntent.ObjectName,
|
MediumName = playListConfiguration.Routes.PresetMediumPicture,
|
||||||
LargeName = pictureIntent.ObjectName
|
LargeName = playListConfiguration.Routes.PresetLargePicture
|
||||||
};
|
};
|
||||||
finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pictureIntent != null && finalPictureKey != null)
|
database.PlayLists.Update(playListEntity);
|
||||||
|
|
||||||
|
if (pictureIntent != null&& finalPictureKey != null)
|
||||||
{
|
{
|
||||||
var publishResult = await PublishUpdatePlayListPictureSourceEventAsync(
|
var publishResult = await PublishUpdatePlayListPictureSourceEventAsync(
|
||||||
playListEntity.Id, pictureIntent, finalPictureKey, cancellationToken);
|
playListEntity.Id, pictureIntent, finalPictureKey, cancellationToken);
|
||||||
@@ -115,7 +111,7 @@ namespace Musify.Application.PlayLists
|
|||||||
return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds);
|
return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync(
|
async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync(
|
||||||
Guid playListId,
|
Guid playListId,
|
||||||
UploadIntent pictureIntent,
|
UploadIntent pictureIntent,
|
||||||
string finalPictureKey,
|
string finalPictureKey,
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace Musify.Application.Serialization;
|
|
||||||
|
|
||||||
public sealed class LongAsStringConverter : JsonConverter<long>
|
|
||||||
{
|
|
||||||
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
|
|
||||||
reader.TokenType == JsonTokenType.String
|
|
||||||
? long.Parse(reader.GetString()!, CultureInfo.InvariantCulture)
|
|
||||||
: reader.GetInt64();
|
|
||||||
|
|
||||||
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) =>
|
|
||||||
writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class NullableLongAsStringConverter : JsonConverter<long?>
|
|
||||||
{
|
|
||||||
public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
|
||||||
{
|
|
||||||
if (reader.TokenType == JsonTokenType.Null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return reader.TokenType == JsonTokenType.String
|
|
||||||
? string.IsNullOrEmpty(reader.GetString()) ? null : long.Parse(reader.GetString()!, CultureInfo.InvariantCulture)
|
|
||||||
: reader.GetInt64();
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options)
|
|
||||||
{
|
|
||||||
if (value.HasValue)
|
|
||||||
writer.WriteStringValue(value.Value.ToString(CultureInfo.InvariantCulture));
|
|
||||||
else
|
|
||||||
writer.WriteNullValue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,9 @@ public sealed class TrackStreamIssuer(
|
|||||||
folderPath,
|
folderPath,
|
||||||
streamGatewayConfiguration.AudioFileName);
|
streamGatewayConfiguration.AudioFileName);
|
||||||
|
|
||||||
if (userId != null)
|
// 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 newListeningHistory = new ListeningHistory
|
||||||
{
|
{
|
||||||
@@ -46,7 +48,7 @@ public sealed class TrackStreamIssuer(
|
|||||||
|
|
||||||
private long? AnonymousFragmentBytes(long? userId)
|
private long? AnonymousFragmentBytes(long? userId)
|
||||||
{
|
{
|
||||||
if (userId != null || playbackConfiguration.AnonymousFragmentSeconds <= 0)
|
if (userId is not null || playbackConfiguration.AnonymousFragmentSeconds <= 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond;
|
return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ public sealed class UploadIntentValidator(
|
|||||||
var intent = await database.UploadIntents
|
var intent = await database.UploadIntents
|
||||||
.FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken);
|
.FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken);
|
||||||
|
|
||||||
if (intent == null || intent.UserId != userId)
|
if (intent is null || intent.UserId != userId)
|
||||||
return Error.NotFound(description: "Upload intent not found or not accessible.");
|
return Error.NotFound(description: "Upload intent not found or not accessible.");
|
||||||
|
|
||||||
if (intent.IsConsumed)
|
if (intent.IsConsumed)
|
||||||
@@ -52,7 +52,7 @@ public sealed class UploadIntentValidator(
|
|||||||
return Error.Validation(description: "Upload intent has expired.");
|
return Error.Validation(description: "Upload intent has expired.");
|
||||||
|
|
||||||
var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken);
|
var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken);
|
||||||
if (metadata == null)
|
if (metadata is null)
|
||||||
return Error.NotFound(description: "Uploaded object not found in storage. Upload the file first.");
|
return Error.NotFound(description: "Uploaded object not found in storage. Upload the file first.");
|
||||||
|
|
||||||
if (metadata.ContentLength > config.MaxUploadBytes)
|
if (metadata.ContentLength > config.MaxUploadBytes)
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
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];
|
||||||
|
}
|
||||||
@@ -17,8 +17,7 @@ namespace Musify.Application.Tracks
|
|||||||
long UserId,
|
long UserId,
|
||||||
string Title,
|
string Title,
|
||||||
Guid PictureIntentId,
|
Guid PictureIntentId,
|
||||||
Guid AudioIntentId,
|
Guid AudioIntentId)
|
||||||
IReadOnlyCollection<Genre> Tags)
|
|
||||||
: ICommand<ErrorOr<TrackApplicationResponse>>;
|
: ICommand<ErrorOr<TrackApplicationResponse>>;
|
||||||
|
|
||||||
public class CreateTrackCommandHandler(
|
public class CreateTrackCommandHandler(
|
||||||
@@ -35,29 +34,12 @@ namespace Musify.Application.Tracks
|
|||||||
{
|
{
|
||||||
var user = await database.Users
|
var user = await database.Users
|
||||||
.SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
.SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||||
if (user == null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
logger.LogWarning("User {UserId} not found", request.UserId);
|
logger.LogWarning("User {UserId} not found", request.UserId);
|
||||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
return Error.NotFound(description: $"User {request.UserId} not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
var distinctTags = request.Tags.Distinct().ToList();
|
|
||||||
if (distinctTags.Count == 0)
|
|
||||||
{
|
|
||||||
logger.LogWarning("Track {Title} was submitted without any tags", request.Title);
|
|
||||||
return Error.Validation(description: "At least one tag is required.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var tagConflicts = GenreCompatibility.FindConflicts(distinctTags);
|
|
||||||
if (tagConflicts.Count > 0)
|
|
||||||
{
|
|
||||||
var conflict = tagConflicts.First();
|
|
||||||
logger.LogWarning(
|
|
||||||
"Track {Title} was submitted with incompatible tags {First} and {Second}",
|
|
||||||
request.Title, conflict.First, conflict.Second);
|
|
||||||
return Error.Validation(description: $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||||
uploadIntentConfiguration,
|
uploadIntentConfiguration,
|
||||||
request.PictureIntentId, request.UserId, cancellationToken);
|
request.PictureIntentId, request.UserId, cancellationToken);
|
||||||
@@ -77,7 +59,7 @@ namespace Musify.Application.Tracks
|
|||||||
var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName);
|
var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName);
|
||||||
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
|
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
|
||||||
|
|
||||||
var trackEntity = new Track
|
var trackEntity = new LocalTrack
|
||||||
{
|
{
|
||||||
Title = request.Title,
|
Title = request.Title,
|
||||||
NormalizedTitle = request.Title.ToUpperInvariant(),
|
NormalizedTitle = request.Title.ToUpperInvariant(),
|
||||||
@@ -98,11 +80,7 @@ namespace Musify.Application.Tracks
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
trackEntity.Tags = distinctTags
|
await database.LocalTracks.AddAsync(trackEntity, cancellationToken);
|
||||||
.Select(tag => new TrackTag { TrackId = trackEntity.Id, Tag = tag })
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
|
||||||
|
|
||||||
await database.UserHasTracks.AddAsync(new UserHasTrack
|
await database.UserHasTracks.AddAsync(new UserHasTrack
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,13 +23,13 @@ namespace Musify.Application.Tracks
|
|||||||
public async ValueTask<ErrorOr<Success>> Handle(DeleteTrackCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(DeleteTrackCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
|
var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
|
||||||
if (track == null)
|
if (track is null)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
||||||
return Error.NotFound();
|
return Error.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (track.OwnerUserId != request.UserId)
|
if (track is not LocalTrack localTrack || localTrack.OwnerUserId != request.UserId)
|
||||||
{
|
{
|
||||||
logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId);
|
logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId);
|
||||||
return Error.Unauthorized();
|
return Error.Unauthorized();
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
|
||||||
{
|
|
||||||
public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery<ErrorOr<TrackApplicationResponse>>;
|
|
||||||
|
|
||||||
public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetLastTrackListenedByUserIdQuery, ErrorOr<TrackApplicationResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var track = await database.ListeningHistories
|
|
||||||
.AsNoTracking()
|
|
||||||
.Include(t => t.Track.Owner)
|
|
||||||
.Include(t => t.Track.Tags)
|
|
||||||
.Where(t => t.UserId == query.UserId)
|
|
||||||
.OrderByDescending(t => t.ListenedAt)
|
|
||||||
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count))
|
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
return track == null
|
|
||||||
? Error.NotFound("Track.NotFound", "No track found for the given user ID.")
|
|
||||||
: track;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
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.Tracks
|
namespace Musify.Application.Tracks
|
||||||
{
|
{
|
||||||
@@ -16,7 +17,6 @@ namespace Musify.Application.Tracks
|
|||||||
public async ValueTask<IEnumerable<TrackApplicationResponse>> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken)
|
public async ValueTask<IEnumerable<TrackApplicationResponse>> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var recentTrackIds = await database.ListeningHistories
|
var recentTrackIds = await database.ListeningHistories
|
||||||
.AsNoTracking()
|
|
||||||
.Where(l => l.UserId == query.UserId)
|
.Where(l => l.UserId == query.UserId)
|
||||||
.GroupBy(l => l.TrackId)
|
.GroupBy(l => l.TrackId)
|
||||||
.OrderByDescending(g => g.Max(l => l.ListenedAt))
|
.OrderByDescending(g => g.Max(l => l.ListenedAt))
|
||||||
@@ -25,15 +25,15 @@ namespace Musify.Application.Tracks
|
|||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var tracksById = await database.Tracks
|
var tracksById = await database.Tracks
|
||||||
.AsNoTracking()
|
.Include(t => ((ExternalTrack)t).TrackArtists)
|
||||||
.Include(t => t.Owner)
|
.ThenInclude(trackArtist => trackArtist.Artist)
|
||||||
.Include(t => t.Tags)
|
.Include(t => ((LocalTrack)t).Owner)
|
||||||
.Where(t => recentTrackIds.Contains(t.Id))
|
.Where(t => recentTrackIds.Contains(t.Id))
|
||||||
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count })
|
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count } )
|
||||||
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
||||||
|
|
||||||
return recentTrackIds
|
return recentTrackIds
|
||||||
.Select(id => tracksById[id])
|
.Select(id => tracksById[id] )
|
||||||
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.ListensCount));
|
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.ListensCount));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
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.Tracks
|
namespace Musify.Application.Tracks
|
||||||
{
|
{
|
||||||
@@ -10,18 +11,19 @@ namespace Musify.Application.Tracks
|
|||||||
: IQuery<ErrorOr<TrackApplicationResponse>>;
|
: IQuery<ErrorOr<TrackApplicationResponse>>;
|
||||||
|
|
||||||
public class GetTrackByIdQueryHandler(IDatabase database)
|
public class GetTrackByIdQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetTrackByIdQuery, ErrorOr<TrackApplicationResponse>>
|
: IQueryHandler<GetTrackByIdQuery, ErrorOr<TrackApplicationResponse> >
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var entity = await database.Tracks.AsNoTracking()
|
var entity = await database.Tracks.AsNoTracking()
|
||||||
.Include(t => t.Owner)
|
.Include(t => ((ExternalTrack)t).TrackArtists)
|
||||||
.Include(t => t.Tags)
|
.ThenInclude(trackArtist => trackArtist.Artist)
|
||||||
|
.Include(t => ((LocalTrack)t).Owner)
|
||||||
.Where(t => t.Id == request.TrackId)
|
.Where(t => t.Id == request.TrackId)
|
||||||
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count })
|
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count })
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (entity == null)
|
if (entity is null)
|
||||||
return Error.NotFound();
|
return Error.NotFound();
|
||||||
|
|
||||||
return TrackApplicationResponse.FromEntity(entity.Track, entity.ListensCount);
|
return TrackApplicationResponse.FromEntity(entity.Track, entity.ListensCount);
|
||||||
|
|||||||
@@ -48,6 +48,6 @@ namespace Musify.Application.Tracks
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static string? BuildKey(Func<string, string> builder, string? name)
|
private static string? BuildKey(Func<string, string> builder, string? name)
|
||||||
=> string.IsNullOrEmpty(name) ? null : builder(name);
|
=> string.IsNullOrEmpty(name)? null: builder(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,8 @@ using Musify.Application.Tracks.Responses;
|
|||||||
|
|
||||||
namespace Musify.Application.Tracks
|
namespace Musify.Application.Tracks
|
||||||
{
|
{
|
||||||
|
/// <param name="UserId">Null for an anonymous request — allowed only when
|
||||||
|
/// <see cref="PlaybackConfiguration.AllowAnonymousListening"/> is enabled.</param>
|
||||||
public record GetTrackStreamQuery(Guid TrackId, long? UserId)
|
public record GetTrackStreamQuery(Guid TrackId, long? UserId)
|
||||||
: IQuery<ErrorOr<TrackStreamResponse>>;
|
: IQuery<ErrorOr<TrackStreamResponse>>;
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ namespace Musify.Application.Tracks
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<TrackStreamResponse>> Handle(GetTrackStreamQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<TrackStreamResponse>> Handle(GetTrackStreamQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (request.UserId == null && !playbackConfiguration.AllowAnonymousListening)
|
if (request.UserId is null && !playbackConfiguration.AllowAnonymousListening)
|
||||||
return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening.");
|
return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening.");
|
||||||
|
|
||||||
var track = await database.Tracks.AsNoTracking()
|
var track = await database.Tracks.AsNoTracking()
|
||||||
|
|||||||
@@ -1,25 +1,37 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using X.PagedList;
|
using X.PagedList;
|
||||||
using X.PagedList.EF;
|
using X.PagedList.EF;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
namespace Musify.Application.Tracks
|
||||||
{
|
{
|
||||||
public record GetTracksQuery(string? Name, int PageNumber, int PageSize)
|
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, string? YoutubeContinuationToken = null)
|
||||||
: IQuery<ErrorOr<TracksSearchResponse>>;
|
: IQuery<ErrorOr<TracksSearchResponse>>;
|
||||||
|
|
||||||
public class GetTracksQueryHandler(IDatabase database)
|
public class GetTracksQueryHandler(
|
||||||
|
IDatabase database,
|
||||||
|
IYouTubeMusicService youTubeMusicService,
|
||||||
|
ILogger<GetTracksQueryHandler> logger)
|
||||||
: IQueryHandler<GetTracksQuery, ErrorOr<TracksSearchResponse>>
|
: IQueryHandler<GetTracksQuery, ErrorOr<TracksSearchResponse>>
|
||||||
{
|
{
|
||||||
|
// Mirrors the web client's search-box debounce threshold: a query shorter than this is too
|
||||||
|
// likely to still be mid-keystroke to justify a live call to the YouTube Music API for every
|
||||||
|
// client that hits this endpoint.
|
||||||
|
private const int MinNameLengthForYouTubeSearch = 2;
|
||||||
|
|
||||||
public async ValueTask<ErrorOr<TracksSearchResponse>> Handle(GetTracksQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<TracksSearchResponse>> Handle(GetTracksQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var tracksQuery = database.UserHasTracks
|
var tracksQuery = database.UserHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(ut => ut.Track.Owner)
|
.Include(ut => ((ExternalTrack)ut.Track).TrackArtists)
|
||||||
.Include(ut => ut.Track.Tags)
|
.ThenInclude(trackArtist => trackArtist.Artist)
|
||||||
|
.Include(ut => ((LocalTrack)ut.Track).Owner)
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Name))
|
if (!string.IsNullOrEmpty(request.Name))
|
||||||
@@ -36,18 +48,61 @@ namespace Musify.Application.Tracks
|
|||||||
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count })
|
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count })
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||||
|
|
||||||
var items = pagedEntities
|
var localItems = pagedEntities
|
||||||
.Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)))
|
.Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return new TracksSearchResponse(
|
var knownVideoIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
items,
|
foreach (var entry in pagedEntities)
|
||||||
|
{
|
||||||
|
if (entry.Track is ExternalTrack { Source: TrackSource.YouTube } external)
|
||||||
|
knownVideoIds.Add(external.ExternalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var youtubeItems = new List<TrackSearchItemResponse>();
|
||||||
|
string? nextContinuationToken = null;
|
||||||
|
var youtubeUnavailable = false;
|
||||||
|
|
||||||
|
if (request.Name?.Trim().Length >= MinNameLengthForYouTubeSearch)
|
||||||
|
(youtubeItems, nextContinuationToken, youtubeUnavailable) =
|
||||||
|
await SearchYouTubeAsync(request.Name, request.YoutubeContinuationToken, knownVideoIds, cancellationToken);
|
||||||
|
|
||||||
|
return TracksSearchResponse.Create(
|
||||||
|
[.. localItems, .. youtubeItems],
|
||||||
pagedEntities.PageNumber,
|
pagedEntities.PageNumber,
|
||||||
pagedEntities.PageSize,
|
pagedEntities.PageSize,
|
||||||
pagedEntities.PageCount,
|
pagedEntities.PageCount,
|
||||||
pagedEntities.TotalItemCount,
|
pagedEntities.TotalItemCount,
|
||||||
pagedEntities.HasPreviousPage,
|
pagedEntities.HasPreviousPage,
|
||||||
pagedEntities.HasNextPage);
|
pagedEntities.HasNextPage,
|
||||||
|
nextContinuationToken,
|
||||||
|
youtubeUnavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(List<TrackSearchItemResponse> Items, string? NextContinuationToken, bool Unavailable)> SearchYouTubeAsync(
|
||||||
|
string name,
|
||||||
|
string? continuationToken,
|
||||||
|
HashSet<string> knownVideoIds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var searchResult = await youTubeMusicService.SearchSongsAsync(name, continuationToken ?? string.Empty, cancellationToken);
|
||||||
|
if (searchResult.IsError)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"YouTube search unavailable while combining track results: {Error}", searchResult.FirstError.Description);
|
||||||
|
return ([], null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = searchResult.Value.Items
|
||||||
|
.Where(song => knownVideoIds.Add(song.VideoId))
|
||||||
|
.Select(TrackSearchItemResponse.FromYouTubeSong)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var nextToken = string.IsNullOrEmpty(searchResult.Value.ContinuationToken)
|
||||||
|
? null
|
||||||
|
: searchResult.Value.ContinuationToken;
|
||||||
|
|
||||||
|
return (items, nextToken, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Musify.Application.Tracks.Responses;
|
|||||||
using X.PagedList;
|
using X.PagedList;
|
||||||
using X.PagedList.EF;
|
using X.PagedList.EF;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
namespace Musify.Application.Tracks
|
||||||
{
|
{
|
||||||
@@ -14,20 +15,21 @@ namespace Musify.Application.Tracks
|
|||||||
string? Name,
|
string? Name,
|
||||||
int PageNumber,
|
int PageNumber,
|
||||||
int PageSize)
|
int PageSize)
|
||||||
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse> > >;
|
||||||
|
|
||||||
public class GetTracksByUserIdQueryHandler(IDatabase database)
|
public class GetTracksByUserIdQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetTracksByUserIdQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
|
: IQueryHandler<GetTracksByUserIdQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>> >
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse> >> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var pageNumber = request.PageNumber < 1 ? 1 : request.PageNumber;
|
var pageNumber = request.PageNumber < 1 ? 1 : request.PageNumber;
|
||||||
var pageSize = request.PageSize < 1 ? 10 : request.PageSize;
|
var pageSize = request.PageSize < 1 ? 10 : request.PageSize;
|
||||||
|
|
||||||
var tracksQuery = database.UserHasTracks
|
var tracksQuery = database.UserHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(ut => ut.Track.Owner)
|
.Include(ut => ((ExternalTrack)ut.Track).TrackArtists)
|
||||||
.Include(ut => ut.Track.Tags)
|
.ThenInclude(trackArtist => trackArtist.Artist)
|
||||||
|
.Include(ut => ((LocalTrack)ut.Track).Owner)
|
||||||
.Where(p => p.UserId == request.UserId);
|
.Where(p => p.UserId == request.UserId);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Name))
|
if (!string.IsNullOrEmpty(request.Name))
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Tracks.Responses
|
||||||
|
{
|
||||||
|
public record ExternalTrackApplicationResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Title,
|
||||||
|
string? Artist,
|
||||||
|
ProcessingStatus AudioStatus,
|
||||||
|
double Duration,
|
||||||
|
int ListensCount,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
DateTime UpdatedAt,
|
||||||
|
string ExternalId)
|
||||||
|
: TrackApplicationResponse(Id, Title, Artist, AudioStatus, Duration, ListensCount, CreatedAt, UpdatedAt)
|
||||||
|
{
|
||||||
|
public static ExternalTrackApplicationResponse FromEntity(ExternalTrack track, int listensCount) =>
|
||||||
|
new(
|
||||||
|
track.Id,
|
||||||
|
track.Title,
|
||||||
|
FormatArtist(track),
|
||||||
|
track.Audio.TranscodeStatus,
|
||||||
|
track.DurationSeconds,
|
||||||
|
listensCount,
|
||||||
|
track.CreatedAt,
|
||||||
|
track.UpdatedAt,
|
||||||
|
track.ExternalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Tracks.Responses
|
||||||
|
{
|
||||||
|
public record LocalTrackApplicationResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Title,
|
||||||
|
string? Artist,
|
||||||
|
ProcessingStatus AudioStatus,
|
||||||
|
double Duration,
|
||||||
|
int ListensCount,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
DateTime UpdatedAt,
|
||||||
|
long OwnerUserId)
|
||||||
|
: TrackApplicationResponse(Id, Title, Artist, AudioStatus, Duration, ListensCount, CreatedAt, UpdatedAt)
|
||||||
|
{
|
||||||
|
public static LocalTrackApplicationResponse FromEntity(LocalTrack track, int listensCount) =>
|
||||||
|
new(
|
||||||
|
track.Id,
|
||||||
|
track.Title,
|
||||||
|
FormatArtist(track),
|
||||||
|
track.Audio.TranscodeStatus,
|
||||||
|
track.DurationSeconds,
|
||||||
|
listensCount,
|
||||||
|
track.CreatedAt,
|
||||||
|
track.UpdatedAt,
|
||||||
|
track.OwnerUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,24 @@
|
|||||||
using Musify.Application.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks.Responses
|
namespace Musify.Application.Tracks.Responses
|
||||||
{
|
{
|
||||||
public record TrackApplicationResponse(
|
// See AlbumApplicationResponse for why [JsonPolymorphic]/[JsonDerivedType] are required
|
||||||
|
// (not optional) whenever a base type carries subtype-only data across the wire. Track is a
|
||||||
|
// cleaner case than Album: both OwnerUserId (local-only) and ExternalId (external-only) are
|
||||||
|
// genuinely exclusive to one side, so the base has no "meaningless sentinel" field at all —
|
||||||
|
// every real value is one of the two derived types, hence base is abstract.
|
||||||
|
//
|
||||||
|
// No explicit Source property here (unlike the domain entities): STJ's polymorphism support
|
||||||
|
// already injects a "source" discriminator property with the same "Local"/"YouTube" values a
|
||||||
|
// hand-written TrackSource field would have, and a real property with that exact name would
|
||||||
|
// collide with it ("contains property 'source' that conflicts with an existing metadata
|
||||||
|
// property name"). Use the discriminator (or a type check) instead of a Source field.
|
||||||
|
[JsonPolymorphic(TypeDiscriminatorPropertyName = "source")]
|
||||||
|
[JsonDerivedType(typeof(LocalTrackApplicationResponse), typeDiscriminator: "Local")]
|
||||||
|
[JsonDerivedType(typeof(ExternalTrackApplicationResponse), typeDiscriminator: "YouTube")]
|
||||||
|
public abstract record TrackApplicationResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Title,
|
string Title,
|
||||||
string? Artist,
|
string? Artist,
|
||||||
@@ -13,21 +26,24 @@ namespace Musify.Application.Tracks.Responses
|
|||||||
double Duration,
|
double Duration,
|
||||||
int ListensCount,
|
int ListensCount,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
DateTime UpdatedAt,
|
DateTime UpdatedAt)
|
||||||
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
|
|
||||||
IReadOnlyCollection<Genre> Tags)
|
|
||||||
{
|
{
|
||||||
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
|
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
|
||||||
new(
|
track switch
|
||||||
track.Id,
|
{
|
||||||
track.Title,
|
LocalTrack local => LocalTrackApplicationResponse.FromEntity(local, listensCount),
|
||||||
track.Owner.Name,
|
ExternalTrack external => ExternalTrackApplicationResponse.FromEntity(external, listensCount),
|
||||||
track.Audio.TranscodeStatus,
|
_ => throw new NotSupportedException($"Unsupported track entity type '{track.GetType().Name}'.")
|
||||||
track.DurationSeconds,
|
};
|
||||||
listensCount,
|
|
||||||
track.CreatedAt,
|
protected static string? FormatArtist(Track track) => track switch
|
||||||
track.UpdatedAt,
|
{
|
||||||
track.OwnerUserId,
|
LocalTrack local => local.Owner.Name,
|
||||||
track.Tags.Select(tag => tag.Tag).ToList());
|
ExternalTrack { TrackArtists.Count: > 0 } external =>
|
||||||
|
string.Join(", ", external.TrackArtists
|
||||||
|
.OrderBy(trackArtist => trackArtist.Position)
|
||||||
|
.Select(trackArtist => trackArtist.Artist.Name)),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks.Responses
|
namespace Musify.Application.Tracks.Responses
|
||||||
{
|
{
|
||||||
public record TrackSearchItemResponse(TrackApplicationResponse Track)
|
/// <summary>One item of a combined local/YouTube track listing. Exactly one of
|
||||||
|
/// <see cref="Track"/> or <see cref="YouTubeSong"/> is populated: <see cref="Track"/> for
|
||||||
|
/// anything already in the catalog (local uploads and YouTube tracks provisioned earlier),
|
||||||
|
/// <see cref="YouTubeSong"/> for a live YouTube Music search hit that has not been
|
||||||
|
/// provisioned yet, so it has no track id.</summary>
|
||||||
|
public record TrackSearchItemResponse(
|
||||||
|
TrackSource Source,
|
||||||
|
TrackApplicationResponse? Track,
|
||||||
|
YouTubeSongResult? YouTubeSong)
|
||||||
{
|
{
|
||||||
public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) => new(track);
|
public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) =>
|
||||||
|
new(track is ExternalTrackApplicationResponse ? TrackSource.YouTube : TrackSource.Local, track, null);
|
||||||
|
|
||||||
|
public static TrackSearchItemResponse FromYouTubeSong(YouTubeSongResult song) =>
|
||||||
|
new(TrackSource.YouTube, null, song);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
namespace Musify.Application.Tracks.Responses
|
namespace Musify.Application.Tracks.Responses
|
||||||
{
|
{
|
||||||
|
/// <summary>Result of a combined tracks listing/search: a page of local catalog tracks
|
||||||
|
/// (uploads and previously provisioned YouTube tracks), plus, when a search term was given,
|
||||||
|
/// live YouTube Music hits appended after them. Pagination metadata (<see cref="PageNumber"/>,
|
||||||
|
/// <see cref="PageSize"/>, <see cref="PageCount"/>, <see cref="TotalItemCount"/>) describes only
|
||||||
|
/// the local page — YouTube has no stable total, so its continuation is tracked separately via
|
||||||
|
/// <see cref="NextYoutubeContinuationToken"/>.</summary>
|
||||||
public record TracksSearchResponse(
|
public record TracksSearchResponse(
|
||||||
IReadOnlyList<TrackSearchItemResponse> Items,
|
IReadOnlyList<TrackSearchItemResponse> Items,
|
||||||
int PageNumber,
|
int PageNumber,
|
||||||
@@ -8,5 +13,26 @@ namespace Musify.Application.Tracks.Responses
|
|||||||
int PageCount,
|
int PageCount,
|
||||||
int TotalItemCount,
|
int TotalItemCount,
|
||||||
bool HasPreviousPage,
|
bool HasPreviousPage,
|
||||||
bool HasNextPage);
|
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 TracksSearchResponse Create(
|
||||||
|
IReadOnlyList<TrackSearchItemResponse> 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ namespace Musify.Application.Users
|
|||||||
}
|
}
|
||||||
|
|
||||||
public class CreateUserCommandHandler(IDatabase database, ILogger<CreateUserCommandHandler> logger)
|
public class CreateUserCommandHandler(IDatabase database, ILogger<CreateUserCommandHandler> logger)
|
||||||
: ICommandHandler<CreateUserCommand, ErrorOr<UserApplicationResponse>>
|
: ICommandHandler<CreateUserCommand, ErrorOr<UserApplicationResponse> >
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<UserApplicationResponse>> Handle(CreateUserCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<UserApplicationResponse>> Handle(CreateUserCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Musify.Application.Users
|
|
||||||
{
|
|
||||||
public record FollowUserCommand(long FollowerId, long FollowedId) : ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class FollowUserCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<FollowUserCommandHandler> logger)
|
|
||||||
: ICommandHandler<FollowUserCommand, ErrorOr<Success>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(FollowUserCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (request.FollowerId == request.FollowedId)
|
|
||||||
return Error.Validation(description: "A user cannot follow themselves.");
|
|
||||||
|
|
||||||
var followedExists = await database.Users
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(u => u.Id == request.FollowedId, cancellationToken);
|
|
||||||
if (!followedExists)
|
|
||||||
{
|
|
||||||
logger.LogInformation("User {FollowedId} not found", request.FollowedId);
|
|
||||||
return Error.NotFound(description: $"User {request.FollowedId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var alreadyFollowing = await database.UserFollows
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken);
|
|
||||||
if (alreadyFollowing)
|
|
||||||
return new Success();
|
|
||||||
|
|
||||||
await database.UserFollows.AddAsync(new UserFollow
|
|
||||||
{
|
|
||||||
FollowerId = request.FollowerId,
|
|
||||||
FollowedId = request.FollowedId
|
|
||||||
}, cancellationToken);
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId);
|
|
||||||
|
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Users
|
|
||||||
{
|
|
||||||
public record GetListeningStatsQuery(long UserId) : IQuery<ListeningStatsResponse>;
|
|
||||||
|
|
||||||
public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays);
|
|
||||||
|
|
||||||
public class GetListeningStatsQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetListeningStatsQuery, ListeningStatsResponse>
|
|
||||||
{
|
|
||||||
public async ValueTask<ListeningStatsResponse> Handle(GetListeningStatsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var weekAgo = DateTime.UtcNow.AddDays(-7);
|
|
||||||
|
|
||||||
var thisWeekHistory = database.ListeningHistories
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo);
|
|
||||||
|
|
||||||
var tracksThisWeek = await thisWeekHistory
|
|
||||||
.Select(l => l.TrackId)
|
|
||||||
.Distinct()
|
|
||||||
.CountAsync(cancellationToken);
|
|
||||||
|
|
||||||
var secondsThisWeek = await thisWeekHistory
|
|
||||||
.Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => t.DurationSeconds)
|
|
||||||
.SumAsync(cancellationToken);
|
|
||||||
|
|
||||||
var listenedDates = await database.ListeningHistories
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(l => l.UserId == request.UserId)
|
|
||||||
.Select(l => l.ListenedAt.Date)
|
|
||||||
.Distinct()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var listenedDateSet = listenedDates.ToHashSet();
|
|
||||||
var streakDays = 0;
|
|
||||||
var day = DateTime.UtcNow.Date;
|
|
||||||
while (listenedDateSet.Contains(day))
|
|
||||||
{
|
|
||||||
streakDays++;
|
|
||||||
day = day.AddDays(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ListeningStatsResponse(tracksThisWeek, (int)Math.Round(secondsThisWeek), streakDays);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,10 +7,10 @@ using Musify.Application.Users.Responses;
|
|||||||
namespace Musify.Application.Users
|
namespace Musify.Application.Users
|
||||||
{
|
{
|
||||||
public record GetUserByIdQuery(long Id)
|
public record GetUserByIdQuery(long Id)
|
||||||
: IQuery<ErrorOr<UserApplicationResponse>>;
|
: IQuery<ErrorOr<UserApplicationResponse> >;
|
||||||
|
|
||||||
public class GetUserByIdQueryHandler(IDatabase database)
|
public class GetUserByIdQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetUserByIdQuery, ErrorOr<UserApplicationResponse>>
|
: IQueryHandler<GetUserByIdQuery, ErrorOr<UserApplicationResponse> >
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<UserApplicationResponse>> Handle(GetUserByIdQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<UserApplicationResponse>> Handle(GetUserByIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -20,7 +20,7 @@ namespace Musify.Application.Users
|
|||||||
.Select(u => UserApplicationResponse.FromEntity(u))
|
.Select(u => UserApplicationResponse.FromEntity(u))
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (user == null)
|
if (user is null)
|
||||||
return Error.NotFound();
|
return Error.NotFound();
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Serialization;
|
|
||||||
|
|
||||||
namespace Musify.Application.Users
|
|
||||||
{
|
|
||||||
public record GetUserProfileQuery(long UserId) : IQuery<ErrorOr<UserProfileResponse>>;
|
|
||||||
|
|
||||||
public record UserProfileResponse(
|
|
||||||
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
|
|
||||||
string Name,
|
|
||||||
string? FirstName,
|
|
||||||
string? SecondName,
|
|
||||||
string? ProfilePictureUrl,
|
|
||||||
DateTime CreatedAt,
|
|
||||||
int FollowersCount,
|
|
||||||
int FollowingCount);
|
|
||||||
|
|
||||||
public class GetUserProfileQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetUserProfileQuery, ErrorOr<UserProfileResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<UserProfileResponse>> Handle(GetUserProfileQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var user = await database.Users
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(u => u.Id == request.UserId)
|
|
||||||
.Select(u => new
|
|
||||||
{
|
|
||||||
u.Id,
|
|
||||||
u.Name,
|
|
||||||
u.FirstName,
|
|
||||||
u.SecondName,
|
|
||||||
u.ProfilePictureUrl,
|
|
||||||
u.CreatedAt
|
|
||||||
})
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (user == null)
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
var followersCount = await database.UserFollows
|
|
||||||
.AsNoTracking()
|
|
||||||
.CountAsync(f => f.FollowedId == request.UserId, cancellationToken);
|
|
||||||
|
|
||||||
var followingCount = await database.UserFollows
|
|
||||||
.AsNoTracking()
|
|
||||||
.CountAsync(f => f.FollowerId == request.UserId, cancellationToken);
|
|
||||||
|
|
||||||
return new UserProfileResponse(
|
|
||||||
user.Id,
|
|
||||||
user.Name,
|
|
||||||
user.FirstName,
|
|
||||||
user.SecondName,
|
|
||||||
user.ProfilePictureUrl,
|
|
||||||
user.CreatedAt,
|
|
||||||
followersCount,
|
|
||||||
followingCount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,29 +9,29 @@ using Musify.Application.Contracts;
|
|||||||
namespace Musify.Application.Users
|
namespace Musify.Application.Users
|
||||||
{
|
{
|
||||||
public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch)
|
public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch)
|
||||||
: IQuery<ErrorOr<PaginatedResponse<UserApplicationResponse>>>;
|
: IQuery<ErrorOr<PaginatedResponse<UserApplicationResponse> >>;
|
||||||
|
|
||||||
public class GetUsersQueryHandler(IDatabase database)
|
public class GetUsersQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetUsersQuery, ErrorOr<PaginatedResponse<UserApplicationResponse>>>
|
: IQueryHandler<GetUsersQuery, ErrorOr<PaginatedResponse<UserApplicationResponse> > >
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<UserApplicationResponse>>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PaginatedResponse<UserApplicationResponse> >> Handle(GetUsersQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var usersQuery = database.Users
|
var user = database.Users
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.UsernameSearch))
|
if (!string.IsNullOrEmpty(request.UsernameSearch))
|
||||||
{
|
{
|
||||||
var normalizedUsername = request.UsernameSearch.Trim().ToUpperInvariant();
|
var normalizedUsername = request.UsernameSearch.Trim().ToUpperInvariant();
|
||||||
usersQuery = usersQuery.Where(u => u.NormalizedName.Contains(normalizedUsername));
|
user = user.Where(u => u.NormalizedName.Contains(normalizedUsername));
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalCount = await usersQuery.CountAsync(cancellationToken);
|
var totalCount = await user.CountAsync(cancellationToken);
|
||||||
var pagedUsers = await usersQuery
|
var pagedUser = await user
|
||||||
.Select(u => UserApplicationResponse.FromEntity(u))
|
.Select(u => UserApplicationResponse.FromEntity(u))
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||||
|
|
||||||
return PaginatedResponse<UserApplicationResponse>.FromPagedList(pagedUsers);
|
return PaginatedResponse<UserApplicationResponse>.FromPagedList(pagedUser);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Users
|
|
||||||
{
|
|
||||||
public record IsFollowingUserQuery(long FollowerId, long FollowedId) : IQuery<bool>;
|
|
||||||
|
|
||||||
public class IsFollowingUserQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<IsFollowingUserQuery, bool>
|
|
||||||
{
|
|
||||||
public ValueTask<bool> Handle(IsFollowingUserQuery request, CancellationToken cancellationToken) =>
|
|
||||||
new(database.UserFollows
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
using System.Text.Json.Serialization;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Application.Serialization;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Musify.Application.Users.Responses
|
namespace Musify.Application.Users.Responses
|
||||||
{
|
{
|
||||||
public record UserApplicationResponse(
|
public record UserApplicationResponse(
|
||||||
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
|
long Id,
|
||||||
string Name,
|
string Name,
|
||||||
string? FirstName,
|
string? FirstName,
|
||||||
string? SecondName,
|
string? SecondName,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ namespace Musify.Application.Users
|
|||||||
var user = await database.Users
|
var user = await database.Users
|
||||||
.SingleOrDefaultAsync(u => u.Id == request.Id, cancellationToken);
|
.SingleOrDefaultAsync(u => u.Id == request.Id, cancellationToken);
|
||||||
|
|
||||||
if (user == null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
await database.Users.AddAsync(new User
|
await database.Users.AddAsync(new User
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Users
|
|
||||||
{
|
|
||||||
public record UnfollowUserCommand(long FollowerId, long FollowedId) : ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class UnfollowUserCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<UnfollowUserCommandHandler> logger)
|
|
||||||
: ICommandHandler<UnfollowUserCommand, ErrorOr<Success>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(UnfollowUserCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var follow = await database.UserFollows
|
|
||||||
.SingleOrDefaultAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken);
|
|
||||||
|
|
||||||
if (follow == null)
|
|
||||||
return new Success();
|
|
||||||
|
|
||||||
database.UserFollows.Remove(follow);
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("User {FollowerId} unfollowed user {FollowedId}", request.FollowerId, request.FollowedId);
|
|
||||||
|
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Services;
|
||||||
|
|
||||||
|
namespace Musify.Application.YouTube
|
||||||
|
{
|
||||||
|
public record GetYouTubeAlbumQuery(string AlbumId)
|
||||||
|
: IQuery<ErrorOr<YouTubeAlbumDetail>>;
|
||||||
|
|
||||||
|
public class GetYouTubeAlbumQueryHandler(
|
||||||
|
IYouTubeMusicService youTubeMusicService,
|
||||||
|
YouTubeTrackProvisioner provisioner,
|
||||||
|
ILogger<GetYouTubeAlbumQueryHandler> logger)
|
||||||
|
: IQueryHandler<GetYouTubeAlbumQuery, ErrorOr<YouTubeAlbumDetail>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<YouTubeAlbumDetail>> Handle(GetYouTubeAlbumQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await youTubeMusicService.GetAlbumAsync(request.AlbumId, cancellationToken);
|
||||||
|
if (result.IsError)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await provisioner.MaterializeAlbumAsync(request.AlbumId, result.Value, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(exception, "Failed to materialize YouTube album {AlbumId}", request.AlbumId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Services;
|
||||||
|
using Musify.Application.YouTube.Responses;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Musify.Application.YouTube
|
||||||
|
{
|
||||||
|
public record ResolveYouTubeTrackStreamCommand(string VideoId, long? UserId)
|
||||||
|
: ICommand<ErrorOr<YouTubeStreamResponse>>;
|
||||||
|
|
||||||
|
public class ResolveYouTubeTrackStreamCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
IYouTubeMusicService youTubeMusicService,
|
||||||
|
YouTubeTrackProvisioner provisioner,
|
||||||
|
TrackStreamIssuer streamIssuer,
|
||||||
|
PlaybackConfiguration playbackConfiguration,
|
||||||
|
ILogger<ResolveYouTubeTrackStreamCommandHandler> logger)
|
||||||
|
: ICommandHandler<ResolveYouTubeTrackStreamCommand, ErrorOr<YouTubeStreamResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<YouTubeStreamResponse>> Handle(ResolveYouTubeTrackStreamCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var isAnonymous = !request.UserId.HasValue;
|
||||||
|
if (isAnonymous && !playbackConfiguration.AllowAnonymousListening)
|
||||||
|
return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening.");
|
||||||
|
|
||||||
|
var track = await provisioner.FindAsync(request.VideoId, cancellationToken);
|
||||||
|
|
||||||
|
if (track != null && track.Audio.IsProcessed)
|
||||||
|
{
|
||||||
|
var serverStream = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken);
|
||||||
|
return new YouTubeStreamResponse(
|
||||||
|
track.Id,
|
||||||
|
YouTubeStreamMode.Server,
|
||||||
|
serverStream.ManifestUrl,
|
||||||
|
serverStream.Ticket,
|
||||||
|
serverStream.ExpiresInSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAnonymous && playbackConfiguration.AnonymousFragmentSeconds > 0)
|
||||||
|
return Error.Conflict(description: "This track isn't available for anonymous preview yet. Try again in a moment.");
|
||||||
|
|
||||||
|
var streamInfo = await youTubeMusicService.GetAudioStreamAsync(request.VideoId, cancellationToken);
|
||||||
|
if (streamInfo.IsError)
|
||||||
|
return streamInfo.Errors;
|
||||||
|
|
||||||
|
if (track == null)
|
||||||
|
{
|
||||||
|
var provisionResult = await provisioner.GetOrCreateAsync(request.VideoId, cancellationToken);
|
||||||
|
if (!provisionResult.IsError)
|
||||||
|
track = provisionResult.Value.Track;
|
||||||
|
else
|
||||||
|
logger.LogWarning("Could not provision YouTube track {VideoId}: {Error}", request.VideoId, provisionResult.FirstError.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track != null&& request.UserId != null)
|
||||||
|
{
|
||||||
|
await database.ListeningHistories.AddAsync(new ListeningHistory
|
||||||
|
{
|
||||||
|
UserId = request.UserId.Value,
|
||||||
|
TrackId = track.Id,
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(exception, "Failed to record listening history for YouTube video {VideoId}", request.VideoId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new YouTubeStreamResponse(
|
||||||
|
track?.Id,
|
||||||
|
YouTubeStreamMode.YouTube,
|
||||||
|
streamInfo.Value.Url, null,
|
||||||
|
streamInfo.Value.ExpiresInSeconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Musify.Application.YouTube.Responses
|
||||||
|
{
|
||||||
|
public enum YouTubeStreamMode
|
||||||
|
{
|
||||||
|
Server = 0,
|
||||||
|
YouTube = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
public record YouTubeStreamResponse(
|
||||||
|
Guid? TrackId,
|
||||||
|
YouTubeStreamMode Mode,
|
||||||
|
string StreamUrl,
|
||||||
|
string? Ticket,
|
||||||
|
int ExpiresInSeconds);
|
||||||
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
using Musify.Domain.Abstractions;
|
using Musify.Domain.Abstractions;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Domain.Entities
|
namespace Musify.Domain.Entities
|
||||||
{
|
{
|
||||||
#pragma warning disable CS8618
|
#pragma warning disable CS8618
|
||||||
public class Album : IAuditable
|
public abstract class Album : IAuditable
|
||||||
{
|
{
|
||||||
[Key]
|
[Key]
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
@@ -26,12 +25,6 @@ namespace Musify.Domain.Entities
|
|||||||
|
|
||||||
public AlbumPictures? Pictures { get; set; }
|
public AlbumPictures? Pictures { get; set; }
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required long OwnerUserId { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(OwnerUserId))]
|
|
||||||
public User Owner { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
|
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Musify.Domain.Abstractions;
|
||||||
|
|
||||||
|
namespace Musify.Domain.Entities
|
||||||
|
{
|
||||||
|
#pragma warning disable CS8618
|
||||||
|
public class Artist : IAuditable
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(200)]
|
||||||
|
public required string Name { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(200)]
|
||||||
|
public required string NormalizedName { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(64)]
|
||||||
|
public required string ExternalId { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public ICollection<TrackArtist> TrackArtists { get; set; } = new List<TrackArtist>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Domain.Entities
|
||||||
|
{
|
||||||
|
public class ExternalAlbum : Album
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public required TrackSource Source { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(64)]
|
||||||
|
public required string ExternalId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(512)]
|
||||||
|
public string? ThumbnailUrl { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Domain.Entities
|
||||||
|
{
|
||||||
|
public class ExternalTrack : Track
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public required TrackSource Source { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(64)]
|
||||||
|
public required string ExternalId { get; set; }
|
||||||
|
|
||||||
|
public ICollection<TrackArtist> TrackArtists { get; set; } = new List<TrackArtist>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Musify.Domain.Entities
|
||||||
|
{
|
||||||
|
public class LocalTrack : Track
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public required long OwnerUserId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(OwnerUserId)) ]
|
||||||
|
public required User Owner { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Domain.Entities
|
namespace Musify.Domain.Entities
|
||||||
{
|
{
|
||||||
#pragma warning disable CS8618
|
#pragma warning disable CS8618
|
||||||
@@ -17,12 +19,32 @@ namespace Musify.Domain.Entities
|
|||||||
public int Position { get; set; } = 0;
|
public int Position { get; set; } = 0;
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public required Guid TrackId { get; set; }
|
public required MixItemSource Source { get; set; }
|
||||||
|
|
||||||
|
public Guid? TrackId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string? VideoId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(300)]
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(300)]
|
||||||
|
public string? Artist { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(512)]
|
||||||
|
public string? ThumbnailUrl { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public double DurationSeconds { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public bool IsExplicit { get; set; }
|
||||||
|
|
||||||
[ForeignKey(nameof(MixId))]
|
[ForeignKey(nameof(MixId))]
|
||||||
public Mix Mix { get; set; }
|
public Mix Mix { get; set; }
|
||||||
|
|
||||||
[ForeignKey(nameof(TrackId))]
|
[ForeignKey(nameof(TrackId))]
|
||||||
public Track Track { get; set; }
|
public Track? Track { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,14 +27,12 @@ namespace Musify.Domain.Entities
|
|||||||
[MaxLength(256)]
|
[MaxLength(256)]
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
|
|
||||||
public PlayListPictures? Pictures { get; set; }
|
[Required]
|
||||||
|
public required PlayListPictures Pictures { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
|
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
|
||||||
|
|
||||||
[Required]
|
|
||||||
public PlaylistVisibility Visibility { get; set; } = PlaylistVisibility.Private;
|
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
using Musify.Domain.Abstractions;
|
using Musify.Domain.Abstractions;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Domain.Entities
|
namespace Musify.Domain.Entities
|
||||||
{
|
{
|
||||||
#pragma warning disable CS8618
|
#pragma warning disable CS8618
|
||||||
public class Track : IAuditable
|
public abstract class Track : IAuditable
|
||||||
{
|
{
|
||||||
[Key]
|
[Key]
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
@@ -22,12 +21,6 @@ namespace Musify.Domain.Entities
|
|||||||
[Required]
|
[Required]
|
||||||
public double DurationSeconds { get; set; }
|
public double DurationSeconds { get; set; }
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required long OwnerUserId { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(OwnerUserId))]
|
|
||||||
public required User Owner { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
|
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
|
||||||
|
|
||||||
@@ -48,7 +41,5 @@ namespace Musify.Domain.Entities
|
|||||||
public ICollection<AlbumHasTrack> AlbumTracks { get; set; } = new List<AlbumHasTrack>();
|
public ICollection<AlbumHasTrack> AlbumTracks { get; set; } = new List<AlbumHasTrack>();
|
||||||
|
|
||||||
public ICollection<ListeningHistory> ListeningHistories { get; set; } = new List<ListeningHistory>();
|
public ICollection<ListeningHistory> ListeningHistories { get; set; } = new List<ListeningHistory>();
|
||||||
|
|
||||||
public ICollection<TrackTag> Tags { get; set; } = new List<TrackTag>();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Musify.Domain.Entities
|
||||||
|
{
|
||||||
|
#pragma warning disable CS8618
|
||||||
|
[Table("TrackArtist")]
|
||||||
|
public class TrackArtist
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public required Guid TrackId { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public required Guid ArtistId { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public int Position { get; set; } = 0;
|
||||||
|
|
||||||
|
[ForeignKey(nameof(TrackId)) ]
|
||||||
|
public ExternalTrack Track { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(ArtistId)) ]
|
||||||
|
public Artist Artist { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace Musify.Domain.Entities
|
|
||||||
{
|
|
||||||
#pragma warning disable CS8618
|
|
||||||
[Table("TrackLikes")]
|
|
||||||
public class TrackLike
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required long UserId { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required Guid TrackId { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
|
|
||||||
[ForeignKey(nameof(UserId))]
|
|
||||||
public User User { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(TrackId))]
|
|
||||||
public Track Track { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Domain.Entities
|
|
||||||
{
|
|
||||||
#pragma warning disable CS8618
|
|
||||||
public class TrackTag
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required Guid TrackId { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required Genre Tag { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(TrackId))]
|
|
||||||
public Track Track { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Musify.Domain.Entities
|
||||||
|
{
|
||||||
|
#pragma warning disable CS8618
|
||||||
|
public class UserAlbum : Album
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public required long OwnerUserId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(OwnerUserId)) ]
|
||||||
|
public User Owner { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace Musify.Domain.Entities
|
|
||||||
{
|
|
||||||
#pragma warning disable CS8618
|
|
||||||
[Table("UserFollows")]
|
|
||||||
public class UserFollow
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required long FollowerId { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required long FollowedId { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
|
|
||||||
[ForeignKey(nameof(FollowerId))]
|
|
||||||
public User Follower { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(FollowedId))]
|
|
||||||
public User Followed { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
namespace Musify.Domain.ValueObjects
|
|
||||||
{
|
|
||||||
public enum Genre
|
|
||||||
{
|
|
||||||
Pop = 0,
|
|
||||||
Rock = 1,
|
|
||||||
HipHop = 2,
|
|
||||||
RnB = 3,
|
|
||||||
Jazz = 4,
|
|
||||||
Blues = 5,
|
|
||||||
Classical = 6,
|
|
||||||
Electronic = 7,
|
|
||||||
House = 8,
|
|
||||||
Techno = 9,
|
|
||||||
Trance = 10,
|
|
||||||
Dubstep = 11,
|
|
||||||
DrumAndBass = 12,
|
|
||||||
Metal = 13,
|
|
||||||
Punk = 14,
|
|
||||||
Reggae = 15,
|
|
||||||
Reggaeton = 16,
|
|
||||||
Country = 17,
|
|
||||||
Folk = 18,
|
|
||||||
Indie = 19,
|
|
||||||
KPop = 20,
|
|
||||||
Latin = 21,
|
|
||||||
Soul = 22,
|
|
||||||
Funk = 23,
|
|
||||||
Ambient = 24,
|
|
||||||
Lofi = 25
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
namespace Musify.Domain.ValueObjects
|
|
||||||
{
|
|
||||||
|
|
||||||
public static class GenreCompatibility
|
|
||||||
{
|
|
||||||
private static readonly HashSet<(Genre, Genre)> IncompatiblePairs = BuildIncompatiblePairs();
|
|
||||||
|
|
||||||
private static HashSet<(Genre, Genre)> BuildIncompatiblePairs()
|
|
||||||
{
|
|
||||||
var pairs = new HashSet<(Genre, Genre)>();
|
|
||||||
|
|
||||||
void Add(Genre a, Genre b)
|
|
||||||
{
|
|
||||||
pairs.Add((a, b));
|
|
||||||
pairs.Add((b, a));
|
|
||||||
}
|
|
||||||
|
|
||||||
var intense = new[] { Genre.Metal, Genre.Punk, Genre.Dubstep, Genre.DrumAndBass };
|
|
||||||
var mellow = new[] { Genre.Classical, Genre.Ambient, Genre.Lofi };
|
|
||||||
|
|
||||||
foreach (var intenseGenre in intense)
|
|
||||||
foreach (var mellowGenre in mellow)
|
|
||||||
Add(intenseGenre, mellowGenre);
|
|
||||||
|
|
||||||
return pairs;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool AreCompatible(Genre a, Genre b) =>
|
|
||||||
a == b || !IncompatiblePairs.Contains((a, b));
|
|
||||||
|
|
||||||
public static IReadOnlyCollection<(Genre First, Genre Second)> FindConflicts(IEnumerable<Genre> tags)
|
|
||||||
{
|
|
||||||
var distinctTags = tags.Distinct().ToList();
|
|
||||||
var conflicts = new List<(Genre, Genre)>();
|
|
||||||
|
|
||||||
for (var i = 0; i < distinctTags.Count; i++)
|
|
||||||
{
|
|
||||||
for (var j = i + 1; j < distinctTags.Count; j++)
|
|
||||||
{
|
|
||||||
if (!AreCompatible(distinctTags[i], distinctTags[j]))
|
|
||||||
conflicts.Add((distinctTags[i], distinctTags[j]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return conflicts;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Musify.Domain.ValueObjects
|
||||||
|
{
|
||||||
|
public enum MixItemSource
|
||||||
|
{
|
||||||
|
Musify = 0,
|
||||||
|
YouTube = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace Musify.Domain.ValueObjects
|
|
||||||
{
|
|
||||||
public enum PlaylistVisibility
|
|
||||||
{
|
|
||||||
Private = 0,
|
|
||||||
Public = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Musify.Domain.ValueObjects
|
||||||
|
{
|
||||||
|
public enum TrackSource
|
||||||
|
{
|
||||||
|
Local = 0,
|
||||||
|
YouTube = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user