~comcloudway/melon

30671537ff1072ca07bc7c622001cadac93272cd — Jakob Meier 3 months ago 740c60d
Format files using black
M melon/application.py => melon/application.py +6 -4
@@ 1,12 1,14 @@
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, Gio, GLib

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


class Application(Adw.Application):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)


@@ 15,10 17,10 @@ class Application(Adw.Application):
        # initialize db
        init_db()
        # this has to wait till the db is initialized
        for _,server_data in get_servers_list().items():
        for _, server_data in get_servers_list().items():
            instance = get_server_instance(server_data)
            load_server(instance)
        self.connect('activate', self.on_activate)
        self.connect("activate", self.on_activate)
        self.connect("shutdown", lambda _: notify("quit"))

    def on_activate(self, app):

M melon/background.py => melon/background.py +6 -1
@@ 1,15 1,18 @@
import threading
from time import sleep

class Queue():

class Queue:
    tasks = []
    thread = None
    starting = False

    def __init__(self, workers=1):
        for i in range(workers):
            self.thread = threading.Thread(target=self.run, name=str(i))
            self.thread.daemon = True
            self.thread.start()

    def run(self):
        while True:
            while not self.tasks:


@@ 19,7 22,9 @@ class Queue():
            args = task[1]
            # run task
            func(*args)

    def add(self, func, *args):
        self.tasks.append((func, args))


queue = Queue(5)

M melon/browse/__init__.py => melon/browse/__init__.py +13 -5
@@ 1,7 1,8 @@
import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, Gio, Gdk, GLib
from gettext import gettext as _



@@ 9,6 10,7 @@ from melon.widgets.iconbutton import IconButton
from melon.servers.utils import get_allowed_servers_list
from melon.models import get_app_settings, register_callback


class BrowseScreen(Adw.NavigationPage):
    def update(self):
        servers = get_allowed_servers_list(get_app_settings())


@@ 18,7 20,9 @@ class BrowseScreen(Adw.NavigationPage):
            status.set_title(_("*crickets chirping*"))
            status.set_description(_("There are no available servers"))
            status.set_icon_name("weather-few-clouds-night-symbolic")
            icon_button = IconButton(_("Enable servers in the settings menu"), "list-add-symbolic")
            icon_button = IconButton(
                _("Enable servers in the settings menu"), "list-add-symbolic"
            )
            icon_button.set_action_name("win.prefs")
            box = Gtk.CenterBox()
            box.set_center_widget(icon_button)


@@ 27,13 31,17 @@ class BrowseScreen(Adw.NavigationPage):
        else:
            results = Adw.PreferencesGroup()
            results.set_title(_("Available Servers"))
            results.set_description(_("You can enable/disable and filter servers in the settings menu"))
            results.set_description(
                _("You can enable/disable and filter servers in the settings menu")
            )
            for server in servers:
                row = Adw.ActionRow()
                row.set_title(server["name"])
                row.set_subtitle(server["description"])
                icon = Adw.Avatar()
                icon.set_custom_image(Gdk.Texture.new_from_filename(server["logo_path"]))
                icon.set_custom_image(
                    Gdk.Texture.new_from_filename(server["logo_path"])
                )
                icon.set_size(32)
                row.add_prefix(icon)
                row.add_suffix(Gtk.Image.new_from_icon_name("go-next-symbolic"))

M melon/browse/channel.py => melon/browse/channel.py +19 -8
@@ 1,7 1,8 @@
import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, Gio, Gdk, GLib
from unidecode import unidecode
from gettext import gettext as _


@@ 14,7 15,12 @@ from melon.widgets.feeditem import AdaptiveFeedItem
from melon.widgets.preferencerow import PreferenceRow, PreferenceType, Preference
from melon.widgets.iconbutton import IconButton
from melon.models import get_app_settings
from melon.models import is_subscribed_to_channel, ensure_subscribed_to_channel, ensure_unsubscribed_from_channel
from melon.models import (
    is_subscribed_to_channel,
    ensure_subscribed_to_channel,
    ensure_unsubscribed_from_channel,
)


class BrowseChannelScreen(Adw.NavigationPage):
    def display_page(self, cont):


@@ 24,7 30,10 @@ class BrowseChannelScreen(Adw.NavigationPage):
        app_conf = get_app_settings()
        self.results.set_header_suffix(None)
        for res in cont:
            self.results.add(AdaptiveFeedItem(res, show_preview=app_conf["show_images_in_browse"]))
            self.results.add(
                AdaptiveFeedItem(res, show_preview=app_conf["show_images_in_browse"])
            )

    def fetch_page(self, page=1):
        """
        Fetch feed information


@@ 32,6 41,7 @@ class BrowseChannelScreen(Adw.NavigationPage):
        feed_id = self.current_feed
        cont = self.instance.get_channel_feed_content(self.channel.id, feed_id)
        GLib.idle_add(self.display_page, cont)

    def change_feed(self, feed_id):
        """
        change the feed and prepare layout


@@ 62,6 72,7 @@ class BrowseChannelScreen(Adw.NavigationPage):
            if feed.name == feed_name:
                self.change_feed(feed.id)
                break

    def on_open_in_browser(self, arg):
        Gtk.UriLauncher.new(uri=self.channel.url).launch()



@@ 94,7 105,7 @@ class BrowseChannelScreen(Adw.NavigationPage):
            _("Add latest uploads to home feed"),
            PreferenceType.TOGGLE,
            False,
            is_subscribed_to_channel(self.channel.server, self.channel.id)
            is_subscribed_to_channel(self.channel.server, self.channel.id),
        )
        sub_row = PreferenceRow(sub_pref)
        sub_row.set_callback(self.update_sub)


@@ 106,8 117,8 @@ class BrowseChannelScreen(Adw.NavigationPage):
            _("Channel feed"),
            _("This channel provides multiple feeds, choose which one to view"),
            PreferenceType.DROPDOWN,
            [ feed.name for feed in self.feeds ],
            self.default_feed_name
            [feed.name for feed in self.feeds],
            self.default_feed_name,
        )
        # display preference
        row = PreferenceRow(pref)


@@ 159,7 170,7 @@ class BrowseChannelScreen(Adw.NavigationPage):
        self.set_title(_("Channel"))

        self.header_bar = Adw.HeaderBar()
        self.external_btn = IconButton("","modem-symbolic")
        self.external_btn = IconButton("", "modem-symbolic")
        self.external_btn.connect("clicked", self.on_open_in_browser)
        self.header_bar.pack_end(self.external_btn)


M melon/browse/playlist.py => melon/browse/playlist.py +22 -9
@@ 1,7 1,8 @@
import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, Gio, Gdk, GLib
from unidecode import unidecode
from gettext import gettext as _


@@ 14,16 15,25 @@ from melon.widgets.feeditem import AdaptiveFeedItem
from melon.widgets.iconbutton import IconButton
from melon.widgets.preferencerow import PreferenceRow, PreferenceType, Preference
from melon.models import get_app_settings
from melon.models import has_bookmarked_external_playlist, ensure_bookmark_external_playlist, ensure_unbookmark_external_playlist
from melon.models import (
    has_bookmarked_external_playlist,
    ensure_bookmark_external_playlist,
    ensure_unbookmark_external_playlist,
)


class BrowsePlaylistScreen(Adw.NavigationPage):
    def render_page(self, items):
        app_conf = get_app_settings()
        for res in items:
            self.results.add(AdaptiveFeedItem(res, show_preview=app_conf["show_images_in_browse"]))
            self.results.add(
                AdaptiveFeedItem(res, show_preview=app_conf["show_images_in_browse"])
            )

    def fetch_page(self, page=0):
        cont = self.instance.get_playlist_content(self.playlist_id)
        GLib.idle_add(self.render_page, cont)

    def do_fetch_page(self, page=0):
        # start background thread
        self.thread = threading.Thread(target=self.fetch_page, args=[page])


@@ 31,13 41,16 @@ class BrowsePlaylistScreen(Adw.NavigationPage):
        self.thread.start()

    def display_info(self, texture):
        self.external_btn = IconButton("","modem-symbolic")
        self.external_btn = IconButton("", "modem-symbolic")
        self.external_btn.connect("clicked", self.on_open_in_browser)
        self.header_bar.pack_end(self.external_btn)
        self.startplay_btn = IconButton("","media-playback-start-symbolic", tooltip=_("Start playing"))
        self.startplay_btn = IconButton(
            "", "media-playback-start-symbolic", tooltip=_("Start playing")
        )
        self.startplay_btn.set_action_name("win.playlistplayer-external")
        self.startplay_btn.set_action_target_value(
            GLib.Variant("as", [self.playlist.server, self.playlist.id]))
            GLib.Variant("as", [self.playlist.server, self.playlist.id])
        )
        self.header_bar.pack_end(self.startplay_btn)

        # base layout


@@ 57,7 70,8 @@ class BrowsePlaylistScreen(Adw.NavigationPage):
            _("Add Playlist to your local playlist collection"),
            PreferenceType.TOGGLE,
            False,
            has_bookmarked_external_playlist(self.playlist.server, self.playlist.id))
            has_bookmarked_external_playlist(self.playlist.server, self.playlist.id),
        )
        bookmark_row = PreferenceRow(bookmark_pref)
        bookmark_row.set_callback(self.bookmark_playlist)
        self.about.add(bookmark_row.get_widget())


@@ 74,7 88,6 @@ class BrowsePlaylistScreen(Adw.NavigationPage):
        self.results = Adw.PreferencesGroup()
        self.box.add(self.results)


    def background(self, playlist_id):
        # obtain playlist information
        self.playlist = self.instance.get_playlist_info(playlist_id)

M melon/browse/search.py => melon/browse/search.py +29 -9
@@ 1,7 1,8 @@
import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, Gio, Gdk, GLib
from gettext import gettext as _
from gettext import ngettext


@@ 13,18 14,22 @@ from melon.servers.utils import get_allowed_servers_list, get_server_instance
from melon.models import get_app_settings, register_callback
from melon.servers import SearchMode, Preference, PreferenceType


class GlobalSearchScreen(Adw.NavigationPage):
    search_mode = SearchMode.ANY
    text = ""

    # update the filter value
    def on_filter(self, mode):
        self.search_mode = mode
        self.search()

    # update the text query
    def update_query(self, query):
        text = query.get_text()
        self.text = text
        self.search()

    # rerun the search
    def search(self):
        # remove old results


@@ 43,11 48,14 @@ class GlobalSearchScreen(Adw.NavigationPage):
            box.set_title(instance.name)
            box.set_subtitle(_("No results"))
            count = len(results)
            box.set_subtitle(ngettext("{count} result","{count} results", count).format(count = count))
            box.set_subtitle(
                ngettext("{count} result", "{count} results", count).format(count=count)
            )
            self.results.add(box)
            for entry in results:
                row = AdaptiveFeedItem(entry, app_conf["show_images_in_browse"])
                box.add_row(row)

    # rerender widget
    def update(self):
        app_conf = get_app_settings()


@@ 56,9 64,13 @@ class GlobalSearchScreen(Adw.NavigationPage):
            # if no servers are allowed, show the nothing here box
            status = Adw.StatusPage()
            status.set_title(_("*crickets chirping*"))
            status.set_description(_("There are no available servers, a search would yield no results"))
            status.set_description(
                _("There are no available servers, a search would yield no results")
            )
            status.set_icon_name("weather-few-clouds-night-symbolic")
            icon_button = IconButton(_("Enable servers in the settings menu"), "list-add-symbolic")
            icon_button = IconButton(
                _("Enable servers in the settings menu"), "list-add-symbolic"
            )
            icon_button.set_action_name("win.prefs")
            box = Gtk.CenterBox()
            box.set_center_widget(icon_button)


@@ 80,15 92,23 @@ class GlobalSearchScreen(Adw.NavigationPage):
            filter_popover = Gtk.Popover()
            filter_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
            # the filter options
            filter_any = FilterButton(_("Any"), SearchMode.ANY, self.on_filter, self.search_mode)
            filter_any = FilterButton(
                _("Any"), SearchMode.ANY, self.on_filter, self.search_mode
            )
            filter_box.append(filter_any)
            filter_channels = FilterButton(_("Channels"), SearchMode.CHANNELS, self.on_filter, self.search_mode)
            filter_channels = FilterButton(
                _("Channels"), SearchMode.CHANNELS, self.on_filter, self.search_mode
            )
            filter_channels.set_group(filter_any)
            filter_box.append(filter_channels)
            filter_playlists = FilterButton(_("Playlists"), SearchMode.PLAYLISTS, self.on_filter, self.search_mode)
            filter_playlists = FilterButton(
                _("Playlists"), SearchMode.PLAYLISTS, self.on_filter, self.search_mode
            )
            filter_playlists.set_group(filter_any)
            filter_box.append(filter_playlists)
            filter_videos = FilterButton(_("Videos"), SearchMode.VIDEOS, self.on_filter, self.search_mode)
            filter_videos = FilterButton(
                _("Videos"), SearchMode.VIDEOS, self.on_filter, self.search_mode
            )
            filter_videos.set_group(filter_any)
            filter_box.append(filter_videos)


M melon/browse/server.py => melon/browse/server.py +35 -13
@@ 1,7 1,8 @@
import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, Gio, Gdk, GLib
import threading
from gettext import gettext as _


@@ 14,11 15,13 @@ from melon.widgets.feeditem import AdaptiveFeedItem
from melon.widgets.filterbutton import FilterButton
from melon.models import get_app_settings


class Search(ViewStackPage):
    query = ""
    text = ""
    search_mode = SearchMode.ANY
    results = None

    def __init__(self, plugin):
        super().__init__("search", _("Search"), "x-office-address-book-symbolic")
        self.instance = plugin


@@ 40,15 43,23 @@ class Search(ViewStackPage):
        filter_popover = Gtk.Popover()
        filter_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        # the filter options
        filter_any = FilterButton(_("Any"), SearchMode.ANY, self.on_filter, self.search_mode)
        filter_any = FilterButton(
            _("Any"), SearchMode.ANY, self.on_filter, self.search_mode
        )
        filter_box.append(filter_any)
        filter_channels = FilterButton(_("Channels"), SearchMode.CHANNELS, self.on_filter, self.search_mode)
        filter_channels = FilterButton(
            _("Channels"), SearchMode.CHANNELS, self.on_filter, self.search_mode
        )
        filter_channels.set_group(filter_any)
        filter_box.append(filter_channels)
        filter_playlists = FilterButton(_("Playlists"), SearchMode.PLAYLISTS, self.on_filter, self.search_mode)
        filter_playlists = FilterButton(
            _("Playlists"), SearchMode.PLAYLISTS, self.on_filter, self.search_mode
        )
        filter_playlists.set_group(filter_any)
        filter_box.append(filter_playlists)
        filter_videos = FilterButton(_("Videos"), SearchMode.VIDEOS, self.on_filter, self.search_mode)
        filter_videos = FilterButton(
            _("Videos"), SearchMode.VIDEOS, self.on_filter, self.search_mode
        )
        filter_videos.set_group(filter_any)
        filter_box.append(filter_videos)



@@ 64,20 75,20 @@ class Search(ViewStackPage):
        self.scroll.set_child(self.inner)
        self.widget.set_child(self.outer)
        self.reset()

    def update_query(self, query):
        self.text = query.get_text()
        self.search()

    def on_filter(self, mode):
        self.search_mode = mode
        self.search()

    def search(self):
        if self.text == "":
            # do not search for empty query
            return
        results = self.instance.search(
            self.text,
            self.search_mode
        )
        results = self.instance.search(self.text, self.search_mode)
        # show empty page
        if len(results) == 0:
            self.reset()


@@ 92,8 103,11 @@ class Search(ViewStackPage):
        app_settings = get_app_settings()
        # add results
        for item in results:
            self.results.add(AdaptiveFeedItem(item, app_settings["show_images_in_browse"]))
            self.results.add(
                AdaptiveFeedItem(item, app_settings["show_images_in_browse"])
            )
        self.inner.append(self.results)

    def reset(self):
        if not self.results is None:
            self.inner.remove(self.results)


@@ 107,6 121,7 @@ class Search(ViewStackPage):
        self.results.set_icon_name("weather-few-clouds-night-symbolic")
        self.inner.append(self.results)


class Feed(ViewStackPage):
    def update(self):
        # show spinner


@@ 119,6 134,7 @@ class Feed(ViewStackPage):
        # long task
        self.results = self.instance.get_public_feed_content(self.feed.id)
        GLib.idle_add(self.display)

    def display(self):
        # display results
        if len(self.results) == 0:


@@ 134,7 150,10 @@ class Feed(ViewStackPage):
            self.inner.set_child(scrollable)
            app_settings = get_app_settings()
            for item in self.results:
                scrollable.add(AdaptiveFeedItem(item, app_settings["show_images_in_browse"]))
                scrollable.add(
                    AdaptiveFeedItem(item, app_settings["show_images_in_browse"])
                )

    def do_update(self):
        self.thread = threading.Thread(target=self.update)
        self.thread.daemon = True


@@ 149,6 168,7 @@ class Feed(ViewStackPage):
        self.widget.set_child(self.inner)
        self.do_update()


class BrowseServerScreen(Adw.NavigationPage):
    def on_open_in_browser(self, arg):
        child_name = self.view_stack.get_visible_child_name()


@@ 158,11 178,13 @@ class BrowseServerScreen(Adw.NavigationPage):
                return
        url = self.instance.get_external_url()
        Gtk.UriLauncher.new(uri=url).launch()

    def render_feeds(self):
        # runs on main
        for feed in self.feeds:
            widg = Feed(self.instance, feed)
            widg.bind_to(self.view_stack)

    def load_feeds(self):
        # runs in thread
        self.feeds = self.instance.get_public_feeds()


@@ 185,7 207,7 @@ class BrowseServerScreen(Adw.NavigationPage):
        self.switcher.set_stack(self.view_stack)
        self.header_bar = Adw.HeaderBar()
        self.header_bar.set_title_widget(self.switcher)
        self.external_btn = IconButton("","modem-symbolic")
        self.external_btn = IconButton("", "modem-symbolic")
        self.external_btn.connect("clicked", self.on_open_in_browser)
        self.header_bar.pack_end(self.external_btn)


M melon/home/__init__.py => melon/home/__init__.py +4 -2
@@ 1,7 1,8 @@
import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, Gio
from gettext import gettext as _



@@ 12,6 13,7 @@ from melon.home.history import History

from melon.widgets.iconbutton import IconButton


class HomeScreen(Adw.NavigationPage):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

M melon/home/history.py => melon/home/history.py +16 -8
@@ 1,7 1,8 @@
import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib
import threading
from gettext import gettext as _


@@ 12,6 13,7 @@ from melon.widgets.feeditem import AdaptiveFeedItem
from melon.models import get_history, register_callback, get_app_settings
from melon.servers.utils import group_by_date, filter_resources


class History(ViewStackPage):
    # datecount displayed
    page_size = 7


@@ 31,7 33,7 @@ class History(ViewStackPage):
            self.inner.set_child(status)
        else:
            self.wrapper = Gtk.Viewport()
            self.results = Gtk.Box(orientation = Gtk.Orientation.VERTICAL)
            self.results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
            title = Adw.PreferencesGroup()
            title.set_title(_("History"))
            title.set_description(_("These are the videos you opened in the past"))


@@ 46,17 48,22 @@ class History(ViewStackPage):
    # keep track of the load more button
    next_ctr = None
    focus_node = None

    def load_page(self, page=0):
        self.focus_node = None
        padding = 12
        # add history entries
        app_settings = get_app_settings()
        for date, content in self.data[page*self.page_size:(page+1)*self.page_size]:
        for date, content in self.data[
            page * self.page_size : (page + 1) * self.page_size
        ]:
            group = Adw.PreferencesGroup()
            group.set_title(date)
            self.results.append(group)
            for resource in content:
                group.add(AdaptiveFeedItem(resource, app_settings["show_images_in_feed"]))
                group.add(
                    AdaptiveFeedItem(resource, app_settings["show_images_in_feed"])
                )
            # because we aren't using a preferencepage
            # we have to take care of padding ourselfs
            group.set_margin_end(padding)


@@ 70,14 77,14 @@ class History(ViewStackPage):
        if not self.next_ctr is None:
            self.results.remove(self.next_ctr)
        # show Load more button if there are more entries
        if len(self.data) > (page+1)*self.page_size:
        if len(self.data) > (page + 1) * self.page_size:
            self.next_ctr = Adw.PreferencesGroup()
            btn = Adw.ActionRow()
            btn.set_title(_("Show more"))
            btn.set_subtitle(_("Load older videos"))
            btn.add_suffix(Gtk.Image.new_from_icon_name("go-down-symbolic"))
            btn.set_activatable(True)
            btn.connect("activated", lambda _: self.load_page(page+1))
            btn.connect("activated", lambda _: self.load_page(page + 1))
            self.next_ctr.add(btn)
            # because we aren't using a preferencepage
            # we have to take care of padding ourselfs


@@ 96,10 103,11 @@ class History(ViewStackPage):
    def schedule(self):
        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 = filter_resources(get_history(), app_settings, access=lambda x: x[0])
        GLib.idle_add(self.update, list(group_by_date(hist).items()))

    thread = None

    def do_update(self):
        # show spinner
        spinner = Gtk.Spinner()

M melon/home/new.py => melon/home/new.py +37 -11
@@ 1,7 1,8 @@
import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib, Gio, GObject
import threading
from datetime import datetime


@@ 13,9 14,15 @@ from melon.widgets.feeditem import AdaptiveFeedItem
from melon.servers.utils import fetch_home_feed, group_by_date
from melon.models import register_callback
from melon.models import get_subscribed_channels, get_app_settings
from melon.models import get_cached_feed, clear_cached_feed, update_cached_feed, get_last_feed_refresh
from melon.models import (
    get_cached_feed,
    clear_cached_feed,
    update_cached_feed,
    get_last_feed_refresh,
)
from melon.models import get_last_playback


class NewFeed(ViewStackPage):
    def update(self):
        news = get_cached_feed(100)


@@ 29,15 36,21 @@ class NewFeed(ViewStackPage):
            status.set_title(_("*crickets chirping*"))
            subs = get_subscribed_channels()
            if len(subs) == 0:
                status.set_description(_("Subscribe to a channel first, to view new uploads"))
                status.set_description(
                    _("Subscribe to a channel first, to view new uploads")
                )
            else:
                status.set_description(_("The channels you are subscribed to haven't uploaded anything yet"))
                status.set_description(
                    _(
                        "The channels you are subscribed to haven't uploaded anything yet"
                    )
                )
            status.set_icon_name("weather-few-clouds-night-symbolic")
            icon_button = IconButton(_("Browse Servers"), "list-add-symbolic")
            icon_button.set_action_name("win.browse")
            icon_button.set_margin_bottom(6)
            box = Gtk.CenterBox()
            ls = Gtk.Box(orientation = Gtk.Orientation.VERTICAL)
            ls = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
            ls.append(icon_button)
            ls.append(refresh_btn)
            box.set_center_widget(ls)


@@ 52,10 65,14 @@ class NewFeed(ViewStackPage):
                last_refresh = "Never"
            else:
                last_refresh = datetime.fromtimestamp(last_refresh).strftime("%c")
            refresh_info = _("(Last refresh: {last_refresh})").format(last_refresh = last_refresh)
            refresh_info = _("(Last refresh: {last_refresh})").format(
                last_refresh=last_refresh
            )
            title = Adw.PreferencesGroup()
            title.set_title(_("What's new") + " " + refresh_info)
            title.set_description(_("These are the latest videos of channels you follow"))
            title.set_description(
                _("These are the latest videos of channels you follow")
            )
            title.set_header_suffix(refresh_btn)
            results.add(title)
            self.inner.set_child(results)


@@ 63,7 80,13 @@ class NewFeed(ViewStackPage):
            for entry in news:
                vid = entry[0]
                uts = entry[1]
                date = datetime.fromtimestamp(uts).date().strftime("%c").replace("00:00:00","").replace("  ", " ")
                date = (
                    datetime.fromtimestamp(uts)
                    .date()
                    .strftime("%c")
                    .replace("00:00:00", "")
                    .replace("  ", " ")
                )
                group = None
                if date in tracker:
                    group = tracker[date]


@@ 77,6 100,7 @@ class NewFeed(ViewStackPage):
        return False

    thread = None

    def do_update(self):
        # show spinner
        spinner = Gtk.Spinner()


@@ 111,6 135,7 @@ class NewFeed(ViewStackPage):
        self.thread.start()

    ov_toast = None

    def load_overlay(self):
        last = get_last_playback()
        if not last is None:


@@ 121,7 146,7 @@ class NewFeed(ViewStackPage):
            elif dur == 0:
                # video has no length, why would anybody continue watching it
                last = None
            elif pos > dur*0.95:
            elif pos > dur * 0.95:
                # video was already played >0.95 percent
                # so we can consider it watched
                # TODO: move into settings menu


@@ 138,7 163,8 @@ class NewFeed(ViewStackPage):
        self.ov_toast.set_button_label(_("Watch"))
        self.ov_toast.set_action_name("win.player")
        self.ov_toast.set_action_target_value(
            GLib.Variant("as", [resource.server, resource.id]))
            GLib.Variant("as", [resource.server, resource.id])
        )
        self.overlay.add_toast(self.ov_toast)
        self.ov_toast.set_timeout(20)


M melon/home/playlists.py => melon/home/playlists.py +31 -17
@@ 1,7 1,8 @@
import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib
import threading
from gettext import gettext as _


@@ 13,40 14,53 @@ from melon.models import register_callback, get_playlists, get_app_settings
from melon.models import PlaylistWrapperType
from melon.servers.utils import server_is_allowed


class Playlists(ViewStackPage):
    def update(self, playlists):
        app_conf = get_app_settings()
        if not playlists:
             status = Adw.StatusPage()
             status.set_title(_("*crickets chirping*"))
             status.set_description(_("You don't have any playlists yet"))
             status.set_icon_name("weather-few-clouds-night-symbolic")
             icon_button = IconButton(_("Create a new playlist"), "list-add-symbolic")
             icon_button.set_action_name("win.new_playlist")
             box = Gtk.CenterBox()
             box.set_center_widget(icon_button)
             status.set_child(box)
             self.inner.set_child(status)
            status = Adw.StatusPage()
            status.set_title(_("*crickets chirping*"))
            status.set_description(_("You don't have any playlists yet"))
            status.set_icon_name("weather-few-clouds-night-symbolic")
            icon_button = IconButton(_("Create a new playlist"), "list-add-symbolic")
            icon_button.set_action_name("win.new_playlist")
            box = Gtk.CenterBox()
            box.set_center_widget(icon_button)
            status.set_child(box)
            self.inner.set_child(status)
        else:
            results = Adw.PreferencesGroup()
            results.set_title(_("Playlists"))
            results.set_description(_("Here are playlists you've bookmarked or created yourself"))
            icon_button = IconButton(_("New"), "list-add-symbolic", tooltip=_("Create a new playlist"))
            results.set_description(
                _("Here are playlists you've bookmarked or created yourself")
            )
            icon_button = IconButton(
                _("New"), "list-add-symbolic", tooltip=_("Create a new playlist")
            )
            icon_button.set_action_name("win.new_playlist")
            results.set_header_suffix(icon_button)
            self.inner.set_child(results)
            for playlist in playlists:
                results.add(AdaptivePlaylistFeedItem(playlist, app_conf["show_images_in_feed"]))
                results.add(
                    AdaptivePlaylistFeedItem(playlist, app_conf["show_images_in_feed"])
                )

    def schedule(self):
        app_conf = get_app_settings()
        # filter external playlists if server is disabled
        playlists = [ playlist for playlist in get_playlists() if playlist.type == PlaylistWrapperType.LOCAL or server_is_allowed(playlist.inner.server, app_conf) ]
        playlists = [
            playlist
            for playlist in get_playlists()
            if playlist.type == PlaylistWrapperType.LOCAL
            or server_is_allowed(playlist.inner.server, app_conf)
        ]
        # local and external playlists have the inner.title attribute
        playlists.sort(key=lambda x:x.inner.title)
        playlists.sort(key=lambda x: x.inner.title)
        GLib.idle_add(self.update, playlists)

    thread = None

    def do_update(self):
        # show spinner
        spinner = Gtk.Spinner()

M melon/home/subs.py => melon/home/subs.py +9 -4
@@ 1,7 1,8 @@
import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib
import threading
from gettext import gettext as _


@@ 12,6 13,7 @@ from melon.widgets.feeditem import AdaptiveFeedItem
from melon.models import get_subscribed_channels, register_callback, get_app_settings
from melon.servers.utils import filter_resources


class Subscriptions(ViewStackPage):
    def update(self, subs):
        app_settings = get_app_settings()


@@ 36,16 38,19 @@ class Subscriptions(ViewStackPage):
            # NOTE: might seem confusing to unknowing users
            self.inner.set_child(results)
            for channel in subs:
                results.add(AdaptiveFeedItem(channel, app_settings["show_images_in_feed"]))
                results.add(
                    AdaptiveFeedItem(channel, app_settings["show_images_in_feed"])
                )

    def schedule(self):
        app_settings = get_app_settings()
        # make sure only channles from available servers are included
        subs = filter_resources(get_subscribed_channels(), app_settings)
        subs.sort(key=lambda c:c.name)
        subs.sort(key=lambda c: c.name)
        GLib.idle_add(self.update, subs)

    thread = None

    def do_update(self):
        # show spinner
        spinner = Gtk.Spinner()

M melon/import_providers/__init__.py => melon/import_providers/__init__.py +6 -2
@@ 1,9 1,12 @@
from enum import Enum, auto
from abc import ABC,abstractmethod
from abc import ABC, abstractmethod


class PickerMode(Enum):
    FILE = auto()
    FOLDER = auto()


class ImportProvider(ABC):
    id: str
    title: str


@@ 15,8 18,9 @@ class ImportProvider(ABC):
    # and mime types in an array as the value
    # only used for PickerMode.FILE
    filters: dict[str, list[str]] = {}

    @abstractmethod
    def load(self, selection:list[str]):
    def load(self, selection: list[str]):
        """
        Called with a list of file/folder paths
        after the selection dialog was shown

M melon/import_providers/newpipe.py => melon/import_providers/newpipe.py +70 -28
@@ 5,51 5,65 @@ from gettext import gettext as _

from melon.import_providers import ImportProvider, PickerMode
from melon.servers import Channel, Playlist, Video
from melon.models import ensure_subscribed_to_channel, ensure_bookmark_external_playlist, add_to_history, new_local_playlist, add_to_local_playlist, set_local_playlist_thumbnail, add_history_items, add_videos
from melon.models import (
    ensure_subscribed_to_channel,
    ensure_bookmark_external_playlist,
    add_to_history,
    new_local_playlist,
    add_to_local_playlist,
    set_local_playlist_thumbnail,
    add_history_items,
    add_videos,
)


def connect_to_db(database_path):
    con = sqlite3.connect(database_path)
    return con


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)
sqlite3.register_converter("json", convert_json)


class NewpipeImporter(ImportProvider, ABC):
    id = "newpipedb"

    title = _("Newpipe Database importer")
    description = _("Import the .db file from inside the newpipe .zip export (as invidious content)")
    description = _(
        "Import the .db file from inside the newpipe .zip export (as invidious content)"
    )
    picker_title = _("Select .db file")

    # the server id for resources
    server_id:str
    server_id: str
    # url used to replace https://www.youtube.com
    base_url:str
    base_url: str
    # url used to replace https://i.ytimg.com
    img_url:str
    img_url: str
    # service id to match in newpipe db
    service_id:int
    service_id: int

    mode = PickerMode.FILE
    is_multi = False

    filters = {
        _("Newpipe Database"): [ "application/x-sqlite3" ]
    }
    filters = {_("Newpipe Database"): ["application/x-sqlite3"]}

    db:sqlite3.Connection = None
    db: sqlite3.Connection = None

    # additional information about newpipes database layout
    # https://github.com/TeamNewPipe/NewPipe/wiki/Database
    def load(self, selection:list[str]):
    def load(self, selection: list[str]):
        db_path = selection[0]
        self.db = connect_to_db(db_path)



@@ 59,10 73,12 @@ class NewpipeImporter(ImportProvider, ABC):
        self.__load_history()

    def __load_subs(self):
        results = self.db.execute("""
        results = self.db.execute(
            """
        SELECT service_id, url, name, avatar_url, description
        FROM subscriptions
        """).fetchall()
        """
        ).fetchall()
        for dt in results:
            if dt[0] == self.service_id:
                server = self.server_id


@@ 75,10 91,12 @@ class NewpipeImporter(ImportProvider, ABC):
                ensure_subscribed_to_channel(c)

    def __load_history(self):
        results = self.db.execute("""
        results = self.db.execute(
            """
        SELECT service_id, url, title, access_date, uploader_url, uploader, thumbnail_url
        FROM streams JOIN stream_history ON stream_history.stream_id = streams.uid
        """).fetchall()
        """
        ).fetchall()
        entries = []
        for dt in results:
            if dt[0] == self.service_id:


@@ 94,27 112,34 @@ class NewpipeImporter(ImportProvider, ABC):
                # newpipe does not store description
                desc = ""
                uts = dt[3] / 1000
                v = Video(server, url, id, title, (channel_name, channel_id), desc, thumb)
                v = Video(
                    server, url, id, title, (channel_name, channel_id), desc, thumb
                )
                entries.append((v, uts))
        add_videos([ d[0] for d in entries ])
        add_videos([d[0] for d in entries])
        add_history_items(entries)

    def __load_local_playlists(self):
        results = self.db.execute("""
        results = self.db.execute(
            """
        SELECT uid, name, thumbnail_stream_id
        FROM playlists
        """).fetchall()
        """
        ).fetchall()
        for dt in results:
            name = dt[1]
            # newpipe doesn't support playlist descriptions
            description = ""
            uid = dt[0]
            vids = self.db.execute("""
            vids = self.db.execute(
                """
            SELECT service_id, url, title, uploader, uploader_url, thumbnail_url
            FROM playlist_stream_join, streams
            WHERE playlist_stream_join.playlist_id = ? AND streams.uid = playlist_stream_join.stream_id
            ORDER BY playlist_stream_join.join_index
            """, (uid,)).fetchall()
            """,
                (uid,),
            ).fetchall()
            videos = []
            for vdt in vids:
                if vdt[0] == self.service_id:


@@ 130,21 155,38 @@ class NewpipeImporter(ImportProvider, ABC):
                    thumb = vdt[5].replace("https://i.ytimg.com", self.img_url)
                    # newpipe doesn't store descriptions
                    desc = ""
                    videos.append(Video(server, url, id, title, (channel_name, channel_id), desc, thumb))
                    videos.append(
                        Video(
                            server,
                            url,
                            id,
                            title,
                            (channel_name, channel_id),
                            desc,
                            thumb,
                        )
                    )
            pid = new_local_playlist(name, description, videos)
            thumb = self.db.execute("""
            thumb = self.db.execute(
                """
            SELECT thumbnail_url
            FROM streams
            WHERE uid = ?
            """, (dt[2],)).fetchone()
            """,
                (dt[2],),
            ).fetchone()
            if not thumb is None:
                set_local_playlist_thumbnail(pid, thumb[0].replace("https://i.ytimg.com", self.img_url))
                set_local_playlist_thumbnail(
                    pid, thumb[0].replace("https://i.ytimg.com", self.img_url)
                )

    def __load_remote_playlists(self):
        results = self.db.execute("""
        results = self.db.execute(
            """
        SELECT service_id, name, url, thumbnail_url, uploader
        FROM remote_playlists
        """).fetchall()
        """
        ).fetchall()
        for dt in results:
            if dt[0] == self.service_id:
                server = self.server_id

M melon/import_providers/utils.py => melon/import_providers/utils.py +11 -3
@@ 1,23 1,29 @@
import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib, Gio
from melon.import_providers import ImportProvider, PickerMode


class ImportPicker(Gtk.FileDialog):
    cb = None
    def __init__(self, provider:ImportProvider, *args, **kwargs):

    def __init__(self, provider: ImportProvider, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.set_title(provider.picker_title)
        self.set_modal(True)
        self.provider = provider

    def set_onselect(self, cb):
        self.cb = cb

    def done(self, paths: list[str]):
        if self.cb is None:
            return
        self.cb(self.provider, paths)

    def open_single_file(self, dialog, result):
        try:
            file = dialog.open_finish(result)


@@ 25,6 31,7 @@ class ImportPicker(Gtk.FileDialog):
                self.done([file.get_path()])
        except GLib.Error as error:
            print(f"Error opening file: {error.message}")

    def open_multi_files(self, dialog, result):
        try:
            data = dialog.open_multiple_finish(result)


@@ 36,6 43,7 @@ class ImportPicker(Gtk.FileDialog):
            self.done(files)
        except GLib.Error as error:
            print(f"Error opening file: {error.message}")

    def show(self, win):
        if self.provider.mode == PickerMode.FILE:
            # generate filter

M melon/importer.py => melon/importer.py +9 -4
@@ 1,7 1,8 @@
import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, Gio
from gettext import gettext as _



@@ 11,6 12,7 @@ from melon.models.callbacks import freeze, unfreeze
from melon.servers.utils import get_allowed_servers_list, get_server_instance
from melon.models import get_app_settings


class ImporterScreen(Adw.NavigationPage):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


@@ 61,7 63,9 @@ class ImporterScreen(Adw.NavigationPage):
            status.set_title(_("*crickets chirping*"))
            status.set_description(_("There are no available importer methods"))
            status.set_icon_name("weather-few-clouds-night-symbolic")
            icon_button = IconButton(_("Enable servers in the settings menu"), "list-add-symbolic")
            icon_button = IconButton(
                _("Enable servers in the settings menu"), "list-add-symbolic"
            )
            icon_button.set_action_name("win.prefs")
            box = Gtk.CenterBox()
            box.set_center_widget(icon_button)


@@ 75,7 79,7 @@ class ImporterScreen(Adw.NavigationPage):
        picker.show(None)
        picker.set_onselect(self.on_select)

    def on_select(self, provider:ImportProvider, files):
    def on_select(self, provider: ImportProvider, files):
        # show spinner
        spinner = Gtk.Spinner()
        spinner.set_size_request(50, 50)


@@ 95,5 99,6 @@ class ImporterScreen(Adw.NavigationPage):
        # automatically go back home
        self.activate_action("win.home", None)


def pass_me(func, *args):
    return lambda x: func(x, *args)

M melon/models/__init__.py => melon/models/__init__.py +496 -160
@@ 1,5 1,5 @@
from copy import deepcopy
from enum import Enum,auto
from enum import Enum, auto
from datetime import datetime
from functools import cache
import sqlite3


@@ 11,7 11,8 @@ 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():

class LocalPlaylist:
    # id used for local identification
    id: int
    # video title


@@ 21,46 22,60 @@ class LocalPlaylist():
    # preview image url (None if non existend)
    thumbnail: str = None
    content: list[(Resource, int)] = []

    def __init__(self, id, title, desc):
        self.id = id
        self.title = title
        self.description = desc


class PlaylistWrapperType(Enum):
    LOCAL = auto()
    EXTERNAL = auto()


class PlaylistWrapper:
    inner: (Playlist | LocalPlaylist) = None
    inner: Playlist | LocalPlaylist = None
    type: PlaylistWrapperType = None

    def __init__(self, type, inner):
        self.inner = inner
        self.type = type

    def from_local(playlist: LocalPlaylist):
        return PlaylistWrapper(PlaylistWrapperType.LOCAL, playlist)

    def from_external(playlist: Playlist):
        return PlaylistWrapper(PlaylistWrapperType.EXTERNAL, playlist)


##################################################
# DATABASE IMPLEMENTATION
##################################################


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)
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:


def execute_sql(conn: sqlite3.Connection, sql, *sqlargs, many=False) -> bool:
    try:
        c = conn.cursor()
        if many:


@@ 70,11 85,12 @@ def execute_sql(conn:sqlite3.Connection, sql, *sqlargs, many=False) -> bool:
        conn.commit()
        c.close()
    except Exception as e:
        print('SQLite-error:', e)
        print("SQLite-error:", e)
        return False
    else:
        return True


VERSION = 3

app_conf_template = {


@@ 89,7 105,7 @@ app_conf_template = {
    # 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
    "login_required": False,
}

server_settings_template = {


@@ 100,12 116,14 @@ server_settings_template = {
    # do not require being stored
    "nsfw_content": False,
    "nsfw_only_content": False,
    "login_required": False
    "login_required": False,
}


def die():
    raise "DB ERROR"


def init_db():
    conn = connect_to_db()
    if conn is None:


@@ 118,32 136,45 @@ def init_db():
        # initial version (defaults to 0, so this means the DB isn't initialized)
        # perform initial setup
        # application settings
        execute_sql(conn, """
        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)
        """,
        ) 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(
        execute_sql(
            conn,
            """CREATE TABLE servers(
        server,
        enabled,
        PRIMARY KEY(server)
        )""") or die()
        )""",
        ) or die()
        # server settings
        execute_sql(conn, """
        execute_sql(
            conn,
            """
        CREATE TABLE server_settings(
        server,
        key,
        value,
        PRIMARY KEY(server, key))""") or die()
        PRIMARY KEY(server, key))""",
        ) or die()
        # channels
        execute_sql(conn,"""
        execute_sql(
            conn,
            """
        CREATE TABLE channels(
        server,
        id,


@@ 152,9 183,12 @@ def init_db():
        bio,
        avatar,
        PRIMARY KEY(server, id)
        )""") or die()
        )""",
        ) or die()
        # videos
        execute_sql(conn, """
        execute_sql(
            conn,
            """
        CREATE TABLE videos(
        server,
        id,


@@ 165,9 199,12 @@ def init_db():
        channel_id,
        channel_name,
        PRIMARY KEY(server, id))
        """) or die()
        """,
        ) or die()
        # playlists
        execute_sql(conn, """
        execute_sql(
            conn,
            """
        CREATE TABLE playlists(
        server,
        id,


@@ 179,9 216,12 @@ def init_db():
        thumbnail,
        PRIMARY KEY(server, id)
        )
        """) or die()
        """,
        ) or die()
        # playlist <-> videos
        execute_sql(conn, """
        execute_sql(
            conn,
            """
        CREATE TABLE playlist_content(
        server,
        playlist_id,


@@ 190,18 230,24 @@ def init_db():
        FOREIGN KEY(video_id, server) REFERENCES videos(id, server),
        PRIMARY KEY(server, playlist_id, video_id)
        )
        """) or die()
        """,
        ) or die()
        # local playlists
        execute_sql(conn, """
        execute_sql(
            conn,
            """
        CREATE TABLE local_playlists(
        id,
        title,
        description,
        thumbnail,
        PRIMARY KEY(id)
        )""") or die()
        )""",
        ) or die()
        # local-playlists <-> videos
        execute_sql(conn, """
        execute_sql(
            conn,
            """
        CREATE TABLE local_playlist_content(
        id,
        video_id,


@@ 210,43 256,58 @@ def init_db():
        FOREIGN KEY(video_id, video_server) REFERENCES videos(id, server),
        PRIMARY KEY(id, position)
        )
        """) or die()
        """,
        ) or die()
        # history
        execute_sql(conn, """
        execute_sql(
            conn,
            """
        CREATE TABLE history(
        timestamp,
        video_id,
        video_server,
        FOREIGN KEY(video_id, video_server) REFERENCES videos(id, server)
        )
        """) or die()
        """,
        ) or die()
        # subscriptions
        execute_sql(conn, """
        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, """
        execute_sql(
            conn,
            """
        CREATE TABLE bookmarked_playlists(
        server,
        playlist_id,
        PRIMARY KEY(server, playlist_id),
        FOREIGN KEY(server, playlist_id) REFERENCES playlists(server, id)
        )
        """)
        execute_sql(conn, """
        """,
        )
        execute_sql(
            conn,
            """
        CREATE TABLE news(
        timestamp,
        video_id,
        video_server,
        FOREIGN KEY(video_id, video_server) REFERENCES videos(id, server)
        )
        """)
        execute_sql(conn, """
        """,
        )
        execute_sql(
            conn,
            """
        CREATE TABLE playback(
        timestamp,
        video_server,


@@ 256,7 317,8 @@ def init_db():
        PRIMARY KEY(video_id, video_server),
        FOREIGN KEY(video_id, video_server) REFERENCES videos(id, server)
        )
        """)
        """,
        )

        # the initial setup should always initialize the newest version
        # and not depend on migrations


@@ 265,7 327,9 @@ def init_db():
    # if minv <= version <= maxv:
    if version < 2:
        # newly added in db v2
        execute_sql(conn, """
        execute_sql(
            conn,
            """
        CREATE TABLE playback(
        timestamp,
        video_server,


@@ 275,10 339,13 @@ def init_db():
        PRIMARY KEY(video_id, video_server),
        FOREIGN KEY(video_id, video_server) REFERENCES videos(id, server)
        )
        """)
        """,
        )
    if version < 3:
        # video_id and video_server are no longer part of the primary key
        execute_sql(conn, """
        execute_sql(
            conn,
            """
        CREATE TABLE lpc2(
        id,
        video_id,


@@ 286,32 353,46 @@ def init_db():
        position,
        FOREIGN KEY(video_id, video_server) REFERENCES videos(id, server),
        PRIMARY KEY(id, position)
        )""")
        execute_sql(conn, """
        )""",
        )
        execute_sql(
            conn,
            """
        INSERT INTO lpc2(id, video_id, video_server, position)
        SELECT id, video_id, video_server, position FROM local_playlist_content
        """)
        execute_sql(conn, """
        """,
        )
        execute_sql(
            conn,
            """
        DROP TABLE local_playlist_content
        """)
        execute_sql(conn, """
        """,
        )
        execute_sql(
            conn,
            """
        ALTER TABLE lpc2 RENAME TO local_playlist_content
        """)
        """,
        )

    # 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, enable=None) -> bool:

def init_server_settings(sid: str, enable=None) -> bool:
    """
    Make sure server config is initialized
    (for this session)
    returns true if changed
    """
    servers[sid] = deepcopy(server_settings_template)


# DO NOT CALL
# used internally
def load_server(server):


@@ 326,7 407,8 @@ def load_server(server):
    # since 1.3.0
    conn = connect_to_db()
    value = conn.execute(
        "SELECT enabled FROM servers WHERE server = ?", (sid,)).fetchone()
        "SELECT enabled FROM servers WHERE server = ?", (sid,)
    ).fetchone()
    if value is None:
        # disable servers that require login by default
        if server.requires_login:


@@ 334,22 416,29 @@ def load_server(server):
            servers[sid]["enabled"] = False
    conn.close()
    notify("settings_changed")
def set_server_setting(sid:str, pref:str, value):


def set_server_setting(sid: str, pref: str, value):
    init_server_settings(sid)
    conn = connect_to_db()
    execute_sql(conn,
                "INSERT OR REPLACE INTO server_settings VALUES (?,?,?)",
                (sid, pref, value))
    execute_sql(
        conn,
        "INSERT OR REPLACE INTO server_settings VALUES (?,?,?)",
        (sid, pref, value),
    )
    conn.close()
    notify("settings_changed")
def get_server_settings(sid:str):


def get_server_settings(sid: str):
    init_server_settings(sid)
    base = servers[sid]
    value = is_server_enabled(sid)
    base["enabled"] = value
    conn = connect_to_db()
    results = conn.execute(
        "SELECT key, value FROM server_settings WHERE server = ?", (sid,)).fetchall()
        "SELECT key, value FROM server_settings WHERE server = ?", (sid,)
    ).fetchall()
    for setting in results:
        value = setting[1]
        try:


@@ 360,35 449,48 @@ def get_server_settings(sid:str):
            value = setting[1]
        base["custom"][setting[0]] = value
    return base


def is_server_enabled(server_id: str):
    conn = connect_to_db()
    value = conn.execute(
        "SELECT enabled FROM servers WHERE server = ?", (server_id,)).fetchone()
        "SELECT enabled FROM servers WHERE server = ?", (server_id,)
    ).fetchone()
    if not value is None:
        value = value[0]
    else:
        value = server_settings_template["enabled"]
    return bool(value)


def ensure_server(server_id: str, mode: bool):
    if is_server_enabled(server_id) == mode:
        # nothing to do
        return
    conn = connect_to_db()
    execute_sql(conn, """
    execute_sql(
        conn,
        """
    INSERT OR REPLACE INTO servers
    VALUES (?, ?)
    """, (server_id, mode))
    """,
        (server_id, mode),
    )
    # notify channels and playlists, because available channels might be different
    conn.close()
    notify("channels_changed")
    notify("playlists_changed")
    notify("settings_changed")


def ensure_server_disabled(server_id: str):
    ensure_server(server_id, False)


def ensure_server_enabled(server_id: str):
    ensure_server(server_id, True)


def get_app_settings():
    base = deepcopy(app_conf_template)
    conn = connect_to_db()


@@ 396,94 498,157 @@ def get_app_settings():
    for setting in results:
        base[setting[0]] = setting[1]
    return base
def set_app_setting(pref:str, value):


def set_app_setting(pref: str, value):
    conn = connect_to_db()
    execute_sql(conn, """
    execute_sql(
        conn,
        """
    UPDATE appconf
    SET value = ?
    WHERE key = ?
    """, (value, pref))
    """,
        (value, pref),
    )
    # notify channels and playlists, because available channels might be different
    conn.close()
    notify("channels_changed")
    notify("playlists_changed")
    notify("settings_changed")

def is_subscribed_to_channel(server_id: str, channel_id:str) -> bool:

def is_subscribed_to_channel(server_id: str, channel_id: str) -> bool:
    conn = connect_to_db()
    results = conn.execute("""
    results = conn.execute(
        """
    SELECT *
    FROM subscriptions
    WHERE server = ? AND video_id = ?
    """, (server_id, channel_id)).fetchall()
    """,
        (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("""
    results = conn.execute(
        """
    SELECT *
    FROM channels
    WHERE server = ? AND id = ?
    """, (channel_server, channel_id)).fetchall()
    """,
        (channel_server, channel_id),
    ).fetchall()
    return len(results) != 0
def ensure_channel(channel:Channel):


def ensure_channel(channel: Channel):
    # insert channel data
    conn = connect_to_db()
    execute_sql(conn, """
    execute_sql(
        conn,
        """
    INSERT OR REPLACE INTO channels
    VALUES (?,?,?,?,?,?)
    """,
    (channel.server, channel.id, channel.url, channel.name, channel.bio, channel.avatar))
        (
            channel.server,
            channel.id,
            channel.url,
            channel.name,
            channel.bio,
            channel.avatar,
        ),
    )
    conn.close()
    notify("channels_changed")


def ensure_subscribed_to_channel(channel: Channel):
    if is_subscribed_to_channel(channel.server, channel.id):
        return
    ensure_channel(channel)
    conn = connect_to_db()
    execute_sql(conn, """
    execute_sql(
        conn,
        """
    INSERT OR REPLACE INTO subscriptions
    VALUES (?,?)
    """, (channel.id, channel.server))
    """,
        (channel.id, channel.server),
    )
    conn.close()
    notify("channels_changed")
def ensure_unsubscribed_from_channel(server_id: str, channel_id:str):


def ensure_unsubscribed_from_channel(server_id: str, channel_id: str):
    conn = connect_to_db()
    execute_sql(conn, """
    execute_sql(
        conn,
        """
    DELETE FROM subscriptions
    WHERE server = ?, id = ?
    """, (server_id, channel_id))
    """,
        (server_id, channel_id),
    )
    conn.close()
    notify("channels_changed")


def get_subscribed_channels() -> list[Channel]:
    conn = connect_to_db()
    results = conn.execute("""
    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()
    """
    ).fetchall()
    conn.close()
    return [ Channel(d[0], d[2], d[1], d[3], d[4], d[5]) for d in results ]
    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("""
    results = conn.execute(
        """
    SELECT *
    FROM videos
    WHERE server = ? AND id = ?
    """, (server_id, video_id)).fetchall()
    """,
        (server_id, video_id),
    ).fetchall()
    return len(results) != 0


def ensure_video(vid):
    # create video
    conn = connect_to_db()
    execute_sql(conn, """
    execute_sql(
        conn,
        """
    INSERT OR REPLACE INTO videos
    VALUES (?,?,?,?,?,?,?,?)
    """,
    (vid.server, vid.id, vid.url, vid.title, vid.description, vid.thumbnail, vid.channel[1], vid.channel[0]))
        (
            vid.server,
            vid.id,
            vid.url,
            vid.title,
            vid.description,
            vid.thumbnail,
            vid.channel[1],
            vid.channel[0],
        ),
    )
    conn.close()
def add_videos(vids:list[Video]):


def add_videos(vids: list[Video]):
    conn = connect_to_db()
    execute_sql(
        conn,


@@ 491,87 656,148 @@ def add_videos(vids:list[Video]):
        INSERT OR REPLACE INTO videos
        VALUES (?,?,?,?,?,?,?,?)
        """,
        [ (vid.server, vid.id, vid.url, vid.title, vid.description, vid.thumbnail, vid.channel[1], vid.channel[0]) for vid in vids],
        many=True)
        [
            (
                vid.server,
                vid.id,
                vid.url,
                vid.title,
                vid.description,
                vid.thumbnail,
                vid.channel[1],
                vid.channel[0],
            )
            for vid in vids
        ],
        many=True,
    )
    conn.close()
    notify("history_changed")

def has_bookmarked_external_playlist(server_id: str, playlist_id:str) -> bool:

def has_bookmarked_external_playlist(server_id: str, playlist_id: str) -> bool:
    conn = connect_to_db()
    results = conn.execute("""
    results = conn.execute(
        """
    SELECT *
    FROM playlists
    WHERE server = ? AND id = ?
    """,
    (server_id, playlist_id)).fetchall()
        (server_id, playlist_id),
    ).fetchall()
    return len(results) != 0


def has_playlist(p: PlaylistWrapper) -> bool:
    conn = connect_to_db()
    if p.type == PlaylistWrapperType.LOCAL:
        results = conn.execute("""
        results = conn.execute(
            """
        SELECT *
        FROM local_playlist
        WHERE id = ?
        """, (p.inner.id)).fetchall()
        """,
            (p.inner.id),
        ).fetchall()
        return len(results) != 0
    else:
        results = conn.execute("""
        results = conn.execute(
            """
        SELECT *
        FROM bookmarked_playlists
        WHERE server = ? AND playlist_id = ?
        """, (p.inner.server, p.inner.id)).fetchall()
        """,
            (p.inner.server, p.inner.id),
        ).fetchall()
        return len(results) != 0


def ensure_playlist(playlist: PlaylistWrapper):
    if playlist.type == PlaylistWrapperType.LOCAL:
        # insert new playlist
        conn = connect_to_db()
        execute_sql(conn, """
        execute_sql(
            conn,
            """
        INSERT OR REPLACE INTO local_playlists
        VALUES (?,?,?,?)
        """,
        (playlist.inner.id, playlist.inner.title, playlist.inner.description, playlist.inner.thumbnail))
            (
                playlist.inner.id,
                playlist.inner.title,
                playlist.inner.description,
                playlist.inner.thumbnail,
            ),
        )
    else:
        # insert new playlist
        conn = connect_to_db()
        execute_sql(conn, """
        execute_sql(
            conn,
            """
        INSERT OR REPLACE 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))
            (
                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,
            ),
        )
    conn.close()
    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, """
    execute_sql(
        conn,
        """
    INSERT OR REPLACE INTO bookmarked_playlists
    VALUES (?,?)
    """,
    (playlist.server, playlist.id))
        (playlist.server, playlist.id),
    )
    # we need to rerun the notification because now the playlist was linked
    conn.close()
    notify("playlists_changed")


def ensure_unbookmark_external_playlist(server_id: str, playlist_id: str):
    # only removes the playlist from the linking table
    conn = connect_to_db()
    execute_sql(conn, """
    execute_sql(
        conn,
        """
    DELETE FROM bookmarked_playlists
    WHERE server = ?, playlist_id = ?
    """,
    (server_id, playlist_id))
        (server_id, playlist_id),
    )
    conn.close()
    notify("playlists_changed")


def new_local_playlist(name: str, description: str, videos=[]) -> int:
    """Create a new local playlist and return id"""
    id = len(get_playlists())
    conn = connect_to_db()
    execute_sql(conn, """
    execute_sql(
        conn,
        """
    INSERT INTO local_playlists
    VALUES (?,?,?,?)
    """,
    (id, name, description, None))
        (id, name, description, None),
    )
    index = 0
    for vid in videos:
        add_to_local_playlist(id, vid, index)


@@ 579,90 805,146 @@ def new_local_playlist(name: str, description: str, videos=[]) -> int:
    conn.close()
    notify("playlists_changed")
    return id
def ensure_delete_local_playlist(playlist_id:int):


def ensure_delete_local_playlist(playlist_id: int):
    conn = connect_to_db()
    execute_sql(conn, """
    execute_sql(
        conn,
        """
    DELETE FROM local_playlist_content
    WHERE id = ?
    """, (playlist_id,))
    execute_sql(conn,"""
    """,
        (playlist_id,),
    )
    execute_sql(
        conn,
        """
    DELETE FROM local_playlists
    WHERE id = ?
    """, (playlist_id,))
    """,
        (playlist_id,),
    )
    notify("playlists_changed")


def get_playlists() -> list[PlaylistWrapper]:
    results = []
    conn = connect_to_db()
    # get all local playlists
    local = conn.execute("""
    local = conn.execute(
        """
    SELECT id, title, description, thumbnail
    FROM local_playlists
    """).fetchall()
    """
    ).fetchall()
    for collection in local:
        p = LocalPlaylist(collection[0], collection[1], collection[2])
        p.thumbnail = collection[3]
        results.append(PlaylistWrapper.from_local(p))
    # get all bookmark playlists
    glob = conn.execute("""
    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])
        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, pos=None):


def add_to_local_playlist(playlist_id: int, vid, pos=None):
    ensure_video(vid)
    conn = connect_to_db()
    if pos is None:
        vids = conn.execute("""
        vids = conn.execute(
            """
        SELECT MAX(position)
        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,)).fetchone()
        pos = vids[0]+1
    execute_sql(conn, """
        """,
            (playlist_id,),
        ).fetchone()
        pos = vids[0] + 1
    execute_sql(
        conn,
        """
    INSERT INTO local_playlist_content
    VALUES (?, ?, ?, ?)
    """, (playlist_id, vid.id, vid.server, pos))
    """,
        (playlist_id, vid.id, vid.server, pos),
    )
    conn.close()
    notify("playlists_changed")
def delete_from_local_playlist(playlist_id: int, pos:int):


def delete_from_local_playlist(playlist_id: int, pos: int):
    conn = connect_to_db()
    execute_sql(conn, """
    execute_sql(
        conn,
        """
    DELETE FROM local_playlist_content
    WHERE id = ? AND position = ?
    """, (playlist_id, pos))
    """,
        (playlist_id, pos),
    )
    conn.close()
    notify("playlists_changed")
def get_local_playlist(playlist_id:int) -> LocalPlaylist:


def get_local_playlist(playlist_id: int) -> LocalPlaylist:
    conn = connect_to_db()
    resp = conn.execute("""
    resp = conn.execute(
        """
    SELECT id, title, description, thumbnail
    FROM local_playlists
    WHERE id = ?
    """, (playlist_id,)).fetchone()
    """,
        (playlist_id,),
    ).fetchone()
    p = LocalPlaylist(resp[0], resp[1], resp[2])
    p.thumbnail = resp[3]
    vids = conn.execute("""
    vids = conn.execute(
        """
    SELECT server, videos.id, url, title, description, thumbnail, channel_id, channel_name, position
    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
    ORDER BY position
    """, (playlist_id,)).fetchall()
    p.content = [ (Video(srv, url, vid, title, (cname, cid), desc, thumb), pos) for (srv, vid, url, title, desc, thumb, cid, cname, pos) in vids ]
    """,
        (playlist_id,),
    ).fetchall()
    p.content = [
        (Video(srv, url, vid, title, (cname, cid), desc, thumb), pos)
        for (srv, vid, url, title, desc, thumb, cid, cname, pos) in vids
    ]
    return p


def set_local_playlist_thumbnail(playlist_id: int, thumb: str):
    conn = connect_to_db()
    execute_sql(conn, """
    execute_sql(
        conn,
        """
    UPDATE local_playlists
    SET thumbnail = ?
    WHERE id = ?
    """, (thumb, playlist_id))
    """,
        (thumb, playlist_id),
    )
    conn.close()
    notify("playlists_changed")


def add_to_history(vid, uts=None):
    """
    Takes :Video type and adds the video to the history


@@ 672,15 954,20 @@ def add_to_history(vid, uts=None):
        uts = datetime.now().timestamp()
    ensure_video(vid)
    conn = connect_to_db()
    execute_sql(conn, """
    execute_sql(
        conn,
        """
    INSERT INTO history
    VALUES (?, ?, ?)
    """,
    (uts, vid.id, vid.server))
        (uts, vid.id, vid.server),
    )
    conn.close()
    notify("history_changed")
    return False
def add_history_items(items:list[(Video, int)]):


def add_history_items(items: list[(Video, int)]):
    # NOTE: you have to ensure the videos exist yourself
    conn = connect_to_db()
    execute_sql(


@@ 690,40 977,59 @@ def add_history_items(items:list[(Video, int)]):
        VALUES (?, ?, ?)
        """,
        [(d[1], d[0].id, d[0].server) for d in items],
        many=True)
        many=True,
    )
    conn.close()
    notify("history_changed")


def get_history():
    """
    Returns a list of (Video, uts) tuples
    """
    conn = connect_to_db()
    results = conn.execute("""
    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
    ORDER BY timestamp DESC
    """).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 ]
    """
    ).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
    ]


def get_cached_feed(limit=100):
    """
    Returns a list of (Video, uts) tuples
    """
    conn = connect_to_db()
    results = conn.execute("""
    results = conn.execute(
        """
    SELECT DISTINCT timestamp, server, id, url, title, description, thumbnail, channel_id, channel_name
    FROM news join videos on news.video_id = videos.id AND news.video_server = videos.server
    ORDER BY timestamp DESC
    LIMIT ?
    """, (limit,)).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 ]
    """,
        (limit,),
    ).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
    ]


def clear_cached_feed():
    conn = connect_to_db()
    execute_sql(conn, """
    execute_sql(
        conn,
        """
    DELETE FROM news
    """)
    """,
    )


def update_cached_feed(ls: list[(Video, int)]):
    uts = datetime.now().timestamp()
    conn = connect_to_db()


@@ 737,74 1043,104 @@ def update_cached_feed(ls: list[(Video, int)]):
        """
        INSERT OR REPLACE INTO appconf
        VALUES (?,?)
        """, ("news-feed-refresh", uts))
        """,
        ("news-feed-refresh", uts),
    )
    # update cached items
    for entry in ls:
        vid = entry[0]
        uts = entry[1]
        ensure_video(vid)
        execute_sql(conn, """
        execute_sql(
            conn,
            """
        INSERT OR REPLACE INTO news
        VALUES (?, ?, ?)
        """,
        (uts, vid.id, vid.server))
            (uts, vid.id, vid.server),
        )


def get_last_feed_refresh() -> int:
    app_conf = get_app_settings()
    if "news-feed-refresh" in app_conf:
        return app_conf["news-feed-refresh"]
    return None

def get_video_duration(server_id:str, video_id:str) -> (float|None):

def get_video_duration(server_id: str, video_id: str) -> float | None:
    conn = connect_to_db()
    results = conn.execute("""
    results = conn.execute(
        """
    SELECT duration
    FROM playback
    WHERE video_id = ? AND video_server = ?
    """, (video_id, server_id)).fetchone()
    """,
        (video_id, server_id),
    ).fetchone()
    if results is None:
        return None
    return results[0]

def get_video_playback_position(server_id:str, video_id:str) -> (float|None):

def get_video_playback_position(server_id: str, video_id: str) -> float | None:
    conn = connect_to_db()
    results = conn.execute("""
    results = conn.execute(
        """
    SELECT position
    FROM playback
    WHERE video_id = ? AND video_server = ?
    """, (video_id, server_id)).fetchone()
    """,
        (video_id, server_id),
    ).fetchone()
    if results is None:
        return None
    return results[0]


def set_video_playback_position(vid: Video, position: float, duration: float):
    uts = datetime.now().timestamp()
    ensure_video(vid)
    conn = connect_to_db()
    execute_sql(conn, """
    execute_sql(
        conn,
        """
    INSERT OR REPLACE INTO playback
    VALUES (?,?,?,?,?)
    """, (uts, vid.server, vid.id, duration, position))
    """,
        (uts, vid.server, vid.id, duration, position),
    )
    conn.close()
    notify("playback_changed")

def set_video_playback_position_force(server_id: str, video_id:str, position: float, duration: float):

def set_video_playback_position_force(
    server_id: str, video_id: str, position: float, duration: float
):
    uts = datetime.now().timestamp()
    conn = connect_to_db()
    execute_sql(conn, """
    execute_sql(
        conn,
        """
    INSERT OR REPLACE INTO playback
    VALUES (?,?,?,?,?)
    """, (uts, server_id, video_id, duration, position))
    """,
        (uts, server_id, video_id, duration, position),
    )
    conn.close()
    notify("playback_changed")

def get_last_playback() -> (tuple[Video, float, float] | None):

def get_last_playback() -> tuple[Video, float, float] | None:
    conn = connect_to_db()
    results = conn.execute("""
    results = conn.execute(
        """
    SELECT server, id, url, title, description, thumbnail, channel_id, channel_name, position, duration
    FROM playback join videos ON playback.video_id = videos.id AND playback.video_server = videos.server
    ORDER BY playback.timestamp DESC
     LIMIT 1
    """).fetchone()
    """
    ).fetchone()
    if results is None:
        return None
    d = results

M melon/models/callbacks.py => melon/models/callbacks.py +13 -3
@@ 12,28 12,38 @@ callbacks = {
    "history_changed": {},
    "settings_changed": {},
    "playback_changed": {},
    "quit": {}
    "quit": {},
}
frozen = False


def freeze():
    global frozen
    frozen = True


def unfreeze():
    global frozen
    frozen = False
    # notify everything - to refresh everything we might have missed
    for key in callbacks:
        notify(key)
def register_callback(target: str, callback_id:str, function):


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):


def notify(target: str):
    if frozen:
        return
    if not target in callbacks:

M melon/player/__init__.py => melon/player/__init__.py +22 -11
@@ 1,8 1,9 @@
import gi

gi.require_version("WebKit", "6.0")
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
gi.require_version('Gst', '1.0')
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
gi.require_version("Gst", "1.0")
from gi.repository import Gtk, Adw, WebKit, GLib, Gst
from unidecode import unidecode
from gettext import gettext as _


@@ 15,9 16,15 @@ from melon.widgets.feeditem import AdaptiveFeedItem
from melon.widgets.preferencerow import PreferenceRow, PreferenceType, Preference
from melon.widgets.iconbutton import IconButton
from melon.models import get_app_settings, add_to_history, register_callback
from melon.models import get_video_playback_position, set_video_playback_position, set_video_playback_position_force, get_video_duration
from melon.models import (
    get_video_playback_position,
    set_video_playback_position,
    set_video_playback_position_force,
    get_video_duration,
)
from melon.utils import pass_me


class PlayerScreen(Adw.NavigationPage):
    def on_open_in_browser(self, arg):
        Gtk.UriLauncher.new(uri=self.video.url).launch()


@@ 27,14 34,16 @@ class PlayerScreen(Adw.NavigationPage):
        self.scrollview.set_child(self.box)
        # video details
        self.about = Adw.PreferencesGroup()
        self.about.set_title(unidecode(self.video.title).replace("&","&amp;"))
        self.about.set_title(unidecode(self.video.title).replace("&", "&amp;"))

        # expandable description field
        desc_field = Adw.ExpanderRow()
        desc_field.set_title(_("Description"))
        desc_field.set_subtitle(unidecode(self.video.description[:40]).replace("&","&amp;")+"...")
        desc_field.set_subtitle(
            unidecode(self.video.description[:40]).replace("&", "&amp;") + "..."
        )
        desc = Adw.ActionRow()
        desc.set_subtitle(unidecode(self.video.description).replace("&","&amp;"))
        desc.set_subtitle(unidecode(self.video.description).replace("&", "&amp;"))
        desc_field.add_row(desc)
        self.about.add(desc_field)
        self.box.append(self.about)


@@ 47,7 56,8 @@ class PlayerScreen(Adw.NavigationPage):
        btn_bookmark.set_activatable(True)
        btn_bookmark.set_action_name("win.add_to_playlist")
        btn_bookmark.set_action_target_value(
            GLib.Variant("as", [self.video.server, self.video.id]))
            GLib.Variant("as", [self.video.server, self.video.id])
        )
        self.about.add(btn_bookmark)

    def display_player(self):


@@ 125,6 135,7 @@ class PlayerScreen(Adw.NavigationPage):
        GLib.idle_add(self.display_channel)

    view = None

    def __init__(self, server_id, video_id, *args, **kwargs):
        super().__init__(*args, **kwargs)



@@ 178,7 189,7 @@ class PlayerScreen(Adw.NavigationPage):
        dur = get_video_duration(server_id, video_id)
        if not pos is None and not dur is None:
            # TODO consider moving this to the settings panel
            consider_done = 0.99*dur
            consider_done = 0.99 * dur
            # video was probably watched till end
            # reset playback position
            if pos >= consider_done:


@@ 193,7 204,7 @@ class PlayerScreen(Adw.NavigationPage):

        if not self.external_btn is None:
            self.header_bar.remove(self.external_btn)
        self.external_btn = IconButton("","modem-symbolic")
        self.external_btn = IconButton("", "modem-symbolic")
        self.external_btn.connect("clicked", self.on_open_in_browser)
        self.header_bar.pack_end(self.external_btn)



@@ 206,7 217,7 @@ class PlayerScreen(Adw.NavigationPage):
        cb.set_center_widget(spinner)
        self.scrollview.set_child(cb)

        self.box = Gtk.Box(orientation = Gtk.Orientation.VERTICAL)
        self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        # add a padding to the box
        # so the preference groups do not touch the edge on mobile
        padding = 12

M melon/player/playlist.py => melon/player/playlist.py +49 -20
@@ 1,7 1,8 @@
import gi

gi.require_version("WebKit", "6.0")
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, WebKit, GLib
from unidecode import unidecode
from gettext import gettext as _


@@ 14,13 15,18 @@ from melon.widgets.feeditem import AdaptiveFeedItem
from melon.widgets.preferencerow import PreferenceRow, PreferenceType, Preference
from melon.widgets.iconbutton import IconButton
from melon.models import get_app_settings, add_to_history, register_callback
from melon.models import get_video_playback_position, set_video_playback_position, set_video_playback_position_force
from melon.models import (
    get_video_playback_position,
    set_video_playback_position,
    set_video_playback_position_force,
)
from melon.utils import pass_me

from melon.models import get_local_playlist, PlaylistWrapper, PlaylistWrapperType

from melon.player import PlayerScreen


class PlaylistPlayerScreen(PlayerScreen):
    playlist = None
    playlist_index = 0


@@ 31,7 37,7 @@ class PlaylistPlayerScreen(PlayerScreen):
    playlist_repeat = False
    playlist_repeat_single = False

    def __init__(self, ref: (tuple[str, str] | int), index=0, *args, **kwargs):
    def __init__(self, ref: tuple[str, str] | int, index=0, *args, **kwargs):
        # make sure to initalize widget
        # PlayerScreen.__init__ will break after initializing parents
        # because neither server or video are supplied


@@ 75,7 81,7 @@ class PlaylistPlayerScreen(PlayerScreen):
        self.thread.start()

    # fetch playlist data & store it interally
    def prepare_playlist_task(self, ref: (tuple[str, str] | int)):
    def prepare_playlist_task(self, ref: tuple[str, str] | int):
        if isinstance(ref, int):
            playlist_id = ref
            # local playlist


@@ 113,7 119,7 @@ class PlaylistPlayerScreen(PlayerScreen):
        if not ind is None:
            self.prepare(ind)

    def get_next_index(self,fallback=None):
    def get_next_index(self, fallback=None):
        index = self.playlist_index + 1
        if not fallback is None:
            index = fallback


@@ 124,10 130,10 @@ class PlaylistPlayerScreen(PlayerScreen):
        elif self.playlist_shuffle:
            # select a random title
            # NOTE: may just repeat the same song again
            index = random.randrange(0,len(self.playlist_content), 1)
            index = random.randrange(0, len(self.playlist_content), 1)
            return index

        if self.playlist_index+1 >= len(self.playlist_content):
        if self.playlist_index + 1 >= len(self.playlist_content):
            # playlist ended
            if self.playlist_repeat:
                # playlist is set to repeat so we continue picking songs


@@ 202,6 208,7 @@ class PlaylistPlayerScreen(PlayerScreen):

    def ctr_toggle_playlist_repeat(self, state):
        self.playlist_repeat = state

    def ctr_toggle_playlist_repeat_single(self, state):
        self.playlist_repeat_single = state



@@ 216,8 223,12 @@ class PlaylistPlayerScreen(PlayerScreen):
        if self.playlist_index > 0 and not self.playlist_shuffle:
            prev_btn = Adw.ActionRow()
            prev_btn.set_title(_("Previous"))
            prev_btn.set_subtitle(_("Play video that comes before this one in the playlist"))
            prev_btn.add_suffix(Gtk.Image.new_from_icon_name("media-skip-backward-symbolic"))
            prev_btn.set_subtitle(
                _("Play video that comes before this one in the playlist")
            )
            prev_btn.add_suffix(
                Gtk.Image.new_from_icon_name("media-skip-backward-symbolic")
            )
            prev_btn.connect("activated", lambda _: self.ctr_previous_video())
            prev_btn.set_activatable(True)
            self.ctr_group.add(prev_btn)


@@ 225,11 236,18 @@ class PlaylistPlayerScreen(PlayerScreen):
        # if the video isn't the last
        # show a next button
        # not shown when shuffle is enbaled
        if self.playlist_index+1 < len(self.playlist_content) and not self.playlist_shuffle:
        if (
            self.playlist_index + 1 < len(self.playlist_content)
            and not self.playlist_shuffle
        ):
            next_btn = Adw.ActionRow()
            next_btn.set_title(_("Next"))
            next_btn.set_subtitle(_("Play video that comes after this one in the playlist"))
            next_btn.add_suffix(Gtk.Image.new_from_icon_name("media-skip-forward-symbolic"))
            next_btn.set_subtitle(
                _("Play video that comes after this one in the playlist")
            )
            next_btn.add_suffix(
                Gtk.Image.new_from_icon_name("media-skip-forward-symbolic")
            )
            next_btn.connect("activated", lambda _: self.ctr_next_video())
            next_btn.set_activatable(True)
            self.ctr_group.add(next_btn)


@@ 239,7 257,9 @@ class PlaylistPlayerScreen(PlayerScreen):
            skip_btn = Adw.ActionRow()
            skip_btn.set_title(_("Skip"))
            skip_btn.set_subtitle(_("Skip this video and pick a new one at random"))
            skip_btn.add_suffix(Gtk.Image.new_from_icon_name("media-skip-forward-symbolic"))
            skip_btn.add_suffix(
                Gtk.Image.new_from_icon_name("media-skip-forward-symbolic")
            )
            skip_btn.connect("activated", lambda _: self.ctr_skip_video())
            skip_btn.set_activatable(True)
            self.ctr_group.add(skip_btn)


@@ 251,7 271,8 @@ class PlaylistPlayerScreen(PlayerScreen):
            _("Chooses the next video at random"),
            PreferenceType.TOGGLE,
            self.playlist_shuffle,
            self.playlist_shuffle)
            self.playlist_shuffle,
        )
        widg_shuffle = PreferenceRow(pref_shuffle)
        widg_shuffle.set_callback(self.ctr_toggle_shuffle)
        self.ctr_group.add(widg_shuffle.get_widget())


@@ 262,7 283,8 @@ class PlaylistPlayerScreen(PlayerScreen):
            _("Puts this video on loop"),
            PreferenceType.TOGGLE,
            self.playlist_repeat_single,
            self.playlist_repeat_single)
            self.playlist_repeat_single,
        )
        widg_repeat_single = PreferenceRow(pref_repeat_single)
        widg_repeat_single.set_callback(self.ctr_toggle_playlist_repeat_single)
        self.ctr_group.add(widg_repeat_single.get_widget())


@@ 274,10 296,13 @@ class PlaylistPlayerScreen(PlayerScreen):
            pref_repeat = Preference(
                "playlist-repeat",
                _("Repeat playlist"),
                _("Start playling the playlist from the beginning after reaching the end"),
                _(
                    "Start playling the playlist from the beginning after reaching the end"
                ),
                PreferenceType.TOGGLE,
                self.playlist_repeat,
                self.playlist_repeat)
                self.playlist_repeat,
            )
            widg_repeat = PreferenceRow(pref_repeat)
            widg_repeat.set_callback(self.ctr_toggle_playlist_repeat)
            self.ctr_group.add(widg_repeat.get_widget())


@@ 285,11 310,15 @@ class PlaylistPlayerScreen(PlayerScreen):
        # expander with playlist content
        expander = Adw.ExpanderRow()
        expander.set_title(_("Playlist Content"))
        expander.set_subtitle(_("Click on videos to continue playing the playlist from there"))
        expander.set_subtitle(
            _("Click on videos to continue playing the playlist from there")
        )
        for i in range(len(self.playlist_content)):
            item = self.playlist_content[i]
            # onClick: starts playling the playlist from <clicked.index>
            row = AdaptiveFeedItem(item, onClick=pass_me(lambda _, i: self.prepare(i), i))
            row = AdaptiveFeedItem(
                item, onClick=pass_me(lambda _, i: self.prepare(i), i)
            )
            expander.add_row(row)
        self.ctr_group.add(expander)


M melon/playlist/__init__.py => melon/playlist/__init__.py +66 -27
@@ 1,7 1,8 @@
import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib
from unidecode import unidecode
from gettext import gettext as _


@@ 11,10 12,24 @@ from melon.servers import Preference, PreferenceType
from melon.widgets.iconbutton import IconButton
from melon.widgets.feeditem import AdaptiveFeedItem
from melon.widgets.simpledialog import SimpleDialog
from melon.models import get_app_settings, get_local_playlist, PlaylistWrapper, ensure_playlist, ensure_delete_local_playlist, set_local_playlist_thumbnail, delete_from_local_playlist
from melon.models import is_server_enabled, ensure_server_disabled, ensure_server_enabled, register_callback
from melon.models import (
    get_app_settings,
    get_local_playlist,
    PlaylistWrapper,
    ensure_playlist,
    ensure_delete_local_playlist,
    set_local_playlist_thumbnail,
    delete_from_local_playlist,
)
from melon.models import (
    is_server_enabled,
    ensure_server_disabled,
    ensure_server_enabled,
    register_callback,
)
from melon.utils import pass_me


class LocalPlaylistScreen(Adw.NavigationPage):
    def __init__(self, playlist_id, *args, **kwargs):
        super().__init__(*args, **kwargs)


@@ 50,16 65,19 @@ class LocalPlaylistScreen(Adw.NavigationPage):
        edit_button = IconButton(_("Edit"), "document-edit-symbolic")
        edit_button.connect("clicked", lambda _: self.open_edit())

        self.startplay_btn = IconButton("","media-playback-start-symbolic", tooltip=_("Start playing"))
        self.startplay_btn = IconButton(
            "", "media-playback-start-symbolic", tooltip=_("Start playing")
        )
        self.startplay_btn.set_action_name("win.playlistplayer-local")
        self.startplay_btn.set_action_target_value(
            GLib.Variant("u", self.playlist.id))
        self.startplay_btn.set_action_target_value(GLib.Variant("u", self.playlist.id))
        self.header_bar.pack_end(self.startplay_btn)

        if len(playlist.content) == 0:
            status = Adw.StatusPage()
            status.set_title(_("*crickets chirping*"))
            status.set_description(_("You haven't added any videos to this playlist yet"))
            status.set_description(
                _("You haven't added any videos to this playlist yet")
            )
            status.set_icon_name("weather-few-clouds-night-symbolic")
            icon_button = IconButton(_("Start watching"), "video-display-symbolic")
            icon_button.set_action_name("win.home")


@@ 81,11 99,25 @@ class LocalPlaylistScreen(Adw.NavigationPage):
            app_conf = get_app_settings()
            self.box.add(group)
            # add playlist content to group as well
            for (entry, index) in sorted(playlist.content, key=lambda x:x[1]):
            for entry, index in sorted(playlist.content, key=lambda x: x[1]):
                item = AdaptiveFeedItem(entry)
                item.menuitems = [
                        (_("Set as playlist thumbnail"), pass_me(lambda pid, thumb: set_local_playlist_thumbnail(pid, thumb), self.playlist_id, entry.thumbnail)),
                        (_("Remove from playlist"), pass_me(lambda pid, pos: delete_from_local_playlist(pid, pos), self.playlist_id, index))
                    (
                        _("Set as playlist thumbnail"),
                        pass_me(
                            lambda pid, thumb: set_local_playlist_thumbnail(pid, thumb),
                            self.playlist_id,
                            entry.thumbnail,
                        ),
                    ),
                    (
                        _("Remove from playlist"),
                        pass_me(
                            lambda pid, pos: delete_from_local_playlist(pid, pos),
                            self.playlist_id,
                            index,
                        ),
                    ),
                ]
                group.add(item)



@@ 114,7 146,9 @@ class LocalPlaylistScreen(Adw.NavigationPage):
        input_group.add(self.input_desc)
        save_btn = Adw.ActionRow()
        save_btn.set_title(_("Save details"))
        save_btn.set_subtitle(_("Change playlist title and description. (Closes the dialog)"))
        save_btn.set_subtitle(
            _("Change playlist title and description. (Closes the dialog)")
        )
        save_btn.add_suffix(Gtk.Image.new_from_icon_name("go-next-symbolic"))
        save_btn.set_activatable(True)
        save_btn.connect("activated", lambda _: self.save_details())


@@ 125,7 159,9 @@ class LocalPlaylistScreen(Adw.NavigationPage):
        dgroup = Adw.PreferencesGroup()
        delete_btn = Adw.ActionRow()
        delete_btn.set_title(_("Delete Playlist"))
        delete_btn.set_subtitle(_("Delete this playlist and it's content. This can NOT be undone."))
        delete_btn.set_subtitle(
            _("Delete this playlist and it's content. This can NOT be undone.")
        )
        delete_btn.add_suffix(Gtk.Image.new_from_icon_name("go-next-symbolic"))
        delete_btn.set_activatable(True)
        delete_btn.connect("activated", lambda _: self.confirm_delete())


@@ 136,10 172,12 @@ class LocalPlaylistScreen(Adw.NavigationPage):
        # to manually close the dialog
        # without applying changes
        bottom_bar = Gtk.Box()
        btn_cancel = IconButton(_("Close"), "process-stop-symbolic", tooltip=_("Close without changing anything"))
        btn_cancel.connect(
            "clicked",
            lambda x: self.edit_diag.hide())
        btn_cancel = IconButton(
            _("Close"),
            "process-stop-symbolic",
            tooltip=_("Close without changing anything"),
        )
        btn_cancel.connect("clicked", lambda x: self.edit_diag.hide())
        padding = 12
        btn_cancel.set_vexpand(True)
        btn_cancel.set_hexpand(True)


@@ 174,27 212,28 @@ class LocalPlaylistScreen(Adw.NavigationPage):
        self.dlt_diag.set_widget(info)

        bottom_bar = Gtk.Box()
        btn_cancel = IconButton(_("Cancel"), "process-stop-symbolic", tooltip=_("Do not delete the playlist"))
        btn_confirm = IconButton(_("Delete"), "list-add-symbolic", tooltip=_("Delete this playlist"))
        btn_confirm.connect(
            "clicked",
            lambda _: self.delete()
        btn_cancel = IconButton(
            _("Cancel"),
            "process-stop-symbolic",
            tooltip=_("Do not delete the playlist"),
        )
        btn_confirm = IconButton(
            _("Delete"), "list-add-symbolic", tooltip=_("Delete this playlist")
        )
        btn_cancel.connect(
            "clicked",
            lambda _: self.dlt_diag.hide())
        btn_confirm.connect("clicked", lambda _: self.delete())
        btn_cancel.connect("clicked", lambda _: self.dlt_diag.hide())

        padding = 12
        btn_confirm.set_vexpand(True)
        btn_confirm.set_hexpand(True)
        btn_confirm.set_margin_end(padding)
        btn_confirm.set_margin_start(padding/2)
        btn_confirm.set_margin_start(padding / 2)
        btn_confirm.set_margin_top(padding)
        btn_confirm.set_margin_bottom(padding)
        btn_cancel.set_vexpand(True)
        btn_cancel.set_hexpand(True)
        btn_cancel.set_margin_start(padding)
        btn_cancel.set_margin_end(padding/2)
        btn_cancel.set_margin_end(padding / 2)
        btn_cancel.set_margin_top(padding)
        btn_cancel.set_margin_bottom(padding)
        bottom_bar.append(btn_cancel)

M melon/playlist/create.py => melon/playlist/create.py +28 -22
@@ 1,7 1,8 @@
import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, Gio, GLib
from gettext import gettext as _
import threading


@@ 12,21 13,26 @@ from melon.widgets.feeditem import AdaptiveFeedItem
from melon.models import new_local_playlist, Video
from melon.servers.utils import get_app_settings


class PlaylistCreatorDialog(SimpleDialog):
    def display(self, video=None):
        page = Adw.PreferencesPage()
        self.content = []
        if not video is None:
            self.content = [ video ]
            self.content = [video]
            # use preference group as preview for video element
            preview_group = Adw.PreferencesGroup()
            preview_group.set_title(_("Video"))
            preview_group.set_description(_("The following video will be added to the new playlist"))
            preview_group.add(AdaptiveFeedItem(
                video,
                clickable=False,
                show_preview=get_app_settings()["show_images_in_feed"]
            ))
            preview_group.set_description(
                _("The following video will be added to the new playlist")
            )
            preview_group.add(
                AdaptiveFeedItem(
                    video,
                    clickable=False,
                    show_preview=get_app_settings()["show_images_in_feed"],
                )
            )
            page.add(preview_group)
        # use preference group for input
        input_group = Adw.PreferencesGroup()


@@ 44,27 50,26 @@ class PlaylistCreatorDialog(SimpleDialog):
        page.add(input_group)

        bottom_bar = Gtk.Box()
        btn_cancel = IconButton(_("Cancel"), "process-stop-symbolic", tooltip=_("Do not create playlist"))
        btn_confirm = IconButton(_("Create"), "list-add-symbolic", tooltip=_("Create playlist"))
        btn_confirm.connect(
            "clicked",
            self.create_playlist
        btn_cancel = IconButton(
            _("Cancel"), "process-stop-symbolic", tooltip=_("Do not create playlist")
        )
        btn_confirm = IconButton(
            _("Create"), "list-add-symbolic", tooltip=_("Create playlist")
        )
        btn_cancel.connect(
            "clicked",
            lambda x: self.hide())
        btn_confirm.connect("clicked", self.create_playlist)
        btn_cancel.connect("clicked", lambda x: self.hide())

        padding = 12
        btn_confirm.set_vexpand(True)
        btn_confirm.set_hexpand(True)
        btn_confirm.set_margin_end(padding)
        btn_confirm.set_margin_start(padding/2)
        btn_confirm.set_margin_start(padding / 2)
        btn_confirm.set_margin_top(padding)
        btn_confirm.set_margin_bottom(padding)
        btn_cancel.set_vexpand(True)
        btn_cancel.set_hexpand(True)
        btn_cancel.set_margin_start(padding)
        btn_cancel.set_margin_end(padding/2)
        btn_cancel.set_margin_end(padding / 2)
        btn_cancel.set_margin_top(padding)
        btn_cancel.set_margin_bottom(padding)
        bottom_bar.append(btn_cancel)


@@ 72,6 77,7 @@ class PlaylistCreatorDialog(SimpleDialog):
        self.toolbar_view.add_bottom_bar(bottom_bar)

        self.set_widget(page)

    def background(self, target=None):
        video = None
        if isinstance(target, Video):


@@ 86,6 92,7 @@ class PlaylistCreatorDialog(SimpleDialog):
                instance = get_server_instance(servers[server_id])
                video = instance.get_video_info(video_id)
        GLib.idle_add(self.display, video)

    def __init__(self, video=None, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.set_title(_("New Playlist"))


@@ 106,7 113,6 @@ class PlaylistCreatorDialog(SimpleDialog):

    def create_playlist(self, x):
        new_local_playlist(
            self.input_title.get_text(),
            self.input_desc.get_text(),
            self.content)
            self.input_title.get_text(), self.input_desc.get_text(), self.content
        )
        self.hide()

M melon/playlist/pick.py => melon/playlist/pick.py +38 -20
@@ 1,7 1,8 @@
import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, Gio, GLib, Gdk
from unidecode import unidecode
from gettext import gettext as _


@@ 11,9 12,15 @@ from melon.widgets.feeditem import AdaptiveFeedItem, AdaptivePlaylistFeedItem
from melon.widgets.iconbutton import IconButton
from melon.widgets.simpledialog import SimpleDialog
from melon.models import get_playlists, PlaylistWrapperType, add_to_local_playlist
from melon.servers.utils import get_app_settings, pixbuf_from_url, get_servers_list, get_server_instance
from melon.servers.utils import (
    get_app_settings,
    pixbuf_from_url,
    get_servers_list,
    get_server_instance,
)
from melon.playlist.create import PlaylistCreatorDialog


class PlaylistPickerDialog(SimpleDialog):
    def open_creator(self, video):
        diag = PlaylistCreatorDialog(video)


@@ 26,18 33,26 @@ class PlaylistPickerDialog(SimpleDialog):
        # use preference group as preview for video element
        preview_group = Adw.PreferencesGroup()
        preview_group.set_title(_("Video"))
        preview_group.set_description(_("The following video will be added to the playlist"))
        preview_group.add(AdaptiveFeedItem(
            video,
            clickable=False,
            show_preview=get_app_settings()["show_images_in_feed"]
        ))
        preview_group.set_description(
            _("The following video will be added to the playlist")
        )
        preview_group.add(
            AdaptiveFeedItem(
                video,
                clickable=False,
                show_preview=get_app_settings()["show_images_in_feed"],
            )
        )
        page.add(preview_group)

        group = Adw.PreferencesGroup()
        page.add(group)
        group.set_title(_("Add to playlist"))
        group.set_description(_("Choose a playlist to add the video to. Note that you can only add videos to local playlists, not external bookmarked ones"))
        group.set_description(
            _(
                "Choose a playlist to add the video to. Note that you can only add videos to local playlists, not external bookmarked ones"
            )
        )
        make_new = Adw.ActionRow()
        make_new.set_title(_("Create new playlist"))
        make_new.set_subtitle(_("Create a new playlist and add the video to it"))


@@ 47,10 62,7 @@ class PlaylistPickerDialog(SimpleDialog):
        # make_new.set_action_target_value(
        #     GLib.Variant("as", [video.server, video.id]))
        # so we'll manually trigger this instead
        make_new.connect(
            "activated",
            lambda _: self.open_creator(video)
        )
        make_new.connect("activated", lambda _: self.open_creator(video))
        make_new.set_activatable(True)
        group.add(make_new)



@@ 61,13 73,20 @@ class PlaylistPickerDialog(SimpleDialog):
            row = AdaptivePlaylistFeedItem(
                playlist,
                onClick=pass_me(
                    lambda _, playlist, video: add_to_local_playlist(playlist.inner.id, video) or self.hide(),
                    playlist, video)
                    lambda _, playlist, video: add_to_local_playlist(
                        playlist.inner.id, video
                    )
                    or self.hide(),
                    playlist,
                    video,
                ),
            )
            group.add(row)

        bottom_bar = Gtk.Box()
        btn_cancel = IconButton(_("Cancel"), "process-stop-symbolic", tooltip=_("Do not create playlist"))
        btn_cancel = IconButton(
            _("Cancel"), "process-stop-symbolic", tooltip=_("Do not create playlist")
        )
        padding = 12
        btn_cancel.set_vexpand(True)
        btn_cancel.set_hexpand(True)


@@ 76,9 95,7 @@ class PlaylistPickerDialog(SimpleDialog):
        btn_cancel.set_margin_top(padding)
        btn_cancel.set_margin_bottom(padding)
        bottom_bar.append(btn_cancel)
        btn_cancel.connect(
            "clicked",
            lambda x:self.hide())
        btn_cancel.connect("clicked", lambda x: self.hide())
        self.toolbar_view.add_bottom_bar(bottom_bar)

        self.set_widget(page)


@@ 119,5 136,6 @@ class PlaylistPickerDialog(SimpleDialog):
        self.thread.daemon = True
        self.thread.start()


def pass_me(func, *args):
    return lambda x: func(x, *args)

M melon/servers/__init__.py => melon/servers/__init__.py +56 -19
@@ 1,9 1,10 @@
from enum import Flag,Enum,auto
from abc import ABC,abstractmethod
from enum import Flag, Enum, auto
from abc import ABC, abstractmethod
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
gi.require_version('WebKit', '6.0')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
gi.require_version("WebKit", "6.0")
from gi.repository import GObject, WebKit
from typing import Callable



@@ 12,11 13,12 @@ from melon.import_providers import ImportProvider

REQUESTS_TIMEOUT = 5

USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0'
USER_AGENT_MOBILE = 'Mozilla/5.0 (Linux; U; Android 4.1.1; en-gb; Build/KLP) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Safari/534.30'
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0"
USER_AGENT_MOBILE = "Mozilla/5.0 (Linux; U; Android 4.1.1; en-gb; Build/KLP) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Safari/534.30"

server_finder.install()


class SearchMode(Enum):
    # show all results
    ANY = auto()


@@ 27,6 29,7 @@ class SearchMode(Enum):
    # show videos
    VIDEOS = auto()


class PreferenceType(Enum):
    # insert text
    # default:str, value:str


@@ 46,6 49,7 @@ class PreferenceType(Enum):
    # default: map[str,str], value: str
    DROPDOWN = auto()


class Preference:
    # name of the settings entry
    name: str


@@ 53,12 57,13 @@ class Preference:
    description: str
    # preference type
    type: PreferenceType
    # default value 
    # default value
    # NOTE: should correspond to type
    default: any
    # current value
    # 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
        self.name = name


@@ 67,6 72,7 @@ class Preference:
        self.default = default
        self.value = value


class Resource(GObject.Object):
    # external url of the resource
    # used for "Open in Browser"


@@ 76,13 82,15 @@ class Resource(GObject.Object):
    # i.e when trying to get additional information
    id: str
    # id of server from where the resource comes
    server:str
    server: str

    def __init__(self, server, url, id):
        super().__init__()
        self.server = server
        self.id = id
        self.url = url


class Video(Resource):
    # video title
    title: str


@@ 91,13 99,16 @@ class Video(Resource):
    # video thumbnail url (None if non existend)
    thumbnail: str
    # name and id of channel
    channel: (str,str)
    def __init__(self, server, url, id, title, channel,desc, thumb):
    channel: (str, str)

    def __init__(self, server, url, id, title, channel, desc, thumb):
        super().__init__(server, url, id)
        self.title = title
        self.description = desc
        self.thumbnail = thumb
        self.channel = channel


class Playlist(Resource):
    # video title
    title: str


@@ 106,12 117,15 @@ class Playlist(Resource):
    # preview image url (None if non existend)
    thumbnail: str
    # name and id of channel
    channel: (str,str)
    channel: (str, str)

    def __init__(self, server, url, id, title, channel, thumb):
        super().__init__(server, url, id)
        self.title = title
        self.thumbnail = thumb
        self.channel = channel


class Channel(Resource):
    # channel name
    name: str


@@ 119,12 133,14 @@ class Channel(Resource):
    bio: str
    # channel logo url (None if non existend)
    avatar: str

    def __init__(self, server, url, id, name, bio, avatar):
        super().__init__(server, url, id)
        self.name = name
        self.bio = bio
        self.avatar = avatar


class Feed:
    # external url
    url: str


@@ 134,24 150,29 @@ class Feed:
    name: str
    # icon to display for category/feed
    icon: str = "starred-symbolic"

    def __init__(self, id, name, icon=None):
        self.id = id
        self.name = name
        self.icon = icon

    def with_url(self, url):
        n = Feed(self.id, self.name, self.icon)
        n.url = url
        return n


class Stream:
    # stream url used for playback
    url: str
    # quality name/id used in selector dropdown
    quality: str

    def __init__(self, url, quality):
        self.url = url
        self.quality = quality


class Server(ABC):
    # required to indeitfy the server in the database
    id: str


@@ 166,30 187,35 @@ class Server(ABC):
    # set to true if the service only works after logging in
    requires_login: bool = False
    # list of Preference's
    settings:dict[str,Preference] = {}
    settings: dict[str, Preference] = {}

    @abstractmethod
    def get_external_url(self) -> str:
        """
        External homepage url
        """
        pass

    def get_public_feeds(self) -> list[Feed]:
        """
        Returns a list of available feeds
        i.e Popular
        """
        return []
    def get_public_feed_content(self,id:str) -> list[Resource]:

    def get_public_feed_content(self, id: str) -> list[Resource]:
        """Returns a list of videos in the given feed"""
        return []

    @abstractmethod
    def search(self, query:str, mask: SearchMode, page=0) -> list[Resource]:
    def search(self, query: str, mask: SearchMode, page=0) -> list[Resource]:
        """
        Search for a given query
        filter for things according to mask
        should also support pagination (if available)
        """
        return []

    @abstractmethod
    def get_channel_info(self, cid: str) -> Channel:
        """


@@ 197,25 223,31 @@ class Server(ABC):
        including channel name, avatar and description
        """
        pass

    @abstractmethod
    def get_channel_feeds(self, cid:str) -> list[Feed]:
    def get_channel_feeds(self, cid: str) -> list[Feed]:
        """
        Returns a list of available channel feeds
        i.e. Videos, Playlists
        """
        return []

    @abstractmethod
    def get_default_channel_feed(self, cid:str) -> str:
    def get_default_channel_feed(self, cid: str) -> str:
        """
        Returns the id of the default channel feed
        """
        pass

    @abstractmethod
    def get_channel_feed_content(self, cid: str, feed_id: str, page=0) -> list[Resource]:
    def get_channel_feed_content(
        self, cid: str, feed_id: str, page=0
    ) -> list[Resource]:
        """
        Returns list of resources in the channel feed
        """
        return []

    @abstractmethod
    def get_playlist_info(self, pid: str) -> Playlist:
        """


@@ 223,12 255,14 @@ class Server(ABC):
        including name and avatar
        """
        pass

    @abstractmethod
    def get_playlist_content(self, pid: str) -> list[Resource]:
        """
        Returns a list of resources in the playlist
        """
        return []

    @abstractmethod
    def get_timeline(self, cid: str) -> list[(Resource, int)]:
        """


@@ 237,19 271,22 @@ class Server(ABC):
        and sorting them
        """
        return []

    @abstractmethod
    def get_video_info(self, vid: str) -> Video:
        """
        Returns video resource object
        """
        pass

    @abstractmethod
    def get_video_streams(self, vid:str) -> list[Stream]:
    def get_video_streams(self, vid: str) -> list[Stream]:
        """
        Returns a list of available streams
        i.e hd, 720p
        """
        return []

    def get_import_providers(self) -> list[ImportProvider]:
        """
        Returns a list of import providers,

M melon/servers/invidious/__init__.py => melon/servers/invidious/__init__.py +85 -64
@@ 1,9 1,10 @@
from bs4 import BeautifulSoup
import requests
from urllib.parse import urlparse,parse_qs
from urllib.parse import urlparse, parse_qs
from datetime import datetime
from gettext import gettext as _
import gi

gi.require_version("WebKit", "6.0")
from gi.repository import GLib, WebKit



@@ 13,14 14,17 @@ from melon.servers import Feed, Channel, Video, Playlist, Stream, SearchMode
from melon.servers import USER_AGENT
from melon.utils import pass_me


class NewpipeInvidiousImporter(NewpipeImporter):
    server_id = "invidious"
    # I think newpipe uses service_id 0 for youtube content
    service_id = 0

    def __init__(self, url):
        self.base_url = url
        self.img_url = url


# NOTE: uses beautifulsoup instead of the invidious api
# because not all invidious servers provide the api
# and even if they do, most of the time it is ratelimited


@@ 34,19 38,22 @@ class Invidious(Server):
        "instance": Preference(
            "instance",
            _("Instance"),
            _("See https://docs.invidious.io/instances/ for a list of available instances"),
            _(
                "See https://docs.invidious.io/instances/ for a list of available instances"
            ),
            PreferenceType.TEXT,
            "https://inv.tux.pizza",
            "https://inv.tux.pizza"),
       }
            "https://inv.tux.pizza",
        ),
    }

    # youtube might contain 18+ content
    # so we have to indicate that this may contain nsfw content
    is_nsfw = True,
    is_nsfw = (True,)

    known_public_feeds = {
        "/feed/trending": Feed("trending", _("Trending"), "starred-symbolic"),
        "/feed/popular": Feed("popular", _("Popular"), "emblem-favorite-symbolic")
        "/feed/popular": Feed("popular", _("Popular"), "emblem-favorite-symbolic"),
    }

    def get_external_url(self):


@@ 67,18 74,19 @@ class Invidious(Server):
        if r.ok:
            soup = BeautifulSoup(r.text, "lxml")
            # extract all elements in the navigation header
            links = soup.find_all("a", {"class":"feed-menu-item"})
            links = soup.find_all("a", {"class": "feed-menu-item"})
            feeds = []
            for elem in links:
                id = elem["href"]
                # lookup the entries and if it is valid save it
                if id in self.known_public_feeds:
                    feeds.append(self.known_public_feeds[id]
                                 .with_url(f"{instance}{id}"))
                    feeds.append(
                        self.known_public_feeds[id].with_url(f"{instance}{id}")
                    )
            return feeds
        return []

    def get_public_feed_content(self,id):
    def get_public_feed_content(self, id):
        """Returns a list of videos in the given feed"""
        instance = self.get_external_url()
        return self.request_and_parse(f"{instance}/feed/{id}")


@@ 93,27 101,32 @@ class Invidious(Server):
            t = "playlist"
        elif mask == SearchMode.VIDEOS:
            t = "video"
        return self.request_and_parse(f"{instance}/search?q={query}&page={page}&type={t}")
        return self.request_and_parse(
            f"{instance}/search?q={query}&page={page}&type={t}"
        )

    def get_channel_info(self, cid: str):
        instance = self.get_external_url()
        url = f"{instance}/channel/{cid}"
        r = requests.get(url, headers={ "User-Agent": USER_AGENT })
        r = requests.get(url, headers={"User-Agent": USER_AGENT})
        if r.ok:
            soup = BeautifulSoup(r.text, "lxml")
            desc_elem = soup.find("div", { "id": "descriptionWrapper" })
            desc = ''.join(desc_elem.strings).strip()
            profile = soup.find("div", { "class": "channel-profile" })
            desc_elem = soup.find("div", {"id": "descriptionWrapper"})
            desc = "".join(desc_elem.strings).strip()
            profile = soup.find("div", {"class": "channel-profile"})
            name = profile.find("span").string
            avatar = profile.find("img")["src"]
            return Channel(self.id, url, cid, name, desc, avatar)
    def get_channel_feeds(self, cid:str) :

    def get_channel_feeds(self, cid: str):
        instance = self.get_external_url()
        url = f"{instance}/channel/{cid}"
        r = requests.get(url, headers={ "User-Agent": USER_AGENT })
        r = requests.get(url, headers={"User-Agent": USER_AGENT})
        if r.ok:
            soup = BeautifulSoup(r.text, "lxml")
            wrapper = soup.find("div", { "id": "descriptionWrapper" }).parent.find_next_sibling()
            wrapper = soup.find(
                "div", {"id": "descriptionWrapper"}
            ).parent.find_next_sibling()
            nav = wrapper.find("div")
            # NOTE: the icons do not matter
            results = [Feed("", "Videos").with_url(url)]


@@ 126,12 139,12 @@ class Invidious(Server):
                if feed_id == "community":
                    # text based posts are currently not supported
                    continue
                results.append(Feed(feed_id, link.string).with_url(url+feed_id))
                results.append(Feed(feed_id, link.string).with_url(url + feed_id))
            return results
            

        return []

    def get_default_channel_feed(self, cid:str):
    def get_default_channel_feed(self, cid: str):
        return ""

    def get_channel_feed_content(self, cid: str, feed_id: str, page=0):


@@ 141,10 154,10 @@ class Invidious(Server):
    def get_playlist_info(self, pid: str):
        instance = self.get_external_url()
        url = f"{instance}/playlist?list={pid}"
        r = requests.get(url, headers={ "User-Agent": USER_AGENT })
        r = requests.get(url, headers={"User-Agent": USER_AGENT})
        if r.ok:
            soup = BeautifulSoup(r.text, "lxml")
            headline = soup.find("div", { "class": "title" })
            headline = soup.find("div", {"class": "title"})
            title_elem = headline.find("h3")
            title = title_elem.string
            details_elem = headline.find_next_sibling()


@@ 161,7 174,7 @@ class Invidious(Server):
    def get_timeline(self, cid: str):
        instance = self.get_external_url()
        url = f"{instance}/feed/channel/{cid}/"
        r = requests.get(url, headers={ "User-Agent": USER_AGENT })
        r = requests.get(url, headers={"User-Agent": USER_AGENT})
        if r.ok:
            soup = BeautifulSoup(r.text, features="xml")
            feed = []


@@ 183,31 196,40 @@ class Invidious(Server):
                    channel_id_elem = entry.find("yt:channelid")
                channel_id = channel_id_elem.string
                channel_name = entry.find("author").find("name").string
                url = entry.find("link", { "rel": "alternate" })["href"]
                url = entry.find("link", {"rel": "alternate"})["href"]
                vid_title = entry.find("title").string
                desc = ''.join(entry.find("content").strings).strip()
                desc = "".join(entry.find("content").strings).strip()
                thumb = entry.find("media:thumbnail")["url"]
                date_str = entry.find("published").string
                uts = datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S%z").timestamp()
                feed.append((Video(
                    self.id,
                    url, vid_id,
                    vid_title,
                    (channel_name, channel_id), desc, thumb), uts))
                feed.append(
                    (
                        Video(
                            self.id,
                            url,
                            vid_id,
                            vid_title,
                            (channel_name, channel_id),
                            desc,
                            thumb,
                        ),
                        uts,
                    )
                )
            return feed
        return []

    def request_and_parse(self,url, recurse = False):
    def request_and_parse(self, url, recurse=False):
        """Fetches the URL and returns a list of Resources"""
        r = requests.get(url, headers={ "User-Agent": USER_AGENT })
        r = requests.get(url, headers={"User-Agent": USER_AGENT})
        if r.ok:
            soup = BeautifulSoup(r.text, "lxml")
            # every result is wrapped in an h-box
            listings = soup.find_all("div", {"class":"h-box"})
            listings = soup.find_all("div", {"class": "h-box"})
            results = []
            for listing in listings:
                # the resource name can be found in the video-card-row div
                row = listing.find("div", { "class": "video-card-row" })
                row = listing.find("div", {"class": "video-card-row"})
                # the top bar also contains an h-box
                # so we have to skip entries without video-card-row elements
                if row is None:


@@ 216,7 238,7 @@ class Invidious(Server):
                link = row.find("a")
                # beautiful soup can automatically flatten
                # single nested elements
                name = ''.join(link.strings).strip()
                name = "".join(link.strings).strip()
                # there is only one image per h-box
                # so we can just use the first one
                img = listing.find("img")["src"]


@@ 235,17 257,19 @@ class Invidious(Server):
                if t == "channel":
                    # is channel
                    bio_elem = listing.find("h5")
                    bio = ''.join(bio_elem.strings).strip()
                    res = Channel(self.id, full_url, dt.path.split("/")[2], name, bio, img)
                    bio = "".join(bio_elem.strings).strip()
                    res = Channel(
                        self.id, full_url, dt.path.split("/")[2], name, bio, img
                    )
                elif t == "playlist":
                    # is playlist
                    pid = parse_qs(dt.query)["list"][0]
                    # the channelname is tthe second video-card-row in the h-box
                    ch = listing.find_all("div", { "class": "video-card-row" })[1]
                    ch = listing.find_all("div", {"class": "video-card-row"})[1]
                    channel_name_elem = ch.find("p", {"class": "channel-name"})
                    # we have to iterate over substrings as well,
                    # because verified channels also contain an i element
                    channel_name = ''.join(channel_name_elem.strings).strip()
                    channel_name = "".join(channel_name_elem.strings).strip()
                    # the channel link is absolute with the form /channel/$id
                    channel_id = ch.find("a")["href"].split("/")[2]
                    channel = (channel_name, channel_id)


@@ 254,16 278,16 @@ class Invidious(Server):
                    # is video
                    vid = parse_qs(dt.query)["v"][0]
                    # the channelname is tthe second video-card-row in the h-box
                    ch = listing.find_all("div", { "class": "video-card-row" })[1]
                    ch = listing.find_all("div", {"class": "video-card-row"})[1]
                    channel_name_elem = ch.find("p", {"class": "channel-name"})
                    # we have to iterate over substrings as well,
                    # because verified channels also contain an i element
                    channel_name = ''.join(channel_name_elem.strings).strip()
                    channel_name = "".join(channel_name_elem.strings).strip()
                    # the channel link is absolute with the form /channel/$id
                    channel_id = ch.find("a")["href"].split("/")[2]
                    channel = (channel_name, channel_id)
                    # we cannot get the description of a video from a list
                    res = Video(self.id, full_url, vid, name, channel,"", img)
                    res = Video(self.id, full_url, vid, name, channel, "", img)
                else:
                    continue
                results.append(res)


@@ 278,7 302,7 @@ class Invidious(Server):
                    except Exception as e:
                        current_page = 1
                instance = self.get_external_url()
                listings = soup.find_all("a", {"class":"pure-button"})
                listings = soup.find_all("a", {"class": "pure-button"})
                for lis in listings:
                    iurl = lis["href"]
                    iurlres = urlparse(iurl)


@@ 305,31 329,30 @@ class Invidious(Server):
    def get_video_info(self, vid: str):
        instance = self.get_external_url()
        url = f"{instance}/watch?v={vid}"
        r = requests.get(url, headers={ "User-Agent": USER_AGENT })
        r = requests.get(url, headers={"User-Agent": USER_AGENT})
        if r.ok:
            soup = BeautifulSoup(r.text, "lxml")
            hbox = soup.find("div", {"id":"player-container"}).find_next_sibling()
            title = ''.join(hbox.find("h1").strings).strip()
            channel_profile = soup.find("div", { "class": "channel-profile" })
            hbox = soup.find("div", {"id": "player-container"}).find_next_sibling()
            title = "".join(hbox.find("h1").strings).strip()
            channel_profile = soup.find("div", {"class": "channel-profile"})
            channel_id = channel_profile.parent["href"].split("/")[-1]
            channel_name = ''.join(soup.find("span", {"id": "channel-name"}).strings).strip()
            desc_elem = soup.find("div", { "id": "descriptionWrapper" })
            desc = ''.join(desc_elem.strings).strip()
            thumb = soup.find("video", { "id": "player" })["poster"]
            channel_name = "".join(
                soup.find("span", {"id": "channel-name"}).strings
            ).strip()
            desc_elem = soup.find("div", {"id": "descriptionWrapper"})
            desc = "".join(desc_elem.strings).strip()
            thumb = soup.find("video", {"id": "player"})["poster"]
            return Video(
                self.id, url,
                vid,
                title,
                (channel_name, channel_id),
                desc,
                thumb)
    def get_video_streams(self, vid:str):
                self.id, url, vid, title, (channel_name, channel_id), desc, thumb
            )

    def get_video_streams(self, vid: str):
        instance = self.get_external_url()
        url = f"{instance}/watch?v={vid}"
        r = requests.get(url, headers={ "User-Agent": USER_AGENT })
        r = requests.get(url, headers={"User-Agent": USER_AGENT})
        if r.ok:
            soup = BeautifulSoup(r.text, "lxml")
            video = soup.find("video", { "id": "player" })
            video = soup.find("video", {"id": "player"})
            results = []
            for src in video.find_all("source"):
                try:


@@ 342,6 365,4 @@ class Invidious(Server):
        return []

    def get_import_providers(self):
        return [
            NewpipeInvidiousImporter(self.get_external_url())
        ]
        return [NewpipeInvidiousImporter(self.get_external_url())]

M melon/servers/loader.py => melon/servers/loader.py +5 -5
@@ 12,7 12,7 @@ import types


class ServerFinder(importlib.abc.MetaPathFinder):
    _PREFIX = 'melon.servers.'
    _PREFIX = "melon.servers."

    def __init__(self, path=None):
        if isinstance(path, str):


@@ 34,10 34,10 @@ class ServerFinder(importlib.abc.MetaPathFinder):
        if not fullname.startswith(self._PREFIX):
            return None

        name = fullname[len(self._PREFIX):]
        base_dir = name.replace('.', '/')
        name = fullname[len(self._PREFIX) :]
        base_dir = name.replace(".", "/")
        for path in self._paths:
            candidate_path = os.path.join(path, base_dir, '__init__.py')
            candidate_path = os.path.join(path, base_dir, "__init__.py")
            if os.path.exists(candidate_path):
                return importlib.machinery.ModuleSpec(
                    fullname,


@@ 69,4 69,4 @@ class ServerLoader(importlib.machinery.SourceFileLoader):
        return module


server_finder = ServerFinder(os.environ.get('MELON_SERVERS_PATH'))
server_finder = ServerFinder(os.environ.get("MELON_SERVERS_PATH"))

M melon/servers/nebula/__init__.py => melon/servers/nebula/__init__.py +62 -55
@@ 9,6 9,7 @@ from melon.servers import Server, Preference, PreferenceType
from melon.servers import Feed, Channel, Video, Playlist, Stream, SearchMode
from melon.servers import USER_AGENT


# NOTE: uses beautifulsoup instead of the invidious api
# because not all invidious servers provide the api
# and even if they do, most of the time it is ratelimited


@@ 16,7 17,9 @@ class Nebula(Server):

    id = "nebula"
    name = "Nebula"
    description = _("Home of smart, thoughtful videos, podcasts, and classes from your favorite creators")
    description = _(
        "Home of smart, thoughtful videos, podcasts, and classes from your favorite creators"
    )

    settings = {
        "email": Preference(


@@ 24,14 27,17 @@ class Nebula(Server):
            _("Email Address"),
            _("Email Address to login to your account"),
            PreferenceType.TEXT,
            "",""
            "",
            "",
        ),
        "password": Preference(
            "password",
            _("Password"),
            _("Password associated with your account"),
            PreferenceType.PASSWORD,
            "",""),
            "",
            "",
        ),
    }

    # nebula might contain 18+ material


@@ 58,23 64,25 @@ class Nebula(Server):
            feeds = []
            for listing in data:
                if listing["title"] == "Latest Videos":
                    feeds.append(Feed(
                        listing["id"],
                        _(listing["title"]),
                        icon="dialog-information-symbolic")
                    .with_url(listing["view_all_url"]))
                    feeds.append(
                        Feed(
                            listing["id"],
                            _(listing["title"]),
                            icon="dialog-information-symbolic",
                        ).with_url(listing["view_all_url"])
                    )
                elif listing["title"] == "Nebula Originals":
                    feeds.append(Feed(
                        listing["id"],
                        _(listing["title"]),
                        icon="starred-symbolic"
                    ).with_url(listing["view_all_url"]))
                    feeds.append(
                        Feed(
                            listing["id"], _(listing["title"]), icon="starred-symbolic"
                        ).with_url(listing["view_all_url"])
                    )
                elif listing["title"] == "Nebula Plus":
                    feeds.append(Feed(
                        listing["id"],
                        _(listing["title"]),
                        icon="list-add-symbolic"
                    ).with_url(listing["view_all_url"]))
                    feeds.append(
                        Feed(
                            listing["id"], _(listing["title"]), icon="list-add-symbolic"
                        ).with_url(listing["view_all_url"])
                    )
            return feeds
        return []



@@ 88,7 96,8 @@ class Nebula(Server):
                entry["id"],
                entry["title"],
                entry["description"],
                entry["images"]["avatar"]["src"])
                entry["images"]["avatar"]["src"],
            )
            return channel
        elif type == "video_episode":
            # video type


@@ 99,7 108,8 @@ class Nebula(Server):
                entry["title"],
                (entry["channel_title"], entry["channel_id"]),
                entry["short_description"],
                entry["images"]["thumbnail"]["src"])
                entry["images"]["thumbnail"]["src"],
            )
            return video
        elif type == "video_playlist":
            # playlist type


@@ 111,10 121,11 @@ class Nebula(Server):
                entry["title"],
                None,
                # nebula doesn't support playlist thumbnails
                None)
                None,
            )
            return playlist

    def get_public_feed_content(self,id):
    def get_public_feed_content(self, id):
        """Returns a list of videos in the given feed"""
        # as fas as I can tell nebula doesn't have a different target
        # to access specific featured rails


@@ 135,7 146,7 @@ class Nebula(Server):
        return []

    def search(self, query, mask, page=0):
        page = page+1
        page = page + 1
        # nebula doesn't provide playlist search
        # which means that this feed will always be empty
        if mask == SearchMode.PLAYLISTS:


@@ 151,7 162,7 @@ class Nebula(Server):
        # than there are videos when looking for a channel
        if mask == SearchMode.ANY or mask == SearchMode.CHANNELS:
            # fetch channels
            r = requests.get(channel_search, headers={ "User-Agent": USER_AGENT })
            r = requests.get(channel_search, headers={"User-Agent": USER_AGENT})
            if r.ok:
                data = json.loads(r.text)
                for entry in data["results"]:


@@ 160,7 171,7 @@ class Nebula(Server):
                        results.append(res)
        if mask == SearchMode.ANY or mask == SearchMode.VIDEOS:
            # fetch videos
            r = requests.get(video_search, headers={ "User-Agent": USER_AGENT })
            r = requests.get(video_search, headers={"User-Agent": USER_AGENT})
            if r.ok:
                data = json.loads(r.text)
                for entry in data["results"]:


@@ 171,7 182,7 @@ class Nebula(Server):

    def get_channel_info(self, channel_id: str):
        url = f"https://content.api.nebula.app/video_channels/{channel_id}/"
        r = requests.get(url, headers={ "User-Agent": USER_AGENT })
        r = requests.get(url, headers={"User-Agent": USER_AGENT})
        if r.ok:
            data = json.loads(r.text)
            return Channel(


@@ 180,20 191,21 @@ class Nebula(Server):
                data["id"],
                data["title"],
                data["description"],
                data["images"]["avatar"]["src"])
                data["images"]["avatar"]["src"],
            )

    def get_channel_feeds(self, channel_id:str):
    def get_channel_feeds(self, channel_id: str):
        url = f"https://content.api.nebula.app/video_channels/{channel_id}/"
        r = requests.get(url, headers={ "User-Agent": USER_AGENT })
        r = requests.get(url, headers={"User-Agent": USER_AGENT})
        video_feed = Feed("videos", _("Videos"))
        if r.ok:
            data = json.loads(r.text)
            if (not "playlist" in data) or len(data["playlist"]) == 0:
                return [ video_feed ]
                return [video_feed]
            else:
                return [ video_feed, Feed("playlists", _("Playlists")) ]
                return [video_feed, Feed("playlists", _("Playlists"))]

    def get_default_channel_feed(self, cid:str):
    def get_default_channel_feed(self, cid: str):
        return "videos"

    def get_channel_feed_content(self, channel_id: str, feed_id: str, page=0):


@@ 203,7 215,7 @@ class Nebula(Server):
            # apparently this call doesn't support page numbers
            # and instead we have to use the provided data["next"] cursor
            url = f"https://content.api.nebula.app/video_channels/{channel_id}/video_episodes/"
            r = requests.get(url, headers={ "User-Agent": USER_AGENT })
            r = requests.get(url, headers={"User-Agent": USER_AGENT})
            if r.ok:
                data = json.loads(r.text)
                for entry in data["results"]:


@@ 215,7 227,7 @@ class Nebula(Server):
            # NOTE: All playlists are included in this call
            # no pagination available
            url = f"https://content.api.nebula.app/video_channels/{channel_id}/"
            r = requests.get(url, headers={ "User-Agent": USER_AGENT })
            r = requests.get(url, headers={"User-Agent": USER_AGENT})
            if r.ok:
                data = json.loads(r.text)
                for entry in data["playlists"]:


@@ 226,25 238,27 @@ class Nebula(Server):

    def get_playlist_info(self, playlist_id: str):
        url = f"https://content.api.nebula.app/video_playlists/{playlist_id}/"
        r = requests.get(url, headers={ "User-Agent": USER_AGENT })
        r = requests.get(url, headers={"User-Agent": USER_AGENT})
        if r.ok:
            data = json.loads(r.text)
            return Playlist(
                self.id,
                # nebula doesn't have external playlist urls
                None,
                playlist_id, data["title"],
                playlist_id,
                data["title"],
                # nebula doesn't support getting the channel
                # when accessing the playlist
                None,
                # nebula doesn't support playlist thumbnails
                None)
                None,
            )

    def get_playlist_content(self, playlist_id: str):
        # apparently this call doesn't support page numbers
        # and instead we have to use the provided data["next"] cursor
        url = f"https://content.api.nebula.app/video_playlists/{playlist_id}/video_episodes/"
        r = requests.get(url, headers={ "User-Agent": USER_AGENT })
        r = requests.get(url, headers={"User-Agent": USER_AGENT})
        results = []
        if r.ok:
            data = json.loads(r.text)


@@ 255,7 269,7 @@ class Nebula(Server):

    def get_timeline(self, channel_id: str):
        url = f"https://content.api.nebula.app/video_channels/{channel_id}/video_episodes/"
        r = requests.get(url, headers={ "User-Agent": USER_AGENT })
        r = requests.get(url, headers={"User-Agent": USER_AGENT})
        results = []
        if r.ok:
            data = json.loads(r.text)


@@ 266,10 280,9 @@ class Nebula(Server):
                results.append((vid, uts))
        return results


    def get_video_info(self, video_id: str):
        url = f"https://content.api.nebula.app/video_episodes/{ video_id }/"
        r = requests.get(url, headers={ "User-Agent": USER_AGENT })
        r = requests.get(url, headers={"User-Agent": USER_AGENT})
        if r.ok:
            data = json.loads(r.text)
            return Video(


@@ 279,9 292,10 @@ class Nebula(Server):
                data["title"],
                (data["channel_title"], data["channel_id"]),
                data["description"],
                data["images"]["thumbnail"]["src"])
                data["images"]["thumbnail"]["src"],
            )

    def get_video_streams(self, video_id:str):
    def get_video_streams(self, video_id: str):
        # THIS ACTION REQUIRES LOGIN DETAILS
        email = self.settings["email"].value
        passwd = self.settings["password"].value


@@ 290,14 304,10 @@ class Nebula(Server):
        # NOTE: the first time the app runs this,
        # it will send out the Login Detected email
        level1_auth = f"https://nebula.tv/auth/login/"
        level1_data = {
            "email": email,
            "password": passwd
        }
        level1_data = {"email": email, "password": passwd}
        r1 = requests.post(
            level1_auth,
            json = level1_data,
            headers = { "Content-Type": "application/json" })
            level1_auth, json=level1_data, headers={"Content-Type": "application/json"}
        )
        if not r1.ok:
            return []
        key1 = r1.json()["key"]


@@ 307,10 317,7 @@ class Nebula(Server):
        level2_auth = "https://users.api.nebula.app/api/v1/authorization/"
        r2 = requests.post(
            level2_auth,
            headers = {
                "User-Agent": USER_AGENT,
                "Authorization": f"Token {key1}"
            }
            headers={"User-Agent": USER_AGENT, "Authorization": f"Token {key1}"},
        )
        if not r2.ok:
            return []


@@ 319,7 326,7 @@ class Nebula(Server):
        # it is easier to obtain the stream manifest using the video slug
        # so we fetch the video info url and get the slug
        vid = f"https://content.api.nebula.app/video_episodes/{ video_id }/"
        r_vid = requests.get(vid, headers={ "User-Agent": USER_AGENT })
        r_vid = requests.get(vid, headers={"User-Agent": USER_AGENT})
        if not r_vid.ok:
            return []
        slug = r_vid.json()["slug"]

M melon/servers/peertube/__init__.py => melon/servers/peertube/__init__.py +53 -43
@@ 1,5 1,5 @@
import requests
from urllib.parse import urlparse,parse_qs
from urllib.parse import urlparse, parse_qs
from datetime import datetime
from gettext import gettext as _



@@ 8,6 8,7 @@ from melon.servers import Feed, Channel, Video, Playlist, Stream, SearchMode
from melon.servers import USER_AGENT
from melon.import_providers.newpipe import NewpipeImporter


class Peertube(Server):

    id = "peertube"


@@ 18,27 19,34 @@ class Peertube(Server):
        "instances": Preference(
            "instances",
            _("Instances"),
            _("List of peertube instances, from which to fetch content. See https://joinpeertube.org/instances"),
            _(
                "List of peertube instances, from which to fetch content. See https://joinpeertube.org/instances"
            ),
            PreferenceType.MULTI,
            ["https://tilvids.com/"],
            ["https://tilvids.com/"]),
            ["https://tilvids.com/"],
        ),
        "nsfw": Preference(
            "nsfw",
            _("Show NSFW content"),
            _("Passes the nsfw filter to the peertube search API"),
            PreferenceType.TOGGLE,
            False, False),
            False,
            False,
        ),
        "federate": Preference(
            "federate",
            _("Enable Federation"),
            _("Returns content from federated instances instead of only local content"),
            PreferenceType.TOGGLE,
            True, True)
       }
            True,
            True,
        ),
    }

    # peertube instances may contain 18+ content
    # so we have to indicate that this may contain nsfw content
    is_nsfw = True,
    is_nsfw = (True,)

    def get_external_url(self):
        # because this plugin supports multiple instances,


@@ 47,16 55,14 @@ class Peertube(Server):
        return "https://joinpeertube.org/"

    def get_instance_list(self):
        return [ inst.strip("/") for inst in self.settings["instances"].value ]
        return [inst.strip("/") for inst in self.settings["instances"].value]

    def get_public_feeds(self):
        # because this plugin supports multiple instances
        # we cannot set n external URL
        # nor is it possible to show trending videos,
        # because they cannot be merged
        return [
            Feed("latest", _("Latest"), "dialog-information-symbolic")
        ]
        return [Feed("latest", _("Latest"), "dialog-information-symbolic")]

    def get_query_config(self) -> str:
        local = not self.settings["federate"].value


@@ 71,11 77,11 @@ class Peertube(Server):
            nsfw = "false"
        return f"isLocal={local}&nsfw={nsfw}"

    def get_public_feed_content(self,id):
    def get_public_feed_content(self, id):
        if id != "latest":
            return []
        conf = self.get_query_config()
        videos:(Video,int) = []
        videos: (Video, int) = []
        for instance in self.get_instance_list():
            url = f"{instance}/feeds/videos.json?sort=-publishedAt&{conf}"
            r = requests.get(url)


@@ 99,8 105,8 @@ class Peertube(Server):
                    uts = datetime.fromisoformat(date_str).timestamp()
                    videos.append((v, uts))

        videos.sort(key=lambda x:x[1])
        return [ v[0] for v in videos ]
        videos.sort(key=lambda x: x[1])
        return [v[0] for v in videos]

    def search(self, query, mask, page=1):
        vid_conf = self.get_query_config()


@@ 118,15 124,16 @@ class Peertube(Server):
                        channel_id = f"https://{item_host}::{item_id}"
                        avatar = None
                        if "avatar" in item and not item["avatar"] is None:
                            path=item["avatar"]["path"]
                            avatar=f"{instance}{path}"
                            path = item["avatar"]["path"]
                            avatar = f"{instance}{path}"
                        c = Channel(
                            self.id,
                            item["url"],
                            channel_id,
                            item["displayName"],
                            item["description"] or "",
                            avatar)
                            avatar,
                        )
                        results.append(c)
            if mask == SearchMode.PLAYLISTS or mask == SearchMode.ANY:
                # playlist search


@@ 148,7 155,8 @@ class Peertube(Server):
                            item_id,
                            item["displayName"],
                            (channel_name, channel_id),
                            thumb)
                            thumb,
                        )
                        results.append(p)
            if mask == SearchMode.VIDEOS or mask == SearchMode.ANY:
                # video search


@@ 158,7 166,7 @@ class Peertube(Server):
                    for item in r.json()["data"]:
                        video_host = instance
                        video_slug = item["uuid"]
                        video_id=f"{video_host}::{video_slug}"
                        video_id = f"{video_host}::{video_slug}"
                        thumb_path = item["thumbnailPath"]
                        thumb = f"{instance}/{thumb_path}"
                        channel_name = item["channel"]["displayName"]


@@ 172,7 180,8 @@ class Peertube(Server):
                            item["name"],
                            (channel_name, channel_id),
                            item["description"] or "",
                            thumb)
                            thumb,
                        )
                        results.append(v)
        return results



@@ 195,20 204,16 @@ class Peertube(Server):
                channel_id,
                data["displayName"],
                data["description"],
                avatar)
                avatar,
            )

    def get_channel_feeds(self, cid:str) :
    def get_channel_feeds(self, cid: str):
        res = self.get_channel_feed_content(cid, "playlists")
        if len(res) != 0:
            return [
                Feed("videos", _("Videos")),
                Feed("playlists", _("Playlists"))
            ]
        return [
            Feed("videos", _("Videos"))
        ]
            return [Feed("videos", _("Videos")), Feed("playlists", _("Playlists"))]
        return [Feed("videos", _("Videos"))]

    def get_default_channel_feed(self, cid:str):
    def get_default_channel_feed(self, cid: str):
        return "videos"

    def get_channel_feed_content(self, cid: str, feed_id: str, page=1):


@@ 221,7 226,7 @@ class Peertube(Server):
                for item in r.json()["data"]:
                    video_host = instance
                    video_slug = item["uuid"]
                    video_id=f"{video_host}::{video_slug}"
                    video_id = f"{video_host}::{video_slug}"
                    thumb_path = item["thumbnailPath"]
                    thumb = f"{instance}{thumb_path}"
                    channel_name = item["channel"]["displayName"]


@@ 235,7 240,8 @@ class Peertube(Server):
                        item["name"],
                        (channel_name, channel_id),
                        item["description"],
                        thumb)
                        thumb,
                    )
                    results.append(v)
        elif feed_id == "playlists":
            url = f"{instance}/api/v1/video-channels/{channel_id}/video-playlists"


@@ 255,12 261,12 @@ class Peertube(Server):
                        item_id,
                        item["displayName"],
                        (channel_name, channel_id),
                        thumb)
                        thumb,
                    )
                    results.append(p)

        return results


    def get_playlist_info(self, pid: str):
        instance, playlist_id = pid.split("::")
        url = f"{instance}/api/v1/video-playlists/{playlist_id}"


@@ 281,7 287,8 @@ class Peertube(Server):
                item_id,
                data["displayName"],
                (channel_name, channel_id),
                thumb)
                thumb,
            )

    def get_playlist_content(self, pid: str):
        instance, playlist_id = pid.split("::")


@@ 294,7 301,7 @@ class Peertube(Server):
                    item = item["video"]
                    video_host = instance
                    video_slug = item["uuid"]
                    video_id=f"{video_host}::{video_slug}"
                    video_id = f"{video_host}::{video_slug}"
                    thumb_path = item["thumbnailPath"]
                    thumb = f"{instance}{thumb_path}"
                    channel_name = item["channel"]["displayName"]


@@ 315,7 322,8 @@ class Peertube(Server):
                    item["name"],
                    (channel_name, channel_id),
                    item["description"],
                    thumb)
                    thumb,
                )
                results.append(v)
        return results



@@ 328,7 336,7 @@ class Peertube(Server):
            for item in r.json()["data"]:
                video_host = instance
                video_slug = item["uuid"]
                video_id=f"{video_host}::{video_slug}"
                video_id = f"{video_host}::{video_slug}"
                thumb_path = item["thumbnailPath"]
                thumb = f"{instance}{thumb_path}"
                channel_name = item["channel"]["displayName"]


@@ 342,7 350,8 @@ class Peertube(Server):
                    item["name"],
                    (channel_name, channel_id),
                    item["description"],
                    thumb)
                    thumb,
                )
                uts = datetime.fromisoformat(item["updatedAt"]).timestamp()
                results.append((v, uts))
        return results


@@ 355,7 364,7 @@ class Peertube(Server):
            data = r.json()
            video_host = instance
            video_slug = data["uuid"]
            video_id=f"{video_host}::{video_slug}"
            video_id = f"{video_host}::{video_slug}"
            channel_name = data["channel"]["displayName"]
            channel_slug = data["channel"]["name"]
            channel_host = data["channel"]["host"]


@@ 369,9 378,10 @@ class Peertube(Server):
                data["name"],
                (channel_name, channel_id),
                data["description"],
                thumb)
                thumb,
            )

    def get_video_streams(self, vid:str):
    def get_video_streams(self, vid: str):
        instance, video_id = vid.split("::")
        url = f"{instance}/api/v1/videos/{video_id}"
        r = requests.get(url)

M melon/servers/utils.py => melon/servers/utils.py +66 -31
@@ 10,11 10,17 @@ import requests
import urllib
from datetime import datetime
from melon.servers.loader import server_finder
from melon.models import get_server_settings, get_subscribed_channels, get_app_settings, load_server
from melon.models import (
    get_server_settings,
    get_subscribed_channels,
    get_app_settings,
    load_server,
)
from melon.servers import Server
from melon.servers import Channel, Playlist, Resource

def pixbuf_from_url(url:str) -> GdkPixbuf.Pixbuf:

def pixbuf_from_url(url: str) -> GdkPixbuf.Pixbuf:
    """
    Fetches the image
    and constructs a Pixbuf using the PixbufLoader


@@ 36,31 42,37 @@ def pixbuf_from_url(url:str) -> GdkPixbuf.Pixbuf:
    except:
        return None


def get_server_instance(server) -> Server:
    """
    Returns a constructed class of the server
    """
    instance = getattr(server['module'], server['class_name'])()
    instance = getattr(server["module"], server["class_name"])()
    # get server settings and update the values in here
    for key, dt in get_server_settings(server["id"])["custom"].items():
        if key in instance.settings:
            instance.settings[key].value = dt
    return instance


import threading


class NewsWorkerPool:
    threads=[]
    tasks=[]
    results=[]
    threads = []
    tasks = []
    results = []

    def __init__(self, size, tasks):
        self.results=[]
        self.threads=[]
        self.results = []
        self.threads = []
        self.tasks = tasks
        for i in range(size):
            thread = threading.Thread(target=self.run, name=str(i))
            thread.daemon = True
            thread.start()
            self.threads.append(thread)

    def run(self):
        name = threading.currentThread().getName()
        while self.tasks:


@@ 70,10 82,12 @@ class NewsWorkerPool:
            dts = instance.get_timeline(channel.id)
            for entry in dts:
                self.results.append(entry)

    def wait(self):
        for thread in self.threads:
            thread.join()


def fetch_home_feed() -> list[Resource]:
    subs = get_subscribed_channels()
    # generate {server}:[channel] database


@@ 82,7 96,7 @@ def fetch_home_feed() -> list[Resource]:
        if sub.server in db:
            db[sub.server].append(sub)
        else:
            db[sub.server] = [ sub ]
            db[sub.server] = [sub]
    servers = get_allowed_servers_list(get_app_settings())
    # generate list of tasks
    tasks = []


@@ 91,23 105,27 @@ def fetch_home_feed() -> list[Resource]:
            continue
        instance = get_server_instance(server)
        channels = db[server["id"]]
        tasks = tasks + [ (instance, channel) for channel in channels ]
        tasks = tasks + [(instance, channel) for channel in channels]
    pool = NewsWorkerPool(6, tasks)
    pool.wait()
    feed = pool.results
    feed.sort(key=lambda dts: dts[1])
    return feed


def group_by_date(dataset):
    db = {}
    for entry in dataset:
        dt = entry[0]
        uts = entry[1]
        date = datetime.fromtimestamp(uts)
        local_date_time = date.date().strftime("%c").replace("00:00:00","").replace("  ", " ")
        local_date_time = (
            date.date().strftime("%c").replace("00:00:00", "").replace("  ", " ")
        )
        if local_date_time in db:
            db[local_date_time].append(dt)
        else:
            db[local_date_time] = [ dt ]
            db[local_date_time] = [dt]
    return db




@@ 117,21 135,31 @@ def server_is_allowed(server_id, app_settings):
    if server_settings["enabled"] is False:
        return False
    # skip servers that may contain nsfw content (if user disabled them)
    if app_settings["nsfw_content"] is False and server_settings['nsfw_content']:
    if app_settings["nsfw_content"] is False and server_settings["nsfw_content"]:
        return False
    # skip servers that contain only nsfw content (if user disable them)
    if app_settings["nsfw_only_content"] is False and server_settings['nsfw_only_content']:
    if (
        app_settings["nsfw_only_content"] is False
        and server_settings["nsfw_only_content"]
    ):
        return False
    # skip servers that require login if the user disabled them
    if app_settings["login_required"] is False and server_settings['login_required']:
    if app_settings["login_required"] is False and server_settings["login_required"]:
        return False
    return True

def filter_resources(reslist:list[Resource], app_settings, access=None) ->list[Resource]:

def filter_resources(
    reslist: list[Resource], app_settings, access=None
) -> list[Resource]:
    if access is None:
        return [ res for res in reslist if server_is_allowed(res.server, app_settings) ]
        return [res for res in reslist if server_is_allowed(res.server, app_settings)]
    else:
        return [ res for res in reslist if server_is_allowed(access(res).server, app_settings) ]
        return [
            res
            for res in reslist
            if server_is_allowed(access(res).server, app_settings)
        ]


# mostly taken from https://codeberg.org/valos/Komikku/src/commit/a8a5adfc814cf049241119a92fccddcd34efc6db/komikku/servers/utils.py


@@ 140,6 168,7 @@ def filter_resources(reslist:list[Resource], app_settings, access=None) ->list[R
# SPDX-License-Identifier: GPL-3.0-only or GPL-3.0-or-later
# Author: Valéry Febvre <vfebvre@easter-eggs.com>


def get_allowed_servers_list(settings):
    servers = []
    for id, server_data in get_servers_list().items():


@@ 167,32 196,33 @@ def get_server_class_name_by_id(id):
    - `module_name` is the name of the module in which the server is defined (optional).
      Only useful if `module_name` is different from `name`.
    """
    return id.split(':')[0].capitalize()
    return id.split(":")[0].capitalize()


def get_server_dir_name_by_id(id):
    name = id.split(':')[0]
    name = id.split(":")[0]
    # Remove _whatever
    name = '_'.join(filter(None, name.split('_')[:2]))
    name = "_".join(filter(None, name.split("_")[:2]))

    return name


def get_server_main_id_by_id(id):
    return id.split(':')[0].split('_')[0]
    return id.split(":")[0].split("_")[0]


def get_server_module_name_by_id(id):
    return id.split(':')[-1].split('_')[0]
    return id.split(":")[-1].split("_")[0]


@cache
def get_servers_list(include_disabled=False, order_by='name'):
def get_servers_list(include_disabled=False, order_by="name"):
    def iter_namespace(ns_pkg):
        # Specifying the second argument (prefix) to iter_modules makes the
        # returned name an absolute name instead of a relative one. This allows
        # import_module to work without having to do additional modification to
        # the name.
        return iter_modules(ns_pkg.__path__, ns_pkg.__name__ + '.')
        return iter_modules(ns_pkg.__path__, ns_pkg.__name__ + ".")

    modules = []
    if server_finder in sys.meta_path:


@@ 203,15 233,17 @@ def get_servers_list(include_disabled=False, order_by='name'):

            count = 0
            for path, _dirs, _files in os.walk(servers_path):
                relpath = path[len(servers_path):]
                relpath = path[len(servers_path) :]
                if not relpath:
                    continue

                relname = relpath.replace(os.path.sep, '.')
                if relname == '.multi':
                relname = relpath.replace(os.path.sep, ".")
                if relname == ".multi":
                    continue

                modules.append(importlib.import_module(relname, package='melon.servers'))
                modules.append(
                    importlib.import_module(relname, package="melon.servers")
                )
                count += 1
    else:
        # fallback to local exploration


@@ 223,13 255,16 @@ def get_servers_list(include_disabled=False, order_by='name'):
    servers = {}
    for module in modules:
        for _name, obj in dict(inspect.getmembers(module)).items():
            if not hasattr(obj, 'id') or not hasattr(obj, 'name'):
            if not hasattr(obj, "id") or not hasattr(obj, "name"):
                continue
            if NotImplemented in (obj.id, obj.name):
                continue

            if inspect.isclass(obj):
                logo_path = os.path.join(os.path.dirname(os.path.abspath(module.__file__)), get_server_main_id_by_id(obj.id) + '.png')
                logo_path = os.path.join(
                    os.path.dirname(os.path.abspath(module.__file__)),
                    get_server_main_id_by_id(obj.id) + ".png",
                )

                servers[obj.id] = dict(
                    id=obj.id,

M melon/settings/__init__.py => melon/settings/__init__.py +79 -50
@@ 1,7 1,8 @@
import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw
from gettext import gettext as _



@@ 9,46 10,68 @@ from melon.servers.utils import get_servers_list, get_server_instance
from melon.servers import Preference, PreferenceType
from melon.widgets.preferencerow import PreferenceRow
from melon.models import get_app_settings, set_app_setting, set_server_setting
from melon.models import is_server_enabled, ensure_server_disabled, ensure_server_enabled
from melon.models import (
    is_server_enabled,
    ensure_server_disabled,
    ensure_server_enabled,
)

global_prefs = {
        # True if images should be shown when searching or browsing public feeds
        "show_images_in_browse": Preference(
            "show_images_in_browse",
            _("Show Previews when browsing public feeds (incl. search)"),
            _("Set to true to show previews when viewing channel contents, public feeds and searching"),
            PreferenceType.TOGGLE,
            True, True),
        # True if images should be shown when browsing channels/playlists/home feed
        "show_images_in_feed": Preference(
            "show_images_in_feed",
            _("Show Previews in local feeds"),
            _("Set to true to show previews in the new feed, for subscribed channels, and saved playlists"),
            PreferenceType.TOGGLE,
            True, True),

        "nsfw_content": Preference(
            "nsfw_content",
            _("Show servers that may contain nsfw content"),
            _("Lists/Delists servers in the browse servers list, if they contain some nsfw content"),
            PreferenceType.TOGGLE,
            True, True),
        "nsfw_only_content": Preference(
            "nsfw_only_content",
            _("Show servers that only contain nsfw content"),
            _("Lists/Delists servers in the browse servers list, if they contain only/mostly nsfw content"),
            PreferenceType.TOGGLE,
            True, True
    # True if images should be shown when searching or browsing public feeds
    "show_images_in_browse": Preference(
        "show_images_in_browse",
        _("Show Previews when browsing public feeds (incl. search)"),
        _(
            "Set to true to show previews when viewing channel contents, public feeds and searching"
        ),
        PreferenceType.TOGGLE,
        True,
        True,
    ),
    # True if images should be shown when browsing channels/playlists/home feed
    "show_images_in_feed": Preference(
        "show_images_in_feed",
        _("Show Previews in local feeds"),
        _(
            "Set to true to show previews in the new feed, for subscribed channels, and saved playlists"
        ),
        PreferenceType.TOGGLE,
        True,
        True,
    ),
    "nsfw_content": Preference(
        "nsfw_content",
        _("Show servers that may contain nsfw content"),
        _(
            "Lists/Delists servers in the browse servers list, if they contain some nsfw content"
        ),
        PreferenceType.TOGGLE,
        True,
        True,
    ),
    "nsfw_only_content": Preference(
        "nsfw_only_content",
        _("Show servers that only contain nsfw content"),
        _(
            "Lists/Delists servers in the browse servers list, if they contain only/mostly nsfw content"
        ),
        "login_required": Preference(
            "login_required",
            _("Show servers that require login"),
            _("Lists/Delists servers in the browse servers list, if they require login to function"),
            PreferenceType.TOGGLE,
            True, True
        )
        PreferenceType.TOGGLE,
        True,
        True,
    ),
    "login_required": Preference(
        "login_required",
        _("Show servers that require login"),
        _(
            "Lists/Delists servers in the browse servers list, if they require login to function"
        ),
        PreferenceType.TOGGLE,
        True,
        True,
    ),
}


class SettingsScreen(Adw.NavigationPage):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


@@ 76,9 99,9 @@ class SettingsScreen(Adw.NavigationPage):
            # add callback when value changed
            # HACK: pass contextual arguments from for loop to helper function
            # fixes problem where pref_id would always have value of last element
            row.set_callback(pass_me(
                lambda val, other: set_app_setting(other[0], val),
                pref_id))
            row.set_callback(
                pass_me(lambda val, other: set_app_setting(other[0], val), pref_id)
            )
            self.general.add(row.get_widget())
        self.box.add(self.general)



@@ 91,14 114,15 @@ class SettingsScreen(Adw.NavigationPage):
            epref = Preference(
                "server-enabled",
                _("Enable Server"),
                _("Disabled servers won't show up in the browser or on the local/home screen"),
                _(
                    "Disabled servers won't show up in the browser or on the local/home screen"
                ),
                PreferenceType.TOGGLE,
                True, is_server_enabled(server_id))
                True,
                is_server_enabled(server_id),
            )
            er = PreferenceRow(epref)
            er.set_callback(pass_me(
                self.toggle_server,
                server_id
            ))
            er.set_callback(pass_me(self.toggle_server, server_id))
            group.add(er.get_widget())
            for pref_id, pref in instance.settings.items():
                # parse preference type and show fitting row


@@ 108,10 132,13 @@ class SettingsScreen(Adw.NavigationPage):
                # add callback when value changed
                # HACK: pass contextual arguments from for loop to helper function
                # fixes problem where pref_id and server_id would always have value of last element
                row.set_callback(pass_me(
                    lambda val, other: set_server_setting(other[0], other[1], val),
                    server_id, pref_id
                ))
                row.set_callback(
                    pass_me(
                        lambda val, other: set_server_setting(other[0], other[1], val),
                        server_id,
                        pref_id,
                    )
                )
                group.add(row.get_widget())
            self.box.add(group)



@@ 119,6 146,7 @@ class SettingsScreen(Adw.NavigationPage):
        self.wrapper.set_child(self.scrollview)
        self.toolbar_view.set_content(self.wrapper)
        self.set_child(self.toolbar_view)

    def toggle_server(self, value, other):
        server_id = other[0]
        if value:


@@ 126,5 154,6 @@ class SettingsScreen(Adw.NavigationPage):
        else:
            ensure_server_disabled(server_id)


def pass_me(func, *args):
    return lambda y: func(y, args)

M melon/utils.py => melon/utils.py +10 -4
@@ 1,11 1,14 @@
import gi
gi.require_version('Gdk', '4.0')
from gi.repository import Gio,GLib

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'))
    return os.path.exists(os.path.join(GLib.get_user_runtime_dir(), "flatpak-info"))


@cache
def get_data_dir():


@@ 13,18 16,21 @@ def get_data_dir():

    if not is_flatpak():
        base_path = data_dir_path
        data_dir_path = os.path.join(base_path, 'melon')
        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


def pass_me(func, *args):
    """
    Pass additional arguments to lambda functions
    """
    return lambda *a: func(*a, *args)


def many(*funcs):
    """
    Make multiple function steps easier to read

M melon/widgets/feeditem.py => melon/widgets/feeditem.py +74 -31
@@ 1,23 1,34 @@
import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib, Gdk
from melon.servers import Resource,Video,Playlist,Channel
from melon.servers import Resource, Video, Playlist, Channel
from melon.servers.utils import pixbuf_from_url
from melon.widgets.iconbutton import IconButton
from melon.models import PlaylistWrapperType,PlaylistWrapper
from melon.models import PlaylistWrapperType, PlaylistWrapper
import threading
from melon.background import queue
from unidecode import unidecode
from gettext import gettext as _
from melon.utils import pass_me, many


class AdaptiveFeedItem(Adw.ActionRow):
    """
    FeedItem with automatic adjustment to resource type
    """
    def __init__(self, resource:Resource, show_preview=True, clickable=True, onClick=None, *args, **kwargs):

    def __init__(
        self,
        resource: Resource,
        show_preview=True,
        clickable=True,
        onClick=None,
        *args,
        **kwargs
    ):
        super().__init__(*args, **kwargs)
        self.res = resource
        self.show_preview = show_preview


@@ 25,9 36,11 @@ class AdaptiveFeedItem(Adw.ActionRow):
        self.onClick = onClick
        self.menuitems = []
        self.update()

    def set_resource(self, resource: Resource):
        self.res = resource
        self.update()

    def update(self):
        if self.res is None:
            return


@@ 37,12 50,13 @@ class AdaptiveFeedItem(Adw.ActionRow):
        self.set_activatable(clickable)
        if self.onClick is None:
            self.set_action_target_value(
                GLib.Variant("as", [resource.server, resource.id]))
                GLib.Variant("as", [resource.server, resource.id])
            )
        thumb_url = None
        if isinstance(resource, Video):
            thumb_url = resource.thumbnail
            self.set_title(unidecode(resource.title).replace("&","&amp;"))
            self.set_subtitle(unidecode(resource.channel[0]).replace("&","&amp;"))
            self.set_title(unidecode(resource.title).replace("&", "&amp;"))
            self.set_subtitle(unidecode(resource.channel[0]).replace("&", "&amp;"))
            if self.onClick is None:
                self.set_action_name("win.player")
            # only videos support rightclickt/long press


@@ 51,33 65,35 @@ class AdaptiveFeedItem(Adw.ActionRow):
            # set the click gesture handler to only listen for right clicks
            click_ctr.set_button(3)
            self.add_controller(click_ctr)
            click_ctr.connect("pressed", lambda e,n,x,y: self._show_video_bottom_sheet())
            click_ctr.connect(
                "pressed", lambda e, n, x, y: self._show_video_bottom_sheet()
            )
            long_ctr = Gtk.GestureLongPress()
            self.add_controller(long_ctr)
            long_ctr.connect("pressed", lambda e,x,y: self._show_video_bottom_sheet())
            long_ctr.connect("pressed", lambda e, x, y: self._show_video_bottom_sheet())

        elif isinstance(resource, Playlist):
            thumb_url = resource.thumbnail
            self.set_title(unidecode(resource.title).replace("&","&amp;"))
            self.set_title(unidecode(resource.title).replace("&", "&amp;"))
            pad = ""
            if len(resource.description) > 80:
                pad = "..."
            # NOTE: this might be a bad idea
            # because it could possibly cut the unicode in half? I think?
            sub = unidecode(resource.description)[:80] + pad
            self.set_subtitle(sub.replace("&","&amp;"))
            self.set_subtitle(sub.replace("&", "&amp;"))
            if self.onClick is None:
                self.set_action_name("win.browse_playlist")
        elif isinstance(resource, Channel):
            thumb_url = resource.avatar
            self.set_title(unidecode(resource.name).replace("&","&amp;"))
            self.set_title(unidecode(resource.name).replace("&", "&amp;"))
            pad = ""
            if len(resource.bio) > 80:
                pad = "..."
            # NOTE: this might be a bad idea
            # because it could possibly cut the unicode in half? I think?
            sub = unidecode(resource.bio)[:80] + pad
            self.set_subtitle(sub.replace("&","&amp;"))
            self.set_subtitle(sub.replace("&", "&amp;"))
            if self.onClick is None:
                self.set_action_name("win.browse_channel")



@@ 97,6 113,7 @@ class AdaptiveFeedItem(Adw.ActionRow):
        if not pixbuf is None:
            texture = Gdk.Texture.new_for_pixbuf(pixbuf)
            GLib.idle_add(self.complete_avatar, texture)

    def complete_avatar(self, texture):
        self.preview.set_custom_image(texture)
        # return false to cancel the idle job


@@ 111,17 128,37 @@ class AdaptiveFeedItem(Adw.ActionRow):
        tbv = Adw.ToolbarView()
        hb = Adw.HeaderBar()
        tbv.add_top_bar(hb)
        diag.set_child(tbv);
        diag.set_child(tbv)
        page = Adw.PreferencesPage()
        group = Adw.PreferencesGroup()
        group.set_title(self.res.title)
        group.set_description(self.res.channel[0])
        # add predefined options to list
        predefined = [
            (_("Watch now"), lambda: self.activate_action("win.player", GLib.Variant("as", [self.res.server, self.res.id]))),
            (_("Add to playlist"), lambda: self.activate_action("win.add_to_playlist", GLib.Variant("as", [self.res.server, self.res.id]))),
            (_("Open in browser"), lambda: Gtk.UriLauncher.new(uri=self.res.url).launch()),
            (_("View channel"), lambda: self.activate_action("win.browse_channel", GLib.Variant("as", [self.res.server, self.res.channel[1]])))
            (
                _("Watch now"),
                lambda: self.activate_action(
                    "win.player", GLib.Variant("as", [self.res.server, self.res.id])
                ),
            ),
            (
                _("Add to playlist"),
                lambda: self.activate_action(
                    "win.add_to_playlist",
                    GLib.Variant("as", [self.res.server, self.res.id]),
                ),
            ),
            (
                _("Open in browser"),
                lambda: Gtk.UriLauncher.new(uri=self.res.url).launch(),
            ),
            (
                _("View channel"),
                lambda: self.activate_action(
                    "win.browse_channel",
                    GLib.Variant("as", [self.res.server, self.res.channel[1]]),
                ),
            ),
        ]
        # add predefined list before custom menu entries and add them
        for dt in predefined + self.menuitems:


@@ 129,29 166,34 @@ class AdaptiveFeedItem(Adw.ActionRow):
            row.set_title(dt[0])
            row.set_activatable(True)
            # run callback & close dialog on click
            row.connect("activated", pass_me(lambda _, cb: many(
                cb(),
                diag.close()
            ), dt[1]))
            row.connect(
                "activated", pass_me(lambda _, cb: many(cb(), diag.close()), dt[1])
            )
            group.add(row)
        page.add(group)
        tbv.set_content(page)


class AdaptivePlaylistFeedItem(Adw.ActionRow):
    def __init__(self, playlist:PlaylistWrapper, show_preview=True, onClick=None, *args, **kwargs):
    def __init__(
        self,
        playlist: PlaylistWrapper,
        show_preview=True,
        onClick=None,
        *args,
        **kwargs
    ):
        super().__init__(*args, **kwargs)
        self.set_title(unidecode(playlist.inner.title).replace("&","&amp;"))
        self.set_subtitle(unidecode(playlist.inner.description).replace("&","&amp;"))
        self.set_title(unidecode(playlist.inner.title).replace("&", "&amp;"))
        self.set_subtitle(unidecode(playlist.inner.description).replace("&", "&amp;"))
        if not onClick is None:
            # use custom click callback
            self.connect(
                "activated",
                onClick
            )
            self.connect("activated", onClick)
        elif playlist.type == PlaylistWrapperType.EXTERNAL:
            self.set_action_name("win.browse_playlist")
            self.set_action_target_value(
                GLib.Variant("as", [playlist.inner.server, playlist.inner.id]))
                GLib.Variant("as", [playlist.inner.server, playlist.inner.id])
            )
        else:
            self.set_action_name("win.view_local_playlist")
            self.set_action_target_value(GLib.Variant("u", playlist.inner.id))


@@ 169,6 211,7 @@ class AdaptivePlaylistFeedItem(Adw.ActionRow):
        if not pixbuf is None:
            texture = Gdk.Texture.new_for_pixbuf(pixbuf)
            GLib.idle_add(self.complete_avatar, texture)

    def complete_avatar(self, texture):
        self.preview.set_custom_image(texture)
        # return false to cancel the idle job

M melon/widgets/filterbutton.py => melon/widgets/filterbutton.py +3 -1
@@ 1,7 1,9 @@
import gi
gi.require_version('Gtk', '4.0')

gi.require_version("Gtk", "4.0")
from gi.repository import Gtk


class FilterButton(Gtk.CheckButton):
    def __init__(self, name, state, cb, current, *args, **kwargs):
        super().__init__(*args, **kwargs)

M melon/widgets/iconbutton.py => melon/widgets/iconbutton.py +8 -2
@@ 1,9 1,11 @@
import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw


class IconButton(Gtk.Button):
    def __init__(self, name, icon, tooltip=None, *args, **kwargs):
        super().__init__(*args, **kwargs)


@@ 12,12 14,16 @@ class IconButton(Gtk.Button):
        self.set_can_shrink(True)
        self.set_child(self.inner)
        self.update(name, icon, tooltip)

    def set_icon(self, icon):
        self.inner.set_icon_name(icon)

    def set_name(self, name):
        self.inner.set_label(name)

    def set_tooltip(self, tooltip):
        self.set_tooltip_text(tooltip)

    def update(self, name, icon, tooltip=None):
        self.set_name(name)
        self.set_icon(icon)

M melon/widgets/player.py => melon/widgets/player.py +119 -49
@@ 1,8 1,9 @@
import gi

gi.require_version("WebKit", "6.0")
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
gi.require_version('Gst', '1.0')
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
gi.require_version("Gst", "1.0")
from gi.repository import Gtk, Adw, WebKit, GLib, Gst, Gio
from unidecode import unidecode
from gettext import gettext as _


@@ 14,7 15,8 @@ from melon.widgets.iconbutton import IconButton
from melon.widgets.preferencerow import PreferenceRow, PreferenceType, Preference
from melon.utils import pass_me

def format_seconds(secs:float) -> str:

def format_seconds(secs: float) -> str:
    secs = int(secs)
    seconds = secs % 60
    mins_hours = secs // 60


@@ 27,9 29,11 @@ def format_seconds(secs:float) -> str:
    s = f"{seconds:02}"
    return f"{h}{m}:{s}"


def clamp(lower, value, upper):
    return max(lower, min(value, upper))


class OverlayDispay(Gtk.Box):
    def __init__(self, icon, text, *args, **kwargs):
        super().__init__(*args, **kwargs)


@@ 44,45 48,56 @@ class OverlayDispay(Gtk.Box):
        self.set_icon(icon)
        self.set_text(text)
        self.show()

    def show(self):
        self.set_opacity(1.0)

    def hide(self):
        self.set_opacity(0.0)
    def set_icon(self, icon:str):

    def set_icon(self, icon: str):
        self._icon.set_from_icon_name(icon)
    def set_text(self, text:str):

    def set_text(self, text: str):
        self._label.set_label(text)


class BrightnessDisplay(OverlayDispay):
    def __init__(self, value:float, *args, **kwargs):
    def __init__(self, value: float, *args, **kwargs):
        initial_icon = self.get_icon_for(value)
        initial_text = self.get_text_for(value)
        super().__init__(initial_icon, initial_text, *args, **kwargs)
    def update(self, value:float):

    def update(self, value: float):
        icon = self.get_icon_for(value)
        text = self.get_text_for(value)
        self.set_icon(icon)
        self.set_text(text)
    def get_icon_for(self, value:float) -> str:

    def get_icon_for(self, value: float) -> str:
        if value < 0.4:
            return "night-light-symbolic"
        if value < 0.8:
            return "weather-clear-symbolic"
        return "display-brightness-symbolic"
    def get_text_for(self, value:float) -> str:

    def get_text_for(self, value: float) -> str:
        return f"{int(value * 100)}%"


class VolumeDisplay(OverlayDispay):
    def __init__(self, value:float, *args, **kwargs):
    def __init__(self, value: float, *args, **kwargs):
        initial_icon = self.get_icon_for(value)
        initial_text = self.get_text_for(value)
        super().__init__(initial_icon, initial_text, *args, **kwargs)
    def update(self, value:float):

    def update(self, value: float):
        icon = self.get_icon_for(value)
        text = self.get_text_for(value)
        self.set_icon(icon)
        self.set_text(text)
    def get_icon_for(self, value:float) -> str:

    def get_icon_for(self, value: float) -> str:
        if value == 0.0:
            return "audio-volume-muted-symbolic"
        if value < 0.4:


@@ 90,9 105,11 @@ class VolumeDisplay(OverlayDispay):
        if value < 0.8:
            return "audio-volume-medium-symbolic"
        return "audio-volume-high-symbolic"
    def get_text_for(self, value:float) -> str:

    def get_text_for(self, value: float) -> str:
        return f"{int(value * 100)}%"


class VideoPlayerBase(Gtk.Overlay):
    volume = 1.0
    # inverse opacity of overlay


@@ 106,8 123,8 @@ class VideoPlayerBase(Gtk.Overlay):
        self.duration = None
        self.paused = True
        self.stopped = True
        self.update_callback=None
        self.ended_callback=None
        self.update_callback = None
        self.ended_callback = None
        self.toggle_fullscreen = None
        self.toggle_popout = None



@@ 177,11 194,15 @@ class VideoPlayerBase(Gtk.Overlay):
        self.win_controls.set_halign(Gtk.Align.END)
        self.win_controls.set_valign(Gtk.Align.START)

        self.popout_ctr = IconButton("", "view-paged-symbolic", tooltip=_("Toggle floating window"))
        self.popout_ctr = IconButton(
            "", "view-paged-symbolic", tooltip=_("Toggle floating window")
        )
        self.popout_ctr.connect("clicked", lambda _: self._toggle_popout())
        self.win_controls.append(self.popout_ctr)

        self.fullscreen_ctr = IconButton("", "view-fullscreen-symbolic", tooltip=_("Toggle fullscreen"))
        self.fullscreen_ctr = IconButton(
            "", "view-fullscreen-symbolic", tooltip=_("Toggle fullscreen")
        )
        self.fullscreen_ctr.connect("clicked", lambda _: self._toggle_fullscreen())
        self.win_controls.append(self.fullscreen_ctr)



@@ 201,7 222,9 @@ class VideoPlayerBase(Gtk.Overlay):
        self.controls.set_valign(Gtk.Align.END)

        # add play/pause indicator
        self.playpause_display = IconButton("", "media-playback-start-symbolic", tooltip=_("Play"))
        self.playpause_display = IconButton(
            "", "media-playback-start-symbolic", tooltip=_("Play")
        )
        self.playpause_display.connect("clicked", self._toggle_playpause)
        self.controls.append(self.playpause_display)



@@ 241,18 264,25 @@ class VideoPlayerBase(Gtk.Overlay):
        # volume control slider
        # (as an alternative to the slide gesture)
        # mapped to 0-100 scale
        self.volume_ctr = Gtk.ScaleButton.new(0, 100, 2, [
            # The first item in the array will be used in the button when the current value is the lowest value,
            "audio-volume-muted-symbolic",
            # the second item for the highest value
            "audio-volume-high-symbolic",
            # All the subsequent icons will be used for all the other values, spread evenly over the range of values.
            "audio-volume-low-symbolic",
            "audio-volume-medium-symbolic",
        ])
        self.volume_ctr = Gtk.ScaleButton.new(
            0,
            100,
            2,
            [
                # The first item in the array will be used in the button when the current value is the lowest value,
                "audio-volume-muted-symbolic",
                # the second item for the highest value
                "audio-volume-high-symbolic",
                # All the subsequent icons will be used for all the other values, spread evenly over the range of values.
                "audio-volume-low-symbolic",
                "audio-volume-medium-symbolic",
            ],
        )
        # self.volume uses the [0,1] interval, but the slider uses [0,100]
        self.volume_ctr.set_value(self.volume * 100)
        self.volume_ctr.connect("value-changed", lambda w, val: self.change_volume(val/100))
        self.volume_ctr.connect(
            "value-changed", lambda w, val: self.change_volume(val / 100)
        )
        self.volume_ctr.set_direction(Gtk.ArrowType.UP)
        self.volume_ctr.get_popup().set_position(Gtk.PositionType.TOP)
        self.controls.append(self.volume_ctr)


@@ 279,7 309,7 @@ class VideoPlayerBase(Gtk.Overlay):
        # click handler
        self.click_ctr = Gtk.GestureClick()
        self.add_controller(self.click_ctr)
        self.click_ctr.connect("pressed", lambda e,n, x,y :self._onclick(n, x,y))
        self.click_ctr.connect("pressed", lambda e, n, x, y: self._onclick(n, x, y))

        # initialize volume & brightness
        self.change_brightness(self.brightness)


@@ 290,17 320,19 @@ class VideoPlayerBase(Gtk.Overlay):

    def connect_update(self, callback=None):
        self.update_callback = callback

    def connect_ended(self, callback=None):
        self.ended_callback = callback

    def _toggle_fullscreen(self):
        if not self.toggle_fullscreen is None:
            state = self.toggle_fullscreen()

    def _toggle_popout(self):
        if not self.toggle_popout is None:
            state = self.toggle_popout()

    def set_toggle_states(self, popout:bool, fullscreen:bool):
    def set_toggle_states(self, popout: bool, fullscreen: bool):
        if popout:
            # window now popped out
            self.popout_ctr.set_icon("view-dual-symbolic")


@@ 314,12 346,14 @@ class VideoPlayerBase(Gtk.Overlay):

    def connect_toggle_fullscreen(self, callback=None):
        self.toggle_fullscreen = callback

    def connect_toggle_popout(self, callback=None):
        self.toggle_popout = callback

    def _show_controls(self):
        self.controls.set_opacity(1)
        self.win_controls.set_opacity(1)

    def _hide_controls(self, force=False):
        if self.hover_ctr.get_property("contains-pointer") and not force:
            # mouse is still hovering on player


@@ 327,15 361,16 @@ class VideoPlayerBase(Gtk.Overlay):
            return True
        self.controls.set_opacity(0)
        self.win_controls.set_opacity(0)

    def _schedule_hide_controls(self, timeout=2):
        GLib.timeout_add(timeout*1000, self._hide_controls)
        GLib.timeout_add(timeout * 1000, self._hide_controls)

    def _update_swipe(self, e, s):
        bounds = self.swipe_ctr.get_bounding_box_center()
        if not bounds[0]:
            return
        width = self.picture.get_width()
        left_bound = width*(2/5)
        left_bound = width * (2 / 5)
        right_bound = width - left_bound

        vel = self.swipe_ctr.get_velocity()


@@ 343,7 378,7 @@ class VideoPlayerBase(Gtk.Overlay):
            return

        # randomly selected values to make it feel responsive
        fact = vel[2]/10000
        fact = vel[2] / 10000
        fact = clamp(-0.2, fact, 0.2)
        if abs(fact) < 0.01:
            # little to no movement


@@ 390,12 425,14 @@ class VideoPlayerBase(Gtk.Overlay):

    def _mouse_enter(self, w, x, y):
        self._show_controls()

    def _mouse_leave(self, w):
        self._schedule_hide_controls()

    _click_opened_controls = False

    def _onclick(self, n, x, y):
        if n==0:
        if n == 0:
            # should never happen
            return
        if n == 1:


@@ 414,9 451,9 @@ class VideoPlayerBase(Gtk.Overlay):

        # douple tap triggers 10 second seek
        # each additional tap seeks another 10 seconds
        distance = (n-1) * 10
        distance = (n - 1) * 10
        width = self.picture.get_width()
        left_bound = width*(2/5)
        left_bound = width * (2 / 5)
        right_bound = width - left_bound
        if x <= left_bound:
            self.seek_backwards(distance)


@@ 425,8 462,10 @@ class VideoPlayerBase(Gtk.Overlay):

    def seek(self, delta):
        self.goto(self.position + delta)

    def seek_forwards(self, delta=30):
        self.seek(delta)

    def seek_backwards(self, delta=30):
        self.seek(-delta)



@@ 437,7 476,9 @@ class VideoPlayerBase(Gtk.Overlay):
        # maybe using playbin3?
        for stream in self.streams:
            item = Gio.MenuItem.new(stream.quality, None)
            item.set_action_and_target_value("quality", GLib.Variant("s", stream.quality))
            item.set_action_and_target_value(
                "quality", GLib.Variant("s", stream.quality)
            )
            quality_model.append_item(item)
        model.append_submenu(_("Resolution"), quality_model)
        self.options_menu.set_menu_model(model)


@@ 451,7 492,7 @@ class VideoPlayerBase(Gtk.Overlay):
        self.play()
        self._build_menu()

    def _find_stream(self, widg, action:str, variant):
    def _find_stream(self, widg, action: str, variant):
        if action != "quality":
            return
        # get resolution id


@@ 468,6 509,7 @@ class VideoPlayerBase(Gtk.Overlay):
        self.stopped = False
        # run once every second
        GLib.timeout_add(1000, self._loop)

    def _loop(self):
        if self.stopped:
            return False


@@ 479,12 521,12 @@ class VideoPlayerBase(Gtk.Overlay):
            self.ended_callback()
        if dur[0]:
            # convert nanoseconds to senconds
            dur = dur[1]/(10**9)
            dur = dur[1] / (10**9)
        else:
            dur = None
        if pos[0]:
            # convert nanoseconds to senconds
            pos = pos[1]/(10**9)
            pos = pos[1] / (10**9)
            # override position with target
            # so the preview shows the correct timestamp
            # has the amazing side effect of also sending


@@ 503,9 545,8 @@ class VideoPlayerBase(Gtk.Overlay):
                position = self.target_position
                self.target_position = None
                self.source.seek_simple(
                    Gst.Format.TIME,
                    Gst.SeekFlags.FLUSH,
                    position*Gst.SECOND)
                    Gst.Format.TIME, Gst.SeekFlags.FLUSH, position * Gst.SECOND
                )
        elif not pos is None:
            self.set_position(pos)
        elif not dur is None:


@@ 517,14 558,17 @@ class VideoPlayerBase(Gtk.Overlay):
        self.position = pos
        self.duration = dur
        self._update_playhead()

    def set_position(self, pos):
        self.position = pos
        self._update_playhead()

    def set_duration(self, dur):
        self.duration = dur
        self._update_playhead()

    target_position = None

    def goto(self, position):
        # the seeking will happen in the _loop function
        self.target_position = position


@@ 556,19 600,27 @@ class VideoPlayerBase(Gtk.Overlay):
        self.source.set_state(Gst.State.PLAYING)
        # show pause button
        # because if playing the onclick action is to pause
        self.playpause_display.update("", "media-playback-pause-symbolic", tooltip=_("Pause"))
        self.playpause_display.update(
            "", "media-playback-pause-symbolic", tooltip=_("Pause")
        )

    def pause(self):
        self.paused = True
        self.source.set_state(Gst.State.PAUSED)
        # show play button
        # because if paused the onclick action is to start playing
        self.playpause_display.update("", "media-playback-start-symbolic", tooltip=_("Play"))
        self.playpause_display.update(
            "", "media-playback-start-symbolic", tooltip=_("Play")
        )

    def stop(self):
        self.paused = True
        self.stopped = True
        self.source.set_state(Gst.State.NULL)
        self.source.set_property("instant-uri", None)
        self.playpause_display.update("", "media-playback-start-symbolic", tooltip=_("Play"))
        self.playpause_display.update(
            "", "media-playback-start-symbolic", tooltip=_("Play")
        )

    def _toggle_playpause(self, _):
        if self.stopped:


@@ 578,6 630,7 @@ class VideoPlayerBase(Gtk.Overlay):
        else:
            self.pause()


class VideoPlayer(Gtk.Stack):
    def __init__(self, streams: list[Stream], *args, **kwargs):
        super().__init__(*args, **kwargs)


@@ 589,6 642,7 @@ class VideoPlayer(Gtk.Stack):
        placeholder.set_label(_("The video is playing in separate window"))
        self.add_named(placeholder, "placeholder")
        self._show_player()

    def _show_player(self):
        has_player = self.get_child_by_name("player")
        if not has_player is None:


@@ 598,34 652,47 @@ class VideoPlayer(Gtk.Stack):
            self.player.unparent()
        self.add_named(self.player, "player")
        self.set_visible_child_name("player")

    def _popout_player(self):
        has_player = self.get_child_by_name("player")
        if not has_player is None:
            self.remove(has_player)
        self.set_visible_child_name("placeholder")

    # bridge methods
    def connect_update(self, callback=None):
        self.player.connect_update(callback)

    def connect_ended(self, callback=None):
        self.player.connect_ended(callback)

    def change_volume(self, vol):
        self.player.change_volume(vol)

    def change_brightness(self, bright):
        self.player.change_brightness(bright)

    def seek(self, delta):
        self.player.seek(delta)

    def seek_forwards(self, delta=30):
        self.player.seek_forwards(delta)

    def seek_backwards(self, delta=30):
        self.player.seek_backwards(delta)

    def goto(self, position):
        self.player.goto(position)

    def play(self):
        self.player.play()

    def pause(self):
        self.player.pause()

    def stop(self):
        self.player.stop()

    def select_stream(self, stream):
        self.player.select_stream(stream)



@@ 638,6 705,7 @@ class VideoPlayer(Gtk.Stack):
        self.window.conenct_full(lambda s: self.player.set_toggle_states(True, s))

    _only_fullscreen = False

    def toggle_fullscreen(self) -> bool:
        if self.window is None:
            # currently fixed


@@ 675,6 743,7 @@ class VideoPlayer(Gtk.Stack):
                res = self.window.is_fullscreen()
                self.player.set_toggle_states(True, res)
                return res

    def toggle_popout(self) -> bool:
        if self.window is None:
            # currently fixed


@@ 694,7 763,7 @@ class VideoPlayer(Gtk.Stack):


class VideoPlayerWindow(Adw.Window):
    def __init__(self, player:VideoPlayerBase, *args, **kwargs):
    def __init__(self, player: VideoPlayerBase, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.player = player
        self.onclose = None


@@ 705,7 774,7 @@ class VideoPlayerWindow(Adw.Window):
        # this should block the application
        self.set_modal(True)
        self.connect("close-request", lambda _: self.close())
        self.connect("notify", lambda a,b: self._notify())
        self.connect("notify", lambda a, b: self._notify())
        self.set_visible(True)
        self.set_title(_("Player"))



@@ 723,5 792,6 @@ class VideoPlayerWindow(Adw.Window):

    def connect_close(self, callback=None):
        self.onclose = callback

    def conenct_full(self, callback=None):
        self.onfull = callback

M melon/widgets/preferencerow.py => melon/widgets/preferencerow.py +57 -49
@@ 1,19 1,21 @@
import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib, Gdk

from gettext import gettext as _
from copy import deepcopy as copy

from melon.servers import Resource,Video,Playlist,Channel,Preference,PreferenceType
from melon.servers import Resource, Video, Playlist, Channel, Preference, PreferenceType
from melon.servers.utils import pixbuf_from_url
from melon.widgets.iconbutton import IconButton
from melon.widgets.simpledialog import SimpleDialog
from melon.utils import pass_me, many

class PreferenceRow():

class PreferenceRow:
    def __init__(self, pref: Preference, *args, **kwargs):
        self.pref = pref
        # keep track of own value


@@ 27,7 29,8 @@ class PreferenceRow():
            self.widget.set_active(pref.value)
            self.widget.connect(
                "notify::active",
                lambda a,p: self.pass_to_callback(self.widget.get_active()))
                lambda a, p: self.pass_to_callback(self.widget.get_active()),
            )
        elif pref.type is PreferenceType.DROPDOWN:
            # implemented as Combo Row
            self.widget = Adw.ComboRow()


@@ 41,7 44,7 @@ class PreferenceRow():
                # there is not better signal available
                # NOTE: this signal is emitted multiple times
                "notify",
                lambda a,p: self.pass_to_callback(opts[self.widget.get_selected()])
                lambda a, p: self.pass_to_callback(opts[self.widget.get_selected()]),
            )
        elif pref.type is PreferenceType.TEXT or pref.type is PreferenceType.PASSWORD:
            if pref.type is PreferenceType.TEXT:


@@ 52,8 55,7 @@ class PreferenceRow():
            self.widget.set_tooltip_text(pref.description)
            self.widget.set_text(pref.value)
            self.widget.connect(
                "changed",
                lambda a: self.pass_to_callback(self.widget.get_text())
                "changed", lambda a: self.pass_to_callback(self.widget.get_text())
            )
        elif pref.type is PreferenceType.NUMBER:
            # implemented as Spin Row


@@ 64,8 66,7 @@ class PreferenceRow():
            self.current_value = float(pref.value)
            self.widget.set_value(self.current_value)
            self.widget.connect(
                "changed",
                lambda a: self.pass_to_callback(self.widget.get_value())
                "changed", lambda a: self.pass_to_callback(self.widget.get_value())
            )
        elif pref.type is PreferenceType.MULTI:
            self.widget = MultiRow(pref)


@@ 75,6 76,7 @@ class PreferenceRow():
            self.widget = Adw.ActionRow()
            self.widget.set_title(pref.name)
            self.widget.set_subtitle(pref.description)

    def pass_to_callback(self, value):
        if value == self.current_value:
            # we don't have to update anything if the value didn't change


@@ 85,24 87,31 @@ class PreferenceRow():
        self.current_value = copy(value)
        if not self.callback is None:
            self.callback(value)

    def get_widget(self):
        return self.widget
    def set_callback(self,cb):

    def set_callback(self, cb):
        self.callback = cb

    def on_active(self):
        if not self.callback is None:
            self.callback(self.get_active())


class MultiRow(Adw.PreferencesRow):
    callback = None
    values = []

    def __init__(self, pref, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.pref = pref
        self.values = copy(pref.value)
        self.update()

    def connect(self, cb):
        self.callback = cb

    def update(self):
        self.inner = Adw.PreferencesGroup()
        padding = 12


@@ 126,24 135,18 @@ class MultiRow(Adw.PreferencesRow):
            # move up button
            # not shown for first element
            if counter > 0:
                move_up = IconButton("","go-up-symbolic")
                move_up = IconButton("", "go-up-symbolic")
                move_up.set_tooltip_text(_("Move up"))
                move_up.add_css_class("circular")
                move_up.connect(
                    "clicked",
                    pass_me(self.move_up, item, counter)
                )
                move_up.connect("clicked", pass_me(self.move_up, item, counter))
                actions.append(move_up)
            # move down button
            # not shown for last element
            if counter < length-1:
            if counter < length - 1:
                move_down = IconButton("", "go-down-symbolic")
                move_down.set_tooltip_text(_("Move down"))
                move_down.add_css_class("circular")
                move_down.connect(
                    "clicked",
                    pass_me(self.move_down, item, counter)
                )
                move_down.connect("clicked", pass_me(self.move_down, item, counter))
                actions.append(move_down)
            # remove button
            remove = IconButton("", "list-remove-symbolic")


@@ 154,15 157,17 @@ class MultiRow(Adw.PreferencesRow):
            row.add_suffix(actions)
            self.inner.add(row)
            counter += 1

    def notify(self):
        if not self.callback is None:
            self.callback(copy(self.values))
        self.update()

    def open_add(self):
        diag = SimpleDialog()
        diag.set_title(_("Add Item"))
        # preview prferences group
        box = Adw.PreferencesGroup();
        box = Adw.PreferencesGroup()
        box.set_title(_("Create a new list entry"))
        box.set_description(_("Enter the new value here"))
        # text input


@@ 172,29 177,30 @@ class MultiRow(Adw.PreferencesRow):
        diag.set_widget(box)
        # place button bar in toolbar
        bottom_bar = Gtk.Box()
        btn_cancel = IconButton(_("Cancel"), "process-stop-symbolic", tooltip="Do not create a new item")
        btn_confirm = IconButton(_("Create"), "list-add-symbolic", tooltip="Add entry to list")
        btn_cancel = IconButton(
            _("Cancel"), "process-stop-symbolic", tooltip="Do not create a new item"
        )
        btn_confirm = IconButton(
            _("Create"), "list-add-symbolic", tooltip="Add entry to list"
        )
        btn_confirm.connect(
            "clicked",
            lambda _: many(
                self.values.append(inp.get_text()),
                self.notify(),
                diag.hide())
                self.values.append(inp.get_text()), self.notify(), diag.hide()
            ),
        )
        btn_cancel.connect(
            "clicked",
            lambda x: diag.hide())
        btn_cancel.connect("clicked", lambda x: diag.hide())
        padding = 12
        btn_confirm.set_vexpand(True)
        btn_confirm.set_hexpand(True)
        btn_confirm.set_margin_end(padding)
        btn_confirm.set_margin_start(padding/2)
        btn_confirm.set_margin_start(padding / 2)
        btn_confirm.set_margin_top(padding)
        btn_confirm.set_margin_bottom(padding)
        btn_cancel.set_vexpand(True)
        btn_cancel.set_hexpand(True)
        btn_cancel.set_margin_start(padding)
        btn_cancel.set_margin_end(padding/2)
        btn_cancel.set_margin_end(padding / 2)
        btn_cancel.set_margin_top(padding)
        btn_cancel.set_margin_bottom(padding)
        bottom_bar.append(btn_cancel)


@@ 202,11 208,12 @@ class MultiRow(Adw.PreferencesRow):
        diag.toolbar_view.add_bottom_bar(bottom_bar)

        diag.show()
    def confirm_delete(self, _, item:str, counter:int):

    def confirm_delete(self, _, item: str, counter: int):
        diag = SimpleDialog()
        diag.set_title(_("Delete"))
        # preview prferences group
        preview = Adw.PreferencesGroup();
        preview = Adw.PreferencesGroup()
        preview.set_title(_("Do you really want to delete this item?"))
        preview.set_description(_("You won't be able to restore it afterwards"))
        row = Adw.ActionRow()


@@ 215,29 222,28 @@ class MultiRow(Adw.PreferencesRow):
        diag.set_widget(preview)
        # place button bar in toolbar
        bottom_bar = Gtk.Box()
        btn_cancel = IconButton(_("Cancel"), "process-stop-symbolic", tooltip=_("Do not remove item"))
        btn_confirm = IconButton(_("Delete"), "list-add-symbolic", tooltip=_("Remove item from list"))
        btn_cancel = IconButton(
            _("Cancel"), "process-stop-symbolic", tooltip=_("Do not remove item")
        )
        btn_confirm = IconButton(
            _("Delete"), "list-add-symbolic", tooltip=_("Remove item from list")
        )
        btn_confirm.connect(
            "clicked",
            lambda _: many(
                self.values.pop(counter),
                self.notify(),
                diag.hide())
            lambda _: many(self.values.pop(counter), self.notify(), diag.hide()),
        )
        btn_cancel.connect(
            "clicked",
            lambda x: diag.hide())
        btn_cancel.connect("clicked", lambda x: diag.hide())
        padding = 12
        btn_confirm.set_vexpand(True)
        btn_confirm.set_hexpand(True)
        btn_confirm.set_margin_end(padding)
        btn_confirm.set_margin_start(padding/2)
        btn_confirm.set_margin_start(padding / 2)
        btn_confirm.set_margin_top(padding)
        btn_confirm.set_margin_bottom(padding)
        btn_cancel.set_vexpand(True)
        btn_cancel.set_hexpand(True)
        btn_cancel.set_margin_start(padding)
        btn_cancel.set_margin_end(padding/2)
        btn_cancel.set_margin_end(padding / 2)
        btn_cancel.set_margin_top(padding)
        btn_cancel.set_margin_bottom(padding)
        bottom_bar.append(btn_cancel)


@@ 245,11 251,13 @@ class MultiRow(Adw.PreferencesRow):
        diag.toolbar_view.add_bottom_bar(bottom_bar)
        diag.show()
        pass
    def move_up(self, _, item:str, counter:int):

    def move_up(self, _, item: str, counter: int):
        val = self.values.pop(counter)
        self.values.insert(counter-1, val)
        self.values.insert(counter - 1, val)
        self.notify()
    def move_down(self, _, item:str, counter:int):

    def move_down(self, _, item: str, counter: int):
        val = self.values.pop(counter)
        self.values.insert(counter+1, val)
        self.values.insert(counter + 1, val)
        self.notify()

M melon/widgets/simpledialog.py => melon/widgets/simpledialog.py +10 -4
@@ 1,10 1,12 @@
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, Gio, GLib


class SimpleDialog(Adw.Window):
    def __init__(self,*args, **kwargs):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.toolbar_view = Adw.ToolbarView()
        self.header_bar = Adw.HeaderBar()


@@ 15,13 17,17 @@ class SimpleDialog(Adw.Window):
        # this is a dialog and thus the main window should not be usable
        # when the dialog is opened
        self.set_modal(True)

    def set_title(self, text):
        self.title.set_title(text)
    def set_widget(self, child,padding=12):

    def set_widget(self, child, padding=12):
        self.toolbar_view.set_content(child)
        child.set_margin_end(padding)
        child.set_margin_start(padding)

    def show(self):
        self.set_visible(True)

    def hide(self):
        self.set_visible(False)

M melon/widgets/viewstackpage.py => melon/widgets/viewstackpage.py +4 -7
@@ 1,16 1,13 @@
class ViewStackPage():
class ViewStackPage:
    name = ""
    title = ""
    icon_name = ""
    widget = None

    def __init__(self, name, title, icon):
        self.name = name
        self.title = title
        self.icon_name = icon

    def bind_to(self, parent):
        parent.add_titled_with_icon(
                self.widget,
                self.name,
                self.title,
                self.icon_name
        )
        parent.add_titled_with_icon(self.widget, self.name, self.title, self.icon_name)

M melon/window.py => melon/window.py +26 -11
@@ 1,7 1,8 @@
import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, Gio, GLib
from gettext import gettext as _



@@ 21,25 22,31 @@ from melon.playlist import LocalPlaylistScreen
from melon.playlist.pick import PlaylistPickerDialog
from melon.playlist.create import PlaylistCreatorDialog


class MainWindow(Adw.ApplicationWindow):
    # Opens the server list page
    def open_browse(self,action,prefs):
    def open_browse(self, action, prefs):
        self.view.push(BrowseScreen())

    def open_global_search(self, action, prefs):
        self.view.push(GlobalSearchScreen())

    # Opens a specific server browse window
    def open_server_browse(self, action, prefs):
        # manually convert GLib.Variant to value
        id = prefs[:]
        self.view.push(BrowseServerScreen(id, window=self))

    def open_channel_browse(self, action, prefs):
        plugin = prefs[0]
        id = prefs[1]
        self.view.push(BrowseChannelScreen(plugin, id))

    def open_playlist_browse(self, action, prefs):
        plugin = prefs[0]
        id = prefs[1]
        self.view.push(BrowsePlaylistScreen(plugin, id))

    def open_player(self, action, prefs):
        plugin = prefs[0]
        id = prefs[1]


@@ 52,6 59,7 @@ class MainWindow(Adw.ApplicationWindow):
        video = (server_id, video_id)
        diag = PlaylistPickerDialog(video)
        diag.show()

    def open_playlist_creator(self, action, prefs):
        act = action.get_name()
        # video to be automatically added (if available)


@@ 64,18 72,20 @@ class MainWindow(Adw.ApplicationWindow):
        # create basic dialog
        diag = PlaylistCreatorDialog(video)
        diag.show()

    def open_local_playlist_viewer(self, action, prefs):
        self.view.push(LocalPlaylistScreen(prefs.unpack()))

    def open_playlistplayer_local(self, action, prefs):
        self.view.push(PlaylistPlayerScreen(prefs.unpack()))

    def open_playlistplayer_external(self, action, prefs):
        server_id = prefs[0]
        playlist_id = prefs[1]
        self.view.push(PlaylistPlayerScreen((server_id, playlist_id)))

    # Opens the about app screen
    def open_about(self,action,prefs):
    def open_about(self, action, prefs):
        dialog = Adw.AboutWindow()
        dialog.set_application_name("Melon")
        dialog.set_version("0.2.0")


@@ 84,26 94,29 @@ class MainWindow(Adw.ApplicationWindow):
        dialog.set_comments(_("Stream videos on the go"))
        dialog.set_website("https://codeberg.org/comcloudway/melon")
        dialog.set_issue_url("https://codeberg.org/comcloudway/melon/issues")
        #dialog.add_credit_section("Contributors", ["Name1 url"])
        #dialog.set_translator_credits("Name1 url")
        # dialog.add_credit_section("Contributors", ["Name1 url"])
        # dialog.set_translator_credits("Name1 url")
        dialog.set_copyright("© 2024 Jakob Meier (@comcloudway)")
        dialog.set_developers(["Jakob Meier (@comcloudway)"])
        # TODO: icon must be uploaded in ~/.local/share/icons or /usr/share/icons
        dialog.set_application_icon("icu.ccw.Melon")
        dialog.set_visible(True)

    # opens the setting panel
    def open_settings(self,action,prefs):
    def open_settings(self, action, prefs):
        self.view.push(SettingsScreen())

    # opens the data import panel
    def open_import(self,action,prefs):
    def open_import(self, action, prefs):
        self.view.push(ImporterScreen())

    # navigate back to the home screen
    def go_home(self,action,prefs):
    def go_home(self, action, prefs):
        self.view.pop_to_page(self.home)
        self.home.go_home()

    def __init__(self, application, *args, **kwargs):
        super().__init__(application=application,*args, **kwargs)
        super().__init__(application=application, *args, **kwargs)
        self.view = Adw.NavigationView()
        self.home = HomeScreen()
        self.view.add(self.home)


@@ 138,7 151,9 @@ class MainWindow(Adw.ApplicationWindow):
        self.reg_action("add_to_new_playlist", self.open_playlist_creator, "as")
        # playlist player
        self.reg_action("playlistplayer-local", self.open_playlistplayer_local, "u")
        self.reg_action("playlistplayer-external", self.open_playlistplayer_external, "as")
        self.reg_action(
            "playlistplayer-external", self.open_playlistplayer_external, "as"
        )

    def reg_action(self, name, func, variant=None, target=None):
        vtype = None

M po/de.po => po/de.po +193 -193
@@ 7,7 7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: Melon 0.1.2\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-06-12 21:13+0200\n"
"POT-Creation-Date: 2024-07-15 07:54+0200\n"
"PO-Revision-Date: 2024-04-20 14:18+0000\n"
"Last-Translator: hurzelchen <hurzelchen@users.noreply.translate.codeberg."
"org>\n"


@@ 86,486 86,486 @@ msgid ""
"button"
msgstr ""

#: ../melon/browse/__init__.py:18 ../melon/browse/search.py:58
#: ../melon/browse/server.py:101 ../melon/browse/server.py:127
#: ../melon/home/history.py:23 ../melon/home/new.py:29
#: ../melon/home/playlists.py:21 ../melon/home/subs.py:20
#: ../melon/importer.py:61 ../melon/player/__init__.py:102
#: ../melon/player/playlist.py:158 ../melon/player/playlist.py:165
#: ../melon/playlist/__init__.py:61
#: ../melon/browse/__init__.py:20 ../melon/browse/search.py:66
#: ../melon/browse/server.py:115 ../melon/browse/server.py:143
#: ../melon/home/history.py:25 ../melon/home/new.py:36
#: ../melon/home/playlists.py:23 ../melon/home/subs.py:22
#: ../melon/importer.py:63 ../melon/player/__init__.py:112
#: ../melon/player/playlist.py:164 ../melon/player/playlist.py:171
#: ../melon/playlist/__init__.py:77
msgid "*crickets chirping*"
msgstr "*Grillen zirpen*"

#: ../melon/browse/__init__.py:19
#: ../melon/browse/__init__.py:21
msgid "There are no available servers"
msgstr "Es sind keine Dienste vorhanden"

#: ../melon/browse/__init__.py:21 ../melon/browse/search.py:61
#: ../melon/importer.py:64
#: ../melon/browse/__init__.py:24 ../melon/browse/search.py:72
#: ../melon/importer.py:67
msgid "Enable servers in the settings menu"
msgstr "Aktiviere Dienste im Einstellungsmenü"

#: ../melon/browse/__init__.py:29
#: ../melon/browse/__init__.py:33
msgid "Available Servers"
msgstr "Verfügbare Dienste"

#: ../melon/browse/__init__.py:30
#: ../melon/browse/__init__.py:35
msgid "You can enable/disable and filter servers in the settings menu"
msgstr ""
"Du kannst Dienste in den Einstellungen aktivieren, deaktivieren und filtern"

#: ../melon/browse/__init__.py:48
#: ../melon/browse/__init__.py:56
msgid "Servers"
msgstr "Dienste"

#: ../melon/browse/__init__.py:56 ../melon/browse/search.py:106
#: ../melon/browse/__init__.py:64 ../melon/browse/search.py:126
msgid "Global Search"
msgstr "Globale Suche"

#: ../melon/browse/channel.py:93
#: ../melon/browse/channel.py:104
msgid "Subscribe to channel"
msgstr "Kanal abonnieren"

#: ../melon/browse/channel.py:94
#: ../melon/browse/channel.py:105
msgid "Add latest uploads to home feed"
msgstr "Zeige neue Videos im Home-Feed an"

#: ../melon/browse/channel.py:106
#: ../melon/browse/channel.py:117
msgid "Channel feed"
msgstr ""

#: ../melon/browse/channel.py:107
#: ../melon/browse/channel.py:118
msgid "This channel provides multiple feeds, choose which one to view"
msgstr ""

#: ../melon/browse/channel.py:159
#: ../melon/browse/channel.py:170
msgid "Channel"
msgstr "Kanal"

#: ../melon/browse/playlist.py:37 ../melon/playlist/__init__.py:53
#: ../melon/browse/playlist.py:48 ../melon/playlist/__init__.py:69
msgid "Start playing"
msgstr "Wiedergeben"

#: ../melon/browse/playlist.py:56 ../melon/player/__init__.py:44
#: ../melon/browse/playlist.py:69 ../melon/player/__init__.py:53
msgid "Bookmark"
msgstr "Merken"

#: ../melon/browse/playlist.py:57
#: ../melon/browse/playlist.py:70
msgid "Add Playlist to your local playlist collection"
msgstr "Wiedergabeliste zur lokalen Sammlung hinzufügen"

#: ../melon/browse/playlist.py:111
#: ../melon/browse/playlist.py:124
msgid "Playlist"
msgstr "Wiedergabeliste"

#: ../melon/browse/search.py:44
#: ../melon/browse/search.py:49
msgid "No results"
msgstr "Keine Ergebnisse"

#: ../melon/browse/search.py:46
#: ../melon/browse/search.py:52
#, python-brace-format
msgid "{count} result"
msgid_plural "{count} results"
msgstr[0] "{count} Ergebnis"
msgstr[1] "{count} Ergebnisse"

#: ../melon/browse/search.py:59
#: ../melon/browse/search.py:68
msgid "There are no available servers, a search would yield no results"
msgstr ""
"Es gibt keine verfügbaren Dienste, eine Suche würde keine Ergebnisse liefern"

#: ../melon/browse/search.py:83 ../melon/browse/server.py:43
#: ../melon/browse/search.py:96 ../melon/browse/server.py:47
msgid "Any"
msgstr "Alle"

#: ../melon/browse/search.py:85 ../melon/browse/server.py:45
#: ../melon/browse/search.py:100 ../melon/browse/server.py:51
msgid "Channels"
msgstr "Kanäle"

#: ../melon/browse/search.py:88 ../melon/browse/server.py:48
#: ../melon/home/playlists.py:32 ../melon/home/playlists.py:64
#: ../melon/servers/nebula/__init__.py:194
#: ../melon/servers/peertube/__init__.py:205
#: ../melon/browse/search.py:105 ../melon/browse/server.py:56
#: ../melon/home/playlists.py:34 ../melon/home/playlists.py:78
#: ../melon/servers/nebula/__init__.py:206
#: ../melon/servers/peertube/__init__.py:213
msgid "Playlists"
msgstr "Wiedergabelisten"

#: ../melon/browse/search.py:91 ../melon/browse/server.py:51
#: ../melon/servers/nebula/__init__.py:188
#: ../melon/servers/peertube/__init__.py:204
#: ../melon/servers/peertube/__init__.py:208
#: ../melon/browse/search.py:110 ../melon/browse/server.py:61
#: ../melon/servers/nebula/__init__.py:200
#: ../melon/servers/peertube/__init__.py:213
#: ../melon/servers/peertube/__init__.py:214
msgid "Videos"
msgstr "Videos"

#: ../melon/browse/server.py:23
#: ../melon/browse/server.py:26
msgid "Search"
msgstr "Suchen"

#: ../melon/browse/server.py:102
#: ../melon/browse/server.py:116
msgid "Try searching for a term"
msgstr "Suche nach einem Suchbegriff"

#: ../melon/browse/server.py:104
#: ../melon/browse/server.py:118
msgid "Try using a different query"
msgstr "Versuche nach einem anderen Begriff zu suchen"

#: ../melon/browse/server.py:128
#: ../melon/browse/server.py:144
msgid "This feed is empty"
msgstr ""

#: ../melon/home/__init__.py:18
#: ../melon/home/__init__.py:20
msgid "Home"
msgstr "Startseite"

#: ../melon/home/__init__.py:39 ../melon/home/history.py:26
#: ../melon/home/new.py:36 ../melon/home/subs.py:23
#: ../melon/home/__init__.py:41 ../melon/home/history.py:28
#: ../melon/home/new.py:49 ../melon/home/subs.py:25
msgid "Browse Servers"
msgstr "Dienste durchsuchen"

#: ../melon/home/__init__.py:46
#: ../melon/home/__init__.py:48
msgid "Preferences"
msgstr "Einstellungen"

#: ../melon/home/__init__.py:47
#: ../melon/home/__init__.py:49
msgid "Import Data"
msgstr "Daten Importieren"

#: ../melon/home/__init__.py:48
#: ../melon/home/__init__.py:50
msgid "About Melon"
msgstr "Über Melon"

#: ../melon/home/history.py:24
#: ../melon/home/history.py:26
msgid "You haven't watched any videos yet"
msgstr "Du hast noch keine Videos geschaut"

#: ../melon/home/history.py:36 ../melon/home/history.py:117
#: ../melon/home/history.py:38 ../melon/home/history.py:125
msgid "History"
msgstr "Verlauf"

#: ../melon/home/history.py:37
#: ../melon/home/history.py:39
msgid "These are the videos you opened in the past"
msgstr "Diese Videos hast du in der Vergangenheit geöffnet"

#: ../melon/home/history.py:76
#: ../melon/home/history.py:83
msgid "Show more"
msgstr "Mehr anzeigen"

#: ../melon/home/history.py:77
#: ../melon/home/history.py:84
msgid "Load older videos"
msgstr ""

#: ../melon/home/new.py:23
#: ../melon/home/new.py:30
msgid "Refresh"
msgstr "Aktualisieren"

#: ../melon/home/new.py:32
#: ../melon/home/new.py:40
msgid "Subscribe to a channel first, to view new uploads"
msgstr "Du musst einem Kanal folgen, um neue Videos zu sehen."

#: ../melon/home/new.py:34
#: ../melon/home/new.py:45
msgid "The channels you are subscribed to haven't uploaded anything yet"
msgstr "Die Kanäle, die du abonniert hast, haben noch nichts hochgeladen"

#: ../melon/home/new.py:55
#: ../melon/home/new.py:68
#, python-brace-format
msgid "(Last refresh: {last_refresh})"
msgstr "(Zuletzt aktualisiert: {last_refresh})"

#: ../melon/home/new.py:57 ../melon/home/new.py:146
#: ../melon/home/new.py:72 ../melon/home/new.py:172
msgid "What's new"
msgstr "Neues"

#: ../melon/home/new.py:58
#: ../melon/home/new.py:74
msgid "These are the latest videos of channels you follow"
msgstr "Hier sind die neusten Videos von Kanälen, denen du folgst"

#: ../melon/home/new.py:137
#: ../melon/home/new.py:162
msgid "Pick up where you left off"
msgstr ""

#: ../melon/home/new.py:138
#: ../melon/home/new.py:163
msgid "Watch"
msgstr ""

#: ../melon/home/playlists.py:22
#: ../melon/home/playlists.py:24
msgid "You don't have any playlists yet"
msgstr "Du hast noch keine Wiedergabelisten"

#: ../melon/home/playlists.py:24 ../melon/home/playlists.py:34
#: ../melon/home/playlists.py:26 ../melon/home/playlists.py:39
msgid "Create a new playlist"
msgstr "Neue Wiedergabeliste erstellen"

#: ../melon/home/playlists.py:33
#: ../melon/home/playlists.py:36
msgid "Here are playlists you've bookmarked or created yourself"
msgstr ""
"Hier ist eine Liste an Wiedergabelisten, die du dir gemerkt oder selber "
"erstellt hast"

#: ../melon/home/playlists.py:34
#: ../melon/home/playlists.py:39
msgid "New"
msgstr "Neu"

#: ../melon/home/subs.py:21
#: ../melon/home/subs.py:23
msgid "You aren't yet subscribed to channels"
msgstr "Du folgst noch keinen Kanälen"

#: ../melon/home/subs.py:31 ../melon/home/subs.py:63
#: ../melon/home/subs.py:33 ../melon/home/subs.py:68
msgid "Subscriptions"
msgstr "Kanäle"

#: ../melon/home/subs.py:32
#: ../melon/home/subs.py:34
msgid "You are subscribed to the following channels"
msgstr "Du folgst den folgenden Kanälen"

#: ../melon/import_providers/newpipe.py:28
#: ../melon/import_providers/newpipe.py:42
msgid "Newpipe Database importer"
msgstr "NewPipe Datenbank Import"

#: ../melon/import_providers/newpipe.py:29
#: ../melon/import_providers/newpipe.py:44
msgid ""
"Import the .db file from inside the newpipe .zip export (as invidious "
"content)"
msgstr ""
"Importiere die .db Datei aus dem NewPipe .zip Export (als Invidious Inhalte)"

#: ../melon/import_providers/newpipe.py:30
#: ../melon/import_providers/newpipe.py:46
msgid "Select .db file"
msgstr "Wähle eine .db Datei"

#: ../melon/import_providers/newpipe.py:45
#: ../melon/import_providers/newpipe.py:60
msgid "Newpipe Database"
msgstr "NewPipe Datenbank"

#: ../melon/importer.py:17 ../melon/importer.py:35
#: ../melon/importer.py:19 ../melon/importer.py:37
msgid "Import"
msgstr "Importieren"

#: ../melon/importer.py:36
#: ../melon/importer.py:38
#, fuzzy
msgid "The following import methods have been found"
msgstr "Die folgenden Import-Methoden wurden gefunden"

#: ../melon/importer.py:62
#: ../melon/importer.py:64
#, fuzzy
msgid "There are no available importer methods"
msgstr "Es gibt keine verfügbaren Import-Methoden"

#: ../melon/player/__init__.py:34
#: ../melon/player/__init__.py:41
msgid "Description"
msgstr "Beschreibung"

#: ../melon/player/__init__.py:45
#: ../melon/player/__init__.py:54
msgid "Add this video to a playlist"
msgstr "Füge dieses Video einer Wiedergabeliste hinzu"

#: ../melon/player/__init__.py:103
#: ../melon/player/__init__.py:113
msgid "Video could not be loaded"
msgstr "Das Video konnte nicht geladen werden"

#: ../melon/player/__init__.py:140 ../melon/player/__init__.py:174
#: ../melon/player/playlist.py:51
#: ../melon/player/__init__.py:151 ../melon/player/__init__.py:185
#: ../melon/player/playlist.py:57
msgid "Loading..."
msgstr ""

#: ../melon/player/playlist.py:159
#: ../melon/player/playlist.py:165
msgid "This playlist is empty"
msgstr "Diese Wiedergabeliste ist leer"

#: ../melon/player/playlist.py:166
#: ../melon/player/playlist.py:172
msgid "There was an error loading the playlist"
msgstr "Beim Laden der Wiedergabeliste ist ein Fehler aufgetreten"

#: ../melon/player/playlist.py:218
#: ../melon/player/playlist.py:225
msgid "Previous"
msgstr "Vorheriges"

#: ../melon/player/playlist.py:219
#: ../melon/player/playlist.py:227
msgid "Play video that comes before this one in the playlist"
msgstr "Spielt das Video, was in der Wiedergabeliste vor diesem kommt"

#: ../melon/player/playlist.py:230
#: ../melon/player/playlist.py:244
msgid "Next"
msgstr "Nächstes"

#: ../melon/player/playlist.py:231
#: ../melon/player/playlist.py:246
msgid "Play video that comes after this one in the playlist"
msgstr "Spielt das Video, was in der Wiedergabeliste nach diesem kommt"

#: ../melon/player/playlist.py:240
#: ../melon/player/playlist.py:258
msgid "Skip"
msgstr "Überspringen"

#: ../melon/player/playlist.py:241
#: ../melon/player/playlist.py:259
msgid "Skip this video and pick a new one at random"
msgstr "Überspringt dieses Video und wählt zufällig ein Neues aus"

#: ../melon/player/playlist.py:250
#: ../melon/player/playlist.py:270
msgid "Shuffle"
msgstr ""

#: ../melon/player/playlist.py:251
#: ../melon/player/playlist.py:271
msgid "Chooses the next video at random"
msgstr "Wählt das nächste Video zufällig aus"

#: ../melon/player/playlist.py:261
#: ../melon/player/playlist.py:282
msgid "Repeat current video"
msgstr ""

#: ../melon/player/playlist.py:262
#: ../melon/player/playlist.py:283
msgid "Puts this video on loop"
msgstr ""

#: ../melon/player/playlist.py:276
#: ../melon/player/playlist.py:298
msgid "Repeat playlist"
msgstr ""

#: ../melon/player/playlist.py:277
#: ../melon/player/playlist.py:300
msgid "Start playling the playlist from the beginning after reaching the end"
msgstr ""
"Spielt die Wiedergabeliste erneut von Beginn, wenn das Ende erreicht wurde"

#: ../melon/player/playlist.py:287
#: ../melon/player/playlist.py:312
msgid "Playlist Content"
msgstr ""

#: ../melon/player/playlist.py:288
#: ../melon/player/playlist.py:314
msgid "Click on videos to continue playing the playlist from there"
msgstr "Klicke auf ein Video, um die Wiedergabeliste von dort fortzusetzen"

#: ../melon/playlist/__init__.py:50
#: ../melon/playlist/__init__.py:65
msgid "Edit"
msgstr "Bearbeiten"

#: ../melon/playlist/__init__.py:62
#: ../melon/playlist/__init__.py:79
msgid "You haven't added any videos to this playlist yet"
msgstr "Du hast noch keine Videos zu dieser Wiedergabeliste hinzugefügt"

#: ../melon/playlist/__init__.py:64
#: ../melon/playlist/__init__.py:82
msgid "Start watching"
msgstr "Videos anschauen"

#: ../melon/playlist/__init__.py:87
#: ../melon/playlist/__init__.py:106
msgid "Set as playlist thumbnail"
msgstr ""

#: ../melon/playlist/__init__.py:88
#: ../melon/playlist/__init__.py:114
msgid "Remove from playlist"
msgstr ""

#: ../melon/playlist/__init__.py:99
#: ../melon/playlist/__init__.py:131
msgid "Edit Playlist"
msgstr "Wiedergabeliste bearbeiten"

#: ../melon/playlist/__init__.py:105
#: ../melon/playlist/__init__.py:137
msgid "Playlist details"
msgstr "Wiedergabelistendetails"

#: ../melon/playlist/__init__.py:106
#: ../melon/playlist/__init__.py:138
msgid "Change playlist information"
msgstr "Wiedergabelistendetails ändern"

#: ../melon/playlist/__init__.py:108 ../melon/playlist/create.py:37
#: ../melon/playlist/__init__.py:140 ../melon/playlist/create.py:43
msgid "Playlist name"
msgstr "Name der Wiedergabeliste"

#: ../melon/playlist/__init__.py:111 ../melon/playlist/create.py:40
#: ../melon/playlist/__init__.py:143 ../melon/playlist/create.py:46
msgid "Playlist description"
msgstr "Beschreibung der Wiedergabeliste"

#: ../melon/playlist/__init__.py:116
#: ../melon/playlist/__init__.py:148
msgid "Save details"
msgstr "Details speichern"

#: ../melon/playlist/__init__.py:117
#: ../melon/playlist/__init__.py:150
msgid "Change playlist title and description. (Closes the dialog)"
msgstr ""
"Titel und Beschreibung der Wiedergabeliste ändern. (Schließt den Dialog)"

#: ../melon/playlist/__init__.py:127 ../melon/playlist/__init__.py:169
#: ../melon/playlist/__init__.py:161 ../melon/playlist/__init__.py:207
msgid "Delete Playlist"
msgstr "Wiedergabeliste löschen"

#: ../melon/playlist/__init__.py:128
#: ../melon/playlist/__init__.py:163
msgid "Delete this playlist and it's content. This can NOT be undone."
msgstr ""
"Diese Wiedergabeliste und ihren Inhalt löschen. Diese Aktion kann nicht "
"rückgängig gemacht werden."

#: ../melon/playlist/__init__.py:139
#: ../melon/playlist/__init__.py:176
msgid "Close"
msgstr "Schließen"

#: ../melon/playlist/__init__.py:139
#: ../melon/playlist/__init__.py:178
msgid "Close without changing anything"
msgstr ""

#: ../melon/playlist/__init__.py:172
#: ../melon/playlist/__init__.py:210
msgid "Do you really want to delete this playlist?"
msgstr "Möchtest du diese Wiedergabeliste wirklich löschen?"

#: ../melon/playlist/__init__.py:173
#: ../melon/playlist/__init__.py:211
msgid "This cannot be undone"
msgstr "Dies kann nicht rückgängig gemacht werden"

#: ../melon/playlist/__init__.py:177 ../melon/playlist/create.py:47
#: ../melon/playlist/pick.py:70 ../melon/widgets/preferencerow.py:175
#: ../melon/widgets/preferencerow.py:218
#: ../melon/playlist/__init__.py:216 ../melon/playlist/create.py:54
#: ../melon/playlist/pick.py:88 ../melon/widgets/preferencerow.py:181
#: ../melon/widgets/preferencerow.py:226
msgid "Cancel"
msgstr "Abbruch"

#: ../melon/playlist/__init__.py:177
#: ../melon/playlist/__init__.py:218
msgid "Do not delete the playlist"
msgstr "Wiedergabeliste nicht löschen"

#: ../melon/playlist/__init__.py:178 ../melon/widgets/preferencerow.py:207
#: ../melon/widgets/preferencerow.py:219
#: ../melon/playlist/__init__.py:221 ../melon/widgets/preferencerow.py:214
#: ../melon/widgets/preferencerow.py:229
msgid "Delete"
msgstr "Löschen"

#: ../melon/playlist/__init__.py:178
#: ../melon/playlist/__init__.py:221
msgid "Delete this playlist"
msgstr "Diese Wiedergabeliste löschen"

#: ../melon/playlist/create.py:23 ../melon/playlist/pick.py:28
#: ../melon/playlist/create.py:25 ../melon/playlist/pick.py:35
msgid "Video"
msgstr "Video"

#: ../melon/playlist/create.py:24
#: ../melon/playlist/create.py:27
msgid "The following video will be added to the new playlist"
msgstr "Das folgende Video wird zu der neuen Wiedergabeliste hinzugefügt"

#: ../melon/playlist/create.py:33 ../melon/playlist/create.py:91
#: ../melon/playlist/create.py:39 ../melon/playlist/create.py:98
msgid "New Playlist"
msgstr "Neue Wiedergabeliste"

#: ../melon/playlist/create.py:34
#: ../melon/playlist/create.py:40
msgid "Enter more playlist information"
msgstr "Gibt weitere Wiedergabelistendetails an"

#: ../melon/playlist/create.py:38
#: ../melon/playlist/create.py:44
msgid "Unnamed Playlist"
msgstr "Unbenannte Wiedergabeliste"

#: ../melon/playlist/create.py:47 ../melon/playlist/pick.py:70
#: ../melon/playlist/create.py:54 ../melon/playlist/pick.py:88
msgid "Do not create playlist"
msgstr "Wiedergabeliste nicht erstellen"

#: ../melon/playlist/create.py:48 ../melon/widgets/preferencerow.py:176
#: ../melon/playlist/create.py:57 ../melon/widgets/preferencerow.py:184
msgid "Create"
msgstr "Erstellen"

#: ../melon/playlist/create.py:48
#: ../melon/playlist/create.py:57
msgid "Create playlist"
msgstr "Wiedergabeliste erstellen"

#: ../melon/playlist/pick.py:29
#: ../melon/playlist/pick.py:37
msgid "The following video will be added to the playlist"
msgstr "Das folgende Video wird zur Wiedergabeliste hinzugefügt"

#: ../melon/playlist/pick.py:39 ../melon/widgets/feeditem.py:122
#: ../melon/playlist/pick.py:50 ../melon/widgets/feeditem.py:145
msgid "Add to playlist"
msgstr "Zur Wiedergabeliste hinzufügen"

#: ../melon/playlist/pick.py:40
#: ../melon/playlist/pick.py:53
msgid ""
"Choose a playlist to add the video to. Note that you can only add videos to "
"local playlists, not external bookmarked ones"


@@ 573,42 573,42 @@ msgstr ""
"Wähle die Wiedergabeliste, zu der das Video hinzugefügt werden soll. (Du "
"kannst Videos nur zu lokalen Wiedergabelisten hinzufügen, nicht in gemerkte)"

#: ../melon/playlist/pick.py:42
#: ../melon/playlist/pick.py:57
msgid "Create new playlist"
msgstr "Neue Wiedergabeliste erstellen"

#: ../melon/playlist/pick.py:43
#: ../melon/playlist/pick.py:58
msgid "Create a new playlist and add the video to it"
msgstr "Neue Wiedergabeliste erstellen und Video hinzufügen"

#: ../melon/playlist/pick.py:102
#: ../melon/playlist/pick.py:119
msgid "Add to Playlist"
msgstr "Zur Wiedergabeliste hinzufügen"

#: ../melon/servers/invidious/__init__.py:31
#: ../melon/servers/invidious/__init__.py:35
msgid "Open source alternative front-end to YouTube"
msgstr "Quelloffenes alternatives Frontend für YouTube"

#: ../melon/servers/invidious/__init__.py:36
#: ../melon/servers/invidious/__init__.py:40
msgid "Instance"
msgstr "Instanz"

#: ../melon/servers/invidious/__init__.py:37
#: ../melon/servers/invidious/__init__.py:42
msgid ""
"See https://docs.invidious.io/instances/ for a list of available instances"
msgstr ""
"Auf https://docs.invidious.io/instances/ kannst du eine liste verfügbarer "
"Instanzen finden"

#: ../melon/servers/invidious/__init__.py:48
#: ../melon/servers/invidious/__init__.py:55
msgid "Trending"
msgstr "Trends"

#: ../melon/servers/invidious/__init__.py:49
#: ../melon/servers/invidious/__init__.py:56
msgid "Popular"
msgstr "Beliebt"

#: ../melon/servers/nebula/__init__.py:19
#: ../melon/servers/nebula/__init__.py:21
msgid ""
"Home of smart, thoughtful videos, podcasts, and classes from your favorite "
"creators"


@@ 616,32 616,32 @@ msgstr ""
"Zu Hause von intelligenten, durchdachten Videos, Podcasts und Kursen von "
"deinen Lieblingskanälen"

#: ../melon/servers/nebula/__init__.py:24
#: ../melon/servers/nebula/__init__.py:27
msgid "Email Address"
msgstr "E-Mail-Adresse"

#: ../melon/servers/nebula/__init__.py:25
#: ../melon/servers/nebula/__init__.py:28
msgid "Email Address to login to your account"
msgstr "E-Mail-Adresse zur Anmeldung mit deinem Konto"

#: ../melon/servers/nebula/__init__.py:31
#: ../melon/servers/nebula/__init__.py:35
msgid "Password"
msgstr "Passwort"

#: ../melon/servers/nebula/__init__.py:32
#: ../melon/servers/nebula/__init__.py:36
msgid "Password associated with your account"
msgstr "Passwort zur Anmeldung mit deinem Konto"

#: ../melon/servers/peertube/__init__.py:15
#: ../melon/servers/peertube/__init__.py:16
msgid "Decentralized video hosting network, based on free/libre software"
msgstr ""
"Dezentralisiertes Video-Hosting-Netzwerk, basierend auf freier/libre Software"

#: ../melon/servers/peertube/__init__.py:20
#: ../melon/servers/peertube/__init__.py:21
msgid "Instances"
msgstr "Instanzen"

#: ../melon/servers/peertube/__init__.py:21
#: ../melon/servers/peertube/__init__.py:23
msgid ""
"List of peertube instances, from which to fetch content. See https://"
"joinpeertube.org/instances"


@@ 649,32 649,32 @@ msgstr ""
"Liste von PeerTube Instanzen, von denen Inhalte geladen werden sollen. Siehe "
"https://joinpeertube.org/instances"

#: ../melon/servers/peertube/__init__.py:27
#: ../melon/servers/peertube/__init__.py:31
msgid "Show NSFW content"
msgstr "NSFW Inhalte anzeigen"

#: ../melon/servers/peertube/__init__.py:28
#: ../melon/servers/peertube/__init__.py:32
#, fuzzy
msgid "Passes the nsfw filter to the peertube search API"
msgstr "Gibt den NSFW Filter an die PeerTube Such-API weiter"

#: ../melon/servers/peertube/__init__.py:33
#: ../melon/servers/peertube/__init__.py:39
msgid "Enable Federation"
msgstr ""

#: ../melon/servers/peertube/__init__.py:34
#: ../melon/servers/peertube/__init__.py:40
msgid "Returns content from federated instances instead of only local content"
msgstr ""

#: ../melon/servers/peertube/__init__.py:58
#: ../melon/servers/peertube/__init__.py:65
msgid "Latest"
msgstr "Neuste"

#: ../melon/settings/__init__.py:18
#: ../melon/settings/__init__.py:23
msgid "Show Previews when browsing public feeds (incl. search)"
msgstr "Vorschaubilder für externe Inhalte anzeigen"

#: ../melon/settings/__init__.py:19
#: ../melon/settings/__init__.py:25
msgid ""
"Set to true to show previews when viewing channel contents, public feeds and "
"searching"


@@ 682,11 682,11 @@ msgstr ""
"Aktiviert Vorschaubilder beim Durchsuchen von Diensten, Ansehen von Kanälen "
"und Wiedergabelisten"

#: ../melon/settings/__init__.py:25
#: ../melon/settings/__init__.py:34
msgid "Show Previews in local feeds"
msgstr "Vorschaubilder für lokale Inhalte anzeigen"

#: ../melon/settings/__init__.py:26
#: ../melon/settings/__init__.py:36
msgid ""
"Set to true to show previews in the new feed, for subscribed channels, and "
"saved playlists"


@@ 694,12 694,12 @@ msgstr ""
"Aktiviert Vorschaubilder im Neues Tab, im Kanäle Tab und für selbst "
"erstellte Wiedergabelisten"

#: ../melon/settings/__init__.py:32
#: ../melon/settings/__init__.py:44
#, fuzzy
msgid "Show servers that may contain nsfw content"
msgstr "Dienste anzeigen, die teilweise NSFW Inhalte enthalten"

#: ../melon/settings/__init__.py:33
#: ../melon/settings/__init__.py:46
#, fuzzy
msgid ""
"Lists/Delists servers in the browse servers list, if they contain some nsfw "


@@ 707,12 707,12 @@ msgid ""
msgstr ""
"Zeige Dienste, die vereinzelt NSFW Inhalte beinhalten, beim Durchsuchen an"

#: ../melon/settings/__init__.py:38
#: ../melon/settings/__init__.py:54
#, fuzzy
msgid "Show servers that only contain nsfw content"
msgstr "Dienste anzeigen, die ausschließlich NSFW Inhalte enthalten"

#: ../melon/settings/__init__.py:39
#: ../melon/settings/__init__.py:56
#, fuzzy
msgid ""
"Lists/Delists servers in the browse servers list, if they contain only/"


@@ 720,137 720,137 @@ msgid ""
msgstr ""
"Zeige Dienste, die (fast) nur NSFW Inhalte beinhalten, beim Durchsuchen an"

#: ../melon/settings/__init__.py:45
#: ../melon/settings/__init__.py:64
msgid "Show servers that require login"
msgstr "Dienste anzeigen, die ein Konto benötigen"

#: ../melon/settings/__init__.py:46
#: ../melon/settings/__init__.py:66
msgid ""
"Lists/Delists servers in the browse servers list, if they require login to "
"function"
msgstr "Zeige Dienste, für die du einen Account brauchst, beim Durchsuchen an"

#: ../melon/settings/__init__.py:55
#: ../melon/settings/__init__.py:78
msgid "Settings"
msgstr "Einstellungen"

#: ../melon/settings/__init__.py:68
#: ../melon/settings/__init__.py:91
msgid "General"
msgstr "Allgemein"

#: ../melon/settings/__init__.py:69
#: ../melon/settings/__init__.py:92
msgid "Global app settings"
msgstr "Globale App Einstellungen"

#: ../melon/settings/__init__.py:93
#: ../melon/settings/__init__.py:116
msgid "Enable Server"
msgstr "Dienst aktivieren"

#: ../melon/settings/__init__.py:94
#: ../melon/settings/__init__.py:118
msgid ""
"Disabled servers won't show up in the browser or on the local/home screen"
msgstr ""
"Deaktivierte Dienste werden beim Durchsuchen und auf dem Startbildschirm "
"nicht angezeigt"

#: ../melon/widgets/feeditem.py:121
#: ../melon/widgets/feeditem.py:139
msgid "Watch now"
msgstr ""

#: ../melon/widgets/feeditem.py:123
#: ../melon/widgets/feeditem.py:152
msgid "Open in browser"
msgstr ""

#: ../melon/widgets/feeditem.py:124
#: ../melon/widgets/feeditem.py:156
msgid "View channel"
msgstr ""

#: ../melon/widgets/player.py:119
#: ../melon/widgets/player.py:136
msgid "No streams available"
msgstr ""

#: ../melon/widgets/player.py:180
#: ../melon/widgets/player.py:198
msgid "Toggle floating window"
msgstr ""

#: ../melon/widgets/player.py:184
#: ../melon/widgets/player.py:204
msgid "Toggle fullscreen"
msgstr ""

#: ../melon/widgets/player.py:204 ../melon/widgets/player.py:565
#: ../melon/widgets/player.py:571
#: ../melon/widgets/player.py:226 ../melon/widgets/player.py:613
#: ../melon/widgets/player.py:622
msgid "Play"
msgstr ""

#: ../melon/widgets/player.py:230
#: ../melon/widgets/player.py:253
msgid "Stream options"
msgstr ""

#: ../melon/widgets/player.py:442
#: ../melon/widgets/player.py:483
msgid "Resolution"
msgstr "Auflösung"

#: ../melon/widgets/player.py:559
#: ../melon/widgets/player.py:604
msgid "Pause"
msgstr "Pause"

#: ../melon/widgets/player.py:589
#: ../melon/widgets/player.py:642
msgid "The video is playing in separate window"
msgstr "Dieses Video spielt in einem separaten Fenster"

#: ../melon/widgets/player.py:710
#: ../melon/widgets/player.py:779
msgid "Player"
msgstr ""

#: ../melon/widgets/preferencerow.py:116
#: ../melon/widgets/preferencerow.py:125
msgid "Add"
msgstr "Hinzufügen"

#: ../melon/widgets/preferencerow.py:130
#: ../melon/widgets/preferencerow.py:139
msgid "Move up"
msgstr "Nach oben verschieben"

#: ../melon/widgets/preferencerow.py:141
#: ../melon/widgets/preferencerow.py:147
msgid "Move down"
msgstr "Nach unten verschieben"

#: ../melon/widgets/preferencerow.py:150
#: ../melon/widgets/preferencerow.py:153
msgid "Remove from list"
msgstr "Von der Liste entfernen"

#: ../melon/widgets/preferencerow.py:163
#: ../melon/widgets/preferencerow.py:168
msgid "Add Item"
msgstr "Eintrag hinzufügen"

#: ../melon/widgets/preferencerow.py:166
#: ../melon/widgets/preferencerow.py:171
msgid "Create a new list entry"
msgstr "Neuen Listeneintrag erstellen"

#: ../melon/widgets/preferencerow.py:167
#: ../melon/widgets/preferencerow.py:172
msgid "Enter the new value here"
msgstr "Gibt den neuen Wert hier ein"

#: ../melon/widgets/preferencerow.py:170
#: ../melon/widgets/preferencerow.py:175
msgid "Value"
msgstr "Wert"

#: ../melon/widgets/preferencerow.py:210
#: ../melon/widgets/preferencerow.py:217
msgid "Do you really want to delete this item?"
msgstr "Möchtest du den Eintrag wirklich löschen?"

#: ../melon/widgets/preferencerow.py:211
#: ../melon/widgets/preferencerow.py:218
msgid "You won't be able to restore it afterwards"
msgstr "Eine Wiederherstellung ist nicht möglich"

#: ../melon/widgets/preferencerow.py:218
#: ../melon/widgets/preferencerow.py:226
msgid "Do not remove item"
msgstr "Eintrag nicht entfernen"

#: ../melon/widgets/preferencerow.py:219
#: ../melon/widgets/preferencerow.py:229
msgid "Remove item from list"
msgstr "Eintrag von der Liste entfernen"

#: ../melon/window.py:84
#: ../melon/window.py:94
msgid "Stream videos on the go"
msgstr "Videos unterwegs streamen"


M po/fa.po => po/fa.po +193 -193
@@ 7,7 7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: Melon 0.1.2\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-06-12 21:13+0200\n"
"POT-Creation-Date: 2024-07-15 07:54+0200\n"
"PO-Revision-Date: 2024-05-30 09:18+0000\n"
"Last-Translator: sohrabbehdani <sohrabbehdani@users.noreply.translate."
"codeberg.org>\n"


@@ 92,477 92,477 @@ msgid ""
msgstr ""
"صفحه ویدیو، با یک پخش‌کننده ویدیو در بالا، اطلاعات ویدیو و دکمه نشانک‌گذاری"

#: ../melon/browse/__init__.py:18 ../melon/browse/search.py:58
#: ../melon/browse/server.py:101 ../melon/browse/server.py:127
#: ../melon/home/history.py:23 ../melon/home/new.py:29
#: ../melon/home/playlists.py:21 ../melon/home/subs.py:20
#: ../melon/importer.py:61 ../melon/player/__init__.py:102
#: ../melon/player/playlist.py:158 ../melon/player/playlist.py:165
#: ../melon/playlist/__init__.py:61
#: ../melon/browse/__init__.py:20 ../melon/browse/search.py:66
#: ../melon/browse/server.py:115 ../melon/browse/server.py:143
#: ../melon/home/history.py:25 ../melon/home/new.py:36
#: ../melon/home/playlists.py:23 ../melon/home/subs.py:22
#: ../melon/importer.py:63 ../melon/player/__init__.py:112
#: ../melon/player/playlist.py:164 ../melon/player/playlist.py:171
#: ../melon/playlist/__init__.py:77
#, fuzzy
msgid "*crickets chirping*"
msgstr "*جیرجیرک جیرجیرک*"

#: ../melon/browse/__init__.py:19
#: ../melon/browse/__init__.py:21
msgid "There are no available servers"
msgstr ""

#: ../melon/browse/__init__.py:21 ../melon/browse/search.py:61
#: ../melon/importer.py:64
#: ../melon/browse/__init__.py:24 ../melon/browse/search.py:72
#: ../melon/importer.py:67
msgid "Enable servers in the settings menu"
msgstr "فعال کردن کارساز در منوی تنظیمات"

#: ../melon/browse/__init__.py:29
#: ../melon/browse/__init__.py:33
msgid "Available Servers"
msgstr "کارساز های موجود"

#: ../melon/browse/__init__.py:30
#: ../melon/browse/__init__.py:35
msgid "You can enable/disable and filter servers in the settings menu"
msgstr "شما می‌توانید کارسازها را در تنظیمات فعال/غیرفعال و پالایش کنید."

#: ../melon/browse/__init__.py:48
#: ../melon/browse/__init__.py:56
msgid "Servers"
msgstr "کارسازها"

#: ../melon/browse/__init__.py:56 ../melon/browse/search.py:106
#: ../melon/browse/__init__.py:64 ../melon/browse/search.py:126
msgid "Global Search"
msgstr "جستجو سراسری"

#: ../melon/browse/channel.py:93
#: ../melon/browse/channel.py:104
msgid "Subscribe to channel"
msgstr "مشترک کانال شوید"

#: ../melon/browse/channel.py:94
#: ../melon/browse/channel.py:105
msgid "Add latest uploads to home feed"
msgstr "افزودن آخرین بارگذاری‌ها به صفحه‌خانه"

#: ../melon/browse/channel.py:106
#: ../melon/browse/channel.py:117
msgid "Channel feed"
msgstr "خوراک کانال"

#: ../melon/browse/channel.py:107
#: ../melon/browse/channel.py:118
msgid "This channel provides multiple feeds, choose which one to view"
msgstr ""

#: ../melon/browse/channel.py:159
#: ../melon/browse/channel.py:170
msgid "Channel"
msgstr "کانال"

#: ../melon/browse/playlist.py:37 ../melon/playlist/__init__.py:53
#: ../melon/browse/playlist.py:48 ../melon/playlist/__init__.py:69
msgid "Start playing"
msgstr "شروع به پخش کردن"

#: ../melon/browse/playlist.py:56 ../melon/player/__init__.py:44
#: ../melon/browse/playlist.py:69 ../melon/player/__init__.py:53
msgid "Bookmark"
msgstr "نشانک گذاری"

#: ../melon/browse/playlist.py:57
#: ../melon/browse/playlist.py:70
msgid "Add Playlist to your local playlist collection"
msgstr "افزودن این لیست‌پخش به مجموعه لیست‌پخش های محلی"

#: ../melon/browse/playlist.py:111
#: ../melon/browse/playlist.py:124
msgid "Playlist"
msgstr "سیاهه‌ٔ پخش"

#: ../melon/browse/search.py:44
#: ../melon/browse/search.py:49
msgid "No results"
msgstr "بدون نتیجه"

#: ../melon/browse/search.py:46
#: ../melon/browse/search.py:52
#, python-brace-format
msgid "{count} result"
msgid_plural "{count} results"
msgstr[0] ""
msgstr[1] ""

#: ../melon/browse/search.py:59
#: ../melon/browse/search.py:68
msgid "There are no available servers, a search would yield no results"
msgstr ""

#: ../melon/browse/search.py:83 ../melon/browse/server.py:43
#: ../melon/browse/search.py:96 ../melon/browse/server.py:47
msgid "Any"
msgstr "همه"

#: ../melon/browse/search.py:85 ../melon/browse/server.py:45
#: ../melon/browse/search.py:100 ../melon/browse/server.py:51
msgid "Channels"
msgstr "کانال‌ها"

#: ../melon/browse/search.py:88 ../melon/browse/server.py:48
#: ../melon/home/playlists.py:32 ../melon/home/playlists.py:64
#: ../melon/servers/nebula/__init__.py:194
#: ../melon/servers/peertube/__init__.py:205
#: ../melon/browse/search.py:105 ../melon/browse/server.py:56
#: ../melon/home/playlists.py:34 ../melon/home/playlists.py:78
#: ../melon/servers/nebula/__init__.py:206
#: ../melon/servers/peertube/__init__.py:213
msgid "Playlists"
msgstr "سیاههٔ پخش"

#: ../melon/browse/search.py:91 ../melon/browse/server.py:51
#: ../melon/servers/nebula/__init__.py:188
#: ../melon/servers/peertube/__init__.py:204
#: ../melon/servers/peertube/__init__.py:208
#: ../melon/browse/search.py:110 ../melon/browse/server.py:61
#: ../melon/servers/nebula/__init__.py:200
#: ../melon/servers/peertube/__init__.py:213
#: ../melon/servers/peertube/__init__.py:214
msgid "Videos"
msgstr "ویدیوها"

#: ../melon/browse/server.py:23
#: ../melon/browse/server.py:26
msgid "Search"
msgstr "جست‌وجو"

#: ../melon/browse/server.py:102
#: ../melon/browse/server.py:116
msgid "Try searching for a term"
msgstr ""

#: ../melon/browse/server.py:104
#: ../melon/browse/server.py:118
msgid "Try using a different query"
msgstr ""

#: ../melon/browse/server.py:128
#: ../melon/browse/server.py:144
msgid "This feed is empty"
msgstr ""

#: ../melon/home/__init__.py:18
#: ../melon/home/__init__.py:20
msgid "Home"
msgstr "خانه"

#: ../melon/home/__init__.py:39 ../melon/home/history.py:26
#: ../melon/home/new.py:36 ../melon/home/subs.py:23
#: ../melon/home/__init__.py:41 ../melon/home/history.py:28
#: ../melon/home/new.py:49 ../melon/home/subs.py:25
msgid "Browse Servers"
msgstr "جستجو در کارساز ها"

#: ../melon/home/__init__.py:46
#: ../melon/home/__init__.py:48
msgid "Preferences"
msgstr "تنظیمات"

#: ../melon/home/__init__.py:47
#: ../melon/home/__init__.py:49
msgid "Import Data"
msgstr "وارد کردن دیتا"

#: ../melon/home/__init__.py:48
#: ../melon/home/__init__.py:50
msgid "About Melon"
msgstr "درباره Melon"

#: ../melon/home/history.py:24
#: ../melon/home/history.py:26
msgid "You haven't watched any videos yet"
msgstr ""

#: ../melon/home/history.py:36 ../melon/home/history.py:117
#: ../melon/home/history.py:38 ../melon/home/history.py:125
msgid "History"
msgstr "تاریخچه"

#: ../melon/home/history.py:37
#: ../melon/home/history.py:39
msgid "These are the videos you opened in the past"
msgstr ""

#: ../melon/home/history.py:76
#: ../melon/home/history.py:83
msgid "Show more"
msgstr "نمایش بیشتر"

#: ../melon/home/history.py:77
#: ../melon/home/history.py:84
msgid "Load older videos"
msgstr "دریافت ویدئوهای قدیمی‌تر"

#: ../melon/home/new.py:23
#: ../melon/home/new.py:30
msgid "Refresh"
msgstr "بازخوانی"

#: ../melon/home/new.py:32
#: ../melon/home/new.py:40
msgid "Subscribe to a channel first, to view new uploads"
msgstr "برای تماشای محتوای بارگذاری جدید، در کانال‌ها مشترک شوید."

#: ../melon/home/new.py:34
#: ../melon/home/new.py:45
msgid "The channels you are subscribed to haven't uploaded anything yet"
msgstr "کانالی که مشترک آن شدید هیچ محتوای بارگذاری شده ای ندارد"

#: ../melon/home/new.py:55
#: ../melon/home/new.py:68
#, python-brace-format
msgid "(Last refresh: {last_refresh})"
msgstr "(آخرین بازخوانی: {last_refresh})"

#: ../melon/home/new.py:57 ../melon/home/new.py:146
#: ../melon/home/new.py:72 ../melon/home/new.py:172
msgid "What's new"
msgstr "چه چیزی جدید است؟"

#: ../melon/home/new.py:58
#: ../melon/home/new.py:74
msgid "These are the latest videos of channels you follow"
msgstr ""

#: ../melon/home/new.py:137
#: ../melon/home/new.py:162
msgid "Pick up where you left off"
msgstr ""

#: ../melon/home/new.py:138
#: ../melon/home/new.py:163
msgid "Watch"
msgstr "تماشا"

#: ../melon/home/playlists.py:22
#: ../melon/home/playlists.py:24
msgid "You don't have any playlists yet"
msgstr "شما هنوز هیچ سیاههٔ پخشی ندارید."

#: ../melon/home/playlists.py:24 ../melon/home/playlists.py:34
#: ../melon/home/playlists.py:26 ../melon/home/playlists.py:39
msgid "Create a new playlist"
msgstr "ایجاد یک لیست‌پخش جدید"

#: ../melon/home/playlists.py:33
#: ../melon/home/playlists.py:36
msgid "Here are playlists you've bookmarked or created yourself"
msgstr "اینها لیست پخش هایی هستند که توسط شما نشانه‌گذاری و یا ایجاد شده‌اند."

#: ../melon/home/playlists.py:34
#: ../melon/home/playlists.py:39
msgid "New"
msgstr "جدید"

#: ../melon/home/subs.py:21
#: ../melon/home/subs.py:23
msgid "You aren't yet subscribed to channels"
msgstr "شما مشترک هیچ کانالی نیستید"

#: ../melon/home/subs.py:31 ../melon/home/subs.py:63
#: ../melon/home/subs.py:33 ../melon/home/subs.py:68
msgid "Subscriptions"
msgstr "اشتراک‌ها"

#: ../melon/home/subs.py:32
#: ../melon/home/subs.py:34
msgid "You are subscribed to the following channels"
msgstr "شما در کانال‌های زیر مشترک هستید"

#: ../melon/import_providers/newpipe.py:28
#: ../melon/import_providers/newpipe.py:42
msgid "Newpipe Database importer"
msgstr "درون‌ریز پایگاه‌دادهٔ نیوپایپ"

#: ../melon/import_providers/newpipe.py:29
#: ../melon/import_providers/newpipe.py:44
msgid ""
"Import the .db file from inside the newpipe .zip export (as invidious "
"content)"
msgstr ""
"درون‌ریزی پروندهٔ ‪.db‬ از داخل خروجی ‬‪.zip‬ نیوپایپ (به عنوان محتوای اینویدیوس)"

#: ../melon/import_providers/newpipe.py:30
#: ../melon/import_providers/newpipe.py:46
msgid "Select .db file"
msgstr "انتخاب فایل .db"

#: ../melon/import_providers/newpipe.py:45
#: ../melon/import_providers/newpipe.py:60
msgid "Newpipe Database"
msgstr "پایگاه‌دادهٔ نیوپایپ"

#: ../melon/importer.py:17 ../melon/importer.py:35
#: ../melon/importer.py:19 ../melon/importer.py:37
msgid "Import"
msgstr "واردکردن"

#: ../melon/importer.py:36
#: ../melon/importer.py:38
msgid "The following import methods have been found"
msgstr "روش های واردکردن پیدا شدند"

#: ../melon/importer.py:62
#: ../melon/importer.py:64
msgid "There are no available importer methods"
msgstr ""

#: ../melon/player/__init__.py:34
#: ../melon/player/__init__.py:41
msgid "Description"
msgstr "توضیحات"

#: ../melon/player/__init__.py:45
#: ../melon/player/__init__.py:54
msgid "Add this video to a playlist"
msgstr "افزودن این ویدئو به لیست‌پخش"

#: ../melon/player/__init__.py:103
#: ../melon/player/__init__.py:113
msgid "Video could not be loaded"
msgstr ""

#: ../melon/player/__init__.py:140 ../melon/player/__init__.py:174
#: ../melon/player/playlist.py:51
#: ../melon/player/__init__.py:151 ../melon/player/__init__.py:185
#: ../melon/player/playlist.py:57
msgid "Loading..."
msgstr "در حال بارکردن..."

#: ../melon/player/playlist.py:159
#: ../melon/player/playlist.py:165
msgid "This playlist is empty"
msgstr ""

#: ../melon/player/playlist.py:166
#: ../melon/player/playlist.py:172
msgid "There was an error loading the playlist"
msgstr ""

#: ../melon/player/playlist.py:218
#: ../melon/player/playlist.py:225
msgid "Previous"
msgstr "قبلی"

#: ../melon/player/playlist.py:219
#: ../melon/player/playlist.py:227
msgid "Play video that comes before this one in the playlist"
msgstr ""

#: ../melon/player/playlist.py:230
#: ../melon/player/playlist.py:244
msgid "Next"
msgstr "بعدی"

#: ../melon/player/playlist.py:231
#: ../melon/player/playlist.py:246
msgid "Play video that comes after this one in the playlist"
msgstr "ویدیو بعد از این را که در سیاههٔ‌پخش وجود دارد پخش کن."

#: ../melon/player/playlist.py:240
#: ../melon/player/playlist.py:258
msgid "Skip"
msgstr "ردکردن"

#: ../melon/player/playlist.py:241
#: ../melon/player/playlist.py:259
msgid "Skip this video and pick a new one at random"
msgstr "رد کردن این ویدئو و انتخاب یک عدد به صورت تصادفی"

#: ../melon/player/playlist.py:250
#: ../melon/player/playlist.py:270
msgid "Shuffle"
msgstr "مخلوط"

#: ../melon/player/playlist.py:251
#: ../melon/player/playlist.py:271
msgid "Chooses the next video at random"
msgstr "انتخاب ویدئو بعدی به صورت تصادفی"

#: ../melon/player/playlist.py:261
#: ../melon/player/playlist.py:282
msgid "Repeat current video"
msgstr "بازپخش این ویدئو"

#: ../melon/player/playlist.py:262
#: ../melon/player/playlist.py:283
msgid "Puts this video on loop"
msgstr "ویدئو را در حلقه قرار دهید."

#: ../melon/player/playlist.py:276
#: ../melon/player/playlist.py:298
msgid "Repeat playlist"
msgstr "بازپخش لیست‌پخش"

#: ../melon/player/playlist.py:277
#: ../melon/player/playlist.py:300
msgid "Start playling the playlist from the beginning after reaching the end"
msgstr ""

#: ../melon/player/playlist.py:287
#: ../melon/player/playlist.py:312
msgid "Playlist Content"
msgstr "محتوای سیاههٔ‌پخش"

#: ../melon/player/playlist.py:288
#: ../melon/player/playlist.py:314
msgid "Click on videos to continue playing the playlist from there"
msgstr "بر روی ویدئو ضربه بزنید تا پخش از لیست پخش از آن ویدئو ادامه پیدا کند."

#: ../melon/playlist/__init__.py:50
#: ../melon/playlist/__init__.py:65
msgid "Edit"
msgstr "ویرایش"

#: ../melon/playlist/__init__.py:62
#: ../melon/playlist/__init__.py:79
msgid "You haven't added any videos to this playlist yet"
msgstr ""

#: ../melon/playlist/__init__.py:64
#: ../melon/playlist/__init__.py:82
msgid "Start watching"
msgstr "شروع به تماشا کنید"

#: ../melon/playlist/__init__.py:87
#: ../melon/playlist/__init__.py:106
msgid "Set as playlist thumbnail"
msgstr ""

#: ../melon/playlist/__init__.py:88
#: ../melon/playlist/__init__.py:114
msgid "Remove from playlist"
msgstr ""

#: ../melon/playlist/__init__.py:99
#: ../melon/playlist/__init__.py:131
msgid "Edit Playlist"
msgstr "حذف لیست‌پخش"

#: ../melon/playlist/__init__.py:105
#: ../melon/playlist/__init__.py:137
msgid "Playlist details"
msgstr "جزئیات لیست‌پخش"

#: ../melon/playlist/__init__.py:106
#: ../melon/playlist/__init__.py:138
msgid "Change playlist information"
msgstr "تغییر اطلاعات لیست پخش"

#: ../melon/playlist/__init__.py:108 ../melon/playlist/create.py:37
#: ../melon/playlist/__init__.py:140 ../melon/playlist/create.py:43
msgid "Playlist name"
msgstr "نام لیست‌پخش"

#: ../melon/playlist/__init__.py:111 ../melon/playlist/create.py:40
#: ../melon/playlist/__init__.py:143 ../melon/playlist/create.py:46
msgid "Playlist description"
msgstr "اطلاعات سیاههٔ پخش"

#: ../melon/playlist/__init__.py:116
#: ../melon/playlist/__init__.py:148
msgid "Save details"
msgstr "ذخیره جزئیات"

#: ../melon/playlist/__init__.py:117
#: ../melon/playlist/__init__.py:150
msgid "Change playlist title and description. (Closes the dialog)"
msgstr "تغییر عنوان و توضیحات لیست پخش (این پنجره بسته می شود)"

#: ../melon/playlist/__init__.py:127 ../melon/playlist/__init__.py:169
#: ../melon/playlist/__init__.py:161 ../melon/playlist/__init__.py:207
msgid "Delete Playlist"
msgstr "حذف لیست پخش"

#: ../melon/playlist/__init__.py:128
#: ../melon/playlist/__init__.py:163
msgid "Delete this playlist and it's content. This can NOT be undone."
msgstr "این عملیات این لیست‌پخش و محتویات آن را حذف می‌کند و قابل بازگشت نیست."

#: ../melon/playlist/__init__.py:139
#: ../melon/playlist/__init__.py:176
msgid "Close"
msgstr "بستن"

#: ../melon/playlist/__init__.py:139
#: ../melon/playlist/__init__.py:178
msgid "Close without changing anything"
msgstr "بستن بدون تغییر دادن چیزی"

#: ../melon/playlist/__init__.py:172
#: ../melon/playlist/__init__.py:210
msgid "Do you really want to delete this playlist?"
msgstr "آیا از حذف این لیست‌پخش اطمینان دارید؟"

#: ../melon/playlist/__init__.py:173
#: ../melon/playlist/__init__.py:211
msgid "This cannot be undone"
msgstr ""

#: ../melon/playlist/__init__.py:177 ../melon/playlist/create.py:47
#: ../melon/playlist/pick.py:70 ../melon/widgets/preferencerow.py:175
#: ../melon/widgets/preferencerow.py:218
#: ../melon/playlist/__init__.py:216 ../melon/playlist/create.py:54
#: ../melon/playlist/pick.py:88 ../melon/widgets/preferencerow.py:181
#: ../melon/widgets/preferencerow.py:226
msgid "Cancel"
msgstr "لغو"

#: ../melon/playlist/__init__.py:177
#: ../melon/playlist/__init__.py:218
msgid "Do not delete the playlist"
msgstr "لیست‌پخش را حذف نکن."

#: ../melon/playlist/__init__.py:178 ../melon/widgets/preferencerow.py:207
#: ../melon/widgets/preferencerow.py:219
#: ../melon/playlist/__init__.py:221 ../melon/widgets/preferencerow.py:214
#: ../melon/widgets/preferencerow.py:229
msgid "Delete"
msgstr "حذف"

#: ../melon/playlist/__init__.py:178
#: ../melon/playlist/__init__.py:221
msgid "Delete this playlist"
msgstr "حذف این لیست‌پخش"

#: ../melon/playlist/create.py:23 ../melon/playlist/pick.py:28
#: ../melon/playlist/create.py:25 ../melon/playlist/pick.py:35
msgid "Video"
msgstr "ویدیو"

#: ../melon/playlist/create.py:24
#: ../melon/playlist/create.py:27
msgid "The following video will be added to the new playlist"
msgstr ""

#: ../melon/playlist/create.py:33 ../melon/playlist/create.py:91
#: ../melon/playlist/create.py:39 ../melon/playlist/create.py:98
msgid "New Playlist"
msgstr "سیاههٔ پخش جدید"

#: ../melon/playlist/create.py:34
#: ../melon/playlist/create.py:40
msgid "Enter more playlist information"
msgstr "وارد کردن توضیحات بیشتر برای لیست‌پخش"

#: ../melon/playlist/create.py:38
#: ../melon/playlist/create.py:44
msgid "Unnamed Playlist"
msgstr "سیاههٔ پخش بدون نام"

#: ../melon/playlist/create.py:47 ../melon/playlist/pick.py:70
#: ../melon/playlist/create.py:54 ../melon/playlist/pick.py:88
msgid "Do not create playlist"
msgstr "لیست پخش را ایجاد نکن."

#: ../melon/playlist/create.py:48 ../melon/widgets/preferencerow.py:176
#: ../melon/playlist/create.py:57 ../melon/widgets/preferencerow.py:184
msgid "Create"
msgstr "ایجاد"

#: ../melon/playlist/create.py:48
#: ../melon/playlist/create.py:57
msgid "Create playlist"
msgstr "ایجاد لیست‌پخش"

#: ../melon/playlist/pick.py:29
#: ../melon/playlist/pick.py:37
msgid "The following video will be added to the playlist"
msgstr ""

#: ../melon/playlist/pick.py:39 ../melon/widgets/feeditem.py:122
#: ../melon/playlist/pick.py:50 ../melon/widgets/feeditem.py:145
msgid "Add to playlist"
msgstr "افزودن به لیست‌پخش"

#: ../melon/playlist/pick.py:40
#: ../melon/playlist/pick.py:53
msgid ""
"Choose a playlist to add the video to. Note that you can only add videos to "
"local playlists, not external bookmarked ones"


@@ 571,73 571,73 @@ msgstr ""
"نکته: شما تنها می‌توانید ویدئو را به لیست پخش های محلی اضافه کنید، نه به "
"نشانک گذاری شده های خارجی."

#: ../melon/playlist/pick.py:42
#: ../melon/playlist/pick.py:57
msgid "Create new playlist"
msgstr "ایجاد یک لیست‌پخش جدید"

#: ../melon/playlist/pick.py:43
#: ../melon/playlist/pick.py:58
msgid "Create a new playlist and add the video to it"
msgstr "ایجاد یک لیست‌پخش جدید و افزودن ویدئو به آن"

#: ../melon/playlist/pick.py:102
#: ../melon/playlist/pick.py:119
msgid "Add to Playlist"
msgstr "افزودن به لیست‌پخش"

#: ../melon/servers/invidious/__init__.py:31
#: ../melon/servers/invidious/__init__.py:35
msgid "Open source alternative front-end to YouTube"
msgstr ""

#: ../melon/servers/invidious/__init__.py:36
#: ../melon/servers/invidious/__init__.py:40
msgid "Instance"
msgstr "نمونه"

#: ../melon/servers/invidious/__init__.py:37
#: ../melon/servers/invidious/__init__.py:42
msgid ""
"See https://docs.invidious.io/instances/ for a list of available instances"
msgstr ""
"برای مشاهده لیستی از کارساز های موجود به https://docs.invidious.io/"
"instances/ مراجعه کنید"

#: ../melon/servers/invidious/__init__.py:48
#: ../melon/servers/invidious/__init__.py:55
msgid "Trending"
msgstr ""

#: ../melon/servers/invidious/__init__.py:49
#: ../melon/servers/invidious/__init__.py:56
msgid "Popular"
msgstr "معروف"

#: ../melon/servers/nebula/__init__.py:19
#: ../melon/servers/nebula/__init__.py:21
msgid ""
"Home of smart, thoughtful videos, podcasts, and classes from your favorite "
"creators"
msgstr ""
"خانه ویدیوها، پادکست‌ها و کلاس‌های هوشمند و متفکر از سازندگان مورد علاقه‌تان"

#: ../melon/servers/nebula/__init__.py:24
#: ../melon/servers/nebula/__init__.py:27
msgid "Email Address"
msgstr "آدرس رایانامه"

#: ../melon/servers/nebula/__init__.py:25
#: ../melon/servers/nebula/__init__.py:28
msgid "Email Address to login to your account"
msgstr "آدرس رایانامه برای ورود به حساب کاربری"

#: ../melon/servers/nebula/__init__.py:31
#: ../melon/servers/nebula/__init__.py:35
msgid "Password"
msgstr ""

#: ../melon/servers/nebula/__init__.py:32
#: ../melon/servers/nebula/__init__.py:36
msgid "Password associated with your account"
msgstr ""

#: ../melon/servers/peertube/__init__.py:15
#: ../melon/servers/peertube/__init__.py:16
msgid "Decentralized video hosting network, based on free/libre software"
msgstr "شبکه میزبانی ویدیو غیرمتمرکز، بر اساس نرم افزار رایگان/آزاد"

#: ../melon/servers/peertube/__init__.py:20
#: ../melon/servers/peertube/__init__.py:21
msgid "Instances"
msgstr "نمونه‌ها"

#: ../melon/servers/peertube/__init__.py:21
#: ../melon/servers/peertube/__init__.py:23
msgid ""
"List of peertube instances, from which to fetch content. See https://"
"joinpeertube.org/instances"


@@ 645,31 645,31 @@ msgstr ""
"فهرست نمونه‌های پیرتیوب که محتوا از آن‌ها واکشی می‌شود. این نشانی را ببینید: "
"https://joinpeertube.org/instances"

#: ../melon/servers/peertube/__init__.py:27
#: ../melon/servers/peertube/__init__.py:31
msgid "Show NSFW content"
msgstr "نمایش محتوای nsfw"

#: ../melon/servers/peertube/__init__.py:28
#: ../melon/servers/peertube/__init__.py:32
msgid "Passes the nsfw filter to the peertube search API"
msgstr ""

#: ../melon/servers/peertube/__init__.py:33
#: ../melon/servers/peertube/__init__.py:39
msgid "Enable Federation"
msgstr "فعال کردن فدریشن"

#: ../melon/servers/peertube/__init__.py:34
#: ../melon/servers/peertube/__init__.py:40
msgid "Returns content from federated instances instead of only local content"
msgstr "محتوا را از کارساز های فدریتد بجای محلی نشان می‌دهد."

#: ../melon/servers/peertube/__init__.py:58
#: ../melon/servers/peertube/__init__.py:65
msgid "Latest"
msgstr "آخرین"

#: ../melon/settings/__init__.py:18
#: ../melon/settings/__init__.py:23
msgid "Show Previews when browsing public feeds (incl. search)"
msgstr "نمایش پیش نمایش ها هنگام مرور خوراک‌های عمومی (از جمله جستجو)"

#: ../melon/settings/__init__.py:19
#: ../melon/settings/__init__.py:25
msgid ""
"Set to true to show previews when viewing channel contents, public feeds and "
"searching"


@@ 677,11 677,11 @@ msgstr ""
"برای نمایش پیش نمایش ها هنگام مشاهده محتوای کانال، فیدهای عمومی و جستجو، روی "
"بله تنظیم کنید"

#: ../melon/settings/__init__.py:25
#: ../melon/settings/__init__.py:34
msgid "Show Previews in local feeds"
msgstr "نمایش پیشنمایش ها در خوراک های محلی"

#: ../melon/settings/__init__.py:26
#: ../melon/settings/__init__.py:36
msgid ""
"Set to true to show previews in the new feed, for subscribed channels, and "
"saved playlists"


@@ 689,156 689,156 @@ msgstr ""
"برای نمایش پیش‌نمایش‌ها در فید جدید، برای کانال‌های مشترک و لیست‌های پخش "
"ذخیره‌شده، روی بله تنظیم کنید"

#: ../melon/settings/__init__.py:32
#: ../melon/settings/__init__.py:44
msgid "Show servers that may contain nsfw content"
msgstr "نمایش کارساز هایی که ممکن است دارای محتوای NSFW باشد"

#: ../melon/settings/__init__.py:33
#: ../melon/settings/__init__.py:46
msgid ""
"Lists/Delists servers in the browse servers list, if they contain some nsfw "
"content"
msgstr ""

#: ../melon/settings/__init__.py:38
#: ../melon/settings/__init__.py:54
msgid "Show servers that only contain nsfw content"
msgstr "نمایش کارساز هایی که تنها دارای محتوای NSFW هستند"

#: ../melon/settings/__init__.py:39
#: ../melon/settings/__init__.py:56
msgid ""
"Lists/Delists servers in the browse servers list, if they contain only/"
"mostly nsfw content"
msgstr ""

#: ../melon/settings/__init__.py:45
#: ../melon/settings/__init__.py:64
msgid "Show servers that require login"
msgstr "نمایش کارساز هایی که به ورود احتیاج دارند"

#: ../melon/settings/__init__.py:46
#: ../melon/settings/__init__.py:66
msgid ""
"Lists/Delists servers in the browse servers list, if they require login to "
"function"
msgstr ""

#: ../melon/settings/__init__.py:55
#: ../melon/settings/__init__.py:78
msgid "Settings"
msgstr "تنظیمات"

#: ../melon/settings/__init__.py:68
#: ../melon/settings/__init__.py:91
msgid "General"
msgstr "کلی"

#: ../melon/settings/__init__.py:69
#: ../melon/settings/__init__.py:92
msgid "Global app settings"
msgstr "تنظیمات کلی کاره"

#: ../melon/settings/__init__.py:93
#: ../melon/settings/__init__.py:116
msgid "Enable Server"
msgstr "فعال کردن کارساز"

#: ../melon/settings/__init__.py:94
#: ../melon/settings/__init__.py:118
msgid ""
"Disabled servers won't show up in the browser or on the local/home screen"
msgstr ""
"کارساز های غیرفعال شده دیگر به شما در بخش محلی و صفحه‌خانه نشان داده نمی‌شوند."

#: ../melon/widgets/feeditem.py:121
#: ../melon/widgets/feeditem.py:139
msgid "Watch now"
msgstr ""

#: ../melon/widgets/feeditem.py:123
#: ../melon/widgets/feeditem.py:152
msgid "Open in browser"
msgstr ""

#: ../melon/widgets/feeditem.py:124
#: ../melon/widgets/feeditem.py:156
msgid "View channel"
msgstr ""

#: ../melon/widgets/player.py:119
#: ../melon/widgets/player.py:136
msgid "No streams available"
msgstr "جریان پخشی موجود نیست"

#: ../melon/widgets/player.py:180
#: ../melon/widgets/player.py:198
msgid "Toggle floating window"
msgstr ""

#: ../melon/widgets/player.py:184
#: ../melon/widgets/player.py:204
msgid "Toggle fullscreen"
msgstr ""

#: ../melon/widgets/player.py:204 ../melon/widgets/player.py:565
#: ../melon/widgets/player.py:571
#: ../melon/widgets/player.py:226 ../melon/widgets/player.py:613
#: ../melon/widgets/player.py:622
msgid "Play"
msgstr ""

#: ../melon/widgets/player.py:230
#: ../melon/widgets/player.py:253
msgid "Stream options"
msgstr "تنظیمات استریم"

#: ../melon/widgets/player.py:442
#: ../melon/widgets/player.py:483
msgid "Resolution"
msgstr "وضوح"

#: ../melon/widgets/player.py:559
#: ../melon/widgets/player.py:604
msgid "Pause"
msgstr ""

#: ../melon/widgets/player.py:589
#: ../melon/widgets/player.py:642
msgid "The video is playing in separate window"
msgstr ""

#: ../melon/widgets/player.py:710
#: ../melon/widgets/player.py:779
msgid "Player"
msgstr "پخش‌کننده"

#: ../melon/widgets/preferencerow.py:116
#: ../melon/widgets/preferencerow.py:125
msgid "Add"
msgstr "افزودن"

#: ../melon/widgets/preferencerow.py:130
#: ../melon/widgets/preferencerow.py:139
msgid "Move up"
msgstr "انتقال به بالا"

#: ../melon/widgets/preferencerow.py:141
#: ../melon/widgets/preferencerow.py:147
msgid "Move down"
msgstr "انتقال به پایین"

#: ../melon/widgets/preferencerow.py:150
#: ../melon/widgets/preferencerow.py:153
msgid "Remove from list"
msgstr "حذف از لیست"

#: ../melon/widgets/preferencerow.py:163
#: ../melon/widgets/preferencerow.py:168
msgid "Add Item"
msgstr "افزودن مورد"

#: ../melon/widgets/preferencerow.py:166
#: ../melon/widgets/preferencerow.py:171
msgid "Create a new list entry"
msgstr "ایجاد یک ورودی لیست جدید"

#: ../melon/widgets/preferencerow.py:167
#: ../melon/widgets/preferencerow.py:172
msgid "Enter the new value here"
msgstr "مقادیر را اینجا وارد کنید."

#: ../melon/widgets/preferencerow.py:170
#: ../melon/widgets/preferencerow.py:175
msgid "Value"
msgstr "مقدار"

#: ../melon/widgets/preferencerow.py:210
#: ../melon/widgets/preferencerow.py:217
msgid "Do you really want to delete this item?"
msgstr "آیا از حذف این مورد اطمینان دارید؟"

#: ../melon/widgets/preferencerow.py:211
#: ../melon/widgets/preferencerow.py:218
msgid "You won't be able to restore it afterwards"
msgstr ""

#: ../melon/widgets/preferencerow.py:218
#: ../melon/widgets/preferencerow.py:226
msgid "Do not remove item"
msgstr "این مورد را حذف نکن."

#: ../melon/widgets/preferencerow.py:219
#: ../melon/widgets/preferencerow.py:229
msgid "Remove item from list"
msgstr "حذف این مورد از لیست"

#: ../melon/window.py:84
#: ../melon/window.py:94
msgid "Stream videos on the go"
msgstr "ویدیو‌هارا استریم کن"


M po/melon.pot => po/melon.pot +193 -193
@@ 8,7 8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: Melon 0.2.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-06-12 21:13+0200\n"
"POT-Creation-Date: 2024-07-15 07:54+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"


@@ 79,737 79,737 @@ msgid ""
"button"
msgstr ""

#: ../melon/browse/__init__.py:18 ../melon/browse/search.py:58
#: ../melon/browse/server.py:101 ../melon/browse/server.py:127
#: ../melon/home/history.py:23 ../melon/home/new.py:29
#: ../melon/home/playlists.py:21 ../melon/home/subs.py:20
#: ../melon/importer.py:61 ../melon/player/__init__.py:102
#: ../melon/player/playlist.py:158 ../melon/player/playlist.py:165
#: ../melon/playlist/__init__.py:61
#: ../melon/browse/__init__.py:20 ../melon/browse/search.py:66
#: ../melon/browse/server.py:115 ../melon/browse/server.py:143
#: ../melon/home/history.py:25 ../melon/home/new.py:36
#: ../melon/home/playlists.py:23 ../melon/home/subs.py:22
#: ../melon/importer.py:63 ../melon/player/__init__.py:112
#: ../melon/player/playlist.py:164 ../melon/player/playlist.py:171
#: ../melon/playlist/__init__.py:77
msgid "*crickets chirping*"
msgstr ""

#: ../melon/browse/__init__.py:19
#: ../melon/browse/__init__.py:21
msgid "There are no available servers"
msgstr ""

#: ../melon/browse/__init__.py:21 ../melon/browse/search.py:61
#: ../melon/importer.py:64
#: ../melon/browse/__init__.py:24 ../melon/browse/search.py:72
#: ../melon/importer.py:67
msgid "Enable servers in the settings menu"
msgstr ""

#: ../melon/browse/__init__.py:29
#: ../melon/browse/__init__.py:33
msgid "Available Servers"
msgstr ""

#: ../melon/browse/__init__.py:30
#: ../melon/browse/__init__.py:35
msgid "You can enable/disable and filter servers in the settings menu"
msgstr ""

#: ../melon/browse/__init__.py:48
#: ../melon/browse/__init__.py:56
msgid "Servers"
msgstr ""

#: ../melon/browse/__init__.py:56 ../melon/browse/search.py:106
#: ../melon/browse/__init__.py:64 ../melon/browse/search.py:126
msgid "Global Search"
msgstr ""

#: ../melon/browse/channel.py:93
#: ../melon/browse/channel.py:104
msgid "Subscribe to channel"
msgstr ""

#: ../melon/browse/channel.py:94
#: ../melon/browse/channel.py:105
msgid "Add latest uploads to home feed"
msgstr ""

#: ../melon/browse/channel.py:106
#: ../melon/browse/channel.py:117
msgid "Channel feed"
msgstr ""

#: ../melon/browse/channel.py:107
#: ../melon/browse/channel.py:118
msgid "This channel provides multiple feeds, choose which one to view"
msgstr ""

#: ../melon/browse/channel.py:159
#: ../melon/browse/channel.py:170
msgid "Channel"
msgstr ""

#: ../melon/browse/playlist.py:37 ../melon/playlist/__init__.py:53
#: ../melon/browse/playlist.py:48 ../melon/playlist/__init__.py:69
msgid "Start playing"
msgstr ""

#: ../melon/browse/playlist.py:56 ../melon/player/__init__.py:44
#: ../melon/browse/playlist.py:69 ../melon/player/__init__.py:53
msgid "Bookmark"
msgstr ""

#: ../melon/browse/playlist.py:57
#: ../melon/browse/playlist.py:70
msgid "Add Playlist to your local playlist collection"
msgstr ""

#: ../melon/browse/playlist.py:111
#: ../melon/browse/playlist.py:124
msgid "Playlist"
msgstr ""

#: ../melon/browse/search.py:44
#: ../melon/browse/search.py:49
msgid "No results"
msgstr ""

#: ../melon/browse/search.py:46
#: ../melon/browse/search.py:52
#, python-brace-format
msgid "{count} result"
msgid_plural "{count} results"
msgstr[0] ""
msgstr[1] ""

#: ../melon/browse/search.py:59
#: ../melon/browse/search.py:68
msgid "There are no available servers, a search would yield no results"
msgstr ""

#: ../melon/browse/search.py:83 ../melon/browse/server.py:43
#: ../melon/browse/search.py:96 ../melon/browse/server.py:47
msgid "Any"
msgstr ""

#: ../melon/browse/search.py:85 ../melon/browse/server.py:45
#: ../melon/browse/search.py:100 ../melon/browse/server.py:51
msgid "Channels"
msgstr ""

#: ../melon/browse/search.py:88 ../melon/browse/server.py:48
#: ../melon/home/playlists.py:32 ../melon/home/playlists.py:64
#: ../melon/servers/nebula/__init__.py:194
#: ../melon/servers/peertube/__init__.py:205
#: ../melon/browse/search.py:105 ../melon/browse/server.py:56
#: ../melon/home/playlists.py:34 ../melon/home/playlists.py:78
#: ../melon/servers/nebula/__init__.py:206
#: ../melon/servers/peertube/__init__.py:213
msgid "Playlists"
msgstr ""

#: ../melon/browse/search.py:91 ../melon/browse/server.py:51
#: ../melon/servers/nebula/__init__.py:188
#: ../melon/servers/peertube/__init__.py:204
#: ../melon/servers/peertube/__init__.py:208
#: ../melon/browse/search.py:110 ../melon/browse/server.py:61
#: ../melon/servers/nebula/__init__.py:200
#: ../melon/servers/peertube/__init__.py:213
#: ../melon/servers/peertube/__init__.py:214
msgid "Videos"
msgstr ""

#: ../melon/browse/server.py:23
#: ../melon/browse/server.py:26
msgid "Search"
msgstr ""

#: ../melon/browse/server.py:102
#: ../melon/browse/server.py:116
msgid "Try searching for a term"
msgstr ""

#: ../melon/browse/server.py:104
#: ../melon/browse/server.py:118
msgid "Try using a different query"
msgstr ""

#: ../melon/browse/server.py:128
#: ../melon/browse/server.py:144
msgid "This feed is empty"
msgstr ""

#: ../melon/home/__init__.py:18
#: ../melon/home/__init__.py:20
msgid "Home"
msgstr ""

#: ../melon/home/__init__.py:39 ../melon/home/history.py:26
#: ../melon/home/new.py:36 ../melon/home/subs.py:23
#: ../melon/home/__init__.py:41 ../melon/home/history.py:28
#: ../melon/home/new.py:49 ../melon/home/subs.py:25
msgid "Browse Servers"
msgstr ""

#: ../melon/home/__init__.py:46
#: ../melon/home/__init__.py:48
msgid "Preferences"
msgstr ""

#: ../melon/home/__init__.py:47
#: ../melon/home/__init__.py:49
msgid "Import Data"
msgstr ""

#: ../melon/home/__init__.py:48
#: ../melon/home/__init__.py:50
msgid "About Melon"
msgstr ""

#: ../melon/home/history.py:24
#: ../melon/home/history.py:26
msgid "You haven't watched any videos yet"
msgstr ""

#: ../melon/home/history.py:36 ../melon/home/history.py:117
#: ../melon/home/history.py:38 ../melon/home/history.py:125
msgid "History"
msgstr ""

#: ../melon/home/history.py:37
#: ../melon/home/history.py:39
msgid "These are the videos you opened in the past"
msgstr ""

#: ../melon/home/history.py:76
#: ../melon/home/history.py:83
msgid "Show more"
msgstr ""

#: ../melon/home/history.py:77
#: ../melon/home/history.py:84
msgid "Load older videos"
msgstr ""

#: ../melon/home/new.py:23
#: ../melon/home/new.py:30
msgid "Refresh"
msgstr ""

#: ../melon/home/new.py:32
#: ../melon/home/new.py:40
msgid "Subscribe to a channel first, to view new uploads"
msgstr ""

#: ../melon/home/new.py:34
#: ../melon/home/new.py:45
msgid "The channels you are subscribed to haven't uploaded anything yet"
msgstr ""

#: ../melon/home/new.py:55
#: ../melon/home/new.py:68
#, python-brace-format
msgid "(Last refresh: {last_refresh})"
msgstr ""

#: ../melon/home/new.py:57 ../melon/home/new.py:146
#: ../melon/home/new.py:72 ../melon/home/new.py:172
msgid "What's new"
msgstr ""

#: ../melon/home/new.py:58
#: ../melon/home/new.py:74
msgid "These are the latest videos of channels you follow"
msgstr ""

#: ../melon/home/new.py:137
#: ../melon/home/new.py:162
msgid "Pick up where you left off"
msgstr ""

#: ../melon/home/new.py:138
#: ../melon/home/new.py:163
msgid "Watch"
msgstr ""

#: ../melon/home/playlists.py:22
#: ../melon/home/playlists.py:24
msgid "You don't have any playlists yet"
msgstr ""

#: ../melon/home/playlists.py:24 ../melon/home/playlists.py:34
#: ../melon/home/playlists.py:26 ../melon/home/playlists.py:39
msgid "Create a new playlist"
msgstr ""

#: ../melon/home/playlists.py:33
#: ../melon/home/playlists.py:36
msgid "Here are playlists you've bookmarked or created yourself"
msgstr ""

#: ../melon/home/playlists.py:34
#: ../melon/home/playlists.py:39
msgid "New"
msgstr ""

#: ../melon/home/subs.py:21
#: ../melon/home/subs.py:23
msgid "You aren't yet subscribed to channels"
msgstr ""

#: ../melon/home/subs.py:31 ../melon/home/subs.py:63
#: ../melon/home/subs.py:33 ../melon/home/subs.py:68
msgid "Subscriptions"
msgstr ""

#: ../melon/home/subs.py:32
#: ../melon/home/subs.py:34
msgid "You are subscribed to the following channels"
msgstr ""

#: ../melon/import_providers/newpipe.py:28
#: ../melon/import_providers/newpipe.py:42
msgid "Newpipe Database importer"
msgstr ""

#: ../melon/import_providers/newpipe.py:29
#: ../melon/import_providers/newpipe.py:44
msgid ""
"Import the .db file from inside the newpipe .zip export (as invidious "
"content)"
msgstr ""

#: ../melon/import_providers/newpipe.py:30
#: ../melon/import_providers/newpipe.py:46
msgid "Select .db file"
msgstr ""

#: ../melon/import_providers/newpipe.py:45
#: ../melon/import_providers/newpipe.py:60
msgid "Newpipe Database"
msgstr ""

#: ../melon/importer.py:17 ../melon/importer.py:35
#: ../melon/importer.py:19 ../melon/importer.py:37
msgid "Import"
msgstr ""

#: ../melon/importer.py:36
#: ../melon/importer.py:38
msgid "The following import methods have been found"
msgstr ""

#: ../melon/importer.py:62
#: ../melon/importer.py:64
msgid "There are no available importer methods"
msgstr ""

#: ../melon/player/__init__.py:34
#: ../melon/player/__init__.py:41
msgid "Description"
msgstr ""

#: ../melon/player/__init__.py:45
#: ../melon/player/__init__.py:54
msgid "Add this video to a playlist"
msgstr ""

#: ../melon/player/__init__.py:103
#: ../melon/player/__init__.py:113
msgid "Video could not be loaded"
msgstr ""

#: ../melon/player/__init__.py:140 ../melon/player/__init__.py:174
#: ../melon/player/playlist.py:51
#: ../melon/player/__init__.py:151 ../melon/player/__init__.py:185
#: ../melon/player/playlist.py:57
msgid "Loading..."
msgstr ""

#: ../melon/player/playlist.py:159
#: ../melon/player/playlist.py:165
msgid "This playlist is empty"
msgstr ""

#: ../melon/player/playlist.py:166
#: ../melon/player/playlist.py:172
msgid "There was an error loading the playlist"
msgstr ""

#: ../melon/player/playlist.py:218
#: ../melon/player/playlist.py:225
msgid "Previous"
msgstr ""

#: ../melon/player/playlist.py:219
#: ../melon/player/playlist.py:227
msgid "Play video that comes before this one in the playlist"
msgstr ""

#: ../melon/player/playlist.py:230
#: ../melon/player/playlist.py:244
msgid "Next"
msgstr ""

#: ../melon/player/playlist.py:231
#: ../melon/player/playlist.py:246
msgid "Play video that comes after this one in the playlist"
msgstr ""

#: ../melon/player/playlist.py:240
#: ../melon/player/playlist.py:258
msgid "Skip"
msgstr ""

#: ../melon/player/playlist.py:241
#: ../melon/player/playlist.py:259
msgid "Skip this video and pick a new one at random"
msgstr ""

#: ../melon/player/playlist.py:250
#: ../melon/player/playlist.py:270
msgid "Shuffle"
msgstr ""

#: ../melon/player/playlist.py:251
#: ../melon/player/playlist.py:271
msgid "Chooses the next video at random"
msgstr ""

#: ../melon/player/playlist.py:261
#: ../melon/player/playlist.py:282
msgid "Repeat current video"
msgstr ""

#: ../melon/player/playlist.py:262
#: ../melon/player/playlist.py:283
msgid "Puts this video on loop"
msgstr ""

#: ../melon/player/playlist.py:276
#: ../melon/player/playlist.py:298
msgid "Repeat playlist"
msgstr ""

#: ../melon/player/playlist.py:277
#: ../melon/player/playlist.py:300
msgid "Start playling the playlist from the beginning after reaching the end"
msgstr ""

#: ../melon/player/playlist.py:287
#: ../melon/player/playlist.py:312
msgid "Playlist Content"
msgstr ""

#: ../melon/player/playlist.py:288
#: ../melon/player/playlist.py:314
msgid "Click on videos to continue playing the playlist from there"
msgstr ""

#: ../melon/playlist/__init__.py:50
#: ../melon/playlist/__init__.py:65
msgid "Edit"
msgstr ""

#: ../melon/playlist/__init__.py:62
#: ../melon/playlist/__init__.py:79
msgid "You haven't added any videos to this playlist yet"
msgstr ""

#: ../melon/playlist/__init__.py:64
#: ../melon/playlist/__init__.py:82
msgid "Start watching"
msgstr ""

#: ../melon/playlist/__init__.py:87
#: ../melon/playlist/__init__.py:106
msgid "Set as playlist thumbnail"
msgstr ""

#: ../melon/playlist/__init__.py:88
#: ../melon/playlist/__init__.py:114
msgid "Remove from playlist"
msgstr ""

#: ../melon/playlist/__init__.py:99
#: ../melon/playlist/__init__.py:131
msgid "Edit Playlist"
msgstr ""

#: ../melon/playlist/__init__.py:105
#: ../melon/playlist/__init__.py:137
msgid "Playlist details"
msgstr ""

#: ../melon/playlist/__init__.py:106
#: ../melon/playlist/__init__.py:138
msgid "Change playlist information"
msgstr ""

#: ../melon/playlist/__init__.py:108 ../melon/playlist/create.py:37
#: ../melon/playlist/__init__.py:140 ../melon/playlist/create.py:43
msgid "Playlist name"
msgstr ""

#: ../melon/playlist/__init__.py:111 ../melon/playlist/create.py:40
#: ../melon/playlist/__init__.py:143 ../melon/playlist/create.py:46
msgid "Playlist description"
msgstr ""

#: ../melon/playlist/__init__.py:116
#: ../melon/playlist/__init__.py:148
msgid "Save details"
msgstr ""

#: ../melon/playlist/__init__.py:117
#: ../melon/playlist/__init__.py:150
msgid "Change playlist title and description. (Closes the dialog)"
msgstr ""

#: ../melon/playlist/__init__.py:127 ../melon/playlist/__init__.py:169
#: ../melon/playlist/__init__.py:161 ../melon/playlist/__init__.py:207
msgid "Delete Playlist"
msgstr ""

#: ../melon/playlist/__init__.py:128
#: ../melon/playlist/__init__.py:163
msgid "Delete this playlist and it's content. This can NOT be undone."
msgstr ""

#: ../melon/playlist/__init__.py:139
#: ../melon/playlist/__init__.py:176
msgid "Close"
msgstr ""

#: ../melon/playlist/__init__.py:139
#: ../melon/playlist/__init__.py:178
msgid "Close without changing anything"
msgstr ""

#: ../melon/playlist/__init__.py:172
#: ../melon/playlist/__init__.py:210
msgid "Do you really want to delete this playlist?"
msgstr ""

#: ../melon/playlist/__init__.py:173
#: ../melon/playlist/__init__.py:211
msgid "This cannot be undone"
msgstr ""

#: ../melon/playlist/__init__.py:177 ../melon/playlist/create.py:47
#: ../melon/playlist/pick.py:70 ../melon/widgets/preferencerow.py:175
#: ../melon/widgets/preferencerow.py:218
#: ../melon/playlist/__init__.py:216 ../melon/playlist/create.py:54
#: ../melon/playlist/pick.py:88 ../melon/widgets/preferencerow.py:181
#: ../melon/widgets/preferencerow.py:226
msgid "Cancel"
msgstr ""

#: ../melon/playlist/__init__.py:177
#: ../melon/playlist/__init__.py:218
msgid "Do not delete the playlist"
msgstr ""

#: ../melon/playlist/__init__.py:178 ../melon/widgets/preferencerow.py:207
#: ../melon/widgets/preferencerow.py:219
#: ../melon/playlist/__init__.py:221 ../melon/widgets/preferencerow.py:214
#: ../melon/widgets/preferencerow.py:229
msgid "Delete"
msgstr ""

#: ../melon/playlist/__init__.py:178
#: ../melon/playlist/__init__.py:221
msgid "Delete this playlist"
msgstr ""

#: ../melon/playlist/create.py:23 ../melon/playlist/pick.py:28
#: ../melon/playlist/create.py:25 ../melon/playlist/pick.py:35
msgid "Video"
msgstr ""

#: ../melon/playlist/create.py:24
#: ../melon/playlist/create.py:27
msgid "The following video will be added to the new playlist"
msgstr ""

#: ../melon/playlist/create.py:33 ../melon/playlist/create.py:91
#: ../melon/playlist/create.py:39 ../melon/playlist/create.py:98
msgid "New Playlist"
msgstr ""

#: ../melon/playlist/create.py:34
#: ../melon/playlist/create.py:40
msgid "Enter more playlist information"
msgstr ""

#: ../melon/playlist/create.py:38
#: ../melon/playlist/create.py:44
msgid "Unnamed Playlist"
msgstr ""

#: ../melon/playlist/create.py:47 ../melon/playlist/pick.py:70
#: ../melon/playlist/create.py:54 ../melon/playlist/pick.py:88
msgid "Do not create playlist"
msgstr ""

#: ../melon/playlist/create.py:48 ../melon/widgets/preferencerow.py:176
#: ../melon/playlist/create.py:57 ../melon/widgets/preferencerow.py:184
msgid "Create"
msgstr ""

#: ../melon/playlist/create.py:48
#: ../melon/playlist/create.py:57
msgid "Create playlist"
msgstr ""

#: ../melon/playlist/pick.py:29
#: ../melon/playlist/pick.py:37
msgid "The following video will be added to the playlist"
msgstr ""

#: ../melon/playlist/pick.py:39 ../melon/widgets/feeditem.py:122
#: ../melon/playlist/pick.py:50 ../melon/widgets/feeditem.py:145
msgid "Add to playlist"
msgstr ""

#: ../melon/playlist/pick.py:40
#: ../melon/playlist/pick.py:53
msgid ""
"Choose a playlist to add the video to. Note that you can only add videos to "
"local playlists, not external bookmarked ones"
msgstr ""

#: ../melon/playlist/pick.py:42
#: ../melon/playlist/pick.py:57
msgid "Create new playlist"
msgstr ""

#: ../melon/playlist/pick.py:43
#: ../melon/playlist/pick.py:58
msgid "Create a new playlist and add the video to it"
msgstr ""

#: ../melon/playlist/pick.py:102
#: ../melon/playlist/pick.py:119
msgid "Add to Playlist"
msgstr ""

#: ../melon/servers/invidious/__init__.py:31
#: ../melon/servers/invidious/__init__.py:35
msgid "Open source alternative front-end to YouTube"
msgstr ""

#: ../melon/servers/invidious/__init__.py:36
#: ../melon/servers/invidious/__init__.py:40
msgid "Instance"
msgstr ""

#: ../melon/servers/invidious/__init__.py:37
#: ../melon/servers/invidious/__init__.py:42
msgid ""
"See https://docs.invidious.io/instances/ for a list of available instances"
msgstr ""

#: ../melon/servers/invidious/__init__.py:48
#: ../melon/servers/invidious/__init__.py:55
msgid "Trending"
msgstr ""

#: ../melon/servers/invidious/__init__.py:49
#: ../melon/servers/invidious/__init__.py:56
msgid "Popular"
msgstr ""

#: ../melon/servers/nebula/__init__.py:19
#: ../melon/servers/nebula/__init__.py:21
msgid ""
"Home of smart, thoughtful videos, podcasts, and classes from your favorite "
"creators"
msgstr ""

#: ../melon/servers/nebula/__init__.py:24
#: ../melon/servers/nebula/__init__.py:27
msgid "Email Address"
msgstr ""

#: ../melon/servers/nebula/__init__.py:25
#: ../melon/servers/nebula/__init__.py:28
msgid "Email Address to login to your account"
msgstr ""

#: ../melon/servers/nebula/__init__.py:31
#: ../melon/servers/nebula/__init__.py:35
msgid "Password"
msgstr ""

#: ../melon/servers/nebula/__init__.py:32
#: ../melon/servers/nebula/__init__.py:36
msgid "Password associated with your account"
msgstr ""

#: ../melon/servers/peertube/__init__.py:15
#: ../melon/servers/peertube/__init__.py:16
msgid "Decentralized video hosting network, based on free/libre software"
msgstr ""

#: ../melon/servers/peertube/__init__.py:20
#: ../melon/servers/peertube/__init__.py:21
msgid "Instances"
msgstr ""

#: ../melon/servers/peertube/__init__.py:21
#: ../melon/servers/peertube/__init__.py:23
msgid ""
"List of peertube instances, from which to fetch content. See https://"
"joinpeertube.org/instances"
msgstr ""

#: ../melon/servers/peertube/__init__.py:27
#: ../melon/servers/peertube/__init__.py:31
msgid "Show NSFW content"
msgstr ""

#: ../melon/servers/peertube/__init__.py:28
#: ../melon/servers/peertube/__init__.py:32
msgid "Passes the nsfw filter to the peertube search API"
msgstr ""

#: ../melon/servers/peertube/__init__.py:33
#: ../melon/servers/peertube/__init__.py:39
msgid "Enable Federation"
msgstr ""

#: ../melon/servers/peertube/__init__.py:34
#: ../melon/servers/peertube/__init__.py:40
msgid "Returns content from federated instances instead of only local content"
msgstr ""

#: ../melon/servers/peertube/__init__.py:58
#: ../melon/servers/peertube/__init__.py:65
msgid "Latest"
msgstr ""

#: ../melon/settings/__init__.py:18
#: ../melon/settings/__init__.py:23
msgid "Show Previews when browsing public feeds (incl. search)"
msgstr ""

#: ../melon/settings/__init__.py:19
#: ../melon/settings/__init__.py:25
msgid ""
"Set to true to show previews when viewing channel contents, public feeds and "
"searching"
msgstr ""

#: ../melon/settings/__init__.py:25
#: ../melon/settings/__init__.py:34
msgid "Show Previews in local feeds"
msgstr ""

#: ../melon/settings/__init__.py:26
#: ../melon/settings/__init__.py:36
msgid ""
"Set to true to show previews in the new feed, for subscribed channels, and "
"saved playlists"
msgstr ""

#: ../melon/settings/__init__.py:32
#: ../melon/settings/__init__.py:44
msgid "Show servers that may contain nsfw content"
msgstr ""

#: ../melon/settings/__init__.py:33
#: ../melon/settings/__init__.py:46
msgid ""
"Lists/Delists servers in the browse servers list, if they contain some nsfw "
"content"
msgstr ""

#: ../melon/settings/__init__.py:38
#: ../melon/settings/__init__.py:54
msgid "Show servers that only contain nsfw content"
msgstr ""

#: ../melon/settings/__init__.py:39
#: ../melon/settings/__init__.py:56
msgid ""
"Lists/Delists servers in the browse servers list, if they contain only/"
"mostly nsfw content"
msgstr ""

#: ../melon/settings/__init__.py:45
#: ../melon/settings/__init__.py:64
msgid "Show servers that require login"
msgstr ""

#: ../melon/settings/__init__.py:46
#: ../melon/settings/__init__.py:66
msgid ""
"Lists/Delists servers in the browse servers list, if they require login to "
"function"
msgstr ""

#: ../melon/settings/__init__.py:55
#: ../melon/settings/__init__.py:78
msgid "Settings"
msgstr ""

#: ../melon/settings/__init__.py:68
#: ../melon/settings/__init__.py:91
msgid "General"
msgstr ""

#: ../melon/settings/__init__.py:69
#: ../melon/settings/__init__.py:92
msgid "Global app settings"
msgstr ""

#: ../melon/settings/__init__.py:93
#: ../melon/settings/__init__.py:116
msgid "Enable Server"
msgstr ""

#: ../melon/settings/__init__.py:94
#: ../melon/settings/__init__.py:118
msgid ""
"Disabled servers won't show up in the browser or on the local/home screen"
msgstr ""

#: ../melon/widgets/feeditem.py:121
#: ../melon/widgets/feeditem.py:139
msgid "Watch now"
msgstr ""

#: ../melon/widgets/feeditem.py:123
#: ../melon/widgets/feeditem.py:152
msgid "Open in browser"
msgstr ""

#: ../melon/widgets/feeditem.py:124
#: ../melon/widgets/feeditem.py:156
msgid "View channel"
msgstr ""

#: ../melon/widgets/player.py:119
#: ../melon/widgets/player.py:136
msgid "No streams available"
msgstr ""

#: ../melon/widgets/player.py:180
#: ../melon/widgets/player.py:198
msgid "Toggle floating window"
msgstr ""

#: ../melon/widgets/player.py:184
#: ../melon/widgets/player.py:204
msgid "Toggle fullscreen"
msgstr ""

#: ../melon/widgets/player.py:204 ../melon/widgets/player.py:565
#: ../melon/widgets/player.py:571
#: ../melon/widgets/player.py:226 ../melon/widgets/player.py:613
#: ../melon/widgets/player.py:622
msgid "Play"
msgstr ""

#: ../melon/widgets/player.py:230
#: ../melon/widgets/player.py:253
msgid "Stream options"
msgstr ""

#: ../melon/widgets/player.py:442
#: ../melon/widgets/player.py:483
msgid "Resolution"
msgstr ""

#: ../melon/widgets/player.py:559
#: ../melon/widgets/player.py:604
msgid "Pause"
msgstr ""

#: ../melon/widgets/player.py:589
#: ../melon/widgets/player.py:642
msgid "The video is playing in separate window"
msgstr ""

#: ../melon/widgets/player.py:710
#: ../melon/widgets/player.py:779
msgid "Player"
msgstr ""

#: ../melon/widgets/preferencerow.py:116
#: ../melon/widgets/preferencerow.py:125
msgid "Add"
msgstr ""

#: ../melon/widgets/preferencerow.py:130
#: ../melon/widgets/preferencerow.py:139
msgid "Move up"
msgstr ""

#: ../melon/widgets/preferencerow.py:141
#: ../melon/widgets/preferencerow.py:147
msgid "Move down"
msgstr ""

#: ../melon/widgets/preferencerow.py:150
#: ../melon/widgets/preferencerow.py:153
msgid "Remove from list"
msgstr ""

#: ../melon/widgets/preferencerow.py:163
#: ../melon/widgets/preferencerow.py:168
msgid "Add Item"
msgstr ""

#: ../melon/widgets/preferencerow.py:166
#: ../melon/widgets/preferencerow.py:171
msgid "Create a new list entry"
msgstr ""

#: ../melon/widgets/preferencerow.py:167
#: ../melon/widgets/preferencerow.py:172
msgid "Enter the new value here"
msgstr ""

#: ../melon/widgets/preferencerow.py:170
#: ../melon/widgets/preferencerow.py:175
msgid "Value"
msgstr ""

#: ../melon/widgets/preferencerow.py:210
#: ../melon/widgets/preferencerow.py:217
msgid "Do you really want to delete this item?"
msgstr ""

#: ../melon/widgets/preferencerow.py:211
#: ../melon/widgets/preferencerow.py:218
msgid "You won't be able to restore it afterwards"
msgstr ""

#: ../melon/widgets/preferencerow.py:218
#: ../melon/widgets/preferencerow.py:226
msgid "Do not remove item"
msgstr ""

#: ../melon/widgets/preferencerow.py:219
#: ../melon/widgets/preferencerow.py:229
msgid "Remove item from list"
msgstr ""

#: ../melon/window.py:84
#: ../melon/window.py:94
msgid "Stream videos on the go"
msgstr ""

M po/nl.po => po/nl.po +196 -196
@@ 7,11 7,11 @@ msgid ""
msgstr ""
"Project-Id-Version: Melon 0.2.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-06-12 21:13+0200\n"
"PO-Revision-Date: 2024-06-14 10:18+0000\n"
"POT-Creation-Date: 2024-07-15 07:54+0200\n"
"PO-Revision-Date: 2024-04-23 12:18+0000\n"
"Last-Translator: Vistaus <Vistaus@users.noreply.translate.codeberg.org>\n"
"Language-Team: Dutch <https://translate.codeberg.org/projects/melon/"
"melon-app/nl/>\n"
"Language-Team: Dutch <https://translate.codeberg.org/projects/melon/melon-"
"app/nl/>\n"
"Language: nl\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"


@@ 92,239 92,239 @@ msgid ""
msgstr ""
"Het videoscherm met de videospeler, metagegevens en favorietenknop erboven"

#: ../melon/browse/__init__.py:18 ../melon/browse/search.py:58
#: ../melon/browse/server.py:101 ../melon/browse/server.py:127
#: ../melon/home/history.py:23 ../melon/home/new.py:29
#: ../melon/home/playlists.py:21 ../melon/home/subs.py:20
#: ../melon/importer.py:61 ../melon/player/__init__.py:102
#: ../melon/player/playlist.py:158 ../melon/player/playlist.py:165
#: ../melon/playlist/__init__.py:61
#: ../melon/browse/__init__.py:20 ../melon/browse/search.py:66
#: ../melon/browse/server.py:115 ../melon/browse/server.py:143
#: ../melon/home/history.py:25 ../melon/home/new.py:36
#: ../melon/home/playlists.py:23 ../melon/home/subs.py:22
#: ../melon/importer.py:63 ../melon/player/__init__.py:112
#: ../melon/player/playlist.py:164 ../melon/player/playlist.py:171
#: ../melon/playlist/__init__.py:77
msgid "*crickets chirping*"
msgstr "*tsjilpende krekels*"

#: ../melon/browse/__init__.py:19
#: ../melon/browse/__init__.py:21
msgid "There are no available servers"
msgstr "Er zijn geen servers beschikbaar"

#: ../melon/browse/__init__.py:21 ../melon/browse/search.py:61
#: ../melon/importer.py:64
#: ../melon/browse/__init__.py:24 ../melon/browse/search.py:72
#: ../melon/importer.py:67
msgid "Enable servers in the settings menu"
msgstr "Schakel servers in in het voorkeurenmenu"

#: ../melon/browse/__init__.py:29
#: ../melon/browse/__init__.py:33
msgid "Available Servers"
msgstr "Beschikbare servers"

#: ../melon/browse/__init__.py:30
#: ../melon/browse/__init__.py:35
msgid "You can enable/disable and filter servers in the settings menu"
msgstr "U kunt servers in- en uitschakelen en filteren in het voorkeurenmenu"

#: ../melon/browse/__init__.py:48
#: ../melon/browse/__init__.py:56
msgid "Servers"
msgstr "Servers"

#: ../melon/browse/__init__.py:56 ../melon/browse/search.py:106
#: ../melon/browse/__init__.py:64 ../melon/browse/search.py:126
msgid "Global Search"
msgstr "Globaal zoeken"

#: ../melon/browse/channel.py:93
#: ../melon/browse/channel.py:104
msgid "Subscribe to channel"
msgstr "Abonneren op kanaal"

#: ../melon/browse/channel.py:94
#: ../melon/browse/channel.py:105
msgid "Add latest uploads to home feed"
msgstr "Recentste uploads toevoegen aan tijdlijn"

#: ../melon/browse/channel.py:106
#: ../melon/browse/channel.py:117
msgid "Channel feed"
msgstr "Kanaaltijdlijn"

#: ../melon/browse/channel.py:107
#: ../melon/browse/channel.py:118
msgid "This channel provides multiple feeds, choose which one to view"
msgstr "Dit kanaal beschikt over meerdere tijdlijnen - kies er een"

#: ../melon/browse/channel.py:159
#: ../melon/browse/channel.py:170
msgid "Channel"
msgstr "Kanaal"

#: ../melon/browse/playlist.py:37 ../melon/playlist/__init__.py:53
#: ../melon/browse/playlist.py:48 ../melon/playlist/__init__.py:69
msgid "Start playing"
msgstr "Afspelen starten"

#: ../melon/browse/playlist.py:56 ../melon/player/__init__.py:44
#: ../melon/browse/playlist.py:69 ../melon/player/__init__.py:53
msgid "Bookmark"
msgstr "Toev. aan favorieten"

#: ../melon/browse/playlist.py:57
#: ../melon/browse/playlist.py:70
msgid "Add Playlist to your local playlist collection"
msgstr "Afspeellijst toevoegen aan lokale verzameling"

#: ../melon/browse/playlist.py:111
#: ../melon/browse/playlist.py:124
msgid "Playlist"
msgstr "Afspeellijst"

#: ../melon/browse/search.py:44
#: ../melon/browse/search.py:49
msgid "No results"
msgstr "Er zijn geen zoekresultaten"

#: ../melon/browse/search.py:46
#: ../melon/browse/search.py:52
#, python-brace-format
msgid "{count} result"
msgid_plural "{count} results"
msgstr[0] "{count} resultaat"
msgstr[1] "{count} resultaten"

#: ../melon/browse/search.py:59
#: ../melon/browse/search.py:68
msgid "There are no available servers, a search would yield no results"
msgstr ""
"Er zijn geen servers beschikbaar, dus een zoekopdracht zou geen resultaten "
"geven"

#: ../melon/browse/search.py:83 ../melon/browse/server.py:43
#: ../melon/browse/search.py:96 ../melon/browse/server.py:47
msgid "Any"
msgstr "Iedere"

#: ../melon/browse/search.py:85 ../melon/browse/server.py:45
#: ../melon/browse/search.py:100 ../melon/browse/server.py:51
msgid "Channels"
msgstr "Kanalen"

#: ../melon/browse/search.py:88 ../melon/browse/server.py:48
#: ../melon/home/playlists.py:32 ../melon/home/playlists.py:64
#: ../melon/servers/nebula/__init__.py:194
#: ../melon/servers/peertube/__init__.py:205
#: ../melon/browse/search.py:105 ../melon/browse/server.py:56
#: ../melon/home/playlists.py:34 ../melon/home/playlists.py:78
#: ../melon/servers/nebula/__init__.py:206
#: ../melon/servers/peertube/__init__.py:213
msgid "Playlists"
msgstr "Afspeellijsten"

#: ../melon/browse/search.py:91 ../melon/browse/server.py:51
#: ../melon/servers/nebula/__init__.py:188
#: ../melon/servers/peertube/__init__.py:204
#: ../melon/servers/peertube/__init__.py:208
#: ../melon/browse/search.py:110 ../melon/browse/server.py:61
#: ../melon/servers/nebula/__init__.py:200
#: ../melon/servers/peertube/__init__.py:213
#: ../melon/servers/peertube/__init__.py:214
msgid "Videos"
msgstr "Video's"

#: ../melon/browse/server.py:23
#: ../melon/browse/server.py:26
msgid "Search"
msgstr "Zoeken"

#: ../melon/browse/server.py:102
#: ../melon/browse/server.py:116
msgid "Try searching for a term"
msgstr "Voer een zoekopdracht in"

#: ../melon/browse/server.py:104
#: ../melon/browse/server.py:118
msgid "Try using a different query"
msgstr "Probeer een andere zoekopdracht"

#: ../melon/browse/server.py:128
#: ../melon/browse/server.py:144
msgid "This feed is empty"
msgstr "Deze tijdlijn is blanco"

#: ../melon/home/__init__.py:18
#: ../melon/home/__init__.py:20
msgid "Home"
msgstr "Tijdlijn"

#: ../melon/home/__init__.py:39 ../melon/home/history.py:26
#: ../melon/home/new.py:36 ../melon/home/subs.py:23
#: ../melon/home/__init__.py:41 ../melon/home/history.py:28
#: ../melon/home/new.py:49 ../melon/home/subs.py:25
msgid "Browse Servers"
msgstr "Servers verkennen"

#: ../melon/home/__init__.py:46
#: ../melon/home/__init__.py:48
msgid "Preferences"
msgstr "Voorkeuren"

#: ../melon/home/__init__.py:47
#: ../melon/home/__init__.py:49
msgid "Import Data"
msgstr "Gegevens importeren"

#: ../melon/home/__init__.py:48
#: ../melon/home/__init__.py:50
msgid "About Melon"
msgstr "Over Melon"

#: ../melon/home/history.py:24
#: ../melon/home/history.py:26
msgid "You haven't watched any videos yet"
msgstr "U heeft nog geen video's bekeken"

#: ../melon/home/history.py:36 ../melon/home/history.py:117
#: ../melon/home/history.py:38 ../melon/home/history.py:125
msgid "History"
msgstr "Geschiedenis"

#: ../melon/home/history.py:37
#: ../melon/home/history.py:39
msgid "These are the videos you opened in the past"
msgstr "Dit zijn video's die u in het verleden bekeken hebt"

#: ../melon/home/history.py:76
#: ../melon/home/history.py:83
msgid "Show more"
msgstr "Meer tonen"

#: ../melon/home/history.py:77
#: ../melon/home/history.py:84
msgid "Load older videos"
msgstr "Oudere video's laden"

#: ../melon/home/new.py:23
#: ../melon/home/new.py:30
msgid "Refresh"
msgstr "Herladen"

#: ../melon/home/new.py:32
#: ../melon/home/new.py:40
msgid "Subscribe to a channel first, to view new uploads"
msgstr "Abonneer op een kanaal om uploads te bekijken"

#: ../melon/home/new.py:34
#: ../melon/home/new.py:45
msgid "The channels you are subscribed to haven't uploaded anything yet"
msgstr "De kanalen waarop u geabonneerd bent hebben nog niks geüpload"

#: ../melon/home/new.py:55
#: ../melon/home/new.py:68
#, python-brace-format
msgid "(Last refresh: {last_refresh})"
msgstr "(Bijgewerkt op {last_refresh})"

#: ../melon/home/new.py:57 ../melon/home/new.py:146
#: ../melon/home/new.py:72 ../melon/home/new.py:172
msgid "What's new"
msgstr "Nieuw"

#: ../melon/home/new.py:58
#: ../melon/home/new.py:74
msgid "These are the latest videos of channels you follow"
msgstr "Dit zijn de nieuwste video's van de kanalen die u volgt"

#: ../melon/home/new.py:137
#: ../melon/home/new.py:162
msgid "Pick up where you left off"
msgstr "Hervatten vanaf pauzepunt"

#: ../melon/home/new.py:138
#: ../melon/home/new.py:163
msgid "Watch"
msgstr "Bekijken"

#: ../melon/home/playlists.py:22
#: ../melon/home/playlists.py:24
msgid "You don't have any playlists yet"
msgstr "Er zijn nog geen afspeellijsten"

#: ../melon/home/playlists.py:24 ../melon/home/playlists.py:34
#: ../melon/home/playlists.py:26 ../melon/home/playlists.py:39
msgid "Create a new playlist"
msgstr "Nieuwe afspeellijst"

#: ../melon/home/playlists.py:33
#: ../melon/home/playlists.py:36
msgid "Here are playlists you've bookmarked or created yourself"
msgstr "Hier vindt u de door u gemaakte en toegevoegde afspeellijsten"

#: ../melon/home/playlists.py:34
#: ../melon/home/playlists.py:39
msgid "New"
msgstr "Nieuw"

#: ../melon/home/subs.py:21
#: ../melon/home/subs.py:23
msgid "You aren't yet subscribed to channels"
msgstr "U bent nog niet geabonneerd op kanalen"

#: ../melon/home/subs.py:31 ../melon/home/subs.py:63
#: ../melon/home/subs.py:33 ../melon/home/subs.py:68
msgid "Subscriptions"
msgstr "Abonnementen"

#: ../melon/home/subs.py:32
#: ../melon/home/subs.py:34
msgid "You are subscribed to the following channels"
msgstr "U bent geabonneerd op de volgende kanalen"

#: ../melon/import_providers/newpipe.py:28
#: ../melon/import_providers/newpipe.py:42
msgid "Newpipe Database importer"
msgstr "NewPipe-databankimport"

#: ../melon/import_providers/newpipe.py:29
#: ../melon/import_providers/newpipe.py:44
msgid ""
"Import the .db file from inside the newpipe .zip export (as invidious "
"content)"


@@ 332,242 332,242 @@ msgstr ""
"Importeer het .db-bestand uit de .zip-export van NewPipe (als Invidious-"
"inhoud)"

#: ../melon/import_providers/newpipe.py:30
#: ../melon/import_providers/newpipe.py:46
msgid "Select .db file"
msgstr "Kies een .db-bestand"

#: ../melon/import_providers/newpipe.py:45
#: ../melon/import_providers/newpipe.py:60
msgid "Newpipe Database"
msgstr "NewPipe-databank"

#: ../melon/importer.py:17 ../melon/importer.py:35
#: ../melon/importer.py:19 ../melon/importer.py:37
msgid "Import"
msgstr "Importeren"

#: ../melon/importer.py:36
#: ../melon/importer.py:38
msgid "The following import methods have been found"
msgstr "De volgende importmogelijkheden zijn beschikbaar"

#: ../melon/importer.py:62
#: ../melon/importer.py:64
msgid "There are no available importer methods"
msgstr "Er zijn beschikbare importmogelijkheden"

#: ../melon/player/__init__.py:34
#: ../melon/player/__init__.py:41
msgid "Description"
msgstr "Beschrijving"

#: ../melon/player/__init__.py:45
#: ../melon/player/__init__.py:54
msgid "Add this video to a playlist"
msgstr "Video toevoegen aan afspeellijst"

#: ../melon/player/__init__.py:103
#: ../melon/player/__init__.py:113
msgid "Video could not be loaded"
msgstr "De video kan niet worden geladen"

#: ../melon/player/__init__.py:140 ../melon/player/__init__.py:174
#: ../melon/player/playlist.py:51
#: ../melon/player/__init__.py:151 ../melon/player/__init__.py:185
#: ../melon/player/playlist.py:57
msgid "Loading..."
msgstr "Bezig met laden…"

#: ../melon/player/playlist.py:159
#: ../melon/player/playlist.py:165
msgid "This playlist is empty"
msgstr "Deze afspeellijst is blanco"

#: ../melon/player/playlist.py:166
#: ../melon/player/playlist.py:172
msgid "There was an error loading the playlist"
msgstr "Er is een fout opgetreden tijdens het laden"

#: ../melon/player/playlist.py:218
#: ../melon/player/playlist.py:225
msgid "Previous"
msgstr "Vorige"

#: ../melon/player/playlist.py:219
#: ../melon/player/playlist.py:227
msgid "Play video that comes before this one in the playlist"
msgstr "Video hiervoor afspelen"

#: ../melon/player/playlist.py:230
#: ../melon/player/playlist.py:244
msgid "Next"
msgstr "Volgende"

#: ../melon/player/playlist.py:231
#: ../melon/player/playlist.py:246
msgid "Play video that comes after this one in the playlist"
msgstr "Video hierna afspelen"

#: ../melon/player/playlist.py:240
#: ../melon/player/playlist.py:258
msgid "Skip"
msgstr "Overslaan"

#: ../melon/player/playlist.py:241
#: ../melon/player/playlist.py:259
msgid "Skip this video and pick a new one at random"
msgstr "Sla deze video over en kies een willekeurige andere"

#: ../melon/player/playlist.py:250
#: ../melon/player/playlist.py:270
msgid "Shuffle"
msgstr "Willekeurig"

#: ../melon/player/playlist.py:251
#: ../melon/player/playlist.py:271
msgid "Chooses the next video at random"
msgstr "Kies een willekeurige video"

#: ../melon/player/playlist.py:261
#: ../melon/player/playlist.py:282
msgid "Repeat current video"
msgstr "Huidige video herhalen"

#: ../melon/player/playlist.py:262
#: ../melon/player/playlist.py:283
msgid "Puts this video on loop"
msgstr "Herhaalt de video"

#: ../melon/player/playlist.py:276
#: ../melon/player/playlist.py:298
msgid "Repeat playlist"
msgstr "Afspeellijst herhalen"

#: ../melon/player/playlist.py:277
#: ../melon/player/playlist.py:300
msgid "Start playling the playlist from the beginning after reaching the end"
msgstr "Starten met afspelen vanaf het begin na de laatste video"

#: ../melon/player/playlist.py:287
#: ../melon/player/playlist.py:312
msgid "Playlist Content"
msgstr "Afspeellijstinhoud"

#: ../melon/player/playlist.py:288
#: ../melon/player/playlist.py:314
msgid "Click on videos to continue playing the playlist from there"
msgstr "Klik op video's om het afspelen vanaf daar te hervatten"

#: ../melon/playlist/__init__.py:50
#: ../melon/playlist/__init__.py:65
msgid "Edit"
msgstr "Bewerken"

#: ../melon/playlist/__init__.py:62
#: ../melon/playlist/__init__.py:79
msgid "You haven't added any videos to this playlist yet"
msgstr "Deze afspeellijst bevat nog geen video's"

#: ../melon/playlist/__init__.py:64
#: ../melon/playlist/__init__.py:82
msgid "Start watching"
msgstr "Beginnen met kijken"

#: ../melon/playlist/__init__.py:87
#: ../melon/playlist/__init__.py:106
msgid "Set as playlist thumbnail"
msgstr "Instellen als afspeellijstminiatuur"

#: ../melon/playlist/__init__.py:88
#: ../melon/playlist/__init__.py:114
msgid "Remove from playlist"
msgstr "Verwijderen van afspeellijst"

#: ../melon/playlist/__init__.py:99
#: ../melon/playlist/__init__.py:131
msgid "Edit Playlist"
msgstr "Afspeellijst bewerken"

#: ../melon/playlist/__init__.py:105
#: ../melon/playlist/__init__.py:137
msgid "Playlist details"
msgstr "Afspeellijstinformatie"

#: ../melon/playlist/__init__.py:106
#: ../melon/playlist/__init__.py:138
msgid "Change playlist information"
msgstr "Afspeellijstinformatie wijzigen"

#: ../melon/playlist/__init__.py:108 ../melon/playlist/create.py:37
#: ../melon/playlist/__init__.py:140 ../melon/playlist/create.py:43
msgid "Playlist name"
msgstr "Afspeellijstnaam"

#: ../melon/playlist/__init__.py:111 ../melon/playlist/create.py:40
#: ../melon/playlist/__init__.py:143 ../melon/playlist/create.py:46
msgid "Playlist description"
msgstr "Afspeellijstbeschrijving"

#: ../melon/playlist/__init__.py:116
#: ../melon/playlist/__init__.py:148
msgid "Save details"
msgstr "Informatie opslaan"

#: ../melon/playlist/__init__.py:117
#: ../melon/playlist/__init__.py:150
msgid "Change playlist title and description. (Closes the dialog)"
msgstr ""
"Wijzig de afspeellijsttitel en -beschrijving (het venster wordt gesloten)"

#: ../melon/playlist/__init__.py:127 ../melon/playlist/__init__.py:169
#: ../melon/playlist/__init__.py:161 ../melon/playlist/__init__.py:207
msgid "Delete Playlist"
msgstr "Afspeellijst verwijderen"

#: ../melon/playlist/__init__.py:128
#: ../melon/playlist/__init__.py:163
msgid "Delete this playlist and it's content. This can NOT be undone."
msgstr ""
"Verwijder deze afspeellijst en alle bijbehorende inhoud. Let op: dit is "
"onomkeerbaar."

#: ../melon/playlist/__init__.py:139
#: ../melon/playlist/__init__.py:176
msgid "Close"
msgstr "Sluiten"

#: ../melon/playlist/__init__.py:139
#: ../melon/playlist/__init__.py:178
msgid "Close without changing anything"
msgstr "Sluiten zonder opslaan"

#: ../melon/playlist/__init__.py:172
#: ../melon/playlist/__init__.py:210
msgid "Do you really want to delete this playlist?"
msgstr "Weet u zeker dat u deze afspeellijst wilt verwijderen?"

#: ../melon/playlist/__init__.py:173
#: ../melon/playlist/__init__.py:211
msgid "This cannot be undone"
msgstr "Dit is onomkeerbaar"

#: ../melon/playlist/__init__.py:177 ../melon/playlist/create.py:47
#: ../melon/playlist/pick.py:70 ../melon/widgets/preferencerow.py:175
#: ../melon/widgets/preferencerow.py:218
#: ../melon/playlist/__init__.py:216 ../melon/playlist/create.py:54
#: ../melon/playlist/pick.py:88 ../melon/widgets/preferencerow.py:181
#: ../melon/widgets/preferencerow.py:226
msgid "Cancel"
msgstr "Annuleren"

#: ../melon/playlist/__init__.py:177
#: ../melon/playlist/__init__.py:218
msgid "Do not delete the playlist"
msgstr "Afspeellijst niet verwijderen"

#: ../melon/playlist/__init__.py:178 ../melon/widgets/preferencerow.py:207
#: ../melon/widgets/preferencerow.py:219
#: ../melon/playlist/__init__.py:221 ../melon/widgets/preferencerow.py:214
#: ../melon/widgets/preferencerow.py:229
msgid "Delete"
msgstr "Verwijderen"

#: ../melon/playlist/__init__.py:178
#: ../melon/playlist/__init__.py:221
msgid "Delete this playlist"
msgstr "Afspeellijst verwijderen"

#: ../melon/playlist/create.py:23 ../melon/playlist/pick.py:28
#: ../melon/playlist/create.py:25 ../melon/playlist/pick.py:35
msgid "Video"
msgstr "Video"

#: ../melon/playlist/create.py:24
#: ../melon/playlist/create.py:27
msgid "The following video will be added to the new playlist"
msgstr "De volgende video zal worden toegevoegd aan de nieuwe afspeellijst"

#: ../melon/playlist/create.py:33 ../melon/playlist/create.py:91
#: ../melon/playlist/create.py:39 ../melon/playlist/create.py:98
msgid "New Playlist"
msgstr "Nieuwe afspeellijst"

#: ../melon/playlist/create.py:34
#: ../melon/playlist/create.py:40
msgid "Enter more playlist information"
msgstr "Voer meer afspeellijstinformatie in"

#: ../melon/playlist/create.py:38
#: ../melon/playlist/create.py:44
msgid "Unnamed Playlist"
msgstr "Naamloze afspeellijst"

#: ../melon/playlist/create.py:47 ../melon/playlist/pick.py:70
#: ../melon/playlist/create.py:54 ../melon/playlist/pick.py:88
msgid "Do not create playlist"
msgstr "Geen afspeellijst maken"

#: ../melon/playlist/create.py:48 ../melon/widgets/preferencerow.py:176
#: ../melon/playlist/create.py:57 ../melon/widgets/preferencerow.py:184
msgid "Create"
msgstr "Maken"

#: ../melon/playlist/create.py:48
#: ../melon/playlist/create.py:57
msgid "Create playlist"
msgstr "Afspeellijst maken"

#: ../melon/playlist/pick.py:29
#: ../melon/playlist/pick.py:37
msgid "The following video will be added to the playlist"
msgstr "De volgende video zal worden toegevoegd aan de afspeellijst"

#: ../melon/playlist/pick.py:39 ../melon/widgets/feeditem.py:122
#: ../melon/playlist/pick.py:50 ../melon/widgets/feeditem.py:145
msgid "Add to playlist"
msgstr "Toevoegen aan afspeellijst"

#: ../melon/playlist/pick.py:40
#: ../melon/playlist/pick.py:53
msgid ""
"Choose a playlist to add the video to. Note that you can only add videos to "
"local playlists, not external bookmarked ones"


@@ 575,42 575,42 @@ msgstr ""
"Kies een afspeellijst om de video aan toe te voegen. Let op: u kunt alleen "
"video's toevoegen aan lokale afspeellijsten."

#: ../melon/playlist/pick.py:42
#: ../melon/playlist/pick.py:57
msgid "Create new playlist"
msgstr "Nieuwe afspeellijst maken"

#: ../melon/playlist/pick.py:43
#: ../melon/playlist/pick.py:58
msgid "Create a new playlist and add the video to it"
msgstr "Nieuwe afspeellijst aanmaken en video toevoegen"

#: ../melon/playlist/pick.py:102
#: ../melon/playlist/pick.py:119
msgid "Add to Playlist"
msgstr "Toevoegen aan afspeellijst"

#: ../melon/servers/invidious/__init__.py:31
#: ../melon/servers/invidious/__init__.py:35
msgid "Open source alternative front-end to YouTube"
msgstr "Een alternatieve, opensourceclient voor YouTube"

#: ../melon/servers/invidious/__init__.py:36
#: ../melon/servers/invidious/__init__.py:40
msgid "Instance"
msgstr "Instantie"

#: ../melon/servers/invidious/__init__.py:37
#: ../melon/servers/invidious/__init__.py:42
msgid ""
"See https://docs.invidious.io/instances/ for a list of available instances"
msgstr ""
"Zie https://docs.invidious.io/instances/ voor een lijst met beschikbare "
"instanties"

#: ../melon/servers/invidious/__init__.py:48
#: ../melon/servers/invidious/__init__.py:55
msgid "Trending"
msgstr "Trending"

#: ../melon/servers/invidious/__init__.py:49
#: ../melon/servers/invidious/__init__.py:56
msgid "Popular"
msgstr "Populair"

#: ../melon/servers/nebula/__init__.py:19
#: ../melon/servers/nebula/__init__.py:21
msgid ""
"Home of smart, thoughtful videos, podcasts, and classes from your favorite "
"creators"


@@ 618,65 618,65 @@ msgstr ""
"Een tijdlijn met slimme, doordachte video's, podcasts en cursussen van uw "
"favoriete makers"

#: ../melon/servers/nebula/__init__.py:24
#: ../melon/servers/nebula/__init__.py:27
msgid "Email Address"
msgstr "E-mailadres"

#: ../melon/servers/nebula/__init__.py:25
#: ../melon/servers/nebula/__init__.py:28
msgid "Email Address to login to your account"
msgstr "Het e-mailadres waarmee u inlogt op uw account"

#: ../melon/servers/nebula/__init__.py:31
#: ../melon/servers/nebula/__init__.py:35
msgid "Password"
msgstr "Wachtwoord"

#: ../melon/servers/nebula/__init__.py:32
#: ../melon/servers/nebula/__init__.py:36
msgid "Password associated with your account"
msgstr "Het wachtwoord van uw account"

#: ../melon/servers/peertube/__init__.py:15
#: ../melon/servers/peertube/__init__.py:16
msgid "Decentralized video hosting network, based on free/libre software"
msgstr ""
"Een gedecentraliseerd videonetwerk, gebaseerd op gratis, vrije software"

#: ../melon/servers/peertube/__init__.py:20
#: ../melon/servers/peertube/__init__.py:21
msgid "Instances"
msgstr "Instanties"

#: ../melon/servers/peertube/__init__.py:21
#: ../melon/servers/peertube/__init__.py:23
msgid ""
"List of peertube instances, from which to fetch content. See https://"
"joinpeertube.org/instances"
msgstr "Lijst met PeerTube-instanties om inhoud van op te halen. Zie"

#: ../melon/servers/peertube/__init__.py:27
#: ../melon/servers/peertube/__init__.py:31
msgid "Show NSFW content"
msgstr "18+-inhoud tonen"

#: ../melon/servers/peertube/__init__.py:28
#: ../melon/servers/peertube/__init__.py:32
msgid "Passes the nsfw filter to the peertube search API"
msgstr "Geeft het 18+-filter door aan de PeerTube-zoek-api"

#: ../melon/servers/peertube/__init__.py:33
#: ../melon/servers/peertube/__init__.py:39
msgid "Enable Federation"
msgstr "Federatie inschakelen"

#: ../melon/servers/peertube/__init__.py:34
#: ../melon/servers/peertube/__init__.py:40
msgid "Returns content from federated instances instead of only local content"
msgstr ""
"Haalt inhoud van gefedereerde instanties op in plaats van alleen lokale "
"inhoud"

#: ../melon/servers/peertube/__init__.py:58
#: ../melon/servers/peertube/__init__.py:65
msgid "Latest"
msgstr "Nieuwste"

#: ../melon/settings/__init__.py:18
#: ../melon/settings/__init__.py:23
msgid "Show Previews when browsing public feeds (incl. search)"
msgstr ""
"Voorvertoningen inschakelen op openbare tijdlijnen (inclusief zoekpagina)"

#: ../melon/settings/__init__.py:19
#: ../melon/settings/__init__.py:25
msgid ""
"Set to true to show previews when viewing channel contents, public feeds and "
"searching"


@@ 684,11 684,11 @@ msgstr ""
"Schakel in om voorvertoningen in te schakelen op kanaalinhoud, openbare "
"tijdlijnen en de zoekpagina"

#: ../melon/settings/__init__.py:25
#: ../melon/settings/__init__.py:34
msgid "Show Previews in local feeds"
msgstr "Voorvertoningen inschakelen op lokale tijdlijnen"

#: ../melon/settings/__init__.py:26
#: ../melon/settings/__init__.py:36
msgid ""
"Set to true to show previews in the new feed, for subscribed channels, and "
"saved playlists"


@@ 696,22 696,22 @@ msgstr ""
"Schakel in om voorvertoningen in te schakelen op de tijdlijn, geabonneerde "
"kanalen en opgeslagen afspeellijsten"

#: ../melon/settings/__init__.py:32
#: ../melon/settings/__init__.py:44
msgid "Show servers that may contain nsfw content"
msgstr "Servers tonen die 18+-inhoud kunnen bevatten"

#: ../melon/settings/__init__.py:33
#: ../melon/settings/__init__.py:46
msgid ""
"Lists/Delists servers in the browse servers list, if they contain some nsfw "
"content"
msgstr ""
"Toon/Verberg servers op de verkenlijst als ze enige 18+-inhoud bevatten"

#: ../melon/settings/__init__.py:38
#: ../melon/settings/__init__.py:54
msgid "Show servers that only contain nsfw content"
msgstr "Servers tonen die alleen 18+-inhoud bevatten"

#: ../melon/settings/__init__.py:39
#: ../melon/settings/__init__.py:56
msgid ""
"Lists/Delists servers in the browse servers list, if they contain only/"
"mostly nsfw content"


@@ 719,136 719,136 @@ msgstr ""
"Toon/Verberg servers op de verkenlijst als ze alleen of veelal 18+-inhoud "
"bevatten"

#: ../melon/settings/__init__.py:45
#: ../melon/settings/__init__.py:64
msgid "Show servers that require login"
msgstr "Servers tonen die een account vereisen"

#: ../melon/settings/__init__.py:46
#: ../melon/settings/__init__.py:66
msgid ""
"Lists/Delists servers in the browse servers list, if they require login to "
"function"
msgstr "Toon/Verberg servers op de verkenlijst als ze een account vereisen"

#: ../melon/settings/__init__.py:55
#: ../melon/settings/__init__.py:78
msgid "Settings"
msgstr "Voorkeuren"

#: ../melon/settings/__init__.py:68
#: ../melon/settings/__init__.py:91
msgid "General"
msgstr "Algemeen"

#: ../melon/settings/__init__.py:69
#: ../melon/settings/__init__.py:92
msgid "Global app settings"
msgstr "Algemene voorkeuren"

#: ../melon/settings/__init__.py:93
#: ../melon/settings/__init__.py:116
msgid "Enable Server"
msgstr "Server inschakelen"

#: ../melon/settings/__init__.py:94
#: ../melon/settings/__init__.py:118
msgid ""
"Disabled servers won't show up in the browser or on the local/home screen"
msgstr ""
"Uitgeschakelde servers worden niet getoond op het verken- en tijdlijnscherm"

#: ../melon/widgets/feeditem.py:121
#: ../melon/widgets/feeditem.py:139
msgid "Watch now"
msgstr "Nu bekijken"

#: ../melon/widgets/feeditem.py:123
#: ../melon/widgets/feeditem.py:152
msgid "Open in browser"
msgstr "Openen in webbrowser"

#: ../melon/widgets/feeditem.py:124
#: ../melon/widgets/feeditem.py:156
msgid "View channel"
msgstr "Kanaal bekijken"

#: ../melon/widgets/player.py:119
#: ../melon/widgets/player.py:136
msgid "No streams available"
msgstr "Er zijn geen streams beschikbaar"

#: ../melon/widgets/player.py:180
#: ../melon/widgets/player.py:198
msgid "Toggle floating window"
msgstr "Zwevend venster aan/uit"

#: ../melon/widgets/player.py:184
#: ../melon/widgets/player.py:204
msgid "Toggle fullscreen"
msgstr "Schermvullende weergave aan/uit"

#: ../melon/widgets/player.py:204 ../melon/widgets/player.py:565
#: ../melon/widgets/player.py:571
#: ../melon/widgets/player.py:226 ../melon/widgets/player.py:613
#: ../melon/widgets/player.py:622
msgid "Play"
msgstr "Afspelen"

#: ../melon/widgets/player.py:230
#: ../melon/widgets/player.py:253
msgid "Stream options"
msgstr "Streamopties"

#: ../melon/widgets/player.py:442
#: ../melon/widgets/player.py:483
msgid "Resolution"
msgstr "Resolutie"

#: ../melon/widgets/player.py:559
#: ../melon/widgets/player.py:604
msgid "Pause"
msgstr "Pauzeren"

#: ../melon/widgets/player.py:589
#: ../melon/widgets/player.py:642
msgid "The video is playing in separate window"
msgstr "De video wordt in een apart venster afgespeeld"

#: ../melon/widgets/player.py:710
#: ../melon/widgets/player.py:779
msgid "Player"
msgstr "Speler"

#: ../melon/widgets/preferencerow.py:116
#: ../melon/widgets/preferencerow.py:125
msgid "Add"
msgstr "Toevoegen"

#: ../melon/widgets/preferencerow.py:130
#: ../melon/widgets/preferencerow.py:139
msgid "Move up"
msgstr "Omhoog verplaatsen"

#: ../melon/widgets/preferencerow.py:141
#: ../melon/widgets/preferencerow.py:147
msgid "Move down"
msgstr "Omlaag verplaatsen"

#: ../melon/widgets/preferencerow.py:150
#: ../melon/widgets/preferencerow.py:153
msgid "Remove from list"
msgstr "Verwijderen van lijst"

#: ../melon/widgets/preferencerow.py:163
#: ../melon/widgets/preferencerow.py:168
msgid "Add Item"
msgstr "Item toevoegen"

#: ../melon/widgets/preferencerow.py:166
#: ../melon/widgets/preferencerow.py:171
msgid "Create a new list entry"
msgstr "Nieuw lijstitem"

#: ../melon/widgets/preferencerow.py:167
#: ../melon/widgets/preferencerow.py:172
msgid "Enter the new value here"
msgstr "Voer hier de nieuwe waarde in"

#: ../melon/widgets/preferencerow.py:170
#: ../melon/widgets/preferencerow.py:175
msgid "Value"
msgstr "Waarde"

#: ../melon/widgets/preferencerow.py:210
#: ../melon/widgets/preferencerow.py:217
msgid "Do you really want to delete this item?"
msgstr "Weet u zeker dat u dit item wilt verwijderen?"

#: ../melon/widgets/preferencerow.py:211
#: ../melon/widgets/preferencerow.py:218
msgid "You won't be able to restore it afterwards"
msgstr "U kunt nadien geen herstel uitvoeren"

#: ../melon/widgets/preferencerow.py:218
#: ../melon/widgets/preferencerow.py:226
msgid "Do not remove item"
msgstr "Item niet verwijderen"

#: ../melon/widgets/preferencerow.py:219
#: ../melon/widgets/preferencerow.py:229
msgid "Remove item from list"
msgstr "Item verwijderen van lijst"

#: ../melon/window.py:84
#: ../melon/window.py:94
msgid "Stream videos on the go"
msgstr "Video's onderweg streamen"