]> littlesong.place Git - littlesongplace.git/commitdiff
Implement basic push notifications
authorChris Fulljames <christianfulljames@gmail.com>
Sat, 17 May 2025 18:11:45 +0000 (14:11 -0400)
committerChris Fulljames <christianfulljames@gmail.com>
Sat, 23 Aug 2025 11:30:17 +0000 (07:30 -0400)
12 files changed:
pyproject.toml
src/littlesongplace/__init__.py
src/littlesongplace/comments.py
src/littlesongplace/push_notifications.py [new file with mode: 0644]
src/littlesongplace/songs.py
src/littlesongplace/sql/schema.sql
src/littlesongplace/sql/schema_revert.sql
src/littlesongplace/sql/schema_update.sql
src/littlesongplace/static/manifest.json
src/littlesongplace/static/nav.js
src/littlesongplace/static/service.js [new file with mode: 0644]
src/littlesongplace/templates/index.html

index d7ccbe2d288769a4f50c5fae9cfccd332b2207a2..51b879a751fe17dbb4494f4d0d57873f16cff434 100644 (file)
@@ -7,6 +7,7 @@ dependencies = [
     "flask",
     "gunicorn",
     "pillow",
+    "pywebpush",
     "yt-dlp",
 ]
 requires-python = ">=3.11"
index a13d13a27868ccc674659e1b3a676a4bf43ba149..e720982e9d754348a8ab2cbca3384dabf4ea172b 100644 (file)
@@ -8,12 +8,13 @@ from logging.handlers import RotatingFileHandler
 from pathlib import Path
 
 import click
+import pywebpush
 from flask import Flask, render_template, request, redirect, g, session, abort, \
         send_from_directory, flash, get_flashed_messages
 from werkzeug.middleware.proxy_fix import ProxyFix
 
 from . import activity, auth, colors, comments, datadir, db, jams, playlists, \
-        profiles, songs, users
+        profiles, push_notifications, songs, users
 from .logutils import flash_and_log
 
 # Logging
@@ -36,6 +37,7 @@ app.register_blueprint(comments.bp)
 app.register_blueprint(jams.bp)
 app.register_blueprint(playlists.bp)
 app.register_blueprint(profiles.bp)
+app.register_blueprint(push_notifications.bp)
 app.register_blueprint(songs.bp)
 db.init_app(app)
 
@@ -115,6 +117,10 @@ def site_news():
 def about():
     return render_template("about.html")
 
+@app.get("/service.js")
+def service_worker():
+    return send_from_directory("static", "service.js")
+
 def get_gif_data():
     # Convert all .gifs to base64 strings and embed them as dataset entries
     # in <div>s.  This is used by nav.js:customImage() - it replaces specific
index 69320a65a76e67267ff1e600b96bc10deb246df4..3e41bdcb3c38ab65506ac5090152fee83dda52fc 100644 (file)
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
 
 from flask import abort, Blueprint, redirect, render_template, request, session
 
-from . import db, songs
+from . import auth, db, push_notifications, songs
 from .sanitize import sanitize_user_text
 
 bp = Blueprint("comments", __name__)
@@ -55,10 +55,8 @@ def for_thread(threadid):
     return song_comments
 
 @bp.route("/comment", methods=["GET", "POST"])
+@auth.requires_login
 def comment():
-    if not "userid" in session:
-        return redirect("/login")
-
     if not "threadid" in request.args:
         abort(400) # Must have threadid
 
@@ -178,7 +176,7 @@ def comment():
             if userid in notification_targets:
                 notification_targets.remove(userid)
 
-            # Create notifications
+            # Create notifications in database
             for target in notification_targets:
                 db.query(
                         """
@@ -188,6 +186,10 @@ def comment():
                         """,
                         [commentid, ObjectType.COMMENT, target, timestamp])
 
+            # Send push notifications
+            push_notifications.notify(
+                    notification_targets, f"Comment from {g.username}", content)
+
         db.commit()
 
         return redirect_to_previous_page()
