~comcloudway/melon

b2c148ea33c6d28d158e1917f7db17430ca1f652 — Jakob Meier 7 months ago bf67c2c 0.1.0.beta
Migrated to a permanent sqlite3 database
M README.md => README.md +1 -0
@@ 24,6 24,7 @@ Of course, you are welcome to send issues and pull requests via email as well:
- `python3`
- `py3-beautifulsoup4`
- `py3-requests`
- `py3-sqlite3`
- `gtk4.0`
- `libadwaita`
- `webkit2gtk-6.0`

M main.py => main.py +9 -1
@@ 2,13 2,21 @@ import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
from gi.repository import Gtk, Adw
from gi.repository import Gtk, Adw, Gio

from melon.window import MainWindow
from melon.models import init_db
from melon.servers.utils import get_server_instance, load_server, get_servers_list

class MyApp(Adw.Application):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        # initialize db
        init_db()
        # this has to wait till the db is initialized
        for _,server_data in get_servers_list().items():
            instance = get_server_instance(server_data)
            load_server(instance)
        self.connect('activate', self.on_activate)

    def on_activate(self, app):

M melon/browse/channel.py => melon/browse/channel.py +2 -2
@@ 65,8 65,8 @@ class BrowseChannelScreen(Adw.NavigationPage):
        self.scrollview = Gtk.ScrolledWindow()
        self.box = Adw.PreferencesPage()
        self.about = Adw.PreferencesGroup()
        self.about.set_title(channel.name)
        self.about.set_description(channel.bio)
        self.about.set_title(channel.name.replace("&", "&"))
        self.about.set_description(channel.bio.replace("&", "&"))

        self.results = None


M melon/home/history.py => melon/home/history.py +1 -0
@@ 15,6 15,7 @@ class History(ViewStackPage):
        app_settings = get_app_settings()
        # make sure only videos from available servers are shown
        hist = filter_resources(get_history(), app_settings, access=lambda x:x[0])
        hist.reverse()
        if len(hist) == 0:
            status = Adw.StatusPage()
            status.set_title("*crickets chirping*")

M melon/models/__init__.py => melon/models/__init__.py +493 -115
@@ 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 ]

A melon/models/callbacks.py => melon/models/callbacks.py +28 -0
@@ 0,0 1,28 @@
##################################################
# 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()

M melon/servers/__init__.py => melon/servers/__init__.py +1 -1
@@ 49,7 49,7 @@ class Preference:
    # NOTE: should correspond to type
    default: any
    # current value
    # TODO: needs to be set using data from melons internal database
    # will be updated from initial database when initalized (when used in a server)
    value: any
    def __init__(self, id, name, description, type, default, value):
        self.id = id

M melon/servers/nebula/__init__.py => melon/servers/nebula/__init__.py +2 -1
@@ 41,7 41,8 @@ class Nebula(Server):

    # nebula might contain 18+ material
    # so we have to indicate that this may contain nsfw content
    is_nsfw = True,
    is_nsfw = True
    requires_login = True

    def get_external_url(self):
        """

M melon/servers/utils.py => melon/servers/utils.py +0 -7
@@ 219,10 219,3 @@ def get_servers_list(include_disabled=False, order_by='name'):
                )

    return servers

########
# INITIALIZE SERVER DB
########
for _,server_data in get_servers_list().items():
    instance = get_server_instance(server_data)
    load_server(instance)

A melon/utils.py => melon/utils.py +21 -0
@@ 0,0 1,21 @@
import gi
gi.require_version('Gdk', '4.0')
from gi.repository import Gio,GLib
from functools import cache
import os

def is_flatpak():
    return os.path.exists(os.path.join(GLib.get_user_runtime_dir(), 'flatpak-info'))

@cache
def get_data_dir():
    data_dir_path = GLib.get_user_data_dir()

    if not is_flatpak():
        base_path = data_dir_path
        data_dir_path = os.path.join(base_path, 'melon')

        if not os.path.exists(data_dir_path):
            os.mkdir(data_dir_path)

    return data_dir_path

M melon/window.py => melon/window.py +1 -1
@@ 73,7 73,7 @@ class MainWindow(Adw.ApplicationWindow):
    def open_about(self,action,prefs):
        dialog = Adw.AboutWindow()
        dialog.set_application_name("Melon")
        dialog.set_version("1.0")
        dialog.set_version("0.1.0")
        dialog.set_developer_name("Jakob Meier (@comcloudway)")
        dialog.set_license_type(Gtk.License(Gtk.License.GPL_3_0))
        dialog.set_comments("Stream videos on the go")