Compare commits

..

15 Commits

Author SHA1 Message Date
Brian O'Reilly 4a79558c75 Merge branch 'glenneth1-glenneth/user-profile-enhancements'
Summary

This PR fixes several issues with the favorites feature:
Fixes

    Favorites star UI - Star now stays solid after favoriting a track (both main player and frame player)
        Fixed race condition where cache was checked before reload completed
        Cache now calls check-favorite-status after loading

    Track-id lookup - find-track-by-title now parses 'Artist - Title' format from Icecast metadata and searches both artist and title columns

    NIL user-id guards - All favorites functions now return early if user-id is NIL, preventing PostgreSQL errors when API is called without authentication

    Favorites API alist keys - Fixed Postmodern key names (TRACK-TITLE not TRACK_TITLE)

Files Changed

    user-profile.lisp - NIL guards and aget-profile helper
    frontend-partials.lisp - find-track-by-title improvement
    parenscript/front-page.lisp - Cache loading fix
    parenscript/stream-player.lisp - Cache loading fix for frame player
2025-12-27 14:00:02 -05:00
glenneth 9a767a7550 Merge remote-tracking branch 'upstream/main' into glenneth/user-profile-enhancements 2025-12-27 21:37:35 +03:00
glenneth 753ff822ce fix: Add NIL user-id guards to favorites functions
Prevents PostgreSQL errors when favorites API is called without
authentication. Functions now return early (nil or 0) instead of
generating invalid SQL with NIL in WHERE clause.
2025-12-27 20:33:40 +03:00
glenneth 25a6341a7b Merge upstream/main into glenneth/user-profile-enhancements
Resolved conflicts keeping our fixes:
- find-track-by-title: parses 'Artist - Title' format
- favorites cache: calls check-favorite-status after load
- toggle-favorite: uses load-favorites-cache instead of update-now-playing
- aget-profile: uses correct Postmodern key names (TRACK-TITLE, -ID)
2025-12-27 20:19:06 +03:00
glenneth 116d9ceebf fix: Favorites star UI and track-id lookup
- Fix find-track-by-title to parse 'Artist - Title' format from Icecast
  and search both artist and title columns in tracks table
- Fix favorites API alist key mismatch (TRACK-TITLE not TRACK_TITLE)
- Fix favorites cache to update UI after loading
- Fix race condition where star reverted after clicking
- Add aget-profile helper for Postmodern uppercase key lookup
2025-12-27 20:06:37 +03:00
glenneth f5ff17b510 chore: Add .jj/ to gitignore for Jujutsu VCS 2025-12-22 05:52:39 +03:00
glenneth bf5793bf8e feat: Add YP directory listings for internet-radio.com and xiph.org
- Add internet-radio.com YP directory entry
- Add xiph.org (Icecast official) YP directory entry
- All mount points already have public=true in Liquidsoap config
2025-12-22 04:57:36 +03:00
glenneth 02f9255a7c docs: Update TODO-next-features.org with completed tasks
- Mark Internet-Radio.com listing as complete
- Mark Listener Requests (library tracks, add to library) as complete
- Mark all Themed streams as complete (low orbit, deep space, darker ambient, underworld)
2025-12-21 19:24:40 +03:00
glenneth 02e585bd5b feat: Custom user playlists with submission and admin review
- Add user playlist creation, editing, and track management
- Add library browser for adding tracks to playlists
- Add playlist submission workflow for station airing
- Add admin review interface with preview, approve, reject
- Generate M3U files on approval in playlists/user-submissions/
- Include user-submissions in playlist scheduler dropdown
- Use playlist description as PHASE tag in M3U
- Add database migration for user_playlists table
- Update TODO-next-features.org to mark feature complete
2025-12-21 18:45:35 +03:00
glenneth 2c49092c01 refactor: Remove Recently Played section from profile page
Removed the Recently Played UI section from profile as redundant.
The listening history backend and APIs remain intact for future use.
Previous commit (0359e59) preserves the full implementation.
2025-12-21 12:53:10 +03:00
glenneth 0359e5909a feat: Track requests, listening history, and profile enhancements
Track Requests:
- Database table for user track requests (migration 007)
- API endpoints for submit, approve, reject, play
- Front page UI for submitting requests
- Shows recently played requests section