diff --git a/src/littlesongplace/push_notifications.py b/src/littlesongplace/push_notifications.py
new file mode 100644 (file)
index 0000000..7445965
--- /dev/null
@@ -0,0 +1,73 @@
+import json
+import threading
+
+import pywebpush
+from flask import Blueprint, current_app, g, request
+
+from . import auth, db
+
+bp = Blueprint("push-notifications", __name__, url_prefix="/push-notifications")
+
+@bp.post("/subscribe")
+@auth.requires_login
+def subscribe():
+    if not request.json:
+        # Request must contain valid subscription JSON
+        abort(400)
+
+    db.query(
+            """
+            INSERT INTO users_push_subscriptions (userid, subscription)
+            VALUES (?, ?)
+            """,
+            [g.userid, json.dumps(request.json)])
+    db.commit()
+
+    current_app.logger.info(f"{g.username} registered push subscription")
+
+    return {"status": "success"}
+
+def get_user_subscriptions(userid):
+    rows = db.query(
+            """
+            SELECT * FROM users_push_subscriptions
+            WHERE userid = ?
+            """,
+            [userid])
+    str_subs = (r["subscription"] for r in rows)
+    subs = []
+    for s in str_subs:
+        try:
+            subs.append(json.loads(s))
+        except json.decoder.JSONDecodeError:
+            current_app.logger.error(f"Invalid subscription: {s}")
+    return subs
+
+def notify_all(title, body, _except=None):
+    # Notify all users (who have notifications enabled)
+    rows = db.query("SELECT * FROM users")
+    userids = [r["userid"] for r in rows]
+    if _except in userids:
+        userids.remove(_except)
+    notify(userids, title, body)
+
+def notify(userids, title, body):
+    # Send push notifications in background thread (could take a while)
+    thread = threading.Thread(
+            target=_do_push,
+            args=(current_app._get_current_object(), userids, title, body))
+    thread.start()
+
+def _do_push(app, userids, title, body):
+    data = {"title": title, "body": body}
+    data_str = json.dumps(data)
+    for userid in userids:
+        with app.app_context():
+            subs = get_user_subscriptions(userid)
+            for sub in subs:
+                try:
+                    # TODO: Use VAPID keys
+                    pywebpush.webpush(sub, data_str)
+                except pywebpush.WebPushException as ex:
+                    current_app.logger.error(f"Failed to send push: {ex}")
+
index 82f5310b467a745c708ad88fc6e8072e426fb268..89ee4a7a8f9552a3e98b419c6033f70d626f43aa 100644 (file)
@@ -9,12 +9,12 @@ from datetime import datetime, timezone
 from dataclasses import dataclass
 from typing import Optional
 
-from flask import Blueprint, current_app, render_template, request, redirect, \
+from flask import Blueprint, current_app, g, render_template, request, redirect, \
         session, abort, send_from_directory
 from yt_dlp import YoutubeDL
 from yt_dlp.utils import DownloadError
 
-from . import comments, colors, datadir, db, users
+from . import auth, comments, colors, datadir, db, push_notifications, users
 from .sanitize import sanitize_user_text
 from .logutils import flash_and_log
 
@@ -170,10 +170,8 @@ def _from_db(query, args=()):
     return songs
 
 @bp.get("/edit-song")
+@auth.requires_login
 def edit_song():
-    if not "userid" in session:
-        return redirect("/login")  # Must be logged in to edit
-
     song = None
 
     song_colors = users.get_user_colors(session["userid"])
@@ -207,10 +205,8 @@ def edit_song():
     return render_template("edit-song.html", song=song, **song_colors, eventid=eventid)
 
 @bp.post("/upload-song")
+@auth.requires_login
 def upload_song():
-    if not "userid" in session:
-        return redirect("/login")  # Must be logged in to edit
-
     userid = session["userid"]
 
     error = validate_song_form()
@@ -404,7 +400,12 @@ def create_song():
             db.commit()
 
             flash_and_log(f"Successfully uploaded '{title}'", "success")
-            return False
+
+            # Send push notifications to all other users
+            push_notifications.notify_all(
+                    f"New song from {g.username}", title, _except=g.userid)
+
+            return False  # No error
 
 def convert_song(tmp_file, request_file, yt_url):
     if request_file:
@@ -459,6 +460,7 @@ def yt_import(tmp_file, yt_url):
         ydl.download([yt_url])
 
 @bp.get("/delete-song/<int:songid>")
+@auth.requires_login
 def delete_song(songid):
     song_data = db.query(
         "SELECT * FROM songs WHERE songid = ?", [songid], one=True)
