aboutsummaryrefslogtreecommitdiffstats
path: root/cogs/utils
diff options
context:
space:
mode:
Diffstat (limited to 'cogs/utils')
-rw-r--r--cogs/utils/database.py126
-rw-r--r--cogs/utils/file_search.py18
-rw-r--r--cogs/utils/settings.py67
-rw-r--r--cogs/utils/url_helper.py21
4 files changed, 206 insertions, 26 deletions
diff --git a/cogs/utils/database.py b/cogs/utils/database.py
new file mode 100644
index 0000000..ea7d356
--- /dev/null
+++ b/cogs/utils/database.py
@@ -0,0 +1,126 @@
1import os
2import sqlite3
3import logging
4from contextlib import contextmanager
5
6logger = logging.getLogger(__name__)
7
8DB_PATH = "data/senator.db"
9
10_initialized = False
11
12
13def get_connection():
14 conn = sqlite3.connect(get_db_path())
15 return _ConnectionContextManager(conn)
16
17
18def get_db_path():
19 return os.getenv("SENATOR_DB_PATH", DB_PATH)
20
21
22def set_db_path(path):
23 global DB_PATH
24 DB_PATH = path
25
26
27class _ConnectionContextManager:
28 """Context manager for sqlite3 connections that auto-closes."""
29 def __init__(self, conn):
30 self.conn = conn
31
32 def __enter__(self):
33 return self.conn
34
35 def __exit__(self, exc_type, exc_val, exc_tb):
36 self.conn.close()
37 return False
38
39
40def init_database():
41 global _initialized
42 if _initialized:
43 return
44
45 _initialized = True
46
47 db_path = get_db_path()
48 db_dir = os.path.dirname(db_path)
49 if db_dir and not os.path.exists(db_dir):
50 os.makedirs(db_dir)
51
52 conn = sqlite3.connect(db_path)
53 try:
54 c = conn.cursor()
55
56 c.execute("PRAGMA foreign_keys = ON")
57
58 c.execute("""
59 CREATE TABLE IF NOT EXISTS settings (
60 key TEXT PRIMARY KEY,
61 value TEXT
62 )""")
63
64 c.execute("""
65 CREATE TABLE IF NOT EXISTS cron_jobs (
66 id INTEGER PRIMARY KEY AUTOINCREMENT,
67 cron_expression TEXT NOT NULL,
68 type TEXT NOT NULL,
69 channel_id INTEGER NOT NULL,
70 active INTEGER DEFAULT 1,
71 audio_filename TEXT,
72 message TEXT,
73 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
74 )""")
75
76 c.execute("""
77 CREATE TABLE IF NOT EXISTS voice_activity (
78 user_id TEXT PRIMARY KEY,
79 join_time REAL,
80 total_active REAL DEFAULT 0,
81 total_afk REAL DEFAULT 0,
82 is_afk INTEGER DEFAULT 0,
83 afk_start REAL,
84 active_start REAL,
85 channel_name TEXT,
86 last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
87 )""")
88
89 c.execute("CREATE INDEX IF NOT EXISTS idx_voice_activity_user_id ON voice_activity(user_id)")
90 c.execute("CREATE INDEX IF NOT EXISTS idx_cron_jobs_active ON cron_jobs(active)")
91
92 c.execute("""
93 CREATE TABLE IF NOT EXISTS triggers (
94 id INTEGER PRIMARY KEY AUTOINCREMENT,
95 name TEXT NOT NULL UNIQUE,
96 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
97 last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
98 )""")
99
100 c.execute("""
101 CREATE TABLE IF NOT EXISTS trigger_keywords (
102 id INTEGER PRIMARY KEY AUTOINCREMENT,
103 trigger_id INTEGER NOT NULL,
104 keyword TEXT NOT NULL,
105 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
106 FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE,
107 UNIQUE(trigger_id, keyword)
108 )""")
109
110 c.execute("""
111 CREATE TABLE IF NOT EXISTS trigger_responses (
112 id INTEGER PRIMARY KEY AUTOINCREMENT,
113 trigger_id INTEGER NOT NULL,
114 response_text TEXT NOT NULL,
115 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
116 FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
117 )""")
118
119 c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_keywords_trigger_id ON trigger_keywords(trigger_id)")
120 c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_keywords_keyword ON trigger_keywords(keyword)")
121 c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_responses_trigger_id ON trigger_responses(trigger_id)")
122
123 conn.commit()
124 logger.info("Database initialized successfully")
125 finally:
126 conn.close()
diff --git a/cogs/utils/file_search.py b/cogs/utils/file_search.py
index 8111216..788f50c 100644
--- a/cogs/utils/file_search.py
+++ b/cogs/utils/file_search.py
@@ -34,10 +34,22 @@ class UniversalSearcher:
34 query = query.strip() 34 query = query.strip()
35 logger.info(f"Starting copyparty search for query: '{query}'") 35 logger.info(f"Starting copyparty search for query: '{query}'")
36 36
37 # Build the query with optional extension filtering 37 tokens = query.split()
38 name_query = f'name like "*{query}*"' 38 if not tokens:
39 return []
40
41 token_queries = []
42 for token in tokens:
43 path_query = f'path like "*{token}*"'
44 name_query = f'name like "*{token}*"'
45 token_queries.append(f"({path_query} or {name_query})")
46
47 if len(token_queries) == 1:
48 name_query = token_queries[0]
49 else:
50 name_query = " and ".join(token_queries)
51
39 if file_extensions: 52 if file_extensions:
40 # Create extension filters using copyparty's .ext$ syntax
41 ext_filters = [f"name like \"*.{ext.lower()}\"" for ext in file_extensions] 53 ext_filters = [f"name like \"*.{ext.lower()}\"" for ext in file_extensions]
42 ext_query = " or ".join(ext_filters) 54 ext_query = " or ".join(ext_filters)
43 query = f"{name_query} and ({ext_query})" 55 query = f"{name_query} and ({ext_query})"
diff --git a/cogs/utils/settings.py b/cogs/utils/settings.py
index d66ce7f..b1a585c 100644
--- a/cogs/utils/settings.py
+++ b/cogs/utils/settings.py
@@ -3,16 +3,18 @@
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io 3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma 4# https://codeberg.org/salmonik/senator-sperma
5 5
6import json
7import logging 6import logging
8import aiofiles 7import sqlite3
9import os 8import os
10from dataclasses import dataclass, field 9from dataclasses import dataclass, field
11 10
11from cogs.utils.database import get_connection, init_database
12
12logger = logging.getLogger(__name__) 13logger = logging.getLogger(__name__)
13 14
14settings_file = "data/settings.json" 15DEFAULT_BASEURL = "http://localhost:3939"
15DEFAULT_BASEURL = "http://localhost:8080" 16
17init_database()
16 18
17 19
18@dataclass 20@dataclass
@@ -28,24 +30,21 @@ class Settings:
28 } 30 }
29 31
30 try: 32 try:
31 if os.path.exists(settings_file): 33 with get_connection() as conn:
32 async with aiofiles.open(settings_file, "r") as f: 34 c = conn.cursor()
33 content = await f.read() 35 c.execute("SELECT key, value FROM settings")
34 if content.strip(): 36 rows = c.fetchall()
35 self._data = json.loads(content) 37
36 self._sync_from_data() 38 if rows:
37 logger.info("Settings loaded into memory") 39 self._data = {row[0]: row[1] for row in rows}
38 else: 40 self._sync_from_data()
39 self._data = default_settings.copy() 41 logger.info("Settings loaded from database")
40 self._sync_from_data()
41 await self.save()
42 logger.warning("Empty settings.json, reset to defaults")
43 else: 42 else:
44 self._data = default_settings.copy() 43 self._data = default_settings.copy()
45 self._sync_from_data() 44 self._sync_from_data()
46 await self.save() 45 await self.save()
47 logger.info("Created default settings.json") 46 logger.info("Created default settings in database")
48 except json.JSONDecodeError as e: 47 except Exception as e:
49 logger.error(f"Error loading settings: {e}") 48 logger.error(f"Error loading settings: {e}")
50 self._data = default_settings.copy() 49 self._data = default_settings.copy()
51 self._sync_from_data() 50 self._sync_from_data()
@@ -53,17 +52,27 @@ class Settings:
53 return self 52 return self
54 53
55 def _sync_from_data(self): 54 def _sync_from_data(self):
56 self.volume = self._data.get("volume", 1.0) 55 try:
56 self.volume = float(self._data.get("volume", 1.0))
57 except (ValueError, TypeError):
58 self.volume = 1.0
57 self.baseurl = self._data.get("baseurl", DEFAULT_BASEURL) 59 self.baseurl = self._data.get("baseurl", DEFAULT_BASEURL)
58 60
59 def _sync_to_data(self): 61 def _sync_to_data(self):
60 self._data["volume"] = self.volume 62 self._data["volume"] = str(self.volume)
61 self._data["baseurl"] = self.baseurl 63 self._data["baseurl"] = self.baseurl
62 64
63 async def save(self): 65 async def save(self):
64 self._sync_to_data() 66 self._sync_to_data()
65 async with aiofiles.open(settings_file, "w") as f: 67 with get_connection() as conn:
66 await f.write(json.dumps(self._data, indent=2)) 68 c = conn.cursor()
69 c.execute("PRAGMA foreign_keys = ON")
70 for key, value in self._data.items():
71 c.execute(
72 "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
73 (key, value),
74 )
75 conn.commit()
67 76
68 77
69_settings_instance: Settings | None = None 78_settings_instance: Settings | None = None
@@ -90,4 +99,16 @@ def get_volume() -> float:
90 99
91 100
92def get_baseurl() -> str: 101def get_baseurl() -> str:
93 return _settings_instance.baseurl.rstrip("/") if _settings_instance else DEFAULT_BASEURL 102 url = os.getenv("COPYPARTY_URL")
103 if not url:
104 logger.warning("COPYPARTY_URL not set! Using default http://localhost:3939")
105 return DEFAULT_BASEURL
106 return url.rstrip("/")
107
108
109def get_uploadurl() -> str:
110 url = os.getenv("COPYPARTY_URL_UPLOAD")
111 if not url:
112 logger.warning("COPYPARTY_URL_UPLOAD not set! Using default http://localhost:3939")
113 return DEFAULT_BASEURL
114 return url.rstrip("/")
diff --git a/cogs/utils/url_helper.py b/cogs/utils/url_helper.py
new file mode 100644
index 0000000..eac06f1
--- /dev/null
+++ b/cogs/utils/url_helper.py
@@ -0,0 +1,21 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6
7def clean_url_for_ytdlp(url: str) -> str:
8 """Clean URL by removing everything after the first '&'.
9
10 This prevents yt-dlp from interpreting additional query parameters
11 (like &list=...) as playlist URLs.
12
13 Example:
14 Input: https://www.youtube.com/watch?v=lPGipwoJiOM&list=RDlPGipwoJiOM&
15 Output: https://www.youtube.com/watch?v=lPGipwoJiOM
16 """
17 # Find the first '&' and truncate
18 first_ampersand = url.find('&')
19 if first_ampersand != -1:
20 return url[:first_ampersand]
21 return url