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
This commit is contained in:
commit
4a79558c75
|
|
@ -1,23 +1,39 @@
|
||||||
(in-package :asteroid)
|
(in-package :asteroid)
|
||||||
|
|
||||||
(defun find-track-by-title (title)
|
(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")))
|
(when (and title (not (string= title "Unknown")))
|
||||||
(handler-case
|
(handler-case
|
||||||
(with-db
|
(with-db
|
||||||
(let* ((search-pattern (format nil "%~a%" title))
|
;; Parse 'Artist - Title' format if present
|
||||||
(result (postmodern:query
|
(let* ((parts (cl-ppcre:split " - " title :limit 2))
|
||||||
(:limit
|
(has-artist (> (length parts) 1))
|
||||||
(:select '_id
|
(artist-part (when has-artist (first parts)))
|
||||||
:from 'tracks
|
(title-part (if has-artist (second parts) title))
|
||||||
:where (:ilike 'title search-pattern))
|
(result
|
||||||
1)
|
(if has-artist
|
||||||
:single)))
|
;; Search by both artist and title
|
||||||
|
(postmodern:query
|
||||||
|
(:limit
|
||||||
|
(:select '_id
|
||||||
|
:from 'tracks
|
||||||
|
:where (:and (:ilike 'artist (format nil "%~a%" artist-part))
|
||||||
|
(:ilike 'title (format nil "%~a%" title-part))))
|
||||||
|
1)
|
||||||
|
:single)
|
||||||
|
;; Fallback: search by title only
|
||||||
|
(postmodern:query
|
||||||
|
(:limit
|
||||||
|
(:select '_id
|
||||||
|
:from 'tracks
|
||||||
|
:where (:ilike 'title (format nil "%~a%" title-part)))
|
||||||
|
1)
|
||||||
|
:single))))
|
||||||
result))
|
result))
|
||||||
(error (e)
|
(error (e)
|
||||||
(declare (ignore e))
|
(declare (ignore e))
|
||||||
nil))))
|
nil))))
|
||||||
|
|
||||||
(defun icecast-now-playing (icecast-base-url &optional (mount "asteroid.mp3"))
|
(defun icecast-now-playing (icecast-base-url &optional (mount "asteroid.mp3"))
|
||||||
"Fetch now-playing information from Icecast server.
|
"Fetch now-playing information from Icecast server.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,9 @@
|
||||||
(when (and data (ps:@ data data) (ps:@ data data favorites))
|
(when (and data (ps:@ data data) (ps:@ data data favorites))
|
||||||
(setf *user-favorites-cache*
|
(setf *user-favorites-cache*
|
||||||
(ps:chain (ps:@ data data favorites)
|
(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))))
|
(catch (lambda (error) nil))))
|
||||||
|
|
||||||
;; Check if current track is in favorites and update UI
|
;; Check if current track is in favorites and update UI
|
||||||
|
|
@ -699,8 +701,9 @@
|
||||||
(= (ps:@ data data status) "success")))
|
(= (ps:@ data data status) "success")))
|
||||||
(ps:chain btn class-list (remove "favorited"))
|
(ps:chain btn class-list (remove "favorited"))
|
||||||
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆")
|
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆")
|
||||||
;; Refresh now playing to update favorite count
|
;; Reload cache (don't call update-now-playing as it would
|
||||||
(update-now-playing))))
|
;; check the old cache before reload completes)
|
||||||
|
(load-favorites-cache))))
|
||||||
(catch (lambda (error)
|
(catch (lambda (error)
|
||||||
(ps:chain console (error "Error removing favorite:" error)))))
|
(ps:chain console (error "Error removing favorite:" error)))))
|
||||||
;; Add favorite
|
;; Add favorite
|
||||||
|
|
@ -718,7 +721,9 @@
|
||||||
(= (ps:@ data data status) "success")))
|
(= (ps:@ data data status) "success")))
|
||||||
(ps:chain btn class-list (add "favorited"))
|
(ps:chain btn class-list (add "favorited"))
|
||||||
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★")
|
(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)
|
(catch (lambda (error)
|
||||||
(ps:chain console (error "Error adding favorite:" error)))))))))))
|
(ps:chain console (error "Error adding favorite:" error)))))))))))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -213,10 +213,15 @@
|
||||||
(ps:chain response (json))
|
(ps:chain response (json))
|
||||||
nil)))
|
nil)))
|
||||||
(then (lambda (data)
|
(then (lambda (data)
|
||||||
(when (and data (ps:@ data data) (ps:@ data data favorites))
|
(when data
|
||||||
(setf *user-favorites-cache-mini*
|
;; Handle both wrapped (data.data.favorites) and unwrapped (data.favorites) responses
|
||||||
(ps:chain (ps:@ data data favorites)
|
(let ((favorites (or (and (ps:@ data data) (ps:@ data data favorites))
|
||||||
(map (lambda (f) (ps:@ f title))))))))
|
(ps:@ data favorites))))
|
||||||
|
(when favorites
|
||||||
|
(setf *user-favorites-cache-mini*
|
||||||
|
(ps:chain favorites (map (lambda (f) (ps:@ f title)))))
|
||||||
|
;; Update UI after cache is loaded
|
||||||
|
(check-favorite-status-mini))))))
|
||||||
(catch (lambda (error) nil))))
|
(catch (lambda (error) nil))))
|
||||||
|
|
||||||
;; Check if current track is in favorites and update mini player UI
|
;; 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")))
|
(let ((title-el (ps:chain document (get-element-by-id "mini-now-playing")))
|
||||||
(btn (ps:chain document (get-element-by-id "favorite-btn-mini"))))
|
(btn (ps:chain document (get-element-by-id "favorite-btn-mini"))))
|
||||||
(when (and title-el btn)
|
(when (and title-el btn)
|
||||||
(let ((title (ps:@ title-el text-content))
|
(let* ((track-title (ps:@ title-el text-content))
|
||||||
(star-icon (ps:chain btn (query-selector ".star-icon"))))
|
(star-icon (ps:chain btn (query-selector ".star-icon")))
|
||||||
(if (ps:chain *user-favorites-cache-mini* (includes title))
|
(is-in-cache (ps:chain *user-favorites-cache-mini* (includes track-title))))
|
||||||
|
(if is-in-cache
|
||||||
(progn
|
(progn
|
||||||
(ps:chain btn class-list (add "favorited"))
|
(ps:chain btn class-list (add "favorited"))
|
||||||
(when star-icon (setf (ps:@ star-icon text-content) "★")))
|
(when star-icon (setf (ps:@ star-icon text-content) "★")))
|
||||||
|
|
@ -310,9 +316,9 @@
|
||||||
(= (ps:@ data data status) "success")))
|
(= (ps:@ data data status) "success")))
|
||||||
(ps:chain btn class-list (remove "favorited"))
|
(ps:chain btn class-list (remove "favorited"))
|
||||||
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆")
|
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆")
|
||||||
;; Reload cache and refresh display to update favorite count
|
;; Reload cache to update favorite count (don't call update-mini-now-playing
|
||||||
(load-favorites-cache-mini)
|
;; as it would check the old cache before reload completes)
|
||||||
(update-mini-now-playing))))
|
(load-favorites-cache-mini))))
|
||||||
(catch (lambda (error)
|
(catch (lambda (error)
|
||||||
(ps:chain console (error "Error removing favorite:" error)))))
|
(ps:chain console (error "Error removing favorite:" error)))))
|
||||||
;; Add favorite
|
;; Add favorite
|
||||||
|
|
@ -330,9 +336,9 @@
|
||||||
(= (ps:@ data data status) "success")))
|
(= (ps:@ data data status) "success")))
|
||||||
(ps:chain btn class-list (add "favorited"))
|
(ps:chain btn class-list (add "favorited"))
|
||||||
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★")
|
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★")
|
||||||
;; Reload cache and refresh display to update favorite count
|
;; Reload cache to update favorite count (don't call update-mini-now-playing
|
||||||
(load-favorites-cache-mini)
|
;; as it would check the old cache before reload completes)
|
||||||
(update-mini-now-playing))))
|
(load-favorites-cache-mini))))
|
||||||
(catch (lambda (error)
|
(catch (lambda (error)
|
||||||
(ps:chain console (error "Error adding favorite:" error)))))))))))
|
(ps:chain console (error "Error adding favorite:" error)))))))))))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
(defun add-favorite (user-id track-id &optional (rating 1) track-title)
|
(defun add-favorite (user-id track-id &optional (rating 1) track-title)
|
||||||
"Add a track to user's favorites with optional rating (1-5).
|
"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."
|
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)))))
|
(let ((rating-val (max 1 (min 5 (or rating 1)))))
|
||||||
(with-db
|
(with-db
|
||||||
(if track-id
|
(if track-id
|
||||||
|
|
@ -26,6 +28,8 @@
|
||||||
|
|
||||||
(defun remove-favorite (user-id track-id &optional track-title)
|
(defun remove-favorite (user-id track-id &optional track-title)
|
||||||
"Remove a track from user's favorites by track-id or title"
|
"Remove a track from user's favorites by track-id or title"
|
||||||
|
(when (null user-id)
|
||||||
|
(return-from remove-favorite nil))
|
||||||
(with-db
|
(with-db
|
||||||
(if track-id
|
(if track-id
|
||||||
(postmodern:query
|
(postmodern:query
|
||||||
|
|
@ -38,6 +42,8 @@
|
||||||
|
|
||||||
(defun update-favorite-rating (user-id track-id rating)
|
(defun update-favorite-rating (user-id track-id rating)
|
||||||
"Update the rating for a favorited track"
|
"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))))
|
(let ((rating-val (max 1 (min 5 rating))))
|
||||||
(with-db
|
(with-db
|
||||||
(postmodern:query
|
(postmodern:query
|
||||||
|
|
@ -48,6 +54,8 @@
|
||||||
|
|
||||||
(defun get-user-favorites (user-id &key (limit 50) (offset 0))
|
(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"
|
"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
|
(with-db
|
||||||
(postmodern:query
|
(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"
|
(: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)
|
(defun is-track-favorited (user-id track-id)
|
||||||
"Check if a track is in user's favorites, returns rating or nil"
|
"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
|
(with-db
|
||||||
(postmodern:query
|
(postmodern:query
|
||||||
(:raw (format nil "SELECT rating FROM user_favorites WHERE \"user-id\" = ~a AND \"track-id\" = ~a"
|
(: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)
|
(defun get-favorites-count (user-id)
|
||||||
"Get total count of user's favorites"
|
"Get total count of user's favorites"
|
||||||
|
(when (null user-id)
|
||||||
|
(return-from get-favorites-count 0))
|
||||||
(with-db
|
(with-db
|
||||||
(postmodern:query
|
(postmodern:query
|
||||||
(:raw (format nil "SELECT COUNT(*) FROM user_favorites WHERE \"user-id\" = ~a" user-id))
|
(: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)"
|
(: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
|
user-id track-id
|
||||||
(if track-title (format nil "'~a'" (sql-escape-string track-title)) "NULL")
|
(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
|
(when track-title
|
||||||
(postmodern:query
|
(postmodern:query
|
||||||
(:raw (format nil "INSERT INTO listening_history (\"user-id\", track_title, \"listen-duration\", completed) VALUES (~a, '~a', ~a, ~a)"
|
(: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))
|
(defun get-listening-history (user-id &key (limit 20) (offset 0))
|
||||||
"Get user's listening history - works with title-based history"
|
"Get user's listening history - works with title-based history"
|
||||||
|
|
@ -161,6 +173,10 @@
|
||||||
;;; API Endpoints for User Favorites
|
;;; 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 () ()
|
(define-api asteroid/user/favorites () ()
|
||||||
"Get current user's favorite tracks"
|
"Get current user's favorite tracks"
|
||||||
(require-authentication)
|
(require-authentication)
|
||||||
|
|
@ -168,13 +184,13 @@
|
||||||
(let* ((user-id (session:field "user-id"))
|
(let* ((user-id (session:field "user-id"))
|
||||||
(favorites (get-user-favorites user-id)))
|
(favorites (get-user-favorites user-id)))
|
||||||
(api-output `(("status" . "success")
|
(api-output `(("status" . "success")
|
||||||
("favorites" . ,(mapcar (lambda (fav)
|
("favorites" . ,(or (mapcar (lambda (fav)
|
||||||
`(("id" . ,(cdr (assoc :_id fav)))
|
`(("id" . ,(aget-profile "-ID" fav))
|
||||||
("track_id" . ,(cdr (assoc :track-id fav)))
|
("track_id" . ,(aget-profile "TRACK-ID" fav))
|
||||||
("title" . ,(or (cdr (assoc :track-title fav))
|
("title" . ,(aget-profile "TRACK-TITLE" fav))
|
||||||
(cdr (assoc :track_title fav))))
|
("rating" . ,(aget-profile "RATING" fav))))
|
||||||
("rating" . ,(cdr (assoc :rating fav)))))
|
favorites)
|
||||||
favorites))
|
(list))) ; Return empty list instead of null
|
||||||
("count" . ,(get-favorites-count user-id)))))))
|
("count" . ,(get-favorites-count user-id)))))))
|
||||||
|
|
||||||
(define-api asteroid/user/favorites/add (&optional track-id rating title) ()
|
(define-api asteroid/user/favorites/add (&optional track-id rating title) ()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue