@@ 1,7 1,13 @@
from copy import deepcopy
from enum import Enum,auto
-from melon.servers import Channel, Playlist, Resource
from datetime import datetime
+from functools import cache
+import sqlite3
+import os
+
+from melon.servers import Video, Channel, Playlist, Resource
+from melon.utils import get_data_dir
+from melon.models.callbacks import notify, register_callback
class LocalPlaylist():
# id used for local identification
@@ 33,100 39,274 @@ class PlaylistWrapper:
return PlaylistWrapper(PlaylistWrapperType.EXTERNAL, playlist)
##################################################
-# CALLBACK API
-# should be used by all functions that change data in the database
-# all non-currently-shown, non-autoupdating pages should subscribe to the updates
-##################################################
-# stores functions by callback id by category
-# the callback id is used to ensure that the same function doesn't accidentally
-# get declared twice
-callbacks = {
- "channels_changed": {},
- "playlists_changed": {},
- "history_changed": {},
- "settings_changed": {}
-}
-def register_callback(target: str, callback_id:str, function):
- if not target in callbacks:
- return
- callbacks[target][callback_id] = function
-def unregister_callback(target: str, callback_id: str):
- if not target in callbacks:
- return
- if callback_id in callbacks[target]:
- callbacks[target].pop(callback_id)
-def notify(target:str):
- if not target in callbacks:
- return
- for _, func in callbacks[target].items():
- func()
-
-##################################################
# DATABASE IMPLEMENTATION
-# TODO: replace with sqlite3
##################################################
-stateless_db = {
- "app": {
- # NOTE: they are set to False to save bandwidth
- # True if images should be shown when searching or browsing public feeds
- "show_images_in_browse": False,
- # True if images should be shown when browsing channels/playlists/home feed
- "show_images_in_feed": True,
-
- "nsfw_content": True,
- "nsfw_only_content": False,
- "login_required": False
- },
- # id:server lookup table
- "servers": {},
- # list of Channel s
- "channels": [],
- # list of PlaylistWrapper s
- "playlists": [],
- # list of (Video, uts:int)
- "history": []
+
+def adapt_json(data):
+ return (json.dumps(data, sort_keys=True)).encode()
+
+def convert_json(blob):
+ return json.loads(blob.decode())
+
+sqlite3.register_adapter(dict, adapt_json)
+sqlite3.register_adapter(list, adapt_json)
+sqlite3.register_adapter(tuple, adapt_json)
+sqlite3.register_converter('json', convert_json)
+
+database_path = os.path.join(get_data_dir(), "melon.db")
+
+def connect_to_db():
+ con = sqlite3.connect(database_path)
+ return con
+def execute_sql(conn:sqlite3.Connection, sql, *sqlargs, many=False) -> bool:
+ try:
+ c = conn.cursor()
+ if many:
+ c.executemany(sql, *sqlargs)
+ else:
+ c.execute(sql, *sqlargs)
+ conn.commit()
+ c.close()
+ except:
+ return False
+ else:
+ return True
+
+VERSION = 1
+
+app_conf_template = {
+ # NOTE: they are set to False to save bandwidth
+ # True if images should be shown when searching or browsing public feeds
+ "show_images_in_browse": False,
+ # True if images should be shown when browsing channels/playlists/home feed
+ "show_images_in_feed": True,
+ # show plugins that may contains nsfw content
+ # as it will most likely include every plugin
+ "nsfw_content": True,
+ # do not include plugins with only nsfw content by default
+ "nsfw_only_content": False,
+ # do not include apps that require login by default
+ "login_required": False
}
+
server_settings_template = {
+ # stored in servers table
"enabled": True,
+ # stored in server_settings table
+ "custom": {},
+ # do not require being stored
"nsfw_content": False,
"nsfw_only_content": False,
- "login_required": False,
- "custom": {}
+ "login_required": False
}
+def die():
+ # TODO remove
+ print("ERROR")
+ raise "DB ERROR"
+
+def init_db():
+ conn = connect_to_db()
+ if conn is None:
+ # "Houston, we have a problem"
+ return
+ version = conn.execute("PRAGMA user_version").fetchone()[0]
+ if version == 0:
+ # initial version (defaults to 0, so this means the DB isn't initialized)
+ # perform initial setup
+ # application settings
+ execute_sql(conn, """
+ CREATE TABLE appconf(
+ key,
+ value,
+ PRIMARY KEY(key)
+ )
+ """) or die()
+ execute_sql(conn,
+ "INSERT INTO appconf VALUES (?,?)",
+ [ (key, value) for key,value in app_conf_template.items() ],
+ many=True)
+ # enabled servers lookup table
+ execute_sql(conn, """CREATE TABLE servers(
+ server,
+ enabled,
+ PRIMARY KEY(server)
+ )""") or die()
+ # server settings
+ execute_sql(conn, """
+ CREATE TABLE server_settings(
+ server,
+ key,
+ value,
+ PRIMARY KEY(server, key))""") or die()
+ # channels
+ execute_sql(conn,"""
+ CREATE TABLE channels(
+ server,
+ id,
+ url,
+ name,
+ bio,
+ avatar,
+ PRIMARY KEY(server, id)
+ )""") or die()
+ # videos
+ execute_sql(conn, """
+ CREATE TABLE videos(
+ server,
+ id,
+ url,
+ title,
+ description,
+ thumbnail,
+ channel_id,
+ channel_name,
+ PRIMARY KEY(server, id))
+ """) or die()
+ # playlists
+ execute_sql(conn, """
+ CREATE TABLE playlists(
+ server,
+ id,
+ url,
+ title,
+ description,
+ channel_id,
+ channel_name,
+ thumbnail,
+ PRIMARY KEY(server, id)
+ )
+ """) or die()
+ # playlist <-> videos
+ execute_sql(conn, """
+ CREATE TABLE playlist_content(
+ server,
+ playlist_id,
+ video_id,
+ FOREIGN KEY(playlist_id, server) REFERENCES playlists(id, server),
+ FOREIGN KEY(video_id, server) REFERENCES videos(id, server),
+ PRIMARY KEY(server, playlist_id, video_id)
+ )
+ """) or die()
+ # local playlists
+ execute_sql(conn, """
+ CREATE TABLE local_playlists(
+ id,
+ title,
+ description,
+ thumbnail,
+ PRIMARY KEY(id)
+ )""") or die()
+ # local-playlists <-> videos
+ execute_sql(conn, """
+ CREATE TABLE local_playlist_content(
+ id,
+ video_id,
+ video_server,
+ FOREIGN KEY(video_id, video_server) REFERENCES videos(id, server),
+ PRIMARY KEY(id, video_id, video_server)
+ )
+ """) or die()
+ # history
+ execute_sql(conn, """
+ CREATE TABLE history(
+ timestamp,
+ video_id,
+ video_server,
+ FOREIGN KEY(video_id, video_server) REFERENCES videos(id, server)
+ )
+ """) or die()
+ # subscriptions
+ execute_sql(conn, """
+ CREATE TABLE subscriptions(
+ video_id,
+ server,
+ PRIMARY KEY(server, video_id),
+ FOREIGN KEY(server, video_id) REFERENCES videos(server, id)
+ )
+ """)
+ # bookmarked playlists
+ execute_sql(conn, """
+ CREATE TABLE bookmarked_playlists(
+ server,
+ playlist_id,
+ PRIMARY KEY(server, playlist_id),
+ FOREIGN KEY(server, playlist_id) REFERENCES playlists(server, id)
+ )
+ """)
+
+ # the initial setup should always initialize the newest version
+ # and not depend on migrations
+ version = VERSION
+ # MIGRATIONS go here
+ # if minv < = version <= maxv:
+
+ # MIGRATIONS FINISHED
+ execute_sql(conn, f"PRAGMA user_version = {VERSION}") or die()
+
+# additional server database
+# for data not stored in database
+servers = {}
+
def init_server_settings(sid:str) -> bool:
"""
Make sure server config is initialized
returns true if changed
"""
- if sid in stateless_db["servers"]:
- return False
- stateless_db["servers"][sid] = deepcopy(server_settings_template)
- notify("settings_changed")
- return True
+ servers[sid] = deepcopy(server_settings_template)
+ conn = connect_to_db()
+ # NOTE: won't initialize the settings table,
+ # as that will only be populated once an entry needs to be stored
+ results = conn.execute(
+ "SELECT * FROM servers WHERE server = ?", (sid,)).fetchall()
+ if len(results) == 0:
+ # initialize enabled config
+ execute_sql(conn, "INSERT INTO servers VALUES (?, ?)", (sid, server_settings_template["enabled"]))
+ notify("settings_changed")
+ return True
+ return False
# DO NOT CALL
# used internally
def load_server(server):
sid = server.id
init_server_settings(sid)
- stateless_db["servers"][sid]["nsfw_content"] = server.is_nsfw
- stateless_db["servers"][sid]["nsfw_content_only"] = server.is_nsfw_only
- stateless_db["servers"][sid]["login_required"] = server.requires_login
+ servers[sid]["nsfw_content"] = server.is_nsfw
+ servers[sid]["nsfw_content_only"] = server.is_nsfw_only
+ servers[sid]["login_required"] = server.requires_login
notify("settings_changed")
def set_server_setting(sid:str, pref:str, value):
init_server_settings(sid)
- stateless_db["servers"][sid]["custom"][pref] = value
+ conn = connect_to_db()
+ execute_sql(conn,
+ "INSERT INTO server_settings VALUE (?,?,?)",
+ (sid, pref, value))
notify("settings_changed")
def get_server_settings(sid:str):
init_server_settings(sid)
- return stateless_db["servers"][sid]
+ base = servers[sid]
+ conn = connect_to_db()
+ value = conn.execute(
+ "SELECT enabled FROM servers WHERE server = ?", (sid,)).fetchone()[0]
+ base["enabled"] = value
+ results = conn.execute(
+ "SELECT key, value FROM server_settings WHERE server = ?", (sid,)).fetchall()
+ for setting in results:
+ base[setting[0]] = setting[1]
+ return base
def is_server_enabled(server_id: str):
- prefs = get_server_settings(server_id)
- return prefs["enabled"]
+ conn = connect_to_db()
+ value = conn.execute(
+ "SELECT enabled FROM servers WHERE server = ?", (server_id,)).fetchone()[0]
+ return value
def ensure_server_disabled(server_id: str):
if not is_server_enabled(server_id):
return
- stateless_db["servers"][server_id]["enabled"] = False
+ conn = connect_to_db()
+ execute_sql(conn, """
+ UPDATE servers
+ SET enabled = ?
+ WHERE server = ?
+ """, (False, server_id))
# notify channels and playlists, because available channels might be different
notify("channels_changed")
notify("playlists_changed")
@@ 134,103 314,301 @@ def ensure_server_disabled(server_id: str):
def ensure_server_enabled(server_id: str):
if is_server_enabled(server_id):
return
- stateless_db["servers"][server_id]["enabled"] = True
+ conn = connect_to_db()
+ execute_sql(conn, """
+ UPDATE servers
+ SET enabled = ?
+ WHERE server = ?
+ """, (True, server_id))
# notify channels and playlists, because available channels might be different
notify("channels_changed")
notify("playlists_changed")
notify("settings_changed")
def get_app_settings():
- return stateless_db["app"]
+ base = deepcopy(app_conf_template)
+ conn = connect_to_db()
+ results = conn.execute("SELECT key, value FROM appconf").fetchall()
+ for setting in results:
+ base[setting[0]] = setting[1]
+ return base
def set_app_setting(pref:str, value):
- stateless_db["app"][pref] = value
+ conn = connect_to_db()
+ execute_sql(conn, """
+ UPDATE appconf
+ SET value = ?
+ WHERE key = ?
+ """, (value, pref))
# notify channels and playlists, because available channels might be different
notify("channels_changed")
notify("playlists_changed")
notify("settings_changed")
def is_subscribed_to_channel(server_id: str, channel_id:str) -> bool:
- for channel in stateless_db["channels"]:
- if channel.id == channel_id and channel.server == server_id:
- return True
- return False
+ conn = connect_to_db()
+ results = conn.execute("""
+ SELECT *
+ FROM subscriptions
+ WHERE server = ? AND video_id = ?
+ """, (server_id, channel_id)).fetchall()
+ if len(results) == 0:
+ return False
+ return True
+def has_channel(channel_server, channel_id):
+ conn = connect_to_db()
+ results = conn.execute("""
+ SELECT *
+ FROM channels
+ WHERE server = ? AND id = ?
+ """, (channel_server, channel_id)).fetchall()
+ return len(results) != 0
+def ensure_channel(channel:Channel):
+ if has_channel(channel.server, channel.id):
+ # Update channel data
+ conn = connect_to_db()
+ execute_sql(conn, """
+ UPDATE channels
+ SET url = ?, name = ?, bio = ?, avatar = ?
+ WHERE server = ?, id = ?
+ """,
+ (channel.url, channel.name, channel.bio, channel.avatar, channel.server, channel.id))
+ else:
+ # insert channel data
+ conn = connect_to_db()
+ execute_sql(conn, """
+ INSERT INTO channels
+ VALUES (?,?,?,?,?,?)
+ """,
+ (channel.server, channel.id, channel.url, channel.name, channel.bio, channel.avatar))
+ notify("channels_changed")
def ensure_subscribed_to_channel(channel: Channel):
if is_subscribed_to_channel(channel.server, channel.id):
return
- stateless_db["channels"].append(channel)
+ ensure_channel(channel)
+ conn = connect_to_db()
+ execute_sql(conn, """
+ INSERT INTO subscriptions
+ VALUES (?,?)
+ """, (channel.id, channel.server))
notify("channels_changed")
def ensure_unsubscribed_from_channel(server_id: str, channel_id:str):
- stateless_db["channels"] = [ channel for channel in stateless_db["channels"] if not (channel.id == channel_id and server.id == server_id)]
+ conn = connect_to_db()
+ execute_sql(conn, """
+ DELETE FROM subscriptions
+ WHERE server = ?, id = ?
+ """, (server_id, channel_id))
notify("channels_changed")
def get_subscribed_channels() -> list[Channel]:
- return stateless_db["channels"]
+ conn = connect_to_db()
+ results = conn.execute("""
+ SELECT channels.server, channels.id, url, name, bio, avatar
+ FROM channels, subscriptions
+ WHERE channels.id = subscriptions.video_id AND channels.server == subscriptions.server
+ """).fetchall()
+ return [ Channel(d[0], d[2], d[1], d[3], d[4], d[5]) for d in results ]
+
+def has_video(server_id, video_id):
+ conn = connect_to_db()
+ results = conn.execute("""
+ SELECT *
+ FROM videos
+ WHERE server = ? AND id = ?
+ """, (server_id, video_id)).fetchall()
+ return len(results) != 0
+def ensure_video(vid):
+ if has_video(vid.server, vid.id):
+ # update video data
+ conn = connect_to_db()
+ execute_sql(conn, """
+ UPDATE videos
+ SET url = ?, title = ?, description = ?, thumbnail = ?, channel_id = ?, channel_name = ?
+ WHERE server = ?, id = ?
+ """,
+ (vid.url, vid.title, vid.description, vid.thumbnail, vid.channel[1], vid.channel[0], vid.server, vid.id))
+ else:
+ # create video
+ conn = connect_to_db()
+ execute_sql(conn, """
+ INSERT INTO videos
+ VALUES (?,?,?,?,?,?,?,?)
+ """,
+ (vid.server, vid.id, vid.url, vid.title, vid.description, vid.thumbnail, vid.channel[1], vid.channel[0])
+ )
def has_bookmarked_external_playlist(server_id: str, playlist_id:str) -> bool:
- for playlist in stateless_db["playlists"]:
- if playlist.type is PlaylistWrapperType.LOCAL:
- continue
- if playlist.inner.server == server_id and playlist.inner.id == playlist_id:
- return True
- return False
+ conn = connect_to_db()
+ results = conn.execute("""
+ SELECT *
+ FROM playlists
+ WHERE server = ? AND id = ?
+ """,
+ (server_id, playlist_id)).fetchall()
+ return len(results) != 0
def has_playlist(p: PlaylistWrapper) -> bool:
- for playlist in stateless_db["playlists"]:
- if playlist.type != p.type:
- continue
- if playlist.type == PlaylistWrapperType.LOCAL:
- if playlist.inner.id == p.inner.id:
- return True
- else:
- if playlist.inner.server == p.inner.server and playlist.inner.id == p.inner.id:
- return True
+ conn = connect_to_db()
+ if p.type == PlaylistWrapperType.LOCAL:
+ results = conn.execute("""
+ SELECT *
+ FROM local_playlist
+ WHERE id = ?
+ """, (p.inner.id)).fetchall()
+ return len(results) != 0
+ else:
+ results = conn.execute("""
+ SELECT *
+ FROM bookmarked_playlists
+ WHERE server = ? AND playlist_id = ?
+ """, (p.inner.server, p.inner.id)).fetchall()
+ return len(results) != 0
return False
def ensure_playlist(playlist: PlaylistWrapper):
- if has_playlist(playlist):
- return
- stateless_db["playlists"].append(playlist)
+ if playlist.type == PlaylistWrapperType.LOCAL:
+ if has_playlist(playlist):
+ # update playlist data
+ conn = connect_to_db()
+ execute_sql(conn, """
+ UPDATE local_playlists
+ SET title = ?, description = ?, thumbnail = ?
+ WHERE id = ?
+ """,
+ (playlist.inner.title, playlist.inner.description, playlist.inner.thumbnail, playlist.inner.id))
+ else:
+ # insert new playlist
+ conn = connect_to_db()
+ execute_sql(conn, """
+ INSERT INTO local_playlists
+ VALUES (?,?,?,?)
+ """,
+ (playlist.inner.id, playlist.inner.title, playlist.inner.description, playlist.inner.thumbnail))
+ else:
+ if has_playlist(playlist):
+ # update playlist data
+ conn = connect_to_db()
+ execute_sql(conn, """
+ UPDATE playlists
+ SET url = ?, title = ?, description = ?, channel_id = ?, channel_name = ?, thumbnail = ?
+ WHERE server = ?, id = ?
+ """,
+ (playlist.inner.url, playlist.inner.title, playlist.inner.description, playlist.inner.channel[1], playlist.inner.channel[0], playlist.inner.thumbnail, playlist.inner.server, playlist.inner.id))
+ else:
+ # insert new playlist
+ conn = connect_to_db()
+ execute_sql(conn, """
+ INSERT INTO playlists
+ VALUES (?,?,?,?,?,?,?,?)
+ """,
+ (playlist.inner.server, playlist.inner.id, playlist.inner.url, playlist.inner.title, playlist.inner.description,playlist.inner.channel[1], playlist.inner.channel[0], playlist.inner.thumbnail))
notify("playlists_changed")
def ensure_bookmark_external_playlist(playlist: Playlist):
wrapped = PlaylistWrapper.from_external(playlist)
# NOTE: this will automatically notify listeners
ensure_playlist(wrapped)
+ conn = connect_to_db()
+ execute_sql(conn, """
+ INSERT INTO bookmarked_playlists
+ VALUES (?,?)
+ """,
+ (playlist.server, playlist.id))
+ # we need to rerun the notification because now the playlist was linked
+ notify("playlists_changed")
def ensure_unbookmark_external_playlist(server_id: str, playlist_id: str):
- stateless_db["playlists"] = [ playlist for playlist in stateless_db["playlists"] if not (playlist.type == PlaylistWrapperType.EXTERNAL and playlist.inner.server == server_id and playlist.inner.id == playlist_id)]
+ # only removes the playlist from the linking table
+ conn = connect_to_db()
+ execute_sql(conn, """
+ DELETE FROM bookmarked_playlists
+ WHERE server = ?, playlist_id = ?
+ """,
+ (server_id, playlist_id))
notify("playlists_changed")
def new_local_playlist(name: str, description: str, videos=[]) -> int:
"""Create a new local playlist and return id"""
- id = len(stateless_db["playlists"])
- base = LocalPlaylist(id, name, description)
- base.content = videos
- playlist = PlaylistWrapper.from_local(base)
- # NOTE: this will automatically notify listeners
- ensure_playlist(playlist)
+ id = len(get_playlists())
+ conn = connect_to_db()
+ execute_sql(conn, """
+ INSERT INTO local_playlists
+ VALUES (?,?,?,?)
+ """,
+ (id, name, description, None))
+ notify("playlists_changed")
return id
def ensure_delete_local_playlist(playlist_id:int):
- stateless_db["playlists"] = [ playlist for playlist in stateless_db["playlists"] if not (playlist is PlaylistWrapperType.LOCAL and playlist.inner.id == playlist_id)]
+ conn = connect_to_db()
+ execute_sql(conn, """
+ DELETE FROM local_playlist_content
+ WHERE id = ?
+ """, (playlist_id,))
+ execute_sql(conn,"""
+ DELETE FROM local_playlists
+ WHERE id = ?
+ """, (playlist_id,))
notify("playlists_changed")
def get_playlists() -> list[PlaylistWrapper]:
- return stateless_db["playlists"]
+ results = []
+ conn = connect_to_db()
+ # get all local playlists
+ local = conn.execute("""
+ SELECT id, title, description, thumbnail
+ FROM local_playlists
+ """).fetchall()
+ for collection in local:
+ p = LocalPlaylist(collection[0], collection[2], collection[1])
+ results.append(PlaylistWrapper.from_local(p))
+ # get all bookmark playlists
+ glob = conn.execute("""
+ SELECT playlists.server, url, id, title, channel_id, channel_name, thumbnail
+ FROM playlists, bookmarked_playlists
+ WHERE playlists.server = bookmarked_playlists.server AND playlists.id = bookmarked_playlists.playlist_id
+ """)
+ for collection in glob:
+ p = Playlist(collection[0], collection[1], collection[2], collection[3], (collection[5], collection[4]), collection[6])
+ results.append(PlaylistWrapper.from_external(p))
+ return results
def add_to_local_playlist(playlist_id:int, vid):
- for playlist in stateless_db["playlists"]:
- if playlist.type == PlaylistWrapperType.EXTERNAL:
- continue
- if playlist.inner.id == playlist_id:
- playlist.inner.content.append(vid)
+ ensure_video(vid)
+ conn = connect_to_db()
+ execute_sql(conn, """
+ INSERT INTO local_playlist_content
+ VALUES (?, ?, ?)
+ """, (playlist_id, vid.id, vid.server))
notify("playlists_changed")
def get_local_playlist(playlist_id:int) -> LocalPlaylist:
- for playlist in stateless_db["playlists"]:
- if playlist.type == PlaylistWrapperType.EXTERNAL:
- continue
- if playlist.inner.id == playlist_id:
- return playlist.inner
+ conn = connect_to_db()
+ resp = conn.execute("""
+ SELECT id, title, description, thumbnail
+ FROM local_playlists
+ WHERE id = ?
+ """, (playlist_id,)).fetchone()
+ p = LocalPlaylist(resp[0], resp[1], resp[2])
+ p.thumbnail = resp[3]
+ vids = conn.execute("""
+ SELECT server, videos.id, url, title, description, thumbnail, channel_id, channel_name
+ FROM local_playlist_content, videos
+ WHERE local_playlist_content.id = ? AND local_playlist_content.video_id = videos.id AND local_playlist_content.video_server = videos.server
+ """, (playlist_id,)).fetchall()
+ p.content = [ Video(d[0], d[2], d[1], d[3], (d[7], d[6]), d[4], d[5]) for d in vids ]
+ return p
def add_to_history(vid):
"""
Takes :Video type and adds the video to the history
automatically adds a uts to the video
"""
- stateless_db["history"].append((vid, datetime.now().timestamp()))
+ uts = datetime.now().timestamp()
+ ensure_video(vid)
+ conn = connect_to_db()
+ execute_sql(conn, """
+ INSERT INTO history
+ VALUES (?, ?, ?)
+ """,
+ (uts, vid.id, vid.server))
def get_history():
"""
Returns a list of (Video, uts) tuples
"""
- return stateless_db["history"]
+ conn = connect_to_db()
+ results = conn.execute("""
+ SELECT timestamp, server, id, url, title, description, thumbnail, channel_id, channel_name
+ FROM history,videos
+ WHERE history.video_id = id AND history.video_server = server
+ """).fetchall()
+ return [ (Video(d[1], d[3], d[2], d[4], (d[8], d[7]), d[5], d[6]), d[0]) for d in results ]