Compare commits
246
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.0.1",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"name": "musify-web",
|
|
||||||
"runtimeExecutable": "npm",
|
|
||||||
"runtimeArgs": ["--prefix", "web-player", "run", "dev"],
|
|
||||||
"port": 3000,
|
|
||||||
"autoPort": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "musify-api",
|
|
||||||
"runtimeExecutable": "dotnet",
|
|
||||||
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Api"],
|
|
||||||
"port": 5111
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "musify-streaming-gateway",
|
|
||||||
"runtimeExecutable": "dotnet",
|
|
||||||
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.StreamingGateway"],
|
|
||||||
"port": 8081
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -4,13 +4,6 @@
|
|||||||
# Force LF for shell scripts
|
# Force LF for shell scripts
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
|
|
||||||
# Env templates get `cp`-ed straight into deploy/.env / deploy/.env.prod,
|
|
||||||
# which containers (e.g. zitadel-init in deploy/compose.yml) parse with
|
|
||||||
# plain shell text tools — keep them LF so a Windows checkout can't
|
|
||||||
# reintroduce CRLF into a value that then looks non-empty but isn't.
|
|
||||||
.env.example text eol=lf
|
|
||||||
.env.prod.example text eol=lf
|
|
||||||
|
|
||||||
# Treat common binaries as binary
|
# Treat common binaries as binary
|
||||||
*.png binary
|
*.png binary
|
||||||
*.jpg binary
|
*.jpg binary
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
name: CI/CD
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ['**']
|
|
||||||
pull_request:
|
|
||||||
branches: ['**']
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: production-deploy
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
backend-tests:
|
|
||||||
name: BackEnd
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 20
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: backend
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: '10.0.x'
|
|
||||||
|
|
||||||
- name: Restore
|
|
||||||
run: dotnet restore Musify.slnx
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: dotnet build Musify.slnx --no-restore -c Release
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: dotnet test Musify.slnx --no-build -c Release
|
|
||||||
|
|
||||||
web-player-checks:
|
|
||||||
name: WebPlayer
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 15
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: web-player
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: web-player/package-lock.json
|
|
||||||
|
|
||||||
- run: npm ci
|
|
||||||
|
|
||||||
- name: Type-check
|
|
||||||
run: npm run check
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: npm run lint
|
|
||||||
|
|
||||||
# Runs on the production host itself, through the existing self-hosted
|
|
||||||
# Gitea Actions runner (act_runner), which also carries a "production"
|
|
||||||
# label registered in native (non-docker) mode so its steps see the host's
|
|
||||||
# real Docker daemon directly (Docker socket + a bind-mounted deploy
|
|
||||||
# directory — see deploy/README.md for how that runner is set up).
|
|
||||||
#
|
|
||||||
# DEPLOY_PATH is the persistent clone already on that host (the one whose
|
|
||||||
# deploy/.env.prod is filled in and matches SharedServices/.env.prod) —
|
|
||||||
# deploy reuses it instead of a fresh checkout, since .env.prod is
|
|
||||||
# untracked and only lives there.
|
|
||||||
deploy:
|
|
||||||
name: Deploy
|
|
||||||
needs: [backend-tests, web-player-checks]
|
|
||||||
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
|
|
||||||
runs-on: [production]
|
|
||||||
timeout-minutes: 20
|
|
||||||
env:
|
|
||||||
DEPLOY_PATH: /home/ubuntu/deploys/musify
|
|
||||||
COMPOSE: docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml
|
|
||||||
steps:
|
|
||||||
- name: Pull latest master
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
git config --global --add safe.directory "$DEPLOY_PATH"
|
|
||||||
cd "$DEPLOY_PATH"
|
|
||||||
git fetch origin master
|
|
||||||
git reset --hard origin/master
|
|
||||||
|
|
||||||
- name: Generate stream-ticket keys
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$DEPLOY_PATH"
|
|
||||||
$COMPOSE run --rm --no-deps keys-init
|
|
||||||
|
|
||||||
# The migrations are baked into the migrator image, so it is rebuilt on
|
|
||||||
# every deploy (plain `up` would reuse the stale image and re-apply the
|
|
||||||
# old migrations). `run --rm` starts wait-for-postgres, blocks until the
|
|
||||||
# migration finishes and propagates its exit code, so a failed migration
|
|
||||||
# aborts the deploy before the new API is started against an old schema.
|
|
||||||
- name: Migrate database
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$DEPLOY_PATH"
|
|
||||||
$COMPOSE build migrate
|
|
||||||
$COMPOSE run --rm migrate
|
|
||||||
|
|
||||||
# Both jobs verify themselves through the public auth./s3. URLs, served
|
|
||||||
# by the shared edge nginx (Infrastructure's edge/), and are idempotent.
|
|
||||||
- name: Create OIDC app and S3 bucket
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$DEPLOY_PATH"
|
|
||||||
$COMPOSE up -d seaweedfs-init zitadel-init
|
|
||||||
for job in musify-seaweedfs-init musify-zitadel-init; do
|
|
||||||
code=$(docker wait "$job")
|
|
||||||
if [ "$code" != "0" ]; then
|
|
||||||
docker logs "$job"
|
|
||||||
echo "$job exited with $code"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Build and start apps
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$DEPLOY_PATH"
|
|
||||||
$COMPOSE --profile apps up -d --build api worker gateway web-player
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Copilot Instructions
|
||||||
|
|
||||||
|
## Project Guidelines
|
||||||
|
- No crear pruebas/tests si el usuario no lo solicita explícitamente; evitar cambios no pedidos.
|
||||||
+1
-13
@@ -7,9 +7,6 @@ obj/
|
|||||||
*.userosscache
|
*.userosscache
|
||||||
.vs/
|
.vs/
|
||||||
artifacts/
|
artifacts/
|
||||||
TestResults/
|
|
||||||
*.trx
|
|
||||||
*.coverage
|
|
||||||
|
|
||||||
# Rider / VS
|
# Rider / VS
|
||||||
.idea/
|
.idea/
|
||||||
@@ -18,14 +15,5 @@ TestResults/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Environment files (deploy/.env, deploy/.env.prod, web-player/.env, ...)
|
# Secrets / local
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
*.env
|
*.env
|
||||||
!.env.example
|
|
||||||
!.env.prod.example
|
|
||||||
|
|
||||||
# Deploy secrets and runtime state
|
|
||||||
deploy/keys/
|
|
||||||
deploy/secrets/
|
|
||||||
deploy/zitadel/.output/
|
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
# Musify
|
|
||||||
|
|
||||||
A music streaming app. Upload your own tracks, organize them into playlists
|
|
||||||
and albums, and stream them back with a web player built around your
|
|
||||||
listening habits: auto-generated mixes, a home feed, search, likes, and an
|
|
||||||
Explore page for finding something new.
|
|
||||||
|
|
||||||
## What it does
|
|
||||||
|
|
||||||
- **Upload and organize your own music**: tracks, albums, cover art, artist credits.
|
|
||||||
- **Playlists**, likes, and a "recently played" history.
|
|
||||||
- **Mixes**, playlists generated automatically from what you actually listen to.
|
|
||||||
- **Explore**, genre browsing and search across tracks, albums, playlists and people.
|
|
||||||
- **Real streaming**, not just file downloads: seek, queue, shuffle, repeat.
|
|
||||||
- **Accounts via OIDC login**, with an optional anonymous listening mode.
|
|
||||||
- Light and dark themes, and a layout that works on both desktop and mobile.
|
|
||||||
|
|
||||||
## How it's built
|
|
||||||
|
|
||||||
**Backend**: .NET 10, split into a REST API, a background worker, and a
|
|
||||||
dedicated streaming gateway.
|
|
||||||
- Clean architecture (Domain / Application / Infrastructure) with CQRS (Mediator) and `ErrorOr` results.
|
|
||||||
- PostgreSQL via EF Core for metadata. RabbitMQ and MassTransit handle async processing (audio transcoding, thumbnails) with saga-based rollback.
|
|
||||||
- SeaweedFS (S3-compatible) for file storage. Uploads and downloads go straight between the client and storage, never through the API.
|
|
||||||
- ffmpeg for audio transcoding, ImageSharp for thumbnails.
|
|
||||||
- A YARP reverse proxy that streams the audio and checks a signed, short-lived ticket on every request.
|
|
||||||
- Zitadel for login (OIDC/OAuth2).
|
|
||||||
|
|
||||||
**Web client**: SvelteKit 2 on Svelte 5 (runes), TypeScript, Tailwind 4.
|
|
||||||
The session lives on the server, so the backend access token never reaches the browser.
|
|
||||||
|
|
||||||
**Infrastructure**: Musify's own apps run from Docker Compose (dev and
|
|
||||||
production overlays). Postgres, RabbitMQ, SeaweedFS, Zitadel and Jaeger are
|
|
||||||
shared infrastructure that lives in a separate `Infrastructure` git
|
|
||||||
repository, used by Musify and by other projects that need the same OIDC
|
|
||||||
provider and object storage.
|
|
||||||
|
|
||||||
## Project layout
|
|
||||||
|
|
||||||
```
|
|
||||||
backend/ .NET solution: Api, Worker, StreamingGateway, and the shared Domain/Application/Infrastructure
|
|
||||||
web-player/ the SvelteKit web client
|
|
||||||
deploy/ Docker Compose stacks for dev and production
|
|
||||||
docs/ architecture notes, one file per area
|
|
||||||
```
|
|
||||||
|
|
||||||
Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger are not part of this
|
|
||||||
layout. They live in the separate `Infrastructure` repository instead.
|
|
||||||
|
|
||||||
## Digging deeper
|
|
||||||
|
|
||||||
[docs/](docs/README.md) covers how each piece works and why: architecture,
|
|
||||||
authentication, storage, streaming, media processing, and the local dev
|
|
||||||
setup. [deploy/README.md](deploy/README.md) has the full instructions for
|
|
||||||
running the stack, in development or on a production server.
|
|
||||||
@@ -8,5 +8,3 @@
|
|||||||
.claude/
|
.claude/
|
||||||
**/DesignSettings.json
|
**/DesignSettings.json
|
||||||
*.user
|
*.user
|
||||||
# Test projects: never shipped, no app Dockerfile needs them in its build context.
|
|
||||||
Tests/
|
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
charset = utf-8
|
|
||||||
insert_final_newline = true
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
indent_style = space
|
|
||||||
|
|
||||||
[*.{csproj,props,targets,slnx,xml}]
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[*.json]
|
|
||||||
indent_size = 4
|
|
||||||
|
|
||||||
[**/Migrations/**.cs]
|
|
||||||
generated_code = true
|
|
||||||
|
|
||||||
[*.cs]
|
|
||||||
indent_size = 4
|
|
||||||
|
|
||||||
csharp_style_namespace_declarations = block_scoped:warning
|
|
||||||
csharp_using_directive_placement = outside_namespace:warning
|
|
||||||
dotnet_sort_system_directives_first = true
|
|
||||||
dotnet_separate_import_directive_groups = false
|
|
||||||
dotnet_diagnostic.IDE0005.severity = warning
|
|
||||||
|
|
||||||
dotnet_style_require_accessibility_modifiers = always:warning
|
|
||||||
csharp_style_var_for_built_in_types = true:suggestion
|
|
||||||
csharp_style_var_when_type_is_apparent = true:suggestion
|
|
||||||
csharp_style_var_elsewhere = true:suggestion
|
|
||||||
csharp_prefer_braces = when_multiline:warning
|
|
||||||
csharp_style_prefer_primary_constructors = true:suggestion
|
|
||||||
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
|
|
||||||
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
|
|
||||||
|
|
||||||
# Naming. Roslyn applies the first matching rule in file order, so the specific rules come first.
|
|
||||||
# Private fields are _camelCase whatever their modifiers (static, readonly, const).
|
|
||||||
dotnet_naming_rule.private_fields.symbols = private_fields
|
|
||||||
dotnet_naming_rule.private_fields.style = underscore_camel_case
|
|
||||||
dotnet_naming_rule.private_fields.severity = warning
|
|
||||||
dotnet_naming_symbols.private_fields.applicable_kinds = field
|
|
||||||
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
|
|
||||||
dotnet_naming_style.underscore_camel_case.capitalization = camel_case
|
|
||||||
dotnet_naming_style.underscore_camel_case.required_prefix = _
|
|
||||||
|
|
||||||
# Non-private fields (constants, static readonly, etc.) are PascalCase.
|
|
||||||
dotnet_naming_rule.other_fields.symbols = other_fields
|
|
||||||
dotnet_naming_rule.other_fields.style = pascal_case
|
|
||||||
dotnet_naming_rule.other_fields.severity = warning
|
|
||||||
dotnet_naming_symbols.other_fields.applicable_kinds = field
|
|
||||||
dotnet_naming_symbols.other_fields.applicable_accessibilities = public, internal, protected, protected_internal, private_protected
|
|
||||||
dotnet_naming_style.pascal_case.capitalization = pascal_case
|
|
||||||
|
|
||||||
dotnet_naming_rule.interfaces.symbols = interfaces
|
|
||||||
dotnet_naming_rule.interfaces.style = interface_style
|
|
||||||
dotnet_naming_rule.interfaces.severity = warning
|
|
||||||
dotnet_naming_symbols.interfaces.applicable_kinds = interface
|
|
||||||
dotnet_naming_style.interface_style.capitalization = pascal_case
|
|
||||||
dotnet_naming_style.interface_style.required_prefix = I
|
|
||||||
|
|
||||||
dotnet_naming_rule.types_and_members.symbols = types_and_members
|
|
||||||
dotnet_naming_rule.types_and_members.style = pascal_case
|
|
||||||
dotnet_naming_rule.types_and_members.severity = warning
|
|
||||||
dotnet_naming_symbols.types_and_members.applicable_kinds = class, struct, enum, delegate, property, method, event
|
|
||||||
dotnet_naming_symbols.types_and_members.applicable_accessibilities = *
|
|
||||||
|
|
||||||
# CA1848/CA1873 would turn every log call into a [LoggerMessage] source-generated method; CA1716 flags the
|
|
||||||
# EF-style IDatabase.Set<T>() and the Musify.Application.Shared namespace, whose names are deliberate.
|
|
||||||
dotnet_diagnostic.CA1848.severity = suggestion
|
|
||||||
dotnet_diagnostic.CA1873.severity = suggestion
|
|
||||||
dotnet_diagnostic.CA1716.severity = suggestion
|
|
||||||
# The routing slip builders are stateless services resolved from DI; they stay instance members.
|
|
||||||
dotnet_diagnostic.CA1822.severity = suggestion
|
|
||||||
|
|
||||||
# Test names follow Method_Scenario_Expected.
|
|
||||||
[Tests/**.cs]
|
|
||||||
dotnet_diagnostic.CA1707.severity = none
|
|
||||||
# xUnit collection fixtures are conventionally named ...Collection.
|
|
||||||
dotnet_diagnostic.CA1711.severity = none
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# Musify: backend
|
|
||||||
|
|
||||||
.NET 10 solution (`Musify.slnx`): `Core/` (Domain, Application, Infrastructure), `Hosts/` (Api, Worker, StreamingGateway) and `Tests/`. Architecture and flows are in `../docs/`.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
Run from `backend/`.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
dotnet build Musify.slnx # warnings are errors, including unused usings (IDE0005)
|
|
||||||
dotnet test Musify.slnx # Infrastructure and Api tests need Docker (Testcontainers)
|
|
||||||
dotnet format Musify.slnx --verify-no-changes
|
|
||||||
```
|
|
||||||
|
|
||||||
- Read the totals of `dotnet test` (`total:`, `succeeded:`) before trusting it; don't chain it with `&&` into a commit.
|
|
||||||
- EF migrations: `dotnet ef migrations add <Name> --project Core/Musify.Infrastructure --startup-project Core/Musify.Infrastructure` (set `Database__ConnectionString` to any value). A model change needs a migration; the entity model lives in `ModelConfiguration.Apply`, which the in-memory test database reuses.
|
|
||||||
- An HTTP contract change (routes, DTOs, responses) needs the web-player schema regenerated (`web-player/src/lib/api/schema.d.ts`, from `/openapi/v1.json`) and `npm run check` clean.
|
|
||||||
- A MassTransit message change (events, routing slip arguments, process kinds) means the queues must be drained before deploying; note it in the commit.
|
|
||||||
|
|
||||||
## Style
|
|
||||||
|
|
||||||
- Block-scoped namespaces (`namespace X { }`; file-scoped `namespace X;` is not allowed), usings outside the namespace and sorted (`.editorconfig` enforces it).
|
|
||||||
- Private fields are `_camelCase` whatever their modifiers (including `static readonly` and `const`); non-private constants and static fields, types, methods and properties are PascalCase; interfaces start with `I`; explicit accessibility. Enforced by `.editorconfig` (IDE1006).
|
|
||||||
- `ArgumentNullException.ThrowIfNull(x)` without `nameof`.
|
|
||||||
- Use `PlayList` (not `Playlist`) in code; configuration POCOs end in `Configuration`; injected services are named after their type (`storageService`).
|
|
||||||
- User-facing strings are in English (the web-player owns translation).
|
|
||||||
- Trim stored names; normalized columns go through `TextNormalizer.Normalize`.
|
|
||||||
- Format ids and numbers with `CultureInfo.InvariantCulture`; put `CancellationToken` last.
|
|
||||||
|
|
||||||
## Application layer
|
|
||||||
|
|
||||||
- One file per use case, `<Feature>/<UseCase>.cs`, holding the record and its handler; responses go in `Responses/`.
|
|
||||||
- Errors come from `AppErrors` (`NotFound`, `Forbidden` = 403 for "not the owner", `Conflict`). `Error.Unauthorized` (401) only means "not signed in". The success value is `Result.Success`; the API maps it to 204.
|
|
||||||
- Handlers that cannot fail return plain types. Lists are paginated through `PageRequest` / `ToPaginatedAsync`, ordered with an `Id` tiebreak, and public reads return only `LifeCycleStatus.Active` rows.
|
|
||||||
- Owned-entity operations go through `FindOwnedAsync` (non-active entities are not found).
|
|
||||||
- No try/catch around `SaveChangesAsync` or `PublishAsync` in handlers: the outbox makes publish + save atomic. Infrastructure either handles an error or lets it propagate; never log and rethrow.
|
|
||||||
- List query records keep `int PageNumber, int PageSize`; the API binds them through `PageQuery` and validates them with `PageQueryValidator`.
|
|
||||||
|
|
||||||
## API layer
|
|
||||||
|
|
||||||
- Routes: `{id:guid}` for the resource, `{trackId:guid}` for sub-resources, `{id:long}` / `{userId:long}` for user ids.
|
|
||||||
- Every endpoint returns `IResult` (`Results.Ok` when the handler cannot fail, `ToHttpResult()` otherwise).
|
|
||||||
- Validation limits live in `Validators/Limits.cs`; the database column length wins when a limit disagrees with the web-player. Upload allow-lists are in `Validators/Uploads.cs`.
|
|
||||||
- `.WithSummary(...)` is sentence case without a trailing period.
|
|
||||||
|
|
||||||
## Infrastructure and hosts
|
|
||||||
|
|
||||||
- MassTransit routing slips are built from `RoutingSlips.Create` / `AddStep` / `TrackFaults`; activities for the three owner types share generic bases (`MarkLifeCycleActivity<T>`, `DeleteEntityActivity<T>`, `UpdatePicturesActivity<T>`).
|
|
||||||
- Recurring work is Hangfire (`RecurringJobsRegistrar`); intervals are in seconds and rounded up to whole minutes.
|
|
||||||
- The Worker calls `AddApplication` and validates its service container on build, so any handler dependency must be registered there. Shared config sections in its `appsettings.json` mirror the API's.
|
|
||||||
- The StreamingGateway references neither Application nor Infrastructure and keeps its own options.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- xUnit v3; pass `TestContext.Current.CancellationToken` to methods that take a token (analyzer xUnit1051).
|
|
||||||
- Test names are `Method_Scenario_Expected`.
|
|
||||||
- Application tests use the in-memory SQLite `TestDatabase`; Infrastructure and Api tests use Testcontainers (Postgres, SeaweedFS). MassTransit sagas and consumers are tested on the MassTransit test harness.
|
|
||||||
- `Infrastructure` exposes its internals to `Musify.Infrastructure.Tests`.
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
|
||||||
{
|
|
||||||
/// <summary>Appends your own tracks to an album in the given order, skipping the ones it already has.</summary>
|
|
||||||
public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList<Guid> TrackIds)
|
|
||||||
: ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class AddTracksToAlbumCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<AddTracksToAlbumCommandHandler> logger)
|
|
||||||
: ICommandHandler<AddTracksToAlbumCommand, ErrorOr<Success>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
|
||||||
if (album.IsError)
|
|
||||||
return album.Errors;
|
|
||||||
|
|
||||||
var requestedIds = request.TrackIds.Distinct().ToList();
|
|
||||||
|
|
||||||
var tracks = await database.Tracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
|
||||||
.Select(track => new { track.Id, track.OwnerUserId })
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var missing = requestedIds.FirstOrDefault(id => tracks.All(track => track.Id != id));
|
|
||||||
if (missing != Guid.Empty)
|
|
||||||
return AppErrors.NotFound("Track", missing);
|
|
||||||
|
|
||||||
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
|
|
||||||
if (foreign != null)
|
|
||||||
return AppErrors.Forbidden("Track", foreign.Id);
|
|
||||||
|
|
||||||
var alreadyInAlbum = await database.AlbumHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(link => link.AlbumId == request.AlbumId && requestedIds.Contains(link.TrackId))
|
|
||||||
.Select(link => link.TrackId)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var toAdd = requestedIds.Where(id => !alreadyInAlbum.Contains(id)).ToList();
|
|
||||||
if (toAdd.Count == 0)
|
|
||||||
return Result.Success;
|
|
||||||
|
|
||||||
// Album track numbers are user-visible, so they start at 1.
|
|
||||||
var nextTrackNumber = (await database.AlbumHasTracks
|
|
||||||
.Where(link => link.AlbumId == request.AlbumId)
|
|
||||||
.MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1;
|
|
||||||
|
|
||||||
foreach (var trackId in toAdd)
|
|
||||||
{
|
|
||||||
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
|
|
||||||
{
|
|
||||||
AlbumId = request.AlbumId,
|
|
||||||
TrackId = trackId,
|
|
||||||
TrackNumber = nextTrackNumber++
|
|
||||||
}, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
var saved = await database.TrySaveChangesAsync(
|
|
||||||
AppErrors.Conflict("Album.ConcurrentChange", "The album changed while adding tracks. Try again."), cancellationToken);
|
|
||||||
if (saved.IsError)
|
|
||||||
return saved;
|
|
||||||
|
|
||||||
logger.LogInformation("Added {Count} tracks to album {AlbumId}", toAdd.Count, request.AlbumId);
|
|
||||||
|
|
||||||
return Result.Success;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Albums.Responses;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Application.Pictures;
|
|
||||||
using Musify.Application.Services;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
|
||||||
{
|
|
||||||
public record CreateAlbumCommand(
|
|
||||||
long UserId,
|
|
||||||
string Title,
|
|
||||||
string? Description,
|
|
||||||
int? ReleaseYear,
|
|
||||||
Guid PictureIntentId)
|
|
||||||
: ICommand<ErrorOr<AlbumApplicationResponse>>;
|
|
||||||
|
|
||||||
public class CreateAlbumCommandHandler(
|
|
||||||
IEventBus eventBus,
|
|
||||||
IDatabase database,
|
|
||||||
UploadIntentValidator uploadIntentValidator,
|
|
||||||
ILogger<CreateAlbumCommandHandler> logger,
|
|
||||||
AlbumConfiguration albumConfiguration)
|
|
||||||
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var userExists = await database.Users
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
|
|
||||||
if (!userExists)
|
|
||||||
return AppErrors.NotFound("User", request.UserId);
|
|
||||||
|
|
||||||
var prepared = await PictureSourceChange.PrepareAsync(
|
|
||||||
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
|
|
||||||
if (prepared.IsError)
|
|
||||||
return prepared.Errors;
|
|
||||||
|
|
||||||
var picture = prepared.Value;
|
|
||||||
var title = request.Title.Trim();
|
|
||||||
|
|
||||||
var album = new Album
|
|
||||||
{
|
|
||||||
OwnerUserId = request.UserId,
|
|
||||||
Title = title,
|
|
||||||
NormalizedTitle = TextNormalizer.Normalize(title),
|
|
||||||
Description = request.Description,
|
|
||||||
ReleaseYear = request.ReleaseYear,
|
|
||||||
Pictures = picture.Pictures
|
|
||||||
};
|
|
||||||
|
|
||||||
await database.Albums.AddAsync(album, cancellationToken);
|
|
||||||
|
|
||||||
await eventBus.PublishAsync(
|
|
||||||
new CreateAlbumResourcesEvent(
|
|
||||||
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId);
|
|
||||||
|
|
||||||
return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
|
||||||
{
|
|
||||||
public record DeleteAlbumCommand(long UserId, Guid AlbumId)
|
|
||||||
: ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class DeleteAlbumCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
IEventBus eventBus,
|
|
||||||
ILogger<DeleteAlbumCommandHandler> logger)
|
|
||||||
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
|
||||||
if (found.IsError)
|
|
||||||
return found.Errors;
|
|
||||||
|
|
||||||
var album = found.Value;
|
|
||||||
album.LifeCycleStatus = LifeCycleStatus.Removing;
|
|
||||||
|
|
||||||
await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken);
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Marked album {AlbumId} as removing", request.AlbumId);
|
|
||||||
|
|
||||||
return Result.Success;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Albums.Responses;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
|
||||||
{
|
|
||||||
public record GetAlbumByIdQuery(Guid Id)
|
|
||||||
: IQuery<ErrorOr<AlbumApplicationResponse>>;
|
|
||||||
|
|
||||||
public class GetAlbumByIdQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetAlbumByIdQuery, ErrorOr<AlbumApplicationResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var album = await database.Albums
|
|
||||||
.AsNoTracking()
|
|
||||||
.Active()
|
|
||||||
.Where(a => a.Id == request.Id)
|
|
||||||
.SelectResponse()
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
return album == null
|
|
||||||
? AppErrors.NotFound("Album", request.Id)
|
|
||||||
: album;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Albums.Responses;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
|
||||||
{
|
|
||||||
public record GetAlbumTracksQuery(
|
|
||||||
Guid AlbumId,
|
|
||||||
int PageNumber,
|
|
||||||
int PageSize)
|
|
||||||
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
|
||||||
|
|
||||||
public class GetAlbumTracksQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetAlbumTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var albumExists = await database.Albums
|
|
||||||
.AsNoTracking()
|
|
||||||
.Active()
|
|
||||||
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
|
|
||||||
if (!albumExists)
|
|
||||||
return AppErrors.NotFound("Album", request.AlbumId);
|
|
||||||
|
|
||||||
return await database.AlbumHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.ThenBy(albumTrack => albumTrack.Id)
|
|
||||||
.Select(albumTrack => albumTrack.Track)
|
|
||||||
.SelectResponse()
|
|
||||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Albums.Responses;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
|
||||||
{
|
|
||||||
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
|
|
||||||
: IQuery<PaginatedResponse<AlbumApplicationResponse>>;
|
|
||||||
|
|
||||||
public class GetAlbumsQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetAlbumsQuery, PaginatedResponse<AlbumApplicationResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<PaginatedResponse<AlbumApplicationResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var albumsQuery = database.Albums
|
|
||||||
.AsNoTracking()
|
|
||||||
.Active();
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Title))
|
|
||||||
{
|
|
||||||
var normalizedTitle = TextNormalizer.Normalize(request.Title);
|
|
||||||
albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle));
|
|
||||||
}
|
|
||||||
|
|
||||||
return await albumsQuery
|
|
||||||
.OrderByDescending(album => album.CreatedAt)
|
|
||||||
.ThenBy(album => album.Id)
|
|
||||||
.SelectResponse()
|
|
||||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Albums.Responses;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
|
||||||
{
|
|
||||||
public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize)
|
|
||||||
: IQuery<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>;
|
|
||||||
|
|
||||||
public class GetAlbumsByUserIdQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetAlbumsByUserIdQuery, ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var userExists = await database.Users
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
|
|
||||||
if (!userExists)
|
|
||||||
return AppErrors.NotFound("User", request.UserId);
|
|
||||||
|
|
||||||
return await database.Albums
|
|
||||||
.AsNoTracking()
|
|
||||||
.Active()
|
|
||||||
.Where(album => album.OwnerUserId == request.UserId)
|
|
||||||
.OrderByDescending(album => album.CreatedAt)
|
|
||||||
.ThenBy(album => album.Id)
|
|
||||||
.SelectResponse()
|
|
||||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Albums.Responses;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
|
||||||
{
|
|
||||||
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
|
|
||||||
: IQuery<IReadOnlyList<AlbumApplicationResponse>>;
|
|
||||||
|
|
||||||
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetRecentlyListenedAlbumsQuery, IReadOnlyList<AlbumApplicationResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<IReadOnlyList<AlbumApplicationResponse>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var listens = database.ListeningHistories
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(history => history.UserId == request.UserId && history.IsCounted);
|
|
||||||
|
|
||||||
var ranked = await database.AlbumHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
|
|
||||||
.Join(
|
|
||||||
listens,
|
|
||||||
albumTrack => albumTrack.TrackId,
|
|
||||||
history => history.TrackId,
|
|
||||||
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt })
|
|
||||||
.GroupBy(entry => entry.AlbumId)
|
|
||||||
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) })
|
|
||||||
.OrderByDescending(entry => entry.LastListenedAt)
|
|
||||||
.ThenBy(entry => entry.AlbumId)
|
|
||||||
.Take(request.Limit)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (ranked.Count == 0)
|
|
||||||
return [];
|
|
||||||
|
|
||||||
var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
|
|
||||||
|
|
||||||
var albumsById = await database.Albums
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(album => albumIds.Contains(album.Id))
|
|
||||||
.SelectResponse()
|
|
||||||
.ToDictionaryAsync(album => album.Id, cancellationToken);
|
|
||||||
|
|
||||||
return ranked
|
|
||||||
.Where(entry => albumsById.ContainsKey(entry.AlbumId))
|
|
||||||
.Select(entry => albumsById[entry.AlbumId])
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
|
||||||
{
|
|
||||||
public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId)
|
|
||||||
: ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class RemoveTrackFromAlbumCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<RemoveTrackFromAlbumCommandHandler> logger)
|
|
||||||
: ICommandHandler<RemoveTrackFromAlbumCommand, ErrorOr<Success>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
|
||||||
if (album.IsError)
|
|
||||||
return album.Errors;
|
|
||||||
|
|
||||||
var albumTracks = await database.AlbumHasTracks
|
|
||||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId);
|
|
||||||
if (link == null)
|
|
||||||
return Error.NotFound("Album.TrackNotFound", "Track is not in the album.");
|
|
||||||
|
|
||||||
database.AlbumHasTracks.Remove(link);
|
|
||||||
|
|
||||||
var trackNumber = 1;
|
|
||||||
foreach (var remaining in albumTracks.Where(albumTrack => albumTrack.Id != link.Id))
|
|
||||||
remaining.TrackNumber = trackNumber++;
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId);
|
|
||||||
|
|
||||||
return Result.Success;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Pictures.Responses;
|
|
||||||
using Musify.Application.Services;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
|
||||||
{
|
|
||||||
public record RequestAlbumPictureUploadCommand(
|
|
||||||
long UserId,
|
|
||||||
string FileType,
|
|
||||||
string ContentType,
|
|
||||||
long? ExpectedSizeBytes = null)
|
|
||||||
: ICommand<ErrorOr<PictureUploadResponse>>;
|
|
||||||
|
|
||||||
public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration)
|
|
||||||
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<PictureUploadResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) =>
|
|
||||||
await issuer.IssuePictureAsync(
|
|
||||||
request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration,
|
|
||||||
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums.Responses
|
|
||||||
{
|
|
||||||
public record AlbumApplicationResponse(
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
|
|
||||||
|
|
||||||
public static AlbumApplicationResponse FromEntity(
|
|
||||||
Album album,
|
|
||||||
int trackCount,
|
|
||||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
|
||||||
{
|
|
||||||
return new AlbumApplicationResponse(
|
|
||||||
album.Id,
|
|
||||||
album.Title,
|
|
||||||
album.Description,
|
|
||||||
album.ReleaseYear,
|
|
||||||
album.OwnerUserId,
|
|
||||||
trackCount,
|
|
||||||
album.Pictures?.SmallName,
|
|
||||||
album.Pictures?.MediumName,
|
|
||||||
album.Pictures?.LargeName,
|
|
||||||
album.CreatedAt,
|
|
||||||
album.UpdatedAt,
|
|
||||||
coverTrackIds ?? []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums.Responses
|
|
||||||
{
|
|
||||||
public static class AlbumProjections
|
|
||||||
{
|
|
||||||
public const int CoverTrackCount = 4;
|
|
||||||
|
|
||||||
public static IQueryable<Album> Active(this IQueryable<Album> query) =>
|
|
||||||
query.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active);
|
|
||||||
|
|
||||||
public static IQueryable<AlbumApplicationResponse> SelectResponse(this IQueryable<Album> query) =>
|
|
||||||
query.Select(album => new AlbumApplicationResponse(
|
|
||||||
album.Id,
|
|
||||||
album.Title,
|
|
||||||
album.Description,
|
|
||||||
album.ReleaseYear,
|
|
||||||
album.OwnerUserId,
|
|
||||||
album.AlbumTracks.Count,
|
|
||||||
album.Pictures!.SmallName,
|
|
||||||
album.Pictures!.MediumName,
|
|
||||||
album.Pictures!.LargeName,
|
|
||||||
album.CreatedAt,
|
|
||||||
album.UpdatedAt,
|
|
||||||
album.AlbumTracks
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.Take(CoverTrackCount)
|
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
|
||||||
.ToList()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Albums.Responses;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Application.Pictures;
|
|
||||||
using Musify.Application.Services;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
|
||||||
{
|
|
||||||
public record UpdateAlbumCommand(
|
|
||||||
long UserId,
|
|
||||||
Guid AlbumId,
|
|
||||||
string Title,
|
|
||||||
string? Description,
|
|
||||||
int? ReleaseYear,
|
|
||||||
Guid? NewPictureIntentId)
|
|
||||||
: ICommand<ErrorOr<AlbumApplicationResponse>>;
|
|
||||||
|
|
||||||
public class UpdateAlbumCommandHandler(
|
|
||||||
IEventBus eventBus,
|
|
||||||
IDatabase database,
|
|
||||||
UploadIntentValidator uploadIntentValidator,
|
|
||||||
ILogger<UpdateAlbumCommandHandler> logger,
|
|
||||||
AlbumConfiguration albumConfiguration)
|
|
||||||
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
|
||||||
if (found.IsError)
|
|
||||||
return found.Errors;
|
|
||||||
|
|
||||||
var album = found.Value;
|
|
||||||
var title = request.Title.Trim();
|
|
||||||
|
|
||||||
album.Title = title;
|
|
||||||
album.NormalizedTitle = TextNormalizer.Normalize(title);
|
|
||||||
album.Description = request.Description;
|
|
||||||
album.ReleaseYear = request.ReleaseYear;
|
|
||||||
|
|
||||||
if (request.NewPictureIntentId is { } intentId)
|
|
||||||
{
|
|
||||||
var prepared = await PictureSourceChange.PrepareAsync(
|
|
||||||
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
|
|
||||||
if (prepared.IsError)
|
|
||||||
return prepared.Errors;
|
|
||||||
|
|
||||||
var picture = prepared.Value;
|
|
||||||
album.Pictures = picture.Pictures;
|
|
||||||
|
|
||||||
await eventBus.PublishAsync(
|
|
||||||
new UpdateAlbumPictureSourceEvent(
|
|
||||||
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Updated album {AlbumId}", album.Id);
|
|
||||||
|
|
||||||
return await database.Albums
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(a => a.Id == album.Id)
|
|
||||||
.SelectResponse()
|
|
||||||
.SingleAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Services;
|
|
||||||
|
|
||||||
namespace Musify.Application
|
|
||||||
{
|
|
||||||
public static class ApplicationDependencyInjection
|
|
||||||
{
|
|
||||||
public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration)
|
|
||||||
{
|
|
||||||
services.AddMediator();
|
|
||||||
services.AddScoped<UploadIntentValidator>();
|
|
||||||
services.AddScoped<UploadIntentIssuer>();
|
|
||||||
services.AddScoped<TrackStreamIssuer>();
|
|
||||||
|
|
||||||
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration);
|
|
||||||
services.AddValidatedOptions<PlayListConfiguration>(configuration);
|
|
||||||
services.AddValidatedOptions<AlbumConfiguration>(configuration);
|
|
||||||
services.AddValidatedOptions<TrackConfiguration>(configuration);
|
|
||||||
services.AddValidatedOptions<MixConfiguration>(configuration);
|
|
||||||
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration);
|
|
||||||
services.AddValidatedOptions<UploadIntentConfiguration>(configuration);
|
|
||||||
services.AddValidatedOptions<PlaybackConfiguration>(configuration);
|
|
||||||
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
|
|
||||||
namespace Musify.Application.Configuration
|
|
||||||
{
|
|
||||||
public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
|
|
||||||
{
|
|
||||||
public static string SectionName => "Album";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public PictureRoutes Routes { get; set; } = new() { ParentFolder = "Albums" };
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public PictureSizes PicturesSizes { get; set; } = new();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace Musify.Application.Configuration
|
|
||||||
{
|
|
||||||
public static class ConfigurationDependencyInjection
|
|
||||||
{
|
|
||||||
public static IServiceCollection AddValidatedOptions<T>(this IServiceCollection services, IConfiguration configuration)
|
|
||||||
where T : class, IConfigurationOptions =>
|
|
||||||
services.AddValidatedOptions<T>(configuration, T.SectionName);
|
|
||||||
|
|
||||||
public static IServiceCollection AddValidatedOptions<T>(
|
|
||||||
this IServiceCollection services,
|
|
||||||
IConfiguration configuration,
|
|
||||||
string sectionName)
|
|
||||||
where T : class
|
|
||||||
{
|
|
||||||
services
|
|
||||||
.AddOptionsWithValidateOnStart<T>()
|
|
||||||
.Bind(configuration.GetRequiredSection(sectionName))
|
|
||||||
.ValidateDataAnnotations();
|
|
||||||
|
|
||||||
services.AddSingleton(serviceProvider =>
|
|
||||||
serviceProvider.GetRequiredService<IOptions<T>>().Value);
|
|
||||||
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
using Musify.Application.Configuration.Responses;
|
|
||||||
|
|
||||||
namespace Musify.Application.Configuration
|
|
||||||
{
|
|
||||||
|
|
||||||
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
|
|
||||||
|
|
||||||
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
|
|
||||||
: IQueryHandler<GetPlaybackPublicConfigQuery, PlaybackPublicConfigResponse>
|
|
||||||
{
|
|
||||||
public ValueTask<PlaybackPublicConfigResponse> Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) =>
|
|
||||||
ValueTask.FromResult(new PlaybackPublicConfigResponse(
|
|
||||||
playbackConfiguration.AllowAnonymousListening,
|
|
||||||
playbackConfiguration.AnonymousFragmentSeconds));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Musify.Application.Configuration
|
|
||||||
{
|
|
||||||
public interface IConfigurationOptions
|
|
||||||
{
|
|
||||||
public static abstract string SectionName { get; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
|
|
||||||
namespace Musify.Application.Configuration
|
|
||||||
{
|
|
||||||
public class MixConfiguration : IConfigurationOptions
|
|
||||||
{
|
|
||||||
public static string SectionName => "Mix";
|
|
||||||
|
|
||||||
[Range(4, 100)]
|
|
||||||
public int ItemsPerMix { get; set; } = 12;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Globalization;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.Configuration
|
|
||||||
{
|
|
||||||
public enum PictureSize
|
|
||||||
{
|
|
||||||
Small,
|
|
||||||
Medium,
|
|
||||||
Large
|
|
||||||
}
|
|
||||||
|
|
||||||
public class PictureRoutes
|
|
||||||
{
|
|
||||||
[Required]
|
|
||||||
public string UploadsFolder { get; set; } = "uploads";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string ParentFolder { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string SmallPicturesFolder { get; set; } = "SmallPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string MediumPicturesFolder { get; set; } = "MediumPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string LargePicturesFolder { get; set; } = "LargePictures";
|
|
||||||
|
|
||||||
public string FolderPath(PictureSize size) => StorageKey.Combine(ParentFolder, size switch
|
|
||||||
{
|
|
||||||
PictureSize.Small => SmallPicturesFolder,
|
|
||||||
PictureSize.Medium => MediumPicturesFolder,
|
|
||||||
PictureSize.Large => LargePicturesFolder,
|
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(size), size, null)
|
|
||||||
});
|
|
||||||
|
|
||||||
public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName);
|
|
||||||
|
|
||||||
public string BuildOriginalPicturePath(long userId, string pictureName) =>
|
|
||||||
StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalPicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildTempPath(string tempRootPrefix, long userId, string objectName) =>
|
|
||||||
StorageKey.Combine(tempRootPrefix, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, objectName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class PictureSizes
|
|
||||||
{
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int SmallWidth { get; set; } = 128;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int SmallHeight { get; set; } = 128;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int MediumWidth { get; set; } = 256;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int MediumHeight { get; set; } = 256;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int LargeWidth { get; set; } = 512;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int LargeHeight { get; set; } = 512;
|
|
||||||
|
|
||||||
public ImageSizes ToImageSizes(PictureRoutes routes) => new(
|
|
||||||
new ImageSize(routes.FolderPath(PictureSize.Small), SmallWidth, SmallHeight),
|
|
||||||
new ImageSize(routes.FolderPath(PictureSize.Medium), MediumWidth, MediumHeight),
|
|
||||||
new ImageSize(routes.FolderPath(PictureSize.Large), LargeWidth, LargeHeight));
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IPictureOwnerConfiguration
|
|
||||||
{
|
|
||||||
public PictureRoutes Routes { get; }
|
|
||||||
|
|
||||||
public PictureSizes PicturesSizes { get; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class PictureSizeParser
|
|
||||||
{
|
|
||||||
/// <summary>Parses the <c>size</c> query value; anything unknown means <see cref="PictureSize.Medium"/>.</summary>
|
|
||||||
public static PictureSize Parse(string? value) =>
|
|
||||||
Enum.TryParse<PictureSize>(value, ignoreCase: true, out var size) && Enum.IsDefined(size)
|
|
||||||
? size
|
|
||||||
: PictureSize.Medium;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
|
|
||||||
namespace Musify.Application.Configuration
|
|
||||||
{
|
|
||||||
public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
|
|
||||||
{
|
|
||||||
public static string SectionName => "PlayList";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public PictureRoutes Routes { get; set; } = new() { ParentFolder = "PlayLists" };
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public PictureSizes PicturesSizes { get; set; } = new();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
|
|
||||||
namespace Musify.Application.Configuration
|
|
||||||
{
|
|
||||||
|
|
||||||
public sealed class PlaybackConfiguration : IConfigurationOptions
|
|
||||||
{
|
|
||||||
public static string SectionName => "Playback";
|
|
||||||
|
|
||||||
public bool AllowAnonymousListening { get; set; }
|
|
||||||
|
|
||||||
[Range(0, 3600)]
|
|
||||||
public int AnonymousFragmentSeconds { get; set; }
|
|
||||||
|
|
||||||
[Range(1, int.MaxValue)]
|
|
||||||
public int EstimatedAudioBytesPerSecond { get; set; } = 16_000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-5
@@ -1,5 +0,0 @@
|
|||||||
namespace Musify.Application.Configuration.Responses
|
|
||||||
{
|
|
||||||
|
|
||||||
public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds);
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Globalization;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.Configuration
|
|
||||||
{
|
|
||||||
public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
|
|
||||||
{
|
|
||||||
public static string SectionName => "Track";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public TrackRoutes Routes { get; set; } = new();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public PictureSizes PicturesSizes { get; set; } = new();
|
|
||||||
|
|
||||||
PictureRoutes IPictureOwnerConfiguration.Routes => Routes;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class TrackRoutes : PictureRoutes
|
|
||||||
{
|
|
||||||
public TrackRoutes()
|
|
||||||
{
|
|
||||||
ParentFolder = "Tracks";
|
|
||||||
}
|
|
||||||
|
|
||||||
[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";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string OriginalAudiosFolder { get; set; } = "OriginalAudios";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string ProcessedAudiosFolder { get; set; } = "ProcessedAudios";
|
|
||||||
|
|
||||||
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture);
|
|
||||||
|
|
||||||
public string PresetMediumPicturePath => StorageKey.Combine(ParentFolder, PresetMediumPicture);
|
|
||||||
|
|
||||||
public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture);
|
|
||||||
|
|
||||||
public string BuildOriginalAudioPath(long userId, string audioName) =>
|
|
||||||
StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalAudiosFolder, audioName);
|
|
||||||
|
|
||||||
public string BuildProcessedAudioPath(string folderName) =>
|
|
||||||
StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
namespace Musify.Application.Contracts
|
|
||||||
{
|
|
||||||
public record AudioTranscodeResult(int StatusCode, TimeSpan Duration);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Musify.Application.Contracts
|
|
||||||
{
|
|
||||||
public interface IAudioTranscoderService
|
|
||||||
{
|
|
||||||
public Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Musify.Application.Contracts
|
|
||||||
{
|
|
||||||
public interface IDatabase
|
|
||||||
{
|
|
||||||
public DbSet<User> Users { get; }
|
|
||||||
|
|
||||||
public DbSet<PlayList> PlayLists { get; }
|
|
||||||
|
|
||||||
public DbSet<Album> Albums { get; }
|
|
||||||
|
|
||||||
public DbSet<AlbumHasTrack> AlbumHasTracks { get; }
|
|
||||||
|
|
||||||
public DbSet<Track> Tracks { get; }
|
|
||||||
|
|
||||||
public DbSet<PlayListHasTrack> PlayListHasTracks { get; }
|
|
||||||
|
|
||||||
public DbSet<Mix> Mixes { get; }
|
|
||||||
|
|
||||||
public DbSet<MixItem> MixItems { get; }
|
|
||||||
|
|
||||||
public DbSet<UploadIntent> UploadIntents { get; }
|
|
||||||
|
|
||||||
public DbSet<ListeningHistory> ListeningHistories { get; }
|
|
||||||
|
|
||||||
public DbSet<TrackLike> TrackLikes { get; }
|
|
||||||
|
|
||||||
public DbSet<TrackTag> TrackTags { get; }
|
|
||||||
|
|
||||||
public DbSet<UserFollow> UserFollows { get; }
|
|
||||||
|
|
||||||
public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
public DbSet<TEntity> Set<TEntity>() where TEntity : class;
|
|
||||||
|
|
||||||
public Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
public Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Musify.Application.Contracts
|
|
||||||
{
|
|
||||||
public interface IEventBus
|
|
||||||
{
|
|
||||||
public Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Musify.Application.Contracts
|
|
||||||
{
|
|
||||||
public interface IPictureService
|
|
||||||
{
|
|
||||||
public Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
namespace Musify.Application.Contracts
|
|
||||||
{
|
|
||||||
public interface IStorageService
|
|
||||||
{
|
|
||||||
/// <summary>Opens an object for reading, or returns null when it does not exist.</summary>
|
|
||||||
public Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
public Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, bool preventOverwrite = true, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
public Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
public Task<IReadOnlyList<string>> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
public Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
public Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
|
|
||||||
CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
public IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
namespace Musify.Application.Events
|
|
||||||
{
|
|
||||||
public record AlbumPictureProcessed(Guid AlbumId);
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
namespace Musify.Application.Events
|
|
||||||
{
|
|
||||||
public record AlbumPictureProcessingFailed(Guid AlbumId);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Musify.Application.Events
|
|
||||||
{
|
|
||||||
public record AlbumProcessingFailed(
|
|
||||||
Guid AlbumId,
|
|
||||||
string? Bucket,
|
|
||||||
string? PictureKey);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.Events
|
|
||||||
{
|
|
||||||
public record CreateAlbumResourcesEvent(
|
|
||||||
Guid AlbumId,
|
|
||||||
Guid PictureIntentId,
|
|
||||||
string Bucket,
|
|
||||||
string PictureSourceKey,
|
|
||||||
string PictureDestinationKey,
|
|
||||||
ImageSizes Sizes);
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Musify.Application.Events
|
|
||||||
{
|
|
||||||
public record DeleteAlbumEvent(
|
|
||||||
Guid AlbumId,
|
|
||||||
long UserId);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.Events
|
|
||||||
{
|
|
||||||
public record UpdateAlbumPictureEvent(
|
|
||||||
Guid AlbumId,
|
|
||||||
string Bucket,
|
|
||||||
string SourceKey,
|
|
||||||
ImageSizes Sizes);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.Events
|
|
||||||
{
|
|
||||||
public record UpdateAlbumPictureSourceEvent(
|
|
||||||
Guid AlbumId,
|
|
||||||
Guid PictureIntentId,
|
|
||||||
string Bucket,
|
|
||||||
string PictureSourceKey,
|
|
||||||
string PictureDestinationKey,
|
|
||||||
ImageSizes Sizes);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
using Musify.Application.Genres.Responses;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Genres
|
|
||||||
{
|
|
||||||
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
|
|
||||||
|
|
||||||
public class GetAvailableGenresQueryHandler
|
|
||||||
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
|
|
||||||
{
|
|
||||||
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var all = Enum.GetValues<Genre>();
|
|
||||||
|
|
||||||
IReadOnlyList<AvailableGenreResponse> result = all
|
|
||||||
.Select(genre => new AvailableGenreResponse(
|
|
||||||
genre,
|
|
||||||
all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList()))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return ValueTask.FromResult(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Genres.Responses;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Genres
|
|
||||||
{
|
|
||||||
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
|
|
||||||
|
|
||||||
public class GetGenresQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var rows = await database.TrackTags
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
|
||||||
.GroupBy(tag => tag.Tag)
|
|
||||||
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
return rows
|
|
||||||
.OrderByDescending(row => row.TrackCount)
|
|
||||||
.ThenBy(row => row.Genre)
|
|
||||||
.Select(row => new GenreResponse(row.Genre, row.TrackCount))
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Genres.Responses
|
|
||||||
{
|
|
||||||
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Genres.Responses
|
|
||||||
{
|
|
||||||
public record GenreResponse(Genre Genre, int TrackCount);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Likes
|
|
||||||
{
|
|
||||||
public record GetLikedTracksQuery(
|
|
||||||
long UserId,
|
|
||||||
int PageNumber,
|
|
||||||
int PageSize)
|
|
||||||
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
|
|
||||||
|
|
||||||
public class GetLikedTracksQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetLikedTracksQuery, PaginatedResponse<TrackApplicationResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return await database.TrackLikes
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
|
||||||
.OrderByDescending(like => like.CreatedAt)
|
|
||||||
.ThenBy(like => like.Id)
|
|
||||||
.Select(like => like.Track)
|
|
||||||
.SelectResponse()
|
|
||||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Likes
|
|
||||||
{
|
|
||||||
public record ToggleTrackLikeCommand(long UserId, Guid TrackId)
|
|
||||||
: ICommand<ErrorOr<bool>>;
|
|
||||||
|
|
||||||
public class ToggleTrackLikeCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<ToggleTrackLikeCommandHandler> logger)
|
|
||||||
: ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var trackExists = await database.Tracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
|
|
||||||
if (!trackExists)
|
|
||||||
return AppErrors.NotFound("Track", request.TrackId);
|
|
||||||
|
|
||||||
var existingLike = await database.TrackLikes
|
|
||||||
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
|
|
||||||
|
|
||||||
bool nowLiked;
|
|
||||||
if (existingLike != null)
|
|
||||||
{
|
|
||||||
database.TrackLikes.Remove(existingLike);
|
|
||||||
nowLiked = false;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await database.TrackLikes.AddAsync(new TrackLike
|
|
||||||
{
|
|
||||||
UserId = request.UserId,
|
|
||||||
TrackId = request.TrackId
|
|
||||||
}, cancellationToken);
|
|
||||||
nowLiked = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A concurrent double click can hit the unique index; the outcome is already in place, so treat it as a no-op.
|
|
||||||
var saved = await database.TrySaveChangesAsync(
|
|
||||||
AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken);
|
|
||||||
if (saved.IsError)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
logger.LogInformation("User {UserId} {Action} track {TrackId}",
|
|
||||||
request.UserId, nowLiked ? "liked" : "unliked", request.TrackId);
|
|
||||||
|
|
||||||
return nowLiked;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
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.Mixes
|
|
||||||
{
|
|
||||||
public record GenerateMixesForUserCommand(long UserId)
|
|
||||||
: ICommand;
|
|
||||||
|
|
||||||
public class GenerateMixesForUserCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
MixConfiguration mixConfiguration,
|
|
||||||
ILogger<GenerateMixesForUserCommandHandler> logger)
|
|
||||||
: ICommandHandler<GenerateMixesForUserCommand>
|
|
||||||
{
|
|
||||||
private sealed record MixDraft(MixKind Kind, IReadOnlyList<Guid> TrackIds);
|
|
||||||
|
|
||||||
public async ValueTask<Unit> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var userId = request.UserId;
|
|
||||||
|
|
||||||
var libraryTrackIds = await database.Tracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active)
|
|
||||||
.Select(track => track.Id)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (libraryTrackIds.Count == 0)
|
|
||||||
{
|
|
||||||
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
|
|
||||||
return Unit.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
var historyTrackIds = await database.ListeningHistories
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(history => history.UserId == userId && history.IsCounted)
|
|
||||||
.Select(history => history.TrackId)
|
|
||||||
.Distinct()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var historySet = new HashSet<Guid>(historyTrackIds);
|
|
||||||
var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList();
|
|
||||||
|
|
||||||
var drafts = new List<MixDraft>();
|
|
||||||
|
|
||||||
var discovery = Take(Shuffle(unheardTrackIds));
|
|
||||||
if (discovery.Count > 0)
|
|
||||||
{
|
|
||||||
drafts.Add(new MixDraft(MixKind.Discovery, discovery));
|
|
||||||
}
|
|
||||||
|
|
||||||
var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
|
|
||||||
if (daily.Count > 0)
|
|
||||||
{
|
|
||||||
drafts.Add(new MixDraft(MixKind.Daily, daily));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (drafts.Count == 0)
|
|
||||||
{
|
|
||||||
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
|
|
||||||
return Unit.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ReplaceMixesAsync(userId, drafts, cancellationToken);
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
|
|
||||||
return Unit.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<Guid> Take(IEnumerable<Guid> trackIds) =>
|
|
||||||
[.. trackIds.Take(mixConfiguration.ItemsPerMix)];
|
|
||||||
|
|
||||||
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) =>
|
|
||||||
[.. trackIds.OrderBy(_ => Guid.NewGuid())];
|
|
||||||
|
|
||||||
private static List<Guid> Interleave(List<Guid> first, List<Guid> second)
|
|
||||||
{
|
|
||||||
var merged = new List<Guid>(first.Count + second.Count);
|
|
||||||
for (var index = 0; index < Math.Max(first.Count, second.Count); index++)
|
|
||||||
{
|
|
||||||
if (index < first.Count)
|
|
||||||
merged.Add(first[index]);
|
|
||||||
if (index < second.Count)
|
|
||||||
merged.Add(second[index]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ReplaceMixesAsync(long userId, IReadOnlyList<MixDraft> drafts, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var previousMixes = await database.Mixes
|
|
||||||
.Where(mix => mix.UserId == userId)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (previousMixes.Count > 0)
|
|
||||||
{
|
|
||||||
database.Mixes.RemoveRange(previousMixes);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var position = 0; position < drafts.Count; position++)
|
|
||||||
{
|
|
||||||
var draft = drafts[position];
|
|
||||||
var mix = new Mix
|
|
||||||
{
|
|
||||||
UserId = userId,
|
|
||||||
Kind = draft.Kind,
|
|
||||||
Position = position
|
|
||||||
};
|
|
||||||
|
|
||||||
await database.Mixes.AddAsync(mix, cancellationToken);
|
|
||||||
|
|
||||||
for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++)
|
|
||||||
{
|
|
||||||
await database.MixItems.AddAsync(new MixItem
|
|
||||||
{
|
|
||||||
MixId = mix.Id,
|
|
||||||
Position = itemPosition,
|
|
||||||
TrackId = draft.TrackIds[itemPosition]
|
|
||||||
}, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Mixes.Responses;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.Mixes
|
|
||||||
{
|
|
||||||
public record GetMixByIdQuery(long UserId, Guid MixId)
|
|
||||||
: IQuery<ErrorOr<MixApplicationResponse>>;
|
|
||||||
|
|
||||||
public class GetMixByIdQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetMixByIdQuery, ErrorOr<MixApplicationResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<MixApplicationResponse>> Handle(GetMixByIdQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var mix = await database.Mixes
|
|
||||||
.AsNoTracking()
|
|
||||||
.Include(mix => mix.Items)
|
|
||||||
.SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
|
|
||||||
|
|
||||||
if (mix == null)
|
|
||||||
return AppErrors.NotFound("Mix", request.MixId);
|
|
||||||
|
|
||||||
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
|
|
||||||
var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken);
|
|
||||||
|
|
||||||
return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Mixes.Responses;
|
|
||||||
|
|
||||||
namespace Musify.Application.Mixes
|
|
||||||
{
|
|
||||||
public record GetMixesByUserIdQuery(long UserId)
|
|
||||||
: IQuery<IReadOnlyList<MixApplicationResponse>>;
|
|
||||||
|
|
||||||
public class GetMixesByUserIdQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetMixesByUserIdQuery, IReadOnlyList<MixApplicationResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<IReadOnlyList<MixApplicationResponse>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var mixes = await database.Mixes
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(mix => mix.UserId == request.UserId)
|
|
||||||
.Include(mix => mix.Items)
|
|
||||||
.OrderBy(mix => mix.Position)
|
|
||||||
.ThenBy(mix => mix.Id)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var orderedItemsByMix = mixes.ToDictionary(
|
|
||||||
mix => mix.Id,
|
|
||||||
mix => mix.Items.OrderBy(item => item.Position).ToList());
|
|
||||||
|
|
||||||
var coverItems = orderedItemsByMix.Values
|
|
||||||
.SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount))
|
|
||||||
.ToList();
|
|
||||||
var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken))
|
|
||||||
.GroupBy(response => response.TrackId)
|
|
||||||
.ToDictionary(group => group.Key, group => group.First());
|
|
||||||
|
|
||||||
var responses = new List<MixApplicationResponse>(mixes.Count);
|
|
||||||
foreach (var mix in mixes)
|
|
||||||
{
|
|
||||||
var orderedItems = orderedItemsByMix[mix.Id];
|
|
||||||
var covers = orderedItems
|
|
||||||
.Take(MixApplicationResponse.CoverItemCount)
|
|
||||||
.Where(item => coverResponses.ContainsKey(item.TrackId))
|
|
||||||
.Select(item => coverResponses[item.TrackId])
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
responses.Add(new MixApplicationResponse(mix.Id, mix.Kind, orderedItems.Count, covers));
|
|
||||||
}
|
|
||||||
|
|
||||||
return responses;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Mixes.Responses;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Mixes
|
|
||||||
{
|
|
||||||
|
|
||||||
internal static class MixItemMapper
|
|
||||||
{
|
|
||||||
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
|
|
||||||
IDatabase database,
|
|
||||||
IReadOnlyList<MixItem> orderedItems,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList();
|
|
||||||
|
|
||||||
var tracksById = await database.Tracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Include(track => track.Owner)
|
|
||||||
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
|
||||||
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
|
|
||||||
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
|
|
||||||
|
|
||||||
return orderedItems
|
|
||||||
.Where(item => tracksById.ContainsKey(item.TrackId))
|
|
||||||
.Select(item =>
|
|
||||||
{
|
|
||||||
var entry = tracksById[item.TrackId];
|
|
||||||
return new MixItemApplicationResponse(
|
|
||||||
item.TrackId,
|
|
||||||
entry.Track.Title,
|
|
||||||
entry.Track.Owner.Name,
|
|
||||||
entry.Track.DurationSeconds,
|
|
||||||
entry.ListensCount);
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Mixes.Responses
|
|
||||||
{
|
|
||||||
public record MixApplicationResponse(
|
|
||||||
Guid Id,
|
|
||||||
MixKind Kind,
|
|
||||||
int ItemCount,
|
|
||||||
IReadOnlyList<MixItemApplicationResponse> Items)
|
|
||||||
{
|
|
||||||
public const int CoverItemCount = 4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace Musify.Application.Mixes.Responses
|
|
||||||
{
|
|
||||||
public record MixItemApplicationResponse(
|
|
||||||
Guid TrackId,
|
|
||||||
string Title,
|
|
||||||
string? Artist,
|
|
||||||
double DurationSeconds,
|
|
||||||
int ListensCount);
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using MimeMapping;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Pictures
|
|
||||||
{
|
|
||||||
public enum CoverOwner
|
|
||||||
{
|
|
||||||
Track,
|
|
||||||
Album,
|
|
||||||
PlayList
|
|
||||||
}
|
|
||||||
|
|
||||||
public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium)
|
|
||||||
: IQuery<ErrorOr<CoverLocation>>;
|
|
||||||
|
|
||||||
public record CoverLocation(string Bucket, string Key, string ContentType);
|
|
||||||
|
|
||||||
public class GetCoverQueryHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
|
||||||
TrackConfiguration trackConfiguration,
|
|
||||||
AlbumConfiguration albumConfiguration,
|
|
||||||
PlayListConfiguration playListConfiguration)
|
|
||||||
: IQueryHandler<GetCoverQuery, ErrorOr<CoverLocation>>
|
|
||||||
{
|
|
||||||
private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large);
|
|
||||||
|
|
||||||
public async ValueTask<ErrorOr<CoverLocation>> Handle(GetCoverQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var (source, routes) = request.Owner switch
|
|
||||||
{
|
|
||||||
CoverOwner.Track => (await database.Tracks.AsNoTracking()
|
|
||||||
.Where(t => t.Id == request.Id && t.LifeCycleStatus == LifeCycleStatus.Active)
|
|
||||||
.Select(t => new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName))
|
|
||||||
.SingleOrDefaultAsync(cancellationToken), trackConfiguration.Routes),
|
|
||||||
CoverOwner.Album => (await database.Albums.AsNoTracking()
|
|
||||||
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null)
|
|
||||||
.Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName))
|
|
||||||
.SingleOrDefaultAsync(cancellationToken), albumConfiguration.Routes),
|
|
||||||
_ => (await database.PlayLists.AsNoTracking()
|
|
||||||
.Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active && p.Pictures != null)
|
|
||||||
.Select(p => new CoverSource(p.OwnerUserId, p.Pictures!.OriginalName, p.Pictures.SmallName, p.Pictures.MediumName, p.Pictures.LargeName))
|
|
||||||
.SingleOrDefaultAsync(cancellationToken), playListConfiguration.Routes)
|
|
||||||
};
|
|
||||||
|
|
||||||
var entity = request.Owner.ToString();
|
|
||||||
if (source == null)
|
|
||||||
return AppErrors.NotFound($"{entity}Cover", request.Id);
|
|
||||||
|
|
||||||
var resizedName = request.Size switch
|
|
||||||
{
|
|
||||||
PictureSize.Small => source.Small,
|
|
||||||
PictureSize.Large => source.Large,
|
|
||||||
_ => source.Medium
|
|
||||||
};
|
|
||||||
|
|
||||||
// The resized files only exist once processing has finished; until then serve the original.
|
|
||||||
var (name, key) = !string.IsNullOrEmpty(resizedName)
|
|
||||||
? (resizedName, routes.BuildPicturePath(request.Size, resizedName))
|
|
||||||
: !string.IsNullOrEmpty(source.Original)
|
|
||||||
? (source.Original, routes.BuildOriginalPicturePath(source.OwnerUserId, source.Original))
|
|
||||||
: (null, null);
|
|
||||||
|
|
||||||
if (name == null || key == null)
|
|
||||||
return AppErrors.NotFound($"{entity}Cover", request.Id);
|
|
||||||
|
|
||||||
return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Services;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Pictures
|
|
||||||
{
|
|
||||||
/// <summary>What an album or playlist needs to switch to a freshly uploaded picture.</summary>
|
|
||||||
public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes);
|
|
||||||
|
|
||||||
public static class PictureSourceChange
|
|
||||||
{
|
|
||||||
public static async Task<ErrorOr<PreparedPicture>> PrepareAsync(
|
|
||||||
UploadIntentValidator uploadIntentValidator,
|
|
||||||
Guid intentId,
|
|
||||||
long userId,
|
|
||||||
UploadIntentPurpose purpose,
|
|
||||||
IPictureOwnerConfiguration owner,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var validation = await uploadIntentValidator.ValidateAndLoadAsync(intentId, userId, purpose, cancellationToken);
|
|
||||||
if (validation.IsError)
|
|
||||||
return validation.Errors;
|
|
||||||
|
|
||||||
var intent = validation.Value;
|
|
||||||
|
|
||||||
return new PreparedPicture(
|
|
||||||
intent,
|
|
||||||
EntityPictures.Pending(intent.ObjectName),
|
|
||||||
owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName),
|
|
||||||
owner.PicturesSizes.ToImageSizes(owner.Routes));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
|
||||||
{
|
|
||||||
/// <summary>Appends tracks to a playlist in the given order, skipping the ones it already has.</summary>
|
|
||||||
public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList<Guid> TrackIds)
|
|
||||||
: ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class AddTracksToPlayListCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<AddTracksToPlayListCommandHandler> logger)
|
|
||||||
: ICommandHandler<AddTracksToPlayListCommand, ErrorOr<Success>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
|
||||||
if (playList.IsError)
|
|
||||||
return playList.Errors;
|
|
||||||
|
|
||||||
var requestedIds = request.TrackIds.Distinct().ToList();
|
|
||||||
|
|
||||||
var existingIds = await database.Tracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
|
||||||
.Select(track => track.Id)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var missing = requestedIds.FirstOrDefault(id => !existingIds.Contains(id));
|
|
||||||
if (missing != Guid.Empty)
|
|
||||||
return AppErrors.NotFound("Track", missing);
|
|
||||||
|
|
||||||
var alreadyInPlayList = await database.PlayListHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(link => link.PlayListId == request.PlayListId && requestedIds.Contains(link.TrackId))
|
|
||||||
.Select(link => link.TrackId)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var toAdd = requestedIds.Where(id => !alreadyInPlayList.Contains(id)).ToList();
|
|
||||||
if (toAdd.Count == 0)
|
|
||||||
return Result.Success;
|
|
||||||
|
|
||||||
// Playlist positions are internal indexes, so they start at 0.
|
|
||||||
var nextPosition = (await database.PlayListHasTracks
|
|
||||||
.Where(link => link.PlayListId == request.PlayListId)
|
|
||||||
.MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1;
|
|
||||||
|
|
||||||
foreach (var trackId in toAdd)
|
|
||||||
{
|
|
||||||
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
|
|
||||||
{
|
|
||||||
PlayListId = request.PlayListId,
|
|
||||||
TrackId = trackId,
|
|
||||||
Position = nextPosition++
|
|
||||||
}, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
var saved = await database.TrySaveChangesAsync(
|
|
||||||
AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken);
|
|
||||||
if (saved.IsError)
|
|
||||||
return saved;
|
|
||||||
|
|
||||||
logger.LogInformation("Added {Count} tracks to playlist {PlayListId}", toAdd.Count, request.PlayListId);
|
|
||||||
|
|
||||||
return Result.Success;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Application.Pictures;
|
|
||||||
using Musify.Application.PlayLists.Responses;
|
|
||||||
using Musify.Application.Services;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
|
||||||
{
|
|
||||||
public record CreatePlayListCommand(
|
|
||||||
long UserId,
|
|
||||||
string Name,
|
|
||||||
string? Description,
|
|
||||||
Guid? PictureIntentId,
|
|
||||||
PlayListVisibility Visibility = PlayListVisibility.Private)
|
|
||||||
: ICommand<ErrorOr<PlayListApplicationResponse>>;
|
|
||||||
|
|
||||||
public class CreatePlayListCommandHandler(
|
|
||||||
IEventBus eventBus,
|
|
||||||
IDatabase database,
|
|
||||||
UploadIntentValidator uploadIntentValidator,
|
|
||||||
ILogger<CreatePlayListCommandHandler> logger,
|
|
||||||
PlayListConfiguration playListConfiguration)
|
|
||||||
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var userExists = await database.Users
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
|
|
||||||
if (!userExists)
|
|
||||||
return AppErrors.NotFound("User", request.UserId);
|
|
||||||
|
|
||||||
PreparedPicture? picture = null;
|
|
||||||
if (request.PictureIntentId is { } intentId)
|
|
||||||
{
|
|
||||||
var prepared = await PictureSourceChange.PrepareAsync(
|
|
||||||
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
|
|
||||||
if (prepared.IsError)
|
|
||||||
return prepared.Errors;
|
|
||||||
|
|
||||||
picture = prepared.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
var name = request.Name.Trim();
|
|
||||||
var playList = new PlayList
|
|
||||||
{
|
|
||||||
OwnerUserId = request.UserId,
|
|
||||||
Name = name,
|
|
||||||
NormalizedName = TextNormalizer.Normalize(name),
|
|
||||||
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
|
||||||
Pictures = picture?.Pictures,
|
|
||||||
Visibility = request.Visibility
|
|
||||||
};
|
|
||||||
await database.PlayLists.AddAsync(playList, cancellationToken);
|
|
||||||
|
|
||||||
if (picture != null)
|
|
||||||
{
|
|
||||||
await eventBus.PublishAsync(
|
|
||||||
new CreatePlayListResourcesEvent(
|
|
||||||
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
|
|
||||||
|
|
||||||
return PlayListApplicationResponse.FromEntity(playList);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
|
||||||
{
|
|
||||||
public record DeletePlayListCommand(long UserId, Guid PlayListId)
|
|
||||||
: ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class DeletePlayListCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
IEventBus eventBus,
|
|
||||||
ILogger<DeletePlayListCommandHandler> logger)
|
|
||||||
: ICommandHandler<DeletePlayListCommand, ErrorOr<Success>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
|
||||||
if (found.IsError)
|
|
||||||
return found.Errors;
|
|
||||||
|
|
||||||
var playList = found.Value;
|
|
||||||
playList.LifeCycleStatus = LifeCycleStatus.Removing;
|
|
||||||
|
|
||||||
await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken);
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Marked playlist {PlayListId} as removing", request.PlayListId);
|
|
||||||
|
|
||||||
return Result.Success;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.PlayLists.Responses;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
|
||||||
{
|
|
||||||
public record GetPlayListTracksQuery(
|
|
||||||
Guid PlayListId,
|
|
||||||
int PageNumber,
|
|
||||||
int PageSize,
|
|
||||||
long? ViewerId = null)
|
|
||||||
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
|
||||||
|
|
||||||
public class GetPlayListTracksQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetPlayListTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var playListVisible = await database.PlayLists
|
|
||||||
.AsNoTracking()
|
|
||||||
.Active()
|
|
||||||
.VisibleTo(request.ViewerId)
|
|
||||||
.AnyAsync(playList => playList.Id == request.PlayListId, cancellationToken);
|
|
||||||
if (!playListVisible)
|
|
||||||
return AppErrors.NotFound("PlayList", request.PlayListId);
|
|
||||||
|
|
||||||
return await database.PlayListHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
|
||||||
.OrderBy(playListTrack => playListTrack.Position)
|
|
||||||
.ThenBy(playListTrack => playListTrack.Id)
|
|
||||||
.Select(playListTrack => playListTrack.Track)
|
|
||||||
.SelectResponse()
|
|
||||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.PlayLists.Responses;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
|
||||||
{
|
|
||||||
public record GetPlayListsByUserIdQuery(
|
|
||||||
long UserId,
|
|
||||||
string? Name,
|
|
||||||
int PageNumber,
|
|
||||||
int PageSize,
|
|
||||||
long? ViewerId = null)
|
|
||||||
: IQuery<PaginatedResponse<PlayListApplicationResponse>>;
|
|
||||||
|
|
||||||
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetPlayListsByUserIdQuery, PaginatedResponse<PlayListApplicationResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<PaginatedResponse<PlayListApplicationResponse>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var playListsQuery = database.PlayLists
|
|
||||||
.AsNoTracking()
|
|
||||||
.Active()
|
|
||||||
.VisibleTo(request.ViewerId)
|
|
||||||
.Where(playList => playList.OwnerUserId == request.UserId);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
|
||||||
{
|
|
||||||
var normalizedName = TextNormalizer.Normalize(request.Name);
|
|
||||||
playListsQuery = playListsQuery.Where(playList => playList.NormalizedName.Contains(normalizedName));
|
|
||||||
}
|
|
||||||
|
|
||||||
return await playListsQuery
|
|
||||||
.OrderBy(playList => playList.CreatedAt)
|
|
||||||
.ThenBy(playList => playList.Id)
|
|
||||||
.SelectResponse()
|
|
||||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
|
||||||
{
|
|
||||||
public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
|
|
||||||
: ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class RemoveTrackFromPlayListCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<RemoveTrackFromPlayListCommandHandler> logger)
|
|
||||||
: ICommandHandler<RemoveTrackFromPlayListCommand, ErrorOr<Success>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
|
||||||
if (playList.IsError)
|
|
||||||
return playList.Errors;
|
|
||||||
|
|
||||||
var playListTracks = await database.PlayListHasTracks
|
|
||||||
.Where(plt => plt.PlayListId == request.PlayListId)
|
|
||||||
.OrderBy(plt => plt.Position)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var link = playListTracks.SingleOrDefault(plt => plt.TrackId == request.TrackId);
|
|
||||||
if (link == null)
|
|
||||||
return Error.NotFound("PlayList.TrackNotFound", "Track is not in the playlist.");
|
|
||||||
|
|
||||||
database.PlayListHasTracks.Remove(link);
|
|
||||||
|
|
||||||
var position = 0;
|
|
||||||
foreach (var remaining in playListTracks.Where(plt => plt.Id != link.Id))
|
|
||||||
remaining.Position = position++;
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId);
|
|
||||||
|
|
||||||
return Result.Success;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Pictures.Responses;
|
|
||||||
using Musify.Application.Services;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
|
||||||
{
|
|
||||||
public record RequestPlayListPictureUploadCommand(
|
|
||||||
long UserId,
|
|
||||||
string FileType,
|
|
||||||
string ContentType,
|
|
||||||
long? ExpectedSizeBytes = null)
|
|
||||||
: ICommand<ErrorOr<PictureUploadResponse>>;
|
|
||||||
|
|
||||||
public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration)
|
|
||||||
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PictureUploadResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) =>
|
|
||||||
await issuer.IssuePictureAsync(
|
|
||||||
request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration,
|
|
||||||
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists.Responses
|
|
||||||
{
|
|
||||||
public record PlayListApplicationResponse(
|
|
||||||
Guid Id,
|
|
||||||
string Name,
|
|
||||||
string? Description,
|
|
||||||
string? SmallImageKeyName,
|
|
||||||
string? MediumImageKeyName,
|
|
||||||
string? LargeImageKeyName,
|
|
||||||
PlayListVisibility Visibility,
|
|
||||||
DateTime CreatedAt,
|
|
||||||
DateTime UpdatedAt,
|
|
||||||
IReadOnlyList<Guid> CoverTrackIds,
|
|
||||||
long OwnerUserId,
|
|
||||||
int TrackCount,
|
|
||||||
double DurationSeconds)
|
|
||||||
{
|
|
||||||
public const int CoverTrackCount = PlayListProjections.CoverTrackCount;
|
|
||||||
|
|
||||||
public static PlayListApplicationResponse FromEntity(
|
|
||||||
PlayList playList,
|
|
||||||
IReadOnlyList<Guid>? coverTrackIds = null,
|
|
||||||
int trackCount = 0,
|
|
||||||
double durationSeconds = 0)
|
|
||||||
{
|
|
||||||
return new PlayListApplicationResponse(
|
|
||||||
playList.Id,
|
|
||||||
playList.Name,
|
|
||||||
playList.Description,
|
|
||||||
playList.Pictures?.SmallName,
|
|
||||||
playList.Pictures?.MediumName,
|
|
||||||
playList.Pictures?.LargeName,
|
|
||||||
playList.Visibility,
|
|
||||||
playList.CreatedAt,
|
|
||||||
playList.UpdatedAt,
|
|
||||||
coverTrackIds ?? [],
|
|
||||||
playList.OwnerUserId,
|
|
||||||
trackCount,
|
|
||||||
durationSeconds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists.Responses
|
|
||||||
{
|
|
||||||
public static class PlayListProjections
|
|
||||||
{
|
|
||||||
public const int CoverTrackCount = 4;
|
|
||||||
|
|
||||||
public static IQueryable<PlayList> Active(this IQueryable<PlayList> query) =>
|
|
||||||
query.Where(playList => playList.LifeCycleStatus == LifeCycleStatus.Active);
|
|
||||||
|
|
||||||
public static IQueryable<PlayList> VisibleTo(this IQueryable<PlayList> query, long? viewerId) =>
|
|
||||||
query.Where(playList => playList.Visibility == PlayListVisibility.Public || playList.OwnerUserId == viewerId);
|
|
||||||
|
|
||||||
public static IQueryable<PlayListApplicationResponse> SelectResponse(this IQueryable<PlayList> query) =>
|
|
||||||
query.Select(playList => new PlayListApplicationResponse(
|
|
||||||
playList.Id,
|
|
||||||
playList.Name,
|
|
||||||
playList.Description,
|
|
||||||
playList.Pictures!.SmallName,
|
|
||||||
playList.Pictures!.MediumName,
|
|
||||||
playList.Pictures!.LargeName,
|
|
||||||
playList.Visibility,
|
|
||||||
playList.CreatedAt,
|
|
||||||
playList.UpdatedAt,
|
|
||||||
playList.PlayListTracks
|
|
||||||
.OrderBy(playListTrack => playListTrack.Position)
|
|
||||||
.Take(CoverTrackCount)
|
|
||||||
.Select(playListTrack => playListTrack.TrackId)
|
|
||||||
.ToList(),
|
|
||||||
playList.OwnerUserId,
|
|
||||||
playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active),
|
|
||||||
playList.PlayListTracks
|
|
||||||
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
|
||||||
.Sum(playListTrack => playListTrack.Track.DurationSeconds)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Application.Pictures;
|
|
||||||
using Musify.Application.PlayLists.Responses;
|
|
||||||
using Musify.Application.Services;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
|
||||||
{
|
|
||||||
public record UpdatePlayListCommand(
|
|
||||||
long UserId,
|
|
||||||
Guid PlayListId,
|
|
||||||
string? NewName,
|
|
||||||
string? NewDescription,
|
|
||||||
Guid? NewPictureIntentId,
|
|
||||||
PlayListVisibility? NewVisibility = null)
|
|
||||||
: ICommand<ErrorOr<PlayListApplicationResponse>>;
|
|
||||||
|
|
||||||
public class UpdatePlayListCommandHandler(
|
|
||||||
IEventBus eventBus,
|
|
||||||
IDatabase database,
|
|
||||||
UploadIntentValidator uploadIntentValidator,
|
|
||||||
ILogger<UpdatePlayListCommandHandler> logger,
|
|
||||||
PlayListConfiguration playListConfiguration)
|
|
||||||
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
|
||||||
if (found.IsError)
|
|
||||||
return found.Errors;
|
|
||||||
|
|
||||||
var playList = found.Value;
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.NewName))
|
|
||||||
{
|
|
||||||
playList.Name = request.NewName.Trim();
|
|
||||||
playList.NormalizedName = TextNormalizer.Normalize(request.NewName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// null means "unchanged"; a blank description clears it.
|
|
||||||
if (request.NewDescription != null)
|
|
||||||
playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim();
|
|
||||||
|
|
||||||
if (request.NewVisibility is { } visibility)
|
|
||||||
playList.Visibility = visibility;
|
|
||||||
|
|
||||||
if (request.NewPictureIntentId is { } intentId)
|
|
||||||
{
|
|
||||||
var prepared = await PictureSourceChange.PrepareAsync(
|
|
||||||
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
|
|
||||||
if (prepared.IsError)
|
|
||||||
return prepared.Errors;
|
|
||||||
|
|
||||||
var picture = prepared.Value;
|
|
||||||
playList.Pictures = picture.Pictures;
|
|
||||||
|
|
||||||
await eventBus.PublishAsync(
|
|
||||||
new UpdatePlayListPictureSourceEvent(
|
|
||||||
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Updated playlist {PlayListId}", playList.Id);
|
|
||||||
|
|
||||||
return await database.PlayLists
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(p => p.Id == playList.Id)
|
|
||||||
.SelectResponse()
|
|
||||||
.SingleAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
|
|
||||||
namespace Musify.Application.Services
|
|
||||||
{
|
|
||||||
/// <summary>Builds the stream URL and signed ticket for a processed track. It never touches the database.</summary>
|
|
||||||
public sealed class TrackStreamIssuer(
|
|
||||||
IStreamTicketService ticketService,
|
|
||||||
TrackConfiguration trackConfiguration,
|
|
||||||
StreamGatewayConfiguration streamGatewayConfiguration,
|
|
||||||
PlaybackConfiguration playbackConfiguration)
|
|
||||||
{
|
|
||||||
public TrackStreamResponse Issue(string audioFolderName, long? userId, Guid? listenId)
|
|
||||||
{
|
|
||||||
var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName);
|
|
||||||
var ticket = ticketService.IssueTicket(userId, $"{folderPath}/", AnonymousFragmentBytes(userId));
|
|
||||||
|
|
||||||
var manifestUrl = string.Join('/',
|
|
||||||
streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'),
|
|
||||||
"media",
|
|
||||||
folderPath,
|
|
||||||
streamGatewayConfiguration.AudioFileName);
|
|
||||||
|
|
||||||
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private long? AnonymousFragmentBytes(long? userId)
|
|
||||||
{
|
|
||||||
if (userId != null || playbackConfiguration.AnonymousFragmentSeconds <= 0)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Pictures.Responses;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Services
|
|
||||||
{
|
|
||||||
public sealed record UploadRequest(
|
|
||||||
UploadIntentPurpose Purpose,
|
|
||||||
PictureRoutes Routes,
|
|
||||||
string FileType,
|
|
||||||
string ContentType,
|
|
||||||
long? ExpectedSizeBytes);
|
|
||||||
|
|
||||||
public sealed record IssuedUpload(
|
|
||||||
Guid IntentId,
|
|
||||||
string Key,
|
|
||||||
string ObjectName,
|
|
||||||
string ContentType,
|
|
||||||
string UploadUrl);
|
|
||||||
|
|
||||||
/// <summary>Issues pre-signed upload URLs and the upload intents that track them, checking the user quota first.</summary>
|
|
||||||
public sealed class UploadIntentIssuer(
|
|
||||||
IDatabase database,
|
|
||||||
IStorageService storageService,
|
|
||||||
UploadIntentValidator uploadIntentValidator,
|
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
|
||||||
UploadIntentConfiguration uploadIntentConfiguration,
|
|
||||||
ILogger<UploadIntentIssuer> logger)
|
|
||||||
{
|
|
||||||
public async Task<ErrorOr<IReadOnlyList<IssuedUpload>>> IssueAsync(
|
|
||||||
long userId,
|
|
||||||
IReadOnlyList<UploadRequest> requests,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var userExists = await database.Users
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(user => user.Id == userId, cancellationToken);
|
|
||||||
if (!userExists)
|
|
||||||
return AppErrors.NotFound("User", userId);
|
|
||||||
|
|
||||||
var planned = requests
|
|
||||||
.Select(request =>
|
|
||||||
{
|
|
||||||
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
|
|
||||||
var key = request.Routes.BuildTempPath(uploadIntentConfiguration.TempRootPrefix, userId, objectName);
|
|
||||||
var size = request.ExpectedSizeBytes ?? DefaultSize(request.Purpose);
|
|
||||||
return (Request: request, ObjectName: objectName, Key: key, Size: size);
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
await using var transaction = await database.BeginTransactionAsync(
|
|
||||||
System.Data.IsolationLevel.Serializable, cancellationToken);
|
|
||||||
|
|
||||||
var quota = await uploadIntentValidator.CheckQuotaAsync(
|
|
||||||
userId, planned.Sum(item => item.Size), planned.Count, cancellationToken);
|
|
||||||
if (quota.IsError)
|
|
||||||
return quota.Errors;
|
|
||||||
|
|
||||||
var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds);
|
|
||||||
var expiresAt = DateTime.UtcNow + expiresIn;
|
|
||||||
var issued = new List<IssuedUpload>(planned.Count);
|
|
||||||
|
|
||||||
foreach (var item in planned)
|
|
||||||
{
|
|
||||||
var uploadUrl = await storageService.GetUploadUrlAsync(
|
|
||||||
storageConfiguration.Bucket, item.Key, item.Request.ContentType, expiresIn, cancellationToken: cancellationToken);
|
|
||||||
|
|
||||||
var intent = new UploadIntent
|
|
||||||
{
|
|
||||||
UserId = userId,
|
|
||||||
Bucket = storageConfiguration.Bucket,
|
|
||||||
Key = item.Key,
|
|
||||||
ObjectName = item.ObjectName,
|
|
||||||
ContentType = item.Request.ContentType,
|
|
||||||
ExpectedSizeBytes = item.Size,
|
|
||||||
Purpose = item.Request.Purpose,
|
|
||||||
ExpiresAt = expiresAt,
|
|
||||||
};
|
|
||||||
|
|
||||||
await database.UploadIntents.AddAsync(intent, cancellationToken);
|
|
||||||
issued.Add(new IssuedUpload(intent.Id, item.Key, item.ObjectName, item.Request.ContentType, uploadUrl));
|
|
||||||
}
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
await transaction.CommitAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Issued {Count} upload intents for user {UserId} ({Purposes})",
|
|
||||||
issued.Count, userId, string.Join('/', planned.Select(item => item.Request.Purpose)));
|
|
||||||
|
|
||||||
return issued;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ErrorOr<IssuedUpload>> IssueOneAsync(long userId, UploadRequest request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var issued = await IssueAsync(userId, [request], cancellationToken);
|
|
||||||
return issued.IsError ? issued.Errors : issued.Value[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ErrorOr<PictureUploadResponse>> IssuePictureAsync(
|
|
||||||
long userId,
|
|
||||||
UploadIntentPurpose purpose,
|
|
||||||
IPictureOwnerConfiguration owner,
|
|
||||||
string fileType,
|
|
||||||
string contentType,
|
|
||||||
long? expectedSizeBytes,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var issued = await IssueOneAsync(
|
|
||||||
userId, new UploadRequest(purpose, owner.Routes, fileType, contentType, expectedSizeBytes), cancellationToken);
|
|
||||||
if (issued.IsError)
|
|
||||||
return issued.Errors;
|
|
||||||
|
|
||||||
return new PictureUploadResponse(
|
|
||||||
issued.Value.IntentId,
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
issued.Value.Key,
|
|
||||||
issued.Value.ObjectName,
|
|
||||||
issued.Value.ContentType,
|
|
||||||
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
|
|
||||||
issued.Value.UploadUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
private long DefaultSize(UploadIntentPurpose purpose) =>
|
|
||||||
purpose == UploadIntentPurpose.TrackAudio
|
|
||||||
? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes
|
|
||||||
: uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Services
|
|
||||||
{
|
|
||||||
public sealed class UploadIntentValidator(
|
|
||||||
IDatabase database,
|
|
||||||
IStorageService storageService,
|
|
||||||
UploadIntentConfiguration config)
|
|
||||||
{
|
|
||||||
public async Task<ErrorOr<Success>> CheckQuotaAsync(
|
|
||||||
long userId,
|
|
||||||
long requiredBytes,
|
|
||||||
int requiredIntentCount,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var pictureDefault = config.DefaultExpectedPictureSizeBytes;
|
|
||||||
var audioDefault = config.DefaultExpectedAudioSizeBytes;
|
|
||||||
|
|
||||||
var active = await database.UploadIntents
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(intent => intent.UserId == userId && intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt > now)
|
|
||||||
.GroupBy(_ => 1)
|
|
||||||
.Select(group => new
|
|
||||||
{
|
|
||||||
Count = group.Count(),
|
|
||||||
Bytes = group.Sum(intent => intent.ExpectedSizeBytes
|
|
||||||
?? (intent.Purpose == UploadIntentPurpose.TrackAudio ? audioDefault : pictureDefault))
|
|
||||||
})
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
if ((active?.Count ?? 0) + requiredIntentCount > config.MaxActiveUploadIntentsPerUser)
|
|
||||||
return Error.Validation(description: "Upload intent limit exceeded. Wait for existing uploads to complete or expire.");
|
|
||||||
|
|
||||||
if ((active?.Bytes ?? 0) + requiredBytes > config.MaxActiveUploadBytesPerUser)
|
|
||||||
return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire.");
|
|
||||||
|
|
||||||
return Result.Success;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ErrorOr<UploadIntent>> ValidateAndLoadAsync(
|
|
||||||
Guid intentId,
|
|
||||||
long userId,
|
|
||||||
UploadIntentPurpose purpose,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var intent = await database.UploadIntents
|
|
||||||
.FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken);
|
|
||||||
|
|
||||||
if (intent == null || intent.UserId != userId)
|
|
||||||
return AppErrors.NotFound("UploadIntent", intentId);
|
|
||||||
|
|
||||||
if (intent.Purpose != purpose)
|
|
||||||
return Error.Validation(description: "Upload intent was issued for a different purpose.");
|
|
||||||
|
|
||||||
if (intent.IsConsumed)
|
|
||||||
return AppErrors.Conflict("UploadIntent.Consumed", "Upload intent has already been consumed.");
|
|
||||||
|
|
||||||
if (intent.IsExpiredAt(DateTime.UtcNow))
|
|
||||||
return Error.Validation(description: "Upload intent has expired.");
|
|
||||||
|
|
||||||
var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken);
|
|
||||||
if (metadata == null)
|
|
||||||
return Error.NotFound("UploadIntent.ObjectMissing", "Uploaded object not found in storage. Upload the file first.");
|
|
||||||
|
|
||||||
if (metadata.ContentLength > config.MaxUploadBytes)
|
|
||||||
return Error.Validation(description: $"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes.");
|
|
||||||
|
|
||||||
return intent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
|
|
||||||
namespace Musify.Application.Shared
|
|
||||||
{
|
|
||||||
public static class AppErrors
|
|
||||||
{
|
|
||||||
public static Error NotFound(string entity, object id) =>
|
|
||||||
Error.NotFound($"{entity}.NotFound", $"{entity} {id} was not found.");
|
|
||||||
|
|
||||||
public static Error Forbidden(string entity, object id) =>
|
|
||||||
Error.Forbidden($"{entity}.Forbidden", $"You do not have permission to modify {entity} {id}.");
|
|
||||||
|
|
||||||
public static Error Conflict(string code, string description) =>
|
|
||||||
Error.Conflict(code, description);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace Musify.Application.Shared
|
|
||||||
{
|
|
||||||
public record ImageSize(
|
|
||||||
string SavePath,
|
|
||||||
int Width,
|
|
||||||
int Height);
|
|
||||||
|
|
||||||
public record ImageSizes(
|
|
||||||
ImageSize Small,
|
|
||||||
ImageSize Medium,
|
|
||||||
ImageSize Large);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Domain.Abstractions;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Shared
|
|
||||||
{
|
|
||||||
public static class OwnershipExtensions
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Loads an active, tracked entity owned by <paramref name="userId"/>: NotFound when it is missing
|
|
||||||
/// or not active, Forbidden when someone else owns it.
|
|
||||||
/// </summary>
|
|
||||||
public static async Task<ErrorOr<T>> FindOwnedAsync<T>(
|
|
||||||
this DbSet<T> set, Guid id, long userId, CancellationToken cancellationToken)
|
|
||||||
where T : class, IOwnedEntity, IHasLifeCycle
|
|
||||||
{
|
|
||||||
var entity = await set.SingleOrDefaultAsync(
|
|
||||||
e => e.Id == id && e.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
|
|
||||||
|
|
||||||
if (entity == null)
|
|
||||||
return AppErrors.NotFound(typeof(T).Name, id);
|
|
||||||
|
|
||||||
return entity.OwnerUserId == userId
|
|
||||||
? entity
|
|
||||||
: AppErrors.Forbidden(typeof(T).Name, id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace Musify.Application.Shared
|
|
||||||
{
|
|
||||||
public record PageRequest(int PageNumber = 1, int PageSize = 20)
|
|
||||||
{
|
|
||||||
public const int MaxPageSize = 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
public record PaginatedResponse<T>(
|
|
||||||
IReadOnlyList<T> Items,
|
|
||||||
int PageNumber,
|
|
||||||
int PageSize,
|
|
||||||
int PageCount,
|
|
||||||
int TotalItemCount,
|
|
||||||
bool HasNextPage,
|
|
||||||
bool HasPreviousPage);
|
|
||||||
|
|
||||||
public static class PaginationExtensions
|
|
||||||
{
|
|
||||||
/// <summary>Counts and pages the same (already filtered, ordered and projected) query.</summary>
|
|
||||||
public static async Task<PaginatedResponse<T>> ToPaginatedAsync<T>(
|
|
||||||
this IQueryable<T> query, PageRequest page, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var pageNumber = Math.Max(page.PageNumber, 1);
|
|
||||||
var pageSize = Math.Clamp(page.PageSize, 1, PageRequest.MaxPageSize);
|
|
||||||
|
|
||||||
var totalCount = await query.CountAsync(cancellationToken);
|
|
||||||
var items = await query
|
|
||||||
.Skip((pageNumber - 1) * pageSize)
|
|
||||||
.Take(pageSize)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var pageCount = (int)Math.Ceiling(totalCount / (double)pageSize);
|
|
||||||
|
|
||||||
return new PaginatedResponse<T>(
|
|
||||||
items,
|
|
||||||
pageNumber,
|
|
||||||
pageSize,
|
|
||||||
pageCount,
|
|
||||||
totalCount,
|
|
||||||
HasNextPage: pageNumber < pageCount,
|
|
||||||
HasPreviousPage: pageNumber > 1 && pageCount > 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Musify.Application.Shared
|
|
||||||
{
|
|
||||||
public static class TextNormalizer
|
|
||||||
{
|
|
||||||
public static string Normalize(string value) => value.Trim().ToUpperInvariant();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Application.Services;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
|
||||||
{
|
|
||||||
public record CreateTrackCommand(
|
|
||||||
long UserId,
|
|
||||||
string Title,
|
|
||||||
Guid PictureIntentId,
|
|
||||||
Guid AudioIntentId,
|
|
||||||
IReadOnlyCollection<Genre> Tags,
|
|
||||||
bool IsExplicit = false)
|
|
||||||
: ICommand<ErrorOr<TrackApplicationResponse>>;
|
|
||||||
|
|
||||||
public class CreateTrackCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
IEventBus eventBus,
|
|
||||||
UploadIntentValidator uploadIntentValidator,
|
|
||||||
ILogger<CreateTrackCommandHandler> logger,
|
|
||||||
TrackConfiguration trackConfiguration)
|
|
||||||
: ICommandHandler<CreateTrackCommand, ErrorOr<TrackApplicationResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var user = await database.Users
|
|
||||||
.SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} not found", request.UserId);
|
|
||||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var distinctTags = request.Tags.Distinct().ToList();
|
|
||||||
|
|
||||||
if (request.PictureIntentId == request.AudioIntentId)
|
|
||||||
return Error.Validation(description: "Picture and audio must use different upload intents.");
|
|
||||||
|
|
||||||
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
|
||||||
request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken);
|
|
||||||
if (pictureValidation.IsError)
|
|
||||||
return pictureValidation.Errors;
|
|
||||||
|
|
||||||
var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
|
||||||
request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken);
|
|
||||||
if (audioValidation.IsError)
|
|
||||||
return audioValidation.Errors;
|
|
||||||
|
|
||||||
var pictureIntent = pictureValidation.Value;
|
|
||||||
var audioIntent = audioValidation.Value;
|
|
||||||
|
|
||||||
var finalPictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
|
||||||
var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName);
|
|
||||||
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
|
|
||||||
|
|
||||||
var trackEntity = new Track
|
|
||||||
{
|
|
||||||
Title = request.Title.Trim(),
|
|
||||||
NormalizedTitle = TextNormalizer.Normalize(request.Title),
|
|
||||||
OwnerUserId = user.Id,
|
|
||||||
Owner = user,
|
|
||||||
IsExplicit = request.IsExplicit,
|
|
||||||
Pictures = new TrackPictures
|
|
||||||
{
|
|
||||||
OriginalName = pictureIntent.ObjectName,
|
|
||||||
SmallName = trackConfiguration.Routes.PresetSmallPicture,
|
|
||||||
MediumName = trackConfiguration.Routes.PresetMediumPicture,
|
|
||||||
LargeName = trackConfiguration.Routes.PresetLargePicture,
|
|
||||||
ProcessingStatus = ProcessingStatus.Pending
|
|
||||||
},
|
|
||||||
Audio = new TrackAudio
|
|
||||||
{
|
|
||||||
OriginalName = audioIntent.ObjectName,
|
|
||||||
TranscodeStatus = ProcessingStatus.Pending
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
trackEntity.Tags = distinctTags
|
|
||||||
.Select(tag => new TrackTag { TrackId = trackEntity.Id, Tag = tag })
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
|
||||||
|
|
||||||
|
|
||||||
var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes);
|
|
||||||
await eventBus.PublishAsync(
|
|
||||||
new CreateTrackResourcesEvent(
|
|
||||||
trackEntity.Id,
|
|
||||||
pictureIntent.Id,
|
|
||||||
audioIntent.Id,
|
|
||||||
pictureIntent.Bucket,
|
|
||||||
pictureIntent.Key,
|
|
||||||
finalPictureKey,
|
|
||||||
audioIntent.Key,
|
|
||||||
finalAudioKey,
|
|
||||||
audioProcessedFolderKey,
|
|
||||||
sizes),
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
|
||||||
{
|
|
||||||
public record DeleteStaleUncountedListensCommand : ICommand<int>;
|
|
||||||
|
|
||||||
public class DeleteStaleUncountedListensCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<DeleteStaleUncountedListensCommandHandler> logger)
|
|
||||||
: ICommandHandler<DeleteStaleUncountedListensCommand, int>
|
|
||||||
{
|
|
||||||
public async ValueTask<int> Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var cutoff = DateTime.UtcNow.AddDays(-ListeningRules.UncountedRetentionDays);
|
|
||||||
|
|
||||||
var stale = await database.ListeningHistories
|
|
||||||
.Where(l => !l.IsCounted && l.ListenedAt < cutoff)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (stale.Count == 0)
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
database.ListeningHistories.RemoveRange(stale);
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Deleted {Count} uncounted listens older than {Days} days", stale.Count, ListeningRules.UncountedRetentionDays);
|
|
||||||
return stale.Count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
|
||||||
{
|
|
||||||
public record DeleteTrackCommand(
|
|
||||||
long UserId,
|
|
||||||
Guid TrackId)
|
|
||||||
: ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class DeleteTrackCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
IEventBus eventBus,
|
|
||||||
ILogger<DeleteTrackCommandHandler> logger)
|
|
||||||
: ICommandHandler<DeleteTrackCommand, ErrorOr<Success>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(DeleteTrackCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken);
|
|
||||||
if (found.IsError)
|
|
||||||
return found.Errors;
|
|
||||||
|
|
||||||
var track = found.Value;
|
|
||||||
if (track.Audio.IsInProgress)
|
|
||||||
return AppErrors.Conflict("Track.Processing", "Track is currently being processed and cannot be deleted.");
|
|
||||||
|
|
||||||
track.LifeCycleStatus = LifeCycleStatus.Removing;
|
|
||||||
|
|
||||||
await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken);
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Marked track {TrackId} as removing", request.TrackId);
|
|
||||||
|
|
||||||
return Result.Success;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
|
||||||
{
|
|
||||||
public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery<TrackApplicationResponse?>;
|
|
||||||
|
|
||||||
public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetLastTrackListenedByUserIdQuery, TrackApplicationResponse?>
|
|
||||||
{
|
|
||||||
public async ValueTask<TrackApplicationResponse?> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return await database.ListeningHistories
|
|
||||||
.AsNoTracking()
|
|
||||||
.Include(t => t.Track.Owner)
|
|
||||||
.Include(t => t.Track.Tags)
|
|
||||||
.Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
|
||||||
.OrderByDescending(t => t.ListenedAt)
|
|
||||||
.ThenBy(t => t.Id)
|
|
||||||
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted)))
|
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
|
||||||
{
|
|
||||||
public record GetListeningHistoryQuery(long UserId) : IQuery<IReadOnlyList<TrackApplicationResponse>>;
|
|
||||||
|
|
||||||
public class GetListeningHistoryQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetListeningHistoryQuery, IReadOnlyList<TrackApplicationResponse>>
|
|
||||||
{
|
|
||||||
public const int ListSize = 15;
|
|
||||||
|
|
||||||
public async ValueTask<IReadOnlyList<TrackApplicationResponse>> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var recentTrackIds = await database.ListeningHistories
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(l => l.UserId == query.UserId && l.IsCounted)
|
|
||||||
.GroupBy(l => l.TrackId)
|
|
||||||
.OrderByDescending(g => g.Max(l => l.ListenedAt))
|
|
||||||
.ThenBy(g => g.Key)
|
|
||||||
.Select(g => g.Key)
|
|
||||||
.Take(ListSize)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var tracksById = await database.Tracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Active()
|
|
||||||
.Where(t => recentTrackIds.Contains(t.Id))
|
|
||||||
.SelectResponse()
|
|
||||||
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
|
||||||
|
|
||||||
return recentTrackIds
|
|
||||||
.Where(id => tracksById.ContainsKey(id))
|
|
||||||
.Select(id => tracksById[id])
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
|
||||||
{
|
|
||||||
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null)
|
|
||||||
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
|
|
||||||
|
|
||||||
public class GetTracksQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetTracksQuery, PaginatedResponse<TrackApplicationResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetTracksQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var tracksQuery = database.Tracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Active();
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
|
||||||
{
|
|
||||||
var normalizedName = TextNormalizer.Normalize(request.Name);
|
|
||||||
tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.Genre is { } genre)
|
|
||||||
tracksQuery = tracksQuery.Where(track => track.Tags.Any(tag => tag.Tag == genre));
|
|
||||||
|
|
||||||
return await tracksQuery
|
|
||||||
.OrderByDescending(track => track.CreatedAt)
|
|
||||||
.ThenBy(track => track.Id)
|
|
||||||
.SelectResponse()
|
|
||||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
|
||||||
{
|
|
||||||
public record GetTracksByUserIdQuery(
|
|
||||||
long UserId,
|
|
||||||
string? Name,
|
|
||||||
int PageNumber,
|
|
||||||
int PageSize)
|
|
||||||
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
|
||||||
|
|
||||||
public class GetTracksByUserIdQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetTracksByUserIdQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var tracksQuery = database.Tracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Active()
|
|
||||||
.Where(track => track.OwnerUserId == request.UserId);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
|
||||||
{
|
|
||||||
var normalizedName = TextNormalizer.Normalize(request.Name);
|
|
||||||
tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName));
|
|
||||||
}
|
|
||||||
|
|
||||||
return await tracksQuery
|
|
||||||
.OrderByDescending(track => track.CreatedAt)
|
|
||||||
.ThenBy(track => track.Id)
|
|
||||||
.SelectResponse()
|
|
||||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
namespace Musify.Application.Tracks
|
|
||||||
{
|
|
||||||
public static class ListeningRules
|
|
||||||
{
|
|
||||||
public const double CountedThresholdSeconds = 30;
|
|
||||||
public const double CountedThresholdFraction = 0.5;
|
|
||||||
public const double DurationToleranceSeconds = 2;
|
|
||||||
public const double ClockToleranceSeconds = 5;
|
|
||||||
public const int UncountedRetentionDays = 8;
|
|
||||||
|
|
||||||
public static double CountedThreshold(double durationSeconds) =>
|
|
||||||
Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
|
||||||
{
|
|
||||||
public record RecordListeningProgressCommand(
|
|
||||||
long UserId,
|
|
||||||
Guid ListenId,
|
|
||||||
double PlayedSeconds)
|
|
||||||
: ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class RecordListeningProgressCommandHandler(IDatabase database)
|
|
||||||
: ICommandHandler<RecordListeningProgressCommand, ErrorOr<Success>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var listen = await database.ListeningHistories
|
|
||||||
.Include(l => l.Track)
|
|
||||||
.SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken);
|
|
||||||
|
|
||||||
if (listen == null)
|
|
||||||
return AppErrors.NotFound("Listen", request.ListenId);
|
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds;
|
|
||||||
var clockCap = (now - listen.ListenedAt).TotalSeconds + ListeningRules.ClockToleranceSeconds;
|
|
||||||
var accepted = Math.Min(request.PlayedSeconds, Math.Min(durationCap, clockCap));
|
|
||||||
|
|
||||||
listen.PlayedSeconds = Math.Max(listen.PlayedSeconds ?? 0, accepted);
|
|
||||||
listen.LastProgressAt = now;
|
|
||||||
listen.IsCounted = listen.IsCounted
|
|
||||||
|| listen.PlayedSeconds >= ListeningRules.CountedThreshold(listen.Track.DurationSeconds);
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return Result.Success;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Services;
|
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
|
||||||
{
|
|
||||||
public record RequestTrackUploadUrlsCommand(
|
|
||||||
long UserId,
|
|
||||||
string PictureFileType,
|
|
||||||
string PictureContentType,
|
|
||||||
string AudioFileType,
|
|
||||||
string AudioContentType,
|
|
||||||
long? ExpectedPictureSizeBytes = null,
|
|
||||||
long? ExpectedAudioSizeBytes = null)
|
|
||||||
: ICommand<ErrorOr<TrackUploadUrlsResponse>>;
|
|
||||||
|
|
||||||
public class RequestTrackUploadUrlsCommandHandler(
|
|
||||||
UploadIntentIssuer issuer,
|
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
|
||||||
TrackConfiguration trackConfiguration,
|
|
||||||
UploadIntentConfiguration uploadIntentConfiguration)
|
|
||||||
: ICommandHandler<RequestTrackUploadUrlsCommand, ErrorOr<TrackUploadUrlsResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<TrackUploadUrlsResponse>> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var routes = trackConfiguration.Routes;
|
|
||||||
var issued = await issuer.IssueAsync(
|
|
||||||
request.UserId,
|
|
||||||
[
|
|
||||||
new UploadRequest(UploadIntentPurpose.TrackPicture, routes, request.PictureFileType, request.PictureContentType, request.ExpectedPictureSizeBytes),
|
|
||||||
new UploadRequest(UploadIntentPurpose.TrackAudio, routes, request.AudioFileType, request.AudioContentType, request.ExpectedAudioSizeBytes)
|
|
||||||
],
|
|
||||||
cancellationToken);
|
|
||||||
if (issued.IsError)
|
|
||||||
return issued.Errors;
|
|
||||||
|
|
||||||
var picture = issued.Value[0];
|
|
||||||
var audio = issued.Value[1];
|
|
||||||
|
|
||||||
return new TrackUploadUrlsResponse(
|
|
||||||
picture.IntentId,
|
|
||||||
audio.IntentId,
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
picture.Key,
|
|
||||||
picture.ObjectName,
|
|
||||||
picture.ContentType,
|
|
||||||
picture.UploadUrl,
|
|
||||||
audio.Key,
|
|
||||||
audio.ObjectName,
|
|
||||||
audio.ContentType,
|
|
||||||
audio.UploadUrl,
|
|
||||||
uploadIntentConfiguration.UploadUrlExpiresInSeconds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks.Responses
|
|
||||||
{
|
|
||||||
public record TrackApplicationResponse(
|
|
||||||
Guid Id,
|
|
||||||
string Title,
|
|
||||||
string? Artist,
|
|
||||||
ProcessingStatus AudioStatus,
|
|
||||||
double Duration,
|
|
||||||
int ListensCount,
|
|
||||||
DateTime CreatedAt,
|
|
||||||
DateTime UpdatedAt,
|
|
||||||
long OwnerUserId,
|
|
||||||
IReadOnlyCollection<Genre> Tags,
|
|
||||||
bool IsExplicit)
|
|
||||||
{
|
|
||||||
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
|
|
||||||
new(
|
|
||||||
track.Id,
|
|
||||||
track.Title,
|
|
||||||
track.Owner.Name,
|
|
||||||
track.Audio.TranscodeStatus,
|
|
||||||
track.DurationSeconds,
|
|
||||||
listensCount,
|
|
||||||
track.CreatedAt,
|
|
||||||
track.UpdatedAt,
|
|
||||||
track.OwnerUserId,
|
|
||||||
track.Tags.Select(tag => tag.Tag).ToList(),
|
|
||||||
track.IsExplicit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks.Responses
|
|
||||||
{
|
|
||||||
public static class TrackProjections
|
|
||||||
{
|
|
||||||
public static IQueryable<Track> Active(this IQueryable<Track> query) =>
|
|
||||||
query.Where(track => track.LifeCycleStatus == LifeCycleStatus.Active);
|
|
||||||
|
|
||||||
public static IQueryable<TrackApplicationResponse> SelectResponse(this IQueryable<Track> query) =>
|
|
||||||
query.Select(track => new TrackApplicationResponse(
|
|
||||||
track.Id,
|
|
||||||
track.Title,
|
|
||||||
track.Owner.Name,
|
|
||||||
track.Audio.TranscodeStatus,
|
|
||||||
track.DurationSeconds,
|
|
||||||
track.ListeningHistories.Count(listen => listen.IsCounted),
|
|
||||||
track.CreatedAt,
|
|
||||||
track.UpdatedAt,
|
|
||||||
track.OwnerUserId,
|
|
||||||
track.Tags.Select(tag => tag.Tag).ToList(),
|
|
||||||
track.IsExplicit));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
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.Shared;
|
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
|
||||||
{
|
|
||||||
/// <summary>Starts a listen: records it for signed-in users and issues the stream ticket.</summary>
|
|
||||||
public record StartListeningCommand(Guid TrackId, long? UserId)
|
|
||||||
: ICommand<ErrorOr<TrackStreamResponse>>;
|
|
||||||
|
|
||||||
public class StartListeningCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
TrackStreamIssuer streamIssuer,
|
|
||||||
PlaybackConfiguration playbackConfiguration,
|
|
||||||
ILogger<StartListeningCommandHandler> logger)
|
|
||||||
: ICommandHandler<StartListeningCommand, ErrorOr<TrackStreamResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<TrackStreamResponse>> Handle(StartListeningCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (request.UserId == null && !playbackConfiguration.AllowAnonymousListening)
|
|
||||||
return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening.");
|
|
||||||
|
|
||||||
var track = await database.Tracks.AsNoTracking()
|
|
||||||
.Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active)
|
|
||||||
.Select(t => new { t.Id, t.Audio })
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (track == null)
|
|
||||||
return AppErrors.NotFound("Track", request.TrackId);
|
|
||||||
|
|
||||||
if (!track.Audio.IsProcessed)
|
|
||||||
return AppErrors.Conflict("Track.AudioNotReady", "Track audio is not available for streaming yet.");
|
|
||||||
|
|
||||||
Guid? listenId = null;
|
|
||||||
if (request.UserId is { } userId)
|
|
||||||
{
|
|
||||||
var listen = new ListeningHistory { UserId = userId, TrackId = track.Id };
|
|
||||||
await database.ListeningHistories.AddAsync(listen, cancellationToken);
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
listenId = listen.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString(CultureInfo.InvariantCulture) ?? "(anonymous)");
|
|
||||||
|
|
||||||
return streamIssuer.Issue(track.Audio.FolderName, request.UserId, listenId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.UploadIntents
|
|
||||||
{
|
|
||||||
/// <summary>Deletes temporary upload objects that nobody claimed within the retention window.</summary>
|
|
||||||
public record DeleteStaleTempUploadsCommand : ICommand<int>;
|
|
||||||
|
|
||||||
public class DeleteStaleTempUploadsCommandHandler(
|
|
||||||
IStorageService storageService,
|
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
|
||||||
UploadIntentConfiguration uploadIntentConfiguration,
|
|
||||||
ILogger<DeleteStaleTempUploadsCommandHandler> logger)
|
|
||||||
: ICommandHandler<DeleteStaleTempUploadsCommand, int>
|
|
||||||
{
|
|
||||||
public async ValueTask<int> Handle(DeleteStaleTempUploadsCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var cutOff = DateTime.UtcNow.AddDays(-uploadIntentConfiguration.TempUploadsRetentionDays);
|
|
||||||
var deleted = 0;
|
|
||||||
|
|
||||||
await foreach (var (key, lastModified) in storageService.ListObjectsAsync(
|
|
||||||
storageConfiguration.Bucket, uploadIntentConfiguration.TempRootPrefix, cancellationToken))
|
|
||||||
{
|
|
||||||
if (lastModified >= cutOff)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
await storageService.RemoveFileAsync(storageConfiguration.Bucket, key, cancellationToken);
|
|
||||||
deleted++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deleted > 0)
|
|
||||||
logger.LogInformation("Deleted {Deleted} orphaned temp uploads", deleted);
|
|
||||||
|
|
||||||
return deleted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.UploadIntents
|
|
||||||
{
|
|
||||||
/// <summary>Marks overdue upload intents as expired and deletes the ones expired for longer than the retention window.</summary>
|
|
||||||
public record ExpireUploadIntentsCommand : ICommand<int>;
|
|
||||||
|
|
||||||
public class ExpireUploadIntentsCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
UploadIntentConfiguration uploadIntentConfiguration,
|
|
||||||
ILogger<ExpireUploadIntentsCommandHandler> logger)
|
|
||||||
: ICommandHandler<ExpireUploadIntentsCommand, int>
|
|
||||||
{
|
|
||||||
public async ValueTask<int> Handle(ExpireUploadIntentsCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
|
|
||||||
var expiredIntents = await database.UploadIntents
|
|
||||||
.Where(intent => intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt < now)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
foreach (var intent in expiredIntents)
|
|
||||||
intent.Status = UploadIntentStatus.Expired;
|
|
||||||
|
|
||||||
var retentionCutoff = now.AddDays(-uploadIntentConfiguration.ExpiredIntentsRetentionDays);
|
|
||||||
var oldExpiredIntents = await database.UploadIntents
|
|
||||||
.Where(intent => intent.Status == UploadIntentStatus.Expired && intent.ExpiresAt < retentionCutoff)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
database.UploadIntents.RemoveRange(oldExpiredIntents);
|
|
||||||
|
|
||||||
if (expiredIntents.Count == 0 && oldExpiredIntents.Count == 0)
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
logger.LogInformation("Marked {Expired} upload intents as expired and deleted {Deleted} old ones",
|
|
||||||
expiredIntents.Count, oldExpiredIntents.Count);
|
|
||||||
|
|
||||||
return expiredIntents.Count + oldExpiredIntents.Count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
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)
|
|
||||||
return AppErrors.NotFound("User", request.FollowedId);
|
|
||||||
|
|
||||||
var alreadyFollowing = await database.UserFollows
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken);
|
|
||||||
if (alreadyFollowing)
|
|
||||||
return Result.Success;
|
|
||||||
|
|
||||||
await database.UserFollows.AddAsync(new UserFollow
|
|
||||||
{
|
|
||||||
FollowerId = request.FollowerId,
|
|
||||||
FollowedId = request.FollowedId
|
|
||||||
}, cancellationToken);
|
|
||||||
|
|
||||||
// A concurrent double click can hit the unique index; already following is a successful no-op.
|
|
||||||
var saved = await database.TrySaveChangesAsync(
|
|
||||||
AppErrors.Conflict("UserFollow.Duplicate", "Already following this user."), cancellationToken);
|
|
||||||
if (saved.IsError)
|
|
||||||
return Result.Success;
|
|
||||||
|
|
||||||
logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId);
|
|
||||||
|
|
||||||
return Result.Success;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Users
|
|
||||||
{
|
|
||||||
public static class FollowVisibility
|
|
||||||
{
|
|
||||||
public static async Task<bool> CanViewFollowersAsync(
|
|
||||||
IDatabase database,
|
|
||||||
long? viewerId,
|
|
||||||
long userId,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (viewerId is not { } viewer)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
if (viewer == userId)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
var mutualRows = await database.UserFollows
|
|
||||||
.AsNoTracking()
|
|
||||||
.CountAsync(f =>
|
|
||||||
(f.FollowerId == viewer && f.FollowedId == userId) ||
|
|
||||||
(f.FollowerId == userId && f.FollowedId == viewer),
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return mutualRows == 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Users.Responses;
|
|
||||||
|
|
||||||
namespace Musify.Application.Users
|
|
||||||
{
|
|
||||||
public record GetListeningStatsQuery(long UserId) : IQuery<ListeningStatsResponse>;
|
|
||||||
|
|
||||||
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
|
|
||||||
.Where(l => l.IsCounted)
|
|
||||||
.Select(l => l.TrackId)
|
|
||||||
.Distinct()
|
|
||||||
.CountAsync(cancellationToken);
|
|
||||||
|
|
||||||
var secondsThisWeek = await thisWeekHistory
|
|
||||||
.Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => l.PlayedSeconds ?? (l.IsCounted ? t.DurationSeconds : 0))
|
|
||||||
.SumAsync(cancellationToken);
|
|
||||||
|
|
||||||
var listenedDates = await database.ListeningHistories
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(l => l.UserId == request.UserId && l.IsCounted)
|
|
||||||
.Select(l => l.ListenedAt.Date)
|
|
||||||
.Distinct()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var listenedDateSet = listenedDates.ToHashSet();
|
|
||||||
var streakDays = 0;
|
|
||||||
var day = DateTime.UtcNow.Date;
|
|
||||||
if (!listenedDateSet.Contains(day))
|
|
||||||
day = day.AddDays(-1);
|
|
||||||
|
|
||||||
while (listenedDateSet.Contains(day))
|
|
||||||
{
|
|
||||||
streakDays++;
|
|
||||||
day = day.AddDays(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ListeningStatsResponse(tracksThisWeek, (int)Math.Round(secondsThisWeek), streakDays);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.Users.Responses;
|
|
||||||
|
|
||||||
namespace Musify.Application.Users
|
|
||||||
{
|
|
||||||
public record GetUserFollowersQuery(long? ViewerId, long UserId, int PageNumber, int PageSize)
|
|
||||||
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
|
|
||||||
|
|
||||||
public class GetUserFollowersQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetUserFollowersQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken))
|
|
||||||
return AppErrors.NotFound("User", request.UserId);
|
|
||||||
|
|
||||||
if (!await FollowVisibility.CanViewFollowersAsync(database, request.ViewerId, request.UserId, cancellationToken))
|
|
||||||
return AppErrors.Forbidden("User", request.UserId);
|
|
||||||
|
|
||||||
var viewerId = request.ViewerId;
|
|
||||||
|
|
||||||
return await database.UserFollows
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(f => f.FollowedId == request.UserId)
|
|
||||||
.OrderByDescending(f => f.CreatedAt)
|
|
||||||
.ThenBy(f => f.Id)
|
|
||||||
.Select(f => new UserSummaryResponse(
|
|
||||||
f.Follower.Id,
|
|
||||||
f.Follower.Name,
|
|
||||||
f.Follower.FirstName,
|
|
||||||
f.Follower.SecondName,
|
|
||||||
f.Follower.ProfilePictureUrl,
|
|
||||||
viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowerId)))
|
|
||||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.Users.Responses;
|
|
||||||
|
|
||||||
namespace Musify.Application.Users
|
|
||||||
{
|
|
||||||
public record GetUserFollowingQuery(long? ViewerId, long UserId, int PageNumber, int PageSize)
|
|
||||||
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
|
|
||||||
|
|
||||||
public class GetUserFollowingQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetUserFollowingQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken))
|
|
||||||
return AppErrors.NotFound("User", request.UserId);
|
|
||||||
|
|
||||||
var viewerId = request.ViewerId;
|
|
||||||
|
|
||||||
return await database.UserFollows
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(f => f.FollowerId == request.UserId)
|
|
||||||
.OrderByDescending(f => f.CreatedAt)
|
|
||||||
.ThenBy(f => f.Id)
|
|
||||||
.Select(f => new UserSummaryResponse(
|
|
||||||
f.Followed.Id,
|
|
||||||
f.Followed.Name,
|
|
||||||
f.Followed.FirstName,
|
|
||||||
f.Followed.SecondName,
|
|
||||||
f.Followed.ProfilePictureUrl,
|
|
||||||
viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowedId)))
|
|
||||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.Users.Responses;
|
|
||||||
|
|
||||||
namespace Musify.Application.Users
|
|
||||||
{
|
|
||||||
public record GetUserProfileQuery(long UserId, long? ViewerId = null) : IQuery<ErrorOr<UserProfileResponse>>;
|
|
||||||
|
|
||||||
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 AppErrors.NotFound("User", request.UserId);
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
var isFollowing = request.ViewerId is { } viewerId && viewerId != request.UserId &&
|
|
||||||
await database.UserFollows
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(f => f.FollowerId == viewerId && f.FollowedId == request.UserId, cancellationToken);
|
|
||||||
|
|
||||||
var canViewFollowers = await FollowVisibility.CanViewFollowersAsync(
|
|
||||||
database, request.ViewerId, request.UserId, cancellationToken);
|
|
||||||
|
|
||||||
return new UserProfileResponse(
|
|
||||||
user.Id,
|
|
||||||
user.Name,
|
|
||||||
user.FirstName,
|
|
||||||
user.SecondName,
|
|
||||||
user.ProfilePictureUrl,
|
|
||||||
user.CreatedAt,
|
|
||||||
followersCount,
|
|
||||||
followingCount,
|
|
||||||
isFollowing,
|
|
||||||
canViewFollowers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.Users.Responses;
|
|
||||||
|
|
||||||
namespace Musify.Application.Users
|
|
||||||
{
|
|
||||||
public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null)
|
|
||||||
: IQuery<PaginatedResponse<UserSummaryResponse>>;
|
|
||||||
|
|
||||||
public class GetUsersQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetUsersQuery, PaginatedResponse<UserSummaryResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<PaginatedResponse<UserSummaryResponse>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var usersQuery = database.Users
|
|
||||||
.AsNoTracking();
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.UsernameSearch))
|
|
||||||
{
|
|
||||||
var normalizedUsername = TextNormalizer.Normalize(request.UsernameSearch);
|
|
||||||
usersQuery = usersQuery.Where(user => user.NormalizedName.Contains(normalizedUsername));
|
|
||||||
}
|
|
||||||
|
|
||||||
var viewerId = request.ViewerId;
|
|
||||||
|
|
||||||
return await usersQuery
|
|
||||||
.OrderBy(user => user.Name)
|
|
||||||
.ThenBy(user => user.Id)
|
|
||||||
.Select(user => new UserSummaryResponse(
|
|
||||||
user.Id,
|
|
||||||
user.Name,
|
|
||||||
user.FirstName,
|
|
||||||
user.SecondName,
|
|
||||||
user.ProfilePictureUrl,
|
|
||||||
viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == user.Id)))
|
|
||||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user