index 4ea3bf13e9ab436cca7b6fcb7c57e2e828471588..a85e5d30d404b2400a84d4dbe909554d38440ebe 100644 (file)
@@ -44,7 +44,6 @@ CREATE TABLE song_tags (
     PRIMARY KEY(songid, tag)
 );
 CREATE INDEX idx_song_tags_tag ON song_tags(tag);
 DROP TABLE IF EXISTS playlists;
 CREATE TABLE playlists (
     playlistid INTEGER PRIMARY KEY,
@@ -158,5 +157,35 @@ CREATE TABLE jam_events(
     FOREIGN KEY(threadid) REFERENCES comment_threads(threadid)
 );
 
-PRAGMA user_version = 5;
+DROP VIEW IF EXISTS songs_view;
+CREATE VIEW songs_view AS
+    WITH
+        tags_agg AS (
+            SELECT songid, GROUP_CONCAT(tag) as tags
+            FROM song_tags
+            GROUP BY songid
+        ),
+        collaborators_agg AS (
+            SELECT songid, GROUP_CONCAT(name) as collaborators
+            FROM song_collaborators
+            GROUP BY songid
+        )
+    SELECT
+        songs.*,
+        users.username,
+        users.fgcolor,
+        users.bgcolor,
+        users.accolor,
+        jam_events.title AS event_title,
+        jam_events.jamid AS jamid,
+        jam_events.enddate AS event_enddate,
+        tags_agg.tags,
+        collaborators_agg.collaborators
+    FROM songs
+    INNER JOIN users ON songs.userid = users.userid
+    LEFT JOIN tags_agg ON tags_agg.songid = songs.songid
+    LEFT JOIN collaborators_agg ON collaborators_agg.songid = songs.songid
+    LEFT JOIN jam_events ON jam_events.eventid = songs.eventid;
+
+PRAGMA user_version = 6;
 
index 408ad84f2458357bf648a6c296e053b9eb2af8f5..a1b7bc619ad70c493b072030e8ed01dff114c6f4 100644 (file)
@@ -1,3 +1,4 @@
-DROP VIEW songs_view;
-PRAGMA user_version = 5;
+DROP TABLE users_push_subscriptions;
+
+PRAGMA user_version = 6;
 
index 3b543b1a291d392b3d5f62174f309247f3e8afbc..90171ed7f11912a240e83d0fc7399e298654ef4f 100644 (file)
@@ -1,31 +1,8 @@
-CREATE VIEW songs_view AS
-    WITH
-        tags_agg AS (
-            SELECT songid, GROUP_CONCAT(tag) as tags
-            FROM song_tags
-            GROUP BY songid
-        ),
-        collaborators_agg AS (
-            SELECT songid, GROUP_CONCAT(name) as collaborators
-            FROM song_collaborators
-            GROUP BY songid
-        )
-    SELECT
-        songs.*,
-        users.username,
-        users.fgcolor,
-        users.bgcolor,
-        users.accolor,
-        jam_events.title AS event_title,
-        jam_events.jamid AS jamid,
-        jam_events.enddate AS event_enddate,
-        tags_agg.tags,
-        collaborators_agg.collaborators
-    FROM songs
-    INNER JOIN users ON songs.userid = users.userid
-    LEFT JOIN tags_agg ON tags_agg.songid = songs.songid
-    LEFT JOIN collaborators_agg ON collaborators_agg.songid = songs.songid
-    LEFT JOIN jam_events ON jam_events.eventid = songs.eventid;
-
-PRAGMA user_version = 6;
+-- DROP TABLE IF EXISTS users_push_subscriptions
+CREATE TABLE users_push_subscriptions (
+    userid INTEGER NOT NULL,
+    subscription TEXT NOT NULL,
+    FOREIGN KEY(userid) REFERENCES users(userid) ON DELETE CASCADE
+);
 
+PRAGMA user_version = 7;
index 7fa4d16131f97d73982b824fb29e41e8a06b763d..e46cd2888e79d2a8b0a66e1d154421242b51fb00 100644 (file)
@@ -6,7 +6,7 @@
       "src": "/static/lsp_notes.png",
       "sizes": "96x96",
       "type": "image/png"
-    },
+    }
   ],
   "start_url": ".",
   "display": "standalone",
index 7ed724993fc401c35b8adc2019b9b49b0c068dae..5d67278b8f02ed217c86cb9a2db25e7385d2a775 100644 (file)
@@ -48,6 +48,17 @@ document.addEventListener("DOMContentLoaded", async (e) => {
     });
 });
 
+async function requestNotificationPermission() {
+    const permission = await window.Notification.requestPermission();
+    if (permission === "granted") {
+        // Register service worker
+        navigator.serviceWorker.register("service.js");
+    }
+    else {
+        console.log("Did not get permission to send notifications:", permission);
+    }
+}
+
 function onLinkClick(event) {
     if (event.defaultPrevented) {
         return;
diff --git a/src/littlesongplace/static/service.js b/src/littlesongplace/static/service.js
new file mode 100644 (file)
index 0000000..91456af
--- /dev/null
@@ -0,0 +1,30 @@
+self.addEventListener("activate", async () => {
+    console.log("hello?");
+    try {
+        // TODO: Use VAPID key
+        const options = {};
+        const subscription = await self.registration.pushManager.subscribe(options);
+        console.log(JSON.stringify(subscription));
+        const response = await fetch(
+            "/push-notifications/subscribe", {
+                method: "post",
+                headers: {"Content-Type": "application/json"},
+                body: JSON.stringify(subscription)
+            }
+        );
+        console.log(response);
+    }
+    catch (err) {
+        console.log("Error", err);
+    }
+
+});
+
+self.addEventListener("push", (event) => {
+    if (event.data) {
+        const data = event.data.json();
+        self.registration.showNotification(data.title, {body: data.body});
+    }
+});
+
+// TODO: handle notificationclick event
index 8f7d9b5544c3009d0156d113a87318c808c8ba02..7c7859820b6a8c218a636be3904f5933f11e07d8 100644 (file)
@@ -4,6 +4,8 @@
 
 {% block body %}
 
+<button onclick="requestNotificationPermission()" class="button">Enable Notifications</button>
+
 <h2 class="mt0">hello!</h2>
 <div style="display: flex; flex-direction: row; justify-content: center; gap: 10px; align-items: center;">
 <div>🎶</div>