Compare commits
No commits in common. "de70fbde5a77712e906bd2385e3a71a6051e8660" and "eb03947f7f74993647554120fd413a48dbc94040" have entirely different histories.
de70fbde5a
...
eb03947f7f
|
|
@ -57,12 +57,12 @@
|
||||||
(rating :integer)
|
(rating :integer)
|
||||||
(created-date :integer))))
|
(created-date :integer))))
|
||||||
|
|
||||||
(unless (db:collection-exists-p "user_listening_history")
|
(unless (db:collection-exists-p "listening_history")
|
||||||
(db:create "user_listening_history" '((user_id :integer)
|
(db:create "listening_history" '((user-id :integer)
|
||||||
(track_title :text)
|
(track-id :integer)
|
||||||
(track_artist :text)
|
(listened-at :integer)
|
||||||
(listened_at :timestamp)
|
(listen-duration :integer)
|
||||||
(duration_seconds :integer))))
|
(completed :integer))))
|
||||||
|
|
||||||
(unless (db:collection-exists-p "user_playlists")
|
(unless (db:collection-exists-p "user_playlists")
|
||||||
(db:create "user_playlists" '((user-id :integer)
|
(db:create "user_playlists" '((user-id :integer)
|
||||||
|
|
@ -98,37 +98,6 @@
|
||||||
(string= (string-upcase (package-name (db:implementation)))
|
(string= (string-upcase (package-name (db:implementation)))
|
||||||
"I-LAMBDALITE"))
|
"I-LAMBDALITE"))
|
||||||
|
|
||||||
(defun format-timestamp-iso8601 (value)
|
|
||||||
"Convert a timestamp value to ISO 8601 format.
|
|
||||||
Handles: integers (Unix epoch), local-time timestamps, strings, and NIL."
|
|
||||||
(cond
|
|
||||||
((null value) nil)
|
|
||||||
((stringp value) value) ; Already a string, assume it's formatted
|
|
||||||
((integerp value)
|
|
||||||
;; Convert Unix epoch to ISO 8601 string
|
|
||||||
(local-time:format-timestring nil (local-time:unix-to-timestamp value)
|
|
||||||
:format '(:year "-" (:month 2) "-" (:day 2) " "
|
|
||||||
(:hour 2) ":" (:min 2) ":" (:sec 2))
|
|
||||||
:timezone local-time:+utc-zone+))
|
|
||||||
((typep value 'local-time:timestamp)
|
|
||||||
(local-time:format-timestring nil value
|
|
||||||
:format '(:year "-" (:month 2) "-" (:day 2) " "
|
|
||||||
(:hour 2) ":" (:min 2) ":" (:sec 2))
|
|
||||||
:timezone local-time:+utc-zone+))
|
|
||||||
(t (format nil "~a" value)))) ; Fallback: convert to string
|
|
||||||
|
|
||||||
(defun normalize-user-timestamps (data-model)
|
|
||||||
"Ensure USERS table timestamp fields are properly formatted for PostgreSQL."
|
|
||||||
(when (string-equal (dm:collection data-model) "USERS")
|
|
||||||
(let ((created-date (dm:field data-model "created-date"))
|
|
||||||
(last-login (dm:field data-model "last-login")))
|
|
||||||
(when created-date
|
|
||||||
(setf (dm:field data-model "created-date")
|
|
||||||
(format-timestamp-iso8601 created-date)))
|
|
||||||
(when last-login
|
|
||||||
(setf (dm:field data-model "last-login")
|
|
||||||
(format-timestamp-iso8601 last-login))))))
|
|
||||||
|
|
||||||
(defun data-model-save (data-model)
|
(defun data-model-save (data-model)
|
||||||
"Wrapper on data-model save method to bypass error using dm:save on lambdalite.
|
"Wrapper on data-model save method to bypass error using dm:save on lambdalite.
|
||||||
It uses the same approach as dm:save under the hood through db:save."
|
It uses the same approach as dm:save under the hood through db:save."
|
||||||
|
|
@ -140,6 +109,4 @@ It uses the same approach as dm:save under the hood through db:save."
|
||||||
(dm:field-table data-model)))
|
(dm:field-table data-model)))
|
||||||
(progn
|
(progn
|
||||||
(format t "Updating database table '~a'~%" (dm:collection data-model))
|
(format t "Updating database table '~a'~%" (dm:collection data-model))
|
||||||
;; Normalize timestamp fields before saving to PostgreSQL
|
|
||||||
(normalize-user-timestamps data-model)
|
|
||||||
(dm:save data-model))))
|
(dm:save data-model))))
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,6 @@
|
||||||
(ps:ps*
|
(ps:ps*
|
||||||
'(progn
|
'(progn
|
||||||
|
|
||||||
;; Global auth state - accessible by other scripts
|
|
||||||
(defvar *auth-state* (ps:create :logged-in false :is-admin false :user-id nil))
|
|
||||||
|
|
||||||
;; Check if user is logged in by calling the API
|
;; Check if user is logged in by calling the API
|
||||||
(defun check-auth-status ()
|
(defun check-auth-status ()
|
||||||
(ps:chain
|
(ps:chain
|
||||||
|
|
@ -19,8 +16,6 @@
|
||||||
(then (lambda (result)
|
(then (lambda (result)
|
||||||
;; api-output wraps response in {status, message, data}
|
;; api-output wraps response in {status, message, data}
|
||||||
(let ((data (or (ps:@ result data) result)))
|
(let ((data (or (ps:@ result data) result)))
|
||||||
;; Store auth state globally for other scripts to use
|
|
||||||
(setf *auth-state* data)
|
|
||||||
data)))
|
data)))
|
||||||
(catch (lambda (error)
|
(catch (lambda (error)
|
||||||
(ps:chain console (error "Error checking auth status:" error))
|
(ps:chain console (error "Error checking auth status:" error))
|
||||||
|
|
|
||||||
|
|
@ -171,17 +171,14 @@
|
||||||
;; Cache of user's favorite track titles for quick lookup
|
;; Cache of user's favorite track titles for quick lookup
|
||||||
(defvar *user-favorites-cache* (array))
|
(defvar *user-favorites-cache* (array))
|
||||||
|
|
||||||
;; Load user's favorites into cache (only if logged in)
|
;; Load user's favorites into cache
|
||||||
(defun load-favorites-cache ()
|
(defun load-favorites-cache ()
|
||||||
;; Check global auth state - only call API if logged in
|
(ps:chain
|
||||||
(when (and (not (= (typeof *auth-state*) "undefined"))
|
(fetch "/api/asteroid/user/favorites")
|
||||||
(ps:@ *auth-state* logged-in))
|
(then (lambda (response)
|
||||||
(ps:chain
|
(if (ps:@ response ok)
|
||||||
(fetch "/api/asteroid/user/favorites")
|
(ps:chain response (json))
|
||||||
(then (lambda (response)
|
nil)))
|
||||||
(if (ps:@ response ok)
|
|
||||||
(ps:chain response (json))
|
|
||||||
nil)))
|
|
||||||
(then (lambda (data)
|
(then (lambda (data)
|
||||||
(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*
|
||||||
|
|
@ -189,7 +186,7 @@
|
||||||
(map (lambda (f) (ps:@ f title)))))
|
(map (lambda (f) (ps:@ f title)))))
|
||||||
;; Update UI after cache is loaded
|
;; Update UI after cache is loaded
|
||||||
(check-favorite-status))))
|
(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
|
||||||
(defun check-favorite-status ()
|
(defun check-favorite-status ()
|
||||||
|
|
@ -208,10 +205,7 @@
|
||||||
|
|
||||||
;; Record track to listening history (only if logged in)
|
;; Record track to listening history (only if logged in)
|
||||||
(defun record-track-listen-main (title)
|
(defun record-track-listen-main (title)
|
||||||
;; Check global auth state - only call API if logged in
|
(when (and title (not (= title "")) (not (= title "Loading..."))
|
||||||
(when (and (not (= (typeof *auth-state*) "undefined"))
|
|
||||||
(ps:@ *auth-state* logged-in)
|
|
||||||
title (not (= title "")) (not (= title "Loading..."))
|
|
||||||
(not (= title "NA")) (not (= title *last-recorded-title-main*)))
|
(not (= title "NA")) (not (= title *last-recorded-title-main*)))
|
||||||
(setf *last-recorded-title-main* title)
|
(setf *last-recorded-title-main* title)
|
||||||
(ps:chain
|
(ps:chain
|
||||||
|
|
@ -220,7 +214,7 @@
|
||||||
(then (lambda (response)
|
(then (lambda (response)
|
||||||
(ps:@ response ok)))
|
(ps:@ response ok)))
|
||||||
(catch (lambda (error)
|
(catch (lambda (error)
|
||||||
;; Silently fail
|
;; Silently fail - user might not be logged in
|
||||||
nil)))))
|
nil)))))
|
||||||
|
|
||||||
;; Update now playing info from API
|
;; Update now playing info from API
|
||||||
|
|
|
||||||
|
|
@ -204,17 +204,14 @@
|
||||||
;; Cache of user's favorite track titles for quick lookup (mini player)
|
;; Cache of user's favorite track titles for quick lookup (mini player)
|
||||||
(defvar *user-favorites-cache-mini* (array))
|
(defvar *user-favorites-cache-mini* (array))
|
||||||
|
|
||||||
;; Load user's favorites into cache (mini player - only if logged in)
|
;; Load user's favorites into cache (mini player)
|
||||||
(defun load-favorites-cache-mini ()
|
(defun load-favorites-cache-mini ()
|
||||||
;; Check global auth state - only call API if logged in
|
(ps:chain
|
||||||
(when (and (not (= (typeof *auth-state*) "undefined"))
|
(fetch "/api/asteroid/user/favorites" (ps:create :credentials "include"))
|
||||||
(ps:@ *auth-state* logged-in))
|
(then (lambda (response)
|
||||||
(ps:chain
|
(if (ps:@ response ok)
|
||||||
(fetch "/api/asteroid/user/favorites" (ps:create :credentials "include"))
|
(ps:chain response (json))
|
||||||
(then (lambda (response)
|
nil)))
|
||||||
(if (ps:@ response ok)
|
|
||||||
(ps:chain response (json))
|
|
||||||
nil)))
|
|
||||||
(then (lambda (data)
|
(then (lambda (data)
|
||||||
(when data
|
(when data
|
||||||
;; Handle both wrapped (data.data.favorites) and unwrapped (data.favorites) responses
|
;; Handle both wrapped (data.data.favorites) and unwrapped (data.favorites) responses
|
||||||
|
|
@ -225,7 +222,7 @@
|
||||||
(ps:chain favorites (map (lambda (f) (ps:@ f title)))))
|
(ps:chain favorites (map (lambda (f) (ps:@ f title)))))
|
||||||
;; Update UI after cache is loaded
|
;; Update UI after cache is loaded
|
||||||
(check-favorite-status-mini))))))
|
(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
|
||||||
(defun check-favorite-status-mini ()
|
(defun check-favorite-status-mini ()
|
||||||
|
|
@ -245,10 +242,7 @@
|
||||||
|
|
||||||
;; Record track to listening history (only if logged in)
|
;; Record track to listening history (only if logged in)
|
||||||
(defun record-track-listen (title)
|
(defun record-track-listen (title)
|
||||||
;; Check global auth state - only call API if logged in
|
(when (and title (not (= title "")) (not (= title "Loading...")) (not (= title *last-recorded-title*)))
|
||||||
(when (and (not (= (typeof *auth-state*) "undefined"))
|
|
||||||
(ps:@ *auth-state* logged-in)
|
|
||||||
title (not (= title "")) (not (= title "Loading...")) (not (= title *last-recorded-title*)))
|
|
||||||
(setf *last-recorded-title* title)
|
(setf *last-recorded-title* title)
|
||||||
(ps:chain
|
(ps:chain
|
||||||
(fetch (+ "/api/asteroid/user/history/record?title=" (encode-u-r-i-component title))
|
(fetch (+ "/api/asteroid/user/history/record?title=" (encode-u-r-i-component title))
|
||||||
|
|
@ -790,14 +784,13 @@
|
||||||
(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)
|
||||||
;; Listen for messages from parent frame (e.g., favorites cache reload)
|
(ps:chain window (add-event-listener
|
||||||
(ps:chain window (add-event-listener
|
"message"
|
||||||
"message"
|
(lambda (event)
|
||||||
(lambda (event)
|
(when (= (ps:@ event data) "reload-favorites")
|
||||||
(when (= (ps:@ event data) "reload-favorites")
|
(load-favorites-cache-mini)))))))
|
||||||
(load-favorites-cache-mini))))))
|
|
||||||
)
|
)
|
||||||
"Compiled JavaScript for stream player - generated at load time")
|
"Compiled JavaScript for stream player - generated at load time")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,12 +70,15 @@
|
||||||
(when (and (= 1 user-active)
|
(when (and (= 1 user-active)
|
||||||
(verify-password password user-password))
|
(verify-password password user-password))
|
||||||
;; Update last login using data-model (database agnostic)
|
;; Update last login using data-model (database agnostic)
|
||||||
|
;; Use ISO 8601 format in UTC that PostgreSQL TIMESTAMP can parse
|
||||||
(handler-case
|
(handler-case
|
||||||
(progn
|
(progn
|
||||||
(setf (dm:field user "last-login")
|
(setf (dm:field user "last-login")
|
||||||
(format-timestamp-iso8601 (local-time:now)))
|
(local-time:format-timestring nil (local-time:now)
|
||||||
;; Use data-model-save to normalize all timestamp fields before saving
|
:format '(:year "-" (:month 2) "-" (:day 2) " "
|
||||||
(data-model-save user))
|
(:hour 2) ":" (:min 2) ":" (:sec 2))
|
||||||
|
:timezone local-time:+utc-zone+))
|
||||||
|
(dm:save user))
|
||||||
(error (e)
|
(error (e)
|
||||||
(format t "Warning: Could not update last-login: ~a~%" e)))
|
(format t "Warning: Could not update last-login: ~a~%" e)))
|
||||||
user)))))
|
user)))))
|
||||||
|
|
|
||||||
|
|
@ -94,14 +94,14 @@
|
||||||
(when (and user-id track-title)
|
(when (and user-id track-title)
|
||||||
;; Get recent listens and check timestamps manually since data-model
|
;; Get recent listens and check timestamps manually since data-model
|
||||||
;; doesn't support interval comparisons directly
|
;; doesn't support interval comparisons directly
|
||||||
(let ((recent (dm:get "user_listening_history"
|
(let ((recent (dm:get "listening_history"
|
||||||
(db:query (:and (:= 'user_id user-id)
|
(db:query (:and (:= 'user-id user-id)
|
||||||
(:= 'track_title track-title)))
|
(:= 'track_title track-title)))
|
||||||
:amount 1
|
:amount 1
|
||||||
:sort '(("listened_at" :DESC)))))
|
:sort '(("listened-at" :DESC)))))
|
||||||
(when recent
|
(when recent
|
||||||
(let* ((listen (first recent))
|
(let* ((listen (first recent))
|
||||||
(listened-at (dm:field listen "listened_at")))
|
(listened-at (dm:field listen "listened-at")))
|
||||||
;; Check if within 60 seconds (listened-at is a timestamp)
|
;; Check if within 60 seconds (listened-at is a timestamp)
|
||||||
(when listened-at
|
(when listened-at
|
||||||
(let ((now (get-universal-time))
|
(let ((now (get-universal-time))
|
||||||
|
|
@ -116,13 +116,12 @@
|
||||||
(when (and user-id (or track-id track-title))
|
(when (and user-id (or track-id track-title))
|
||||||
;; Check for recent duplicate
|
;; Check for recent duplicate
|
||||||
(unless (get-recent-listen user-id track-title)
|
(unless (get-recent-listen user-id track-title)
|
||||||
(let ((listen (dm:hull "user_listening_history")))
|
(let ((listen (dm:hull "listening_history")))
|
||||||
(setf (dm:field listen "user_id") user-id)
|
(setf (dm:field listen "user-id") user-id)
|
||||||
(setf (dm:field listen "duration_seconds") (or duration 0))
|
(setf (dm:field listen "listen-duration") (or duration 0))
|
||||||
(when track-title
|
(setf (dm:field listen "completed") (if completed 1 0))
|
||||||
(let ((pos (search " - " track-title)))
|
(when track-id
|
||||||
(when pos
|
(setf (dm:field listen "track-id") track-id))
|
||||||
(setf (dm:field listen "track_artist") (subseq track-title 0 pos)))))
|
|
||||||
(when track-title
|
(when track-title
|
||||||
(setf (dm:field listen "track_title") track-title))
|
(setf (dm:field listen "track_title") track-title))
|
||||||
(dm:insert listen)))))
|
(dm:insert listen)))))
|
||||||
|
|
@ -130,26 +129,26 @@
|
||||||
(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
|
(when user-id
|
||||||
(dm:get "user_listening_history" (db:query (:= 'user_id user-id))
|
(dm:get "listening_history" (db:query (:= 'user-id user-id))
|
||||||
:amount limit
|
:amount limit
|
||||||
:skip offset
|
:skip offset
|
||||||
:sort '(("listened_at" :DESC)))))
|
:sort '(("listened-at" :DESC)))))
|
||||||
|
|
||||||
(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
|
(when user-id
|
||||||
(let* ((history (dm:get "user_listening_history" (db:query (:= 'user_id user-id))))
|
(let* ((history (dm:get "listening_history" (db:query (:= 'user-id user-id))))
|
||||||
(tracks-played (length history))
|
(tracks-played (length history))
|
||||||
(total-listen-time (reduce #'+ history
|
(total-listen-time (reduce #'+ history
|
||||||
:key (lambda (h) (or (dm:field h "duration_seconds") 0))
|
:key (lambda (h) (or (dm:field h "listen-duration") 0))
|
||||||
:initial-value 0)))
|
:initial-value 0)))
|
||||||
(list :tracks-played tracks-played
|
(list :tracks-played tracks-played
|
||||||
:total-listen-time total-listen-time))))
|
: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 or uses track_artist"
|
"Get user's most listened artists - extracts artist from track_title"
|
||||||
(when user-id
|
(when user-id
|
||||||
(let* ((history (dm:get "user_listening_history" (db:query (:= 'user_id user-id))))
|
(let* ((history (dm:get "listening_history" (db:query (:= 'user-id user-id))))
|
||||||
(artist-counts (make-hash-table :test 'equal)))
|
(artist-counts (make-hash-table :test 'equal)))
|
||||||
;; Count plays per artist
|
;; Count plays per artist
|
||||||
(dolist (h history)
|
(dolist (h history)
|
||||||
|
|
@ -169,19 +168,19 @@
|
||||||
(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
|
(when user-id
|
||||||
(let ((history (dm:get "user_listening_history" (db:query (:= 'user_id user-id)))))
|
(let ((history (dm:get "listening_history" (db:query (:= 'user-id user-id)))))
|
||||||
(dolist (entry history)
|
(dolist (entry history)
|
||||||
(dm:delete entry)))))
|
(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
|
(when user-id
|
||||||
(let* ((history (dm:get "user_listening_history" (db:query (:= 'user_id user-id))))
|
(let* ((history (dm:get "listening_history" (db:query (:= 'user-id user-id))))
|
||||||
(cutoff-time (- (get-universal-time) (* days 24 60 60)))
|
(cutoff-time (- (get-universal-time) (* days 24 60 60)))
|
||||||
(day-counts (make-hash-table :test 'equal)))
|
(day-counts (make-hash-table :test 'equal)))
|
||||||
;; Filter to recent days and count per day
|
;; Filter to recent days and count per day
|
||||||
(dolist (h history)
|
(dolist (h history)
|
||||||
(let ((listened-at (dm:field h "listened_at")))
|
(let ((listened-at (dm:field h "listened-at")))
|
||||||
(when (and listened-at (> listened-at cutoff-time))
|
(when (and listened-at (> listened-at cutoff-time))
|
||||||
;; Convert universal time to date string
|
;; Convert universal time to date string
|
||||||
(multiple-value-bind (sec min hour day month year)
|
(multiple-value-bind (sec min hour day month year)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue