Author SHA1 Message Date
Admin 0cba32f776 Simplify infrastructure services and move all recurring jobs to Hangfire 2026-09-24 12:18:57 +02:00
Admin 0fa033faff Separate responsibilities: start-listening command, API-side JSON converters, bulk track endpoints, mix kinds 2026-09-24 12:14:53 +02:00
Admin 75d0c0817e Deduplicate picture flows: shared upload issuer, cover query and picture source change 2026-09-24 12:04:54 +02:00
Admin 4b147d9ee3 Add shared pagination, projections and ownership helpers; simplify query and command handlers 2026-09-24 11:59:34 +02:00
Admin 5a0f82b77b Clean up domain entities: shared picture value object, lifecycle and ownership interfaces, explicit tables 2026-09-24 11:43:50 +02:00
Admin 4d943e068e Unify picture configuration and options registration 2026-09-24 05:23:40 +02:00
Admin 5e819d7676 Remove dead code: unused storage/transcoder members, Upload table, retry columns, redundant packages 2026-09-24 05:19:59 +02:00
Admin 0e3576922f Fix query and API bugs: 403 for non-owners, page bounds, lifecycle filters, cover fallback, race-safe inserts, async album delete 2026-09-24 05:14:05 +02:00
Admin ab388e6ad6 Fix processing pipeline: report album failures, fault handling on create slips, config key names, audio duration 2026-09-24 05:02:04 +02:00
Admin c8e9c7790e Close data leaks: remove anonymous user creation, hide private playlists, validate uploads and clamp gateway ranges 2026-09-24 05:00:21 +02:00
Admin c8fe28b07b Add editorconfig and analyzers, format backend code 2026-09-24 04:54:17 +02:00
NotS1mp 87c3f97529 Merge pull request 'Web player general changes' (#22) from web-player-general-changes into master
CI/CD / BackEnd (push) Successful in 1m17s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Successful in 2m4s
Reviewed-on: #22
2026-09-23 22:37:30 +00:00
Admin 302dd1e0f8 Merge master into web-player-general-changes
CI/CD / BackEnd (push) Canceled after 0s
CI/CD / WebPlayer (push) Canceled after 0s
CI/CD / BackEnd (pull_request) Successful in 1m23s
CI/CD / WebPlayer (pull_request) Successful in 40s
CI/CD / Deploy (pull_request) Skipped
CI/CD / Deploy (push) Canceled after 0s
2026-09-24 00:32:03 +02:00
NotS1mp 421fc929e8 Merge pull request 'Dynamic genres' (#20) from dynamic-genres into master
CI/CD / BackEnd (push) Successful in 1m23s
CI/CD / WebPlayer (push) Successful in 39s
CI/CD / Deploy (push) Successful in 1m38s
Reviewed-on: #20
2026-09-23 22:24:22 +00:00
NotS1mp c4ab5dc388 Merge branch 'master' into dynamic-genres
CI/CD / BackEnd (push) Canceled after 0s
CI/CD / WebPlayer (push) Canceled after 0s
CI/CD / Deploy (push) Canceled after 0s
CI/CD / BackEnd (pull_request) Successful in 1m20s
CI/CD / WebPlayer (pull_request) Successful in 40s
CI/CD / Deploy (pull_request) Skipped
2026-09-23 22:15:22 +00:00
NotS1mp 920dcc6779 Merge pull request 'Listening time accuracy' (#19) from listening-time-accuracy into master
CI/CD / BackEnd (push) Successful in 1m18s
CI/CD / WebPlayer (push) Successful in 39s
CI/CD / Deploy (push) Successful in 1m40s
Reviewed-on: #19
2026-09-23 22:15:10 +00:00
Admin 430c711ff2 Refactor CI/CD workflow to improve database migration process and key generation
CI/CD / BackEnd (push) Successful in 1m18s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
CI/CD / BackEnd (pull_request) Successful in 1m27s
CI/CD / WebPlayer (pull_request) Successful in 39s
CI/CD / Deploy (pull_request) Skipped
2026-09-23 23:59:45 +02:00
Admin cfdc61a4b8 Scroll content inside the shell so the player shares the ambient backdrop
CI/CD / BackEnd (push) Successful in 1m17s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
The player dock used to sit fixed over the scrolling page, so it needed an
opaque background. The shell is now a fixed-height frame where only the
content column scrolls, letting the dock stay transparent like the sidebar
and queue. Scroll reset/restore on navigation moves to restoreScroll(), and
the stable scrollbar gutter moves from <html> to the content scroller.
2026-09-23 23:52:27 +02:00
Admin 0f28e19c1d Remove follow button from user rows
CI/CD / BackEnd (push) Successful in 1m24s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
2026-09-23 23:20:27 +02:00
Admin 7316e355ee Add a second greeting variant per time slot 2026-09-23 23:20:27 +02:00
Admin b39e55fd87 Restore master hero and spotlight gradients and sway them in place 2026-09-23 23:15:15 +02:00
Admin cb046bba9e Let users browse followers and following lists
Following lists are public; followers are visible to the user and to mutual followers.
Adds GET /users/{id}/followers and /following, exposes isFollowing and canViewFollowers
on the profile, and returns isFollowedByViewer from user search so the web player no
longer calls /is-following per user.
2026-09-23 22:55:14 +02:00
Admin b9a40186b1 Lighten active surface gradients in light mode
CI/CD / BackEnd (push) Successful in 1m15s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
2026-09-23 22:44:42 +02:00
Admin fd0a39b879 IsExplicit 2026-09-23 22:42:28 +02:00
Admin cc7afe4326 Add explicit flag to tracks and mark it on upload 2026-09-23 22:41:57 +02:00
Admin 72cbc18a48 Add follow button next to each user in search results
Extract FollowButton and shared follow/unfollow actions, reused by the user profile page.
2026-09-23 22:34:56 +02:00
Admin 84408b842b Semibold 2026-09-23 22:25:04 +02:00
Admin 339fcb56ad Semibold 2026-09-23 22:24:57 +02:00
Admin 74cf534be5 Animate queue drag and drop reordering 2026-09-23 22:18:21 +02:00
Admin 89820c1377 Fix gradient background syntax in Artwork component 2026-09-23 22:10:46 +02:00
Admin 2fe26b15dd Allow reordering upcoming tracks in the queue by drag and drop 2026-09-23 22:10:21 +02:00
Admin 2cb560fda4 Keep light mode gradients visible while drifting 2026-09-23 22:07:50 +02:00
Admin d8530b9209 Animate menu close with shared pop transition 2026-09-23 22:01:51 +02:00
Admin 5049b0ba91 Update global icon stroke width to 1.5 in layout.css 2026-09-23 21:59:24 +02:00
Admin 1b98a75c0e Added shadow 2026-09-23 21:59:13 +02:00
Admin cbbde95773 Set global icon stroke width and remove per-icon strokeWidth props 2026-09-23 21:55:00 +02:00
Admin 26bb8a50a3 Solid player bar, gradient sliders and smooth progress 2026-09-23 21:48:39 +02:00
Admin bf19bb31f9 Make accent hue crossfade between songs last 2 to 4 seconds 2026-09-23 21:40:22 +02:00
Admin 55cc803708 Drift accent gradients on buttons, hero, spotlight, active rows and modals 2026-09-23 21:37:41 +02:00
Admin 805e521b1d Animate ambient background, add mute toggle and smooth queue panel 2026-09-23 21:36:01 +02:00
Admin f8d431a0d7 Implement volume control with mute functionality; update PlayerState to manage muted state 2026-09-23 21:28:17 +02:00
Admin 7b72db8a24 Add ambient drift animation to app backdrop; adjust inset and handle reduced motion 2026-09-23 21:28:07 +02:00
Admin b61ff1ebec Make Discover and upload genres dynamic
CI/CD / BackEnd (push) Successful in 1m22s
CI/CD / WebPlayer (push) Successful in 38s
CI/CD / Deploy (push) Skipped
CI/CD / BackEnd (pull_request) Successful in 1m21s
CI/CD / WebPlayer (pull_request) Successful in 40s
CI/CD / Deploy (pull_request) Skipped
Discover used a hardcoded list of genre tiles that searched track titles,
and the upload form kept its own copy of the genre list and compatibility
rules. Both now come from the backend.

Backend:
- GET /genres returns genres with at least one track and their counts.
- GET /genres/available returns every assignable genre with the genres
  it cannot be combined with, so the rules live only in
  GenreCompatibility.
- GET /tracks accepts ?genre= (case-insensitive, 400 on unknown values).

web-player:
- Discover tiles are built from /genres; each opens /explore?genre=X, a
  song list filtered by that tag. Unknown genres fall back to a label
  derived from their name.
- The upload form loads /genres/available, disables incompatible genres
  as you pick, and the server validates before reserving upload URLs.
- lib/data/trackGenres.ts is removed; its helpers merge into
  lib/data/genres.ts.
- Regenerate the typed API client.
2026-09-23 20:05:30 +02:00
Admin 8870c65c6f Move ListeningRules to its own file and split listening tests by subject
CI/CD / BackEnd (push) Successful in 1m16s
CI/CD / BackEnd (pull_request) Canceled after 0s
CI/CD / WebPlayer (pull_request) Canceled after 0s
CI/CD / Deploy (pull_request) Canceled after 0s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
The counted-listen tests lived in one catch-all UncountedListensTests
file. Each now sits next to the handler it covers, and the cleanup
handler gets its own test file.
2026-09-23 20:02:50 +02:00
Admin 5df5e635f3 Count real listening time instead of full track duration
"Listening time this week" summed the full duration of every track whose
stream ticket was issued, so pressing play and skipping counted the whole
song. Playback is now measured on the client and reported to the API.

Backend:
- ListeningHistory gains PlayedSeconds, LastProgressAt and IsCounted
  (migration TrackPlayedSeconds marks existing rows as counted).
- /tracks/{id}/stream returns a listenId; new
  PUT /tracks/listens/{listenId}/progress stores the accumulated seconds,
  capped by track duration and elapsed wall-clock time, monotonic and
  owner-only.
- A listen counts once it reaches min(30s, 50% of the track). Weekly
  stats, streak, recent history/albums, mixes and listensCount only use
  counted listens; seconds sum PlayedSeconds.
- ListeningHistoryCleanupJob (daily) deletes uncounted listens older
  than 8 days.

web-player:
- ListenTracker accumulates played time (seeks are ignored) and flushes
  on pause, track change, ended, every 30s, and on pagehide.
- Proxy route /api/listens/[id]/progress; repeat starts a new listen per
  loop.
2026-09-23 20:00:45 +02:00
NotS1mp d5e3cadc43 Merge pull request 'Web player missing features' (#3) from web-player-missing-features into master
CI/CD / BackEnd (push) Successful in 1m22s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Successful in 1m35s
Reviewed-on: #3
2026-09-23 17:07:09 +00:00
Admin 505ae05c53 Refactor AuditableEntityInterceptor to use property access for UpdatedAt; update Database configuration to add interceptor and adjust tests for consistency
CI/CD / BackEnd (push) Successful in 1m16s
CI/CD / WebPlayer (push) Successful in 39s
CI/CD / Deploy (push) Skipped
CI/CD / BackEnd (pull_request) Successful in 1m17s
CI/CD / WebPlayer (pull_request) Successful in 39s
CI/CD / Deploy (pull_request) Skipped
2026-09-23 19:04:21 +02:00
Admin a7929c0ac9 Enhance upload functionality with genre selection and validation; refactor session cookie handling
CI/CD / WebPlayer (push) Successful in 39s
CI/CD / Deploy (push) Canceled after 0s
CI/CD / BackEnd (push) Canceled after 39s
2026-09-23 18:33:21 +02:00
NotS1mp 4d39008eab Merge pull request 'Web Player Refactor' (#2) from web-player-ref into master
CI/CD / BackEnd (push) Successful in 1m16s
CI/CD / WebPlayer (push) Successful in 38s
CI/CD / Deploy (push) Successful in 1m42s
Reviewed-on: #2
2026-09-23 15:05:11 +00:00
Admin 5efd59826b Use the shared edge nginx instead of Musify's own
CI/CD / BackEnd (push) Successful in 1m27s
CI/CD / WebPlayer (push) Successful in 39s
CI/CD / Deploy (push) Skipped
CI/CD / BackEnd (pull_request) Successful in 1m16s
CI/CD / WebPlayer (pull_request) Successful in 39s
CI/CD / Deploy (pull_request) Skipped
Remove deploy/nginx and the nginx service from compose.prod.yml: the
shared edge in Infrastructure owns ports 80/443 and serves every
domain. Make the deploy job fail when zitadel-init or seaweedfs-init
fail, and document the runner and edge setup.
2026-09-23 16:56:27 +02:00
Admin 8970f29410 Refactor CI/CD job names for clarity and consistency
CI/CD / BackEnd (push) Successful in 1m20s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
2026-09-23 16:14:53 +02:00
Admin 523318fc06 Fix 2026-09-23 16:00:39 +02:00
Admin ba531b4f61 Add actions 2026-09-23 05:28:11 +02:00
Admin 3d51f08e0b Changes on header 2026-09-23 01:40:16 +02:00
Admin 2660c124d5 Simplify design 2026-09-23 01:33:25 +02:00
Admin d7199b4d1d Changes on the colors 2026-09-22 22:23:06 +02:00
Admin c60704080b Restore dark-mode accentL to its original 0.74
0.65 was still too dark per feedback. The original concern that made
us drop accentL from 0.74 (accent surfaces stacking too bright over
the darkened spotlight/hero cards) is already fixed architecturally by
the earlier color-mix() conversion, which makes bg-accent-tint/
bg-accent-btn a solid precomputed value that no longer stacks with
whatever's underneath. So there's no reason to keep accentL dimmed for
that reason anymore.
2026-09-22 20:40:23 +02:00
Admin 1e3e46cfdc Brighten dark-mode accentL back up slightly
0.55 (dropped from the original 0.74 to fix accent surfaces looking
too bright when stacked on the darkened spotlight/hero cards) made
bg-accent-tint on the plain page background barely visible. Split the
difference at 0.65, closer to how it looked originally.
2026-09-22 20:38:03 +02:00
Admin 93d4220f3a Make accent backgrounds solid so they don't stack on tinted surfaces
--mf-accent-soft-bg/--mf-accent-btn-bg/-hover were translucent alpha
overlays, so an accent-tinted element (e.g. the active-row highlight,
or the accent Button) rendered differently depending on what was
already underneath it: fine over the plain page background, but over
an already-tinted surface like the spotlight card, the two translucent
layers stacked and the combined result read brighter/more saturated
than intended.

Same fix as bg-btn earlier: replace the alpha overlay with a solid
color-mix() against --mf-bg, computed once, so the token always
renders identically regardless of what it's placed on top of. Verified
the active-row highlight and the accent button now compute to the
exact same color whether they sit on the plain background or on the
spotlight card.
2026-09-22 20:33:25 +02:00
Admin 8a748f2b3a Darken hero/spotlight cards further and adapt accent buttons to match
Per feedback, --mf-hero-bg/--mf-spotlight-bg in dark mode were still
too bright: drop their tc() base lightness from 42 to 38 and alpha
from 0.2 to 0.1, so the cards read as a subtle wash close to the page
background instead of a distinct bright block.

That made bg-accent-tint (Button's accent variant, ListRow/TrackList's
active-row highlight) look comparatively too light against the now
much darker cards, since accentL was still 0.74. Drop dark's accentL
to 0.55 so every accent-derived surface (rings, accent-tint, accent-btn
hover) darkens to match. Text (titleL, still 0.87) is untouched since
it needs to stay legible regardless of how dark the surface under it
gets.

Cleaned up the now-unused ACCENT_LIGHTNESS import/L constant.
2026-09-22 20:27:17 +02:00
Admin 2138a4a890 Shrink menu item text and drop the border on the account dropdown
MenuItem used text-sm (14px) for its label, which read as oversized
for a dropdown; drop to text-xs (12px). This is a shared component
(AccountMenu, ContextMenu, MobileHeader's menu), so all of them get
the more compact sizing.

Add a border prop to GlassMenu (default true, unchanged for
ContextMenu) and set it false specifically on AccountMenu's dropdown,
per feedback that its border didn't fit.
2026-09-22 20:19:58 +02:00
Admin 94a1981a4c Soften dark-mode accent colors (buttons and text)
Dark mode's accent chroma came from the ACCENT_CHROMA constant (0.145,
originally meant as a generic default), used unconditionally for every
accent-derived token regardless of the actual song's extracted vividness
- extractAccent() already clamps a song's real chroma to 0.045-0.11, but
buildThemeTokens() never used it, only the hue. That made every accent
button, ring and highlight uniformly vivid. Drop it to 0.09.

Also soften --mf-accent-title (the accent-colored track title text,
e.g. active rows) from 0.07 to 0.05 chroma, since that was called out
specifically as too strong.

Cleaned up the now-unused C/ACCENT_CHROMA import.
2026-09-22 20:14:46 +02:00
Admin 2272a401bb Dim the dark-mode primary button further
#e6e6e8 still read as too white per feedback. Use #c4c4c8, a clearly
gray tone instead of an off-white, while text-ink still contrasts
comfortably against it.
2026-09-22 20:05:03 +02:00
Admin 3ab43a2ef6 Dim the dark-mode primary button from pure white to a soft off-white
--mf-cta-strong-bg was #ffffff in dark mode, reading as too bright/
glaring against the dark surfaces around it. Use #e6e6e8 instead, a
still-white but slightly dimmed tone. Light mode's own value (#17171a,
already dark) is untouched.
2026-09-22 20:02:33 +02:00
Admin 79698f2265 Brighten secondary button text for dark-mode legibility
secondary used text-fg-2 (--mf-text-2), which in dark mode is a fairly
dim mid-gray (#8a8a8e) that read poorly against the dark bg-btn fill,
even though the same class looks great in light mode where --mf-text-2
is a dark, high-contrast gray. Use text-fg instead, which is tuned
bright in dark mode and dark in light mode, so both modes get good
contrast from the same class. Drop the now-redundant hover:text-fg
since resting state already uses it.
2026-09-22 19:58:39 +02:00
Admin 94f80c03c5 Make dark-mode hero/spotlight cards a solid color, not a fading gradient
--mf-hero-bg/--mf-spotlight-bg were multi-stop, directional gradients
in dark mode (fading from a vivid corner to transparent), unlike light
mode which uses the same color at both gradient stops so it reads as
flat. Match that: both now use a single tc() tone repeated at both
stops, so the hero and spotlight cards render as one solid tint in
dark mode too.
2026-09-22 19:53:21 +02:00
Admin d98d01ab20 Make the dark-mode ambient glow more subtle and spread further
The two radial glows behind app-backdrop were fairly small (52%/38%
and 46%/34% ellipses) and faded out by half their radius, so they only
covered the top portion of the viewport at a fairly strong 0.26/0.2
alpha. Enlarge both ellipses and push the fade-out point out to 88%,
so the tint reaches much further across the screen like light mode's
flat wash does, while lowering the alpha so it reads as subtle instead
of a bright patch in the corners.
2026-09-22 19:50:23 +02:00
Admin 6be5c43e6d Let sidebar and queue show the same ambient backdrop as the center
Sidebar and Queue painted their own opaque bg-bg, which sat in front of
the fixed, full-viewport app-backdrop div (z-index: -10) that carries
--mf-ambient's radial-gradient glow in dark mode. Since the main content
area has no background of its own, the glow only ever showed through
there — sidebar and queue blocked it with a flat, gradient-less fill of
the same base color.

Drop bg-bg from both so the same app-backdrop (color + ambient gradient)
shows through underneath them too. In light mode this is a no-op
(--mf-ambient is 'none' and app-backdrop's flat color already matches
what bg-bg painted), so only dark mode is visually affected.
2026-09-22 19:46:25 +02:00
Admin 8deb762a52 Revert chrome-bg: sidebar/queue should just be the tinted background
Per feedback, the solid color-mix bar was wrong — sidebar, queue and
the mobile header should stay on the plain --mf-bg (the same subtly
hue-shifting black used everywhere else), exactly like light mode
already does, not a separately-boosted colored panel. Drop --mf-chrome-bg/
--color-chrome and switch those three back to bg-bg.

The general dark-mode richness bump (tc(), --mf-bg/--mf-elevated/etc.)
and the glass-bar background-color fix from the previous commit stay;
only the separate chrome treatment is reverted.
2026-09-22 19:02:10 +02:00
Admin fe21c19810 Make dark mode more colorful and give chrome bars a full tint
Mirror the earlier light-mode pastel/colorful pass for dark mode:
boost tc()'s lightness and chroma, and richen --mf-bg/--mf-elevated/
--mf-panel-bg/--mf-bar-bg/--mf-hairline the same way, so dark mode
reads as colorful instead of near-grayscale.

Sidebar, Queue, and MobileHeader used bg-bg directly, so in dark mode
they stayed almost black regardless of hue. Add --mf-chrome-bg (an
alias to --mf-bg in light mode, a solid color-mix of --mf-hero-tint
into --mf-bg in dark mode) and switch those three to bg-chrome, so
they read as a full, solid colored bar in dark mode instead of a
fading gradient (per feedback: a whole bar, not a gradient).

Also fixes glass-bar (PlayerBar/TabsBar): it set
`background-image: var(--mf-bar-bg)`, but --mf-bar-bg is a plain
color, not a valid <image>, so the declaration was silently dropped
and these bars never actually showed their tint in either mode. Changed
to background-color.
2026-09-22 18:57:16 +02:00
Admin 6e86c3d2c5 Carry track duration through the queue pipeline
ApiTrackLike/QueueItem had no duration field, so toQueueItem(s) always
dropped it and toTrack() hardcoded a new track's duration to 0, relying
entirely on the <audio> element's durationchange/loadedmetadata events
to fill it in once that track actually became current. Upcoming queue
rows never reach that point, so "A continuación" always showed a blank
duration even though the backend already returns it.

Add duration to both types, thread it through toQueueItems/toQueueItem/
trackFromQueueItem, and seed toTrack() from it. The real-time audio
sync still overwrites it once a track actually plays, so this only
fills the gap for tracks that haven't played yet.

Also map MixItemApplicationResponse's durationSeconds through
mixItemTrack, since mixes use a differently-named field.
2026-09-22 18:46:17 +02:00
Admin aca4b73fac Match the accent button's background to the active-row highlight
Button's accent variant used bg-accent-btn (15% alpha) while the
active-track highlight in ListRow/TrackList uses bg-accent-tint (8%
alpha) with the same text-accent-soft title color, so a button like
"Reproducir" sat at a visibly different tint than the row it plays.
Use bg-accent-tint for accent's resting background too, so both read
as the same accent color; hover still steps up to bg-accent-btn-hover.
2026-09-22 18:41:03 +02:00
Admin 078f7ac6f6 Stop Button's generic transition from lagging its hue-driven background
Button used Tailwind's bare `transition` utility, whose default
property list includes background-color. Since the button's bg is a
CSS custom property recomputed every frame by the same 600ms hue
animation the main background uses, that extra transition smoothed it
on a different (~150ms) timing, so its color change didn't match the
page background's.

Add a btn-transition utility that mirrors Tailwind's default
transition list minus background-color, so the button's background
tracks the hue animation frame-for-frame like the rest of the app,
while hover/focus feedback (text color, ring, filter) still transitions
normally.
2026-09-22 18:08:58 +02:00
Admin b168df6ca6 Make the hero/spotlight surface tint more transparent
--mf-hero-bg/--mf-spotlight-bg's overlay tint was at 16% alpha, which
after the pastel lightness/chroma boost read as a fairly solid block
of color rather than a soft wash. Halve it to 8% so more of the plain
page background shows through underneath.

Keep the secondary button's color-mix ratio (in layout.css) in sync at
8% too, since it's built to replicate exactly what the surface behind
it looks like.
2026-09-22 18:06:11 +02:00
Admin 8d178884d4 Sync sidebar/queue/player bar color transitions with the main background
Sidebar, Queue, PlayerBar, TabsBar, and the hero/spotlight surfaces all
had an extra CSS `transition: background 0.5s ease-out` (theme-transition/
glass-bar) layered on top of backgrounds that are already animated
frame-by-frame over 600ms by the hue-interpolation loop in +layout.svelte.
That extra transition made them visibly lag behind and take a different
amount of time than the main app-backdrop, which has no such transition
and therefore tracks the JS animation exactly. Drop the redundant
transition so every hue-driven surface updates in lockstep.

Also fixes a dangling --color-btn-hover: var(--mf-btn-bg-hover) that
referenced an undefined custom property (theme.css never defined
--mf-btn-bg-hover after an earlier partial commit), which silently
broke the secondary button's hover background.
2026-09-22 18:01:25 +02:00
Admin cd2131ef6f Make the dynamic light-mode hue palette more pastel and colorful
The hue-driven light-mode tokens (page bg, hero/modal/spotlight
gradients, hairlines, logo/cover art gradients) leaned nearly
grayscale, with just a whisper of tint. Push tcLight()'s lightness
and chroma up together so every derived surface stays light and soft
but reads as an actual color instead of a faint gray wash, and bump
chroma on --mf-bg/--mf-elevated/--mf-panel-bg/--mf-bar-bg/--mf-hairline
and the logo/cover gradient stops the same way.

Scoped to light mode only: dark mode's accents are meant to read as
bright highlights against near-black surfaces, where a pastel wash
would just look washed out rather than colorful.
2026-09-22 17:55:39 +02:00
Admin d6bff15f11 Replace secondary button's backdrop-blur with an equivalent fixed color
backdrop-blur samples whatever is actually behind the element, so two
secondary buttons with identical classes could render differently
depending on nearby content (e.g. dark album art bleeding into the
blur near the spotlight card). Replace it with a fixed color-mix that
reproduces the same result mathematically instead of sampling pixels:
45% --mf-elevated over the hero/spotlight surface's own tint color
(16% --mf-hero-tint over --mf-bg, the same ratio hero-surface itself
uses), computed via CSS color-mix() rather than a backdrop-filter.

Verified against the previous blur version: both now compute to the
identical color(srgb 0.9329 0.9611 0.9332), matching what the blurred
button rendered against the same hero background, but fixed so every
secondary button is now guaranteed pixel-identical regardless of what
happens to be nearby.
2026-09-22 17:50:59 +02:00
Admin b82fca0bc2 Drop backdrop-blur on secondary for a consistent hue-tied tint
Sampling whatever's behind the button via backdrop-filter meant two
secondary buttons could render differently depending on what happened
to be nearby (e.g. dark album art bleeding into the blur), so
"Explorar musica" and "Aleatorio" ended up visibly different colors
despite being the same variant. Replace it with a fixed, hue-tied
translucent tint (bg-accent-tint, the same token family accent already
uses) so every secondary button computes to the identical color, ties
into the same dynamic accent hue as the rest of the app, and reads as
a softer sibling of the accent button instead of an unrelated gray.

Hover now steps up to bg-accent-btn (same hue family, stronger) instead
of a flat, hue-agnostic gray. Removed the now-unused --mf-btn-bg-hover/
--color-btn-hover tokens.
2026-09-22 17:34:34 +02:00
Admin 4fbd4fa81d Make secondary button translucent so it tracks whatever surface it's on
bg-elevated is fully opaque and only hue-matches --mf-bg (the plain
page background), so it stayed stark white on the hero/spotlight cards,
which use a separately-computed, more saturated gradient (--mf-hero-bg/
--mf-spotlight-bg). An opaque fill can never pick up that color no
matter what hue it's tied to.

Make it translucent (bg-elevated/45) with backdrop-blur-lg so it
samples and lightly tints whatever is actually behind it, hero card,
spotlight card, or plain background, instead of only matching one of
them.
2026-09-22 17:31:22 +02:00
Admin 68b5709d88 Tie secondary button color to the page background instead of a flat tint
secondary was fully bg-transparent (invisible at rest) or a flat neutral
overlay (clashed next to accent-tinted buttons and looked different
depending on what surface it sat on). Use bg-elevated instead, which is
already derived from the same hue as --mf-bg, so the button is always a
faint, minimal step above whatever page background it's on, and shifts
with it automatically.

Drop the now-unused --color-btn/--mf-btn-bg tokens.
2026-09-22 17:23:57 +02:00
Admin 434b2c1305 Make the secondary button adapt to shifting backgrounds
secondary used a flat, static tint that only worked over a plain
surface, so it looked out of place on the hero's hue-shifting gradient.
Add a backdrop blur (no border, per feedback) so it always reads
against whatever's actually behind it. Gave the button its own
background tokens instead of aliasing --mf-surface-2, and dropped the
now-unused --color-btn-glass tokens left over from the removed glass
variant.
2026-09-22 17:15:53 +02:00
Admin 9064ef687c Changes on the design 2026-09-22 17:10:26 +02:00
Admin c93f3d352c Revert "Make the glass button variant more transparent and soften accent text"
This reverts commit f52efa0f87.
2026-09-22 16:45:33 +02:00
Admin a9bfcbbe2f Make the glass button variant more transparent and soften accent text
The secondary/glass Button variant used a flat, fairly opaque black or
white tint with no blur, so it didn't read as glass and clashed with
the hue-shifting hero background. Add a backdrop blur and a hairline
border (matching the app's existing glass-bar/glass-panel treatment)
and lower the tint opacity.

Wire up the already-computed but unused --mf-accent-muted token as
--color-accent-muted, and use it for the accent Button variant's text
instead of the higher-chroma --color-accent-soft, so it reads softer.
2026-09-22 16:42:56 +02:00
Admin 717e939038 Fix permanent login failure after Zitadel restarts
getOidcConfig() memoized the discovery promise even when it rejected,
so a single failed OIDC discovery (e.g. Zitadel not ready yet when the
dev server starts) permanently broke /auth/login until the process was
restarted. Clear the cached promise on rejection so it retries.
2026-09-22 16:29:58 +02:00
Admin dd1cb8a5d1 Clean up backend and web-player leftovers
Remove dead scaffold/config cruft and stale references left over from
recent refactors: dotnet's WebApi.http template file, the AppSettings
PascalCase/Dockerfile workaround (renamed to appsettings.json to match
convention), empty directories left behind by the auth-route and
folder-structure moves, a stale ESLint ignore, and the now-fully-applied
frontend refactor plan doc.
2026-09-22 16:08:44 +02:00
Admin 7c9399a342 Translate frontend-refactor-plan.md to English
Same content and structure, just in English now and reformatted with
prettier for consistent table alignment.
2026-09-22 14:35:36 +02:00
Admin a3ec9d19d3 Translate paginas.md to English, clean up CLAUDE.md tone, drop finished plan
Rewrites CLAUDE.md without em dashes and with plainer phrasing, and
translates docs/paginas.md to English the same way. The folder
structure plan is done, so it gets removed instead of kept around as
a stale reference.
2026-09-22 14:29:50 +02:00
Admin 5f2b5bd5a2 Translate CLAUDE.md to English and document new folder structure
Fixes the stale /login reference (now /auth) and adds a Folder
structure section covering lib/state, lib/data, lib/utils,
components/layout and the ui/ category split from the recent
reorganization.
2026-09-22 14:24:07 +02:00
Admin 3bb4e66ad0 Reflect completed auth/user route consolidation in docs 2026-09-22 14:21:00 +02:00
Admin fbb102ffbd Consolidate auth routes under /auth and rename /u to /user
Moves the login screen from /login to /auth, logout from /logout to
/auth/logout (alongside the existing /auth/login and /auth/callback
endpoints), and the public profile route from /u/[id] to /user/[id].

AUTH_REDIRECT_URI and AUTH_POST_LOGOUT_URI already point at
/auth/callback and / respectively, so no Zitadel config change is
needed for the auth move.
2026-09-22 14:20:46 +02:00
Admin 76f7535ffa Update folder structure plan registro after F1-F4/F6 2026-09-22 14:16:37 +02:00
Admin 7fcf0645da Categorize components/ui into primitives, overlay, media, forms and layout
Splits the flat 38-component ui/ folder into primitives/ (generic
controls), overlay/ (menus/modals), media/ (music-domain display
components), forms/ (composite entity forms) and layout/ (page
scaffolding), and updates every import path accordingly.
2026-09-22 14:16:25 +02:00
Admin 50618865e7 Move app chrome components into components/layout 2026-09-22 14:12:11 +02:00
Admin fe2f6a785a Move format helpers into lib/utils 2026-09-22 14:11:30 +02:00
Admin 53fc24d4bc Move domain data helpers into lib/data 2026-09-22 14:10:55 +02:00
Admin ff5fa501af Remove leftover old paths from the lib/state move 2026-09-22 14:10:15 +02:00
Admin 6e9883018a Add folder structure reorganization plan 2026-09-22 14:09:37 +02:00
Admin a985ff4fb3 Move rune-based state modules into lib/state 2026-09-22 14:09:36 +02:00
606 changed files with 22944 additions and 15448 deletions
+127
View File
@@ -0,0 +1,127 @@
name: CI/CD
on:
push:
branches: ['**']
pull_request:
branches: ['**']
concurrency:
group: production-deploy
cancel-in-progress: false
jobs:
backend-tests:
name: BackEnd
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore Musify.slnx
- name: Build
run: dotnet build Musify.slnx --no-restore -c Release
- name: Test
run: dotnet test Musify.slnx --no-build -c Release
web-player-checks:
name: WebPlayer
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: web-player
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: web-player/package-lock.json
- run: npm ci
- name: Type-check
run: npm run check
- name: Lint
run: npm run lint
# Runs on the production host itself, through the existing self-hosted
# Gitea Actions runner (act_runner), which also carries a "production"
# label registered in native (non-docker) mode so its steps see the host's
# real Docker daemon directly (Docker socket + a bind-mounted deploy
# directory — see deploy/README.md for how that runner is set up).
#
# DEPLOY_PATH is the persistent clone already on that host (the one whose
# deploy/.env.prod is filled in and matches SharedServices/.env.prod) —
# deploy reuses it instead of a fresh checkout, since .env.prod is
# untracked and only lives there.
deploy:
name: Deploy
needs: [backend-tests, web-player-checks]
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
runs-on: [production]
timeout-minutes: 20
env:
DEPLOY_PATH: /home/ubuntu/deploys/musify
COMPOSE: docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml
steps:
- name: Pull latest master
run: |
set -euo pipefail
git config --global --add safe.directory "$DEPLOY_PATH"
cd "$DEPLOY_PATH"
git fetch origin master
git reset --hard origin/master
- name: Generate stream-ticket keys
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE run --rm --no-deps keys-init
# The migrations are baked into the migrator image, so it is rebuilt on
# every deploy (plain `up` would reuse the stale image and re-apply the
# old migrations). `run --rm` starts wait-for-postgres, blocks until the
# migration finishes and propagates its exit code, so a failed migration
# aborts the deploy before the new API is started against an old schema.
- name: Migrate database
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE build migrate
$COMPOSE run --rm migrate
# Both jobs verify themselves through the public auth./s3. URLs, served
# by the shared edge nginx (Infrastructure's edge/), and are idempotent.
- name: Create OIDC app and S3 bucket
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE up -d seaweedfs-init zitadel-init
for job in musify-seaweedfs-init musify-zitadel-init; do
code=$(docker wait "$job")
if [ "$code" != "0" ]; then
docker logs "$job"
echo "$job exited with $code"
exit 1
fi
done
- name: Build and start apps
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE --profile apps up -d --build api worker gateway web-player
+3 -1
View File
@@ -7,6 +7,9 @@ obj/
*.userosscache *.userosscache
.vs/ .vs/
artifacts/ artifacts/
TestResults/
*.trx
*.coverage
# Rider / VS # Rider / VS
.idea/ .idea/
@@ -25,5 +28,4 @@ Thumbs.db
# Deploy secrets and runtime state # Deploy secrets and runtime state
deploy/keys/ deploy/keys/
deploy/secrets/ deploy/secrets/
deploy/nginx/certs/
deploy/zitadel/.output/ deploy/zitadel/.output/
+65
View File
@@ -0,0 +1,65 @@
root = true
[*]
charset = utf-8
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
[*.{csproj,props,targets,slnx,xml}]
indent_size = 2
[*.json]
indent_size = 4
[**/Migrations/**.cs]
generated_code = true
[*.cs]
indent_size = 4
csharp_style_namespace_declarations = file_scoped:warning
csharp_using_directive_placement = outside_namespace:warning
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
dotnet_diagnostic.IDE0005.severity = warning
dotnet_style_require_accessibility_modifiers = always:warning
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
csharp_prefer_braces = when_multiline:warning
csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
dotnet_naming_rule.private_fields.symbols = private_fields
dotnet_naming_rule.private_fields.style = camel_case
dotnet_naming_rule.private_fields.severity = warning
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_symbols.private_fields.required_modifiers =
dotnet_naming_style.camel_case.capitalization = camel_case
dotnet_naming_rule.private_constants.symbols = private_constants
dotnet_naming_rule.private_constants.style = pascal_case
dotnet_naming_rule.private_constants.severity = warning
dotnet_naming_symbols.private_constants.applicable_kinds = field
dotnet_naming_symbols.private_constants.required_modifiers = const
dotnet_naming_style.pascal_case.capitalization = pascal_case
# Analyzer rules that would need behaviour or API changes; downgraded in P0, revisit in P15.
dotnet_diagnostic.CA1848.severity = suggestion
dotnet_diagnostic.CA1873.severity = suggestion
dotnet_diagnostic.CA1305.severity = suggestion
dotnet_diagnostic.CA1707.severity = suggestion
dotnet_diagnostic.CA1859.severity = suggestion
dotnet_diagnostic.CA1716.severity = suggestion
dotnet_diagnostic.CA1000.severity = suggestion
dotnet_diagnostic.CA1725.severity = suggestion
dotnet_diagnostic.CA1816.severity = suggestion
dotnet_diagnostic.CA1822.severity = suggestion
dotnet_diagnostic.CA1852.severity = suggestion
dotnet_diagnostic.CA1068.severity = suggestion
dotnet_diagnostic.CA1711.severity = suggestion
dotnet_diagnostic.CA1861.severity = suggestion
@@ -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();
}
}
}
@@ -0,0 +1,77 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums;
/// <summary>Appends your own tracks to an album in the given order, skipping the ones it already has.</summary>
public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList<Guid> TrackIds)
: ICommand<ErrorOr<Success>>;
public class AddTracksToAlbumCommandHandler(
IDatabase database,
ILogger<AddTracksToAlbumCommandHandler> logger)
: ICommandHandler<AddTracksToAlbumCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken)
{
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
if (album.IsError)
return album.Errors;
var requestedIds = request.TrackIds.Distinct().ToList();
var tracks = await database.Tracks
.AsNoTracking()
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(track => new { track.Id, track.OwnerUserId })
.ToListAsync(cancellationToken);
var missing = requestedIds.FirstOrDefault(id => tracks.All(track => track.Id != id));
if (missing != Guid.Empty)
return AppErrors.NotFound("Track", missing);
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
if (foreign != null)
return AppErrors.Forbidden("Track", foreign.Id);
var alreadyInAlbum = await database.AlbumHasTracks
.AsNoTracking()
.Where(link => link.AlbumId == request.AlbumId && requestedIds.Contains(link.TrackId))
.Select(link => link.TrackId)
.ToListAsync(cancellationToken);
var toAdd = requestedIds.Where(id => !alreadyInAlbum.Contains(id)).ToList();
if (toAdd.Count == 0)
return Result.Success;
// Album track numbers are user-visible, so they start at 1.
var nextTrackNumber = (await database.AlbumHasTracks
.Where(link => link.AlbumId == request.AlbumId)
.MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1;
foreach (var trackId in toAdd)
{
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
{
AlbumId = request.AlbumId,
TrackId = trackId,
TrackNumber = nextTrackNumber++
}, cancellationToken);
}
var saved = await database.TrySaveChangesAsync(
AppErrors.Conflict("Album.ConcurrentChange", "The album changed while adding tracks. Try again."), cancellationToken);
if (saved.IsError)
return saved;
logger.LogInformation("Added {Count} tracks to album {AlbumId}", toAdd.Count, request.AlbumId);
return Result.Success;
}
}
@@ -2,127 +2,71 @@ using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums namespace Musify.Application.Albums;
public record CreateAlbumCommand(
long UserId,
string Title,
string? Description,
int? ReleaseYear,
Guid PictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class CreateAlbumCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<CreateAlbumCommandHandler> logger,
AlbumConfiguration albumConfiguration)
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{ {
public record CreateAlbumCommand( public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken)
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)
return AppErrors.NotFound("User", request.UserId);
var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
if (prepared.IsError)
return prepared.Errors;
var picture = prepared.Value;
var title = request.Title.Trim();
var album = new Album
{ {
var userExists = await database.Users OwnerUserId = request.UserId,
.AsNoTracking() Title = title,
.AnyAsync(user => user.Id == request.UserId, cancellationToken); NormalizedTitle = TextNormalizer.Normalize(title),
if (!userExists) Description = request.Description,
{ ReleaseYear = request.ReleaseYear,
logger.LogWarning("User {UserId} not found", request.UserId); Pictures = picture.Pictures
return Error.NotFound(description: $"User {request.UserId} not found"); };
}
var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync( await database.Albums.AddAsync(album, cancellationToken);
uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken);
if (pictureIntent.IsError)
return pictureIntent.Errors;
var intent = pictureIntent.Value; await eventBus.PublishAsync(
var title = request.Title.Trim(); new CreateAlbumResourcesEvent(
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
cancellationToken);
var album = new Album await database.SaveChangesAsync(cancellationToken);
{
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); logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId);
var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName); return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
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 +1,36 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums namespace Musify.Application.Albums;
public record DeleteAlbumCommand(long UserId, Guid AlbumId)
: ICommand<ErrorOr<Success>>;
public class DeleteAlbumCommandHandler(
IDatabase database,
IEventBus eventBus,
ILogger<DeleteAlbumCommandHandler> logger)
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
{ {
public record DeleteAlbumCommand(long UserId, Guid AlbumId) public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
: 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 found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
{ if (found.IsError)
var album = await database.Albums return found.Errors;
.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) var album = found.Value;
{ album.LifeCycleStatus = LifeCycleStatus.Removing;
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
return Error.Unauthorized();
}
database.Albums.Remove(album); await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken);
await database.SaveChangesAsync(cancellationToken);
try logger.LogInformation("Marked album {AlbumId} as removing", request.AlbumId);
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to delete album {AlbumId}", request.AlbumId);
return Error.Failure(description: $"Failed to delete album {request.AlbumId}");
}
logger.LogInformation("Deleted album {AlbumId} for user {UserId}", request.AlbumId, request.UserId); return Result.Success;
return new Success();
}
} }
} }
@@ -3,36 +3,27 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared;
namespace Musify.Application.Albums namespace Musify.Application.Albums;
public record GetAlbumByIdQuery(Guid Id)
: IQuery<ErrorOr<AlbumApplicationResponse>>;
public class GetAlbumByIdQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumByIdQuery, ErrorOr<AlbumApplicationResponse>>
{ {
public record GetAlbumByIdQuery(Guid Id) public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken)
: 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()
var album = await database.Albums .Active()
.AsNoTracking() .Where(a => a.Id == request.Id)
.Where(a => a.Id == request.Id) .SelectResponse()
.Select(a => new .SingleOrDefaultAsync(cancellationToken);
{
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 album == null
return Error.NotFound(); ? AppErrors.NotFound("Album", request.Id)
: album;
return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds);
}
} }
} }
@@ -1,49 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using MimeMapping;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
namespace Musify.Application.Albums
{
public record GetAlbumCoverQuery(Guid AlbumId, string Size)
: IQuery<ErrorOr<AlbumCoverLocation>>;
public record AlbumCoverLocation(string Bucket, string Key, string ContentType);
public class GetAlbumCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration)
: IQueryHandler<GetAlbumCoverQuery, ErrorOr<AlbumCoverLocation>>
{
public async ValueTask<ErrorOr<AlbumCoverLocation>> Handle(GetAlbumCoverQuery request, CancellationToken cancellationToken)
{
var pictures = await database.Albums.AsNoTracking()
.Where(a => a.Id == request.AlbumId)
.Select(a => a.Pictures)
.SingleOrDefaultAsync(cancellationToken);
if (pictures == 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 +1,38 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Albums 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 record GetAlbumTracksQuery( public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken)
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()
var albumExists = await database.Albums .Active()
.AsNoTracking() .AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken); if (!albumExists)
if (!albumExists) return AppErrors.NotFound("Album", request.AlbumId);
return Error.NotFound();
var tracksQuery = database.AlbumHasTracks return await database.AlbumHasTracks
.AsNoTracking() .AsNoTracking()
.Include(albumTrack => albumTrack.Track.Owner) .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
.Include(albumTrack => albumTrack.Track.Tags) .OrderBy(albumTrack => albumTrack.TrackNumber)
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId); .ThenBy(albumTrack => albumTrack.Id)
.Select(albumTrack => albumTrack.Track)
var totalCount = await tracksQuery.CountAsync(cancellationToken); .SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), 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 +1,33 @@
using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using X.PagedList; using Musify.Application.Shared;
using X.PagedList.EF;
namespace Musify.Application.Albums namespace Musify.Application.Albums;
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
: IQuery<PaginatedResponse<AlbumApplicationResponse>>;
public class GetAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsQuery, PaginatedResponse<AlbumApplicationResponse>>
{ {
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize) public async ValueTask<PaginatedResponse<AlbumApplicationResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
: 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()
.Active();
if (!string.IsNullOrWhiteSpace(request.Title))
{ {
var albumsQuery = database.Albums var normalizedTitle = TextNormalizer.Normalize(request.Title);
.AsNoTracking() albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle));
.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);
} }
return await albumsQuery
.OrderByDescending(album => album.CreatedAt)
.ThenBy(album => album.Id)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
} }
} }
@@ -4,50 +4,30 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Albums namespace Musify.Application.Albums;
public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>;
public class GetAlbumsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsByUserIdQuery, ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>
{ {
public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize) public async ValueTask<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken)
: 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()
var userExists = await database.Users .AnyAsync(user => user.Id == request.UserId, cancellationToken);
.AsNoTracking() if (!userExists)
.AnyAsync(user => user.Id == request.UserId, cancellationToken); return AppErrors.NotFound("User", request.UserId);
if (!userExists)
return Error.NotFound(description: $"User {request.UserId} not found");
var albumsQuery = database.Albums return await database.Albums
.AsNoTracking() .AsNoTracking()
.Where(album => album.OwnerUserId == request.UserId); .Active()
.Where(album => album.OwnerUserId == request.UserId)
var totalCount = await albumsQuery.CountAsync(cancellationToken); .OrderByDescending(album => album.CreatedAt)
.ThenBy(album => album.Id)
var pagedEntities = await albumsQuery .SelectResponse()
.OrderByDescending(album => album.CreatedAt) .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
.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 +1,51 @@
using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
namespace Musify.Application.Albums namespace Musify.Application.Albums;
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
: IQuery<IReadOnlyList<AlbumApplicationResponse>>;
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetRecentlyListenedAlbumsQuery, IReadOnlyList<AlbumApplicationResponse>>
{ {
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit) public async ValueTask<IReadOnlyList<AlbumApplicationResponse>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
: 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 listens = database.ListeningHistories
{ .AsNoTracking()
var listenedTrackIds = database.ListeningHistories .Where(history => history.UserId == request.UserId && history.IsCounted);
.AsNoTracking()
.Where(history => history.UserId == request.UserId);
var ranked = await database.AlbumHasTracks var ranked = await database.AlbumHasTracks
.AsNoTracking() .AsNoTracking()
.Join( .Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
listenedTrackIds, .Join(
albumTrack => albumTrack.TrackId, listens,
history => history.TrackId, albumTrack => albumTrack.TrackId,
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt }) history => history.TrackId,
.GroupBy(entry => entry.AlbumId) (albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt })
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) }) .GroupBy(entry => entry.AlbumId)
.OrderByDescending(entry => entry.LastListenedAt) .Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) })
.Take(request.Limit) .OrderByDescending(entry => entry.LastListenedAt)
.ToListAsync(cancellationToken); .ThenBy(entry => entry.AlbumId)
.Take(request.Limit)
.ToListAsync(cancellationToken);
if (ranked.Count == 0) if (ranked.Count == 0)
return new List<AlbumApplicationResponse>(); return [];
var albumIds = ranked.Select(entry => entry.AlbumId).ToList(); var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
var albums = await database.Albums var albumsById = await database.Albums
.AsNoTracking() .AsNoTracking()
.Where(album => albumIds.Contains(album.Id)) .Where(album => albumIds.Contains(album.Id))
.Select(album => new .SelectResponse()
{ .ToDictionaryAsync(album => album.Id, cancellationToken);
Album = album,
TrackCount = album.AlbumTracks.Count,
CoverTrackIds = album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.ToDictionaryAsync(entry => entry.Album.Id, cancellationToken);
return ranked return ranked
.Where(entry => albums.ContainsKey(entry.AlbumId)) .Where(entry => albumsById.ContainsKey(entry.AlbumId))
.Select(entry => albums[entry.AlbumId]) .Select(entry => albumsById[entry.AlbumId])
.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)) .ToList();
.ToList();
}
} }
} }
@@ -3,57 +3,43 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared;
namespace Musify.Application.Albums namespace Musify.Application.Albums;
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 record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId) public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
: ICommand<ErrorOr<Success>>;
public class RemoveTrackFromAlbumCommandHandler(
IDatabase database,
ILogger<RemoveTrackFromAlbumCommandHandler> logger)
: ICommandHandler<RemoveTrackFromAlbumCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken) var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
{ if (album.IsError)
var album = await database.Albums return album.Errors;
.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) var albumTracks = await database.AlbumHasTracks
{ .Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId); .OrderBy(albumTrack => albumTrack.TrackNumber)
return Error.Unauthorized(); .ToListAsync(cancellationToken);
}
var albumTracks = await database.AlbumHasTracks var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId);
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId) if (link == null)
.OrderBy(albumTrack => albumTrack.TrackNumber) return Error.NotFound("Album.TrackNotFound", "Track is not in the album.");
.ToListAsync(cancellationToken);
var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId); database.AlbumHasTracks.Remove(link);
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++;
var trackNumber = 1; await database.SaveChangesAsync(cancellationToken);
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);
logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId); return Result.Success;
return new Success();
}
} }
} }
@@ -1,105 +1,24 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Albums.Responses; using Musify.Application.Pictures.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums namespace Musify.Application.Albums;
public record RequestAlbumPictureUploadCommand(
long UserId,
string FileType,
string ContentType,
long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<PictureUploadResponse>>;
public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration)
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<PictureUploadResponse>>
{ {
public record RequestAlbumPictureUploadCommand( public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) =>
long UserId, await issuer.IssuePictureAsync(
string FileType, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration,
string ContentType, request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
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 +1,40 @@
using System.Text.Json.Serialization;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Application.Serialization;
namespace Musify.Application.Albums.Responses namespace Musify.Application.Albums.Responses;
public record AlbumApplicationResponse(
Guid Id,
string Title,
string? Description,
int? ReleaseYear,
long OwnerUserId,
int TrackCount,
string? SmallImageKeyName,
string? MediumImageKeyName,
string? LargeImageKeyName,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds)
{ {
public record AlbumApplicationResponse( public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
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( public static AlbumApplicationResponse FromEntity(
Album album, Album album,
int trackCount, int trackCount,
IReadOnlyList<Guid>? coverTrackIds = null) IReadOnlyList<Guid>? coverTrackIds = null)
{ {
return new AlbumApplicationResponse( return new AlbumApplicationResponse(
album.Id, album.Id,
album.Title, album.Title,
album.Description, album.Description,
album.ReleaseYear, album.ReleaseYear,
album.OwnerUserId, album.OwnerUserId,
trackCount, trackCount,
album.Pictures?.SmallName, album.Pictures?.SmallName,
album.Pictures?.MediumName, album.Pictures?.MediumName,
album.Pictures?.LargeName, album.Pictures?.LargeName,
album.CreatedAt, album.CreatedAt,
album.UpdatedAt, album.UpdatedAt,
coverTrackIds ?? []); 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);
}
@@ -0,0 +1,31 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums.Responses;
public static class AlbumProjections
{
public const int CoverTrackCount = 4;
public static IQueryable<Album> Active(this IQueryable<Album> query) =>
query.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active);
public static IQueryable<AlbumApplicationResponse> SelectResponse(this IQueryable<Album> query) =>
query.Select(album => new AlbumApplicationResponse(
album.Id,
album.Title,
album.Description,
album.ReleaseYear,
album.OwnerUserId,
album.AlbumTracks.Count,
album.Pictures!.SmallName,
album.Pictures!.MediumName,
album.Pictures!.LargeName,
album.CreatedAt,
album.UpdatedAt,
album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()));
}
@@ -1,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);
}
@@ -2,149 +2,72 @@ using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Services; using Musify.Application.Configuration;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums namespace Musify.Application.Albums;
public record UpdateAlbumCommand(
long UserId,
Guid AlbumId,
string Title,
string? Description,
int? ReleaseYear,
Guid? NewPictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdateAlbumCommandHandler> logger,
AlbumConfiguration albumConfiguration)
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{ {
public record UpdateAlbumCommand( public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
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 found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var album = found.Value;
var title = request.Title.Trim();
album.Title = title;
album.NormalizedTitle = TextNormalizer.Normalize(title);
album.Description = request.Description;
album.ReleaseYear = request.ReleaseYear;
if (request.NewPictureIntentId is { } intentId)
{ {
var album = await database.Albums var prepared = await PictureSourceChange.PrepareAsync(
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
if (album == null) if (prepared.IsError)
{ return prepared.Errors;
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
if (album.OwnerUserId != request.UserId) var picture = prepared.Value;
{ album.Pictures = picture.Pictures;
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
return Error.Unauthorized();
}
var title = request.Title.Trim(); await eventBus.PublishAsync(
new UpdateAlbumPictureSourceEvent(
album.Title = title; album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
album.NormalizedTitle = title.ToUpperInvariant(); cancellationToken);
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( await database.SaveChangesAsync(cancellationToken);
Guid albumId,
UploadIntent pictureIntent, logger.LogInformation("Updated album {AlbumId}", album.Id);
string finalPictureKey,
CancellationToken cancellationToken) return await database.Albums
{ .AsNoTracking()
try .Where(a => a.Id == album.Id)
{ .SelectResponse()
await eventBus.PublishAsync( .SingleAsync(cancellationToken);
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}");
}
}
} }
} }
@@ -19,8 +19,6 @@
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" /> <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" /> <PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
<PackageReference Include="MimeMapping" /> <PackageReference Include="MimeMapping" />
<PackageReference Include="X.PagedList" />
<PackageReference Include="X.PagedList.EF" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -11,16 +11,17 @@ public static class ApplicationDependencyInjection
{ {
services.AddMediator(); services.AddMediator();
services.AddScoped<UploadIntentValidator>(); services.AddScoped<UploadIntentValidator>();
services.AddScoped<UploadIntentIssuer>();
services.AddScoped<TrackStreamIssuer>(); services.AddScoped<TrackStreamIssuer>();
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName); services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration);
services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName); services.AddValidatedOptions<PlayListConfiguration>(configuration);
services.AddValidatedOptions<AlbumConfiguration>(configuration, AlbumConfiguration.SectionName); services.AddValidatedOptions<AlbumConfiguration>(configuration);
services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfiguration.SectionName); services.AddValidatedOptions<TrackConfiguration>(configuration);
services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName); services.AddValidatedOptions<MixConfiguration>(configuration);
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration, StreamGatewayConfiguration.SectionName); services.AddValidatedOptions<StreamGatewayConfiguration>(configuration);
services.AddValidatedOptions<UploadIntentConfiguration>(configuration, UploadIntentConfiguration.SectionName); services.AddValidatedOptions<UploadIntentConfiguration>(configuration);
services.AddValidatedOptions<PlaybackConfiguration>(configuration, PlaybackConfiguration.SectionName); services.AddValidatedOptions<PlaybackConfiguration>(configuration);
return services; return services;
} }
@@ -1,4 +1,4 @@
using Mediator; using Mediator;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
[assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)] [assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)]
@@ -1,79 +1,14 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Musify.Application.Shared;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration;
public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{ {
public class AlbumConfiguration public static string SectionName => "Album";
{
public const string SectionName = "Album";
[Required] [Required]
public AlbumRoutes Routes { get; set; } = new AlbumRoutes(); public PictureRoutes Routes { get; set; } = new() { ParentFolder = "Albums" };
[Required] [Required]
public AlbumPicturesSizes PicturesSizes { get; set; } = new AlbumPicturesSizes(); public PictureSizes PicturesSizes { get; set; } = new();
}
public class AlbumRoutes
{
[Required]
public string UploadsFolder { get; set; } = "uploads";
[Required]
public string ParentFolders { get; set; } = "Albums";
[Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required]
public string LargePicturesFolder { get; set; } = "LargePictures";
public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder);
public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName);
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName);
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName);
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName);
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName);
}
public class AlbumPicturesSizes
{
[Range(1, 1024)]
public int SmallPictureWidth { get; set; } = 128;
[Range(1, 1024)]
public int SmallPictureHeight { get; set; } = 128;
[Range(1, 1024)]
public int MediumPictureWidth { get; set; } = 256;
[Range(1, 1024)]
public int MediumPictureHeight { get; set; } = 256;
[Range(1, 1024)]
public int LargePictureWidth { get; set; } = 512;
[Range(1, 1024)]
public int LargePictureHeight { get; set; } = 512;
}
} }
@@ -1,13 +1,12 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration;
{
public class ApplicationStorageConfiguration
{
public const string SectionName = "ApplicationStorage";
[Required] public class ApplicationStorageConfiguration : IConfigurationOptions
[StringLength(128, MinimumLength = 1)] {
public required string Bucket { get; set; } public static string SectionName => "ApplicationStorage";
}
[Required]
[StringLength(128, MinimumLength = 1)]
public required string Bucket { get; set; }
} }
@@ -6,6 +6,10 @@ namespace Musify.Application.Configuration;
public static class ConfigurationDependencyInjection public static class ConfigurationDependencyInjection
{ {
public static IServiceCollection AddValidatedOptions<T>(this IServiceCollection services, IConfiguration configuration)
where T : class, IConfigurationOptions =>
services.AddValidatedOptions<T>(configuration, T.SectionName);
public static IServiceCollection AddValidatedOptions<T>( public static IServiceCollection AddValidatedOptions<T>(
this IServiceCollection services, this IServiceCollection services,
IConfiguration configuration, IConfiguration configuration,
@@ -1,17 +1,16 @@
using Mediator; using Mediator;
using Musify.Application.Configuration.Responses; using Musify.Application.Configuration.Responses;
namespace Musify.Application.Configuration 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) =>
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>; ValueTask.FromResult(new PlaybackPublicConfigResponse(
playbackConfiguration.AllowAnonymousListening,
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration) playbackConfiguration.AnonymousFragmentSeconds));
: IQueryHandler<GetPlaybackPublicConfigQuery, PlaybackPublicConfigResponse>
{
public ValueTask<PlaybackPublicConfigResponse> Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) =>
ValueTask.FromResult(new PlaybackPublicConfigResponse(
playbackConfiguration.AllowAnonymousListening,
playbackConfiguration.AnonymousFragmentSeconds));
}
} }
@@ -0,0 +1,6 @@
namespace Musify.Application.Configuration;
public interface IConfigurationOptions
{
public static abstract string SectionName { get; }
}
@@ -1,12 +1,11 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration;
{
public class MixConfiguration
{
public const string SectionName = "Mix";
[Range(4, 100)] public class MixConfiguration : IConfigurationOptions
public int ItemsPerMix { get; set; } = 12; {
} public static string SectionName => "Mix";
[Range(4, 100)]
public int ItemsPerMix { get; set; } = 12;
} }
@@ -0,0 +1,90 @@
using System.ComponentModel.DataAnnotations;
using Musify.Application.Shared;
namespace Musify.Application.Configuration;
public enum PictureSize
{
Small,
Medium,
Large
}
public class PictureRoutes
{
[Required]
public string UploadsFolder { get; set; } = "uploads";
[Required]
public string ParentFolder { get; set; } = string.Empty;
[Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required]
public string LargePicturesFolder { get; set; } = "LargePictures";
public string FolderPath(PictureSize size) => StorageKey.Combine(ParentFolder, size switch
{
PictureSize.Small => SmallPicturesFolder,
PictureSize.Medium => MediumPicturesFolder,
PictureSize.Large => LargePicturesFolder,
_ => throw new ArgumentOutOfRangeException(nameof(size), size, null)
});
public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName);
public string BuildOriginalPicturePath(long userId, string pictureName) =>
StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolder, OriginalPicturesFolder, pictureName);
public string BuildTempPath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
}
public class PictureSizes
{
[Range(1, 1024)]
public int SmallWidth { get; set; } = 128;
[Range(1, 1024)]
public int SmallHeight { get; set; } = 128;
[Range(1, 1024)]
public int MediumWidth { get; set; } = 256;
[Range(1, 1024)]
public int MediumHeight { get; set; } = 256;
[Range(1, 1024)]
public int LargeWidth { get; set; } = 512;
[Range(1, 1024)]
public int LargeHeight { get; set; } = 512;
public ImageSizes ToImageSizes(PictureRoutes routes) => new(
new ImageSize(routes.FolderPath(PictureSize.Small), SmallWidth, SmallHeight),
new ImageSize(routes.FolderPath(PictureSize.Medium), MediumWidth, MediumHeight),
new ImageSize(routes.FolderPath(PictureSize.Large), LargeWidth, LargeHeight));
}
public interface IPictureOwnerConfiguration
{
public PictureRoutes Routes { get; }
public PictureSizes PicturesSizes { get; }
}
public static class PictureSizeParser
{
/// <summary>Parses the <c>size</c> query value; anything unknown means <see cref="PictureSize.Medium"/>.</summary>
public static PictureSize Parse(string? value) =>
Enum.TryParse<PictureSize>(value, ignoreCase: true, out var size) && Enum.IsDefined(size)
? size
: PictureSize.Medium;
}
@@ -1,79 +1,14 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Musify.Application.Shared;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration;
public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{ {
public class PlayListConfiguration public static string SectionName => "PlayList";
{
public const string SectionName = "PlayList";
[Required] [Required]
public PlayListRoutes Routes { get; set; } = new PlayListRoutes(); public PictureRoutes Routes { get; set; } = new() { ParentFolder = "PlayLists" };
[Required] [Required]
public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes(); public PictureSizes PicturesSizes { get; set; } = new();
}
public class PlayListRoutes
{
[Required]
public string UploadsFolder { get; set; } = "uploads";
[Required]
public string ParentFolders { get; set; } = "PlayLists";
[Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required]
public string LargePicturesFolder { get; set; } = "LargePictures";
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 PlayListPicturesSizes
{
[Range(1, 1024)]
public int SmallPictureWidth { get; set; } = 128;
[Range(1, 1024)]
public int SmallPictureHeight { get; set; } = 128;
[Range(1, 1024)]
public int MediumPictureWidth { get; set; } = 256;
[Range(1, 1024)]
public int MediumPictureHeight { get; set; } = 256;
[Range(1, 1024)]
public int LargePictureWidth { get; set; } = 512;
[Range(1, 1024)]
public int LargePictureHeight { get; set; } = 512;
}
} }
@@ -1,18 +1,17 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration;
public sealed class PlaybackConfiguration : IConfigurationOptions
{ {
public static string SectionName => "Playback";
public sealed class PlaybackConfiguration public bool AllowAnonymousListening { get; set; }
{
public const string SectionName = "Playback";
public bool AllowAnonymousListening { get; set; } [Range(0, 3600)]
public int AnonymousFragmentSeconds { get; set; }
[Range(0, 3600)] [Range(1, int.MaxValue)]
public int AnonymousFragmentSeconds { get; set; } public int EstimatedAudioBytesPerSecond { get; set; } = 16_000;
[Range(1, int.MaxValue)]
public int EstimatedAudioBytesPerSecond { get; set; } = 16_000;
}
} }
@@ -1,5 +1,4 @@
namespace Musify.Application.Configuration.Responses namespace Musify.Application.Configuration.Responses;
{
public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds);
} public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds);
@@ -1,16 +1,15 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration;
public class StreamGatewayConfiguration : IConfigurationOptions
{ {
public class StreamGatewayConfiguration public static string SectionName => "StreamGateway";
{
public const string SectionName = "StreamGateway";
[Url] [Url]
[Required] [Required]
public required string PublicBaseUrl { get; set; } public required string PublicBaseUrl { get; set; }
[Required] [Required]
public string AudioFileName { get; set; } = "audio.m4a"; public string AudioFileName { get; set; } = "audio.m4a";
}
} }
@@ -1,116 +1,55 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration;
public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{ {
public class TrackConfiguration public static string SectionName => "Track";
{
public const string SectionName = "Track";
[Required] [Required]
public TrackRoutes Routes { get; set; } = new TrackRoutes(); public TrackRoutes Routes { get; set; } = new();
[Required] [Required]
public TrackPicturesSizes PicturesSizes { get; set; } = new TrackPicturesSizes(); public PictureSizes PicturesSizes { get; set; } = new();
}
public class TrackRoutes PictureRoutes IPictureOwnerConfiguration.Routes => Routes;
}
public class TrackRoutes : PictureRoutes
{
public TrackRoutes()
{ {
[Required] ParentFolder = "Tracks";
public string UploadsFolder { get; set; } = "uploads"; }
[Required]
public string ParentFolder { get; set; } = "Tracks";
[Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required]
public string LargePicturesFolder { get; set; } = "LargePictures";
[Required]
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
[Required]
public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp";
[Required]
public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp";
[Required]
public string PresetLargePicture { get; set; } = "PresetLargePicture.webp";
[Required]
public string OriginalAudiosFolder { get; set; } = "OriginalAudios";
[Required]
public string ProcessedAudioFolder { get; set; } = "ProcessedAudios";
public string SmallPicturesPath => StorageKey.Combine(ParentFolder, SmallPicturesFolder);
public string MediumPicturesPath => StorageKey.Combine(ParentFolder, MediumPicturesFolder);
public string LargePicturesPath => StorageKey.Combine(ParentFolder, LargePicturesFolder); [Required]
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture); [Required]
public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp";
public string PresetMediumPicturePath => StorageKey.Combine(ParentFolder, PresetMediumPicture); [Required]
public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp";
public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture); [Required]
public string PresetLargePicture { get; set; } = "PresetLargePicture.webp";
public string OriginalPicturesPath => StorageKey.Combine(ParentFolder, OriginalPicturesFolder); [Required]
public string OriginalAudiosFolder { get; set; } = "OriginalAudios";
public string OriginalAudiosPath => StorageKey.Combine(ParentFolder, OriginalAudiosFolder); [Required]
public string ProcessedAudiosFolder { get; set; } = "ProcessedAudios";
public string ProcessedAudiosPath => StorageKey.Combine(ParentFolder, ProcessedAudioFolder); public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture);
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(OriginalPicturesPath, pictureName); public string PresetMediumPicturePath => StorageKey.Combine(ParentFolder, PresetMediumPicture);
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalPicturesPath, pictureName); public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture);
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(SmallPicturesPath, pictureName); public string BuildOriginalAudioPath(long userId, string audioName) =>
StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolder, OriginalAudiosFolder, audioName);
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(MediumPicturesPath, pictureName); public string BuildProcessedAudioPath(string folderName) =>
StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName);
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(LargePicturesPath, pictureName);
public string BuildOriginalAudioPath(string audioName) => StorageKey.Combine(OriginalAudiosPath, audioName);
public string BuildOriginalAudioPath(long userId, string audioName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalAudiosPath, audioName);
public string BuildProcessedAudioPath(string audioName) => StorageKey.Combine(ProcessedAudiosPath, audioName);
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
public string BuildTempAudioPath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
}
public class TrackPicturesSizes
{
[Range(1, 1024)]
public int SmallPictureWidth { get; set; } = 128;
[Range(1, 1024)]
public int SmallPictureHeight { get; set; } = 128;
[Range(1, 1024)]
public int MediumPictureWidth { get; set; } = 256;
[Range(1, 1024)]
public int MediumPictureHeight { get; set; } = 256;
[Range(1, 1024)]
public int LargePictureWidth { get; set; } = 512;
[Range(1, 1024)]
public int LargePictureHeight { get; set; } = 512;
}
} }
@@ -1,42 +1,41 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration;
public class UploadIntentConfiguration : IConfigurationOptions
{ {
public class UploadIntentConfiguration public static string SectionName => "UploadIntent";
{
public const string SectionName = "UploadIntent";
[Range(10, 3600)] [Range(10, 3600)]
public int UploadUrlExpiresInSeconds { get; set; } = 120; public int UploadUrlExpiresInSeconds { get; set; } = 120;
[Range(1, 100)] [Range(1, 100)]
public int MaxActiveUploadIntentsPerUser { get; set; } = 5; public int MaxActiveUploadIntentsPerUser { get; set; } = 5;
[Range(1, long.MaxValue)] [Range(1, long.MaxValue)]
public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024; public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024;
[Range(1, long.MaxValue)] [Range(1, long.MaxValue)]
public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024; public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024;
[Range(1, long.MaxValue)] [Range(1, long.MaxValue)]
public long DefaultExpectedPictureSizeBytes { get; set; } = 10L * 1024 * 1024; public long DefaultExpectedPictureSizeBytes { get; set; } = 10L * 1024 * 1024;
[Range(1, long.MaxValue)] [Range(1, long.MaxValue)]
public long DefaultExpectedAudioSizeBytes { get; set; } = 100L * 1024 * 1024; public long DefaultExpectedAudioSizeBytes { get; set; } = 100L * 1024 * 1024;
[Range(30, 86400)] [Range(30, 86400)]
public int ExpirationJobIntervalSeconds { get; set; } = 300; public int ExpirationJobIntervalSeconds { get; set; } = 300;
[Range(0, 365)] [Range(0, 365)]
public int ExpiredIntentsRetentionDays { get; set; } = 7; public int ExpiredIntentsRetentionDays { get; set; } = 7;
[Required, MinLength(1)] [Required, MinLength(1)]
public string TemporalRootPrefix { get; set; } = "temporal"; public string TempRootPrefix { get; set; } = "temp";
[Range(1, 30)] [Range(1, 30)]
public int TemporalUploadsRetentionDays { get; set; } = 3; public int TempUploadsRetentionDays { get; set; } = 3;
[Range(60, 86400)] [Range(60, 86400)]
public int TemporalCleanUpJobIntervalSeconds { get; set; } = 3600; public int TempCleanupJobIntervalSeconds { get; set; } = 3600;
}
} }
@@ -1,4 +1,3 @@
namespace Musify.Application.Contracts namespace Musify.Application.Contracts;
{
public record AudioTranscodeResult(int StatusCode, TimeSpan Duration); public record AudioTranscodeResult(int StatusCode, TimeSpan Duration);
}
@@ -1,9 +1,6 @@
namespace Musify.Application.Contracts namespace Musify.Application.Contracts;
{
public interface IAudioTranscoderService
{
Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
Task<bool> IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken); public interface IAudioTranscoderService
} {
public Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
} }
@@ -1,42 +1,40 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Domain.Entities; using Musify.Domain.Entities;
namespace Musify.Application.Contracts namespace Musify.Application.Contracts;
public interface IDatabase
{ {
public interface IDatabase public DbSet<User> Users { get; }
{
DbSet<User> Users { get; }
DbSet<PlayList> PlayLists { get; } public DbSet<PlayList> PlayLists { get; }
DbSet<Album> Albums { get; } public DbSet<Album> Albums { get; }
DbSet<AlbumHasTrack> AlbumHasTracks { get; } public DbSet<AlbumHasTrack> AlbumHasTracks { get; }
DbSet<Track> Tracks { get; } public DbSet<Track> Tracks { get; }
DbSet<UserHasTrack> UserHasTracks { get; } public DbSet<PlayListHasTrack> PlayListHasTracks { get; }
DbSet<PlayListHasTrack> PlayListHasTracks { get; } public DbSet<Mix> Mixes { get; }
DbSet<Mix> Mixes { get; } public DbSet<MixItem> MixItems { get; }
DbSet<MixItem> MixItems { get; } public DbSet<UploadIntent> UploadIntents { get; }
DbSet<Upload> Uploads { get; } public DbSet<ListeningHistory> ListeningHistories { get; }
DbSet<UploadIntent> UploadIntents { get; } public DbSet<TrackLike> TrackLikes { get; }
DbSet<ListeningHistory> ListeningHistories { get; } public DbSet<TrackTag> TrackTags { get; }
DbSet<TrackLike> TrackLikes { get; } public DbSet<UserFollow> UserFollows { get; }
DbSet<TrackTag> TrackTags { get; } public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
DbSet<UserFollow> UserFollows { get; } public Task<int> SaveChangesAsync(CancellationToken cancellationToken);
Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); public Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken);
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
} }
@@ -1,7 +1,6 @@
namespace Musify.Application.Contracts namespace Musify.Application.Contracts;
public interface IDatabaseTransaction : IAsyncDisposable
{ {
public interface IDatabaseTransaction : IAsyncDisposable public Task CommitAsync(CancellationToken cancellationToken = default);
{
Task CommitAsync(CancellationToken cancellationToken = default);
}
} }
@@ -1,7 +1,6 @@
namespace Musify.Application.Contracts namespace Musify.Application.Contracts;
public interface IEventBus
{ {
public interface IEventBus public Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
{
Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
}
} }
@@ -1,7 +1,6 @@
namespace Musify.Application.Contracts namespace Musify.Application.Contracts;
public interface IPictureService
{ {
public interface IPictureService public Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
{
Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
}
} }
@@ -1,28 +1,24 @@
namespace Musify.Application.Contracts namespace Musify.Application.Contracts;
public interface IStorageService
{ {
public interface IStorageService /// <summary>Opens an object for reading, or returns null when it does not exist.</summary>
{ public Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken); public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
Task<string> GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken); public Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, bool preventOverwrite = true, CancellationToken cancellationToken = default);
Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, CancellationToken cancellationToken, bool preventOverwrite = true); public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken); public Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken);
Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken); public Task<IReadOnlyList<string>> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken);
Task<IReadOnlyList<string>> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken); public Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken);
Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken); public Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
CancellationToken cancellationToken);
Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, public IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
CancellationToken cancellationToken);
Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken);
IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
}
} }
@@ -1,10 +1,8 @@
namespace Musify.Application.Contracts namespace Musify.Application.Contracts;
public interface IStreamTicketService
{ {
public interface IStreamTicketService public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
{
StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
}
public record StreamTicket(string Token, int ExpiresInSeconds);
} }
public record StreamTicket(string Token, int ExpiresInSeconds);
@@ -1,4 +1,3 @@
namespace Musify.Application.Contracts namespace Musify.Application.Contracts;
{
public record ObjectMetaData(string ContentType, long ContentLength); public record ObjectMetaData(string ContentType, long ContentLength);
}
@@ -1,4 +1,3 @@
namespace Musify.Application.Contracts namespace Musify.Application.Contracts;
{
public record StorageObject(string Key, DateTime LastModifiedUtc); public record StorageObject(string Key, DateTime LastModifiedUtc);
}
@@ -1,4 +1,3 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record AlbumPictureProcessed(Guid AlbumId); public record AlbumPictureProcessed(Guid AlbumId);
}
@@ -1,4 +1,3 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record AlbumPictureProcessingFailed(Guid AlbumId); public record AlbumPictureProcessingFailed(Guid AlbumId);
}
@@ -1,7 +1,6 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record AlbumProcessingFailed( public record AlbumProcessingFailed(
Guid AlbumId, Guid AlbumId,
string? Bucket, string? Bucket,
string? PictureKey); string? PictureKey);
}
@@ -1,14 +1,11 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record CreateAlbumResourcesEvent( public record CreateAlbumResourcesEvent(
Guid AlbumId, Guid AlbumId,
Guid PictureIntentId, Guid PictureIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
}
@@ -1,14 +1,11 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record CreatePlayListResourcesEvent( public record CreatePlayListResourcesEvent(
Guid PlayListId, Guid PlayListId,
Guid PictureIntentId, Guid PictureIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
}
@@ -1,18 +1,15 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record CreateTrackResourcesEvent( public record CreateTrackResourcesEvent(
Guid TrackId, Guid TrackId,
Guid PictureIntentId, Guid PictureIntentId,
Guid AudioIntentId, Guid AudioIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
string AudioSourceKey, string AudioSourceKey,
string AudioDestinationKey, string AudioDestinationKey,
string AudioProcessedFolderKey, string AudioProcessedFolderKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
}
@@ -0,0 +1,5 @@
namespace Musify.Application.Events;
public record DeleteAlbumEvent(
Guid AlbumId,
long UserId);
@@ -1,6 +1,5 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record DeletePlayListEvent( public record DeletePlayListEvent(
Guid PlayListId, Guid PlayListId,
long UserId); long UserId);
}
@@ -1,6 +1,5 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record DeleteTrackEvent( public record DeleteTrackEvent(
Guid TrackId, Guid TrackId,
long UserId); long UserId);
}
@@ -1,4 +1,3 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record PlayListPictureProcessed(Guid PlayListId); public record PlayListPictureProcessed(Guid PlayListId);
}
@@ -1,4 +1,3 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record PlayListPictureProcessingFailed(Guid PlayListId); public record PlayListPictureProcessingFailed(Guid PlayListId);
}
@@ -1,7 +1,6 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record PlayListProcessingFailed( public record PlayListProcessingFailed(
Guid PlayListId, Guid PlayListId,
string? Bucket, string? Bucket,
string? PictureKey); string? PictureKey);
}
@@ -1,4 +1,3 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record TrackAudioProcessed(Guid TrackId); public record TrackAudioProcessed(Guid TrackId);
}
@@ -1,4 +1,3 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record TrackAudioProcessingFailed(Guid TrackId); public record TrackAudioProcessingFailed(Guid TrackId);
}
@@ -1,4 +1,3 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record TrackPictureProcessed(Guid TrackId); public record TrackPictureProcessed(Guid TrackId);
}
@@ -1,4 +1,3 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record TrackPictureProcessingFailed(Guid TrackId); public record TrackPictureProcessingFailed(Guid TrackId);
}
@@ -1,8 +1,7 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record TrackProcessingFailed( public record TrackProcessingFailed(
Guid TrackId, Guid TrackId,
string? Bucket, string? Bucket,
string? PictureKey, string? PictureKey,
string? AudioKey); string? AudioKey);
}
@@ -1,12 +1,9 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record UpdateAlbumPictureEvent( public record UpdateAlbumPictureEvent(
Guid AlbumId, Guid AlbumId,
string Bucket, string Bucket,
string SourceKey, string SourceKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
}
@@ -1,14 +1,11 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record UpdateAlbumPictureSourceEvent( public record UpdateAlbumPictureSourceEvent(
Guid AlbumId, Guid AlbumId,
Guid PictureIntentId, Guid PictureIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
}
@@ -1,12 +1,9 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record UpdatePlayListPictureEvent( public record UpdatePlayListPictureEvent(
Guid PlayListId, Guid PlayListId,
string Bucket, string Bucket,
string SourceKey, string SourceKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
}
@@ -1,14 +1,11 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record UpdatePlayListPictureSourceEvent( public record UpdatePlayListPictureSourceEvent(
Guid PlayListId, Guid PlayListId,
Guid PictureIntentId, Guid PictureIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
}
@@ -1,9 +1,8 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record UpdateTrackAudioEvent( public record UpdateTrackAudioEvent(
Guid TrackId, Guid TrackId,
string SourceBucket, string SourceBucket,
string SourceKey, string SourceKey,
string DestinationBucket, string DestinationBucket,
string DestinationFolderKey); string DestinationFolderKey);
}
@@ -1,12 +1,9 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record UpdateTrackPictureEvent( public record UpdateTrackPictureEvent(
Guid TrackId, Guid TrackId,
string Bucket, string Bucket,
string SourceKey, string SourceKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
}
@@ -0,0 +1,24 @@
using Mediator;
using Musify.Domain.ValueObjects;
using Musify.Application.Genres.Responses;
namespace Musify.Application.Genres;
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
{
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
{
var all = Enum.GetValues<Genre>();
IReadOnlyList<AvailableGenreResponse> result = all
.Select(genre => new AvailableGenreResponse(
genre,
all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList()))
.ToList();
return ValueTask.FromResult(result);
}
}
@@ -0,0 +1,29 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
using Musify.Application.Genres.Responses;
namespace Musify.Application.Genres;
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
public class GetGenresQueryHandler(IDatabase database)
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
{
public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken)
{
var rows = await database.TrackTags
.AsNoTracking()
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active)
.GroupBy(tag => tag.Tag)
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
.ToListAsync(cancellationToken);
return rows
.OrderByDescending(row => row.TrackCount)
.ThenBy(row => row.Genre)
.Select(row => new GenreResponse(row.Genre, row.TrackCount))
.ToList();
}
}
@@ -0,0 +1,5 @@
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres.Responses;
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
@@ -0,0 +1,5 @@
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres.Responses;
public record GenreResponse(Genre Genre, int TrackCount);
@@ -1,43 +1,30 @@
using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using X.PagedList; using Musify.Domain.ValueObjects;
using X.PagedList.EF;
namespace Musify.Application.Likes namespace Musify.Application.Likes;
public record GetLikedTracksQuery(
long UserId,
int PageNumber,
int PageSize)
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
public class GetLikedTracksQueryHandler(IDatabase database)
: IQueryHandler<GetLikedTracksQuery, PaginatedResponse<TrackApplicationResponse>>
{ {
public record GetLikedTracksQuery( public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
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) return await database.TrackLikes
{ .AsNoTracking()
var likesQuery = database.TrackLikes .Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active)
.AsNoTracking() .OrderByDescending(like => like.CreatedAt)
.Include(like => like.Track.Owner) .ThenBy(like => like.Id)
.Include(like => like.Track.Tags) .Select(like => like.Track)
.Where(like => like.UserId == request.UserId); .SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
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);
}
} }
} }
@@ -3,54 +3,56 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Likes 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 record ToggleTrackLikeCommand(long UserId, Guid TrackId) public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
: ICommand<ErrorOr<bool>>;
public class ToggleTrackLikeCommandHandler(
IDatabase database,
ILogger<ToggleTrackLikeCommandHandler> logger)
: ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>>
{ {
public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken) var trackExists = await database.Tracks
.AsNoTracking()
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
if (!trackExists)
return AppErrors.NotFound("Track", request.TrackId);
var existingLike = await database.TrackLikes
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
bool nowLiked;
if (existingLike != null)
{ {
var trackExists = await database.Tracks database.TrackLikes.Remove(existingLike);
.AsNoTracking() nowLiked = false;
.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;
} }
else
{
await database.TrackLikes.AddAsync(new TrackLike
{
UserId = request.UserId,
TrackId = request.TrackId
}, cancellationToken);
nowLiked = true;
}
// A concurrent double click can hit the unique index; the outcome is already in place, so treat it as a no-op.
var saved = await database.TrySaveChangesAsync(
AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken);
if (saved.IsError)
return true;
logger.LogInformation("User {UserId} {Action} track {TrackId}",
request.UserId, nowLiked ? "liked" : "unliked", request.TrackId);
return nowLiked;
} }
} }
@@ -1,4 +1,3 @@
using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -7,146 +6,124 @@ using Musify.Application.Contracts;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes namespace Musify.Application.Mixes;
public record GenerateMixesForUserCommand(long UserId)
: ICommand;
public class GenerateMixesForUserCommandHandler(
IDatabase database,
MixConfiguration mixConfiguration,
ILogger<GenerateMixesForUserCommandHandler> logger)
: ICommandHandler<GenerateMixesForUserCommand>
{ {
public record GenerateMixesForUserCommand(long UserId) private sealed record MixDraft(MixKind Kind, IReadOnlyList<Guid> TrackIds);
: ICommand<ErrorOr<Success>>;
public class GenerateMixesForUserCommandHandler( public async ValueTask<Unit> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
IDatabase database,
MixConfiguration mixConfiguration,
ILogger<GenerateMixesForUserCommandHandler> logger)
: ICommandHandler<GenerateMixesForUserCommand, ErrorOr<Success>>
{ {
private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList<Guid> TrackIds); var userId = request.UserId;
public async ValueTask<ErrorOr<Success>> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken) var libraryTrackIds = await database.Tracks
.AsNoTracking()
.Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(track => track.Id)
.ToListAsync(cancellationToken);
if (libraryTrackIds.Count == 0)
{ {
var userId = request.UserId; logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
return Unit.Value;
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) => var historyTrackIds = await database.ListeningHistories
[.. trackIds.Take(mixConfiguration.ItemsPerMix)]; .AsNoTracking()
.Where(history => history.UserId == userId && history.IsCounted)
.Select(history => history.TrackId)
.Distinct()
.ToListAsync(cancellationToken);
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) => var historySet = new HashSet<Guid>(historyTrackIds);
[.. trackIds.OrderBy(_ => Guid.NewGuid())]; var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList();
private static List<Guid> Interleave(IReadOnlyList<Guid> first, IReadOnlyList<Guid> second) var drafts = new List<MixDraft>();
var discovery = Take(Shuffle(unheardTrackIds));
if (discovery.Count > 0)
{ {
var merged = new List<Guid>(first.Count + second.Count); drafts.Add(new MixDraft(MixKind.Discovery, discovery));
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 daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
if (daily.Count > 0)
{ {
var previousMixes = await database.Mixes drafts.Add(new MixDraft(MixKind.Daily, daily));
.Where(mix => mix.UserId == userId) }
.ToListAsync(cancellationToken);
if (previousMixes.Count > 0) if (drafts.Count == 0)
{
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
return Unit.Value;
}
await ReplaceMixesAsync(userId, drafts, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
return Unit.Value;
}
private List<Guid> Take(IEnumerable<Guid> trackIds) =>
[.. trackIds.Take(mixConfiguration.ItemsPerMix)];
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) =>
[.. trackIds.OrderBy(_ => Guid.NewGuid())];
private static List<Guid> Interleave(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)
{
database.Mixes.RemoveRange(previousMixes);
}
for (var position = 0; position < drafts.Count; position++)
{
var draft = drafts[position];
var mix = new Mix
{ {
var previousIds = previousMixes.Select(mix => mix.Id).ToList(); UserId = userId,
var previousItems = await database.MixItems Kind = draft.Kind,
.Where(item => previousIds.Contains(item.MixId)) Position = position
.ToListAsync(cancellationToken); };
database.MixItems.RemoveRange(previousItems); await database.Mixes.AddAsync(mix, cancellationToken);
database.Mixes.RemoveRange(previousMixes);
}
for (var position = 0; position < drafts.Count; position++) for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++)
{ {
var draft = drafts[position]; await database.MixItems.AddAsync(new MixItem
var mix = new Mix
{ {
UserId = userId, MixId = mix.Id,
Title = draft.Title, Position = itemPosition,
Subtitle = draft.Subtitle, TrackId = draft.TrackIds[itemPosition]
Position = position }, cancellationToken);
};
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);
}
} }
} }
} }
@@ -3,29 +3,29 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses; using Musify.Application.Mixes.Responses;
using Musify.Application.Shared;
namespace Musify.Application.Mixes namespace Musify.Application.Mixes;
public record GetMixByIdQuery(long UserId, Guid MixId)
: IQuery<ErrorOr<MixApplicationResponse>>;
public class GetMixByIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixByIdQuery, ErrorOr<MixApplicationResponse>>
{ {
public record GetMixByIdQuery(long UserId, Guid MixId) public async ValueTask<ErrorOr<MixApplicationResponse>> Handle(GetMixByIdQuery request, CancellationToken cancellationToken)
: 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()
var mix = await database.Mixes .Include(mix => mix.Items)
.AsNoTracking() .SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
.Include(mix => mix.Items)
.SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
if (mix == null) if (mix == null)
return Error.NotFound(description: $"Mix {request.MixId} was not found"); return AppErrors.NotFound("Mix", request.MixId);
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList(); var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken); var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken);
return new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, items.Count, items); return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items);
}
} }
} }
@@ -1,37 +1,50 @@
using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses; using Musify.Application.Mixes.Responses;
namespace Musify.Application.Mixes namespace Musify.Application.Mixes;
public record GetMixesByUserIdQuery(long UserId)
: IQuery<IReadOnlyList<MixApplicationResponse>>;
public class GetMixesByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixesByUserIdQuery, IReadOnlyList<MixApplicationResponse>>
{ {
public record GetMixesByUserIdQuery(long UserId) public async ValueTask<IReadOnlyList<MixApplicationResponse>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
: 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)
.ThenBy(mix => mix.Id)
.ToListAsync(cancellationToken);
var orderedItemsByMix = mixes.ToDictionary(
mix => mix.Id,
mix => mix.Items.OrderBy(item => item.Position).ToList());
var coverItems = orderedItemsByMix.Values
.SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount))
.ToList();
var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken))
.GroupBy(response => response.TrackId)
.ToDictionary(group => group.Key, group => group.First());
var responses = new List<MixApplicationResponse>(mixes.Count);
foreach (var mix in mixes)
{ {
var mixes = await database.Mixes var orderedItems = orderedItemsByMix[mix.Id];
.AsNoTracking() var covers = orderedItems
.Where(mix => mix.UserId == request.UserId) .Take(MixApplicationResponse.CoverItemCount)
.Include(mix => mix.Items) .Where(item => coverResponses.ContainsKey(item.TrackId))
.OrderBy(mix => mix.Position) .Select(item => coverResponses[item.TrackId])
.ToListAsync(cancellationToken); .ToList();
var responses = new List<MixApplicationResponse>(mixes.Count); responses.Add(new MixApplicationResponse(mix.Id, mix.Kind, orderedItems.Count, covers));
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;
} }
return responses;
} }
} }
@@ -2,39 +2,39 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses; using Musify.Application.Mixes.Responses;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes namespace Musify.Application.Mixes;
internal static class MixItemMapper
{ {
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
internal static class MixItemMapper IDatabase database,
IReadOnlyList<MixItem> orderedItems,
CancellationToken cancellationToken)
{ {
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync( var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList();
IDatabase database,
IReadOnlyList<MixItem> orderedItems,
CancellationToken cancellationToken)
{
var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList();
var tracksById = await database.Tracks var tracksById = await database.Tracks
.AsNoTracking() .AsNoTracking()
.Include(track => track.Owner) .Include(track => track.Owner)
.Where(track => trackIds.Contains(track.Id)) .Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count }) .Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
.ToDictionaryAsync(x => x.Track.Id, cancellationToken); .ToDictionaryAsync(x => x.Track.Id, cancellationToken);
return orderedItems return orderedItems
.Where(item => tracksById.ContainsKey(item.TrackId)) .Where(item => tracksById.ContainsKey(item.TrackId))
.Select(item => .Select(item =>
{ {
var entry = tracksById[item.TrackId]; var entry = tracksById[item.TrackId];
return new MixItemApplicationResponse( return new MixItemApplicationResponse(
item.TrackId, item.TrackId,
entry.Track.Title, entry.Track.Title,
entry.Track.Owner.Name, entry.Track.Owner.Name,
entry.Track.DurationSeconds, entry.Track.DurationSeconds,
entry.ListensCount); entry.ListensCount);
}) })
.ToList(); .ToList();
}
} }
} }
@@ -1,12 +1,12 @@
namespace Musify.Application.Mixes.Responses using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes.Responses;
public record MixApplicationResponse(
Guid Id,
MixKind Kind,
int ItemCount,
IReadOnlyList<MixItemApplicationResponse> Items)
{ {
public record MixApplicationResponse( public const int CoverItemCount = 4;
Guid Id,
string Title,
string? Subtitle,
int ItemCount,
IReadOnlyList<MixItemApplicationResponse> Items)
{
public const int CoverItemCount = 4;
}
} }
@@ -1,9 +1,8 @@
namespace Musify.Application.Mixes.Responses namespace Musify.Application.Mixes.Responses;
{
public record MixItemApplicationResponse( public record MixItemApplicationResponse(
Guid TrackId, Guid TrackId,
string Title, string Title,
string? Artist, string? Artist,
double DurationSeconds, double DurationSeconds,
int ListensCount); int ListensCount);
}
@@ -0,0 +1,75 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using MimeMapping;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Pictures;
public enum CoverOwner
{
Track,
Album,
PlayList
}
public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium)
: IQuery<ErrorOr<CoverLocation>>;
public record CoverLocation(string Bucket, string Key, string ContentType);
public class GetCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration,
AlbumConfiguration albumConfiguration,
PlayListConfiguration playListConfiguration)
: IQueryHandler<GetCoverQuery, ErrorOr<CoverLocation>>
{
private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large);
public async ValueTask<ErrorOr<CoverLocation>> Handle(GetCoverQuery request, CancellationToken cancellationToken)
{
var (source, routes) = request.Owner switch
{
CoverOwner.Track => (await database.Tracks.AsNoTracking()
.Where(t => t.Id == request.Id && t.LifeCycleStatus == LifeCycleStatus.Active)
.Select(t => new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName))
.SingleOrDefaultAsync(cancellationToken), trackConfiguration.Routes),
CoverOwner.Album => (await database.Albums.AsNoTracking()
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null)
.Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName))
.SingleOrDefaultAsync(cancellationToken), albumConfiguration.Routes),
_ => (await database.PlayLists.AsNoTracking()
.Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active && p.Pictures != null)
.Select(p => new CoverSource(p.OwnerUserId, p.Pictures!.OriginalName, p.Pictures.SmallName, p.Pictures.MediumName, p.Pictures.LargeName))
.SingleOrDefaultAsync(cancellationToken), playListConfiguration.Routes)
};
var entity = request.Owner.ToString();
if (source == null)
return AppErrors.NotFound($"{entity}Cover", request.Id);
var resizedName = request.Size switch
{
PictureSize.Small => source.Small,
PictureSize.Large => source.Large,
_ => source.Medium
};
// The resized files only exist once processing has finished; until then serve the original.
var (name, key) = !string.IsNullOrEmpty(resizedName)
? (resizedName, routes.BuildPicturePath(request.Size, resizedName))
: !string.IsNullOrEmpty(source.Original)
? (source.Original, routes.BuildOriginalPicturePath(source.OwnerUserId, source.Original))
: (null, null);
if (name == null || key == null)
return AppErrors.NotFound($"{entity}Cover", request.Id);
return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name));
}
}
@@ -0,0 +1,35 @@
using ErrorOr;
using Musify.Application.Configuration;
using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Pictures;
/// <summary>What an album or playlist needs to switch to a freshly uploaded picture.</summary>
public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes);
public static class PictureSourceChange
{
public static async Task<ErrorOr<PreparedPicture>> PrepareAsync(
UploadIntentValidator uploadIntentValidator,
Guid intentId,
long userId,
UploadIntentPurpose purpose,
IPictureOwnerConfiguration owner,
CancellationToken cancellationToken)
{
var validation = await uploadIntentValidator.ValidateAndLoadAsync(intentId, userId, purpose, cancellationToken);
if (validation.IsError)
return validation.Errors;
var intent = validation.Value;
return new PreparedPicture(
intent,
EntityPictures.Pending(intent.ObjectName),
owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName),
owner.PicturesSizes.ToImageSizes(owner.Routes));
}
}
@@ -0,0 +1,10 @@
namespace Musify.Application.Pictures.Responses;
public record PictureUploadResponse(
Guid IntentId,
string Bucket,
string Key,
string PictureName,
string ContentType,
int ExpiresInSeconds,
string UploadUrl);
@@ -1,75 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
namespace Musify.Application.PlayLists
{
public record AddTrackToPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class AddTrackToPlayListCommandHandler(
IDatabase database,
ILogger<AddTrackToPlayListCommandHandler> logger)
: ICommandHandler<AddTrackToPlayListCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playList == null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
var trackExists = await database.Tracks
.AsNoTracking()
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
if (!trackExists)
{
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound(description: $"Track {request.TrackId} not found");
}
var alreadyAdded = await database.PlayListHasTracks
.AsNoTracking()
.AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
if (alreadyAdded)
{
logger.LogInformation("Track {TrackId} already in playlist {PlayListId}", request.TrackId, request.PlayListId);
return Error.Conflict(description: "Track is already in the playlist.");
}
var playListTracksQuery = database.PlayListHasTracks
.Where(plt => plt.PlayListId == request.PlayListId);
var hasTracks = await playListTracksQuery.AnyAsync(cancellationToken);
var nextPosition = hasTracks
? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1
: 0;
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
{
PlayListId = request.PlayListId,
TrackId = request.TrackId,
Position = nextPosition
}, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Added track {TrackId} to playlist {PlayListId} at position {Position}",
request.TrackId, request.PlayListId, nextPosition);
return new Success();
}
}
}
@@ -0,0 +1,73 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists;
/// <summary>Appends tracks to a playlist in the given order, skipping the ones it already has.</summary>
public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList<Guid> TrackIds)
: ICommand<ErrorOr<Success>>;
public class AddTracksToPlayListCommandHandler(
IDatabase database,
ILogger<AddTracksToPlayListCommandHandler> logger)
: ICommandHandler<AddTracksToPlayListCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
if (playList.IsError)
return playList.Errors;
var requestedIds = request.TrackIds.Distinct().ToList();
var existingIds = await database.Tracks
.AsNoTracking()
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(track => track.Id)
.ToListAsync(cancellationToken);
var missing = requestedIds.FirstOrDefault(id => !existingIds.Contains(id));
if (missing != Guid.Empty)
return AppErrors.NotFound("Track", missing);
var alreadyInPlayList = await database.PlayListHasTracks
.AsNoTracking()
.Where(link => link.PlayListId == request.PlayListId && requestedIds.Contains(link.TrackId))
.Select(link => link.TrackId)
.ToListAsync(cancellationToken);
var toAdd = requestedIds.Where(id => !alreadyInPlayList.Contains(id)).ToList();
if (toAdd.Count == 0)
return Result.Success;
// Playlist positions are internal indexes, so they start at 0.
var nextPosition = (await database.PlayListHasTracks
.Where(link => link.PlayListId == request.PlayListId)
.MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1;
foreach (var trackId in toAdd)
{
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
{
PlayListId = request.PlayListId,
TrackId = trackId,
Position = nextPosition++
}, cancellationToken);
}
var saved = await database.TrySaveChangesAsync(
AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken);
if (saved.IsError)
return saved;
logger.LogInformation("Added {Count} tracks to playlist {PlayListId}", toAdd.Count, request.PlayListId);
return Result.Success;
}
}
@@ -3,144 +3,76 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Shared; using Musify.Application.Contracts;
using Musify.Application.Events; using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists;
public record CreatePlayListCommand(
long UserId,
string Name,
string? Description,
Guid? PictureIntentId,
PlayListVisibility Visibility = PlayListVisibility.Private)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class CreatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<CreatePlayListCommandHandler> logger,
PlayListConfiguration playListConfiguration)
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{ {
public record CreatePlayListCommand( public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
long UserId,
string Name,
string? Description,
Guid? PictureIntentId,
PlaylistVisibility Visibility = PlaylistVisibility.Private)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class CreatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<CreatePlayListCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{ {
private sealed record ResolvedPlayListPictures(PlayListPictures? Pictures, UploadIntent? Intent); var userExists = await database.Users
.AsNoTracking()
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists)
return AppErrors.NotFound("User", request.UserId);
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken) PreparedPicture? picture = null;
if (request.PictureIntentId is { } intentId)
{ {
var userExists = await database.Users var prepared = await PictureSourceChange.PrepareAsync(
.AsNoTracking() uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
.AnyAsync(u => u.Id == request.UserId, cancellationToken); if (prepared.IsError)
if (!userExists) return prepared.Errors;
{
logger.LogWarning("User {UserId} not found", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
}
var picturesResult = await ResolvePicturesAsync(request, cancellationToken); picture = prepared.Value;
if (picturesResult.IsError)
return picturesResult.Errors;
var resolvedPictures = picturesResult.Value;
var playList = new PlayList
{
UserId = request.UserId,
Name = request.Name,
NormalizedName = request.Name.Trim().ToUpperInvariant(),
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
Pictures = resolvedPictures.Pictures,
Visibility = request.Visibility
};
await database.PlayLists.AddAsync(playList, cancellationToken);
if (resolvedPictures.Intent != null)
{
var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, resolvedPictures.Pictures!.OriginalName);
var publishResult = await PublishCreatePlayListEventAsync(playList.Id, resolvedPictures.Intent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save playlist for user {UserId}", request.UserId);
return Error.Failure(description: $"Failed to create playlist for user {request.UserId}");
}
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
return PlayListApplicationResponse.FromEntity(playList);
} }
private async ValueTask<ErrorOr<ResolvedPlayListPictures>> ResolvePicturesAsync( var name = request.Name.Trim();
CreatePlayListCommand request, CancellationToken cancellationToken) var playList = new PlayList
{ {
if (!request.PictureIntentId.HasValue) OwnerUserId = request.UserId,
{ Name = name,
return new ResolvedPlayListPictures(null, null); NormalizedName = TextNormalizer.Normalize(name),
} Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
Pictures = picture?.Pictures,
Visibility = request.Visibility
};
await database.PlayLists.AddAsync(playList, cancellationToken);
var validation = await uploadIntentValidator.ValidateAndLoadAsync( if (picture != null)
uploadIntentConfiguration, request.PictureIntentId.Value, request.UserId, cancellationToken); {
await eventBus.PublishAsync(
if (validation.IsError) new CreatePlayListResourcesEvent(
return validation.Errors; playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
cancellationToken);
return new ResolvedPlayListPictures(new PlayListPictures
{
OriginalName = validation.Value.ObjectName,
SmallName = validation.Value.ObjectName,
MediumName = validation.Value.ObjectName,
LargeName = validation.Value.ObjectName
}, validation.Value);
} }
private async Task<ErrorOr<Success>> PublishCreatePlayListEventAsync( await database.SaveChangesAsync(cancellationToken);
Guid playListId,
UploadIntent pictureIntent, logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
string finalPictureKey,
CancellationToken cancellationToken) return PlayListApplicationResponse.FromEntity(playList);
{
try
{
await eventBus.PublishAsync(
new CreatePlayListResourcesEvent(
playListId,
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
new ImageSize(
playListConfiguration.Routes.SmallPicturesPath,
playListConfiguration.PicturesSizes.SmallPictureWidth,
playListConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
playListConfiguration.Routes.MediumPicturesPath,
playListConfiguration.PicturesSizes.MediumPictureWidth,
playListConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
playListConfiguration.Routes.LargePicturesPath,
playListConfiguration.PicturesSizes.LargePictureWidth,
playListConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish create playlist event for playlist {PlayListId}", playListId);
return Error.Failure(description: $"Failed to publish create playlist event for playlist {playListId}");
}
}
} }
} }
@@ -1,62 +1,36 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Events; using Musify.Application.Events;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists;
public record DeletePlayListCommand(long UserId, Guid PlayListId)
: ICommand<ErrorOr<Success>>;
public class DeletePlayListCommandHandler(
IDatabase database,
IEventBus eventBus,
ILogger<DeletePlayListCommandHandler> logger)
: ICommandHandler<DeletePlayListCommand, ErrorOr<Success>>
{ {
public record DeletePlayListCommand(long UserId, Guid PlayListId) public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
: ICommand<ErrorOr<Success>>;
public class DeletePlayListCommandHandler(
IDatabase database,
IEventBus eventBus,
ILogger<DeletePlayListCommandHandler> logger)
: ICommandHandler<DeletePlayListCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken) var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
{ if (found.IsError)
var playList = await database.PlayLists.SingleOrDefaultAsync(p => p.Id == request.PlayListId, cancellationToken); return found.Errors;
if (playList == null)
{
logger.LogDebug("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId) var playList = found.Value;
{ playList.LifeCycleStatus = LifeCycleStatus.Removing;
logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId);
return Error.Unauthorized();
}
playList.LifeCycleStatus = LifeCycleStatus.Removing; await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken);
await database.SaveChangesAsync(cancellationToken);
try logger.LogInformation("Marked playlist {PlayListId} as removing", request.PlayListId);
{
await eventBus.PublishAsync(
new DeletePlayListEvent(playList.Id, request.UserId),
cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish delete playlist event for playlist {PlayListId}", request.PlayListId);
return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}");
}
try return Result.Success;
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to mark playlist {PlayListId} as removing", request.PlayListId);
return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}");
}
return new Success();
}
} }
} }
@@ -1,42 +1,30 @@
using ErrorOr; using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.PlayLists.Responses;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Domain.ValueObjects; using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared;
namespace Musify.Application.PlayLists 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 record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null) public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
: IQuery<ErrorOr<PlayListApplicationResponse>>;
public class GetPlayListByIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListByIdQuery, ErrorOr<PlayListApplicationResponse>>
{ {
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken) var playList = await database.PlayLists
{ .AsNoTracking()
var entry = await database.PlayLists .Active()
.AsNoTracking() .VisibleTo(request.RequestingUserId)
.Where(p => p.Id == request.Id) .Where(p => p.Id == request.Id)
.Select(p => new .SelectResponse()
{ .SingleOrDefaultAsync(cancellationToken);
PlayList = p,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToList()
})
.SingleOrDefaultAsync(cancellationToken);
if (entry == null) return playList == null
return Error.NotFound(); ? AppErrors.NotFound("PlayList", request.Id)
: playList;
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);
}
}
@@ -2,48 +2,40 @@ using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using X.PagedList; using Musify.Domain.ValueObjects;
using X.PagedList.EF;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists;
public record GetPlayListTracksQuery(
Guid PlayListId,
int PageNumber,
int PageSize,
long? ViewerId = null)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetPlayListTracksQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{ {
public record GetPlayListTracksQuery( public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
Guid PlayListId,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetPlayListTracksQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{ {
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken) var playListVisible = await database.PlayLists
{ .AsNoTracking()
var playListExists = await database.PlayLists .Active()
.AsNoTracking() .VisibleTo(request.ViewerId)
.AnyAsync(p => p.Id == request.PlayListId, cancellationToken); .AnyAsync(playList => playList.Id == request.PlayListId, cancellationToken);
if (!playListExists) if (!playListVisible)
return Error.NotFound(); return AppErrors.NotFound("PlayList", request.PlayListId);
var tracksQuery = database.PlayListHasTracks return await database.PlayListHasTracks
.AsNoTracking() .AsNoTracking()
.Include(plt => plt.Track.Owner) .Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
.Include(plt => plt.Track.Tags) .OrderBy(playListTrack => playListTrack.Position)
.Where(plt => plt.PlayListId == request.PlayListId); .ThenBy(playListTrack => playListTrack.Id)
.Select(playListTrack => playListTrack.Track)
var totalCount = await tracksQuery.CountAsync(cancellationToken); .SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
var pagedEntities = await tracksQuery
.OrderBy(plt => plt.Position)
.Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
}
} }
} }
@@ -1,43 +0,0 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Shared;
using Musify.Application.PlayLists.Responses;
using X.PagedList;
using X.PagedList.EF;
using Mediator;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists
{
public record GetPlayListsQuery(int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
public class GetPlayListsQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsQuery request, CancellationToken cancellationToken)
{
var totalCount = await database.PlayLists.CountAsync(cancellationToken);
var pagedEntities = await database.PlayLists
.AsNoTracking()
.OrderBy(p => p.Id)
.Select(p => new
{
PlayList = p,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
}
}
}
@@ -1,64 +1,40 @@
using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists;
public record GetPlayListsByUserIdQuery(
long UserId,
string? Name,
int PageNumber,
int PageSize,
long? ViewerId = null)
: IQuery<PaginatedResponse<PlayListApplicationResponse>>;
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsByUserIdQuery, PaginatedResponse<PlayListApplicationResponse>>
{ {
public record GetPlayListsByUserIdQuery( public async ValueTask<PaginatedResponse<PlayListApplicationResponse>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
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()
.Active()
.VisibleTo(request.ViewerId)
.Where(playList => playList.OwnerUserId == request.UserId);
if (!string.IsNullOrWhiteSpace(request.Name))
{ {
var playListsQuery = database.PlayLists var normalizedName = TextNormalizer.Normalize(request.Name);
.AsNoTracking() playListsQuery = playListsQuery.Where(playList => playList.NormalizedName.Contains(normalizedName));
.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);
} }
return await playListsQuery
.OrderBy(playList => playList.CreatedAt)
.ThenBy(playList => playList.Id)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
} }
} }
@@ -3,47 +3,43 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists;
public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class RemoveTrackFromPlayListCommandHandler(
IDatabase database,
ILogger<RemoveTrackFromPlayListCommandHandler> logger)
: ICommandHandler<RemoveTrackFromPlayListCommand, ErrorOr<Success>>
{ {
public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId) public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
: ICommand<ErrorOr<Success>>;
public class RemoveTrackFromPlayListCommandHandler(
IDatabase database,
ILogger<RemoveTrackFromPlayListCommandHandler> logger)
: ICommandHandler<RemoveTrackFromPlayListCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken) var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
{ if (playList.IsError)
var playList = await database.PlayLists return playList.Errors;
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playList == null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId) var playListTracks = await database.PlayListHasTracks
{ .Where(plt => plt.PlayListId == request.PlayListId)
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); .OrderBy(plt => plt.Position)
return Error.Unauthorized(); .ToListAsync(cancellationToken);
}
var link = await database.PlayListHasTracks var link = playListTracks.SingleOrDefault(plt => plt.TrackId == request.TrackId);
.SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken); if (link == null)
if (link == null) return Error.NotFound("PlayList.TrackNotFound", "Track is not in the playlist.");
{
logger.LogInformation("Track {TrackId} not in playlist {PlayListId}", request.TrackId, request.PlayListId);
return Error.NotFound(description: "Track is not in the playlist.");
}
database.PlayListHasTracks.Remove(link); database.PlayListHasTracks.Remove(link);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId); var position = 0;
foreach (var remaining in playListTracks.Where(plt => plt.Id != link.Id))
remaining.Position = position++;
return new Success(); await database.SaveChangesAsync(cancellationToken);
}
logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId);
return Result.Success;
} }
} }
@@ -1,105 +1,24 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.PlayLists.Responses; using Musify.Application.Pictures.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists;
public record RequestPlayListPictureUploadCommand(
long UserId,
string FileType,
string ContentType,
long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<PictureUploadResponse>>;
public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration)
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PictureUploadResponse>>
{ {
public record RequestPlayListPictureUploadCommand( public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) =>
long UserId, await issuer.IssuePictureAsync(
string FileType, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration,
string ContentType, request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<PlayListPictureUploadResponse>>;
public class RequestPlayListPictureUploadCommandHandler(
IDatabase database,
UploadIntentValidator uploadIntentValidator,
IStorageService storageService,
ILogger<RequestPlayListPictureUploadCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PlayListPictureUploadResponse>>
{
public async ValueTask<ErrorOr<PlayListPictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken)
{
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 = playListConfiguration.Routes.BuildTempPicturePath(
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName);
try
{
var uploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket,
tempKey,
request.ContentType,
TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
cancellationToken);
await using var transaction = await database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, cancellationToken);
var quotaCheck = await uploadIntentValidator.CheckQuotaAsync(
uploadIntentConfiguration,
request.UserId, effectiveSizeBytes, 1, cancellationToken);
if (quotaCheck.IsError)
{
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
return quotaCheck.Errors;
}
var intent = new UploadIntent
{
UserId = request.UserId,
Bucket = storageConfiguration.Bucket,
Key = tempKey,
ObjectName = objectName,
ContentType = request.ContentType,
ExpectedSizeBytes = effectiveSizeBytes,
Purpose = UploadIntentPurpose.PlayListPicture,
ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
};
await database.UploadIntents.AddAsync(intent, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (PlayListPicture, temp key: {Key})", intent.Id, request.UserId, tempKey);
return new PlayListPictureUploadResponse(
intent.Id,
storageConfiguration.Bucket,
tempKey,
objectName,
request.ContentType,
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
uploadUrl);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate playlist picture upload URL for user {UserId}", request.UserId);
return Error.Failure(description: "Failed to generate upload URL");
}
}
}
} }
@@ -1,37 +1,45 @@
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; 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( namespace Musify.Application.PlayLists.Responses;
PlayList playList,
IReadOnlyList<Guid>? coverTrackIds = null) public record PlayListApplicationResponse(
{ Guid Id,
return new PlayListApplicationResponse( string Name,
playList.Id, string? Description,
playList.Name, string? SmallImageKeyName,
playList.Description, string? MediumImageKeyName,
playList.Pictures?.SmallName, string? LargeImageKeyName,
playList.Pictures?.MediumName, PlayListVisibility Visibility,
playList.Pictures?.LargeName, DateTime CreatedAt,
playList.Visibility, DateTime UpdatedAt,
playList.CreatedAt, IReadOnlyList<Guid> CoverTrackIds,
playList.UpdatedAt, long OwnerUserId,
coverTrackIds ?? []); int TrackCount,
} double DurationSeconds)
{
public const int CoverTrackCount = PlayListProjections.CoverTrackCount;
public static PlayListApplicationResponse FromEntity(
PlayList playList,
IReadOnlyList<Guid>? coverTrackIds = null,
int trackCount = 0,
double durationSeconds = 0)
{
return new PlayListApplicationResponse(
playList.Id,
playList.Name,
playList.Description,
playList.Pictures?.SmallName,
playList.Pictures?.MediumName,
playList.Pictures?.LargeName,
playList.Visibility,
playList.CreatedAt,
playList.UpdatedAt,
coverTrackIds ?? [],
playList.OwnerUserId,
trackCount,
durationSeconds);
} }
} }
@@ -1,11 +0,0 @@
namespace Musify.Application.PlayLists.Responses
{
public record PlayListPictureUploadResponse(
Guid IntentId,
string Bucket,
string Key,
string PictureName,
string ContentType,
int ExpiresInSeconds,
string UploadUrl);
}
@@ -0,0 +1,37 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists.Responses;
public static class PlayListProjections
{
public const int CoverTrackCount = 4;
public static IQueryable<PlayList> Active(this IQueryable<PlayList> query) =>
query.Where(playList => playList.LifeCycleStatus == LifeCycleStatus.Active);
public static IQueryable<PlayList> VisibleTo(this IQueryable<PlayList> query, long? viewerId) =>
query.Where(playList => playList.Visibility == PlayListVisibility.Public || playList.OwnerUserId == viewerId);
public static IQueryable<PlayListApplicationResponse> SelectResponse(this IQueryable<PlayList> query) =>
query.Select(playList => new PlayListApplicationResponse(
playList.Id,
playList.Name,
playList.Description,
playList.Pictures!.SmallName,
playList.Pictures!.MediumName,
playList.Pictures!.LargeName,
playList.Visibility,
playList.CreatedAt,
playList.UpdatedAt,
playList.PlayListTracks
.OrderBy(playListTrack => playListTrack.Position)
.Take(CoverTrackCount)
.Select(playListTrack => playListTrack.TrackId)
.ToList(),
playList.OwnerUserId,
playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active),
playList.PlayListTracks
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
.Sum(playListTrack => playListTrack.Track.DurationSeconds)));
}
@@ -3,153 +3,78 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Shared; using Musify.Application.Contracts;
using Musify.Application.Events; using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Domain.Entities; using Musify.Application.Shared;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists;
public record UpdatePlayListCommand(
long UserId,
Guid PlayListId,
string? NewName,
string? NewDescription,
Guid? NewPictureIntentId,
PlayListVisibility? NewVisibility = null)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class UpdatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdatePlayListCommandHandler> logger,
PlayListConfiguration playListConfiguration)
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{ {
public record UpdatePlayListCommand( public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
long UserId,
Guid PlayListId,
string? NewName,
string? NewDescription,
Guid? NewPictureIntentId,
PlaylistVisibility? NewVisibility = null)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class UpdatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdatePlayListCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{ {
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken) var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var playList = found.Value;
if (!string.IsNullOrWhiteSpace(request.NewName))
{ {
var playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); playList.Name = request.NewName.Trim();
if (playListEntity == null) playList.NormalizedName = TextNormalizer.Normalize(request.NewName);
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playListEntity.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
if (!string.IsNullOrWhiteSpace(request.NewName))
{
playListEntity.Name = request.NewName;
playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant();
}
if (!string.IsNullOrWhiteSpace(request.NewDescription))
{
playListEntity.Description = request.NewDescription;
}
if (request.NewVisibility.HasValue)
{
playListEntity.Visibility = request.NewVisibility.Value;
}
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;
playListEntity.Pictures = new PlayListPictures
{
OriginalName = pictureIntent.ObjectName,
SmallName = pictureIntent.ObjectName,
MediumName = pictureIntent.ObjectName,
LargeName = pictureIntent.ObjectName
};
finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
}
if (pictureIntent != null && finalPictureKey != null)
{
var publishResult = await PublishUpdatePlayListPictureSourceEventAsync(
playListEntity.Id, pictureIntent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to update playlist {PlayListId}", playListEntity.Id);
return Error.Failure(description: $"Failed to update playlist {playListEntity.Id}");
}
var coverTrackIds = await database.PlayListHasTracks
.AsNoTracking()
.Where(plt => plt.PlayListId == playListEntity.Id)
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToListAsync(cancellationToken);
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds);
} }
private async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync( // null means "unchanged"; a blank description clears it.
Guid playListId, if (request.NewDescription != null)
UploadIntent pictureIntent, playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim();
string finalPictureKey,
CancellationToken cancellationToken) if (request.NewVisibility is { } visibility)
playList.Visibility = visibility;
if (request.NewPictureIntentId is { } intentId)
{ {
try var prepared = await PictureSourceChange.PrepareAsync(
{ uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
await eventBus.PublishAsync( if (prepared.IsError)
new UpdatePlayListPictureSourceEvent( return prepared.Errors;
playListId,
pictureIntent.Id, var picture = prepared.Value;
storageConfiguration.Bucket, playList.Pictures = picture.Pictures;
pictureIntent.Key,
finalPictureKey, await eventBus.PublishAsync(
new ImageSize( new UpdatePlayListPictureSourceEvent(
playListConfiguration.Routes.SmallPicturesPath, playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
playListConfiguration.PicturesSizes.SmallPictureWidth, cancellationToken);
playListConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
playListConfiguration.Routes.MediumPicturesPath,
playListConfiguration.PicturesSizes.MediumPictureWidth,
playListConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
playListConfiguration.Routes.LargePicturesPath,
playListConfiguration.PicturesSizes.LargePictureWidth,
playListConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish update playlist picture source event for playlist {PlayListId}", playListId);
return Error.Failure(description: $"Failed to publish update playlist picture source event for playlist {playListId}");
}
} }
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Updated playlist {PlayListId}", playList.Id);
return await database.PlayLists
.AsNoTracking()
.Where(p => p.Id == playList.Id)
.SelectResponse()
.SingleAsync(cancellationToken);
} }
} }

Some files were not shown because too many files have changed in this diff Show More