Skip to content
Merged

Dev #90

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
# ── Startup validation ────────────────────────────────────────
validate_startup()

VERSION = "2.22.2"
VERSION = "2.22.3"

# ── CORS ──────────────────────────────────────────────────────

Expand Down
5 changes: 3 additions & 2 deletions api/app/routers/playlist_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,11 +262,12 @@ async def create_playlist(
):
user_id = user["sub"]
data = _load(user_id)
pl = _new_playlist(user_id, req.title, req.description or "", [])
pl = _new_playlist(user_id, req.title, req.description or "", list(req.trackIds or []))
data[pl["id"]] = pl
_save(user_id, data)
await _sync_mongo(pl)
return pl
# Seeds stay as IDs in storage; the list shape carries no hydrated tracks.
return _summarize(pl)


@router.patch("/{playlist_id}", response_model=PlaylistSchema)
Expand Down
3 changes: 3 additions & 0 deletions api/app/schemas/playlist_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ def _coerce_duration(cls, v):
class CreatePlaylistSchema(BaseModel):
title: str
description: str = ""
# Optional seed tracks — lets a client create-and-fill in one round trip
# (e.g. "save a resolved Spotify playlist"). Order is preserved.
trackIds: list[str] = []


class UpdatePlaylistSchema(BaseModel):
Expand Down
2 changes: 1 addition & 1 deletion api/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "rheoson-api"
version = "2.22.2"
version = "2.22.3"
requires-python = ">=3.13"
license = "Apache-2.0"
dependencies = [
Expand Down
20 changes: 20 additions & 0 deletions api/tests/test_playlists.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,26 @@ async def test_create_playlist(client):
assert data["trackCount"] == 0


@pytest.mark.asyncio
async def test_create_playlist_with_seed_tracks(client):
"""POST /playlists with optional seed trackIds creates-and-fills in one
call (the "save a resolved Spotify playlist" path). Order is preserved."""
resp = await client.post("/api/playlists", json={
"title": "Seeded",
"trackIds": ["vid-1", "vid-2", "vid-3"],
})
assert resp.status_code == 201
data = resp.json()
assert data["trackCount"] == 3
assert data["trackIds"] == ["vid-1", "vid-2", "vid-3"]

# The detail endpoint hydrates the same seeds back out.
detail = await client.get(f"/api/playlists/{data['id']}")
assert detail.status_code == 200
hydrated = detail.json()
assert [t["id"] for t in hydrated["tracks"]] == ["vid-1", "vid-2", "vid-3"]


@pytest.mark.asyncio
async def test_create_playlist_requires_title(client):
"""POST /playlists without title should return 422."""
Expand Down
2 changes: 1 addition & 1 deletion api/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,26 @@ reappear.

---

## Deployment profiles — bring-your-own infrastructure (planned)

Rheoson is open source; the people deploying it have wildly different
resources. The plan is a small set of deployment profiles that map one
configuration file onto what the deployer actually has:

- **Zero-config** — point Rheoson at an existing Postgres/Mongo/Redis (or the
equivalents it can reach) and it wires itself up on first boot.
- **Batteries-included** — one command brings up the full stack with its own
containers, for people who just want it running.
- **Partial attach** — reuse some managed resources (a cloud database) and
self-host the rest, with the health report making the mix explicit.

The Doctor's health probes become the validator: after attaching, a profile
check confirms each resource is reachable, schema-current and writable
before the app accepts traffic. Deliberately deferred until the current
roadmap ships — recorded here so it isn't lost.

---

**Guardrails for every phase:** `tsc`, `eslint`, the full vitest and pytest
suites, a production build and the API-base check all pass before the commit;
existing settings, themes and preference sync keep working; the APK still
Expand Down
4 changes: 2 additions & 2 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "rheoson-web",
"private": true,
"version": "2.22.2",
"version": "2.22.3",
"license": "Apache-2.0",
"type": "module",
"scripts": {
Expand Down
26 changes: 26 additions & 0 deletions web/src/api/playlists.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,31 @@ export const playlistsApi = {
}
},

/** Create a playlist pre-filled with tracks in one round trip (the
* backend accepts optional seed `trackIds`). Falls back to
* create-then-add when the backend predates seed support. */
createBulk: async (data: { title: string; description?: string; trackIds: string[] }): Promise<Playlist> => {
try {
const remote = await api.postQueued<Playlist>('/playlists', {
title: data.title,
description: data.description ?? '',
trackIds: data.trackIds,
});
if (remote?.id) {
await playlistsStore.put({ ...remote, tracks: [] });
return remote;
}
} catch {
// Fall through to the offline path below.
}
// Offline / seed unsupported: create locally, then queue the adds.
const pl = await playlistsApi.createPlaylist({ title: data.title, description: data.description });
for (const id of data.trackIds) {
await playlistsApi.addTrack(pl.id, id);
}
return pl;
},

removeTrack: async (playlistId: string, trackId: string): Promise<void> => {
// Optimistic local update
const local = await playlistsStore.get(playlistId);
Expand Down Expand Up @@ -221,6 +246,7 @@ export const {
getPlaylists,
getPlaylist,
createPlaylist,
createBulk,
updatePlaylist,
deletePlaylist,
addTrack,
Expand Down
6 changes: 3 additions & 3 deletions web/src/components/player/PlayerBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ export default function PlayerBar() {
<motion.button
whileTap={{ scale: 0.8 }}
onClick={handleLike}
aria-label={liked ? "Remove from favourites" : "Favourite"}
aria-label={liked ? "Removed from saved" : "Save"}
className='flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center active:bg-[var(--bg-elevated)]'>
<Heart
className={cn(
Expand Down Expand Up @@ -260,8 +260,8 @@ export default function PlayerBar() {
{[
{
label: liked
? "Remove from favourites"
: "Favourite",
? "Removed from saved"
: "Save",
icon: (
<Heart
className={cn(
Expand Down
30 changes: 21 additions & 9 deletions web/src/components/player/QueuePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,24 +37,36 @@ export default function QueuePanel() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm lg:hidden"
className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm"
onClick={toggleQueue}
/>

{/* Panel */}
{/* Bottom sheet — rises from the bottom edge on every screen
size; a side panel fought the thumb-reach and covered the
player bar it describes. Drag-handle + rounded top keep it
reading as a sheet, not a dialog. */}
<motion.div
key="queue-panel"
initial={{ x: '100%', opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: '100%', opacity: 0 }}
transition={{ type: 'spring', damping: 28, stiffness: 300 }}
initial={{ y: '100%', opacity: 0.6 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '100%', opacity: 0.6 }}
transition={{ type: 'spring', damping: 30, stiffness: 320 }}
role="dialog"
aria-label="Playback queue"
className={cn(
'fixed right-0 top-0 bottom-0 z-50 w-80 max-w-[92vw] flex flex-col',
'glass-strong border-l border-[var(--border)]'
'fixed inset-x-0 bottom-0 z-50 max-h-[82vh] flex flex-col',
'rounded-t-3xl glass-strong border-t border-[var(--border)]',
'pb-[calc(var(--player-height,72px)+var(--nav-height,64px))]',
'sm:left-auto sm:right-4 sm:w-96 sm:max-w-[92vw] sm:rounded-3xl sm:mb-4'
)}
>
{/* Drag handle affordance */}
<div className="flex justify-center pt-2.5" aria-hidden>
<span className="w-10 h-1 rounded-full bg-[var(--border-strong)]" />
</div>

{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border)]">
<div className="flex items-center justify-between px-5 py-3 border-b border-[var(--border)]">
<div className="flex items-center gap-2.5">
<Queue className="w-5 h-5 text-[var(--accent)]" />
<h2 className="font-bold text-[var(--text-primary)]">Queue</h2>
Expand Down
6 changes: 3 additions & 3 deletions web/src/components/track/TrackContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ function useMenuActions(track: Track, onClose: () => void) {
},
{
id: 'like',
label: track.isLiked ? 'Remove from favourites' : 'Favourite',
label: track.isLiked ? 'Removed from saved' : 'Save',
icon: (
<Heart
className={`w-4 h-4 ${track.isLiked ? 'text-[var(--accent)] fill-current' : ''}`}
Expand All @@ -114,10 +114,10 @@ function useMenuActions(track: Track, onClose: () => void) {
run: async () => {
if (track.isLiked) {
await tracksApi.unlikeTrack(track.id)
toast('Removed from favourites', 'info', 1800)
toast('Removed from saved', 'info', 1800)
} else {
await tracksApi.likeTrack(track.id)
toast('Added to favourites', 'success', 1800)
toast('Saved', 'success', 1800)
}
refreshLikes()
onClose()
Expand Down
9 changes: 6 additions & 3 deletions web/src/components/ui/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,18 @@ export function Toast({ id, type, message, code, fullDetail, onDismiss }: ToastP
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className={cn(
'flex flex-col gap-1 px-4 py-3 rounded-2xl shadow-xl border',
'flex flex-col gap-1 px-4 py-3 rounded-2xl shadow-xl border overflow-hidden',
'glass-strong min-w-[280px] max-w-sm',
styles[type]
)}
role={type === 'error' ? 'alert' : 'status'}
>
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 min-w-0">
{icons[type]}
<p className="flex-1 text-sm font-medium text-[var(--text-primary)]">{message}</p>
{/* min-w-0 + overflow-wrap:anywhere — a long unbroken token (a URL,
yt-dlp noise) must wrap inside the toast instead of pushing the
flex item past the viewport edge. */}
<p className="flex-1 min-w-0 text-sm font-medium text-[var(--text-primary)] [overflow-wrap:anywhere]">{message}</p>
{hasMore && (
<button
type="button"
Expand Down
13 changes: 12 additions & 1 deletion web/src/hooks/search.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ export function useSearch() {
const [query, setQueryState] = useState(saved.query);
const [filter, setFilter] = useState<SearchFilter>(saved.filter);
const [results, setResults] = useState<SearchResults | null>(null);
/** For link resolves: the album/playlist title from the resolver, so the
* UI can offer "Save all as playlist" with the real name. */
const [resolvedTitle, setResolvedTitle] = useState<string | null>(null);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [isLoading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand All @@ -101,7 +104,9 @@ export function useSearch() {

const setQuery = useCallback((q: string) => {
setQueryState(q);
if (!q) {
if (q) {
setResolvedTitle(null);
} else {
setSuggestions([]);
setResults(null);
setError(null);
Expand Down Expand Up @@ -160,6 +165,11 @@ export function useSearch() {
// URL resolution — no filter applies here
const resolved = await searchApi.resolve(q, ctrl.signal);
const tracks = resolveToTracks(resolved);
setResolvedTitle(
(resolved.type === "album" || resolved.type === "playlist") && resolved.title
? resolved.title
: null
);
data = {
tracks,
albums: [],
Expand Down Expand Up @@ -228,6 +238,7 @@ export function useSearch() {
filter,
setFilter,
results,
resolvedTitle,
isLoading,
suggestions,
error,
Expand Down
2 changes: 1 addition & 1 deletion web/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export const STORAGE_KEYS = {

export const APP_NAME = "Rheoson";
export const APP_SLOGAN = "Feel the Beat";
export const APP_VERSION = "2.22.2";
export const APP_VERSION = "2.22.3";

// ── Clerk ────────────────────────────────────────────────────
// Publishable key for Clerk auth. Must be set in .env (VITE_CLERK_PUBLISHABLE_KEY).
Expand Down
2 changes: 1 addition & 1 deletion web/src/pages/album/Album.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export default function Album() {
<Shuffle className="w-4 h-4" />
Shuffle
</Button>
<IconButton size="md" variant="ghost" onClick={toggleLike} title={liked ? 'Remove from favourites' : 'Favourite album'}>
<IconButton size="md" variant="ghost" onClick={toggleLike} title={liked ? 'Removed from saved' : 'Save album'}>
<Heart className={cn('w-5 h-5', liked && 'fill-current text-[var(--accent)]')} />
</IconButton>
<IconButton
Expand Down
15 changes: 7 additions & 8 deletions web/src/pages/artist/Artist.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Check, MusicNotes, Play, Plus, Sparkle, User, UserPlus, X } from '@phos
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { invalidateArtistFollowSurfaces } from '@/lib/queryInvalidation'
import { qk } from '@/lib/queryKeys'
import { ArtworkImage } from '@/components/ui/ArtworkImage'
import { useQueue } from '@/hooks/queue.hook'
import { useTrackContextMenu } from '@/hooks/useTrackContextMenu'
import { usePrefetchOnIntent } from '@/hooks/prefetchIntent.hook'
Expand Down Expand Up @@ -434,14 +435,12 @@ function PopularTrackRow({ track, index, onClick }: PopularTrackRowProps) {
</span>
<Play className="w-4 h-4 fill-current text-[var(--text-primary)] hidden group-hover:block" />

{track.artworkUrl
? <img src={track.artworkUrl} alt={track.title} loading="lazy" decoding="async" className="w-11 h-11 rounded-xl object-cover flex-shrink-0" />
: (
<div className="w-11 h-11 rounded-xl flex-shrink-0 bg-[var(--bg-overlay)] border border-[var(--border)] flex items-center justify-center">
<MusicNotes className="w-5 h-5 text-[var(--text-muted)]" weight="duotone" />
</div>
)
}
<ArtworkImage
src={track.artworkUrl}
alt={track.title}
size={44}
radius="rounded-xl"
/>

<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-[var(--text-primary)] truncate">{track.title}</p>
Expand Down
3 changes: 2 additions & 1 deletion web/src/pages/blend/Blend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { messagesApi, type PeerInfo } from '@/api/messages.api'
import { qk } from '@/lib/queryKeys'
import { useAuthStore } from '@/store/auth.store'
import { useQueue } from '@/hooks/queue.hook'
import { ArtworkImage } from '@/components/ui/ArtworkImage'
import { useToast } from '@/components/ui/Toaster'
import { Skeleton } from '@/components/ui/Skeleton'
import { splitErrorCode } from '@/api/client.api'
Expand Down Expand Up @@ -281,7 +282,7 @@ export default function Blend() {
<button
onClick={() => playTrack(t, tracks)}
className='relative w-11 h-11 flex-shrink-0 rounded-xl overflow-hidden'>
<img src={t.artworkUrl || '/assets/logo.png'} alt='' className='w-full h-full object-cover' />
<ArtworkImage src={t.artworkUrl} alt='' className='w-full h-full' radius='rounded-none' />
<span className='absolute inset-0 hidden group-hover:flex items-center justify-center bg-black/40'>
<Play className='w-4 h-4 text-white' weight='fill' />
</span>
Expand Down
2 changes: 1 addition & 1 deletion web/src/pages/downloads/components/LibraryTrackRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export default function LibraryTrackRow({
type='button'
onClick={handleLike}
className='flex-shrink-0 p-1.5 -mr-1'
aria-label={track.isLiked ? 'Remove from favourites' : 'Favourite'}
aria-label={track.isLiked ? 'Removed from saved' : 'Save'}
>
<Heart
className={cn(
Expand Down
Loading
Loading