bragi/database.py
Terry Geng 40ea744e7f
refactor: move music db into a separated file.
IMPORTANT NOTE:
The default database path has changed. It was database.db, but
now they ARE settings.db and music.db.

Database migration will detect music table in settings.db, and
move it out of settings.db.

To update from old version, you need to
 1. if you use your own db path settings in the command
    line or in configuration.ini, you don't have to change
    anything;
 1. or,if you use default database path, rename database.db
    into settings.db.
2020-05-13 10:43:40 +08:00

659 lines
22 KiB
Python

import os
import re
import sqlite3
import json
import datetime
import time
import logging
log = logging.getLogger("bot")
class DatabaseError(Exception):
pass
class Condition:
def __init__(self):
self.filler = []
self._sql = ""
self._limit = 0
self._offset = 0
self._order_by = ""
self.has_regex = False
pass
def sql(self, conn: sqlite3.Connection = None):
sql = self._sql
if not self._sql:
sql = "1"
if self._order_by:
sql += f" ORDER BY {self._order_by}"
if self._desc:
sql += " DESC"
if self._limit:
sql += f" LIMIT {self._limit}"
if self._offset:
sql += f" OFFSET {self._offset}"
if self.has_regex and conn:
conn.create_function("REGEXP", 2, self._regexp)
return sql
@staticmethod
def _regexp(expr, item):
if not item:
return False
reg = re.compile(expr)
return reg.search(item) is not None
def or_equal(self, column, equals_to, case_sensitive=True):
if not case_sensitive:
column = f"LOWER({column})"
equals_to = equals_to.lower()
if self._sql:
self._sql += f" OR {column}=?"
else:
self._sql += f"{column}=?"
self.filler.append(equals_to)
return self
def and_equal(self, column, equals_to, case_sensitive=True):
if not case_sensitive:
column = f"LOWER({column})"
equals_to = equals_to.lower()
if self._sql:
self._sql += f" AND {column}=?"
else:
self._sql += f"{column}=?"
self.filler.append(equals_to)
return self
def or_like(self, column, equals_to, case_sensitive=True):
if not case_sensitive:
column = f"LOWER({column})"
equals_to = equals_to.lower()
if self._sql:
self._sql += f" OR {column} LIKE ?"
else:
self._sql += f"{column} LIKE ?"
self.filler.append(equals_to)
return self
def and_like(self, column, equals_to, case_sensitive=True):
if not case_sensitive:
column = f"LOWER({column})"
equals_to = equals_to.lower()
if self._sql:
self._sql += f" AND {column} LIKE ?"
else:
self._sql += f"{column} LIKE ?"
self.filler.append(equals_to)
return self
def and_regexp(self, column, regex):
self.has_regex = True
if self._sql:
self._sql += f" AND {column} REGEXP ?"
else:
self._sql += f"{column} REGEXP ?"
self.filler.append(regex)
return self
def or_regexp(self, column, regex):
self.has_regex = True
if self._sql:
self._sql += f" OR {column} REGEXP ?"
else:
self._sql += f"{column} REGEXP ?"
self.filler.append(regex)
return self
def or_sub_condition(self, sub_condition):
if sub_condition.has_regex:
self.has_regex = True
self.filler.extend(sub_condition.filler)
if self._sql:
self._sql += f" OR ({sub_condition.sql(None)})"
else:
self._sql += f"({sub_condition.sql(None)})"
return self
def or_not_sub_condition(self, sub_condition):
if sub_condition.has_regex:
self.has_regex = True
self.filler.extend(sub_condition.filler)
if self._sql:
self._sql += f" OR NOT ({sub_condition.sql(None)})"
else:
self._sql += f"NOT ({sub_condition.sql(None)})"
return self
def and_sub_condition(self, sub_condition):
if sub_condition.has_regex:
self.has_regex = True
self.filler.extend(sub_condition.filler)
if self._sql:
self._sql += f" AND ({sub_condition.sql(None)})"
else:
self._sql += f"({sub_condition.sql(None)})"
return self
def and_not_sub_condition(self, sub_condition):
if sub_condition.has_regex:
self.has_regex = True
self.filler.extend(sub_condition.filler)
if self._sql:
self._sql += f" AND NOT({sub_condition.sql(None)})"
else:
self._sql += f"NOT ({sub_condition.sql(None)})"
return self
def limit(self, limit):
self._limit = limit
return self
def offset(self, offset):
self._offset = offset
return self
def order_by(self, order_by, desc=False):
self._order_by = order_by
self._desc = desc
return self
SETTING_DB_VERSION = 2
MUSIC_DB_VERSION = 2
class SettingsDatabase:
def __init__(self, db_path):
self.db_path = db_path
def get(self, section, option, **kwargs):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
result = cursor.execute("SELECT value FROM botamusique WHERE section=? AND option=?", (section, option)).fetchall()
conn.close()
if len(result) > 0:
return result[0][0]
else:
if 'fallback' in kwargs:
return kwargs['fallback']
else:
raise DatabaseError("Item not found")
def getboolean(self, section, option, **kwargs):
return bool(int(self.get(section, option, **kwargs)))
def getfloat(self, section, option, **kwargs):
return float(self.get(section, option, **kwargs))
def getint(self, section, option, **kwargs):
return int(self.get(section, option, **kwargs))
def set(self, section, option, value):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute("INSERT OR REPLACE INTO botamusique (section, option, value) "
"VALUES (?, ?, ?)", (section, option, value))
conn.commit()
conn.close()
def has_option(self, section, option):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
result = cursor.execute("SELECT value FROM botamusique WHERE section=? AND option=?", (section, option)).fetchall()
conn.close()
if len(result) > 0:
return True
else:
return False
def remove_option(self, section, option):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute("DELETE FROM botamusique WHERE section=? AND option=?", (section, option))
conn.commit()
conn.close()
def remove_section(self, section):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute("DELETE FROM botamusique WHERE section=?", (section, ))
conn.commit()
conn.close()
def items(self, section):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
results = cursor.execute("SELECT option, value FROM botamusique WHERE section=?", (section, )).fetchall()
conn.close()
if len(results) > 0:
return list(map(lambda v: (v[0], v[1]), results))
else:
return []
def drop_table(self):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute("DROP TABLE botamusique")
conn.close()
class MusicDatabase:
def __init__(self, db_path):
self.db_path = db_path
def insert_music(self, music_dict, _conn=None):
conn = sqlite3.connect(self.db_path) if _conn is None else _conn
cursor = conn.cursor()
id = music_dict['id']
title = music_dict['title']
type = music_dict['type']
path = music_dict['path'] if 'path' in music_dict else ''
keywords = music_dict['keywords']
tags_list = list(dict.fromkeys(music_dict['tags']))
tags = ''
if tags_list:
tags = ",".join(tags_list) + ","
del music_dict['id']
del music_dict['title']
del music_dict['type']
del music_dict['tags']
if 'path' in music_dict:
del music_dict['path']
del music_dict['keywords']
existed = cursor.execute("SELECT 1 FROM music WHERE id=?", (id,)).fetchall()
if len(existed) == 0:
cursor.execute(
"INSERT INTO music (id, type, title, metadata, tags, path, keywords) VALUES (?, ?, ?, ?, ?, ?, ?)",
(id,
type,
title,
json.dumps(music_dict),
tags,
path,
keywords))
else:
cursor.execute("UPDATE music SET type=:type, title=:title, metadata=:metadata, tags=:tags, "
"path=:path, keywords=:keywords WHERE id=:id",
{'id': id,
'type': type,
'title': title,
'metadata': json.dumps(music_dict),
'tags': tags,
'path': path,
'keywords': keywords})
if not _conn:
conn.commit()
conn.close()
def query_music_ids(self, condition: Condition):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
results = cursor.execute("SELECT id FROM music WHERE id != 'info' AND %s" %
condition.sql(conn), condition.filler).fetchall()
conn.close()
return list(map(lambda i: i[0], results))
def query_all_paths(self):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
results = cursor.execute("SELECT path FROM music WHERE id != 'info' AND type = 'file'").fetchall()
conn.close()
paths = []
for result in results:
if result and result[0]:
paths.append(result[0])
return paths
def query_all_tags(self):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
results = cursor.execute("SELECT tags FROM music WHERE id != 'info'").fetchall()
tags = []
for result in results:
for tag in result[0].strip(",").split(","):
if tag and tag not in tags:
tags.append(tag)
conn.close()
return tags
def query_music_count(self, condition: Condition):
filler = condition.filler
conn = sqlite3.connect(self.db_path)
condition_str = condition.sql(conn)
cursor = conn.cursor()
results = cursor.execute("SELECT COUNT(*) FROM music "
"WHERE id != 'info' AND %s" % condition_str, filler).fetchall()
conn.close()
return results[0][0]
def query_music(self, condition: Condition, _conn=None):
filler = condition.filler
conn = sqlite3.connect(self.db_path) if _conn is None else _conn
condition_str = condition.sql(conn)
cursor = conn.cursor()
results = cursor.execute("SELECT id, type, title, metadata, tags, path, keywords FROM music "
"WHERE id != 'info' AND %s" % condition_str, filler).fetchall()
if not _conn:
conn.close()
return self._result_to_dict(results)
def _query_music_by_plain_sql_cond(self, sql_cond, _conn=None):
conn = sqlite3.connect(self.db_path) if _conn is None else _conn
cursor = conn.cursor()
results = cursor.execute("SELECT id, type, title, metadata, tags, path, keywords FROM music "
"WHERE id != 'info' AND %s" % sql_cond).fetchall()
if not _conn:
conn.close()
return self._result_to_dict(results)
def query_music_by_id(self, _id, _conn=None):
results = self.query_music(Condition().and_equal("id", _id), _conn)
if results:
return results[0]
else:
return None
def query_music_by_keywords(self, keywords, _conn=None):
condition = Condition()
for keyword in keywords:
condition.and_like("title", f"%{keyword}%", case_sensitive=False)
return self.query_music(condition, _conn)
def query_music_by_tags(self, tags, _conn=None):
condition = Condition()
for tag in tags:
condition.and_like("tags", f"%{tag},%", case_sensitive=False)
return self.query_music(condition, _conn)
def manage_special_tags(self):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute("UPDATE music SET tags=REPLACE(tags, 'recent added,', '') WHERE tags LIKE '%recent added,%' "
"AND create_at <= DATETIME('now', '-1 day') AND id != 'info'")
cursor.execute("UPDATE music SET tags=tags||'recent added,' WHERE tags NOT LIKE '%recent added,%' "
"AND create_at > DATETIME('now', '-1 day') AND id != 'info'")
conn.commit()
conn.close()
def query_tags(self, condition: Condition):
# TODO: Can we keep a index of tags?
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
results = cursor.execute("SELECT id, tags FROM music "
"WHERE id != 'info' AND %s" % condition.sql(conn), condition.filler).fetchall()
conn.close()
lookup = {}
if len(results) > 0:
for result in results:
id = result[0]
tags = result[1].strip(",").split(",")
lookup[id] = tags if tags[0] else []
return lookup
def query_random_music(self, count, condition: Condition = None):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
results = []
if condition is None:
condition = Condition().and_not_sub_condition(Condition().and_equal('id', 'info'))
results = cursor.execute("SELECT id, type, title, metadata, tags, path, keywords FROM music "
"WHERE id IN (SELECT id FROM music WHERE %s ORDER BY RANDOM() LIMIT ?) ORDER BY RANDOM()"
% condition.sql(conn), condition.filler + [count]).fetchall()
conn.close()
return self._result_to_dict(results)
def _result_to_dict(self, results):
if len(results) > 0:
music_dicts = []
for result in results:
music_dict = json.loads(result[3])
music_dict['type'] = result[1]
music_dict['title'] = result[2]
music_dict['id'] = result[0]
music_dict['tags'] = result[4].strip(",").split(",") if result[4] else []
music_dict['path'] = result[5]
music_dict['keywords'] = result[6]
music_dicts.append(music_dict)
return music_dicts
else:
return []
def delete_music(self, condition: Condition):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute("DELETE FROM music "
"WHERE %s" % condition.sql(conn), condition.filler)
conn.commit()
conn.close()
def drop_table(self):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute("DROP TABLE music")
conn.close()
class DatabaseMigration:
def __init__(self, settings_db: SettingsDatabase, music_db: MusicDatabase):
self.settings_db = settings_db
self.music_db = music_db
self.settings_table_migrate_func = {}
self.settings_table_migrate_func[0] = self.settings_table_migrate_from_0_to_2
self.settings_table_migrate_func[1] = self.settings_table_migrate_from_0_to_2
self.music_table_migrate_func = {}
self.music_table_migrate_func[0] = self.music_table_migrate_from_0_to_1
self.music_table_migrate_func[1] = self.music_table_migrate_from_1_to_2
def migrate(self):
self.settings_database_migrate()
self.music_database_migrate()
def settings_database_migrate(self):
conn = sqlite3.connect(self.settings_db.db_path)
cursor = conn.cursor()
if self.has_table('botamusique', conn):
current_version = 0
ver = cursor.execute("SELECT value FROM botamusique WHERE section='bot' "
"AND option='db_version'").fetchone()
if ver:
current_version = int(ver[0])
if current_version == SETTING_DB_VERSION:
conn.close()
return
else:
log.info(f"database: migrating from settings table version {current_version} to {SETTING_DB_VERSION}...")
while current_version < SETTING_DB_VERSION:
log.debug(f"database: migrate step {current_version}/{SETTING_DB_VERSION - 1}")
current_version = self.settings_table_migrate_func[current_version](conn)
log.info(f"database: migration done.")
cursor.execute("UPDATE botamusique SET value=? "
"WHERE section='bot' AND option='db_version'", (SETTING_DB_VERSION,))
else:
log.info(f"database: no settings table found. Creating settings table version {SETTING_DB_VERSION}.")
self.create_settings_table_version_2(conn)
conn.commit()
conn.close()
def music_database_migrate(self):
conn = sqlite3.connect(self.music_db.db_path)
cursor = conn.cursor()
if self.has_table('music', conn):
current_version = 0
ver = cursor.execute("SELECT title FROM music WHERE id='info'").fetchone()
if ver:
current_version = int(ver[0])
if current_version == MUSIC_DB_VERSION:
conn.close()
return
else:
log.info(f"database: migrating from music table version {current_version} to {MUSIC_DB_VERSION}...")
while current_version < MUSIC_DB_VERSION:
log.debug(f"database: migrate step {current_version}/{MUSIC_DB_VERSION - 1}")
current_version = self.music_table_migrate_func[current_version](conn)
log.info(f"database: migration done.")
cursor.execute("UPDATE music SET title=? "
"WHERE id='info'", (MUSIC_DB_VERSION,))
else:
log.info(f"database: no music table found. Creating music table version {MUSIC_DB_VERSION}.")
self.create_music_table_version_2(conn)
conn.commit()
conn.close()
def has_table(self, table, conn):
cursor = conn.cursor()
tables = cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?;", (table,)).fetchall()
if len(tables) == 0:
return False
return True
def create_settings_table_version_2(self, conn):
cursor = conn.cursor()
cursor.execute("CREATE TABLE IF NOT EXISTS botamusique ("
"section TEXT, "
"option TEXT, "
"value TEXT, "
"UNIQUE(section, option))")
cursor.execute("INSERT INTO botamusique (section, option, value) "
"VALUES (?, ?, ?)", ("bot", "db_version", 2))
conn.commit()
return 1
def create_music_table_version_1(self, conn):
cursor = conn.cursor()
cursor.execute("CREATE TABLE music ("
"id TEXT PRIMARY KEY, "
"type TEXT, "
"title TEXT, "
"keywords TEXT, "
"metadata TEXT, "
"tags TEXT, "
"path TEXT, "
"create_at DATETIME DEFAULT CURRENT_TIMESTAMP"
")")
cursor.execute("INSERT INTO music (id, title) "
"VALUES ('info', ?)", (MUSIC_DB_VERSION,))
conn.commit()
def create_music_table_version_2(self, conn):
self.create_music_table_version_1(conn)
def settings_table_migrate_from_0_to_2(self, conn):
cursor = conn.cursor()
cursor.execute("DROP TABLE botamusique")
conn.commit()
self.create_settings_table_version_2(conn)
# move music database into a separated file
if self.has_table('music', conn) and not os.path.exists(self.music_db.db_path):
log.info(f"database: move music db into separated file.")
cursor.execute(f"ATTACH DATABASE '{self.music_db.db_path}' AS music_db")
cursor.execute(f"SELECT sql FROM sqlite_master "
f"WHERE type='table' AND name='music'")
sql_create_table = cursor.fetchone()[0]
sql_create_table = sql_create_table.replace("music", "music_db.music")
cursor.execute(sql_create_table)
cursor.execute("INSERT INTO music_db.music SELECT * FROM music")
conn.commit()
cursor.execute("DETACH DATABASE music_db")
cursor.execute("DROP TABLE music")
return 2
def music_table_migrate_from_0_to_1(self, conn):
cursor = conn.cursor()
cursor.execute("ALTER TABLE music RENAME TO music_old")
conn.commit()
self.create_music_table_version_1(conn)
cursor.execute("INSERT INTO music (id, type, title, metadata, tags)"
"SELECT id, type, title, metadata, tags FROM music_old")
cursor.execute("DROP TABLE music_old")
conn.commit()
return 1 # return new version number
def music_table_migrate_from_1_to_2(self, conn):
items_to_update = self.db.query_music(Condition(), conn)
for item in items_to_update:
item['keywords'] = item['title']
if 'artist' in item:
item['keywords'] += ' ' + item['artist']
tags = []
for tag in item['tags']:
if tag:
tags.append(tag)
item['tags'] = tags
self.db.insert_music(item)
conn.commit()
return 2 # return new version number