From 873b2903ccf9a90ce4a1d13ddb64780ab529fbd3 Mon Sep 17 00:00:00 2001 From: Brian O'Reilly Date: Thu, 11 Sep 2025 10:33:26 -0400 Subject: [PATCH] refactor glenn's database feature into discrete files. --- asteroid.asd | 2 + asteroid.lisp | 153 +----------------- database.lisp | 25 +++ static/asteroid.css | 366 ++++++++++++++++++++++++++++++++++++++++++++ stream-media.lisp | 130 ++++++++++++++++ 5 files changed, 525 insertions(+), 151 deletions(-) create mode 100644 database.lisp create mode 100644 stream-media.lisp diff --git a/asteroid.asd b/asteroid.asd index 3ac3b2f..9f9a70a 100644 --- a/asteroid.asd +++ b/asteroid.asd @@ -21,4 +21,6 @@ :pathname "./" :components ((:file "app-utils") (:file "module") + (:file "database") + (:file "stream-media") (:file "asteroid"))) diff --git a/asteroid.lisp b/asteroid.lisp index 068d717..4345a0c 100644 --- a/asteroid.lisp +++ b/asteroid.lisp @@ -12,163 +12,14 @@ (:use #:cl #:radiance #:lass #:r-clip) (:domain "asteroid")) -;; Configuration +;; Configuration -- this will be refactored to a dedicated +;; configuration logic. Probably using 'ubiquity (defparameter *server-port* 8080) (defparameter *music-library-path* (merge-pathnames "music/library/" (asdf:system-source-directory :asteroid))) (defparameter *supported-formats* '("mp3" "flac" "ogg" "wav")) -;; Database initialization - must be in db:connected trigger -(define-trigger db:connected () - "Initialize database collections when database connects" - (unless (db:collection-exists-p "tracks") - (db:create "tracks" '((title :text) - (artist :text) - (album :text) - (duration :integer) - (file-path :text) - (format :text) - (bitrate :integer) - (added-date :integer) - (play-count :integer)))) - - (unless (db:collection-exists-p "playlists") - (db:create "playlists" '((name :text) - (description :text) - (created-date :integer) - (track-ids :text)))) - - (format t "Database collections initialized~%")) - -;; Music library scanning functions -(defun supported-audio-file-p (pathname) - "Check if file has a supported audio format extension" - (let ((extension (string-downcase (pathname-type pathname)))) - (member extension *supported-formats* :test #'string=))) - -(defun scan-directory-for-music (directory) - "Recursively scan directory for supported audio files" - (when (cl-fad:directory-exists-p directory) - (remove-if-not #'supported-audio-file-p - (cl-fad:list-directory directory :follow-symlinks nil)))) - -(defun extract-metadata-with-taglib (file-path) - "Extract metadata using taglib library" - (handler-case - (let* ((audio-file (audio-streams:open-audio-file (namestring file-path))) - (file-info (sb-posix:stat file-path)) - (format (string-downcase (pathname-type file-path)))) - (list :file-path (namestring file-path) - :format format - :size (sb-posix:stat-size file-info) - :modified (sb-posix:stat-mtime file-info) - :title (or (abstract-tag:title audio-file) (pathname-name file-path)) - :artist (or (abstract-tag:artist audio-file) "Unknown Artist") - :album (or (abstract-tag:album audio-file) "Unknown Album") - :duration (or (and (slot-exists-p audio-file 'audio-streams::duration) - (slot-boundp audio-file 'audio-streams::duration) - (round (audio-streams::duration audio-file))) - 0) - :bitrate (or (and (slot-exists-p audio-file 'audio-streams::bit-rate) - (slot-boundp audio-file 'audio-streams::bit-rate) - (round (audio-streams::bit-rate audio-file))) - 0))) - (error (e) - (format t "Warning: Could not extract metadata from ~a: ~a~%" file-path e) - ;; Fallback to basic file metadata - (extract-basic-metadata file-path)))) - -(defun extract-basic-metadata (file-path) - "Extract basic file metadata (fallback when taglib fails)" - (when (probe-file file-path) - (let ((file-info (sb-posix:stat file-path))) - (list :file-path (namestring file-path) - :format (string-downcase (pathname-type file-path)) - :size (sb-posix:stat-size file-info) - :modified (sb-posix:stat-mtime file-info) - :title (pathname-name file-path) - :artist "Unknown Artist" - :album "Unknown Album" - :duration 0 - :bitrate 0)))) - -(defun insert-track-to-database (metadata) - "Insert track metadata into database" - (db:insert "tracks" - (list (list "title" (getf metadata :title)) - (list "artist" (getf metadata :artist)) - (list "album" (getf metadata :album)) - (list "duration" (getf metadata :duration)) - (list "file-path" (getf metadata :file-path)) - (list "format" (getf metadata :format)) - (list "bitrate" (getf metadata :bitrate)) - (list "added-date" (local-time:timestamp-to-unix (local-time:now))) - (list "play-count" 0)))) - -(defun scan-music-library (&optional (directory *music-library-path*)) - "Scan music library directory and add tracks to database" - (format t "Scanning music library: ~a~%" directory) - (let ((audio-files (scan-directory-for-music directory)) - (added-count 0)) - (dolist (file audio-files) - (let ((metadata (extract-metadata-with-taglib file))) - (when metadata - (handler-case - (progn - (insert-track-to-database metadata) - (incf added-count) - (format t "Added: ~a~%" (getf metadata :file-path))) - (error (e) - (format t "Error adding ~a: ~a~%" file e)))))) - (format t "Library scan complete. Added ~a tracks.~%" added-count) - added-count)) - -;; Initialize music directory structure -(defun ensure-music-directories () - "Create music directory structure if it doesn't exist" - (let ((base-dir (merge-pathnames "music/" (asdf:system-source-directory :asteroid)))) - (ensure-directories-exist (merge-pathnames "library/" base-dir)) - (ensure-directories-exist (merge-pathnames "incoming/" base-dir)) - (ensure-directories-exist (merge-pathnames "temp/" base-dir)) - (format t "Music directories initialized at ~a~%" base-dir))) - -;; Simple file copy endpoint for manual uploads -(define-page copy-files #@"/admin/copy-files" () - "Copy files from incoming directory to library" - (handler-case - (let ((incoming-dir (merge-pathnames "music/incoming/" - (asdf:system-source-directory :asteroid))) - (library-dir (merge-pathnames "music/library/" - (asdf:system-source-directory :asteroid))) - (files-copied 0)) - (ensure-directories-exist incoming-dir) - (ensure-directories-exist library-dir) - - ;; Process all files in incoming directory - (dolist (file (directory (merge-pathnames "*.*" incoming-dir))) - (when (probe-file file) - (let* ((filename (file-namestring file)) - (file-extension (string-downcase (or (pathname-type file) ""))) - (target-path (merge-pathnames filename library-dir))) - (when (member file-extension *supported-formats* :test #'string=) - (alexandria:copy-file file target-path) - (delete-file file) - (incf files-copied) - ;; Extract metadata and add to database - (let ((metadata (extract-metadata-with-taglib target-path))) - (insert-track-to-database metadata)))))) - - (setf (radiance:header "Content-Type") "application/json") - (cl-json:encode-json-to-string - `(("status" . "success") - ("message" . ,(format nil "Copied ~d files to library" files-copied)) - ("files-copied" . ,files-copied)))) - (error (e) - (setf (radiance:header "Content-Type") "application/json") - (cl-json:encode-json-to-string - `(("status" . "error") - ("message" . ,(format nil "Copy failed: ~a" e))))))) ;; API Routes (define-page admin-scan-library #@"/admin/scan-library" () diff --git a/database.lisp b/database.lisp new file mode 100644 index 0000000..30be8c5 --- /dev/null +++ b/database.lisp @@ -0,0 +1,25 @@ +(in-package :asteroid) + +;; Database initialization - must be in db:connected trigger because +;; the system could load before the database is ready. + +(define-trigger db:connected () + "Initialize database collections when database connects" + (unless (db:collection-exists-p "tracks") + (db:create "tracks" '((title :text) + (artist :text) + (album :text) + (duration :integer) + (file-path :text) + (format :text) + (bitrate :integer) + (added-date :integer) + (play-count :integer)))) + + (unless (db:collection-exists-p "playlists") + (db:create "playlists" '((name :text) + (description :text) + (created-date :integer) + (track-ids :text)))) + + (format t "Database collections initialized~%")) diff --git a/static/asteroid.css b/static/asteroid.css index bf419e4..66e57a6 100644 --- a/static/asteroid.css +++ b/static/asteroid.css @@ -120,6 +120,372 @@ body .player .controls button{ font-size: 1.2em; } +body .player-section{ + background: #1a1a1a; + padding: 25px; + border: 1px solid #333; + margin: 20px 0; + border-radius: 5px; +} + +body .track-browser{ + margin: 15px 0; +} + +body .search-input{ + width: 100%; + padding: 12px; + background: #0a0a0a; + color: #00ff00; + border: 1px solid #333; + font-family: Courier New, monospace; + font-size: 14px; + margin-bottom: 15px; +} + +body .track-list{ + max-height: 400px; + overflow-y: auto; + border: 1px solid #333; + background: #0a0a0a; +} + +body .track-item{ + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 15px; + border-bottom: 1px solid #333; + -moz-transition: background-color 0.2s; + -o-transition: background-color 0.2s; + -webkit-transition: background-color 0.2s; + -ms-transition: background-color 0.2s; + transition: background-color 0.2s; +} + +body .track-item :hover{ + background: #1a1a1a; +} + +body .track-info{ + flex: 1; +} + +body .track-info .track-title{ + color: #00ff00; + font-weight: bold; + margin-bottom: 4px; +} + +body .track-info .track-meta{ + color: #888; + font-size: 0.9em; +} + +body .track-actions{ + display: flex; + gap: 8px; +} + +body .audio-player{ + text-align: center; +} + +body .track-art{ + font-size: 3em; + margin-right: 20px; + color: #ff6600; +} + + + +body .track-details .track-title{ + font-size: 1.4em; + color: #00ff00; + margin-bottom: 5px; +} + +body .track-details .track-artist{ + font-size: 1.1em; + color: #ff6600; + margin-bottom: 3px; +} + +body .track-details .track-album{ + color: #888; +} + +body .player-controls{ + margin: 20px 0; + display: flex; + justify-content: center; + gap: 10px; + flex-wrap: wrap; +} + +body .player-info{ + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 15px; + padding: 10px; + background: #0a0a0a; + border: 1px solid #333; + border-radius: 3px; +} + +body .time-display{ + color: #00ff00; + font-family: Courier New, monospace; +} + +body .volume-control{ + display: flex; + align-items: center; + gap: 10px; +} + +body .volume-control label{ + color: #ff6600; +} + +body .volume-slider{ + width: 100px; + height: 5px; + background: #333; + outline: none; + border-radius: 3px; +} + +body .btn{ + background: #333; + color: #00ff00; + border: 1px solid #555; + padding: 8px 16px; + margin: 3px; + cursor: pointer; + font-family: Courier New, monospace; + font-size: 14px; + border-radius: 3px; + -moz-transition: all 0.2s; + -o-transition: all 0.2s; + -webkit-transition: all 0.2s; + -ms-transition: all 0.2s; + transition: all 0.2s; +} + +body .btn :hover{ + background: #555; + border-color: #777; +} + +body .btn-primary{ + background: #0066cc; + border-color: #0088ff; +} + +body .btn-primary :hover{ + background: #0088ff; +} + +body .btn-success{ + background: #006600; + border-color: #00aa00; +} + +body .btn-success :hover{ + background: #00aa00; +} + +body .btn-danger{ + background: #cc0000; + border-color: #ff0000; +} + +body .btn-danger :hover{ + background: #ff0000; +} + +body .btn-info{ + background: #006666; + border-color: #00aaaa; +} + +body .btn-info :hover{ + background: #00aaaa; +} + +body .btn-warning{ + background: #cc6600; + border-color: #ff8800; +} + +body .btn-warning :hover{ + background: #ff8800; +} + +body .btn-secondary{ + background: #444; + border-color: #666; +} + +body .btn-secondary :hover{ + background: #666; +} + +body .btn-sm{ + padding: 4px 8px; + font-size: 12px; +} + +body .btn.active{ + background: #ff6600; + border-color: #ff8800; + color: #000; +} + +body .playlist-controls{ + margin-bottom: 15px; + display: flex; + gap: 10px; + align-items: center; +} + +body .playlist-input{ + flex: 1; + padding: 8px 12px; + background: #0a0a0a; + color: #00ff00; + border: 1px solid #333; + font-family: Courier New, monospace; +} + +body .playlist-list{ + border: 1px solid #333; + background: #0a0a0a; + min-height: 100px; + padding: 10px; +} + +body .queue-controls{ + margin-bottom: 15px; + display: flex; + gap: 10px; +} + +body .play-queue{ + border: 1px solid #333; + background: #0a0a0a; + min-height: 150px; + max-height: 300px; + overflow-y: auto; + padding: 10px; +} + +body .queue-item{ + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 10px; + border-bottom: 1px solid #333; + margin-bottom: 5px; +} + +body .queue-item :last-child{ + border-bottom: none; + margin-bottom: 0; +} + +body .empty-queue{ + text-align: center; + color: #666; + padding: 20px; + font-style: italic; +} + +body .no-tracks{ + text-align: center; + color: #666; + padding: 20px; + font-style: italic; +} + +body .no-playlists{ + text-align: center; + color: #666; + padding: 20px; + font-style: italic; +} + +body .loading{ + text-align: center; + color: #ff6600; + padding: 20px; +} + +body .error{ + text-align: center; + color: #ff0000; + padding: 20px; + font-weight: bold; +} + +body .upload-section{ + margin: 20px 0; + padding: 20px; + background: #0a0a0a; + border: 1px solid #333; + border-radius: 5px; +} + +body .upload-controls{ + display: flex; + gap: 15px; + align-items: center; + margin-bottom: 15px; +} + +body .upload-info{ + color: #888; + font-size: 0.9em; +} + +body .upload-progress{ + margin-top: 10px; + padding: 10px; + background: #1a1a1a; + border: 1px solid #333; + border-radius: 3px; +} + +body .progress-bar{ + height: 20px; + background: #ff6600; + border-radius: 3px; + -moz-transition: width 0.3s ease; + -o-transition: width 0.3s ease; + -webkit-transition: width 0.3s ease; + -ms-transition: width 0.3s ease; + transition: width 0.3s ease; + width: 0%; +} + +body .progress-text{ + display: block; + margin-top: 5px; + color: #00ff00; + font-size: 0.9em; +} + +body input{ + padding: 8px 12px; + background: #1a1a1a; + color: #00ff00; + border: 1px solid #333; + border-radius: 3px; + font-family: Courier New, monospace; +} + body body.player-page{ text-align: center; } \ No newline at end of file diff --git a/stream-media.lisp b/stream-media.lisp new file mode 100644 index 0000000..c1145b6 --- /dev/null +++ b/stream-media.lisp @@ -0,0 +1,130 @@ +(in-package :asteroid) + +;; Music library scanning functions +(defun supported-audio-file-p (pathname) + "Check if file has a supported audio format extension" + (let ((extension (string-downcase (pathname-type pathname)))) + (member extension *supported-formats* :test #'string=))) + +(defun scan-directory-for-music (directory) + "Recursively scan directory for supported audio files" + (when (cl-fad:directory-exists-p directory) + (remove-if-not #'supported-audio-file-p + (cl-fad:list-directory directory :follow-symlinks nil)))) + +(defun extract-metadata-with-taglib (file-path) + "Extract metadata using taglib library" + (handler-case + (let* ((audio-file (audio-streams:open-audio-file (namestring file-path))) + (file-info (sb-posix:stat file-path)) + (format (string-downcase (pathname-type file-path)))) + (list :file-path (namestring file-path) + :format format + :size (sb-posix:stat-size file-info) + :modified (sb-posix:stat-mtime file-info) + :title (or (abstract-tag:title audio-file) (pathname-name file-path)) + :artist (or (abstract-tag:artist audio-file) "Unknown Artist") + :album (or (abstract-tag:album audio-file) "Unknown Album") + :duration (or (and (slot-exists-p audio-file 'audio-streams::duration) + (slot-boundp audio-file 'audio-streams::duration) + (round (audio-streams::duration audio-file))) + 0) + :bitrate (or (and (slot-exists-p audio-file 'audio-streams::bit-rate) + (slot-boundp audio-file 'audio-streams::bit-rate) + (round (audio-streams::bit-rate audio-file))) + 0))) + (error (e) + (format t "Warning: Could not extract metadata from ~a: ~a~%" file-path e) + ;; Fallback to basic file metadata + (extract-basic-metadata file-path)))) + +(defun extract-basic-metadata (file-path) + "Extract basic file metadata (fallback when taglib fails)" + (when (probe-file file-path) + (let ((file-info (sb-posix:stat file-path))) + (list :file-path (namestring file-path) + :format (string-downcase (pathname-type file-path)) + :size (sb-posix:stat-size file-info) + :modified (sb-posix:stat-mtime file-info) + :title (pathname-name file-path) + :artist "Unknown Artist" + :album "Unknown Album" + :duration 0 + :bitrate 0)))) + +(defun insert-track-to-database (metadata) + "Insert track metadata into database" + (db:insert "tracks" + (list (list "title" (getf metadata :title)) + (list "artist" (getf metadata :artist)) + (list "album" (getf metadata :album)) + (list "duration" (getf metadata :duration)) + (list "file-path" (getf metadata :file-path)) + (list "format" (getf metadata :format)) + (list "bitrate" (getf metadata :bitrate)) + (list "added-date" (local-time:timestamp-to-unix (local-time:now))) + (list "play-count" 0)))) + +(defun scan-music-library (&optional (directory *music-library-path*)) + "Scan music library directory and add tracks to database" + (format t "Scanning music library: ~a~%" directory) + (let ((audio-files (scan-directory-for-music directory)) + (added-count 0)) + (dolist (file audio-files) + (let ((metadata (extract-metadata-with-taglib file))) + (when metadata + (handler-case + (progn + (insert-track-to-database metadata) + (incf added-count) + (format t "Added: ~a~%" (getf metadata :file-path))) + (error (e) + (format t "Error adding ~a: ~a~%" file e)))))) + (format t "Library scan complete. Added ~a tracks.~%" added-count) + added-count)) + +;; Initialize music directory structure +(defun ensure-music-directories () + "Create music directory structure if it doesn't exist" + (let ((base-dir (merge-pathnames "music/" (asdf:system-source-directory :asteroid)))) + (ensure-directories-exist (merge-pathnames "library/" base-dir)) + (ensure-directories-exist (merge-pathnames "incoming/" base-dir)) + (ensure-directories-exist (merge-pathnames "temp/" base-dir)) + (format t "Music directories initialized at ~a~%" base-dir))) + +;; Simple file copy endpoint for manual uploads +(define-page copy-files #@"/admin/copy-files" () + "Copy files from incoming directory to library" + (handler-case + (let ((incoming-dir (merge-pathnames "music/incoming/" + (asdf:system-source-directory :asteroid))) + (library-dir (merge-pathnames "music/library/" + (asdf:system-source-directory :asteroid))) + (files-copied 0)) + (ensure-directories-exist incoming-dir) + (ensure-directories-exist library-dir) + + ;; Process all files in incoming directory + (dolist (file (directory (merge-pathnames "*.*" incoming-dir))) + (when (probe-file file) + (let* ((filename (file-namestring file)) + (file-extension (string-downcase (or (pathname-type file) ""))) + (target-path (merge-pathnames filename library-dir))) + (when (member file-extension *supported-formats* :test #'string=) + (alexandria:copy-file file target-path) + (delete-file file) + (incf files-copied) + ;; Extract metadata and add to database + (let ((metadata (extract-metadata-with-taglib target-path))) + (insert-track-to-database metadata)))))) + + (setf (radiance:header "Content-Type") "application/json") + (cl-json:encode-json-to-string + `(("status" . "success") + ("message" . ,(format nil "Copied ~d files to library" files-copied)) + ("files-copied" . ,files-copied)))) + (error (e) + (setf (radiance:header "Content-Type") "application/json") + (cl-json:encode-json-to-string + `(("status" . "error") + ("message" . ,(format nil "Copy failed: ~a" e)))))))