import os import json import sqlite3 import logging from hashlib import sha512 from contextlib import closing logging = logging.getLogger("database") keys_in_table = ["title", "album", "artist", "type", "size"] def dict_factory(cursor, row): d = {} for idx, col in enumerate(cursor.description): d[col[0]] = row[idx] return d class NotFoundError(Exception): pass class PysonicDatabase(object): def __init__(self, path): self.sqlite_opts = dict(check_same_thread=False, cached_statements=0, isolation_level=None) self.path = path self.db = None self.open() self.migrate() def open(self): self.db = sqlite3.connect(self.path, **self.sqlite_opts) self.db.row_factory = dict_factory def migrate(self): # Create db queries = ["""CREATE TABLE 'meta' ( 'key' TEXT PRIMARY KEY NOT NULL, 'value' TEXT);""", """INSERT INTO meta VALUES ('db_version', '3');""", """CREATE TABLE 'nodes' ( 'id' INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 'parent' INTEGER NOT NULL, 'isdir' BOOLEAN NOT NULL, 'size' INTEGER NOT NULL DEFAULT -1, 'name' TEXT NOT NULL, 'type' TEXT, 'title' TEXT, 'album' TEXT, 'artist' TEXT, 'metadata' TEXT )""", """CREATE TABLE 'users' ( 'id' INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 'username' TEXT UNIQUE NOT NULL, 'password' TEXT NOT NULL, 'admin' BOOLEAN DEFAULT 0, 'email' TEXT)""", """CREATE TABLE 'stars' ( 'userid' INTEGER, 'nodeid' INTEGER, primary key ('userid', 'nodeid'))"""] with closing(self.db.cursor()) as cursor: cursor.execute("SELECT * FROM sqlite_master WHERE type='table' AND name='meta';") # Initialize DB if len(cursor.fetchall()) == 0: logging.warning("Initializing database") for query in queries: cursor.execute(query) else: # Migrate if old db exists version = int(cursor.execute("SELECT * FROM meta WHERE key='db_version';").fetchone()['value']) if version < 1: logging.warning("migrating database to v1 from %s", version) users_table = """CREATE TABLE 'users' ( 'id' INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 'username' TEXT UNIQUE NOT NULL, 'password' TEXT NOT NULL, 'admin' BOOLEAN DEFAULT 0, 'email' TEXT)""" cursor.execute(users_table) version = 1 if version < 2: logging.warning("migrating database to v2 from %s", version) stars_table = """CREATE TABLE 'stars' ( 'userid' INTEGER, 'nodeid' INTEGER, primary key ('userid', 'nodeid'))""" cursor.execute(stars_table) version = 2 if version < 3: logging.warning("migrating database to v3 from %s", version) size_col = """ALTER TABLE nodes ADD 'size' INTEGER NOT NULL DEFAULT -1;""" cursor.execute(size_col) version = 3 cursor.execute("""UPDATE meta SET value=? WHERE key="db_version";""", (str(version), )) logging.warning("db schema is version {}".format(version)) # Virtual file tree def getnode(self, node_id): return self.getnodes(node_id=node_id)[0] def _populate_meta(self, node): node['metadata'] = self.decode_metadata(node['metadata']) return node def getnodes(self, *parent_ids, node_id=None, types=None, limit=None, order=None): """ Find nodes that match the passed paramters. :param parent_ids: one or more parents to find children of :type parent_ids: int :param node_id: single node id to return :type node_id: int :param types: filter by type column :type types: list :param limit: number of records to limit to :param order: one of ("rand") to select ordering mode """ query = "SELECT * FROM nodes WHERE " qargs = [] def add_filter(name, values): nonlocal query nonlocal qargs query += "{} in (".format(name) for value in (values if type(values) in [list, tuple] else [values]): query += "?, " qargs += [value] query = query.rstrip(", ") query += ") AND" if node_id: add_filter("id", node_id) if parent_ids: add_filter("parent", parent_ids) if types: add_filter("type", types) query = query.rstrip(" AND").rstrip("WHERE ") if order: query += "ORDER BY " if order == "rand": query += "RANDOM()" if limit: # TODO 2-item tuple limit query += " limit {}".format(limit) with closing(self.db.cursor()) as cursor: return list(map(self._populate_meta, cursor.execute(query, qargs).fetchall())) def addnode(self, parent_id, fspath, name, size=-1): fullpath = os.path.join(fspath, name) is_dir = os.path.isdir(fullpath) return self._addnode(parent_id, name, is_dir, size=size) def _addnode(self, parent_id, name, is_dir=True, size=-1): with closing(self.db.cursor()) as cursor: cursor.execute("INSERT INTO nodes (parent, isdir, name, size) VALUES (?, ?, ?, ?);", (parent_id, 1 if is_dir else 0, name, size)) return self.getnode(cursor.lastrowid) def delnode(self, node_id): deleted = 1 for child in self.getnodes(node_id): deleted += self.delnode(child["id"]) with closing(self.db.cursor()) as cursor: cursor.execute("DELETE FROM nodes WHERE id=?;", (node_id, )) return deleted def update_metadata(self, node_id, mergedict=None, **kwargs): mergedict = mergedict if mergedict else {} mergedict.update(kwargs) with closing(self.db.cursor()) as cursor: for table_key in keys_in_table: if table_key in mergedict: cursor.execute("UPDATE nodes SET {}=? WHERE id=?;".format(table_key), (mergedict[table_key], node_id)) other_meta = {k: v for k, v in mergedict.items() if k not in keys_in_table} if other_meta: metadata = self.get_metadata(node_id) metadata.update(other_meta) cursor.execute("UPDATE nodes SET metadata=? WHERE id=?;", (json.dumps(metadata), node_id, )) def get_metadata(self, node_id): node = self.getnode(node_id) meta = node["metadata"] meta.update({item: node[item] for item in keys_in_table}) return meta def decode_metadata(self, metadata): if metadata: return json.loads(metadata) return {} def hashit(self, unicode_string): return sha512(unicode_string.encode('UTF-8')).hexdigest() def validate_password(self, realm, username, password): with closing(self.db.cursor()) as cursor: users = cursor.execute("SELECT * FROM users WHERE username=? AND password=?;", (username, self.hashit(password))).fetchall() return bool(users) def add_user(self, username, password, is_admin=False): with closing(self.db.cursor()) as cursor: cursor.execute("REPLACE INTO users (username, password, admin) VALUES (?, ?, ?)", (username, self.hashit(password), is_admin)).fetchall() def get_user(self, user): with closing(self.db.cursor()) as cursor: try: column = "id" if type(user) is int else "username" return cursor.execute("SELECT * FROM users WHERE {}=?;".format(column), (user, )).fetchall()[0] except IndexError: raise NotFoundError("User doesn't exist") def set_starred(self, user_id, node_id, starred=True): with closing(self.db.cursor()) as cursor: if starred: query = "INSERT INTO stars (userid, nodeid) VALUES (?, ?);" else: query = "DELETE FROM stars WHERE userid=? and nodeid=?;" try: cursor.execute(query, (user_id, node_id)) except sqlite3.IntegrityError: pass def get_starred_items(self, for_user_id=None): with closing(self.db.cursor()) as cursor: q = """SELECT n.* FROM nodes as n INNER JOIN stars as s ON s.nodeid = n.id""" qargs = [] if for_user_id: q += """ AND userid=?""" qargs += [int(for_user_id)] return list(map(self._populate_meta, cursor.execute(q, qargs).fetchall()))