Listening History:
- Auto-records tracks when playing (with 60s deduplication)
- Recently Played section on profile (has date formatting issues)
- Activity chart showing listening patterns by day
- Load More Tracks pagination

Profile Improvements:
- Fixed 401 errors returning proper JSON
- Fixed PostgreSQL boolean type for completed column
- Added offset parameter to recent-tracks API

Note: Recently Played section has date formatting issues showing
'20397 days ago' - may be removed in future commit if not needed.
The listening history backend works correctly.

For production: run migrations/007-track-requests.sql
2025-12-21 12:45:49 +03:00
glenneth 8f5fe7534d feat: Add avatar upload and fix authentication errors
Avatars:
- Add avatar_path column to USERS table (migration 006)
- Upload API endpoint /api/asteroid/user/avatar/upload
- Profile page shows avatar with hover-to-change overlay
- Default SVG avatar for users without uploaded image
- Avatars stored in static/avatars/ directory

Fixes:
- 401 errors now return proper JSON instead of 500
- SQL escaping for history recording (single quotes)
- Added debug logging for history/record API
- Avatar container has background color for visibility

For production: run migrations/006-user-avatars.sql
2025-12-21 09:07:56 +03:00
glenneth a2ebc415f2 feat: Add listening activity chart to profile page
- New API endpoint /api/asteroid/user/activity for daily aggregation
- Bar chart showing tracks played per day (last 30 days)
- Hover tooltips show exact date and count
- Total tracks summary below chart
- Green gradient bars matching site theme
2025-12-21 08:43:36 +03:00
glenneth 7600ea6bed feat: Add listening history tracking and fix favorites
Listening History:
- Auto-record tracks when they change (logged-in users only)
- Track stored by title (no tracks table dependency)
- Profile page shows real recent tracks, top artists, listening stats
- APIs: /api/asteroid/user/history, /user/listening-stats, /user/recent-tracks, /user/top-artists

Favorites Fixes:
- Remove favorite now uses title instead of track-id
- Fixed response parsing to show green success message
- Profile page remove button works correctly

Migration Script Updated:
- track_title column added to both tables
- track-id now optional (nullable)
- Unique index on (user-id, track_title)
- No foreign key to tracks table (title-based storage)

For production: run migrations/005-user-favorites-history.sql
2025-12-21 08:35:35 +03:00
glenneth 5225a07b8b feat: Add track favorites feature with star button
- Add user_favorites and listening_history database tables
- Add migration 005-user-favorites-history.sql
- Create user-profile.lisp with favorites/history API endpoints
- Add star button (☆/★) to Now Playing on main page
- Add star button to frame player bar
- Add Favorites section to profile page
- Show login prompt when unauthenticated user clicks star
- Use gold color (#ffcc00) for favorited state (space theme)
- Fix require-authentication to properly detect API routes
- Support title-based favorites (no track DB required)
2025-12-21 08:15:52 +03:00
4 changed files with 79 additions and 36 deletions

View File

@ -1,23 +1,39 @@
(in-package :asteroid)
(defun find-track-by-title (title)
"Find a track in the database by its title. Returns track ID or nil."
"Find a track in the database by its title. Returns track ID or nil.
Handles 'Artist - Title' format from Icecast metadata."
(when (and title (not (string= title "Unknown")))
(handler-case
(with-db
(let* ((search-pattern (format nil "%~a%" title))
(result (postmodern:query
;; Parse 'Artist - Title' format if present
(let* ((parts (cl-ppcre:split " - " title :limit 2))
(has-artist (> (length parts) 1))
(artist-part (when has-artist (first parts)))
(title-part (if has-artist (second parts) title))
(result
(if has-artist
;; Search by both artist and title
(postmodern:query
(:limit
(:select '_id
:from 'tracks
:where (:ilike 'title search-pattern))
:where (:and (:ilike 'artist (format nil "%~a%" artist-part))
(:ilike 'title (format nil "%~a%" title-part))))
1)
:single)))
:single)
;; Fallback: search by title only
(postmodern:query
(:limit
(:select '_id
:from 'tracks
:where (:ilike 'title (format nil "%~a%" title-part)))
1)
:single))))
result))
(error (e)
(declare (ignore e))
nil))))
(defun icecast-now-playing (icecast-base-url &optional (mount "asteroid.mp3"))
"Fetch now-playing information from Icecast server.

View File

@ -183,7 +183,9 @@
(when (and data (ps:@ data data) (ps:@ data data favorites))
(setf *user-favorites-cache*
(ps:chain (ps:@ data data favorites)
(map (lambda (f) (ps:@ f title))))))))
(map (lambda (f) (ps:@ f title)))))
;; Update UI after cache is loaded
(check-favorite-status))))
(catch (lambda (error) nil))))
;; Check if current track is in favorites and update UI
@ -699,8 +701,9 @@
(= (ps:@ data data status) "success")))
(ps:chain btn class-list (remove "favorited"))
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆")
;; Refresh now playing to update favorite count
(update-now-playing))))
;; Reload cache (don't call update-now-playing as it would
;; check the old cache before reload completes)
(load-favorites-cache))))
(catch (lambda (error)
(ps:chain console (error "Error removing favorite:" error)))))
;; Add favorite
@ -718,7 +721,9 @@
(= (ps:@ data data status) "success")))
(ps:chain btn class-list (add "favorited"))
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★")
(update-now-playing))))
;; Reload cache (don't call update-now-playing as it would
;; check the old cache before reload completes)
(load-favorites-cache))))
(catch (lambda (error)
(ps:chain console (error "Error adding favorite:" error)))))))))))

