diff options
| author | Tim <contact@bytim.eu> | 2025-11-01 17:36:48 +0100 |
|---|---|---|
| committer | Tim <contact@bytim.eu> | 2025-11-01 17:36:48 +0100 |
| commit | 32ee50f0f6f53f8d5dea3bf159be3f65974c4b7b (patch) | |
| tree | c317610f1a275936baefae2efeab360befbb9950 /src | |
Initial commit
Diffstat (limited to 'src')
| -rw-r--r-- | src/dionysus/blacklist.clj | 36 | ||||
| -rw-r--r-- | src/dionysus/core.clj | 9 | ||||
| -rw-r--r-- | src/dionysus/spotify.clj | 70 | ||||
| -rw-r--r-- | src/dionysus/web/admin.clj | 45 | ||||
| -rw-r--r-- | src/dionysus/web/admin/home.clj | 32 | ||||
| -rw-r--r-- | src/dionysus/web/admin/home/blacklist.clj | 106 | ||||
| -rw-r--r-- | src/dionysus/web/admin/home/server_settings.clj | 25 | ||||
| -rw-r--r-- | src/dionysus/web/enduser.clj | 35 | ||||
| -rw-r--r-- | src/dionysus/web/enduser/home.clj | 53 | ||||
| -rw-r--r-- | src/dionysus/web/enduser/home/search.clj | 67 | ||||
| -rw-r--r-- | src/dionysus/web/utils.clj | 22 |
11 files changed, 500 insertions, 0 deletions
diff --git a/src/dionysus/blacklist.clj b/src/dionysus/blacklist.clj new file mode 100644 index 0000000..6d94ac3 --- /dev/null +++ b/src/dionysus/blacklist.clj @@ -0,0 +1,36 @@ +(ns dionysus.blacklist + (:require [duratom.core :as duratom] + [dionysus.spotify :as dspotify])) + +(def blacklist (duratom/duratom :local-file + :file-path "./blacklist.edn" + :init [])) + +(defn add [share-url] + (when-let [parsed-url (dspotify/parse-share-url share-url)] + (swap! blacklist conj parsed-url))) + +(defn change [index new-share-url] + (when-let [parsed-url (dspotify/parse-share-url new-share-url)] + (swap! blacklist assoc index parsed-url))) + +(defn delete [index] + (when (and (not (neg? index)) + (< index (.length @blacklist))) + (swap! blacklist (fn [coll] + (->> coll + (keep-indexed #(when (not= %1 index) %2)) + vec))))) + +(defn on-blacklist? [track-id] + (let [artist-ids (->> (dspotify/get-track! track-id) + :artists + (map :id))] + (or (->> @blacklist + (filter #(and (= (:type %) "track") (= (:id %) track-id))) + seq + some?) + (->> @blacklist + (filter #(and (= (:type %) "artist") (not= -1 (.indexOf artist-ids (:id %))))) + seq + some?)))) diff --git a/src/dionysus/core.clj b/src/dionysus/core.clj new file mode 100644 index 0000000..9c186ea --- /dev/null +++ b/src/dionysus/core.clj @@ -0,0 +1,9 @@ +(ns dionysus.core + (:require [dionysus.web.admin :as dwadmin] + [dionysus.web.enduser :as dwenduser]) + (:gen-class)) + +(defn -main [& args] + (dwadmin/start-server!) + (dwenduser/start-server!)) +#_(-main) diff --git a/src/dionysus/spotify.clj b/src/dionysus/spotify.clj new file mode 100644 index 0000000..531c825 --- /dev/null +++ b/src/dionysus/spotify.clj @@ -0,0 +1,70 @@ +(ns dionysus.spotify + (:require [clj-spotify.core :as spotify-api] + [hato.client :as hc] + [dotenv :as env] + [overtone.at-at :as at]) + (:import (java.util Date Calendar))) + +(def token (atom nil)) + +(defn search! [query] (spotify-api/search {:q query :type "track"} (:token @token))) + +(defn add-item-to-queue! [uri] ((spotify-api/api-post "me/player/queue" {:query-params [:uri]}) + {:uri uri} (:token @token))) + +(defn get-current-track! [] (spotify-api/get-users-currently-playing-track {} (:token @token))) +#_(:item (get-current-track!)) + +(defn get-track! [id] (spotify-api/get-a-track {:id id} (:token @token))) + +(defn get-artist! [id] (spotify-api/get-an-artist {:id id} (:token @token))) + +(defn parse-share-url [url] + (when-let [parsed (->> url + (re-seq #"https:\/\/open\.spotify\.com\/intl-de\/(track|artist)\/([0-9A-z]*)") + first)] + {:url (first parsed) + :type (second parsed) + :id (last parsed)})) + +(defn time-to-refresh-token? [] + (when-let [t @token] + (-> t + :expires + .getTime + (- (.getTime (Date.))) + (< 120000) ; Is token valid for under two minutes? + ))) + +(def ^:private http-client (delay (hc/build-http-client {}))) + +(defn- update-token! [refresh-resp-body] + (swap! token assoc :token (get refresh-resp-body "access_token")) + (swap! token assoc :expires (let [calendar-instance (Calendar/getInstance)] + (.setTime calendar-instance (Date.)) + (.add calendar-instance Calendar/SECOND (get refresh-resp-body "expires_in")) + (.getTime calendar-instance)))) + +(defn- request-new-token! [] + (hc/post "https://accounts.spotify.com/api/token" + {:http-client @http-client + :throw-exceptions? false + :content-type "application/x-www-form-urlencoded" + :form-params {"grant_type" "refresh_token" + "refresh_token" (:refresh-token @token)} + :basic-auth {:user (env/env "SPOTIFY_CLIENT_ID") + :pass (env/env "SPOTIFY_CLIENT_SECRET")} + :as :json-string-keys})) + +(defn- refresh-token! [] + (when (time-to-refresh-token?) + (let [resp (request-new-token!)] + (if (= (:status resp) 200) + (do (update-token! (:body resp)) + (println "refreshed token")) + (println "refreshing token failed"))))) + +(def ^:private at-pool (at/mk-pool)) + +(defn start-token-watcher! [] + (at/every 60000 refresh-token! at-pool)) diff --git a/src/dionysus/web/admin.clj b/src/dionysus/web/admin.clj new file mode 100644 index 0000000..20c7409 --- /dev/null +++ b/src/dionysus/web/admin.clj @@ -0,0 +1,45 @@ +(ns dionysus.web.admin + (:require [reitit.ring :as rring] + [org.httpkit.server :as http-server] + [ring.middleware.oauth2 :as rmoauth2] + [dotenv :as env] + [ring.middleware.params :as rmparams] + [ring.middleware.session :as rmsessions] + + [dionysus.web.admin.home :as dwahome] + [dionysus.web.admin.home.blacklist :as dwahblacklist] + [dionysus.web.admin.home.server-settings :as dwahserver-settings])) + +(def ^:private routes + [["/" {:get {:handler dwahome/handle}}] + ["/blacklist" + ["/" {:get {:handler dwahblacklist/handle}}] + ["/add" {:post {:handler dwahblacklist/handle-add}}] + ["/change/:index" {:post {:handler dwahblacklist/handle-change}}] + ["/delete/:index" {:delete {:handler dwahblacklist/handle-delete}}]] + ["/server-settings" {:post {:handler dwahserver-settings/handle}}] + ["/assets/*" (rring/create-resource-handler)]]) + +(def ^:private handler + (-> routes + rring/router + (rring/ring-handler (rring/redirect-trailing-slash-handler)) + (rmoauth2/wrap-oauth2 {:spotify {:authorize-uri "https://accounts.spotify.com/authorize" + :access-token-uri "https://accounts.spotify.com/api/token" + :client-id (env/env "SPOTIFY_CLIENT_ID") + :client-secret (env/env "SPOTIFY_CLIENT_SECRET") + :scopes ["user-read-private" + "user-modify-playback-state" + "user-read-currently-playing"] + :launch-uri "/auth" + :redirect-uri "/callback" + :landing-uri "/"}}) + rmsessions/wrap-session + rmparams/wrap-params)) + +(def ^:private stop-fn (atom nil)) + +(defn start-server! [] + (when (fn? @stop-fn) (@stop-fn)) + (reset! stop-fn (http-server/run-server handler {:port (or (env/env "ADMIN_PORT") 8081)}))) +#_(start-server!) diff --git a/src/dionysus/web/admin/home.clj b/src/dionysus/web/admin/home.clj new file mode 100644 index 0000000..a06a4ff --- /dev/null +++ b/src/dionysus/web/admin/home.clj @@ -0,0 +1,32 @@ +(ns dionysus.web.admin.home + (:require [dionysus.spotify :as dspotify] + [dionysus.web.utils :as dwutils] + [ring.util.response :as ruresp] + [dionysus.web.admin.home.blacklist :as dwahblacklist] + [dionysus.web.admin.home.server-settings :as dwahserver-settings])) + +(defn- render [_req] + (let [title (str @dwutils/title " - ADMIN")] + (dwutils/render-page title + [:div {:class "text"} + [:h1 title] + (dwahserver-settings/render-server-settings) + [:h2 "Blacklist"] + (dwahblacklist/render-blacklist-table nil)] + [:script {:src "/assets/htmx.js"}]))) + +(defn handle [req] + (let [access-token (get-in req [:oauth2/access-tokens :spotify]) + page-resp (delay (-> req + render + ruresp/response))] + (cond + (and (nil? access-token) (nil? @dspotify/token)) + (ruresp/redirect "/auth") + + (nil? @dspotify/token) + (do (reset! dspotify/token access-token) + @page-resp) + + :else + @page-resp))) diff --git a/src/dionysus/web/admin/home/blacklist.clj b/src/dionysus/web/admin/home/blacklist.clj new file mode 100644 index 0000000..81ac39d --- /dev/null +++ b/src/dionysus/web/admin/home/blacklist.clj @@ -0,0 +1,106 @@ +(ns dionysus.web.admin.home.blacklist + (:require [clojure.string :as cstr] + [dionysus.blacklist :as dblacklist] + [dionysus.spotify :as dspotify] + [dionysus.web.utils :as dwutils] + [ring.util.response :as ruresp])) + +(defn- blacklist-table-item [index edited-item] + (let [item (nth @dblacklist/blacklist index) + editor? (= (str index) edited-item)] + [:tr + [:td (when-not editor? (cstr/upper-case (:type item)))] + [:td (if editor? + [:input {:type :text :name "link" :value (:url item)}] + [:a {:href (:url item) :target "_blank"} + (condp = (:type item) + "track" + (-> (:id item) + dspotify/get-track! + :name) + + "artist" + (-> (:id item) + dspotify/get-artist! + :name) + + nil)])] + (if editor? + [:td + [:img {:src "/assets/icons/check.svg" + :class "icon" + :hx-trigger "click" + :hx-post (str "/blacklist/change/" index) + :hx-target "#blacklist" + :hx-swap "outerHTML" + :hx-include "previous input"}] + [:img {:src "/assets/icons/trash.svg" + :class "icon" + :hx-trigger "click" + :hx-delete (str "/blacklist/delete/" index) + :hx-target "#blacklist" + :hx-swap "outerHTML"}]] + [:td + [:img {:src "/assets/icons/edit.svg" + :class "icon" + :hx-trigger "click" + :hx-get (str "/blacklist?editing=" index) + :hx-target "#blacklist" + :hx-swap "outerHTML"}]])])) + +(defn render-blacklist-table [edited-item] + [:table {:id "blacklist"} + [:thead + [:tr + [:th "Typ"] + [:th "Link"] + [:th "Aktionen"]]] + [:tbody + (for [index (-> @dblacklist/blacklist + .length + range)] + (blacklist-table-item index edited-item)) + [:tr + [:td] + [:td [:input {:type :text + :placeholder "Link to track or artist" + :name "link"}]] + [:td [:img {:src "/assets/icons/plus.svg" + :class "icon" + :hx-trigger "click" + :hx-post "/blacklist/add" + :hx-target "#blacklist" + :hx-swap "outerHTML" + :hx-include "previous input"}]]]]]) + +(defn handle [req] + (-> (get-in req [:query-params "editing"]) + render-blacklist-table + dwutils/render-html + ruresp/response)) + +(defn handle-add [req] + (-> req + (get-in [:form-params "link"]) + dblacklist/add) + (handle {})) + +(defn- valid-index? [s] + (and (string? s) (string? (re-matches #"[0-9][0-9]*" s)))) + +(defn handle-change [req] + (let [index (get-in req [:path-params :index]) + link (get-in req [:form-params "link"])] + (if (and (valid-index? index) (string? link)) + (do (-> index + Integer/parseInt + (dblacklist/change link)) + (handle {})) + (ruresp/bad-request "Bad request.")))) + +(defn handle-delete [req] + (let [index (get-in req [:path-params :index])] + (if (valid-index? index) + (do (dblacklist/delete (Integer/parseInt index)) + (handle {})) + (ruresp/bad-request "Bad request.")))) diff --git a/src/dionysus/web/admin/home/server_settings.clj b/src/dionysus/web/admin/home/server_settings.clj new file mode 100644 index 0000000..e4a8d3f --- /dev/null +++ b/src/dionysus/web/admin/home/server_settings.clj @@ -0,0 +1,25 @@ +(ns dionysus.web.admin.home.server-settings + (:require [ring.util.response :as ruresp] + [dionysus.web.utils :as dwutils] + [dionysus.web.enduser :as dwenduser])) + +(defn render-server-settings [] + [:div {:id "server-settings"} + [:input {:type :checkbox + :name "enduser-ui" + :style {:display :inline-block} + :checked (dwenduser/server-runs?) + :hx-trigger "input" + :hx-post "/server-settings" + :hx-swap "outerHTML" + :hx-target "#server-settings"}] + [:p {:style {:display :inline-block + :margin-right "1ch"}} "Gäste UI"]]) + +(defn handle [req] + (if (= (get-in req [:form-params "enduser-ui"]) "on") + (dwenduser/start-server!) + (dwenduser/stop-server!)) + (-> (render-server-settings) + dwutils/render-html + ruresp/response)) diff --git a/src/dionysus/web/enduser.clj b/src/dionysus/web/enduser.clj new file mode 100644 index 0000000..90b661b --- /dev/null +++ b/src/dionysus/web/enduser.clj @@ -0,0 +1,35 @@ +(ns dionysus.web.enduser + (:require [dotenv :as env] + [org.httpkit.server :as http-server] + [reitit.ring :as rring] + + [dionysus.web.enduser.home :as dwehome] + [dionysus.web.enduser.home.search :as dwehsearch] + [ring.middleware.params :as rmparams])) + +(def ^:private routes + [["/" {:get {:handler dwehome/handle}}] + ["/search" + ["/" {:get {:handler dwehsearch/handle-search}}] + ["/add/:uri/" {:post {:handler dwehsearch/handle-search-add}}]] + ["/current-track/" {:get {:handler dwehome/handle-current-track}}] + ["/assets/*" (rring/create-resource-handler)]]) + +(def ^:private handler + (-> routes + rring/router + (rring/ring-handler (rring/redirect-trailing-slash-handler)) + rmparams/wrap-params)) + +(def ^:private stop-fn (atom nil)) +(defn server-runs? [] (fn? @stop-fn)) + +(defn start-server! [] + (when (server-runs?) (@stop-fn)) + (reset! stop-fn (http-server/run-server handler {:port (or (env/env "ENDUSER_PORT") 8080)}))) +#_(start-server!) + +(defn stop-server! [] + (when (server-runs?) + (@stop-fn) + (reset! stop-fn nil))) diff --git a/src/dionysus/web/enduser/home.clj b/src/dionysus/web/enduser/home.clj new file mode 100644 index 0000000..69d8ece --- /dev/null +++ b/src/dionysus/web/enduser/home.clj @@ -0,0 +1,53 @@ +(ns dionysus.web.enduser.home + (:require [dionysus.web.utils :as dwutils] + [ring.util.response :as ruresp] + [dionysus.spotify :as dspotify] + [clojure.string :as cstr] + [dionysus.web.enduser.home.search :as dwehsearch])) + +(defn- render-current-track [_req] + (let [track (:item (dspotify/get-current-track!))] + [:div {:id "current-track" + :class "card" + :hx-swap "outerHTML" + :hx-get "/current-track/" + :hx-trigger "every 10s"} + [:img {:class "album-image" + :src (get-in track [:album :images 0 :url]) + :width "130em"}] + [:div + [:b (:name track)] + [:p (str "Von " (cstr/join ", " (map :name (:artists track))))] + [:a {:href (get-in track [:external_urls :spotify]) + :target "_blank"} "Auf Spotify anschauen"]]])) + +(defn handle-current-track [req] + (-> req + render-current-track + dwutils/render-html + ruresp/response)) + +(defn- render [_req] + (dwutils/render-page (str @dwutils/title " - Home") + [:div {:class "text"} + [:h1 @dwutils/title] + [:p "Füge hier Lieder zur Warteschlange hinzu!"]] + [:input {:id "search-bar" + :type "text" + :placeholder "Suche nach einem Lied:" + :name "query" + :hx-get "/search/" + :hx-swap "innerHTML" + :hx-target "#search-results" + :hx-trigger "change"}] + [:div {:id "search-results"} + (dwehsearch/render-search nil)] + [:h2 {:class "text"} + "Aktuelles Lied"] + (render-current-track nil) + [:script {:src "/assets/htmx.js"}])) + +(defn handle [req] + (-> req + render + ruresp/response)) diff --git a/src/dionysus/web/enduser/home/search.clj b/src/dionysus/web/enduser/home/search.clj new file mode 100644 index 0000000..cea539c --- /dev/null +++ b/src/dionysus/web/enduser/home/search.clj @@ -0,0 +1,67 @@ +(ns dionysus.web.enduser.home.search + (:require [clojure.string :as cstr] + [dionysus.blacklist :as dblacklist] + [dionysus.spotify :as dspotify] + [dionysus.web.utils :as dwutils] + [ring.util.response :as ruresp])) + +(defn- search-item [track] + [:div {:class ["card" "search-item"]} + [:img {:class "album-image" + :src (get-in track [:album :images 0 :url]) + :width "130em"}] + [:div + [:b (:name track)] + [:p (str "Von " (cstr/join ", " (map :name (:artists track))))]] + [:img {:class "track-action-icon" + :src "/assets/icons/plus.svg" + :hx-post (str "/search/add/" (:uri track) "/") + :hx-trigger "click" + :hx-swap "outerHTML" + :hx-target "this" + :height "60em" + :alt "plus icon" + :title "Lied zur Warteschlange hinzufügen"}]]) + +(defn render-search [req] + (let [query (get-in req [:query-params "query"]) + tracks (-> (dspotify/search! query) + (get-in [:tracks :items]) + delay)] + (cond + (cstr/blank? query) + [:i "Nutze die Suchleiste oben, um nach einem Lied zu suchen oder füge den Link zu dem Lied ein."] + + (nil? @tracks) + [:b "An error occurred."] + + :else + (for [track @tracks] + (search-item track))))) + +(defn handle-search [req] + (-> req + render-search + dwutils/render-html + ruresp/response)) + +(defn handle-search-add [req] + (let [uri (get-in req [:path-params :uri]) + on-blacklist? (-> uri + (cstr/split #":") + last + dblacklist/on-blacklist?)] + (when on-blacklist? (dspotify/add-item-to-queue! uri)) + (-> [:img {:class "track-action-icon" + :src (if on-blacklist? + "/assets/icons/warning.svg" + "/assets/icons/check.svg") + :alt (if on-blacklist? + "warn dreieck icon" + "haken icon") + :title (if on-blacklist? + "Lied oder Künstler ist auf der Blacklist" + "Lied wurde zur Warteschlange hinzugefügt") + :height "60em"}] + dwutils/render-html + ruresp/response))) diff --git a/src/dionysus/web/utils.clj b/src/dionysus/web/utils.clj new file mode 100644 index 0000000..771bd6d --- /dev/null +++ b/src/dionysus/web/utils.clj @@ -0,0 +1,22 @@ +(ns dionysus.web.utils + (:require [hiccup2.core :as html] + [dotenv :as env])) + +(def title (delay (or (env/env "TITLE") "Dionysus"))) + +(defn render-html [src] + (-> src + html/html + str)) + +(defn render-page [title & src] + (-> [:html {:lang "de"} + [:head + [:meta {:charset "UTF-8"}] + [:meta {:name "viewport" :content "width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"}] + [:link {:rel "stylesheet" :href "/assets/style.css"}] + [:title title]] + (-> src + (conj :body) + vec)] + render-html)) |
