-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathuser-profile.lisp
More file actions
405 lines (368 loc) · 18.8 KB
/
user-profile.lisp
File metadata and controls
405 lines (368 loc) · 18.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
;;;; user-profile.lisp - User profile features: favorites, listening history
;;;; Part of Asteroid Radio
(in-package #:asteroid)
;;; ==========================================================================
;;; User Favorites - Track likes/ratings
;;; ==========================================================================
(defun get-favorite (user-id track-id &optional track-title)
"Gets a user's favorite track by id or name"
(when (and user-id (or track-id track-title))
(let ((query (if track-id
(db:query (:and (:= 'user-id user-id) (:= 'track-id track-id)))
(when track-title
(db:query (:and (:= 'user-id user-id) (:= 'track_title track-title)))))))
(when query
(dm:get-one "user_favorites" query)))))
(defun add-favorite (user-id track-id &optional (rating 1) track-title)
"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.
When favorite already exists for user, returns it instead to avoid duplicates."
(when (and user-id (or track-id track-title))
(let ((favorite (get-favorite user-id track-id track-title)))
(if favorite
favorite
(let ((rating-val (max 1 (min 5 (or rating 1))))
(favorite (dm:hull "user_favorites")))
(setf (dm:field favorite "user-id") user-id)
(setf (dm:field favorite "rating") rating-val)
(when track-id
(setf (dm:field favorite "track-id") track-id))
(when track-title
(setf (dm:field favorite "track_title") track-title))
(dm:insert favorite))))))
(defun remove-favorite (user-id track-id &optional track-title)
"Remove a track from user's favorites by track-id or title"
(let ((favorite (get-favorite user-id track-id track-title)))
(when favorite
(dm:delete favorite))))
(defun update-favorite-rating (user-id track-id rating)
"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)))
(favorite (get-favorite user-id track-id)))
(unless favorite
(error 'not-found-error
:message (format nil "Favorite #~a not found for user #~a"
track-id
user-id)))
(setf (dm:field favorite "rating-val") rating-val)
(data-model-save favorite)))
(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"
(when user-id
(dm:get "user_favorites" (db:query (:= 'user-id user-id))
:amount limit
:skip offset
:sort '(("created-date" :DESC)))))
(defun is-track-favorited (user-id track-id)
"Check if a track is in user's favorites, returns rating or nil"
(when (and user-id track-id)
(dm:get-one "user_favorites" (db:query (:and (:= 'user-id user-id)
(:= 'track-id track-id))))))
(defun get-favorites-count (user-id)
"Get total count of user's favorites"
(when user-id
(db:count "user_favorites" (db:query (:= 'user-id user-id)))))
(defun get-track-favorite-count (track-title)
"Get count of how many users have favorited a track by title"
(if (and track-title (not (string= track-title "")))
(handler-case
(let ((result (db:count "user_favorites" (db:query (:= 'track_title track-title)))))
(or result 0))
(error (e)
(declare (ignore e))
0))
0))
;;; ==========================================================================
;;; Listening History - Per-user track play history
;;; ==========================================================================
(defun get-recent-listen (user-id track-title)
"Check if user has listened to this track in the last 60 seconds"
(when (and user-id track-title)
;; Get recent listens and check timestamps manually since data-model
;; doesn't support interval comparisons directly
(let ((recent (dm:get "user_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))
"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."
(when (and user-id (or track-id track-title))
;; Check for recent duplicate
(unless (get-recent-listen user-id track-title)
(let ((listen (dm:hull "user_listening_history")))
(setf (dm:field listen "user_id") user-id)
(setf (dm:field listen "duration_seconds") (or duration 0))
(when track-title
(let ((pos (search " - " track-title)))
(when pos
(setf (dm:field listen "track_artist") (subseq track-title 0 pos)))))
(when track-title
(setf (dm:field listen "track_title") track-title))
(dm:insert listen)))))
(defun get-listening-history (user-id &key (limit 20) (offset 0))
"Get user's listening history - works with title-based history"
(when user-id
(dm:get "user_listening_history" (db:query (:= 'user_id user-id))
:amount limit
:skip offset
:sort '(("listened_at" :DESC)))))
(defun get-listening-stats (user-id)
"Get aggregate listening statistics for a user"
(when user-id
(let* ((history (dm:get "user_listening_history" (db:query (:= 'user_id user-id))))
(tracks-played (length history))
(total-listen-time (reduce #'+ history
:key (lambda (h) (or (dm:field h "duration_seconds") 0))
:initial-value 0)))
(list :tracks-played tracks-played
:total-listen-time total-listen-time))))
(defun get-top-artists (user-id &key (limit 5))
"Get user's most listened artists - extracts artist from track_title or uses track_artist"
(when user-id
(let* ((history (dm:get "user_listening_history" (db:query (:= 'user_id user-id))))
(artist-counts (make-hash-table :test 'equal)))
;; Count plays per artist
(dolist (h history)
(let* ((title (dm:field h "track_title"))
(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)
"Clear all listening history for a user"
(when user-id
(let ((history (dm:get "user_listening_history" (db:query (:= 'user_id user-id)))))
(dolist (entry history)
(dm:delete entry)))))
(defun get-listening-activity (user-id &key (days 30))
"Get listening activity aggregated by day for the last N days"
(when user-id
(let* ((history (dm:get "user_listening_history" (db:query (:= 'user_id user-id))))
(cutoff-time (- (get-universal-time) (* days 24 60 60)))
(day-counts (make-hash-table :test 'equal)))
;; Filter to recent days and count per day
(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
;;; ==========================================================================
(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 () ()
"Get current user's favorite tracks"
(require-authentication)
(with-error-handling
(let* ((user-id (session:field "user-id"))
(favorites (get-user-favorites user-id)))
(api-output `(("status" . "success")
("favorites" . ,(or (mapcar (lambda (fav)
`(("id" . ,(dm:id fav))
("track_id" . ,(dm:field fav "track-id"))
("title" . ,(dm:field fav "track_title"))
("rating" . ,(dm:field fav "rating"))))
favorites)
(list)))
("count" . ,(get-favorites-count user-id)))))))
(define-api asteroid/user/favorites/add (&optional track-id rating title) ()
"Add a track to user's favorites. Can use track-id or title."
(require-authentication)
(with-error-handling
(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)))
(rating-int (if rating (parse-integer rating :junk-allowed t) 1)))
(unless user-id
(error 'authentication-error
:message "User not authenticated"))
(format t "Adding favorite: user-id=~a track-id=~a title=~a~%" user-id track-id-int title)
(add-favorite user-id track-id-int (or rating-int 1) title)
(api-output `(("status" . "success")
("message" . "Track added to favorites"))))))
(define-api asteroid/user/favorites/remove (&optional track-id title) ()
"Remove a track from user's favorites by track-id or title"
(require-authentication)
(with-error-handling
(let* ((user-id (session:field "user-id"))
(track-id-int (when (and track-id (not (string= track-id "")))
(parse-integer track-id :junk-allowed t))))
(remove-favorite user-id track-id-int title)
(api-output `(("status" . "success")
("message" . "Track removed from favorites"))))))
(define-api asteroid/user/favorites/rate (track-id rating) ()
"Update rating for a favorited track"
(require-authentication)
(with-error-handling
(let* ((user-id (session:field "user-id"))
(track-id-int (parse-integer track-id))
(rating-int (parse-integer rating)))
(update-favorite-rating user-id track-id-int rating-int)
(api-output `(("status" . "success")
("message" . "Rating updated"))))))
(define-api asteroid/user/favorites/check (track-id) ()
"Check if a track is in user's favorites"
(require-authentication)
(with-error-handling
(let* ((user-id (session:field "user-id"))
(track-id-int (parse-integer track-id))
(rating (is-track-favorited user-id track-id-int)))
(api-output `(("status" . "success")
("favorited" . ,(if rating t nil))
("rating" . ,rating))))))
;;; ==========================================================================
;;; API Endpoints for Listening History
;;; ==========================================================================
(define-api asteroid/user/history () ()
"Get current user's listening history"
(require-authentication)
(with-error-handling
(let* ((user-id (session:field "user-id"))
(history (get-listening-history user-id)))
(api-output `(("status" . "success")
("history" . ,(mapcar (lambda (h)
`(("id" . ,(dm:id h))
("track_id" . ,(dm:field h "track-id"))
("title" . ,(dm:field h "track_title"))
("listened_at" . ,(dm:field h "listened-at"))
("listen_duration" . ,(dm:field h "listen-duration"))
("completed" . ,(let ((c (dm:field h "completed")))
(and c (= 1 c))))))
history)))))))
(defun get-session-user-id ()
"Get user-id from session, handling BIT type from PostgreSQL"
(let ((user-id-raw (session:field "user-id")))
(cond
((null user-id-raw) nil)
((integerp user-id-raw) user-id-raw)
((stringp user-id-raw) (parse-integer user-id-raw :junk-allowed t))
((bit-vector-p user-id-raw) (parse-integer (format nil "~a" user-id-raw) :junk-allowed t))
(t (handler-case (parse-integer (format nil "~a" user-id-raw) :junk-allowed t)
(error () nil))))))
(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."
(let ((user-id (get-session-user-id)))
(if (null user-id)
(api-output `(("status" . "error")
("message" . "Not authenticated"))
:status 401)
(with-error-handling
(let* ((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"))))
(when title
(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"))))))))
(define-api asteroid/user/history/clear () ()
"Clear user's listening history"
(require-authentication)
(with-error-handling
(let ((user-id (session:field "user-id")))
(clear-listening-history user-id)
(api-output `(("status" . "success")
("message" . "Listening history cleared"))))))
(define-api asteroid/user/activity (&optional (days "30")) ()
"Get listening activity by day for the last N days"
(require-authentication)
(with-error-handling
(let* ((user-id (session:field "user-id"))
(days-int (or (parse-integer days :junk-allowed t) 30))
(activity (get-listening-activity user-id :days days-int)))
(api-output `(("status" . "success")
("activity" . ,(mapcar (lambda (a)
`(("day" . ,(car a))
("track_count" . ,(cdr a))))
activity)))))))
;;; ==========================================================================
;;; Avatar Management
;;; ==========================================================================
(defun get-avatars-directory ()
"Get the path to the avatars directory"
(merge-pathnames "static/avatars/" (asdf:system-source-directory :asteroid)))
(defun save-avatar (user-id temp-file-path original-filename)
"Save an avatar file from temp path and return the relative path"
(let* ((extension (pathname-type original-filename))
(safe-ext (if (member extension '("png" "jpg" "jpeg" "gif" "webp") :test #'string-equal)
extension
"png"))
(new-filename (format nil "~a.~a" user-id safe-ext))
(full-path (merge-pathnames new-filename (get-avatars-directory)))
(relative-path (format nil "/asteroid/static/avatars/~a" new-filename)))
;; Copy from temp file to avatars directory
(uiop:copy-file temp-file-path full-path)
;; Update database - use raw SQL for single field update to avoid timestamp issues
(with-db
(postmodern:query
(:raw (format nil "UPDATE \"USERS\" SET avatar_path = '~a' WHERE _id = ~a"
relative-path user-id))))
relative-path))
(defun get-user-avatar (user-id)
"Get the avatar path for a user"
(when user-id
(let ((user (dm:get-one "USERS" (db:query (:= '_id user-id)))))
(when user
(dm:field user "avatar_path")))))
(define-api asteroid/user/avatar/upload () ()
"Upload a new avatar image"
(require-authentication)
(with-error-handling
(let* ((user-id (session:field "user-id"))
;; Radiance wraps hunchentoot - post-var returns (path filename content-type) for files
(file-info (radiance:post-var "avatar"))
(temp-path (when (listp file-info) (first file-info)))
(original-name (when (listp file-info) (second file-info))))
(format t "Avatar upload: file-info=~a temp-path=~a original-name=~a~%" file-info temp-path original-name)
(if (and temp-path (probe-file temp-path))
(let ((avatar-path (save-avatar user-id temp-path (or original-name "avatar.png"))))
(api-output `(("status" . "success")
("message" . "Avatar uploaded successfully")
("avatar_path" . ,avatar-path))))
(api-output `(("status" . "error")
("message" . "No file provided"))
:status 400)))))
(define-api asteroid/user/avatar () ()
"Get current user's avatar path"
(require-authentication)
(with-error-handling
(let* ((user-id (session:field "user-id"))
(avatar-path (get-user-avatar user-id)))
(api-output `(("status" . "success")
("avatar_path" . ,avatar-path))))))