View File

@ -213,10 +213,15 @@
(ps:chain response (json))
nil)))
(then (lambda (data)
(when (and data (ps:@ data data) (ps:@ data data favorites))
(when data
;; Handle both wrapped (data.data.favorites) and unwrapped (data.favorites) responses
(let ((favorites (or (and (ps:@ data data) (ps:@ data data favorites))
(ps:@ data favorites))))
(when favorites
(setf *user-favorites-cache-mini*
(ps:chain (ps:@ data data favorites)
(map (lambda (f) (ps:@ f title))))))))
(ps:chain favorites (map (lambda (f) (ps:@ f title)))))
;; Update UI after cache is loaded
(check-favorite-status-mini))))))
(catch (lambda (error) nil))))
;; Check if current track is in favorites and update mini player UI
@ -224,9 +229,10 @@
(let ((title-el (ps:chain document (get-element-by-id "mini-now-playing")))
(btn (ps:chain document (get-element-by-id "favorite-btn-mini"))))
(when (and title-el btn)
(let ((title (ps:@ title-el text-content))
(star-icon (ps:chain btn (query-selector ".star-icon"))))
(if (ps:chain *user-favorites-cache-mini* (includes title))
(let* ((track-title (ps:@ title-el text-content))
(star-icon (ps:chain btn (query-selector ".star-icon")))
(is-in-cache (ps:chain *user-favorites-cache-mini* (includes track-title))))
(if is-in-cache
(progn
(ps:chain btn class-list (add "favorited"))
(when star-icon (setf (ps:@ star-icon text-content) "★")))
@ -310,9 +316,9 @@
(= (ps:@ data data status) "success")))
(ps:chain btn class-list (remove "favorited"))
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆")
;; Reload cache and refresh display to update favorite count
(load-favorites-cache-mini)
(update-mini-now-playing))))
;; Reload cache to update favorite count (don't call update-mini-now-playing
;; as it would check the old cache before reload completes)
(load-favorites-cache-mini))))
(catch (lambda (error)
(ps:chain console (error "Error removing favorite:" error)))))
;; Add favorite
@ -330,9 +336,9 @@
(= (ps:@ data data status) "success")))
(ps:chain btn class-list (add "favorited"))
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★")
;; Reload cache and refresh display to update favorite count
(load-favorites-cache-mini)
(update-mini-now-playing))))
;; Reload cache to update favorite count (don't call update-mini-now-playing
;; as it would check the old cache before reload completes)
(load-favorites-cache-mini))))
(catch (lambda (error)
(ps:chain console (error "Error adding favorite:" error)))))))))))

View File

