diff --git a/asteroid.lisp b/asteroid.lisp index 4ae53e2..8940159 100644 --- a/asteroid.lisp +++ b/asteroid.lisp @@ -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)))) - (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"))))))) + (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" . 0) + ("favorite_genre" . "Ambient")))))))) (define-api asteroid/user/recent-tracks (&optional (limit "3")) () "Get recently played tracks for user" (require-authentication) - (api-output `(("status" . "success") - ("tracks" . ())))) + (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" . ,(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) - (api-output `(("status" . "success") - ("artists" . ())))) + (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" . ,(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" () diff --git a/migrations/005-user-favorites-history.sql b/migrations/005-user-favorites-history.sql index cacb4b9..199053c 100644 --- a/migrations/005-user-favorites-history.sql +++ b/migrations/005-user-favorites-history.sql @@ -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 diff --git a/parenscript/front-page.lisp b/parenscript/front-page.lisp index a8a1551..e62da0a 100644 --- a/parenscript/front-page.lisp +++ b/parenscript/front-page.lisp @@ -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))))))) diff --git a/parenscript/profile.lisp b/parenscript/profile.lisp index f8ab9d3..eb12b92 100644 --- a/parenscript/profile.lisp +++ b/parenscript/profile.lisp @@ -193,7 +193,7 @@ "" "
")) (ps:chain container (append-child item))))))) (setf (ps:@ container inner-h-t-m-l) "No favorites yet. Like tracks while listening!
")))))) @@ -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") - (progn - (show-message "Removed from favorites" "success") - (load-favorites)) - (show-message "Failed to remove favorite" "error")))) + ;; 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"))))) (catch (lambda (error) (ps:chain console (error "Error removing favorite:" error)) (show-message "Error removing favorite" "error"))))) diff --git a/parenscript/stream-player.lisp b/parenscript/stream-player.lisp index ce9e6b9..7d83537 100644 --- a/parenscript/stream-player.lisp +++ b/parenscript/stream-player.lisp @@ -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)))) diff --git a/user-profile.lisp b/user-profile.lisp index 6c3aff6..5e4515a 100644 --- a/user-profile.lisp +++ b/user-profile.lisp @@ -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 - (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))))) + (if track-id + (postmodern:query + (: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"))))))