Compare commits
236
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,115 +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: Migrate database, generate keys
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$DEPLOY_PATH"
|
|
||||||
$COMPOSE up -d wait-for-postgres keys-init 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.
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
**/bin/
|
|
||||||
**/obj/
|
|
||||||
**/.vs/
|
|
||||||
**/.idea/
|
|
||||||
.git/
|
|
||||||
.github/
|
|
||||||
.agents/
|
|
||||||
.claude/
|
|
||||||
**/DesignSettings.json
|
|
||||||
*.user
|
|
||||||
# Test projects: never shipped, no app Dockerfile needs them in its build context.
|
|
||||||
Tests/
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
|
||||||
{
|
|
||||||
public record AddTrackToAlbumCommand(long UserId, Guid AlbumId, Guid TrackId)
|
|
||||||
: ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class AddTrackToAlbumCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<AddTrackToAlbumCommandHandler> logger)
|
|
||||||
: ICommandHandler<AddTrackToAlbumCommand, ErrorOr<Success>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var album = await database.Albums
|
|
||||||
.AsNoTracking()
|
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
|
||||||
if (album == null)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (album.OwnerUserId != request.UserId)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var track = await database.Tracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
|
|
||||||
if (track == null)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
|
||||||
return Error.NotFound(description: $"Track {request.TrackId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (track.OwnerUserId != request.UserId)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var alreadyAdded = await database.AlbumHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.TrackId == request.TrackId, cancellationToken);
|
|
||||||
if (alreadyAdded)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Track {TrackId} already in album {AlbumId}", request.TrackId, request.AlbumId);
|
|
||||||
return Error.Conflict(description: "Track is already in the album.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var albumTracksQuery = database.AlbumHasTracks
|
|
||||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
|
|
||||||
|
|
||||||
var hasTracks = await albumTracksQuery.AnyAsync(cancellationToken);
|
|
||||||
var nextTrackNumber = hasTracks
|
|
||||||
? await albumTracksQuery.MaxAsync(albumTrack => albumTrack.TrackNumber, cancellationToken) + 1
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
|
|
||||||
{
|
|
||||||
AlbumId = request.AlbumId,
|
|
||||||
TrackId = request.TrackId,
|
|
||||||
TrackNumber = nextTrackNumber
|
|
||||||
}, cancellationToken);
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Added track {TrackId} to album {AlbumId} as number {TrackNumber}",
|
|
||||||
request.TrackId, request.AlbumId, nextTrackNumber);
|
|
||||||
|
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Application.Albums.Responses;
|
|
||||||
using Musify.Application.Services;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
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,
|
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
|
||||||
AlbumConfiguration albumConfiguration,
|
|
||||||
UploadIntentConfiguration uploadIntentConfiguration)
|
|
||||||
: 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)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} not found", request.UserId);
|
|
||||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync(
|
|
||||||
uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken);
|
|
||||||
if (pictureIntent.IsError)
|
|
||||||
return pictureIntent.Errors;
|
|
||||||
|
|
||||||
var intent = pictureIntent.Value;
|
|
||||||
var title = request.Title.Trim();
|
|
||||||
|
|
||||||
var album = new Album
|
|
||||||
{
|
|
||||||
OwnerUserId = request.UserId,
|
|
||||||
Title = title,
|
|
||||||
NormalizedTitle = title.ToUpperInvariant(),
|
|
||||||
Description = request.Description,
|
|
||||||
ReleaseYear = request.ReleaseYear,
|
|
||||||
Pictures = new AlbumPictures
|
|
||||||
{
|
|
||||||
OriginalName = intent.ObjectName,
|
|
||||||
SmallName = intent.ObjectName,
|
|
||||||
MediumName = intent.ObjectName,
|
|
||||||
LargeName = intent.ObjectName
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await database.Albums.AddAsync(album, cancellationToken);
|
|
||||||
|
|
||||||
var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName);
|
|
||||||
var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken);
|
|
||||||
if (publishResult.IsError)
|
|
||||||
return publishResult.Errors;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to save album for user {UserId}", request.UserId);
|
|
||||||
return Error.Failure(description: $"Failed to create album for user {request.UserId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId);
|
|
||||||
|
|
||||||
return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<ErrorOr<Success>> PublishCreateAlbumEventAsync(
|
|
||||||
Guid albumId,
|
|
||||||
UploadIntent pictureIntent,
|
|
||||||
string finalPictureKey,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await eventBus.PublishAsync(
|
|
||||||
new CreateAlbumResourcesEvent(
|
|
||||||
albumId,
|
|
||||||
pictureIntent.Id,
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
pictureIntent.Key,
|
|
||||||
finalPictureKey,
|
|
||||||
new ImageSize(
|
|
||||||
albumConfiguration.Routes.SmallPicturesPath,
|
|
||||||
albumConfiguration.PicturesSizes.SmallPictureWidth,
|
|
||||||
albumConfiguration.PicturesSizes.SmallPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
albumConfiguration.Routes.MediumPicturesPath,
|
|
||||||
albumConfiguration.PicturesSizes.MediumPictureWidth,
|
|
||||||
albumConfiguration.PicturesSizes.MediumPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
albumConfiguration.Routes.LargePicturesPath,
|
|
||||||
albumConfiguration.PicturesSizes.LargePictureWidth,
|
|
||||||
albumConfiguration.PicturesSizes.LargePictureHeight)),
|
|
||||||
cancellationToken);
|
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to publish create album event for album {AlbumId}", albumId);
|
|
||||||
return Error.Failure(description: $"Failed to publish create album event for album {albumId}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
|
||||||
{
|
|
||||||
public record DeleteAlbumCommand(long UserId, Guid AlbumId)
|
|
||||||
: ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class DeleteAlbumCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<DeleteAlbumCommandHandler> logger)
|
|
||||||
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var album = await database.Albums
|
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
|
||||||
if (album == null)
|
|
||||||
{
|
|
||||||
logger.LogDebug("Album {AlbumId} not found", request.AlbumId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (album.OwnerUserId != request.UserId)
|
|
||||||
{
|
|
||||||
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
database.Albums.Remove(album);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to delete album {AlbumId}", request.AlbumId);
|
|
||||||
return Error.Failure(description: $"Failed to delete album {request.AlbumId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogInformation("Deleted album {AlbumId} for user {UserId}", request.AlbumId, request.UserId);
|
|
||||||
|
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Albums.Responses;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
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()
|
|
||||||
.Where(a => a.Id == request.Id)
|
|
||||||
.Select(a => new
|
|
||||||
{
|
|
||||||
Album = a,
|
|
||||||
TrackCount = a.AlbumTracks.Count,
|
|
||||||
CoverTrackIds = a.AlbumTracks
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (album == null)
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using MimeMapping;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
|
||||||
{
|
|
||||||
public record GetAlbumCoverQuery(Guid AlbumId, string Size)
|
|
||||||
: IQuery<ErrorOr<AlbumCoverLocation>>;
|
|
||||||
|
|
||||||
public record AlbumCoverLocation(string Bucket, string Key, string ContentType);
|
|
||||||
|
|
||||||
public class GetAlbumCoverQueryHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
|
||||||
AlbumConfiguration albumConfiguration)
|
|
||||||
: IQueryHandler<GetAlbumCoverQuery, ErrorOr<AlbumCoverLocation>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<AlbumCoverLocation>> Handle(GetAlbumCoverQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var pictures = await database.Albums.AsNoTracking()
|
|
||||||
.Where(a => a.Id == request.AlbumId)
|
|
||||||
.Select(a => a.Pictures)
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (pictures == null)
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
var routes = albumConfiguration.Routes;
|
|
||||||
var (name, key) = request.Size.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
|
|
||||||
"large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
|
|
||||||
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
|
|
||||||
};
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
var contentType = MimeUtility.GetMimeMapping(name);
|
|
||||||
return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? BuildKey(Func<string, string> builder, string? name)
|
|
||||||
=> string.IsNullOrEmpty(name) ? null : builder(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.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()
|
|
||||||
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
|
|
||||||
if (!albumExists)
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
var tracksQuery = database.AlbumHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Include(albumTrack => albumTrack.Track.Owner)
|
|
||||||
.Include(albumTrack => albumTrack.Track.Tags)
|
|
||||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
|
|
||||||
|
|
||||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
|
||||||
|
|
||||||
var pagedEntities = await tracksQuery
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count })
|
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
|
||||||
|
|
||||||
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
|
|
||||||
pagedEntities.Select(entry => TrackApplicationResponse.FromEntity(entry.Track, entry.ListensCount)),
|
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
|
||||||
|
|
||||||
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Albums.Responses;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
|
||||||
{
|
|
||||||
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
|
|
||||||
: IQuery<ErrorOr<AlbumsSearchResponse>>;
|
|
||||||
|
|
||||||
public class GetAlbumsQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetAlbumsQuery, ErrorOr<AlbumsSearchResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<AlbumsSearchResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var albumsQuery = database.Albums
|
|
||||||
.AsNoTracking()
|
|
||||||
.AsQueryable();
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Title))
|
|
||||||
{
|
|
||||||
var normalizedTitle = request.Title.Trim().ToUpperInvariant();
|
|
||||||
albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle));
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalCount = await albumsQuery.CountAsync(cancellationToken);
|
|
||||||
|
|
||||||
var pagedEntities = await albumsQuery
|
|
||||||
.OrderByDescending(album => album.CreatedAt)
|
|
||||||
.Select(album => new
|
|
||||||
{
|
|
||||||
Album = album,
|
|
||||||
TrackCount = album.AlbumTracks.Count,
|
|
||||||
CoverTrackIds = album.AlbumTracks
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
|
||||||
|
|
||||||
var items = pagedEntities
|
|
||||||
.Select(entry => AlbumSearchItemResponse.FromAlbum(
|
|
||||||
AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return new AlbumsSearchResponse(
|
|
||||||
items,
|
|
||||||
pagedEntities.PageNumber,
|
|
||||||
pagedEntities.PageSize,
|
|
||||||
pagedEntities.PageCount,
|
|
||||||
pagedEntities.TotalItemCount,
|
|
||||||
pagedEntities.HasPreviousPage,
|
|
||||||
pagedEntities.HasNextPage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Albums.Responses;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
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 Error.NotFound(description: $"User {request.UserId} not found");
|
|
||||||
|
|
||||||
var albumsQuery = database.Albums
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(album => album.OwnerUserId == request.UserId);
|
|
||||||
|
|
||||||
var totalCount = await albumsQuery.CountAsync(cancellationToken);
|
|
||||||
|
|
||||||
var pagedEntities = await albumsQuery
|
|
||||||
.OrderByDescending(album => album.CreatedAt)
|
|
||||||
.Select(album => new
|
|
||||||
{
|
|
||||||
Album = album,
|
|
||||||
TrackCount = album.AlbumTracks.Count,
|
|
||||||
CoverTrackIds = album.AlbumTracks
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
|
||||||
|
|
||||||
var pagedAlbums = new StaticPagedList<AlbumApplicationResponse>(
|
|
||||||
pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)),
|
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
|
||||||
|
|
||||||
return PaginatedResponse<AlbumApplicationResponse>.FromPagedList(pagedAlbums);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
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<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>;
|
|
||||||
|
|
||||||
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetRecentlyListenedAlbumsQuery, ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var listenedTrackIds = database.ListeningHistories
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(history => history.UserId == request.UserId);
|
|
||||||
|
|
||||||
var ranked = await database.AlbumHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Join(
|
|
||||||
listenedTrackIds,
|
|
||||||
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)
|
|
||||||
.Take(request.Limit)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (ranked.Count == 0)
|
|
||||||
return new List<AlbumApplicationResponse>();
|
|
||||||
|
|
||||||
var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
|
|
||||||
|
|
||||||
var albums = await database.Albums
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(album => albumIds.Contains(album.Id))
|
|
||||||
.Select(album => new
|
|
||||||
{
|
|
||||||
Album = album,
|
|
||||||
TrackCount = album.AlbumTracks.Count,
|
|
||||||
CoverTrackIds = album.AlbumTracks
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.ToDictionaryAsync(entry => entry.Album.Id, cancellationToken);
|
|
||||||
|
|
||||||
return ranked
|
|
||||||
.Where(entry => albums.ContainsKey(entry.AlbumId))
|
|
||||||
.Select(entry => albums[entry.AlbumId])
|
|
||||||
.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds))
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
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
|
|
||||||
.AsNoTracking()
|
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
|
||||||
if (album == null)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (album.OwnerUserId != request.UserId)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var albumTracks = await database.AlbumHasTracks
|
|
||||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId);
|
|
||||||
if (link == null)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Track {TrackId} not in album {AlbumId}", request.TrackId, request.AlbumId);
|
|
||||||
return Error.NotFound(description: "Track is not in the album.");
|
|
||||||
}
|
|
||||||
|
|
||||||
database.AlbumHasTracks.Remove(link);
|
|
||||||
|
|
||||||
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 new Success();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Albums.Responses;
|
|
||||||
using Musify.Application.Services;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
|
||||||
{
|
|
||||||
public record RequestAlbumPictureUploadCommand(
|
|
||||||
long UserId,
|
|
||||||
string FileType,
|
|
||||||
string ContentType,
|
|
||||||
long? ExpectedSizeBytes = null)
|
|
||||||
: ICommand<ErrorOr<AlbumPictureUploadResponse>>;
|
|
||||||
|
|
||||||
public class RequestAlbumPictureUploadCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
UploadIntentValidator uploadIntentValidator,
|
|
||||||
IStorageService storageService,
|
|
||||||
ILogger<RequestAlbumPictureUploadCommandHandler> logger,
|
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
|
||||||
AlbumConfiguration albumConfiguration,
|
|
||||||
UploadIntentConfiguration uploadIntentConfiguration)
|
|
||||||
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<AlbumPictureUploadResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<AlbumPictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var userExists = await database.Users
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
|
|
||||||
if (!userExists)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} not found", request.UserId);
|
|
||||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var effectiveSizeBytes = request.ExpectedSizeBytes
|
|
||||||
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
|
||||||
|
|
||||||
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
|
|
||||||
|
|
||||||
var tempKey = albumConfiguration.Routes.BuildTempPicturePath(
|
|
||||||
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var uploadUrl = await storageService.GetUploadUrlAsync(
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
tempKey,
|
|
||||||
request.ContentType,
|
|
||||||
TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
await using var transaction = await database.BeginTransactionAsync(
|
|
||||||
System.Data.IsolationLevel.Serializable, cancellationToken);
|
|
||||||
|
|
||||||
var quotaCheck = await uploadIntentValidator.CheckQuotaAsync(
|
|
||||||
uploadIntentConfiguration,
|
|
||||||
request.UserId, effectiveSizeBytes, 1, cancellationToken);
|
|
||||||
if (quotaCheck.IsError)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
|
|
||||||
return quotaCheck.Errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
var intent = new UploadIntent
|
|
||||||
{
|
|
||||||
UserId = request.UserId,
|
|
||||||
Bucket = storageConfiguration.Bucket,
|
|
||||||
Key = tempKey,
|
|
||||||
ObjectName = objectName,
|
|
||||||
ContentType = request.ContentType,
|
|
||||||
ExpectedSizeBytes = effectiveSizeBytes,
|
|
||||||
Purpose = UploadIntentPurpose.AlbumPicture,
|
|
||||||
ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
|
|
||||||
};
|
|
||||||
|
|
||||||
await database.UploadIntents.AddAsync(intent, cancellationToken);
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
await transaction.CommitAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (AlbumPicture, temp key: {Key})", intent.Id, request.UserId, tempKey);
|
|
||||||
|
|
||||||
return new AlbumPictureUploadResponse(
|
|
||||||
intent.Id,
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
tempKey,
|
|
||||||
objectName,
|
|
||||||
request.ContentType,
|
|
||||||
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
|
|
||||||
uploadUrl);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to generate album picture upload URL for user {UserId}", request.UserId);
|
|
||||||
return Error.Failure(description: "Failed to generate upload URL");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Application.Serialization;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums.Responses
|
|
||||||
{
|
|
||||||
public record AlbumApplicationResponse(
|
|
||||||
Guid Id,
|
|
||||||
string Title,
|
|
||||||
string? Description,
|
|
||||||
int? ReleaseYear,
|
|
||||||
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
|
|
||||||
int TrackCount,
|
|
||||||
string? SmallImageKeyName,
|
|
||||||
string? MediumImageKeyName,
|
|
||||||
string? LargeImageKeyName,
|
|
||||||
DateTime CreatedAt,
|
|
||||||
DateTime UpdatedAt,
|
|
||||||
IReadOnlyList<Guid> CoverTrackIds)
|
|
||||||
{
|
|
||||||
public const int CoverTrackCount = 4;
|
|
||||||
|
|
||||||
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,11 +0,0 @@
|
|||||||
namespace Musify.Application.Albums.Responses
|
|
||||||
{
|
|
||||||
public record AlbumPictureUploadResponse(
|
|
||||||
Guid IntentId,
|
|
||||||
string Bucket,
|
|
||||||
string Key,
|
|
||||||
string PictureName,
|
|
||||||
string ContentType,
|
|
||||||
int ExpiresInSeconds,
|
|
||||||
string UploadUrl);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Musify.Application.Albums.Responses
|
|
||||||
{
|
|
||||||
public record AlbumSearchItemResponse(AlbumApplicationResponse Album)
|
|
||||||
{
|
|
||||||
public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) => new(album);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace Musify.Application.Albums.Responses
|
|
||||||
{
|
|
||||||
|
|
||||||
public record AlbumsSearchResponse(
|
|
||||||
IReadOnlyList<AlbumSearchItemResponse> Items,
|
|
||||||
int PageNumber,
|
|
||||||
int PageSize,
|
|
||||||
int PageCount,
|
|
||||||
int TotalItemCount,
|
|
||||||
bool HasPreviousPage,
|
|
||||||
bool HasNextPage);
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Application.Albums.Responses;
|
|
||||||
using Musify.Application.Services;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
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,
|
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
|
||||||
AlbumConfiguration albumConfiguration,
|
|
||||||
UploadIntentConfiguration uploadIntentConfiguration)
|
|
||||||
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var album = await database.Albums
|
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
|
||||||
if (album == null)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (album.OwnerUserId != request.UserId)
|
|
||||||
{
|
|
||||||
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var title = request.Title.Trim();
|
|
||||||
|
|
||||||
album.Title = title;
|
|
||||||
album.NormalizedTitle = title.ToUpperInvariant();
|
|
||||||
album.Description = request.Description;
|
|
||||||
album.ReleaseYear = request.ReleaseYear;
|
|
||||||
|
|
||||||
UploadIntent? pictureIntent = null;
|
|
||||||
string? finalPictureKey = null;
|
|
||||||
|
|
||||||
if (request.NewPictureIntentId.HasValue)
|
|
||||||
{
|
|
||||||
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
|
||||||
uploadIntentConfiguration,
|
|
||||||
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
|
|
||||||
if (validation.IsError)
|
|
||||||
return validation.Errors;
|
|
||||||
|
|
||||||
pictureIntent = validation.Value;
|
|
||||||
album.Pictures = new AlbumPictures
|
|
||||||
{
|
|
||||||
OriginalName = pictureIntent.ObjectName,
|
|
||||||
SmallName = pictureIntent.ObjectName,
|
|
||||||
MediumName = pictureIntent.ObjectName,
|
|
||||||
LargeName = pictureIntent.ObjectName
|
|
||||||
};
|
|
||||||
finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pictureIntent != null && finalPictureKey != null)
|
|
||||||
{
|
|
||||||
var publishResult = await PublishUpdateAlbumPictureSourceEventAsync(
|
|
||||||
album.Id, pictureIntent, finalPictureKey, cancellationToken);
|
|
||||||
if (publishResult.IsError)
|
|
||||||
return publishResult.Errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to update album {AlbumId}", request.AlbumId);
|
|
||||||
return Error.Failure(description: $"Failed to update album {request.AlbumId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var albumTracks = database.AlbumHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(albumTrack => albumTrack.AlbumId == album.Id);
|
|
||||||
|
|
||||||
var trackCount = await albumTracks.CountAsync(cancellationToken);
|
|
||||||
var coverTrackIds = await albumTracks
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Updated album {AlbumId}", album.Id);
|
|
||||||
return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<ErrorOr<Success>> PublishUpdateAlbumPictureSourceEventAsync(
|
|
||||||
Guid albumId,
|
|
||||||
UploadIntent pictureIntent,
|
|
||||||
string finalPictureKey,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await eventBus.PublishAsync(
|
|
||||||
new UpdateAlbumPictureSourceEvent(
|
|
||||||
albumId,
|
|
||||||
pictureIntent.Id,
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
pictureIntent.Key,
|
|
||||||
finalPictureKey,
|
|
||||||
new ImageSize(
|
|
||||||
albumConfiguration.Routes.SmallPicturesPath,
|
|
||||||
albumConfiguration.PicturesSizes.SmallPictureWidth,
|
|
||||||
albumConfiguration.PicturesSizes.SmallPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
albumConfiguration.Routes.MediumPicturesPath,
|
|
||||||
albumConfiguration.PicturesSizes.MediumPictureWidth,
|
|
||||||
albumConfiguration.PicturesSizes.MediumPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
albumConfiguration.Routes.LargePicturesPath,
|
|
||||||
albumConfiguration.PicturesSizes.LargePictureWidth,
|
|
||||||
albumConfiguration.PicturesSizes.LargePictureHeight)),
|
|
||||||
cancellationToken);
|
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to publish update album picture source event for album {AlbumId}", albumId);
|
|
||||||
return Error.Failure(description: $"Failed to publish update album picture source event for album {albumId}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.Configuration
|
|
||||||
{
|
|
||||||
public class AlbumConfiguration
|
|
||||||
{
|
|
||||||
public const string SectionName = "Album";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public AlbumRoutes Routes { get; set; } = new AlbumRoutes();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public AlbumPicturesSizes PicturesSizes { get; set; } = new AlbumPicturesSizes();
|
|
||||||
}
|
|
||||||
|
|
||||||
public class AlbumRoutes
|
|
||||||
{
|
|
||||||
[Required]
|
|
||||||
public string UploadsFolder { get; set; } = "uploads";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string ParentFolders { get; set; } = "Albums";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string SmallPicturesFolder { get; set; } = "SmallPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string MediumPicturesFolder { get; set; } = "MediumPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string LargePicturesFolder { get; set; } = "LargePictures";
|
|
||||||
|
|
||||||
public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
|
|
||||||
|
|
||||||
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
|
|
||||||
|
|
||||||
public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder);
|
|
||||||
|
|
||||||
public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
|
|
||||||
|
|
||||||
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
|
|
||||||
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class AlbumPicturesSizes
|
|
||||||
{
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int SmallPictureWidth { get; set; } = 128;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int SmallPictureHeight { get; set; } = 128;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int MediumPictureWidth { get; set; } = 256;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int MediumPictureHeight { get; set; } = 256;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int LargePictureWidth { get; set; } = 512;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int LargePictureHeight { get; set; } = 512;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,12 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
|
|
||||||
namespace Musify.Application.Configuration
|
|
||||||
{
|
|
||||||
public class MixConfiguration
|
|
||||||
{
|
|
||||||
public const string SectionName = "Mix";
|
|
||||||
|
|
||||||
[Range(4, 100)]
|
|
||||||
public int ItemsPerMix { get; set; } = 12;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
|
|
||||||
namespace Musify.Application.Configuration
|
|
||||||
{
|
|
||||||
|
|
||||||
public sealed class PlaybackConfiguration
|
|
||||||
{
|
|
||||||
public const 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,4 +0,0 @@
|
|||||||
namespace Musify.Application.Contracts
|
|
||||||
{
|
|
||||||
public record AudioTranscodeResult(int StatusCode, TimeSpan Duration);
|
|
||||||
}
|
|
||||||
@@ -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,14 +0,0 @@
|
|||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.Events
|
|
||||||
{
|
|
||||||
public record CreateAlbumResourcesEvent(
|
|
||||||
Guid AlbumId,
|
|
||||||
Guid PictureIntentId,
|
|
||||||
string Bucket,
|
|
||||||
string PictureSourceKey,
|
|
||||||
string PictureDestinationKey,
|
|
||||||
ImageSize Small,
|
|
||||||
ImageSize Medium,
|
|
||||||
ImageSize Large);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.Events
|
|
||||||
{
|
|
||||||
public record UpdateAlbumPictureEvent(
|
|
||||||
Guid AlbumId,
|
|
||||||
string Bucket,
|
|
||||||
string SourceKey,
|
|
||||||
ImageSize Small,
|
|
||||||
ImageSize Medium,
|
|
||||||
ImageSize Large);
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.Events
|
|
||||||
{
|
|
||||||
public record UpdateAlbumPictureSourceEvent(
|
|
||||||
Guid AlbumId,
|
|
||||||
Guid PictureIntentId,
|
|
||||||
string Bucket,
|
|
||||||
string PictureSourceKey,
|
|
||||||
string PictureDestinationKey,
|
|
||||||
ImageSize Small,
|
|
||||||
ImageSize Medium,
|
|
||||||
ImageSize Large);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.Likes
|
|
||||||
{
|
|
||||||
public record GetLikedTracksQuery(
|
|
||||||
long UserId,
|
|
||||||
int PageNumber,
|
|
||||||
int PageSize)
|
|
||||||
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
|
||||||
|
|
||||||
public class GetLikedTracksQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetLikedTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var likesQuery = database.TrackLikes
|
|
||||||
.AsNoTracking()
|
|
||||||
.Include(like => like.Track.Owner)
|
|
||||||
.Include(like => like.Track.Tags)
|
|
||||||
.Where(like => like.UserId == request.UserId);
|
|
||||||
|
|
||||||
var totalCount = await likesQuery.CountAsync(cancellationToken);
|
|
||||||
|
|
||||||
var pagedEntities = await likesQuery
|
|
||||||
.OrderByDescending(like => like.CreatedAt)
|
|
||||||
.Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count })
|
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
|
||||||
|
|
||||||
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
|
|
||||||
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
|
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
|
||||||
|
|
||||||
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Musify.Application.Likes
|
|
||||||
{
|
|
||||||
public record ToggleTrackLikeCommand(long UserId, Guid TrackId)
|
|
||||||
: ICommand<ErrorOr<bool>>;
|
|
||||||
|
|
||||||
public class ToggleTrackLikeCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<ToggleTrackLikeCommandHandler> logger)
|
|
||||||
: ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var trackExists = await database.Tracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
|
|
||||||
if (!trackExists)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
|
||||||
return Error.NotFound(description: $"Track {request.TrackId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var existingLike = await database.TrackLikes
|
|
||||||
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
|
|
||||||
|
|
||||||
bool nowLiked;
|
|
||||||
if (existingLike != null)
|
|
||||||
{
|
|
||||||
database.TrackLikes.Remove(existingLike);
|
|
||||||
nowLiked = false;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await database.TrackLikes.AddAsync(new TrackLike
|
|
||||||
{
|
|
||||||
UserId = request.UserId,
|
|
||||||
TrackId = request.TrackId
|
|
||||||
}, cancellationToken);
|
|
||||||
nowLiked = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("User {UserId} {Action} track {TrackId}",
|
|
||||||
request.UserId, nowLiked ? "liked" : "unliked", request.TrackId);
|
|
||||||
|
|
||||||
return nowLiked;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
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<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class GenerateMixesForUserCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
MixConfiguration mixConfiguration,
|
|
||||||
ILogger<GenerateMixesForUserCommandHandler> logger)
|
|
||||||
: ICommandHandler<GenerateMixesForUserCommand, ErrorOr<Success>>
|
|
||||||
{
|
|
||||||
private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList<Guid> TrackIds);
|
|
||||||
|
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var userId = request.UserId;
|
|
||||||
|
|
||||||
var libraryTrackIds = await database.UserHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(userTrack => userTrack.UserId == userId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
|
||||||
.Select(userTrack => userTrack.TrackId)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (libraryTrackIds.Count == 0)
|
|
||||||
{
|
|
||||||
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
|
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
|
|
||||||
var historyTrackIds = await database.ListeningHistories
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(history => history.UserId == userId)
|
|
||||||
.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(
|
|
||||||
"Descubrimiento",
|
|
||||||
"Canciones de tu biblioteca que todavía no has escuchado.",
|
|
||||||
discovery));
|
|
||||||
}
|
|
||||||
|
|
||||||
var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
|
|
||||||
if (daily.Count > 0)
|
|
||||||
{
|
|
||||||
drafts.Add(new MixDraft(
|
|
||||||
"Tu mezcla diaria",
|
|
||||||
"Lo que más escuchas, con alguna sorpresa de tu biblioteca.",
|
|
||||||
daily));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (drafts.Count == 0)
|
|
||||||
{
|
|
||||||
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
|
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
|
|
||||||
await ReplaceMixesAsync(userId, drafts, cancellationToken);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to generate mixes for user {UserId}", userId);
|
|
||||||
return Error.Failure(description: $"Failed to generate mixes for user {userId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
|
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
|
|
||||||
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(IReadOnlyList<Guid> first, IReadOnlyList<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)
|
|
||||||
{
|
|
||||||
var previousIds = previousMixes.Select(mix => mix.Id).ToList();
|
|
||||||
var previousItems = await database.MixItems
|
|
||||||
.Where(item => previousIds.Contains(item.MixId))
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
database.MixItems.RemoveRange(previousItems);
|
|
||||||
database.Mixes.RemoveRange(previousMixes);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var position = 0; position < drafts.Count; position++)
|
|
||||||
{
|
|
||||||
var draft = drafts[position];
|
|
||||||
var mix = new Mix
|
|
||||||
{
|
|
||||||
UserId = userId,
|
|
||||||
Title = draft.Title,
|
|
||||||
Subtitle = draft.Subtitle,
|
|
||||||
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,31 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Mixes.Responses;
|
|
||||||
|
|
||||||
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 Error.NotFound(description: $"Mix {request.MixId} was not found");
|
|
||||||
|
|
||||||
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
|
|
||||||
var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken);
|
|
||||||
|
|
||||||
return new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, items.Count, items);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Mixes.Responses;
|
|
||||||
|
|
||||||
namespace Musify.Application.Mixes
|
|
||||||
{
|
|
||||||
public record GetMixesByUserIdQuery(long UserId)
|
|
||||||
: IQuery<ErrorOr<IReadOnlyList<MixApplicationResponse>>>;
|
|
||||||
|
|
||||||
public class GetMixesByUserIdQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetMixesByUserIdQuery, ErrorOr<IReadOnlyList<MixApplicationResponse>>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<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)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var responses = new List<MixApplicationResponse>(mixes.Count);
|
|
||||||
foreach (var mix in mixes)
|
|
||||||
{
|
|
||||||
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
|
|
||||||
var coverItems = await MixItemMapper.ToResponsesAsync(
|
|
||||||
database, orderedItems.Take(MixApplicationResponse.CoverItemCount).ToList(), cancellationToken);
|
|
||||||
|
|
||||||
responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, coverItems));
|
|
||||||
}
|
|
||||||
|
|
||||||
return responses;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Mixes.Responses;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Musify.Application.Mixes
|
|
||||||
{
|
|
||||||
|
|
||||||
internal static class MixItemMapper
|
|
||||||
{
|
|
||||||
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
|
|
||||||
IDatabase database,
|
|
||||||
IReadOnlyList<MixItem> orderedItems,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList();
|
|
||||||
|
|
||||||
var tracksById = await database.Tracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Include(track => track.Owner)
|
|
||||||
.Where(track => trackIds.Contains(track.Id))
|
|
||||||
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count })
|
|
||||||
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
|
|
||||||
|
|
||||||
return orderedItems
|
|
||||||
.Where(item => tracksById.ContainsKey(item.TrackId))
|
|
||||||
.Select(item =>
|
|
||||||
{
|
|
||||||
var entry = tracksById[item.TrackId];
|
|
||||||
return new MixItemApplicationResponse(
|
|
||||||
item.TrackId,
|
|
||||||
entry.Track.Title,
|
|
||||||
entry.Track.Owner.Name,
|
|
||||||
entry.Track.DurationSeconds,
|
|
||||||
entry.ListensCount);
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace Musify.Application.Mixes.Responses
|
|
||||||
{
|
|
||||||
public record MixApplicationResponse(
|
|
||||||
Guid Id,
|
|
||||||
string Title,
|
|
||||||
string? Subtitle,
|
|
||||||
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,42 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.PlayLists.Responses;
|
|
||||||
using Mediator;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
|
||||||
{
|
|
||||||
public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null)
|
|
||||||
: IQuery<ErrorOr<PlayListApplicationResponse>>;
|
|
||||||
|
|
||||||
public class GetPlayListByIdQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetPlayListByIdQuery, ErrorOr<PlayListApplicationResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var entry = await database.PlayLists
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(p => p.Id == request.Id)
|
|
||||||
.Select(p => new
|
|
||||||
{
|
|
||||||
PlayList = p,
|
|
||||||
CoverTrackIds = p.PlayListTracks
|
|
||||||
.OrderBy(plt => plt.Position)
|
|
||||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(plt => plt.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (entry == null)
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
if (entry.PlayList.Visibility == PlaylistVisibility.Private
|
|
||||||
&& entry.PlayList.UserId != request.RequestingUserId)
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using MimeMapping;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
|
||||||
{
|
|
||||||
public record GetPlayListCoverQuery(Guid PlayListId, string Size)
|
|
||||||
: IQuery<ErrorOr<PlayListCoverLocation>>;
|
|
||||||
|
|
||||||
public record PlayListCoverLocation(string Bucket, string Key, string ContentType);
|
|
||||||
|
|
||||||
public class GetPlayListCoverQueryHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
|
||||||
PlayListConfiguration playListConfiguration)
|
|
||||||
: IQueryHandler<GetPlayListCoverQuery, ErrorOr<PlayListCoverLocation>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<PlayListCoverLocation>> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var pictures = await database.PlayLists.AsNoTracking()
|
|
||||||
.Where(p => p.Id == request.PlayListId)
|
|
||||||
.Select(p => p.Pictures)
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (pictures == null)
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
var routes = playListConfiguration.Routes;
|
|
||||||
var (name, key) = request.Size.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
|
|
||||||
"large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
|
|
||||||
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
|
|
||||||
};
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
var contentType = MimeUtility.GetMimeMapping(name);
|
|
||||||
return new PlayListCoverLocation(storageConfiguration.Bucket, key, contentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? BuildKey(Func<string, string> builder, string? name)
|
|
||||||
=> string.IsNullOrEmpty(name) ? null : builder(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.PlayLists.Responses;
|
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
using Mediator;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
|
||||||
{
|
|
||||||
public record GetPlayListsQuery(int PageNumber, int PageSize)
|
|
||||||
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
|
|
||||||
|
|
||||||
public class GetPlayListsQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetPlayListsQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var totalCount = await database.PlayLists.CountAsync(cancellationToken);
|
|
||||||
|
|
||||||
var pagedEntities = await database.PlayLists
|
|
||||||
.AsNoTracking()
|
|
||||||
.OrderBy(p => p.Id)
|
|
||||||
.Select(p => new
|
|
||||||
{
|
|
||||||
PlayList = p,
|
|
||||||
CoverTrackIds = p.PlayListTracks
|
|
||||||
.OrderBy(plt => plt.Position)
|
|
||||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(plt => plt.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
|
||||||
|
|
||||||
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
|
||||||
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
|
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
|
||||||
|
|
||||||
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.PlayLists.Responses;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
|
||||||
{
|
|
||||||
public record GetPlayListsByUserIdQuery(
|
|
||||||
long UserId,
|
|
||||||
string? Name,
|
|
||||||
int PageNumber,
|
|
||||||
int PageSize,
|
|
||||||
bool OnlyPublic = false)
|
|
||||||
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
|
|
||||||
|
|
||||||
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetPlayListsByUserIdQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var playListsQuery = database.PlayLists
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(p => p.UserId == request.UserId);
|
|
||||||
|
|
||||||
if (request.OnlyPublic)
|
|
||||||
{
|
|
||||||
playListsQuery = playListsQuery.Where(p => p.Visibility == PlaylistVisibility.Public);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Name))
|
|
||||||
{
|
|
||||||
var normalizedName = request.Name.Trim().ToUpperInvariant();
|
|
||||||
playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName));
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalCount = await playListsQuery.CountAsync(cancellationToken);
|
|
||||||
|
|
||||||
var pagedEntities = await playListsQuery
|
|
||||||
.OrderBy(p => p.CreatedAt)
|
|
||||||
.Where(p => p.LifeCycleStatus == LifeCycleStatus.Active)
|
|
||||||
.Select(p => new
|
|
||||||
{
|
|
||||||
PlayList = p,
|
|
||||||
CoverTrackIds = p.PlayListTracks
|
|
||||||
.OrderBy(plt => plt.Position)
|
|
||||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(plt => plt.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
|
||||||
|
|
||||||
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
|
||||||
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
|
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
|
||||||
|
|
||||||
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +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)
|
|
||||||
{
|
|
||||||
public const int CoverTrackCount = 4;
|
|
||||||
|
|
||||||
public static PlayListApplicationResponse FromEntity(
|
|
||||||
PlayList playList,
|
|
||||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
|
||||||
{
|
|
||||||
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 ?? []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace Musify.Application.Serialization;
|
|
||||||
|
|
||||||
public sealed class LongAsStringConverter : JsonConverter<long>
|
|
||||||
{
|
|
||||||
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
|
|
||||||
reader.TokenType == JsonTokenType.String
|
|
||||||
? long.Parse(reader.GetString()!, CultureInfo.InvariantCulture)
|
|
||||||
: reader.GetInt64();
|
|
||||||
|
|
||||||
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) =>
|
|
||||||
writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class NullableLongAsStringConverter : JsonConverter<long?>
|
|
||||||
{
|
|
||||||
public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
|
||||||
{
|
|
||||||
if (reader.TokenType == JsonTokenType.Null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return reader.TokenType == JsonTokenType.String
|
|
||||||
? string.IsNullOrEmpty(reader.GetString()) ? null : long.Parse(reader.GetString()!, CultureInfo.InvariantCulture)
|
|
||||||
: reader.GetInt64();
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options)
|
|
||||||
{
|
|
||||||
if (value.HasValue)
|
|
||||||
writer.WriteStringValue(value.Value.ToString(CultureInfo.InvariantCulture));
|
|
||||||
else
|
|
||||||
writer.WriteNullValue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Musify.Application.Services;
|
|
||||||
|
|
||||||
public sealed class TrackStreamIssuer(
|
|
||||||
IDatabase database,
|
|
||||||
IStreamTicketService ticketService,
|
|
||||||
TrackConfiguration trackConfiguration,
|
|
||||||
StreamGatewayConfiguration streamGatewayConfiguration,
|
|
||||||
PlaybackConfiguration playbackConfiguration)
|
|
||||||
{
|
|
||||||
public async Task<TrackStreamResponse> IssueAsync(
|
|
||||||
Guid trackId,
|
|
||||||
string audioFolderName,
|
|
||||||
long? userId,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName);
|
|
||||||
var keyPrefix = $"{folderPath}/";
|
|
||||||
|
|
||||||
var maxBytes = AnonymousFragmentBytes(userId);
|
|
||||||
var ticket = ticketService.IssueTicket(userId, keyPrefix, maxBytes);
|
|
||||||
|
|
||||||
var manifestUrl = string.Join('/',
|
|
||||||
streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'),
|
|
||||||
"media",
|
|
||||||
folderPath,
|
|
||||||
streamGatewayConfiguration.AudioFileName);
|
|
||||||
|
|
||||||
if (userId != null)
|
|
||||||
{
|
|
||||||
var newListeningHistory = new ListeningHistory
|
|
||||||
{
|
|
||||||
UserId = userId.Value,
|
|
||||||
TrackId = trackId,
|
|
||||||
};
|
|
||||||
await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken);
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private long? AnonymousFragmentBytes(long? userId)
|
|
||||||
{
|
|
||||||
if (userId != null || playbackConfiguration.AnonymousFragmentSeconds <= 0)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
|
||||||
{
|
|
||||||
public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery<ErrorOr<TrackApplicationResponse>>;
|
|
||||||
|
|
||||||
public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetLastTrackListenedByUserIdQuery, ErrorOr<TrackApplicationResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var track = await database.ListeningHistories
|
|
||||||
.AsNoTracking()
|
|
||||||
.Include(t => t.Track.Owner)
|
|
||||||
.Include(t => t.Track.Tags)
|
|
||||||
.Where(t => t.UserId == query.UserId)
|
|
||||||
.OrderByDescending(t => t.ListenedAt)
|
|
||||||
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count))
|
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
return track == null
|
|
||||||
? Error.NotFound("Track.NotFound", "No track found for the given user ID.")
|
|
||||||
: track;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<IEnumerable<TrackApplicationResponse>>;
|
|
||||||
|
|
||||||
public class GetListeningHistoryQueryHandler(
|
|
||||||
IDatabase database)
|
|
||||||
: IQueryHandler<GetListeningHistoryQuery, IEnumerable<TrackApplicationResponse>>
|
|
||||||
{
|
|
||||||
public const int ListSize = 15;
|
|
||||||
|
|
||||||
public async ValueTask<IEnumerable<TrackApplicationResponse>> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var recentTrackIds = await database.ListeningHistories
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(l => l.UserId == query.UserId)
|
|
||||||
.GroupBy(l => l.TrackId)
|
|
||||||
.OrderByDescending(g => g.Max(l => l.ListenedAt))
|
|
||||||
.Select(g => g.Key)
|
|
||||||
.Take(ListSize)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var tracksById = await database.Tracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Include(t => t.Owner)
|
|
||||||
.Include(t => t.Tags)
|
|
||||||
.Where(t => recentTrackIds.Contains(t.Id))
|
|
||||||
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count })
|
|
||||||
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
|
||||||
|
|
||||||
return recentTrackIds
|
|
||||||
.Select(id => tracksById[id])
|
|
||||||
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.ListensCount));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
|
||||||
{
|
|
||||||
public record GetTrackCoverQuery(Guid TrackId, string Size)
|
|
||||||
: IQuery<ErrorOr<TrackCoverLocation>>;
|
|
||||||
|
|
||||||
public record TrackCoverLocation(string Bucket, string Key, string ContentType);
|
|
||||||
|
|
||||||
public class GetTrackCoverQueryHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
|
||||||
TrackConfiguration trackConfiguration)
|
|
||||||
: IQueryHandler<GetTrackCoverQuery, ErrorOr<TrackCoverLocation>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<TrackCoverLocation>> Handle(GetTrackCoverQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var track = await database.Tracks.AsNoTracking()
|
|
||||||
.Where(t => t.Id == request.TrackId)
|
|
||||||
.Select(t => new { t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName })
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (track == null)
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
var routes = trackConfiguration.Routes;
|
|
||||||
var (name, key) = request.Size.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"small" => (track.SmallName, BuildKey(routes.BuildSmallPicturePath, track.SmallName)),
|
|
||||||
"large" => (track.LargeName, BuildKey(routes.BuildLargePicturePath, track.LargeName)),
|
|
||||||
_ => (track.MediumName, BuildKey(routes.BuildMediumPicturePath, track.MediumName))
|
|
||||||
};
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(name) || key == null)
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
var contentType = name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase)
|
|
||||||
? "image/webp"
|
|
||||||
: name.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
|
|
||||||
? "image/png"
|
|
||||||
: "image/jpeg";
|
|
||||||
|
|
||||||
return new TrackCoverLocation(storageConfiguration.Bucket, key, contentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? BuildKey(Func<string, string> builder, string? name)
|
|
||||||
=> string.IsNullOrEmpty(name) ? null : builder(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
|
||||||
{
|
|
||||||
public record GetTracksQuery(string? Name, int PageNumber, int PageSize)
|
|
||||||
: IQuery<ErrorOr<TracksSearchResponse>>;
|
|
||||||
|
|
||||||
public class GetTracksQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetTracksQuery, ErrorOr<TracksSearchResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<TracksSearchResponse>> Handle(GetTracksQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var tracksQuery = database.UserHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Include(ut => ut.Track.Owner)
|
|
||||||
.Include(ut => ut.Track.Tags)
|
|
||||||
.AsQueryable();
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Name))
|
|
||||||
{
|
|
||||||
var normalizedName = request.Name.Trim().ToUpperInvariant();
|
|
||||||
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
|
||||||
|
|
||||||
var pagedEntities = await tracksQuery
|
|
||||||
.OrderByDescending(t => t.Track.CreatedAt)
|
|
||||||
.ThenBy(t => t.Id)
|
|
||||||
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count })
|
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
|
||||||
|
|
||||||
var items = pagedEntities
|
|
||||||
.Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return new TracksSearchResponse(
|
|
||||||
items,
|
|
||||||
pagedEntities.PageNumber,
|
|
||||||
pagedEntities.PageSize,
|
|
||||||
pagedEntities.PageCount,
|
|
||||||
pagedEntities.TotalItemCount,
|
|
||||||
pagedEntities.HasPreviousPage,
|
|
||||||
pagedEntities.HasNextPage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
using Musify.Application.Serialization;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks.Responses
|
|
||||||
{
|
|
||||||
public record TrackApplicationResponse(
|
|
||||||
Guid Id,
|
|
||||||
string Title,
|
|
||||||
string? Artist,
|
|
||||||
ProcessingStatus AudioStatus,
|
|
||||||
double Duration,
|
|
||||||
int ListensCount,
|
|
||||||
DateTime CreatedAt,
|
|
||||||
DateTime UpdatedAt,
|
|
||||||
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
|
|
||||||
IReadOnlyCollection<Genre> Tags)
|
|
||||||
{
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Musify.Application.Tracks.Responses
|
|
||||||
{
|
|
||||||
public record TrackSearchItemResponse(TrackApplicationResponse Track)
|
|
||||||
{
|
|
||||||
public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) => new(track);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace Musify.Application.Tracks.Responses
|
|
||||||
{
|
|
||||||
|
|
||||||
public record TracksSearchResponse(
|
|
||||||
IReadOnlyList<TrackSearchItemResponse> Items,
|
|
||||||
int PageNumber,
|
|
||||||
int PageSize,
|
|
||||||
int PageCount,
|
|
||||||
int TotalItemCount,
|
|
||||||
bool HasPreviousPage,
|
|
||||||
bool HasNextPage);
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Musify.Application.Users
|
|
||||||
{
|
|
||||||
public record FollowUserCommand(long FollowerId, long FollowedId) : ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class FollowUserCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<FollowUserCommandHandler> logger)
|
|
||||||
: ICommandHandler<FollowUserCommand, ErrorOr<Success>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(FollowUserCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (request.FollowerId == request.FollowedId)
|
|
||||||
return Error.Validation(description: "A user cannot follow themselves.");
|
|
||||||
|
|
||||||
var followedExists = await database.Users
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(u => u.Id == request.FollowedId, cancellationToken);
|
|
||||||
if (!followedExists)
|
|
||||||
{
|
|
||||||
logger.LogInformation("User {FollowedId} not found", request.FollowedId);
|
|
||||||
return Error.NotFound(description: $"User {request.FollowedId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var alreadyFollowing = await database.UserFollows
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken);
|
|
||||||
if (alreadyFollowing)
|
|
||||||
return new Success();
|
|
||||||
|
|
||||||
await database.UserFollows.AddAsync(new UserFollow
|
|
||||||
{
|
|
||||||
FollowerId = request.FollowerId,
|
|
||||||
FollowedId = request.FollowedId
|
|
||||||
}, cancellationToken);
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId);
|
|
||||||
|
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Users
|
|
||||||
{
|
|
||||||
public record GetListeningStatsQuery(long UserId) : IQuery<ListeningStatsResponse>;
|
|
||||||
|
|
||||||
public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays);
|
|
||||||
|
|
||||||
public class GetListeningStatsQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetListeningStatsQuery, ListeningStatsResponse>
|
|
||||||
{
|
|
||||||
public async ValueTask<ListeningStatsResponse> Handle(GetListeningStatsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var weekAgo = DateTime.UtcNow.AddDays(-7);
|
|
||||||
|
|
||||||
var thisWeekHistory = database.ListeningHistories
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo);
|
|
||||||
|
|
||||||
var tracksThisWeek = await thisWeekHistory
|
|
||||||
.Select(l => l.TrackId)
|
|
||||||
.Distinct()
|
|
||||||
.CountAsync(cancellationToken);
|
|
||||||
|
|
||||||
var secondsThisWeek = await thisWeekHistory
|
|
||||||
.Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => t.DurationSeconds)
|
|
||||||
.SumAsync(cancellationToken);
|
|
||||||
|
|
||||||
var listenedDates = await database.ListeningHistories
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(l => l.UserId == request.UserId)
|
|
||||||
.Select(l => l.ListenedAt.Date)
|
|
||||||
.Distinct()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var listenedDateSet = listenedDates.ToHashSet();
|
|
||||||
var streakDays = 0;
|
|
||||||
var day = DateTime.UtcNow.Date;
|
|
||||||
while (listenedDateSet.Contains(day))
|
|
||||||
{
|
|
||||||
streakDays++;
|
|
||||||
day = day.AddDays(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ListeningStatsResponse(tracksThisWeek, (int)Math.Round(secondsThisWeek), streakDays);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Serialization;
|
|
||||||
|
|
||||||
namespace Musify.Application.Users
|
|
||||||
{
|
|
||||||
public record GetUserProfileQuery(long UserId) : IQuery<ErrorOr<UserProfileResponse>>;
|
|
||||||
|
|
||||||
public record UserProfileResponse(
|
|
||||||
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
|
|
||||||
string Name,
|
|
||||||
string? FirstName,
|
|
||||||
string? SecondName,
|
|
||||||
string? ProfilePictureUrl,
|
|
||||||
DateTime CreatedAt,
|
|
||||||
int FollowersCount,
|
|
||||||
int FollowingCount);
|
|
||||||
|
|
||||||
public class GetUserProfileQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetUserProfileQuery, ErrorOr<UserProfileResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<UserProfileResponse>> Handle(GetUserProfileQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var user = await database.Users
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(u => u.Id == request.UserId)
|
|
||||||
.Select(u => new
|
|
||||||
{
|
|
||||||
u.Id,
|
|
||||||
u.Name,
|
|
||||||
u.FirstName,
|
|
||||||
u.SecondName,
|
|
||||||
u.ProfilePictureUrl,
|
|
||||||
u.CreatedAt
|
|
||||||
})
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (user == null)
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
var followersCount = await database.UserFollows
|
|
||||||
.AsNoTracking()
|
|
||||||
.CountAsync(f => f.FollowedId == request.UserId, cancellationToken);
|
|
||||||
|
|
||||||
var followingCount = await database.UserFollows
|
|
||||||
.AsNoTracking()
|
|
||||||
.CountAsync(f => f.FollowerId == request.UserId, cancellationToken);
|
|
||||||
|
|
||||||
return new UserProfileResponse(
|
|
||||||
user.Id,
|
|
||||||
user.Name,
|
|
||||||
user.FirstName,
|
|
||||||
user.SecondName,
|
|
||||||
user.ProfilePictureUrl,
|
|
||||||
user.CreatedAt,
|
|
||||||
followersCount,
|
|
||||||
followingCount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Users
|
|
||||||
{
|
|
||||||
public record IsFollowingUserQuery(long FollowerId, long FollowedId) : IQuery<bool>;
|
|
||||||
|
|
||||||
public class IsFollowingUserQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<IsFollowingUserQuery, bool>
|
|
||||||
{
|
|
||||||
public ValueTask<bool> Handle(IsFollowingUserQuery request, CancellationToken cancellationToken) =>
|
|
||||||
new(database.UserFollows
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Users
|
|
||||||
{
|
|
||||||
public record UnfollowUserCommand(long FollowerId, long FollowedId) : ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class UnfollowUserCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<UnfollowUserCommandHandler> logger)
|
|
||||||
: ICommandHandler<UnfollowUserCommand, ErrorOr<Success>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(UnfollowUserCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var follow = await database.UserFollows
|
|
||||||
.SingleOrDefaultAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken);
|
|
||||||
|
|
||||||
if (follow == null)
|
|
||||||
return new Success();
|
|
||||||
|
|
||||||
database.UserFollows.Remove(follow);
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("User {FollowerId} unfollowed user {FollowedId}", request.FollowerId, request.FollowedId);
|
|
||||||
|
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<AssemblyName>Musify.Domain</AssemblyName>
|
|
||||||
<RootNamespace>Musify.Domain</RootNamespace>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
using Musify.Domain.Abstractions;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Domain.Entities
|
|
||||||
{
|
|
||||||
#pragma warning disable CS8618
|
|
||||||
public class Album : IAuditable
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(200)]
|
|
||||||
public required string Title { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(200)]
|
|
||||||
public required string NormalizedTitle { get; set; }
|
|
||||||
|
|
||||||
[MaxLength(256)]
|
|
||||||
public string? Description { get; set; }
|
|
||||||
|
|
||||||
public int? ReleaseYear { get; set; }
|
|
||||||
|
|
||||||
public AlbumPictures? Pictures { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required long OwnerUserId { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(OwnerUserId))]
|
|
||||||
public User Owner { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
|
|
||||||
public ICollection<AlbumHasTrack> AlbumTracks { get; set; } = new List<AlbumHasTrack>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace Musify.Domain.Entities
|
|
||||||
{
|
|
||||||
#pragma warning disable CS8618
|
|
||||||
[Table("AlbumHasTrack")]
|
|
||||||
public class AlbumHasTrack
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required Guid AlbumId { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required Guid TrackId { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public int TrackNumber { get; set; } = 1;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public DateTime AddedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
|
|
||||||
[ForeignKey(nameof(AlbumId))]
|
|
||||||
public Album Album { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(TrackId))]
|
|
||||||
public Track Track { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace Musify.Domain.Entities
|
|
||||||
{
|
|
||||||
#pragma warning disable CS8618
|
|
||||||
[Table("ListeningHistory")]
|
|
||||||
public class ListeningHistory
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required long UserId { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required Guid TrackId { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public DateTime ListenedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
|
|
||||||
[ForeignKey(nameof(UserId) )]
|
|
||||||
public User User { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(TrackId)) ]
|
|
||||||
public Track Track { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
using Musify.Domain.Abstractions;
|
|
||||||
|
|
||||||
namespace Musify.Domain.Entities
|
|
||||||
{
|
|
||||||
#pragma warning disable CS8618
|
|
||||||
[Table("Mixes")]
|
|
||||||
public class Mix : IAuditable
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required long UserId { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(100)]
|
|
||||||
public required string Title { get; set; }
|
|
||||||
|
|
||||||
[MaxLength(256)]
|
|
||||||
public string? Subtitle { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public int Position { get; set; } = 0;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
|
|
||||||
[ForeignKey(nameof(UserId))]
|
|
||||||
public User User { get; set; }
|
|
||||||
|
|
||||||
public ICollection<MixItem> Items { get; set; } = new List<MixItem>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace Musify.Domain.Entities
|
|
||||||
{
|
|
||||||
#pragma warning disable CS8618
|
|
||||||
[Table("MixItems")]
|
|
||||||
public class MixItem
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required Guid MixId { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public int Position { get; set; } = 0;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required Guid TrackId { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(MixId))]
|
|
||||||
public Mix Mix { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(TrackId))]
|
|
||||||
public Track Track { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
using Musify.Domain.Abstractions;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Domain.Entities
|
|
||||||
{
|
|
||||||
#pragma warning disable CS8618
|
|
||||||
public class Track : IAuditable
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(200)]
|
|
||||||
public required string Title { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(200)]
|
|
||||||
public required string NormalizedTitle { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public double DurationSeconds { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required long OwnerUserId { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(OwnerUserId))]
|
|
||||||
public required User Owner { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
|
|
||||||
public TrackPictures Pictures { get; set; } = new();
|
|
||||||
|
|
||||||
public TrackAudio Audio { get; set; } = new();
|
|
||||||
|
|
||||||
public ICollection<UserHasTrack> UserTracks { get; set; } = new List<UserHasTrack>();
|
|
||||||
|
|
||||||
public ICollection<PlayListHasTrack> PlayListTracks { get; set; } = new List<PlayListHasTrack>();
|
|
||||||
|
|
||||||
public ICollection<AlbumHasTrack> AlbumTracks { get; set; } = new List<AlbumHasTrack>();
|
|
||||||
|
|
||||||
public ICollection<ListeningHistory> ListeningHistories { get; set; } = new List<ListeningHistory>();
|
|
||||||
|
|
||||||
public ICollection<TrackTag> Tags { get; set; } = new List<TrackTag>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace Musify.Domain.Entities
|
|
||||||
{
|
|
||||||
#pragma warning disable CS8618
|
|
||||||
[Table("TrackLikes")]
|
|
||||||
public class TrackLike
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required long UserId { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required Guid TrackId { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
|
|
||||||
[ForeignKey(nameof(UserId))]
|
|
||||||
public User User { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(TrackId))]
|
|
||||||
public Track Track { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Domain.Entities
|
|
||||||
{
|
|
||||||
#pragma warning disable CS8618
|
|
||||||
public class TrackTag
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required Guid TrackId { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required Genre Tag { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(TrackId))]
|
|
||||||
public Track Track { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace Musify.Domain.Entities
|
|
||||||
{
|
|
||||||
#pragma warning disable CS8618
|
|
||||||
[Table("UserFollows")]
|
|
||||||
public class UserFollow
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required long FollowerId { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required long FollowedId { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
|
|
||||||
[ForeignKey(nameof(FollowerId))]
|
|
||||||
public User Follower { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(FollowedId))]
|
|
||||||
public User Followed { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
|
|
||||||
namespace Musify.Domain.ValueObjects
|
|
||||||
{
|
|
||||||
public class AlbumPictures
|
|
||||||
{
|
|
||||||
[Required]
|
|
||||||
public required string OriginalName { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required string SmallName { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required string MediumName { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required string LargeName { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
namespace Musify.Domain.ValueObjects
|
|
||||||
{
|
|
||||||
public enum Genre
|
|
||||||
{
|
|
||||||
Pop = 0,
|
|
||||||
Rock = 1,
|
|
||||||
HipHop = 2,
|
|
||||||
RnB = 3,
|
|
||||||
Jazz = 4,
|
|
||||||
Blues = 5,
|
|
||||||
Classical = 6,
|
|
||||||
Electronic = 7,
|
|
||||||
House = 8,
|
|
||||||
Techno = 9,
|
|
||||||
Trance = 10,
|
|
||||||
Dubstep = 11,
|
|
||||||
DrumAndBass = 12,
|
|
||||||
Metal = 13,
|
|
||||||
Punk = 14,
|
|
||||||
Reggae = 15,
|
|
||||||
Reggaeton = 16,
|
|
||||||
Country = 17,
|
|
||||||
Folk = 18,
|
|
||||||
Indie = 19,
|
|
||||||
KPop = 20,
|
|
||||||
Latin = 21,
|
|
||||||
Soul = 22,
|
|
||||||
Funk = 23,
|
|
||||||
Ambient = 24,
|
|
||||||
Lofi = 25
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
namespace Musify.Domain.ValueObjects
|
|
||||||
{
|
|
||||||
|
|
||||||
public static class GenreCompatibility
|
|
||||||
{
|
|
||||||
private static readonly HashSet<(Genre, Genre)> IncompatiblePairs = BuildIncompatiblePairs();
|
|
||||||
|
|
||||||
private static HashSet<(Genre, Genre)> BuildIncompatiblePairs()
|
|
||||||
{
|
|
||||||
var pairs = new HashSet<(Genre, Genre)>();
|
|
||||||
|
|
||||||
void Add(Genre a, Genre b)
|
|
||||||
{
|
|
||||||
pairs.Add((a, b));
|
|
||||||
pairs.Add((b, a));
|
|
||||||
}
|
|
||||||
|
|
||||||
var intense = new[] { Genre.Metal, Genre.Punk, Genre.Dubstep, Genre.DrumAndBass };
|
|
||||||
var mellow = new[] { Genre.Classical, Genre.Ambient, Genre.Lofi };
|
|
||||||
|
|
||||||
foreach (var intenseGenre in intense)
|
|
||||||
foreach (var mellowGenre in mellow)
|
|
||||||
Add(intenseGenre, mellowGenre);
|
|
||||||
|
|
||||||
return pairs;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool AreCompatible(Genre a, Genre b) =>
|
|
||||||
a == b || !IncompatiblePairs.Contains((a, b));
|
|
||||||
|
|
||||||
public static IReadOnlyCollection<(Genre First, Genre Second)> FindConflicts(IEnumerable<Genre> tags)
|
|
||||||
{
|
|
||||||
var distinctTags = tags.Distinct().ToList();
|
|
||||||
var conflicts = new List<(Genre, Genre)>();
|
|
||||||
|
|
||||||
for (var i = 0; i < distinctTags.Count; i++)
|
|
||||||
{
|
|
||||||
for (var j = i + 1; j < distinctTags.Count; j++)
|
|
||||||
{
|
|
||||||
if (!AreCompatible(distinctTags[i], distinctTags[j]))
|
|
||||||
conflicts.Add((distinctTags[i], distinctTags[j]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return conflicts;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
|
|
||||||
namespace Musify.Domain.ValueObjects
|
|
||||||
{
|
|
||||||
public class PlayListPictures
|
|
||||||
{
|
|
||||||
[Required]
|
|
||||||
public required string OriginalName { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required string SmallName { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required string MediumName { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public required string LargeName { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace Musify.Domain.ValueObjects
|
|
||||||
{
|
|
||||||
public enum PlaylistVisibility
|
|
||||||
{
|
|
||||||
Private = 0,
|
|
||||||
Public = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
|
||||||
|
|
||||||
namespace Musify.Domain.ValueObjects
|
|
||||||
{
|
|
||||||
#pragma warning disable CS8618
|
|
||||||
public class TrackAudio
|
|
||||||
{
|
|
||||||
public string? OriginalName { get; set; }
|
|
||||||
|
|
||||||
public string? FolderName { get; set; }
|
|
||||||
|
|
||||||
public ProcessingStatus TranscodeStatus { get; set; } = ProcessingStatus.Pending;
|
|
||||||
|
|
||||||
public bool DownloadRequested { get; set; }
|
|
||||||
|
|
||||||
public int RetryCount { get; set; }
|
|
||||||
|
|
||||||
public DateTime? LastRetryAt { get; set; }
|
|
||||||
|
|
||||||
[MemberNotNullWhen(true, nameof(FolderName))]
|
|
||||||
public bool IsProcessed => TranscodeStatus == ProcessingStatus.Completed;
|
|
||||||
|
|
||||||
public bool IsFailed => TranscodeStatus == ProcessingStatus.Failed;
|
|
||||||
|
|
||||||
public bool IsInProgress => TranscodeStatus is ProcessingStatus.Pending or ProcessingStatus.Processing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
|
||||||
|
|
||||||
namespace Musify.Domain.ValueObjects
|
|
||||||
{
|
|
||||||
#pragma warning disable CS8618
|
|
||||||
public class TrackPictures
|
|
||||||
{
|
|
||||||
public string? OriginalName { get; set; }
|
|
||||||
|
|
||||||
public string? SmallName { get; set; }
|
|
||||||
|
|
||||||
public string? MediumName { get; set; }
|
|
||||||
|
|
||||||
public string? LargeName { get; set; }
|
|
||||||
|
|
||||||
public ProcessingStatus ProcessingStatus { get; set; } = ProcessingStatus.Pending;
|
|
||||||
|
|
||||||
[MemberNotNullWhen(true, nameof(SmallName))]
|
|
||||||
[MemberNotNullWhen(true, nameof(MediumName))]
|
|
||||||
[MemberNotNullWhen(true, nameof(LargeName))]
|
|
||||||
public bool IsProcessed => ProcessingStatus == ProcessingStatus.Completed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Mixes;
|
|
||||||
|
|
||||||
namespace Musify.Infrastructure.Jobs
|
|
||||||
{
|
|
||||||
public class DailyMixGenerationJob(
|
|
||||||
IDatabase database,
|
|
||||||
IMediator mediator,
|
|
||||||
ILogger<DailyMixGenerationJob> logger)
|
|
||||||
{
|
|
||||||
public async Task RunAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var userIds = await database.ListeningHistories
|
|
||||||
.AsNoTracking()
|
|
||||||
.Select(history => history.UserId)
|
|
||||||
.Distinct()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Starting daily mix generation for {Count} users", userIds.Count);
|
|
||||||
|
|
||||||
foreach (var userId in userIds)
|
|
||||||
{
|
|
||||||
var result = await mediator.Send(new GenerateMixesForUserCommand(userId), cancellationToken);
|
|
||||||
if (result.IsError)
|
|
||||||
{
|
|
||||||
logger.LogWarning("Failed to generate mixes for user {UserId}: {Error}",
|
|
||||||
userId, result.FirstError.Description);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogInformation("Finished daily mix generation");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
using MassTransit;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
using Musify.Infrastructure.MassTransit.Arguments;
|
|
||||||
|
|
||||||
namespace Musify.Infrastructure.MassTransit.Activities.Albums
|
|
||||||
{
|
|
||||||
internal class MarkAlbumAsFailedActivity(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<MarkAlbumAsFailedActivity> logger)
|
|
||||||
: IExecuteActivity<MarkAlbumAsFailedArguments>
|
|
||||||
{
|
|
||||||
public const string ExecuteEndpointName = "mark-album-as-failed";
|
|
||||||
|
|
||||||
public async Task<ExecutionResult> Execute(ExecuteContext<MarkAlbumAsFailedArguments> executeContext)
|
|
||||||
{
|
|
||||||
var album = await database.Albums
|
|
||||||
.SingleOrDefaultAsync(a => a.Id == executeContext.Arguments.AlbumId, executeContext.CancellationToken);
|
|
||||||
|
|
||||||
if (album != null)
|
|
||||||
{
|
|
||||||
album.LifeCycleStatus = LifeCycleStatus.Failed;
|
|
||||||
await database.SaveChangesAsync(executeContext.CancellationToken);
|
|
||||||
logger.LogInformation("Marked album {AlbumId} as failed", executeContext.Arguments.AlbumId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return executeContext.Completed();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-34
@@ -1,34 +0,0 @@
|
|||||||
using MassTransit;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Infrastructure.MassTransit.Arguments;
|
|
||||||
|
|
||||||
namespace Musify.Infrastructure.MassTransit.Activities.Albums
|
|
||||||
{
|
|
||||||
internal class PublishAlbumPictureProcessingEventActivity(
|
|
||||||
IPublishEndpoint publishEndpoint,
|
|
||||||
ILogger<PublishAlbumPictureProcessingEventActivity> logger)
|
|
||||||
: IExecuteActivity<PublishAlbumPictureProcessingEventArguments>
|
|
||||||
{
|
|
||||||
public const string ExecuteEndpointName = "publish-album-picture-processing-event";
|
|
||||||
|
|
||||||
public async Task<ExecutionResult> Execute(ExecuteContext<PublishAlbumPictureProcessingEventArguments> executeContext)
|
|
||||||
{
|
|
||||||
var arguments = executeContext.Arguments;
|
|
||||||
|
|
||||||
await publishEndpoint.Publish(
|
|
||||||
new UpdateAlbumPictureEvent(
|
|
||||||
arguments.AlbumId,
|
|
||||||
arguments.Bucket,
|
|
||||||
arguments.FinalPictureKey,
|
|
||||||
arguments.Small,
|
|
||||||
arguments.Medium,
|
|
||||||
arguments.Large),
|
|
||||||
executeContext.CancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Published picture processing event for album {AlbumId}", arguments.AlbumId);
|
|
||||||
|
|
||||||
return executeContext.Completed();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-69
@@ -1,69 +0,0 @@
|
|||||||
using MassTransit;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Infrastructure.MassTransit.Arguments;
|
|
||||||
using Musify.Infrastructure.MassTransit.Logs;
|
|
||||||
|
|
||||||
namespace Musify.Infrastructure.MassTransit.Activities.Files
|
|
||||||
{
|
|
||||||
internal class DownloadFileFromUrlActivity(
|
|
||||||
IHttpClientFactory httpClientFactory,
|
|
||||||
ILogger<DownloadFileFromUrlActivity> logger)
|
|
||||||
: IActivity<DownloadFileFromUrlArguments, DownloadFileFromUrlLog>
|
|
||||||
{
|
|
||||||
public const string ExecuteEndpointName = "download-file-from-url";
|
|
||||||
|
|
||||||
public async Task<ExecutionResult> Execute(ExecuteContext<DownloadFileFromUrlArguments> executeContext)
|
|
||||||
{
|
|
||||||
var destinationPath = executeContext.GetVariable<string>(executeContext.Arguments.DestinationFilePathVariable);
|
|
||||||
ArgumentNullException.ThrowIfNull(destinationPath, nameof(destinationPath));
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var httpClient = httpClientFactory.CreateClient();
|
|
||||||
|
|
||||||
using var response = await httpClient.GetAsync(
|
|
||||||
executeContext.Arguments.SourceUrl,
|
|
||||||
HttpCompletionOption.ResponseHeadersRead,
|
|
||||||
executeContext.CancellationToken);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
var destinationDirectory = Path.GetDirectoryName(destinationPath);
|
|
||||||
if (!string.IsNullOrWhiteSpace(destinationDirectory))
|
|
||||||
Directory.CreateDirectory(destinationDirectory);
|
|
||||||
|
|
||||||
await using (var destinationStream = File.Create(destinationPath))
|
|
||||||
{
|
|
||||||
await response.Content.CopyToAsync(destinationStream, executeContext.CancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogInformation("Downloaded {SourceUrl} to {DestinationPath}",
|
|
||||||
executeContext.Arguments.SourceUrl, destinationPath);
|
|
||||||
|
|
||||||
return executeContext.Completed(new DownloadFileFromUrlLog(
|
|
||||||
executeContext.Arguments.SourceUrl,
|
|
||||||
destinationPath));
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to download file from {SourceUrl}", executeContext.Arguments.SourceUrl);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<CompensationResult> Compensate(CompensateContext<DownloadFileFromUrlLog> compensateContext)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (File.Exists(compensateContext.Log.DestinationFilePath))
|
|
||||||
File.Delete(compensateContext.Log.DestinationFilePath);
|
|
||||||
return Task.FromResult(compensateContext.Compensated());
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to compensate downloaded file {DestinationFilePath}",
|
|
||||||
compensateContext.Log.DestinationFilePath);
|
|
||||||
return Task.FromResult(compensateContext.Failed(exception));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-113
@@ -1,113 +0,0 @@
|
|||||||
using MassTransit;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
using Musify.Infrastructure.MassTransit.Arguments;
|
|
||||||
using Musify.Infrastructure.MassTransit.Logs;
|
|
||||||
|
|
||||||
namespace Musify.Infrastructure.MassTransit.Activities.Pictures
|
|
||||||
{
|
|
||||||
internal class UpdateAlbumPictureActivity(
|
|
||||||
IDatabase database,
|
|
||||||
IPublishEndpoint publishEndpoint,
|
|
||||||
ILogger<UpdateAlbumPictureActivity> logger)
|
|
||||||
: IActivity<UpdateAlbumPictureArguments, UpdateAlbumPictureLog>
|
|
||||||
{
|
|
||||||
public const string ExecuteEndpointName = "update-album-picture";
|
|
||||||
|
|
||||||
public async Task<ExecutionResult> Execute(ExecuteContext<UpdateAlbumPictureArguments> executeContext)
|
|
||||||
{
|
|
||||||
var smallResizedVariable = executeContext.GetVariable<string>(executeContext.Arguments.SmallPictureVariable);
|
|
||||||
ArgumentNullException.ThrowIfNull(smallResizedVariable, nameof(smallResizedVariable));
|
|
||||||
var mediumResizedVariable = executeContext.GetVariable<string>(executeContext.Arguments.MediumPictureVariable);
|
|
||||||
ArgumentNullException.ThrowIfNull(mediumResizedVariable, nameof(mediumResizedVariable));
|
|
||||||
var largeResizedVariable = executeContext.GetVariable<string>(executeContext.Arguments.LargePictureVariable);
|
|
||||||
ArgumentNullException.ThrowIfNull(largeResizedVariable, nameof(largeResizedVariable));
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var album = await database.Albums.FindAsync(
|
|
||||||
[executeContext.Arguments.AlbumId],
|
|
||||||
cancellationToken: executeContext.CancellationToken);
|
|
||||||
|
|
||||||
if (album == null)
|
|
||||||
{
|
|
||||||
logger.LogWarning("Album {AlbumId} not found",
|
|
||||||
executeContext.Arguments.AlbumId);
|
|
||||||
throw new InvalidOperationException($"Album with id {executeContext.Arguments.AlbumId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (album.Pictures == null)
|
|
||||||
{
|
|
||||||
logger.LogWarning("Album {AlbumId} has no pending picture upload",
|
|
||||||
executeContext.Arguments.AlbumId);
|
|
||||||
throw new InvalidOperationException($"Album with id {executeContext.Arguments.AlbumId} has no pending picture upload");
|
|
||||||
}
|
|
||||||
|
|
||||||
var log = new UpdateAlbumPictureLog(
|
|
||||||
album.Id,
|
|
||||||
album.Pictures.OriginalName,
|
|
||||||
album.Pictures.SmallName,
|
|
||||||
album.Pictures.MediumName,
|
|
||||||
album.Pictures.LargeName);
|
|
||||||
|
|
||||||
album.Pictures.OriginalName = Path.GetFileName(executeContext.Arguments.OriginalPictureKey);
|
|
||||||
album.Pictures.SmallName = Path.GetFileName(smallResizedVariable);
|
|
||||||
album.Pictures.MediumName = Path.GetFileName(mediumResizedVariable);
|
|
||||||
album.Pictures.LargeName = Path.GetFileName(largeResizedVariable);
|
|
||||||
|
|
||||||
database.Albums.Update(album);
|
|
||||||
await database.SaveChangesAsync(executeContext.CancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Updated album {AlbumId} pictures",
|
|
||||||
executeContext.Arguments.AlbumId);
|
|
||||||
|
|
||||||
await publishEndpoint.Publish(
|
|
||||||
new AlbumPictureProcessed(executeContext.Arguments.AlbumId),
|
|
||||||
executeContext.CancellationToken);
|
|
||||||
|
|
||||||
return executeContext.Completed();
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to update album {AlbumId} pictures",
|
|
||||||
executeContext.Arguments.AlbumId);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<CompensationResult> Compensate(CompensateContext<UpdateAlbumPictureLog> compensateContext)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var album = await database.Albums.FindAsync(
|
|
||||||
[compensateContext.Log.AlbumId],
|
|
||||||
cancellationToken: compensateContext.CancellationToken);
|
|
||||||
|
|
||||||
if (album == null)
|
|
||||||
{
|
|
||||||
return compensateContext.Compensated();
|
|
||||||
}
|
|
||||||
|
|
||||||
album.Pictures = new AlbumPictures
|
|
||||||
{
|
|
||||||
OriginalName = compensateContext.Log.PreviousOriginalPictureKey,
|
|
||||||
SmallName = compensateContext.Log.PreviousSmallPictureKey,
|
|
||||||
MediumName = compensateContext.Log.PreviousMediumPictureKey,
|
|
||||||
LargeName = compensateContext.Log.PreviousLargePictureKey
|
|
||||||
};
|
|
||||||
|
|
||||||
database.Albums.Update(album);
|
|
||||||
await database.SaveChangesAsync(compensateContext.CancellationToken);
|
|
||||||
|
|
||||||
return compensateContext.Compensated();
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to compensate album {AlbumId} pictures", compensateContext.Log.AlbumId);
|
|
||||||
return compensateContext.Failed(exception);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
namespace Musify.Infrastructure.MassTransit.Arguments
|
|
||||||
{
|
|
||||||
public record DownloadFileFromUrlArguments(
|
|
||||||
string SourceUrl,
|
|
||||||
string DestinationFilePathVariable);
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
namespace Musify.Infrastructure.MassTransit.Arguments
|
|
||||||
{
|
|
||||||
public record MarkAlbumAsFailedArguments(Guid AlbumId);
|
|
||||||
}
|
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Infrastructure.MassTransit.Arguments
|
|
||||||
{
|
|
||||||
public record PublishAlbumPictureProcessingEventArguments(
|
|
||||||
Guid AlbumId,
|
|
||||||
string Bucket,
|
|
||||||
string FinalPictureKey,
|
|
||||||
ImageSize Small,
|
|
||||||
ImageSize Medium,
|
|
||||||
ImageSize Large);
|
|
||||||
}
|
|
||||||
-9
@@ -1,9 +0,0 @@
|
|||||||
namespace Musify.Infrastructure.MassTransit.Arguments
|
|
||||||
{
|
|
||||||
public record UpdateAlbumPictureArguments(
|
|
||||||
Guid AlbumId,
|
|
||||||
string OriginalPictureKey,
|
|
||||||
string SmallPictureVariable,
|
|
||||||
string MediumPictureVariable,
|
|
||||||
string LargePictureVariable);
|
|
||||||
}
|
|
||||||
-36
@@ -1,36 +0,0 @@
|
|||||||
using MassTransit;
|
|
||||||
using MassTransit.Courier.Contracts;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Infrastructure.MassTransit.Activities.Albums;
|
|
||||||
using Musify.Infrastructure.MassTransit.Activities.Files;
|
|
||||||
using Musify.Infrastructure.MassTransit.Arguments;
|
|
||||||
using Musify.Infrastructure.MassTransit.RoutingSlip;
|
|
||||||
|
|
||||||
namespace Musify.Infrastructure.MassTransit.Consumers
|
|
||||||
{
|
|
||||||
public class AlbumProcessingFailedConsumer(IBus bus) : IConsumer<AlbumProcessingFailed>
|
|
||||||
{
|
|
||||||
public async Task Consume(ConsumeContext<AlbumProcessingFailed> context)
|
|
||||||
{
|
|
||||||
var message = context.Message;
|
|
||||||
|
|
||||||
var builder = new RoutingSlipBuilder(NewId.NextGuid());
|
|
||||||
builder.AddSubscription(
|
|
||||||
EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName),
|
|
||||||
RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(message.Bucket) && !string.IsNullOrWhiteSpace(message.PictureKey))
|
|
||||||
builder.AddActivity(
|
|
||||||
ActivityNames.RemoveAlbumOriginalPicture,
|
|
||||||
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
|
|
||||||
new RemoveFileFromBucketArguments(message.Bucket, message.PictureKey));
|
|
||||||
|
|
||||||
builder.AddActivity(
|
|
||||||
ActivityNames.MarkAlbumAsFailed,
|
|
||||||
EndpointHelper.BuildExecuteActivityUri(MarkAlbumAsFailedActivity.ExecuteEndpointName),
|
|
||||||
new MarkAlbumAsFailedArguments(message.AlbumId));
|
|
||||||
|
|
||||||
await bus.Execute(builder.Build());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
using MassTransit;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
|
|
||||||
|
|
||||||
namespace Musify.Infrastructure.MassTransit.Consumers
|
|
||||||
{
|
|
||||||
public class CreateAlbumConsumer(
|
|
||||||
IBus bus,
|
|
||||||
AlbumPictureSourceRoutingSlipBuilder routingSlipBuilder)
|
|
||||||
: IConsumer<CreateAlbumResourcesEvent>
|
|
||||||
{
|
|
||||||
|
|
||||||
public async Task Consume(ConsumeContext<CreateAlbumResourcesEvent> consumeContext)
|
|
||||||
{
|
|
||||||
var routingSlip = routingSlipBuilder
|
|
||||||
.Build(consumeContext.Message, consumeContext.CorrelationId)
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
await bus.Execute(routingSlip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
using MassTransit;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
|
|
||||||
|
|
||||||
namespace Musify.Infrastructure.MassTransit.Consumers
|
|
||||||
{
|
|
||||||
public class UpdateAlbumPictureConsumer(
|
|
||||||
IBus bus,
|
|
||||||
PictureWorkflowRoutingSlipBuilder routingSlipBuilder)
|
|
||||||
: IConsumer<UpdateAlbumPictureEvent>
|
|
||||||
{
|
|
||||||
|
|
||||||
public async Task Consume(ConsumeContext<UpdateAlbumPictureEvent> consumeContext)
|
|
||||||
{
|
|
||||||
var routingSlip = routingSlipBuilder
|
|
||||||
.Build(consumeContext.Message, consumeContext.CorrelationId)
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
await bus.Execute(routingSlip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
using MassTransit;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
|
|
||||||
|
|
||||||
namespace Musify.Infrastructure.MassTransit.Consumers
|
|
||||||
{
|
|
||||||
public class UpdateAlbumPictureSourceConsumer(
|
|
||||||
IBus bus,
|
|
||||||
AlbumPictureSourceRoutingSlipBuilder routingSlipBuilder)
|
|
||||||
: IConsumer<UpdateAlbumPictureSourceEvent>
|
|
||||||
{
|
|
||||||
|
|
||||||
public async Task Consume(ConsumeContext<UpdateAlbumPictureSourceEvent> consumeContext)
|
|
||||||
{
|
|
||||||
var routingSlip = routingSlipBuilder
|
|
||||||
.Build(consumeContext.Message, consumeContext.CorrelationId)
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
await bus.Execute(routingSlip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Musify.Infrastructure.MassTransit.Logs
|
|
||||||
{
|
|
||||||
public record DownloadFileFromUrlLog(
|
|
||||||
string SourceUrl,
|
|
||||||
string DestinationFilePath);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace Musify.Infrastructure.MassTransit.Logs
|
|
||||||
{
|
|
||||||
public record UpdateAlbumPictureLog(
|
|
||||||
Guid AlbumId,
|
|
||||||
string PreviousOriginalPictureKey,
|
|
||||||
string PreviousSmallPictureKey,
|
|
||||||
string PreviousMediumPictureKey,
|
|
||||||
string PreviousLargePictureKey);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace Musify.Infrastructure.MassTransit.Logs
|
|
||||||
{
|
|
||||||
public record UpdateTrackPictureLog(
|
|
||||||
Guid TrackId,
|
|
||||||
string? PreviousOriginalPictureKey,
|
|
||||||
string? PreviousSmallPictureKey,
|
|
||||||
string? PreviousMediumPictureKey,
|
|
||||||
string? PreviousLargePictureKey);
|
|
||||||
}
|
|
||||||
-85
@@ -1,85 +0,0 @@
|
|||||||
using MassTransit;
|
|
||||||
using MassTransit.Courier.Contracts;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Infrastructure.MassTransit.Activities.Albums;
|
|
||||||
using Musify.Infrastructure.MassTransit.Activities.Files;
|
|
||||||
using Musify.Infrastructure.MassTransit.Activities.UploadIntents;
|
|
||||||
using Musify.Infrastructure.MassTransit.Arguments;
|
|
||||||
using Musify.Infrastructure.MassTransit.Consumers;
|
|
||||||
|
|
||||||
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
|
|
||||||
{
|
|
||||||
public class AlbumPictureSourceRoutingSlipBuilder
|
|
||||||
{
|
|
||||||
public RoutingSlipBuilder Build(CreateAlbumResourcesEvent message, Guid? correlationId)
|
|
||||||
=> BuildSlip(
|
|
||||||
message.AlbumId,
|
|
||||||
message.PictureIntentId,
|
|
||||||
message.Bucket,
|
|
||||||
message.PictureSourceKey,
|
|
||||||
message.PictureDestinationKey,
|
|
||||||
message.Small,
|
|
||||||
message.Medium,
|
|
||||||
message.Large,
|
|
||||||
correlationId);
|
|
||||||
|
|
||||||
public RoutingSlipBuilder Build(UpdateAlbumPictureSourceEvent message, Guid? correlationId)
|
|
||||||
=> BuildSlip(
|
|
||||||
message.AlbumId,
|
|
||||||
message.PictureIntentId,
|
|
||||||
message.Bucket,
|
|
||||||
message.PictureSourceKey,
|
|
||||||
message.PictureDestinationKey,
|
|
||||||
message.Small,
|
|
||||||
message.Medium,
|
|
||||||
message.Large,
|
|
||||||
correlationId);
|
|
||||||
|
|
||||||
static RoutingSlipBuilder BuildSlip(
|
|
||||||
Guid albumId,
|
|
||||||
Guid pictureIntentId,
|
|
||||||
string bucket,
|
|
||||||
string pictureSourceKey,
|
|
||||||
string pictureDestinationKey,
|
|
||||||
ImageSize small,
|
|
||||||
ImageSize medium,
|
|
||||||
ImageSize large,
|
|
||||||
Guid? correlationId)
|
|
||||||
{
|
|
||||||
var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid());
|
|
||||||
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty);
|
|
||||||
|
|
||||||
routingSlipBuilder.AddSubscription(
|
|
||||||
EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName),
|
|
||||||
RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted);
|
|
||||||
|
|
||||||
routingSlipBuilder.AddActivity(
|
|
||||||
ActivityNames.CopyPictureToFinal,
|
|
||||||
EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName),
|
|
||||||
new CopyFileInBucketArguments(
|
|
||||||
bucket,
|
|
||||||
pictureSourceKey,
|
|
||||||
bucket,
|
|
||||||
pictureDestinationKey));
|
|
||||||
|
|
||||||
routingSlipBuilder.AddActivity(
|
|
||||||
ActivityNames.ConsumeUploadIntents,
|
|
||||||
EndpointHelper.BuildExecuteActivityUri(ConsumeUploadIntentsActivity.ExecuteEndpointName),
|
|
||||||
new ConsumeUploadIntentsArguments([pictureIntentId]));
|
|
||||||
|
|
||||||
routingSlipBuilder.AddActivity(
|
|
||||||
ActivityNames.PublishAlbumPictureProcessingEvent,
|
|
||||||
EndpointHelper.BuildExecuteActivityUri(PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName),
|
|
||||||
new PublishAlbumPictureProcessingEventArguments(
|
|
||||||
albumId,
|
|
||||||
bucket,
|
|
||||||
pictureDestinationKey,
|
|
||||||
small,
|
|
||||||
medium,
|
|
||||||
large));
|
|
||||||
|
|
||||||
return routingSlipBuilder;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
using MassTransit;
|
|
||||||
|
|
||||||
namespace Musify.Infrastructure.MassTransit.Sagas
|
|
||||||
{
|
|
||||||
public class AlbumProcessingState : SagaStateMachineInstance
|
|
||||||
{
|
|
||||||
public Guid CorrelationId { get; set; }
|
|
||||||
|
|
||||||
public string CurrentState { get; set; } = null!;
|
|
||||||
|
|
||||||
public string? Bucket { get; set; }
|
|
||||||
|
|
||||||
public string? PictureKey { get; set; }
|
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; }
|
|
||||||
|
|
||||||
public DateTime UpdatedAt { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
using MassTransit;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
|
|
||||||
namespace Musify.Infrastructure.MassTransit.Sagas
|
|
||||||
{
|
|
||||||
public sealed class AlbumProcessingStateMachine : MassTransitStateMachine<AlbumProcessingState>
|
|
||||||
{
|
|
||||||
public State Processing { get; private set; } = null!;
|
|
||||||
public State Failed { get; private set; } = null!;
|
|
||||||
|
|
||||||
public Event<CreateAlbumResourcesEvent> ProcessingStarted { get; private set; } = null!;
|
|
||||||
public Event<AlbumPictureProcessed> PictureProcessed { get; private set; } = null!;
|
|
||||||
public Event<AlbumPictureProcessingFailed> PictureFailed { get; private set; } = null!;
|
|
||||||
|
|
||||||
public AlbumProcessingStateMachine()
|
|
||||||
{
|
|
||||||
InstanceState(state => state.CurrentState);
|
|
||||||
|
|
||||||
Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.AlbumId));
|
|
||||||
Event(() => PictureProcessed, config => config.CorrelateById(context => context.Message.AlbumId));
|
|
||||||
Event(() => PictureFailed, config => config.CorrelateById(context => context.Message.AlbumId));
|
|
||||||
|
|
||||||
Initially(
|
|
||||||
When(ProcessingStarted)
|
|
||||||
.Then(context =>
|
|
||||||
{
|
|
||||||
context.Saga.CreatedAt = DateTime.UtcNow;
|
|
||||||
context.Saga.UpdatedAt = DateTime.UtcNow;
|
|
||||||
context.Saga.Bucket = context.Message.Bucket;
|
|
||||||
context.Saga.PictureKey = context.Message.PictureDestinationKey;
|
|
||||||
} )
|
|
||||||
.TransitionTo(Processing));
|
|
||||||
|
|
||||||
During(Processing,
|
|
||||||
When(PictureProcessed).Finalize(),
|
|
||||||
When(PictureFailed)
|
|
||||||
.Then(context => context.Saga.UpdatedAt = DateTime.UtcNow)
|
|
||||||
.TransitionTo(Failed)
|
|
||||||
.Publish(context => new AlbumProcessingFailed(
|
|
||||||
context.Saga.CorrelationId,
|
|
||||||
context.Saga.Bucket,
|
|
||||||
context.Saga.PictureKey)));
|
|
||||||
|
|
||||||
During(Failed,
|
|
||||||
Ignore(PictureProcessed),
|
|
||||||
Ignore(PictureFailed));
|
|
||||||
|
|
||||||
SetCompletedWhenFinalized();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage;
|
|
||||||
using MassTransit;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
using Musify.Infrastructure.Configuration;
|
|
||||||
using Musify.Infrastructure.MassTransit.Sagas;
|
|
||||||
using AppIDatabase = Musify.Application.Contracts.IDatabase;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Infrastructure.Persistence
|
|
||||||
{
|
|
||||||
public class Database(DatabaseConfiguration configuration)
|
|
||||||
: DbContext, AppIDatabase
|
|
||||||
{
|
|
||||||
private static readonly AuditableEntityInterceptor AuditInterceptor = new();
|
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
|
||||||
{
|
|
||||||
optionsBuilder
|
|
||||||
.UseNpgsql(configuration.ConnectionString, o => o.MapEnum<Genre>("genre"))
|
|
||||||
.AddInterceptors(AuditInterceptor);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
base.OnModelCreating(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.AddInboxStateEntity();
|
|
||||||
modelBuilder.AddOutboxMessageEntity();
|
|
||||||
modelBuilder.AddOutboxStateEntity();
|
|
||||||
|
|
||||||
var trackProcessing = modelBuilder.Entity<TrackProcessingState>();
|
|
||||||
trackProcessing.HasKey(state => state.CorrelationId);
|
|
||||||
trackProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
|
|
||||||
trackProcessing.Property(state => state.CurrentState).HasMaxLength(64);
|
|
||||||
|
|
||||||
modelBuilder.Entity<Track>()
|
|
||||||
.OwnsOne(track => track.Pictures, pictures =>
|
|
||||||
{
|
|
||||||
pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64);
|
|
||||||
pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64);
|
|
||||||
pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64);
|
|
||||||
pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64);
|
|
||||||
pictures.Property(p => p.ProcessingStatus).HasColumnName("PicturesProcessingStatus");
|
|
||||||
});
|
|
||||||
modelBuilder.Entity<Track>().Navigation(track => track.Pictures).IsRequired();
|
|
||||||
|
|
||||||
modelBuilder.Entity<Track>()
|
|
||||||
.OwnsOne(track => track.Audio, audio =>
|
|
||||||
{
|
|
||||||
audio.Property(a => a.OriginalName).HasColumnName("OriginalAudioName").HasMaxLength(64);
|
|
||||||
audio.Property(a => a.FolderName).HasColumnName("AudioFolderName").HasMaxLength(64);
|
|
||||||
audio.Property(a => a.TranscodeStatus).HasColumnName("AudioTranscodeProcessingStatus");
|
|
||||||
audio.Property(a => a.DownloadRequested).HasColumnName("DownloadRequested");
|
|
||||||
audio.Property(a => a.RetryCount).HasColumnName("RetryCount");
|
|
||||||
audio.Property(a => a.LastRetryAt).HasColumnName("LastRetryAt");
|
|
||||||
});
|
|
||||||
modelBuilder.Entity<Track>().Navigation(track => track.Audio).IsRequired();
|
|
||||||
|
|
||||||
modelBuilder.Entity<Track>()
|
|
||||||
.HasOne(track => track.Owner)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(track => track.OwnerUserId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
modelBuilder.Entity<PlayList>()
|
|
||||||
.Property(playList => playList.Visibility)
|
|
||||||
.HasDefaultValue(PlaylistVisibility.Private);
|
|
||||||
|
|
||||||
modelBuilder.Entity<PlayList>()
|
|
||||||
.OwnsOne(playList => playList.Pictures, pictures =>
|
|
||||||
{
|
|
||||||
pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64).IsRequired(false);
|
|
||||||
pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false);
|
|
||||||
pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false);
|
|
||||||
pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false);
|
|
||||||
});
|
|
||||||
modelBuilder.Entity<PlayList>().Navigation(playList => playList.Pictures).IsRequired(false);
|
|
||||||
|
|
||||||
modelBuilder.Entity<Album>()
|
|
||||||
.OwnsOne(album => album.Pictures, pictures =>
|
|
||||||
{
|
|
||||||
pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64);
|
|
||||||
pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64);
|
|
||||||
pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64);
|
|
||||||
pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64);
|
|
||||||
});
|
|
||||||
modelBuilder.Entity<Album>().Navigation(album => album.Pictures).IsRequired(false);
|
|
||||||
|
|
||||||
modelBuilder.Entity<Album>()
|
|
||||||
.HasOne(album => album.Owner)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(album => album.OwnerUserId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
modelBuilder.Entity<AlbumHasTrack>()
|
|
||||||
.HasOne(albumTrack => albumTrack.Album)
|
|
||||||
.WithMany(album => album.AlbumTracks)
|
|
||||||
.HasForeignKey(albumTrack => albumTrack.AlbumId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
modelBuilder.Entity<AlbumHasTrack>()
|
|
||||||
.HasOne(albumTrack => albumTrack.Track)
|
|
||||||
.WithMany(track => track.AlbumTracks)
|
|
||||||
.HasForeignKey(albumTrack => albumTrack.TrackId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
modelBuilder.Entity<AlbumHasTrack>()
|
|
||||||
.HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId })
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
modelBuilder.Entity<Mix>()
|
|
||||||
.HasIndex(mix => new { mix.UserId, mix.Position });
|
|
||||||
|
|
||||||
modelBuilder.Entity<MixItem>()
|
|
||||||
.HasOne(item => item.Mix)
|
|
||||||
.WithMany(mix => mix.Items)
|
|
||||||
.HasForeignKey(item => item.MixId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
modelBuilder.Entity<MixItem>()
|
|
||||||
.HasOne(item => item.Track)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(item => item.TrackId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
modelBuilder.Entity<MixItem>()
|
|
||||||
.HasIndex(item => new { item.MixId, item.Position });
|
|
||||||
|
|
||||||
modelBuilder.Entity<TrackLike>()
|
|
||||||
.HasIndex(like => new { like.UserId, like.TrackId })
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
modelBuilder.Entity<TrackTag>()
|
|
||||||
.HasOne(trackTag => trackTag.Track)
|
|
||||||
.WithMany(track => track.Tags)
|
|
||||||
.HasForeignKey(trackTag => trackTag.TrackId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
modelBuilder.Entity<TrackTag>()
|
|
||||||
.HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag })
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
modelBuilder.Entity<UserFollow>()
|
|
||||||
.HasOne(follow => follow.Follower)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(follow => follow.FollowerId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
modelBuilder.Entity<UserFollow>()
|
|
||||||
.HasOne(follow => follow.Followed)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(follow => follow.FollowedId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
modelBuilder.Entity<UserFollow>()
|
|
||||||
.HasIndex(follow => new { follow.FollowerId, follow.FollowedId })
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
var playListProcessing = modelBuilder.Entity<PlayListProcessingState>();
|
|
||||||
playListProcessing.HasKey(state => state.CorrelationId);
|
|
||||||
playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
|
|
||||||
playListProcessing.Property(state => state.CurrentState).HasMaxLength(64);
|
|
||||||
|
|
||||||
var albumProcessing = modelBuilder.Entity<AlbumProcessingState>();
|
|
||||||
albumProcessing.HasKey(state => state.CorrelationId);
|
|
||||||
albumProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
|
|
||||||
albumProcessing.Property(state => state.CurrentState).HasMaxLength(64);
|
|
||||||
}
|
|
||||||
|
|
||||||
public DbSet<User> Users => Set<User>();
|
|
||||||
|
|
||||||
public DbSet<Track> Tracks => Set<Track>();
|
|
||||||
|
|
||||||
public DbSet<PlayList> PlayLists => Set<PlayList>();
|
|
||||||
|
|
||||||
public DbSet<Album> Albums => Set<Album>();
|
|
||||||
|
|
||||||
public DbSet<AlbumHasTrack> AlbumHasTracks => Set<AlbumHasTrack>();
|
|
||||||
|
|
||||||
public DbSet<UserHasTrack> UserHasTracks => Set<UserHasTrack>();
|
|
||||||
|
|
||||||
public DbSet<PlayListHasTrack> PlayListHasTracks => Set<PlayListHasTrack>();
|
|
||||||
|
|
||||||
public DbSet<Mix> Mixes => Set<Mix>();
|
|
||||||
|
|
||||||
public DbSet<MixItem> MixItems => Set<MixItem>();
|
|
||||||
|
|
||||||
public DbSet<Upload> Uploads => Set<Upload>();
|
|
||||||
|
|
||||||
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
|
|
||||||
|
|
||||||
public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>();
|
|
||||||
|
|
||||||
public DbSet<TrackLike> TrackLikes => Set<TrackLike>();
|
|
||||||
|
|
||||||
public DbSet<TrackTag> TrackTags => Set<TrackTag>();
|
|
||||||
|
|
||||||
public DbSet<UserFollow> UserFollows => Set<UserFollow>();
|
|
||||||
|
|
||||||
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);
|
|
||||||
return new DatabaseTransaction(transaction);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class DatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction
|
|
||||||
{
|
|
||||||
public Task CommitAsync(CancellationToken cancellationToken) =>
|
|
||||||
inner.CommitAsync(cancellationToken);
|
|
||||||
|
|
||||||
public ValueTask DisposeAsync() => inner.DisposeAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-56
@@ -1,56 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Musify.Infrastructure.Persistence.Migrations
|
|
||||||
{
|
|
||||||
|
|
||||||
public partial class AddListeningHistory : Migration
|
|
||||||
{
|
|
||||||
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "ListeningHistory",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
UserId = table.Column<long>(type: "bigint", nullable: false),
|
|
||||||
TrackId = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
ListenedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_ListeningHistory", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_ListeningHistory_Tracks_TrackId",
|
|
||||||
column: x => x.TrackId,
|
|
||||||
principalTable: "Tracks",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_ListeningHistory_User_UserId",
|
|
||||||
column: x => x.UserId,
|
|
||||||
principalTable: "User",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_ListeningHistory_TrackId",
|
|
||||||
table: "ListeningHistory",
|
|
||||||
column: "TrackId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_ListeningHistory_UserId",
|
|
||||||
table: "ListeningHistory",
|
|
||||||
column: "UserId");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "ListeningHistory");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user