From 62dde5e3cf848837434faf1904d4d1d3f3d58dad Mon Sep 17 00:00:00 2001 From: glenneth Date: Sun, 21 Dec 2025 12:45:49 +0300 Subject: [PATCH] feat: Track requests, listening history, and profile enhancements Track Requests: - Database table for user track requests (migration 007) - API endpoints for submit, approve, reject, play - Front page UI for submitting requests - Shows recently played requests section Listening History: - Auto-records tracks when playing (with 60s deduplication) - Recently Played section on profile (has date formatting issues) - Activity chart showing listening patterns by day - Load More Tracks pagination Profile Improvements: - Fixed 401 errors returning proper JSON - Fixed PostgreSQL boolean type for completed column - Added offset parameter to recent-tracks API Note: Recently Played section has date formatting issues showing '20397 days ago' - may be removed in future commit if not needed. The listening history backend works correctly. For production: run migrations/007-track-requests.sql --- asteroid.asd | 1 + asteroid.lisp | 7 +- migrations/007-track-requests.sql | 31 +++++ parenscript/front-page.lisp | 72 +++++++++- parenscript/profile.lisp | 76 ++++++++--- static/asteroid.css | 88 ++++++++++++ static/asteroid.lass | 74 ++++++++++ static/avatars/5.png | Bin 0 -> 14757 bytes template/front-page.ctml | 20 +++ track-requests.lisp | 219 ++++++++++++++++++++++++++++++ user-profile.lisp | 73 ++++++---- 11 files changed, 613 insertions(+), 48 deletions(-) create mode 100644 migrations/007-track-requests.sql create mode 100644 static/avatars/5.png create mode 100644 track-requests.lisp diff --git a/asteroid.asd b/asteroid.asd index 305afd1..f09308b 100644 --- a/asteroid.asd +++ b/asteroid.asd @@ -64,6 +64,7 @@ (:file "playlist-scheduler") (:file "listener-stats") (:file "user-profile") + (:file "track-requests") (:file "auth-routes") (:file "frontend-partials") (:file "asteroid"))) diff --git a/asteroid.lisp b/asteroid.lisp index 8940159..a88e22c 100644 --- a/asteroid.lisp +++ b/asteroid.lisp @@ -1167,13 +1167,14 @@ ("session_count" . 0) ("favorite_genre" . "Ambient")))))))) -(define-api asteroid/user/recent-tracks (&optional (limit "3")) () +(define-api asteroid/user/recent-tracks (&optional (limit "3") (offset "0")) () "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)))) + (limit-int (or (parse-integer limit :junk-allowed t) 3)) + (offset-int (or (parse-integer offset :junk-allowed t) 0)) + (history (get-listening-history user-id :limit limit-int :offset offset-int))) (api-output `(("status" . "success") ("tracks" . ,(mapcar (lambda (h) `(("title" . ,(or (cdr (assoc :track-title h)) diff --git a/migrations/007-track-requests.sql b/migrations/007-track-requests.sql new file mode 100644 index 0000000..50a984c --- /dev/null +++ b/migrations/007-track-requests.sql @@ -0,0 +1,31 @@ +-- Migration 007: Track Request System +-- Allows users to request tracks for the stream with social attribution + +-- Track requests table +CREATE TABLE IF NOT EXISTS track_requests ( + _id SERIAL PRIMARY KEY, + "user-id" INTEGER NOT NULL REFERENCES "USERS"(_id) ON DELETE CASCADE, + track_title TEXT NOT NULL, -- Track title (Artist - Title format) + track_path TEXT, -- Optional: path to file if known + message TEXT, -- Optional message from requester + status TEXT DEFAULT 'pending', -- pending, approved, rejected, played + "created-at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "reviewed-at" TIMESTAMP, -- When admin reviewed + "reviewed-by" INTEGER REFERENCES "USERS"(_id), + "played-at" TIMESTAMP -- When it was actually played +); + +-- Create indexes for efficient queries +CREATE INDEX IF NOT EXISTS idx_track_requests_user_id ON track_requests("user-id"); +CREATE INDEX IF NOT EXISTS idx_track_requests_status ON track_requests(status); +CREATE INDEX IF NOT EXISTS idx_track_requests_created ON track_requests("created-at"); + +-- Grant permissions +GRANT ALL PRIVILEGES ON track_requests TO asteroid; +GRANT ALL PRIVILEGES ON SEQUENCE track_requests__id_seq TO asteroid; + +-- Verification +DO $$ +BEGIN + RAISE NOTICE 'Migration 007: Track requests table created successfully!'; +END $$; diff --git a/parenscript/front-page.lisp b/parenscript/front-page.lisp index e62da0a..cadbff4 100644 --- a/parenscript/front-page.lisp +++ b/parenscript/front-page.lisp @@ -721,7 +721,77 @@ (when (and *popout-window* (ps:@ *popout-window* closed)) (update-popout-button nil) (setf *popout-window* nil))) - 1000))) + 1000) + + ;; Track Request Functions + (defun submit-track-request () + (let ((title-input (ps:chain document (get-element-by-id "request-title"))) + (message-input (ps:chain document (get-element-by-id "request-message"))) + (status-div (ps:chain document (get-element-by-id "request-status")))) + (when (and title-input message-input status-div) + (let ((title (ps:@ title-input value)) + (message (ps:@ message-input value))) + (if (or (not title) (= title "")) + (progn + (setf (ps:@ status-div style display) "block") + (setf (ps:@ status-div class-name) "request-status error") + (setf (ps:@ status-div text-content) "Please enter a track title")) + (progn + (setf (ps:@ status-div style display) "block") + (setf (ps:@ status-div class-name) "request-status info") + (setf (ps:@ status-div text-content) "Submitting request...") + (ps:chain + (fetch (+ "/api/asteroid/requests/submit?title=" (encode-u-r-i-component title) + (if message (+ "&message=" (encode-u-r-i-component message)) "")) + (ps:create :method "POST")) + (then (lambda (response) + (if (ps:@ response ok) + (ps:chain response (json)) + (progn + (setf (ps:@ status-div class-name) "request-status error") + (setf (ps:@ status-div text-content) "Please log in to submit requests") + nil)))) + (then (lambda (data) + (when data + (let ((status (or (ps:@ data data status) (ps:@ data status)))) + (if (= status "success") + (progn + (setf (ps:@ status-div class-name) "request-status success") + (setf (ps:@ status-div text-content) "Request submitted! An admin will review it soon.") + (setf (ps:@ title-input value) "") + (setf (ps:@ message-input value) "")) + (progn + (setf (ps:@ status-div class-name) "request-status error") + (setf (ps:@ status-div text-content) "Failed to submit request"))))))) + (catch (lambda (error) + (ps:chain console (error "Error submitting request:" error)) + (setf (ps:@ status-div class-name) "request-status error") + (setf (ps:@ status-div text-content) "Error submitting request")))))))))) + + (defun load-recent-requests () + (let ((container (ps:chain document (get-element-by-id "recent-requests-list")))) + (when container + (ps:chain + (fetch "/api/asteroid/requests/recent") + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (and (= (ps:@ data status) "success") + (ps:@ data requests) + (> (ps:@ data requests length) 0)) + (let ((html "")) + (ps:chain (ps:@ data requests) (for-each (lambda (req) + (setf html (+ html "
" + "" (ps:@ req title) "" + "Requested by @" (ps:@ req username) "" + "
"))))) + (setf (ps:@ container inner-h-t-m-l) html)) + (setf (ps:@ container inner-h-t-m-l) "

No recent requests yet. Be the first!

"))))) + (catch (lambda (error) + (ps:chain console (log "Could not load recent requests:" error)))))))) + + ;; Load recent requests on page load + (load-recent-requests))) "Compiled JavaScript for front-page - generated at load time") (defun generate-front-page-js () diff --git a/parenscript/profile.lisp b/parenscript/profile.lisp index a6da081..dd7c954 100644 --- a/parenscript/profile.lisp +++ b/parenscript/profile.lisp @@ -32,20 +32,31 @@ :day "numeric"))))) (defun format-relative-time (date-string) - (let* ((date (ps:new (-date date-string))) - (now (ps:new (-date))) - (diff-ms (- now date)) - (diff-days (ps:chain -math (floor (/ diff-ms (* 1000 60 60 24))))) - (diff-hours (ps:chain -math (floor (/ diff-ms (* 1000 60 60))))) - (diff-minutes (ps:chain -math (floor (/ diff-ms (* 1000 60)))))) - (cond - ((> diff-days 0) - (+ diff-days " day" (if (> diff-days 1) "s" "") " ago")) - ((> diff-hours 0) - (+ diff-hours " hour" (if (> diff-hours 1) "s" "") " ago")) - ((> diff-minutes 0) - (+ diff-minutes " minute" (if (> diff-minutes 1) "s" "") " ago")) - (t "Just now")))) + (when (not date-string) + (return-from format-relative-time "Unknown")) + ;; Convert PostgreSQL timestamp format to ISO format + ;; "2025-12-21 09:22:58.215986" -> "2025-12-21T09:22:58.215986Z" + (let* ((iso-string (if (and (ps:@ date-string replace) + (ps:chain date-string (includes " "))) + (+ (ps:chain date-string (replace " " "T")) "Z") + date-string)) + (date (ps:new (-date iso-string))) + (now (ps:new (-date)))) + ;; Check if date is valid + (when (ps:chain -number (is-na-n (ps:chain date (get-time)))) + (return-from format-relative-time "Recently")) + (let* ((diff-ms (- now date)) + (diff-days (ps:chain -math (floor (/ diff-ms (* 1000 60 60 24))))) + (diff-hours (ps:chain -math (floor (/ diff-ms (* 1000 60 60))))) + (diff-minutes (ps:chain -math (floor (/ diff-ms (* 1000 60)))))) + (cond + ((> diff-days 0) + (+ diff-days " day" (if (> diff-days 1) "s" "") " ago")) + ((> diff-hours 0) + (+ diff-hours " hour" (if (> diff-hours 1) "s" "") " ago")) + ((> diff-minutes 0) + (+ diff-minutes " minute" (if (> diff-minutes 1) "s" "") " ago")) + (t "Just now"))))) (defun format-duration (seconds) (let ((hours (ps:chain -math (floor (/ seconds 3600)))) @@ -297,8 +308,13 @@ (ps:chain activity (for-each (lambda (day) (let* ((count (or (ps:@ day track_count) 0)) (height (ps:chain -math (round (* (/ count max-count) 100)))) - (date-str (ps:@ day day)) - (date-parts (ps:chain date-str (split "-"))) + (date-raw (ps:@ day day)) + (date-str (if (and date-raw (ps:@ date-raw to-string)) + (ps:chain date-raw (to-string)) + (+ "" date-raw))) + (date-parts (if (and date-str (ps:@ date-str split)) + (ps:chain date-str (split "-")) + (array))) (day-label (if (> (ps:@ date-parts length) 2) (ps:getprop date-parts 2) ""))) @@ -345,10 +361,36 @@ (load-activity-chart) (load-avatar)) + ;; Track offset for pagination + (defvar *recent-tracks-offset* 3) + ;; Action functions (defun load-more-recent-tracks () (ps:chain console (log "Loading more recent tracks...")) - (show-message "Loading more tracks..." "info")) + (ps:chain + (fetch (+ "/api/asteroid/user/recent-tracks?limit=10&offset=" *recent-tracks-offset*)) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result)) + (container (ps:chain document (get-element-by-id "recent-tracks-list")))) + (when container + (if (and (= (ps:@ data status) "success") + (ps:@ data tracks) + (> (ps:@ data tracks length) 0)) + (progn + (ps:chain (ps:@ data tracks) (for-each (lambda (track) + (let ((item (ps:chain document (create-element "div")))) + (setf (ps:@ item class-name) "track-item") + (setf (ps:@ item inner-h-t-m-l) + (+ "" (or (ps:@ track title) "Unknown") "" + "" (or (ps:@ track played_at) "") "")) + (ps:chain container (append-child item)))))) + (setf *recent-tracks-offset* (+ *recent-tracks-offset* (ps:@ data tracks length))) + (show-message (+ "Loaded " (ps:@ data tracks length) " more tracks") "success")) + (show-message "No more tracks to load" "info")))))) + (catch (lambda (error) + (ps:chain console (error "Error loading more tracks:" error)) + (show-message "Error loading tracks" "error"))))) (defun edit-profile () (ps:chain console (log "Edit profile clicked")) diff --git a/static/asteroid.css b/static/asteroid.css index cf58547..77b3c5a 100644 --- a/static/asteroid.css +++ b/static/asteroid.css @@ -1754,6 +1754,94 @@ body.popout-body .status-mini{ opacity: 1; } +.request-panel{ + background: rgba(0, 255, 0, 0.05); + border: 1px solid #333; + border-radius: 8px; + padding: 20px; + margin-top: 20px; +} + +.request-description{ + color: #888; + margin-bottom: 15px; +} + +.request-form{ + display: flex; + flex-direction: column; + gap: 10px; +} + +.request-input{ + background: #1a1a1a; + border: 1px solid #333; + border-radius: 4px; + padding: 10px; + color: #00cc00; + font-size: 1em; +} + +.request-input:focus{ + border-color: #00cc00; + outline: none; +} + +.request-status{ + padding: 10px; + border-radius: 4px; + margin-top: 10px; + text-align: center; +} + +.request-status.success{ + background: rgba(0, 255, 0, 0.2); + color: #00ff00; +} + +.request-status.error{ + background: rgba(255, 0, 0, 0.2); + color: #ff6b6b; +} + +.request-status.info{ + background: rgba(0, 150, 255, 0.2); + color: #66b3ff; +} + +.recent-requests{ + margin-top: 20px; + border-top: 1px solid #333; + padding-top: 15px; +} + +.recent-requests h4{ + color: #888; + margin-bottom: 10px; +} + +.request-item{ + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid #222; +} + +.request-title{ + color: #00cc00; +} + +.request-by{ + color: #666; + font-size: 0.9em; +} + +.no-requests{ + color: #666; + font-style: italic; +} + .activity-chart{ padding: 15px; } diff --git a/static/asteroid.lass b/static/asteroid.lass index 5bd0449..1c5b0ac 100644 --- a/static/asteroid.lass +++ b/static/asteroid.lass @@ -1401,6 +1401,80 @@ (.avatar-overlay :opacity "1")) + ;; Track Request styling + (.request-panel + :background "rgba(0, 255, 0, 0.05)" + :border "1px solid #333" + :border-radius "8px" + :padding "20px" + :margin-top "20px") + + (.request-description + :color "#888" + :margin-bottom "15px") + + (.request-form + :display "flex" + :flex-direction "column" + :gap "10px") + + (.request-input + :background "#1a1a1a" + :border "1px solid #333" + :border-radius "4px" + :padding "10px" + :color "#00cc00" + :font-size "1em") + + ((:and .request-input :focus) + :border-color "#00cc00" + :outline "none") + + (.request-status + :padding "10px" + :border-radius "4px" + :margin-top "10px" + :text-align "center") + + ((:and .request-status .success) + :background "rgba(0, 255, 0, 0.2)" + :color "#00ff00") + + ((:and .request-status .error) + :background "rgba(255, 0, 0, 0.2)" + :color "#ff6b6b") + + ((:and .request-status .info) + :background "rgba(0, 150, 255, 0.2)" + :color "#66b3ff") + + (.recent-requests + :margin-top "20px" + :border-top "1px solid #333" + :padding-top "15px" + + (h4 + :color "#888" + :margin-bottom "10px")) + + (.request-item + :display "flex" + :justify-content "space-between" + :align-items "center" + :padding "8px 0" + :border-bottom "1px solid #222") + + (.request-title + :color "#00cc00") + + (.request-by + :color "#666" + :font-size "0.9em") + + (.no-requests + :color "#666" + :font-style "italic") + ;; Activity chart styling (.activity-chart :padding "15px" diff --git a/static/avatars/5.png b/static/avatars/5.png new file mode 100644 index 0000000000000000000000000000000000000000..95e48be0d3a9690b779dde0ac69c973826007818 GIT binary patch literal 14757 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 literal 0 HcmV?d00001 diff --git a/template/front-page.ctml b/template/front-page.ctml index 77994cd..a34913c 100644 --- a/template/front-page.ctml +++ b/template/front-page.ctml @@ -125,6 +125,26 @@

Loading...

+ + +
+

🎵 Request a Track

+

Want to hear something specific? Submit a request!

+
+ + + +
+ + + +
+

Recently Played Requests

+
+

No recent requests

+
+
+