Compare commits
No commits in common. "eb03947f7f74993647554120fd413a48dbc94040" and "2effe3bdefdc9fb170d4ff4f96eda765b863e157" have entirely different histories.
eb03947f7f
...
2effe3bdef
|
|
@ -703,11 +703,7 @@
|
||||||
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆")
|
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆")
|
||||||
;; Reload cache (don't call update-now-playing as it would
|
;; Reload cache (don't call update-now-playing as it would
|
||||||
;; check the old cache before reload completes)
|
;; check the old cache before reload completes)
|
||||||
(load-favorites-cache)
|
(load-favorites-cache))))
|
||||||
;; Notify frame player to reload its cache
|
|
||||||
(let ((player-frame (ps:chain document (get-element-by-id "player-frame"))))
|
|
||||||
(when (and player-frame (ps:@ player-frame content-window))
|
|
||||||
(ps:chain player-frame content-window (post-message "reload-favorites" "*")))))))
|
|
||||||
(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
|
||||||
|
|
@ -727,11 +723,7 @@
|
||||||
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★")
|
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★")
|
||||||
;; Reload cache (don't call update-now-playing as it would
|
;; Reload cache (don't call update-now-playing as it would
|
||||||
;; check the old cache before reload completes)
|
;; check the old cache before reload completes)
|
||||||
(load-favorites-cache)
|
(load-favorites-cache))))
|
||||||
;; Notify frame player to reload its cache
|
|
||||||
(let ((player-frame (ps:chain document (get-element-by-id "player-frame"))))
|
|
||||||
(when (and player-frame (ps:@ player-frame content-window))
|
|
||||||
(ps:chain player-frame content-window (post-message "reload-favorites" "*")))))))
|
|
||||||
(catch (lambda (error)
|
(catch (lambda (error)
|
||||||
(ps:chain console (error "Error adding favorite:" error)))))))))))
|
(ps:chain console (error "Error adding favorite:" error)))))))))))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,7 @@
|
||||||
;; Load user's favorites into cache (mini player)
|
;; Load user's favorites into cache (mini player)
|
||||||
(defun load-favorites-cache-mini ()
|
(defun load-favorites-cache-mini ()
|
||||||
(ps:chain
|
(ps:chain
|
||||||
(fetch "/api/asteroid/user/favorites" (ps:create :credentials "include"))
|
(fetch "/api/asteroid/user/favorites")
|
||||||
(then (lambda (response)
|
(then (lambda (response)
|
||||||
(if (ps:@ response ok)
|
(if (ps:@ response ok)
|
||||||
(ps:chain response (json))
|
(ps:chain response (json))
|
||||||
|
|
@ -304,7 +304,7 @@
|
||||||
;; Remove favorite
|
;; Remove favorite
|
||||||
(ps:chain
|
(ps:chain
|
||||||
(fetch (+ "/api/asteroid/user/favorites/remove?" params)
|
(fetch (+ "/api/asteroid/user/favorites/remove?" params)
|
||||||
(ps:create :method "POST" :credentials "include"))
|
(ps:create :method "POST"))
|
||||||
(then (lambda (response)
|
(then (lambda (response)
|
||||||
(cond
|
(cond
|
||||||
((not (ps:@ response ok))
|
((not (ps:@ response ok))
|
||||||
|
|
@ -324,7 +324,7 @@
|
||||||
;; Add favorite
|
;; Add favorite
|
||||||
(ps:chain
|
(ps:chain
|
||||||
(fetch (+ "/api/asteroid/user/favorites/add?" params)
|
(fetch (+ "/api/asteroid/user/favorites/add?" params)
|
||||||
(ps:create :method "POST" :credentials "include"))
|
(ps:create :method "POST"))
|
||||||
(then (lambda (response)
|
(then (lambda (response)
|
||||||
(cond
|
(cond
|
||||||
((not (ps:@ response ok))
|
((not (ps:@ response ok))
|
||||||
|
|
@ -784,13 +784,7 @@
|
||||||
(init-persistent-player))
|
(init-persistent-player))
|
||||||
;; Check for popout player
|
;; Check for popout player
|
||||||
(when (ps:chain document (get-element-by-id "live-audio"))
|
(when (ps:chain document (get-element-by-id "live-audio"))
|
||||||
(init-popout-player))))
|
(init-popout-player))))))
|
||||||
;; Listen for messages from parent frame (e.g., favorites cache reload)
|
|
||||||
(ps:chain window (add-event-listener
|
|
||||||
"message"
|
|
||||||
(lambda (event)
|
|
||||||
(when (= (ps:@ event data) "reload-favorites")
|
|
||||||
(load-favorites-cache-mini)))))))
|
|
||||||
)
|
)
|
||||||
"Compiled JavaScript for stream player - generated at load time")
|
"Compiled JavaScript for stream player - generated at load time")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,110 +89,74 @@
|
||||||
;;; Listening History - Per-user track play history
|
;;; Listening History - Per-user track play history
|
||||||
;;; ==========================================================================
|
;;; ==========================================================================
|
||||||
|
|
||||||
(defun get-recent-listen (user-id track-title)
|
(defun sql-escape-string (str)
|
||||||
"Check if user has listened to this track in the last 60 seconds"
|
"Escape a string for SQL by doubling single quotes"
|
||||||
(when (and user-id track-title)
|
(if str
|
||||||
;; Get recent listens and check timestamps manually since data-model
|
(cl-ppcre:regex-replace-all "'" str "''")
|
||||||
;; doesn't support interval comparisons directly
|
""))
|
||||||
(let ((recent (dm:get "listening_history"
|
|
||||||
(db:query (:and (:= 'user-id user-id)
|
|
||||||
(:= 'track_title track-title)))
|
|
||||||
:amount 1
|
|
||||||
:sort '(("listened-at" :DESC)))))
|
|
||||||
(when recent
|
|
||||||
(let* ((listen (first recent))
|
|
||||||
(listened-at (dm:field listen "listened-at")))
|
|
||||||
;; Check if within 60 seconds (listened-at is a timestamp)
|
|
||||||
(when listened-at
|
|
||||||
(let ((now (get-universal-time))
|
|
||||||
(listen-time (if (integerp listened-at)
|
|
||||||
listened-at
|
|
||||||
(get-universal-time))))
|
|
||||||
(< (- now listen-time) 60))))))))
|
|
||||||
|
|
||||||
(defun record-listen (user-id &key track-id track-title (duration 0) (completed nil))
|
(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.
|
"Record a track listen in user's history. Can use track-id or track-title.
|
||||||
Prevents duplicate entries for the same track within 60 seconds."
|
Prevents duplicate entries for the same track within 60 seconds."
|
||||||
(when (and user-id (or track-id track-title))
|
(with-db
|
||||||
;; Check for recent duplicate
|
;; Check for recent duplicate (same user + same title within 60 seconds)
|
||||||
(unless (get-recent-listen user-id track-title)
|
(let ((recent-exists
|
||||||
(let ((listen (dm:hull "listening_history")))
|
|
||||||
(setf (dm:field listen "user-id") user-id)
|
|
||||||
(setf (dm:field listen "listen-duration") (or duration 0))
|
|
||||||
(setf (dm:field listen "completed") (if completed 1 0))
|
|
||||||
(when track-id
|
|
||||||
(setf (dm:field listen "track-id") track-id))
|
|
||||||
(when track-title
|
(when track-title
|
||||||
(setf (dm:field listen "track_title") track-title))
|
(postmodern:query
|
||||||
(dm:insert listen)))))
|
(:raw (format nil "SELECT 1 FROM listening_history WHERE \"user-id\" = ~a AND track_title = '~a' AND \"listened-at\" > NOW() - INTERVAL '60 seconds' LIMIT 1"
|
||||||
|
user-id (sql-escape-string track-title)))
|
||||||
|
:single))))
|
||||||
|
(unless recent-exists
|
||||||
|
(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'" (sql-escape-string 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 (sql-escape-string track-title) duration (if completed 1 0))))))))))
|
||||||
|
|
||||||
(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"
|
||||||
(when user-id
|
(with-db
|
||||||
(dm:get "listening_history" (db:query (:= 'user-id user-id))
|
(postmodern:query
|
||||||
:amount limit
|
(: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"
|
||||||
:skip offset
|
user-id limit offset))
|
||||||
:sort '(("listened-at" :DESC)))))
|
:alists)))
|
||||||
|
|
||||||
(defun get-listening-stats (user-id)
|
(defun get-listening-stats (user-id)
|
||||||
"Get aggregate listening statistics for a user"
|
"Get aggregate listening statistics for a user"
|
||||||
(when user-id
|
(with-db
|
||||||
(let* ((history (dm:get "listening_history" (db:query (:= 'user-id user-id))))
|
(let ((stats (postmodern:query
|
||||||
(tracks-played (length history))
|
(:raw (format nil "SELECT COUNT(*), COALESCE(SUM(\"listen-duration\"), 0) FROM listening_history WHERE \"user-id\" = ~a" user-id))
|
||||||
(total-listen-time (reduce #'+ history
|
:row)))
|
||||||
:key (lambda (h) (or (dm:field h "listen-duration") 0))
|
(list :tracks-played (or (first stats) 0)
|
||||||
:initial-value 0)))
|
:total-listen-time (or (second stats) 0)))))
|
||||||
(list :tracks-played tracks-played
|
|
||||||
:total-listen-time total-listen-time))))
|
|
||||||
|
|
||||||
(defun get-top-artists (user-id &key (limit 5))
|
(defun get-top-artists (user-id &key (limit 5))
|
||||||
"Get user's most listened artists - extracts artist from track_title"
|
"Get user's most listened artists - extracts artist from track_title"
|
||||||
(when user-id
|
(with-db
|
||||||
(let* ((history (dm:get "listening_history" (db:query (:= 'user-id user-id))))
|
;; Extract artist from 'Artist - Title' format in track_title
|
||||||
(artist-counts (make-hash-table :test 'equal)))
|
(postmodern:query
|
||||||
;; Count plays per artist
|
(: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"
|
||||||
(dolist (h history)
|
user-id limit))
|
||||||
(let* ((title (dm:field h "track_title"))
|
:alists)))
|
||||||
(artist (when title
|
|
||||||
(let ((pos (search " - " title)))
|
|
||||||
(if pos (subseq title 0 pos) title)))))
|
|
||||||
(when artist
|
|
||||||
(incf (gethash artist artist-counts 0)))))
|
|
||||||
;; Convert to sorted list and take top N
|
|
||||||
(let ((sorted (sort (loop for artist being the hash-keys of artist-counts
|
|
||||||
using (hash-value count)
|
|
||||||
collect (cons artist count))
|
|
||||||
#'> :key #'cdr)))
|
|
||||||
(subseq sorted 0 (min limit (length sorted)))))))
|
|
||||||
|
|
||||||
(defun clear-listening-history (user-id)
|
(defun clear-listening-history (user-id)
|
||||||
"Clear all listening history for a user"
|
"Clear all listening history for a user"
|
||||||
(when user-id
|
(with-db
|
||||||
(let ((history (dm:get "listening_history" (db:query (:= 'user-id user-id)))))
|
(postmodern:query
|
||||||
(dolist (entry history)
|
(:raw (format nil "DELETE FROM listening_history WHERE \"user-id\" = ~a" user-id)))))
|
||||||
(dm:delete entry)))))
|
|
||||||
|
|
||||||
(defun get-listening-activity (user-id &key (days 30))
|
(defun get-listening-activity (user-id &key (days 30))
|
||||||
"Get listening activity aggregated by day for the last N days"
|
"Get listening activity aggregated by day for the last N days"
|
||||||
(when user-id
|
(with-db
|
||||||
(let* ((history (dm:get "listening_history" (db:query (:= 'user-id user-id))))
|
(postmodern:query
|
||||||
(cutoff-time (- (get-universal-time) (* days 24 60 60)))
|
(:raw (format nil "SELECT DATE(\"listened-at\") as day, COUNT(*) as track_count FROM listening_history WHERE \"user-id\" = ~a AND \"listened-at\" >= NOW() - INTERVAL '~a days' GROUP BY DATE(\"listened-at\") ORDER BY day ASC"
|
||||||
(day-counts (make-hash-table :test 'equal)))
|
user-id days))
|
||||||
;; Filter to recent days and count per day
|
:alists)))
|
||||||
(dolist (h history)
|
|
||||||
(let ((listened-at (dm:field h "listened-at")))
|
|
||||||
(when (and listened-at (> listened-at cutoff-time))
|
|
||||||
;; Convert universal time to date string
|
|
||||||
(multiple-value-bind (sec min hour day month year)
|
|
||||||
(decode-universal-time listened-at)
|
|
||||||
(declare (ignore sec min hour))
|
|
||||||
(let ((date-key (format nil "~4,'0d-~2,'0d-~2,'0d" year month day)))
|
|
||||||
(incf (gethash date-key day-counts 0)))))))
|
|
||||||
;; Convert to sorted list
|
|
||||||
(sort (loop for day being the hash-keys of day-counts
|
|
||||||
using (hash-value count)
|
|
||||||
collect (cons day count))
|
|
||||||
#'string< :key #'car))))
|
|
||||||
|
|
||||||
;;; ==========================================================================
|
;;; ==========================================================================
|
||||||
;;; API Endpoints for User Favorites
|
;;; API Endpoints for User Favorites
|
||||||
|
|
@ -282,12 +246,13 @@
|
||||||
(history (get-listening-history user-id)))
|
(history (get-listening-history user-id)))
|
||||||
(api-output `(("status" . "success")
|
(api-output `(("status" . "success")
|
||||||
("history" . ,(mapcar (lambda (h)
|
("history" . ,(mapcar (lambda (h)
|
||||||
`(("id" . ,(dm:id h))
|
`(("id" . ,(cdr (assoc :_id h)))
|
||||||
("track_id" . ,(dm:field h "track-id"))
|
("track_id" . ,(cdr (assoc :track-id h)))
|
||||||
("title" . ,(dm:field h "track_title"))
|
("title" . ,(or (cdr (assoc :track-title h))
|
||||||
("listened_at" . ,(dm:field h "listened-at"))
|
(cdr (assoc :track_title h))))
|
||||||
("listen_duration" . ,(dm:field h "listen-duration"))
|
("listened_at" . ,(cdr (assoc :listened-at h)))
|
||||||
("completed" . ,(let ((c (dm:field h "completed")))
|
("listen_duration" . ,(cdr (assoc :listen-duration h)))
|
||||||
|
("completed" . ,(let ((c (cdr (assoc :completed h))))
|
||||||
(and c (= 1 c))))))
|
(and c (= 1 c))))))
|
||||||
history)))))))
|
history)))))))
|
||||||
|
|
||||||
|
|
@ -338,8 +303,8 @@
|
||||||
(activity (get-listening-activity user-id :days days-int)))
|
(activity (get-listening-activity user-id :days days-int)))
|
||||||
(api-output `(("status" . "success")
|
(api-output `(("status" . "success")
|
||||||
("activity" . ,(mapcar (lambda (a)
|
("activity" . ,(mapcar (lambda (a)
|
||||||
`(("day" . ,(car a))
|
`(("day" . ,(cdr (assoc :day a)))
|
||||||
("track_count" . ,(cdr a))))
|
("track_count" . ,(cdr (assoc :track-count a)))))
|
||||||
activity)))))))
|
activity)))))))
|
||||||
|
|
||||||
;;; ==========================================================================
|
;;; ==========================================================================
|
||||||
|
|
@ -361,7 +326,7 @@
|
||||||
(relative-path (format nil "/asteroid/static/avatars/~a" new-filename)))
|
(relative-path (format nil "/asteroid/static/avatars/~a" new-filename)))
|
||||||
;; Copy from temp file to avatars directory
|
;; Copy from temp file to avatars directory
|
||||||
(uiop:copy-file temp-file-path full-path)
|
(uiop:copy-file temp-file-path full-path)
|
||||||
;; Update database - use raw SQL for single field update to avoid timestamp issues
|
;; Update database
|
||||||
(with-db
|
(with-db
|
||||||
(postmodern:query
|
(postmodern:query
|
||||||
(:raw (format nil "UPDATE \"USERS\" SET avatar_path = '~a' WHERE _id = ~a"
|
(:raw (format nil "UPDATE \"USERS\" SET avatar_path = '~a' WHERE _id = ~a"
|
||||||
|
|
@ -370,10 +335,10 @@
|
||||||
|
|
||||||
(defun get-user-avatar (user-id)
|
(defun get-user-avatar (user-id)
|
||||||
"Get the avatar path for a user"
|
"Get the avatar path for a user"
|
||||||
(when user-id
|
(with-db
|
||||||
(let ((user (dm:get-one "USERS" (db:query (:= '_id user-id)))))
|
(postmodern:query
|
||||||
(when user
|
(:raw (format nil "SELECT avatar_path FROM \"USERS\" WHERE _id = ~a" user-id))
|
||||||
(dm:field user "avatar_path")))))
|
:single)))
|
||||||
|
|
||||||
(define-api asteroid/user/avatar/upload () ()
|
(define-api asteroid/user/avatar/upload () ()
|
||||||
"Upload a new avatar image"
|
"Upload a new avatar image"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue