]> 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, 17 May 2025 18:11:45 +0000 (14:11 -0400)
13 files changed:
pyproject.toml
src/littlesongplace/__init__.py
src/littlesongplace/comments.py
src/littlesongplace/db.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 5754392307871a35d5af0d0c446563cd3e243ec8..69afb4ade0de416131a3ced39f5d58078b83d2a5 100644 (file)
@@ -7,12 +7,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
@@ -35,6 +36,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)
 
@@ -91,6 +93,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()
index ec1ebad111f272904489cee6cbd7e021efaed155..0fd94c9698f240ba7866f2b6c8099f4da154570c 100644 (file)
@@ -6,7 +6,7 @@ from flask import abort, g, current_app
 
 from . import datadir
 
-DB_VERSION = 5
+DB_VERSION = 6
 
 def get():
     db = getattr(g, '_database', None)
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 a8c25f8c5a20e094291467bd0e30cac41e22c559..0521782c33f8d01dd217c33807e92c1b239b58a1 100644 (file)
@@ -8,12 +8,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
 
@@ -223,10 +223,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"])
@@ -260,10 +258,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()
@@ -457,7 +453,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:
@@ -512,6 +513,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 51057b6baff7d7007233030866dfca20c4ce6220..a2c8bde902d7a9d7855ef6a99d92ef883adb3eb2 100644 (file)
@@ -21,9 +21,12 @@ CREATE TABLE songs (
     title TEXT NOT NULL,
     description TEXT,
     threadid INTEGER,
-    FOREIGN KEY(userid) REFERENCES users(userid)
+    eventid INTEGER,
+    FOREIGN KEY(userid) REFERENCES users(userid),
+    FOREIGN KEY(eventid) REFERENCES jam_events(eventid)
 );
 CREATE INDEX idx_songs_by_user ON songs(userid);
+CREATE INDEX idx_songs_by_eventid ON songs(eventid);
 
 DROP TABLE IF EXISTS song_collaborators;
 CREATE TABLE song_collaborators (
@@ -41,34 +44,6 @@ CREATE TABLE song_tags (
     PRIMARY KEY(songid, tag)
 );
 CREATE INDEX idx_song_tags_tag ON song_tags(tag);
--- Old comment system (superceded by comments/comment_threads/comment_notifications
---
--- DROP TABLE IF EXISTS song_comments;
--- CREATE TABLE song_comments (
---     commentid INTEGER PRIMARY KEY,
---     songid INTEGER NOT NULL,
---     userid INTEGER NOT NULL,
---     replytoid INTEGER,
---     created TEXT NOT NULL,
---     content TEXT NOT NULL,
---     FOREIGN KEY(songid) REFERENCES songs(songid) ON DELETE CASCADE,
---     FOREIGN KEY(userid) REFERENCES users(userid) ON DELETE CASCADE
--- );
--- CREATE INDEX idx_comments_by_song ON song_comments(songid);
--- CREATE INDEX idx_comments_by_user ON song_comments(userid);
--- CREATE INDEX idx_comments_by_replyto ON song_comments(replytoid);
--- CREATE INDEX idx_comments_by_time ON song_comments(created);
--- 
--- DROP TABLE IF EXISTS song_comment_notifications;
--- CREATE TABLE song_comment_notifications (
---     notificationid INTEGER PRIMARY KEY,
---     commentid INTEGER NOT NULL,
---     targetuserid INTEGER NOT NULL,
---     FOREIGN KEY(commentid) REFERENCES song_comments(commentid) ON DELETE CASCADE,
---     FOREIGN KEY(targetuserid) REFERENCES users(userid) ON DELETE CASCADE
--- );
--- CREATE INDEX idx_song_comment_notifications_by_target ON song_comment_notifications(targetuserid);
 
 DROP TABLE IF EXISTS playlists;
 CREATE TABLE playlists (
@@ -159,5 +134,29 @@ BEGIN
     DELETE FROM notifications WHERE objectid = OLD.commentid AND objecttype = 0;
 END;
 
-PRAGMA user_version = 4;
+DROP TABLE IF EXISTS jams;
+CREATE TABLE jams (
+    jamid INTEGER PRIMARY KEY,
+    ownerid INTEGER NOT NULL,
+    created TEXT NOT NULL,
+    title TEXT NOT NULL,
+    description TEXT,
+    FOREIGN KEY(ownerid) REFERENCES users(userid)
+);
+
+DROP TABLE IF EXISTS jam_events;
+CREATE TABLE jam_events(
+    eventid INTEGER PRIMARY KEY,
+    jamid INTEGER NOT NULL,
+    threadid INTEGER NOT NULL,
+    created TEXT NOT NULL,
+    title TEXT NOT NULL, -- Hidden until startdate
+    startdate TEXT,
+    enddate TEXT,
+    description TEXT, -- Hidden until startdate
+    FOREIGN KEY(jamid) REFERENCES jams(jamid),
+    FOREIGN KEY(threadid) REFERENCES comment_threads(threadid)
+);
+
+PRAGMA user_version = 5;
 
index a6cd88388abb6d846254cc2b24979922302cff4d..f92e9af9035352c31f44d2270ae28b21e5b78c69 100644 (file)
@@ -1,7 +1,4 @@
-DROP INDEX idx_songs_by_eventid;
-ALTER TABLE songs DROP COLUMN eventid;
-DROP TABLE IF EXISTS jams;
-DROP TABLE IF EXISTS jam_events;
+DROP TABLE users_push_subscriptions;
 
-PRAGMA user_version = 4;
+PRAGMA user_version = 5;
 
index ffb9b60a0b69310759846dfde36c7b2e28a96927..14208a2d4a0bb0638a49c4dc7e6ec3736bb9050d 100644 (file)
@@ -1,29 +1,8 @@
---DROP TABLE IF EXISTS jams;
-CREATE TABLE jams (
-    jamid INTEGER PRIMARY KEY,
-    ownerid INTEGER NOT NULL,
-    created TEXT NOT NULL,
-    title TEXT NOT NULL,
-    description TEXT,
-    FOREIGN KEY(ownerid) REFERENCES users(userid)
+-- 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
 );
 
---DROP TABLE IF EXISTS jam_events;
-CREATE TABLE jam_events(
-    eventid INTEGER PRIMARY KEY,
-    jamid INTEGER NOT NULL,
-    threadid INTEGER NOT NULL,
-    created TEXT NOT NULL,
-    title TEXT NOT NULL, -- Hidden until startdate
-    startdate TEXT,
-    enddate TEXT,
-    description TEXT, -- Hidden until startdate
-    FOREIGN KEY(jamid) REFERENCES jams(jamid),
-    FOREIGN KEY(threadid) REFERENCES comment_threads(threadid)
-);
-
-ALTER TABLE songs ADD COLUMN eventid INTEGER REFERENCES jam_events(eventid);
-CREATE INDEX idx_songs_by_eventid ON songs(eventid);
-
-PRAGMA user_version = 5;
-
+PRAGMA user_version = 6;
index 7fa4d16131f97d73982b824fb29e41e8a06b763d..e46cd2888e79d2a8b0a66e1d154421242b51fb00 100644 (file)
@@ -6,7 +6,7 @@
       "src": "/static/lsp_notes.png",
       "sizes": "96x96",
       "type": "image/png"
-    },
+    }
   ],
   "start_url": ".",
   "display": "standalone",
index b369974306f8cd2c863c06c4b01c63a025756e90..dc89f3e3374954b0d5f44cbe9e612e6378f1ce1d 100644 (file)
@@ -1,4 +1,4 @@
-document.addEventListener("DOMContentLoaded", (e) => {
+document.addEventListener("DOMContentLoaded", async (e) => {
 
     // Handle link clicks with AJAX
     document.querySelectorAll("a").forEach((anchor) => {
@@ -41,6 +41,17 @@ document.addEventListener("DOMContentLoaded", (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 d627aa818b4a0a53bb95004628c1c478d3af28b6..92824ddf63c03233b2315af6a321c523f69be91d 100644 (file)
@@ -4,6 +4,8 @@
 
 {% block body %}
 
+<button onclick="requestNotificationPermission()" class="button">Enable Notifications</button>
+
 <h2>hello!</h2>
 <div style="display: flex; flex-direction: row; justify-content: center; gap: 10px; align-items: center;">
 <div>🎶</div>