@ -10,6 +10,8 @@
(defun add-favorite (user-id track-id &optional (rating 1) track-title)
"Add a track to user's favorites with optional rating (1-5).
If track-id is nil but track-title is provided, stores by title."
(when (null user-id)
(return-from add-favorite nil))
(let ((rating-val (max 1 (min 5 (or rating 1)))))
(with-db
(if track-id
@ -26,6 +28,8 @@
(defun remove-favorite (user-id track-id &optional track-title)
"Remove a track from user's favorites by track-id or title"
(when (null user-id)
(return-from remove-favorite nil))
(with-db
(if track-id
(postmodern:query
@ -38,6 +42,8 @@
(defun update-favorite-rating (user-id track-id rating)
"Update the rating for a favorited track"
(when (null user-id)
(return-from update-favorite-rating nil))
(let ((rating-val (max 1 (min 5 rating))))
(with-db
(postmodern:query
@ -48,6 +54,8 @@
(defun get-user-favorites (user-id &key (limit 50) (offset 0))
"Get user's favorite tracks - works with both track-id and title-based favorites"
(when (null user-id)
(return-from get-user-favorites nil))
(with-db
(postmodern:query
(:raw (format nil "SELECT _id, rating, \"created-date\", track_title, \"track-id\" FROM user_favorites WHERE \"user-id\" = ~a ORDER BY \"created-date\" DESC LIMIT ~a OFFSET ~a"
@ -56,6 +64,8 @@
(defun is-track-favorited (user-id track-id)
"Check if a track is in user's favorites, returns rating or nil"
(when (null user-id)
(return-from is-track-favorited nil))
(with-db
(postmodern:query
(:raw (format nil "SELECT rating FROM user_favorites WHERE \"user-id\" = ~a AND \"track-id\" = ~a"
@ -64,6 +74,8 @@
(defun get-favorites-count (user-id)
"Get total count of user's favorites"
(when (null user-id)
(return-from get-favorites-count 0))
(with-db
(postmodern:query
(:raw (format nil "SELECT COUNT(*) FROM user_favorites WHERE \"user-id\" = ~a" user-id))
@ -111,11 +123,11 @@
(:raw (format nil "INSERT INTO listening_history (\"user-id\", \"track-id\", track_title, \"listen-duration\", completed) VALUES (~a, ~a, ~a, ~a, ~a)"
user-id track-id
(if track-title (format nil "'~a'" (sql-escape-string track-title)) "NULL")
duration (if completed 1 0))))
duration (if completed "TRUE" "FALSE"))))
(when track-title
(postmodern:query
(:raw (format nil "INSERT INTO listening_history (\"user-id\", track_title, \"listen-duration\", completed) VALUES (~a, '~a', ~a, ~a)"
user-id (sql-escape-string track-title) duration (if completed 1 0))))))))))
user-id (sql-escape-string track-title) duration (if completed "TRUE" "FALSE"))))))))))
(defun get-listening-history (user-id &key (limit 20) (offset 0))
"Get user's listening history - works with title-based history"
@ -161,6 +173,10 @@
;;; API Endpoints for User Favorites
;;; ==========================================================================
(defun aget-profile (key alist)
"Get value from alist using string-equal comparison for key (Postmodern returns uppercase keys)"
(cdr (assoc key alist :test (lambda (a b) (string-equal (string a) (string b))))))
(define-api asteroid/user/favorites () ()
"Get current user's favorite tracks"
(require-authentication)
@ -168,13 +184,13 @@
(let* ((user-id (session:field "user-id"))
(favorites (get-user-favorites user-id)))
(api-output `(("status" . "success")
("favorites" . ,(mapcar (lambda (fav)
`(("id" . ,(cdr (assoc :_id fav)))
("track_id" . ,(cdr (assoc :track-id fav)))
("title" . ,(or (cdr (assoc :track-title fav))
(cdr (assoc :track_title fav))))
("rating" . ,(cdr (assoc :rating fav)))))
favorites))
("favorites" . ,(or (mapcar (lambda (fav)
`(("id" . ,(aget-profile "-ID" fav))
("track_id" . ,(aget-profile "TRACK-ID" fav))
("title" . ,(aget-profile "TRACK-TITLE" fav))
("rating" . ,(aget-profile "RATING" fav))))
favorites)
(list))) ; Return empty list instead of null
("count" . ,(get-favorites-count user-id)))))))
(define-api asteroid/user/favorites/add (&optional track-id rating title) ()