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
This commit is contained in:
parent
5225a07b8b
commit
7600ea6bed
|
|
@ -1158,28 +1158,44 @@
|
|||
(define-api asteroid/user/listening-stats () ()
|
||||
"Get user listening statistics"
|
||||
(require-authentication)
|
||||
(let* ((current-user (get-current-user))
|
||||
(user-id (when current-user (dm:id current-user)))
|
||||
(stats (if user-id
|
||||
(get-user-listening-stats user-id)
|
||||
(list :total-listen-time 0 :session-count 0 :tracks-played 0))))
|
||||
(with-error-handling
|
||||
(let* ((user-id (session:field "user-id"))
|
||||
(stats (get-listening-stats user-id)))
|
||||
(api-output `(("status" . "success")
|
||||
("stats" . (("total_listen_time" . ,(getf stats :total-listen-time 0))
|
||||
("tracks_played" . ,(getf stats :tracks-played 0))
|
||||
("session_count" . ,(getf stats :session-count 0))
|
||||
("favorite_genre" . "Unknown")))))))
|
||||
("session_count" . 0)
|
||||
("favorite_genre" . "Ambient"))))))))
|
||||
|
||||
(define-api asteroid/user/recent-tracks (&optional (limit "3")) ()
|
||||
"Get recently played tracks for user"
|
||||
(require-authentication)
|
||||
(with-error-handling
|
||||
(let* ((user-id (session:field "user-id"))
|
||||
(limit-int (parse-integer limit :junk-allowed t))
|
||||
(history (get-listening-history user-id :limit (or limit-int 3))))
|
||||
(api-output `(("status" . "success")
|
||||
("tracks" . ()))))
|
||||
("tracks" . ,(mapcar (lambda (h)
|
||||
`(("title" . ,(or (cdr (assoc :track-title h))
|
||||
(cdr (assoc :track_title h))))
|
||||
("artist" . "")
|
||||
("played_at" . ,(cdr (assoc :listened-at h)))
|
||||
("duration" . ,(or (cdr (assoc :listen-duration h)) 0))))
|
||||
history)))))))
|
||||
|
||||
(define-api asteroid/user/top-artists (&optional (limit "5")) ()
|
||||
"Get top artists for user"
|
||||
(require-authentication)
|
||||
(with-error-handling
|
||||
(let* ((user-id (session:field "user-id"))
|
||||
(limit-int (parse-integer limit :junk-allowed t))
|
||||
(artists (get-top-artists user-id :limit (or limit-int 5))))
|
||||
(api-output `(("status" . "success")
|
||||
("artists" . ()))))
|
||||
("artists" . ,(mapcar (lambda (a)
|
||||
`(("name" . ,(or (cdr (assoc :artist a)) "Unknown"))
|
||||
("play_count" . ,(or (cdr (assoc :play-count a))
|
||||
(cdr (assoc :play_count a)) 0))))
|
||||
artists)))))))
|
||||
|
||||
;; Register page (GET)
|
||||
(define-page register #@"/register" ()
|
||||
|
|
|
|||
|
|
@ -1,29 +1,34 @@
|
|||
-- Migration 005: User Favorites and Listening History
|
||||
-- Adds tables for track favorites/ratings and per-user listening history
|
||||
-- Updated to support title-based storage (no tracks table dependency)
|
||||
|
||||
-- User favorites table - tracks that users have liked/rated
|
||||
-- Supports both track-id (when tracks table is populated) and track_title (for now)
|
||||
CREATE TABLE IF NOT EXISTS user_favorites (
|
||||
_id SERIAL PRIMARY KEY,
|
||||
"user-id" INTEGER NOT NULL REFERENCES "USERS"(_id) ON DELETE CASCADE,
|
||||
"track-id" INTEGER NOT NULL REFERENCES tracks(_id) ON DELETE CASCADE,
|
||||
"track-id" INTEGER, -- Optional: references tracks(_id) when available
|
||||
track_title TEXT, -- Store title directly for title-based favorites
|
||||
rating INTEGER DEFAULT 1 CHECK (rating >= 1 AND rating <= 5),
|
||||
"created-date" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE("user-id", "track-id")
|
||||
"created-date" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes for efficient queries
|
||||
CREATE INDEX IF NOT EXISTS idx_user_favorites_user_id ON user_favorites("user-id");
|
||||
CREATE INDEX IF NOT EXISTS idx_user_favorites_track_id ON user_favorites("track-id");
|
||||
CREATE INDEX IF NOT EXISTS idx_user_favorites_rating ON user_favorites(rating);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_favorites_unique ON user_favorites("user-id", COALESCE(track_title, ''));
|
||||
|
||||
-- User listening history - per-user track play history
|
||||
-- Supports both track-id and track_title
|
||||
CREATE TABLE IF NOT EXISTS listening_history (
|
||||
_id SERIAL PRIMARY KEY,
|
||||
"user-id" INTEGER NOT NULL REFERENCES "USERS"(_id) ON DELETE CASCADE,
|
||||
"track-id" INTEGER NOT NULL REFERENCES tracks(_id) ON DELETE CASCADE,
|
||||
"track-id" INTEGER, -- Optional: references tracks(_id) when available
|
||||
track_title TEXT, -- Store title directly for title-based history
|
||||
"listened-at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
"listen-duration" INTEGER DEFAULT 0, -- seconds listened
|
||||
"completed" BOOLEAN DEFAULT false -- did they listen to the whole track?
|
||||
completed INTEGER DEFAULT 0 -- 1 if they listened to the whole track
|
||||
);
|
||||
|
||||
-- Create indexes for efficient queries
|
||||
|
|
|
|||
|
|
@ -165,6 +165,24 @@
|
|||
(get-stream-config (ps:@ stream-base-url value) channel quality))))
|
||||
(if config (ps:@ config mount) "asteroid.mp3")))
|
||||
|
||||
;; Track last recorded title to avoid duplicate history entries
|
||||
(defvar *last-recorded-title-main* nil)
|
||||
|
||||
;; Record track to listening history (only if logged in)
|
||||
(defun record-track-listen-main (title)
|
||||
(when (and title (not (= title "")) (not (= title "Loading..."))
|
||||
(not (= title "NA")) (not (= title *last-recorded-title-main*)))
|
||||
(setf *last-recorded-title-main* title)
|
||||
(ps:chain
|
||||
(fetch (+ "/api/asteroid/user/history/record?title=" (encode-u-r-i-component title))
|
||||
(ps:create :method "POST"))
|
||||
(then (lambda (response)
|
||||
(when (ps:@ response ok)
|
||||
(ps:chain console (log "Recorded listen:" title)))))
|
||||
(catch (lambda (error)
|
||||
;; Silently fail - user might not be logged in
|
||||
nil)))))
|
||||
|
||||
;; Update now playing info from API
|
||||
(defun update-now-playing ()
|
||||
(let ((mount (get-current-mount)))
|
||||
|
|
@ -176,8 +194,19 @@
|
|||
(ps:chain response (text))
|
||||
(throw (ps:new (-error "Error connecting to stream")))))))
|
||||
(then (lambda (data)
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "now-playing")) inner-h-t-m-l)
|
||||
data)))
|
||||
(let ((now-playing-el (ps:chain document (get-element-by-id "now-playing"))))
|
||||
(when now-playing-el
|
||||
;; Get current title before updating
|
||||
(let ((old-title-el (ps:chain now-playing-el (query-selector "#current-track-title"))))
|
||||
(setf (ps:@ now-playing-el inner-h-t-m-l) data)
|
||||
;; Get new title after updating
|
||||
(let ((new-title-el (ps:chain now-playing-el (query-selector "#current-track-title"))))
|
||||
(when new-title-el
|
||||
(let ((new-title (ps:@ new-title-el text-content)))
|
||||
;; Record if title changed
|
||||
(when (or (not old-title-el)
|
||||
(not (= (ps:@ old-title-el text-content) new-title)))
|
||||
(record-track-listen-main new-title))))))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (log "Could not fetch stream status:" error)))))))
|
||||
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@
|
|||
"</div>"
|
||||
"<div class=\"track-meta\">"
|
||||
"<span class=\"rating\">" (render-stars (or (ps:@ fav rating) 1)) "</span>"
|
||||
"<button class=\"btn btn-small btn-danger\" onclick=\"removeFavorite(" (ps:@ fav track_id) ")\">Remove</button>"
|
||||
"<button class=\"btn btn-small btn-danger\" onclick=\"removeFavorite('" (ps:chain (or (ps:@ fav title) "") (replace (ps:regex "/'/g") "\\'")) "')\">Remove</button>"
|
||||
"</div>"))
|
||||
(ps:chain container (append-child item)))))))
|
||||
(setf (ps:@ container inner-h-t-m-l) "<p class=\"no-data\">No favorites yet. Like tracks while listening!</p>"))))))
|
||||
|
|
@ -209,17 +209,22 @@
|
|||
(setf stars (+ stars (if (< i rating) "★" "☆"))))
|
||||
stars))
|
||||
|
||||
(defun remove-favorite (track-id)
|
||||
(defun remove-favorite (title)
|
||||
(ps:chain
|
||||
(fetch (+ "/api/asteroid/user/favorites/remove?track-id=" track-id)
|
||||
(fetch (+ "/api/asteroid/user/favorites/remove?title=" (encode-u-r-i-component title))
|
||||
(ps:create :method "POST"))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (response)
|
||||
(if (ps:@ response ok)
|
||||
(ps:chain response (json))
|
||||
(throw (ps:new (-error "Request failed"))))))
|
||||
(then (lambda (data)
|
||||
(if (= (ps:@ data status) "success")
|
||||
;; API returns {"status": 200, "data": {"status": "success"}}
|
||||
(let ((inner-status (or (ps:@ data data status) (ps:@ data status))))
|
||||
(if (or (= inner-status "success") (= (ps:@ data status) 200))
|
||||
(progn
|
||||
(show-message "Removed from favorites" "success")
|
||||
(load-favorites))
|
||||
(show-message "Failed to remove favorite" "error"))))
|
||||
(show-message "Failed to remove favorite" "error")))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error removing favorite:" error))
|
||||
(show-message "Error removing favorite" "error")))))
|
||||
|
|
|
|||
|
|
@ -198,6 +198,23 @@
|
|||
(config (get-stream-config stream-base-url channel quality)))
|
||||
(if config (ps:@ config mount) "asteroid.mp3")))
|
||||
|
||||
;; Track the last recorded title to avoid duplicate history entries
|
||||
(defvar *last-recorded-title* nil)
|
||||
|
||||
;; Record track to listening history (only if logged in)
|
||||
(defun record-track-listen (title)
|
||||
(when (and title (not (= title "")) (not (= title "Loading...")) (not (= title *last-recorded-title*)))
|
||||
(setf *last-recorded-title* title)
|
||||
(ps:chain
|
||||
(fetch (+ "/api/asteroid/user/history/record?title=" (encode-u-r-i-component title))
|
||||
(ps:create :method "POST"))
|
||||
(then (lambda (response)
|
||||
(when (ps:@ response ok)
|
||||
(ps:chain console (log "Recorded listen:" title)))))
|
||||
(catch (lambda (error)
|
||||
;; Silently fail - user might not be logged in
|
||||
nil)))))
|
||||
|
||||
;; Update mini now playing display (for persistent player frame)
|
||||
(defun update-mini-now-playing ()
|
||||
(let ((mount (get-current-mount)))
|
||||
|
|
@ -213,6 +230,9 @@
|
|||
(track-id-el (ps:chain document (get-element-by-id "current-track-id-mini")))
|
||||
(title (or (ps:@ data data title) (ps:@ data title) "Loading...")))
|
||||
(when el
|
||||
;; Check if track changed and record to history
|
||||
(when (not (= (ps:@ el text-content) title))
|
||||
(record-track-listen title))
|
||||
(setf (ps:@ el text-content) title))
|
||||
(when track-id-el
|
||||
(let ((track-id (or (ps:@ data data track_id) (ps:@ data track_id))))
|
||||
|
|
|
|||
|
|
@ -73,64 +73,51 @@
|
|||
;;; Listening History - Per-user track play history
|
||||
;;; ==========================================================================
|
||||
|
||||
(defun record-listen (user-id track-id &key (duration 0) (completed nil))
|
||||
"Record a track listen in user's history"
|
||||
(defun record-listen (user-id &key track-id track-title (duration 0) (completed nil))
|
||||
"Record a track listen in user's history. Can use track-id or track-title."
|
||||
(with-db
|
||||
(if track-id
|
||||
(postmodern:query
|
||||
(:insert-into 'listening_history
|
||||
:set '"user-id" user-id
|
||||
'"track-id" track-id
|
||||
'"listened-at" (:current_timestamp)
|
||||
'"listen-duration" duration
|
||||
'completed (if completed 1 0)))))
|
||||
(: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$$" track-title) "NULL")
|
||||
duration (if completed 1 0))))
|
||||
(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 track-title duration (if completed 1 0))))))))
|
||||
|
||||
(defun get-listening-history (user-id &key (limit 20) (offset 0))
|
||||
"Get user's listening history with track details"
|
||||
"Get user's listening history - works with title-based history"
|
||||
(with-db
|
||||
(postmodern:query
|
||||
(:limit
|
||||
(:order-by
|
||||
(:select 'lh._id 'lh.listened-at 'lh.listen-duration 'lh.completed
|
||||
't.title 't.artist 't.album 't.duration 't._id
|
||||
:from (:as 'listening_history 'lh)
|
||||
:inner-join (:as 'tracks 't) :on (:= 'lh.track-id 't._id)
|
||||
:where (:= 'lh.user-id user-id))
|
||||
(:desc 'lh.listened-at))
|
||||
limit offset)
|
||||
(:raw (format nil "SELECT _id, \"listened-at\", \"listen-duration\", completed, track_title, \"track-id\" FROM listening_history WHERE \"user-id\" = ~a ORDER BY \"listened-at\" DESC LIMIT ~a OFFSET ~a"
|
||||
user-id limit offset))
|
||||
:alists)))
|
||||
|
||||
(defun get-listening-stats (user-id)
|
||||
"Get aggregate listening statistics for a user"
|
||||
(with-db
|
||||
(let ((stats (postmodern:query
|
||||
(:select (:count '*) (:sum 'listen-duration)
|
||||
:from 'listening_history
|
||||
:where (:= '"user-id" user-id))
|
||||
(:raw (format nil "SELECT COUNT(*), COALESCE(SUM(\"listen-duration\"), 0) FROM listening_history WHERE \"user-id\" = ~a" user-id))
|
||||
:row)))
|
||||
(list :tracks-played (or (first stats) 0)
|
||||
:total-listen-time (or (second stats) 0)))))
|
||||
|
||||
(defun get-top-artists (user-id &key (limit 5))
|
||||
"Get user's most listened artists"
|
||||
"Get user's most listened artists - extracts artist from track_title"
|
||||
(with-db
|
||||
;; Extract artist from 'Artist - Title' format in track_title
|
||||
(postmodern:query
|
||||
(:limit
|
||||
(:order-by
|
||||
(:select 't.artist (:as (:count '*) 'play_count)
|
||||
:from (:as 'listening_history 'lh)
|
||||
:inner-join (:as 'tracks 't) :on (:= 'lh.track-id 't._id)
|
||||
:where (:= 'lh.user-id user-id)
|
||||
:group-by 't.artist)
|
||||
(:desc 'play_count))
|
||||
limit)
|
||||
(:raw (format nil "SELECT SPLIT_PART(track_title, ' - ', 1) as artist, COUNT(*) as play_count FROM listening_history WHERE \"user-id\" = ~a AND track_title IS NOT NULL GROUP BY SPLIT_PART(track_title, ' - ', 1) ORDER BY play_count DESC LIMIT ~a"
|
||||
user-id limit))
|
||||
:alists)))
|
||||
|
||||
(defun clear-listening-history (user-id)
|
||||
"Clear all listening history for a user"
|
||||
(with-db
|
||||
(postmodern:query
|
||||
(:delete-from 'listening_history
|
||||
:where (:= '"user-id" user-id)))))
|
||||
(:raw (format nil "DELETE FROM listening_history WHERE \"user-id\" = ~a" user-id)))))
|
||||
|
||||
;;; ==========================================================================
|
||||
;;; API Endpoints for User Favorites
|
||||
|
|
@ -214,24 +201,29 @@
|
|||
(api-output `(("status" . "success")
|
||||
("history" . ,(mapcar (lambda (h)
|
||||
`(("id" . ,(cdr (assoc :_id h)))
|
||||
("track_id" . ,(cdr (assoc :_id h)))
|
||||
("title" . ,(cdr (assoc :title h)))
|
||||
("artist" . ,(cdr (assoc :artist h)))
|
||||
("album" . ,(cdr (assoc :album h)))
|
||||
("duration" . ,(cdr (assoc :duration h)))
|
||||
("track_id" . ,(cdr (assoc :track-id h)))
|
||||
("title" . ,(or (cdr (assoc :track-title h))
|
||||
(cdr (assoc :track_title h))))
|
||||
("listened_at" . ,(cdr (assoc :listened-at h)))
|
||||
("completed" . ,(= 1 (cdr (assoc :completed h))))))
|
||||
("listen_duration" . ,(cdr (assoc :listen-duration h)))
|
||||
("completed" . ,(let ((c (cdr (assoc :completed h))))
|
||||
(and c (= 1 c))))))
|
||||
history)))))))
|
||||
|
||||
(define-api asteroid/user/history/record (track-id &optional duration completed) ()
|
||||
"Record a track listen (called by player)"
|
||||
(define-api asteroid/user/history/record (&optional track-id title duration completed) ()
|
||||
"Record a track listen (called by player). Can use track-id or title."
|
||||
(require-authentication)
|
||||
(with-error-handling
|
||||
(let* ((user-id (session:field "user-id"))
|
||||
(track-id-int (parse-integer track-id))
|
||||
(duration-int (if duration (parse-integer duration) 0))
|
||||
(let* ((user-id-raw (session:field "user-id"))
|
||||
(user-id (if (stringp user-id-raw)
|
||||
(parse-integer user-id-raw :junk-allowed t)
|
||||
user-id-raw))
|
||||
(track-id-int (when (and track-id (not (string= track-id "")))
|
||||
(parse-integer track-id :junk-allowed t)))
|
||||
(duration-int (if duration (parse-integer duration :junk-allowed t) 0))
|
||||
(completed-bool (and completed (string-equal completed "true"))))
|
||||
(record-listen user-id track-id-int :duration duration-int :completed completed-bool)
|
||||
(record-listen user-id :track-id track-id-int :track-title title
|
||||
:duration (or duration-int 0) :completed completed-bool)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Listen recorded"))))))
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue