Compare commits

..

No commits in common. "main" and "emmental-3.0.1" have entirely different histories.

118 changed files with 1338 additions and 5024 deletions

2
.gitmodules vendored
View File

@ -3,4 +3,4 @@
url = ssh://aur@aur.archlinux.org/emmental.git
[submodule "mpris-spec"]
path = mpris-spec
url = https://gitlab.freedesktop.org/mpris/mpris-spec.git
url = https://github.com/freedesktop/mpris-spec.git

View File

@ -20,7 +20,6 @@ other playlists run out of tracks.
* Python3
* dateutil
* gobject
* liblistenbrainz
* musicbrainzngs
* mutagen
* pyxdg

2
aur

@ -1 +1 @@
Subproject commit 543220f35990f5b5a69eac5a6c06e69eae6ffb82
Subproject commit c1c0847718a15224a6100de220e4326ea3ec2f6c

19
data/PKGBUILD Normal file
View File

@ -0,0 +1,19 @@
# Maintainer: Anna Schumaker <anna@nowheycreamery.com>
pkgname=emmental
pkgver={MAJOR}.{MINOR}
pkgrel=1
pkgdesc='The cheesy music player'
url='https://www.git.nowheycreamery.com/anna/emmental'
arch=('any')
license=('GPL3')
depends=('python' 'python-gobject' 'python-mutagen' 'python-pyxdg' 'gtk4' 'gstreamer' 'gst-plugins-base')
optdepends=('gst-plugins-good' 'gst-plugins-bad' 'gst-plugins-ugly')
source=("https://git.nowheycreamery.com/anna/emmental/archive/emmental-$pkgver.tar.gz")
sha256sums=({SHA256SUM})
package() {
cd "$pkgname"
make PREFIX="$pkgdir/usr" install
sed -i "s|$pkgdir||" $pkgdir/usr/bin/emmental
sed -i "s|$pkgdir||" $pkgdir/usr/share/applications/emmental.desktop
}

View File

@ -3,11 +3,9 @@
import musicbrainzngs
import pathlib
from . import gsetup
from . import action
from . import audio
from . import db
from . import header
from . import listenbrainz
from . import mpris2
from . import nowplaying
from . import options
@ -21,8 +19,8 @@ from gi.repository import Gio
from gi.repository import Adw
MAJOR_VERSION = 3
MINOR_VERSION = 2
MICRO_VERSION = 0
MINOR_VERSION = 0
MICRO_VERSION = 1
VERSION_NUMBER = f"{MAJOR_VERSION}.{MINOR_VERSION}.{MICRO_VERSION}"
VERSION_STRING = f"Emmental {VERSION_NUMBER}{gsetup.DEBUG_STR}"
@ -35,7 +33,6 @@ class Application(Adw.Application):
factory = GObject.Property(type=playlist.Factory)
mpris = GObject.Property(type=mpris2.Connection)
player = GObject.Property(type=audio.Player)
lbrainz = GObject.Property(type=listenbrainz.ListenBrainz)
win = GObject.Property(type=window.Window)
autopause = GObject.Property(type=int, default=-1, minimum=-1, maximum=99)
@ -47,11 +44,6 @@ class Application(Adw.Application):
flags=Gio.ApplicationFlags.HANDLES_OPEN)
self.add_main_option_entries([options.Version])
def __add_accelerators(self, accels: list[action.ActionEntry]) -> None:
for entry in accels:
self.add_action(entry.action)
self.set_accels_for_action(f"app.{entry.name}", entry.accels)
def __load_file(self, file: pathlib.Path,
*, gapless: bool = False) -> None:
self.__stop_current_track()
@ -137,18 +129,13 @@ class Application(Adw.Application):
hdr = header.Header(sql=self.db, title=VERSION_STRING)
for prop in ["bg-enabled", "bg-volume", "volume"]:
hdr.bind_property(prop, self.player, prop)
hdr.bind_property("listenbrainz-token", self.lbrainz, "user-token")
for (setting, property) in [("audio.volume", "volume"),
("audio.background.enabled", "bg-enabled"),
("audio.background.volume", "bg-volume"),
("audio.replaygain.enabled", "rg-enabled"),
("audio.replaygain.mode", "rg-mode"),
("listenbrainz.token",
"listenbrainz_token")]:
("audio.replaygain.mode", "rg-mode")]:
self.db.settings.bind_setting(setting, hdr, property)
self.__add_accelerators(hdr.accelerators)
hdr.connect("notify::rg-enabled", self.__set_replaygain)
hdr.connect("notify::rg-mode", self.__set_replaygain)
hdr.connect("track-requested", self.__load_path)
@ -172,8 +159,6 @@ class Application(Adw.Application):
self.db.settings.bind_setting("now-playing.prefer-artist",
playing, "prefer-artist")
self.__add_accelerators(playing.accelerators)
playing.connect("jump", self.__on_jump)
playing.connect("play", self.player.play)
playing.connect("pause", self.player.pause)
@ -187,7 +172,6 @@ class Application(Adw.Application):
side_bar = sidebar.Card(sql=self.db)
self.db.settings.bind_setting("sidebar.artists.show-all", side_bar,
"show-all-artists")
self.__add_accelerators(side_bar.accelerators)
return side_bar
def build_tracklist(self) -> tracklist.Card:
@ -200,8 +184,6 @@ class Application(Adw.Application):
self.db.settings.bind_setting(f"tracklist.{name}.visible",
column, "visible")
self.factory.bind_property("visible-playlist", track_list, "playlist")
self.__add_accelerators(track_list.accelerators)
return track_list
def build_window(self) -> window.Window:
@ -211,17 +193,13 @@ class Application(Adw.Application):
now_playing=self.build_now_playing(),
sidebar=self.build_sidebar(),
tracklist=self.build_tracklist())
win.bind_property("show-sidebar", win.header, "show-sidebar",
GObject.BindingFlags.BIDIRECTIONAL)
win.bind_property("user-editing", win.now_playing, "editing")
for (setting, property) in [("window.width", "default-width"),
("window.height", "default-height"),
("now-playing.size", "now-playing-size"),
("sidebar.show", "show-sidebar")]:
("sidebar.size", "sidebar-size")]:
self.db.settings.bind_setting(setting, win, property)
self.__add_accelerators(win.accelerators)
return win
def connect_mpris2(self) -> None:
@ -258,15 +236,6 @@ class Application(Adw.Application):
self.mpris.player.connect("SetPosition", self.__set_position)
self.mpris.player.connect("Stop", self.player.stop)
def connect_listenbrainz(self) -> None:
"""Connect the listenbrainz client."""
self.db.tracks.bind_property("current-track",
self.lbrainz, "now-playing")
self.lbrainz.bind_property("valid-token", self.win.header,
"listenbrainz-token-valid")
self.db.tracks.connect("track-played", self.lbrainz.submit_listens)
def connect_playlist_factory(self) -> None:
"""Connect the playlist factory properties."""
self.db.playlists.bind_property("previous",
@ -293,7 +262,6 @@ class Application(Adw.Application):
Adw.Application.do_startup(self)
self.db = db.Connection()
self.mpris = mpris2.Connection()
self.lbrainz = listenbrainz.ListenBrainz(self.db)
self.factory = playlist.Factory(self.db)
self.player = audio.Player()
@ -306,7 +274,6 @@ class Application(Adw.Application):
self.win = self.build_window()
self.add_window(self.win)
self.connect_mpris2()
self.connect_listenbrainz()
self.connect_playlist_factory()
self.connect_player()
@ -332,9 +299,6 @@ class Application(Adw.Application):
if self.win is not None:
self.win.close()
self.win = None
if self.lbrainz is not None:
self.lbrainz.stop()
self.lbrainz = None
if self.mpris is not None:
self.mpris.disconnect()
self.mpris = None

View File

@ -1,39 +0,0 @@
# Copyright 2023 (c) Anna Schumaker.
"""A custom ActionEntry that works in Python."""
from gi.repository import GObject
from gi.repository import Gio
from gi.repository import Gtk
class ActionEntry(GObject.GObject):
"""Our own AcitionEntry class to make accelerators easier."""
enabled = GObject.Property(type=bool, default=True)
def __init__(self, name: str, func: callable, *accels: tuple[str],
enabled: tuple[GObject.GObject, str] | None = None):
"""Initialize an ActionEntry."""
super().__init__()
for accel in accels:
if not Gtk.accelerator_parse(accel)[0]:
raise ValueError
self.accels = list(accels)
self.func = func
if enabled is not None:
self.enabled = enabled[0].get_property(enabled[1])
enabled[0].bind_property(enabled[1], self, "enabled")
self.action = Gio.SimpleAction(name=name, enabled=self.enabled)
self.action.connect("activate", self.__activate)
self.bind_property("enabled", self.action, "enabled")
def __activate(self, action: Gio.SimpleAction, param) -> None:
self.func()
@property
def name(self) -> str:
"""Get then name of this ActionEntry."""
return self.action.get_name()

View File

@ -1,48 +0,0 @@
# Copyright 2023 (c) Anna Schumaker.
"""Functions for configuring a callback at a specific time."""
import datetime
import math
from gi.repository import GLib
_GSOURCE_MAPPING = dict()
_NEXT_ALARM_ID = 1
def _calc_seconds(time: datetime.time) -> int:
"""Calculate the number of seconds until the given time."""
now = datetime.datetime.now()
then = datetime.datetime.combine(now.date(), time)
if now >= then:
then += datetime.timedelta(days=1)
return math.ceil((then - now).total_seconds())
def __set_alarm(time: datetime.time, func: callable, alarm_id: int) -> None:
gsrcid = GLib.timeout_add_seconds(_calc_seconds(time), _do_alarm,
time, func, alarm_id)
_GSOURCE_MAPPING[alarm_id] = gsrcid
return alarm_id
def _do_alarm(time: datetime.time, func: callable, alarm_id: int) -> bool:
"""Run an alarm callback."""
func()
__set_alarm(time, func, alarm_id)
return GLib.SOURCE_REMOVE
def set_alarm(time: datetime.time, func: callable) -> int:
"""Register a callback to be called at a specific time."""
global _NEXT_ALARM_ID
res = __set_alarm(time, func, _NEXT_ALARM_ID)
_NEXT_ALARM_ID += 1
return res
def cancel_alarm(alarm_id: int) -> None:
"""Cancel an alarm."""
GLib.source_remove(_GSOURCE_MAPPING[alarm_id])
del _GSOURCE_MAPPING[alarm_id]

View File

@ -8,29 +8,22 @@ class Button(Gtk.Button):
"""A Gtk.Button with extra properties and default large size."""
icon_name = GObject.Property(type=str)
icon_size = GObject.Property(type=Gtk.IconSize,
default=Gtk.IconSize.NORMAL)
icon_opacity = GObject.Property(type=float, default=1.0,
minimum=0.0, maximum=1.0)
def __init__(self, large_icon: bool = False, **kwargs):
def __init__(self, **kwargs):
"""Initialize a Button."""
super().__init__(focusable=False, **kwargs)
icon_size = Gtk.IconSize.LARGE if large_icon else Gtk.IconSize.NORMAL
self._image = Gtk.Image(icon_name=self.icon_name, icon_size=icon_size,
self._image = Gtk.Image(icon_name=self.icon_name,
icon_size=self.icon_size,
opacity=self.icon_opacity)
self.bind_property("icon-name", self._image, "icon-name")
self.bind_property("icon-size", self._image, "icon-size")
self.bind_property("icon-opacity", self._image, "opacity")
self.set_child(self._image)
@GObject.Property(type=bool, default=False)
def large_icon(self) -> bool:
"""Get if this Button has a large icon."""
return self._image.get_icon_size() == Gtk.IconSize.LARGE
@large_icon.setter
def large_icon(self, newval: bool) -> None:
size = Gtk.IconSize.LARGE if newval else Gtk.IconSize.NORMAL
self._image.set_icon_size(size)
class PopoverButton(Gtk.MenuButton):
"""A MenuButton with a Gtk.Popover attached."""
@ -52,20 +45,20 @@ class SplitButton(Gtk.Box):
"""A Button and secondary widget packed together."""
icon_name = GObject.Property(type=str)
large_icon = GObject.Property(type=bool, default=False)
icon_size = GObject.Property(type=Gtk.IconSize,
default=Gtk.IconSize.NORMAL)
def __init__(self, secondary: Gtk.Button, **kwargs):
"""Initialize a Split Button."""
super().__init__(**kwargs)
self._primary = Button(hexpand=True, icon_name=self.icon_name,
large_icon=self.large_icon)
icon_size=self.icon_size)
self._separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL,
margin_top=12, margin_bottom=12)
self._secondary = secondary
self.bind_property("icon-name", self._primary, "icon-name")
self.bind_property("large-icon", self._primary, "large-icon")
self._primary.connect("activate", self.__activate)
self.bind_property("icon-size", self._primary, "icon-size")
self._primary.connect("clicked", self.__clicked)
self.append(self._primary)
@ -74,25 +67,14 @@ class SplitButton(Gtk.Box):
self.add_css_class("emmental-splitbutton")
def __activate(self, button: Button) -> None:
self.emit("activate-primary")
def __clicked(self, button: Button) -> None:
self.emit("clicked")
def activate(self, *args) -> None:
"""Activate the primary button."""
self._primary.activate()
@GObject.Property(type=Gtk.Button, flags=GObject.ParamFlags.READABLE)
def secondary(self) -> Gtk.Button:
"""Get the secondary button attached to the SplitButton."""
return self._secondary
@GObject.Signal
def activate_primary(self) -> None:
"""Signal that the primary button has been activated."""
@GObject.Signal
def clicked(self) -> None:
"""Signal that the primary button has been clicked."""
@ -102,30 +84,14 @@ class ImageToggle(Button):
"""Inspired by a ToggleButton, but changes image based on state."""
active_icon_name = GObject.Property(type=str)
active_tooltip_text = GObject.Property(type=str)
inactive_icon_name = GObject.Property(type=str)
inactive_tooltip_text = GObject.Property(type=str)
def __init__(self, active_icon_name: str, inactive_icon_name: str,
active_tooltip_text: str | None = None,
inactive_tooltip_text: str | None = None,
*, active: bool = False, **kwargs) -> None:
active: bool = False, **kwargs) -> None:
"""Initialize an ImageToggle button."""
super().__init__(active_icon_name=active_icon_name,
inactive_icon_name=inactive_icon_name,
icon_name=inactive_icon_name,
active_tooltip_text=active_tooltip_text,
inactive_tooltip_text=inactive_tooltip_text,
tooltip_text=inactive_tooltip_text,
active=active, **kwargs)
self.connect("notify", self.__notify)
def __notify(self, toggle: Button, param: GObject.ParamSpec) -> None:
match (param.name, self.active):
case ("active-tooltip-text", True) | \
("inactive-tooltip-text", False):
self.set_tooltip_text(self.get_property(param.name))
icon_name=inactive_icon_name, active=active, **kwargs)
def do_clicked(self) -> None:
"""Handle a click event."""
@ -139,12 +105,8 @@ class ImageToggle(Button):
@active.setter
def active(self, newval: bool) -> None:
if newval != self.active:
if newval:
self.icon_name = self.active_icon_name
self.props.tooltip_text = self.active_tooltip_text
else:
self.icon_name = self.inactive_icon_name
self.props.tooltip_text = self.inactive_tooltip_text
icon = self.active_icon_name if newval else self.inactive_icon_name
self.icon_name = icon
self.emit("toggled")
@GObject.Signal

View File

@ -18,16 +18,13 @@ from . import tracks
from . import years
SQL_V1_SCRIPT = pathlib.Path(__file__).parent / "emmental.sql"
SQL_V2_SCRIPT = pathlib.Path(__file__).parent / "upgrade-v2.sql"
SQL_V3_SCRIPT = pathlib.Path(__file__).parent / "upgrade-v3.sql"
SQL_SCRIPT = pathlib.Path(__file__).parent / "emmental.sql"
class Connection(connection.Connection):
"""Connect to the database."""
active_playlist = GObject.Property(type=playlist.Playlist)
loaded = GObject.Property(type=bool, default=False)
def __init__(self):
"""Initialize a sqlite connection."""
@ -46,25 +43,13 @@ class Connection(connection.Connection):
self.tracks = tracks.Table(self)
def __check_loaded(self) -> None:
for tbl in list(self.playlist_tables()) + [self.tracks]:
if tbl.loaded is False:
return
self.loaded = True
def __check_version(self) -> None:
user_version = self("PRAGMA user_version").fetchone()["user_version"]
match user_version:
case 0:
self.executescript(SQL_V1_SCRIPT)
self.executescript(SQL_V2_SCRIPT)
self.executescript(SQL_V3_SCRIPT)
case 1:
self.executescript(SQL_V2_SCRIPT)
self.executescript(SQL_V3_SCRIPT)
case 2:
self.executescript(SQL_V3_SCRIPT)
case 3: pass
with open(SQL_SCRIPT) as f:
self._sql.executescript(f.read())
case 1: pass
case _:
raise Exception(f"Unsupported data version: {user_version}")
@ -97,8 +82,6 @@ class Connection(connection.Connection):
def set_active_playlist(self, plist: playlist.Playlist) -> None:
"""Set the currently active playlist."""
if self.active_playlist == plist:
return
if self.active_playlist is not None:
self.active_playlist.active = False
@ -111,4 +94,3 @@ class Connection(connection.Connection):
def table_loaded(self, tbl: table.Table) -> None:
"""Signal that a table has been loaded."""
tbl.loaded = True
self.__check_loaded()

View File

@ -3,6 +3,7 @@
import pathlib
import sqlite3
from gi.repository import GObject
from gi.repository import Gtk
from .media import Medium
from .. import format
from . import playlist
@ -22,11 +23,10 @@ class Album(playlist.Playlist):
"""Initialize an Album object."""
super().__init__(**kwargs)
self.add_children(self.table.sql.media,
self.table.get_mediumids(self))
Gtk.CustomFilter.new(self.__match_medium))
def add_medium(self, medium: Medium) -> None:
"""Add a Medium to this Album."""
self.add_child(medium)
def __match_medium(self, medium: Medium) -> bool:
return medium.albumid == self.albumid and len(medium.name) > 0
def get_artists(self) -> list[playlist.Playlist]:
"""Get a list of artists for this album."""
@ -36,14 +36,6 @@ class Album(playlist.Playlist):
"""Get a list of media for this album."""
return self.table.get_media(self)
def has_medium(self, medium: Medium) -> bool:
"""Check if a Medium is from this Album."""
return self.has_child(medium)
def remove_medium(self, medium: Medium) -> None:
"""Remove a Medium from this Album."""
return self.remove_child(medium)
@property
def primary_key(self) -> int:
"""Get the Album primary key."""
@ -147,11 +139,6 @@ class Table(playlist.Table):
def get_media(self, album: Album) -> list[Medium]:
"""Get the list of media for this album."""
return [self.sql.media.rows.get(id)
for id in self.get_mediumids(album)]
def get_mediumids(self, album: Album) -> set[int]:
"""Get the set of mediumids for this album."""
rows = self.sql("SELECT mediumid FROM media WHERE albumid=?",
album.albumid)
return {row["mediumid"] for row in rows.fetchall()}
return [self.sql.media.rows.get(row["mediumid"]) for row in rows]

View File

@ -19,21 +19,21 @@ class Artist(playlist.Playlist):
"""Initialize an Artist object."""
super().__init__(**kwargs)
self.add_children(self.table.sql.albums,
self.table.get_albumids(self))
table.Filter(self.table.get_albumids(self)))
def add_album(self, album: Album) -> None:
"""Add an Album to this Artist."""
if self.table.add_album(self, album):
self.add_child(album)
self.children.get_filter().add_row(album)
def has_album(self, album: Album) -> bool:
"""Check if the Artist has this Album."""
return self.has_child(album)
return self.children.get_filter().match(album)
def remove_album(self, album: Album) -> None:
"""Remove an album from this Artist."""
self.children.get_filter().remove_row(album)
self.table.remove_album(self, album)
self.remove_child(album)
@property
def primary_key(self) -> int:
@ -41,7 +41,7 @@ class Artist(playlist.Playlist):
return self.artistid
class Filter(table.KeySet):
class Filter(table.Filter):
"""Custom filter to hide artists without albums."""
show_all = GObject.Property(type=bool, default=False)
@ -51,7 +51,7 @@ class Filter(table.KeySet):
super().__init__(show_all=show_all)
self.connect("notify::show-all", self.__notify_show_all)
def __notify_show_all(self, filter: table.KeySet, param) -> None:
def __notify_show_all(self, filter: table.Filter, param) -> None:
self.changed(Gtk.FilterChange.LESS_STRICT if self.show_all else
Gtk.FilterChange.MORE_STRICT)
@ -66,7 +66,7 @@ class Filter(table.KeySet):
"""Check if the artist matches the filter."""
res = super().do_match(artist)
if not self.show_all and res:
return artist.child_set.keyset.n_keys > 0
return artist.children.get_filter().n_keys > 0
return res

View File

@ -75,21 +75,9 @@ class Connection(GObject.GObject):
self._sql.close()
self.connected = False
def commit(self) -> None:
"""Commit pending changes."""
self._sql.commit()
def executemany(self, statement: str, *args) -> sqlite3.Cursor | None:
"""Execute several similar SQL statements at once."""
try:
return self._sql.executemany(statement, args)
except sqlite3.InternalError:
return None
def executescript(self, script: pathlib.Path) -> sqlite3.Cursor | None:
"""Execute a SQL script."""
if script.is_file():
with open(script) as f:
cur = self._sql.executescript(f.read())
self.commit()
return cur

View File

@ -2,6 +2,7 @@
"""A custom Gio.ListModel for working with decades."""
import sqlite3
from gi.repository import GObject
from gi.repository import Gtk
from .years import Year
from . import playlist
from . import tracks
@ -16,24 +17,15 @@ class Decade(playlist.Playlist):
"""Initialize a Decade object."""
super().__init__(**kwargs)
self.add_children(self.table.sql.years,
self.table.get_yearids(self))
Gtk.CustomFilter.new(self.__match_year))
def add_year(self, year: Year) -> None:
"""Add a year to this decade."""
self.add_child(year)
def __match_year(self, year: Year) -> bool:
return self.decade == year.year // 10 * 10
def get_years(self) -> list[Year]:
"""Get a list of years for this decade."""
return self.table.get_years(self)
def has_year(self, year: Year) -> bool:
"""Check if the year is in this decade."""
return self.has_child(year)
def remove_year(self, year: Year) -> None:
"""Remove a year from this decade."""
self.remove_child(year)
@property
def primary_key(self) -> int:
"""Get the primary key of this Decade."""
@ -98,12 +90,8 @@ class Table(playlist.Table):
return self.sql("""SELECT trackid FROM decade_tracks_view
WHERE decade=?""", decade.decade)
def get_yearids(self, decade: Decade) -> set[int]:
"""Get the set of years for this decade."""
rows = self.sql("SELECT year FROM years WHERE (year / 10 * 10)=?",
decade.decade)
return {row["year"] for row in rows}
def get_years(self, decade: Decade) -> list[Year]:
"""Get the list of years for this decade."""
return [self.sql.years.rows.get(yr) for yr in self.get_yearids(decade)]
rows = self.sql("SELECT year FROM years WHERE (year / 10 * 10)=?",
decade.decade)
return [self.sql.years.rows.get(row["year"]) for row in rows]

View File

@ -52,11 +52,6 @@ class Queue(GObject.GObject):
self.running = False
self._idle_id = None
def cancel_task(self, func: typing.Callable) -> None:
"""Remove all instances of a specific task from the Idle Queue."""
self._tasks = [t for t in self._tasks if t[0] != func]
self.__update_counters()
def complete(self) -> None:
"""Complete all pending tasks."""
if self.running:
@ -65,13 +60,12 @@ class Queue(GObject.GObject):
self.cancel()
def push(self, func: typing.Callable, *args,
now: bool = False, first: bool = False) -> bool | None:
now: bool = False) -> bool | None:
"""Add a task to the Idle Queue."""
if not self.enabled or now:
return func(*args)
pos = 0 if first else len(self._tasks)
self._tasks.insert(pos, (func, *args))
self._tasks.append((func, *args))
self.total += 1
self.__start()

View File

@ -55,11 +55,10 @@ class Library(playlist.Playlist):
def __tag_track(self, path: pathlib.Path) -> bool:
if self.tagger.ready.is_set():
result = self.tagger.get_result(db=self.table.sql, library=self)
if result is None:
(file, tags) = self.tagger.get_result(self.table.sql, self)
if file is None:
track = self.table.sql.tracks.lookup(self, path=path)
mtime = track.mtime if track else None
self.tagger.tag_file(path, mtime=mtime)
self.tagger.tag_file(path, track.mtime if track else None)
else:
return True
return False

View File

@ -2,10 +2,8 @@
"""A custom Gio.ListModel for managing individual media in an album."""
import sqlite3
from gi.repository import GObject
from gi.repository import Gtk
from .. import format
from . import playlist
from . import table
from . import tracks
@ -36,26 +34,12 @@ class Medium(playlist.Playlist):
return self.get_album()
class Filter(table.KeySet):
"""Custom filter to hide media with empty names."""
def do_get_strictness(self) -> Gtk.FilterMatch:
"""Get the strictness of the filter."""
if (res := super().do_get_strictness()) == Gtk.FilterMatch.ALL:
res = Gtk.FilterMatch.SOME
return res
def do_match(self, medium: Medium) -> bool:
"""Check if the Medium matches the filter."""
return len(medium.name) > 0 if super().do_match(medium) else False
class Table(playlist.Table):
"""Our Media Table."""
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
"""Initialize the Media Table."""
super().__init__(sql=sql, filter=Filter(), autodelete=True,
super().__init__(sql=sql, autodelete=True,
system_tracks=False, **kwargs)
def do_construct(self, **kwargs) -> Medium:
@ -77,7 +61,6 @@ class Table(playlist.Table):
def do_sql_delete(self, medium: Medium) -> sqlite3.Cursor:
"""Delete a medium."""
medium.get_album().remove_medium(medium)
return self.sql("DELETE FROM media WHERE mediumid=?",
medium.mediumid)
@ -117,13 +100,6 @@ class Table(playlist.Table):
return self.sql(f"UPDATE media SET {column}=? WHERE mediumid=?",
newval, medium.mediumid)
def create(self, album: playlist.Playlist,
*args, **kwargs) -> Medium | None:
"""Create a new Medium playlist."""
if (medium := super().create(album, *args, **kwargs)) is not None:
album.add_medium(medium)
return medium
def rename(self, medium: Medium, new_name: str) -> bool:
"""Rename a medium."""
if (new_name := new_name.strip()) != medium.name:

View File

@ -1,7 +1,6 @@
# Copyright 2022 (c) Anna Schumaker
"""A customized Gio.ListStore for tracking Playlist GObjects."""
import sqlite3
import typing
from gi.repository import GObject
from gi.repository import Gio
from gi.repository import Gtk
@ -29,7 +28,6 @@ class Playlist(table.Row):
tracks_movable = GObject.Property(type=bool, default=False)
current_trackid = GObject.Property(type=int)
child_set = GObject.Property(type=table.TableSubset)
children = GObject.Property(type=Gtk.FilterListModel)
def __init__(self, table: Gio.ListModel, propertyid: int,
@ -50,27 +48,20 @@ class Playlist(table.Row):
self.table.remove_track(self, track)
return True
def add_children(self, child_table: table.Table, child_keys: set) -> None:
def add_children(self, child_table: table.Table,
child_filter: Gtk.Filter) -> None:
"""Create a FilterListModel for this playlist's children."""
self.child_set = table.TableSubset(child_table, keys=child_keys)
self.children = Gtk.FilterListModel.new(self.child_set,
child_table.get_filter())
self.children = Gtk.FilterListModel.new(child_table, child_filter)
self.children.set_incremental(True)
def do_update(self, column: str) -> bool:
"""Update a Playlist object."""
match column:
case "propertyid" | "name" | "n-tracks" | "child-set" | \
"children" | "user-tracks" | "tracks-loaded" | \
"tracks-movable": pass
case "propertyid" | "name" | "n-tracks" | "children" | \
"user-tracks" | "tracks-loaded" | "tracks-movable": pass
case _: return super().do_update(column)
return True
def add_child(self, child: typing.Self) -> None:
"""Add a child Playlist to this Playlist."""
self.child_set.add_row(child)
if self.child_set.keyset.n_keys == 1:
self.table.refilter(Gtk.FilterChange.LESS_STRICT)
def add_track(self, track: Track, *, idle: bool = False) -> None:
"""Add a Track to this Playlist."""
if self.table.add_track(self, track):
@ -80,10 +71,6 @@ class Playlist(table.Row):
"""Get a dictionary mapping for trackid -> sorted position."""
return self.table.get_track_order(self)
def has_child(self, child: typing.Self) -> bool:
"""Check if this Playlist has a specific child Playlist."""
return child in self.child_set
def has_track(self, track: Track) -> bool:
"""Check if a Track is on this Playlist."""
return track in self.tracks
@ -108,12 +95,6 @@ class Playlist(table.Row):
self.tracks_loaded = False
self.table.queue.push(self.load_tracks, now=not idle)
def remove_child(self, child: typing.Self) -> None:
"""Remove a child Playlist from this Playlist."""
self.child_set.remove_row(child)
if self.child_set.keyset.n_keys == 0:
self.table.refilter(Gtk.FilterChange.MORE_STRICT)
def remove_track(self, track: table.Row, *, idle: bool = False) -> None:
"""Remove a Track from this Playlist."""
self.table.queue.push(self.__remove_track, track, now=not idle)
@ -157,10 +138,6 @@ class Table(table.Table):
def __create_tree(self, plist: Playlist) -> Gtk.FilterListModel | None:
return plist.children
def __refilter(self, change_how: Gtk.FilterChange) -> bool:
self.get_filter().changed(change_how)
return True
def do_add_track(self, playlist: Playlist, track: Track) -> bool:
"""Add a Track to the Playlist."""
raise NotImplementedError
@ -262,11 +239,6 @@ class Table(table.Table):
playlist.sort_order = "user"
return res
def refilter(self, change_how: Gtk.FilterChange) -> None:
"""Schedule refiltering the Table."""
self.queue.cancel_task(self.__refilter)
self.queue.push(self.__refilter, change_how, first=True)
def remove_system_track(self, playlist: Playlist, track: Track) -> bool:
"""Remove a Track from a system Playlist."""
return self.sql("""DELETE FROM system_tracks

View File

@ -1,9 +1,7 @@
# Copyright 2022 (c) Anna Schumaker
"""A custom Gio.ListModel for working with playlists."""
import datetime
import sqlite3
from gi.repository import GObject
from .. import alarm
from . import playlist
from . import tracks
@ -59,11 +57,6 @@ class Table(playlist.Table):
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
"""Initialize the Playlists Table."""
super().__init__(sql=sql, system_tracks=False, **kwargs)
alarm.set_alarm(datetime.time(hour=0, minute=0, second=5),
self.__at_midnight)
def __at_midnight(self) -> None:
self.new_tracks.reload_tracks()
def __move_user_trackid(self, playlist: Playlist, trackid: int,
*, offset: int) -> bool:
@ -120,9 +113,6 @@ class Table(playlist.Table):
case self.previous:
self.add_system_track(playlist, track)
return True
case self.queued:
self.sql.set_active_playlist(playlist)
return self.add_user_track(playlist, track)
case self.unplayed: return track.playcount == 0
case _: return self.add_user_track(playlist, track)

View File

@ -1,6 +1,5 @@
# Copyright 2022 (c) Anna Schumaker
"""Base classes for database objects."""
import bisect
import sqlite3
from gi.repository import GObject
from gi.repository import Gio
@ -38,52 +37,44 @@ class Row(GObject.GObject):
raise NotImplementedError
class KeySet(Gtk.Filter):
"""A Gtk.Filter that also acts as a Python Set."""
class Filter(Gtk.Filter):
"""A Filter that can be used to search playlists."""
n_keys = GObject.Property(type=int)
def __init__(self, keys: set | None = None, **kwargs):
"""Set up our KeySet."""
"""Set up our Filter."""
super().__init__(**kwargs)
self._keys = keys
self.n_keys = len(keys) if keys is not None else -1
def __contains__(self, row: Row) -> bool:
"""Check if a Row is in the KeySet."""
return self._keys is None or row.primary_key in self._keys
def __sub__(self, rhs: Gtk.Filter) -> set[int]:
"""Subtract two KeySets and return the result."""
"""Subtract two Filters and return the result."""
match (self._keys, rhs._keys):
case (None, _): return None
case (_, None): return self._keys
case (_, _): return self._keys - rhs._keys
def __find_difference(self, new: set[any] | None) \
-> tuple[set, set, Gtk.FilterChange | None]:
if self._keys is None:
if new is None:
return (set(), set(), None)
return (set(), new, Gtk.FilterChange.MORE_STRICT)
elif new is None:
return (self._keys, set(), Gtk.FilterChange.LESS_STRICT)
removed = self._keys - new
added = new - self._keys
match len(removed), len(added):
case 0, 0: return (removed, added, None)
case _, 0: return (removed, added, Gtk.FilterChange.MORE_STRICT)
case 0, _: return (removed, added, Gtk.FilterChange.LESS_STRICT)
case _, _: return (removed, added, Gtk.FilterChange.DIFFERENT)
def __find_change(self, keys: set[any] | None) -> Gtk.FilterChange | None:
if keys == self._keys:
return None
elif keys is None:
return Gtk.FilterChange.LESS_STRICT
elif self._keys is None:
return Gtk.FilterChange.MORE_STRICT
elif keys.issuperset(self._keys):
return Gtk.FilterChange.LESS_STRICT
elif keys.issubset(self._keys):
return Gtk.FilterChange.MORE_STRICT
return Gtk.FilterChange.DIFFERENT
def changed(self, how: Gtk.FilterChange) -> None:
"""Notify that the KeySet has changed."""
"""Notify that the filter has changed."""
self.n_keys = len(self._keys) if self._keys is not None else -1
super().changed(how)
def do_get_strictness(self) -> Gtk.FilterMatch:
"""Get the strictness of the Gtk.Filter."""
"""Get the strictness of the filter."""
if self._keys is None:
return Gtk.FilterMatch.ALL
if len(self._keys) == 0:
@ -91,21 +82,19 @@ class KeySet(Gtk.Filter):
return Gtk.FilterMatch.SOME
def do_match(self, row: Row) -> bool:
"""Check if the Row is in the KeySet."""
"""Check if the Row matches the filter."""
return self._keys is None or row.primary_key in self._keys
def add_row(self, row: Row) -> None:
"""Add a Row to the KeySet."""
if row not in self:
"""Add a Row to the Filter."""
if self._keys is not None:
self._keys.add(row.primary_key)
self.emit("key-added", row.primary_key)
self.changed(Gtk.FilterChange.LESS_STRICT)
def remove_row(self, row: Row) -> None:
"""Remove a Row from the KeySet."""
if self._keys is not None and row in self:
"""Remove a Row from the Filter."""
if self._keys is not None:
self._keys.discard(row.primary_key)
self.emit("key-removed", row.primary_key)
self.changed(Gtk.FilterChange.MORE_STRICT)
@property
@ -116,23 +105,9 @@ class KeySet(Gtk.Filter):
@keys.setter
def keys(self, keys: set[any] | None) -> None:
"""Set the matching primary keys."""
(removed, added, change) = self.__find_difference(keys)
if change is not None:
if (how := self.__find_change(keys)) is not None:
self._keys = keys
self.emit("keys-changed", removed, added)
self.changed(change)
@GObject.Signal(arg_types=(int,))
def key_added(self, key: int) -> None:
"""Signal that a Row has been added to the KeySet."""
@GObject.Signal(arg_types=(int,))
def key_removed(self, key: int) -> None:
"""Signal that a Row has been removed from the KeySet."""
@GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT, GObject.TYPE_PYOBJECT))
def keys_changed(self, removed: set | None, added: set | None) -> None:
"""Signal that the KeySet has been directly modified."""
self.changed(how)
class Table(Gtk.FilterListModel):
@ -146,12 +121,12 @@ class Table(Gtk.FilterListModel):
loaded = GObject.Property(type=bool, default=False)
def __init__(self, sql: GObject.TYPE_PYOBJECT,
filter: KeySet | None = None,
filter: Filter | None = None,
queue: Queue | None = None, **kwargs):
"""Set up our Table object."""
super().__init__(sql=sql, rows=dict(),
super().__init__(sql=sql, rows=dict(), incremental=True,
store=store.SortedList(self.get_sort_key),
filter=(filter if filter else KeySet()),
filter=(filter if filter else Filter()),
queue=(queue if queue else Queue()), **kwargs)
self.set_model(self.store)
@ -213,7 +188,6 @@ class Table(Gtk.FilterListModel):
def delete(self, row: Row) -> bool:
"""Delete a Row from the Table."""
if row in self and self.do_sql_delete(row).rowcount == 1:
self.sql.commit()
self.store.remove(row)
del self.rows[row.primary_key]
return True
@ -227,8 +201,7 @@ class Table(Gtk.FilterListModel):
def filter(self, glob: str | None, *, now: bool = False) -> None:
"""Filter the displayed Rows."""
if glob is not None:
self.queue.cancel_task(self._filter_idle)
self.queue.push(self._filter_idle, glob, now=now, first=True)
self.queue.push(self._filter_idle, glob, now=now)
else:
self.get_filter().keys = None
@ -273,75 +246,3 @@ class Table(Gtk.FilterListModel):
def update(self, row: Row, column: str, newval) -> bool:
"""Update a Row."""
return self.do_sql_update(row, column, newval) is not None
class TableSubset(GObject.GObject, Gio.ListModel):
"""A list model containing a subset of the rows in the source Table."""
keyset = GObject.Property(type=KeySet)
table = GObject.Property(type=Table)
n_rows = GObject.Property(type=int)
def __init__(self, table: Table, *, keys: set[any] | None = None):
"""Initialize a KeySetModel."""
super().__init__(keyset=KeySet(set() if keys is None else keys),
table=table)
self._items = []
self.keyset.connect("key-added", self.__on_key_added)
self.keyset.connect("key-removed", self.__on_key_removed)
self.table.connect("notify::loaded", self.__notify_table_loaded)
def __contains__(self, row: Row) -> bool:
"""Check if the Row is in the internal KeySet."""
return row in self.keyset
def __bisect(self, key: any) -> int | None:
if self.table.loaded:
sort_key = self.table.get_sort_key(self.table.rows[key])
return bisect.bisect_left(self._items, sort_key,
key=self.table.get_sort_key)
return None
def __items_changed(self, position: int, removed: int, added: int) -> None:
self.n_rows = len(self._items)
self.items_changed(position, removed, added)
def __notify_table_loaded(self, table: Table, param) -> None:
if table.loaded and self.keyset.n_keys > 0:
self._items = sorted([table.rows[k] for k in self.keyset.keys],
key=self.table.get_sort_key)
self.__items_changed(0, 0, self.keyset.n_keys)
elif not table.loaded and self.n_rows > 0:
self._items = []
self.__items_changed(0, self.n_rows, 0)
def __on_key_added(self, keyset: KeySet, key: any) -> None:
if (pos := self.__bisect(key)) is not None:
self._items.insert(pos, self.table.rows[key])
self.__items_changed(pos, 0, 1)
def __on_key_removed(self, keyset: KeySet, key: any) -> None:
if (pos := self.__bisect(key)) is not None:
del self._items[pos]
self.__items_changed(pos, 1, 0)
def do_get_item_type(self) -> GObject.GType:
"""Get the Gio.ListModel item type."""
return Row.__gtype__
def do_get_n_items(self) -> int:
"""Get the number of Rows in the TableSubset."""
return self.n_rows
def do_get_item(self, n: int) -> int:
"""Get the nth item in the TableSubset."""
return self._items[n] if n < len(self._items) else None
def add_row(self, row: Row) -> None:
"""Add a row to the TableSubset."""
self.keyset.add_row(row)
def remove_row(self, row: Row) -> None:
"""Remove a row from the TableSubset."""
self.keyset.remove_row(row)

View File

@ -3,9 +3,9 @@
import emmental.audio.tagger
import musicbrainzngs
import pathlib
import threading
from gi.repository import GObject
from .. import audio
from .. import thread
from . import albums
from . import artists
from . import connection
@ -178,12 +178,24 @@ class Tags:
return year if year else self.db.years.create(raw_year)
class Thread(thread.Thread):
class Thread(threading.Thread):
"""A thread for tagging files without blocking the UI."""
def __init__(self):
"""Initialize the Tagger Thread."""
super().__init__()
self.ready = threading.Event()
self._connection = None
self._condition = threading.Condition()
self._file = None
self._mtime = None
self._tags = None
self.start()
def __close_connection(self) -> None:
if self._connection:
self._connection.close()
self._connection = None
def __get_connection(self) -> connection.Connection:
@ -201,31 +213,55 @@ class Thread(thread.Thread):
mb_res = musicbrainzngs.get_artist_by_id(artist.mbid)
artist.name = mb_res["artist"]["name"]
def do_get_result(self, result: thread.Data, db: GObject.TYPE_PYOBJECT,
library: playlist.Playlist) -> tuple:
def get_result(self, db: GObject.TYPE_PYOBJECT,
library: playlist.Playlist) \
-> tuple[pathlib.Path | None, Tags | None]:
"""Return the resulting Tags structure."""
tags = None if result.tags is None else Tags(db, result.tags, library)
return (result.path, tags)
with self._condition:
if not self.ready.is_set():
return (None, None)
def do_run_task(self, task: thread.Data) -> None:
tags = Tags(db, self._tags, library) if self._tags else None
res = (self._file, tags)
self._file = None
self._tags = None
return res
def run(self) -> None:
"""Sleep until we have work to do."""
with self._condition:
self.ready.set()
while self._condition.wait():
if self._file is None:
break
tags = emmental.audio.tagger.tag_file(self._file, self._mtime)
if tags is not None:
for artist in tags.artists:
self.__check_artist(artist)
self._tags = tags
self.ready.set()
self.__close_connection()
def stop(self) -> None:
"""Stop the thread."""
with self._condition:
self._file = None
self._mtime = None
self._condition.notify()
self.join()
def tag_file(self, file: pathlib.Path, mtime: float | None) -> None:
"""Tag a file."""
tags = emmental.audio.tagger.tag_file(task.path, task.mtime)
if tags is not None:
for artist in tags.artists:
self.__check_artist(artist)
self.set_result(path=task.path, tags=tags)
def do_stop(self) -> None:
"""Close the connection before stopping."""
if self._connection:
self._connection.close()
self._connection = None
def tag_file(self, path: pathlib.Path,
*, mtime: float | None = None) -> None:
"""Tag a file."""
self.set_task(path=path, mtime=mtime)
with self._condition:
self.ready.clear()
self._file = file
self._mtime = mtime
self._tags = None
self._condition.notify()
def untag_track(db: GObject.TYPE_PYOBJECT, track: tracks.Track) -> None:

View File

@ -90,7 +90,7 @@ class Track(table.Row):
return self.trackid
class Filter(table.KeySet):
class Filter(table.Filter):
"""A customized Filter that never sets strictness to FilterMatch.All."""
def do_get_strictness(self) -> Gtk.FilterMatch:
@ -200,12 +200,6 @@ class Table(table.Table):
return self.sql(f"UPDATE tracks SET {column}=? WHERE trackid=?",
newval, track.trackid)
def delete_listens(self, listenids: list[int]) -> None:
"""Delete the listens indicated by the provided listenids."""
self.sql.executemany("""DELETE FROM listenbrainz_queue
WHERE listenid=?""",
*[(id,) for id in listenids])
def get_artists(self, track: Track) -> list[table.Row]:
"""Get the set of Artists for a specific Track."""
rows = self.sql("""SELECT artistid FROM artist_tracks_view
@ -218,14 +212,6 @@ class Table(table.Table):
WHERE trackid=?""", track.trackid).fetchall()
return [self.sql.genres.rows.get(row["genreid"]) for row in rows]
def get_n_listens(self, n: int) -> list[tuple]:
"""Get the n most recent listens from the listenbrainz queue."""
cur = self.sql("""SELECT listenid, trackid, timestamp
FROM listenbrainz_queue ORDER BY timestamp DESC
LIMIT ?""", n)
return [(row["listenid"], self.rows[row["trackid"]], row["timestamp"])
for row in cur.fetchall()]
def map_sort_order(self, ordering: str) -> dict[int, int]:
"""Get a lookup table for Track sort keys."""
ordering = ordering if len(ordering) > 0 else "trackid"
@ -255,7 +241,6 @@ class Table(table.Table):
track.active = True
track.laststarted = cur.fetchone()["laststarted"]
self.current_track = track
self.sql.commit()
def stop_track(self, track: Track, played: bool) -> None:
"""Mark that a Track has been stopped."""
@ -284,16 +269,6 @@ class Table(table.Table):
self.sql.playlists.most_played.reload_tracks(idle=True)
self.sql.playlists.queued.remove_track(track)
self.sql.playlists.unplayed.remove_track(track)
self.emit("track-played", track)
self.sql.commit()
@GObject.Signal(arg_types=(Track,))
def track_played(self, track: Track) -> None:
"""Signal that a Track was played."""
if track is not None:
self.sql("""INSERT INTO listenbrainz_queue (trackid, timestamp)
VALUES (?, ?)""", track.trackid, track.lastplayed)
class TrackidSet(GObject.GObject):

View File

@ -1,38 +0,0 @@
/* Copyright 2023 (c) Anna Schumaker */
PRAGMA user_version = 2;
/*
* The `saved_track_data` table is missing the date added field, which
* causes restored tracks to show up in the "New Tracks" playlist again.
* We can fix this by storing the date that the track was initially added
* to the database, and restoring it later.
*/
ALTER TABLE saved_track_data
ADD COLUMN added DATE DEFAULT NULL;
UPDATE saved_track_data SET added = CURRENT_DATE;
DROP TRIGGER tracks_delete_save;
CREATE TRIGGER tracks_delete_save BEFORE DELETE ON tracks
WHEN OLD.mbid != "" BEGIN
INSERT INTO saved_track_data
(mbid, favorite, playcount, lastplayed, laststarted, added)
VALUES (OLD.mbid, OLD.favorite, OLD.playcount,
OLD.lastplayed, OLD.laststarted, OLD.added);
END;
DROP TRIGGER tracks_insert_restore;
CREATE TRIGGER tracks_insert_restore AFTER INSERT ON tracks
WHEN NEW.mbid != "" BEGIN
UPDATE tracks SET favorite = saved_track_data.favorite,
playcount = saved_track_data.playcount,
lastplayed = saved_track_data.lastplayed,
laststarted = saved_track_data.laststarted,
added = saved_track_data.added
FROM saved_track_data
WHERE tracks.mbid = saved_track_data.mbid AND
tracks.mbid = NEW.mbid;
DELETE FROM saved_track_data WHERE mbid = NEW.mbid;
END;

View File

@ -1,25 +0,0 @@
/* Copyright 2024 (c) Anna Schumaker */
PRAGMA user_version = 3;
/*
* The `listenbrainz_queue` table is used to store recently played tracks
* before submitting them to ListenBrainz. This gives us some form of offline
* recovery, since anything in this table needs to be submitted the next time
* we can successfully connect. As a bonus, I prepopulate this table using
* the last played data from tracks that have already been played when this
* table is created.
*/
CREATE TABLE listenbrainz_queue (
listenid INTEGER PRIMARY KEY,
trackid INTEGER REFERENCES tracks (trackid)
ON DELETE CASCADE
ON UPDATE CASCADE,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO listenbrainz_queue (trackid, timestamp)
SELECT trackid, lastplayed FROM tracks
WHERE lastplayed IS NOT NULL;

View File

@ -48,8 +48,6 @@ class Table(playlist.Table):
def do_sql_delete(self, year: Year) -> sqlite3.Cursor:
"""Delete a year."""
if year.parent is not None:
year.parent.remove_year(year)
return self.sql("DELETE FROM years WHERE year=?", year.year)
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
@ -73,10 +71,3 @@ class Table(playlist.Table):
"""Load a Year's Tracks from the database."""
return self.sql("""SELECT trackid FROM year_tracks_view
WHERE year=?""", year.year)
def create(self, *args, **kwargs) -> Year | None:
"""Create a new Year playlist."""
if (year := super().create(*args, **kwargs)) is not None:
if year.parent is not None:
year.parent.add_year(year)
return year

View File

@ -21,29 +21,29 @@ box.emmental-splitbutton>menubutton>button {
padding: 6px;
}
row.emmental-active-row {
listview > row:checked {
font-weight: bold;
background-color: alpha(@accent_color, 0.15);
background-color: alpha(@accent_color, 0.2);
}
row.emmental-active-row:hover {
background-color: alpha(@accent_color, 0.22);
listview > row:checked:hover {
background-color: alpha(@accent_color, 0.27);
}
row.emmental-active-row:active {
background-color: alpha(@accent_color, 0.31);
listview > row:checked:active {
background-color: alpha(@accent_color, 0.36);
}
row.emmental-active-row:selected {
background-color: alpha(@accent_color, 0.25);
listview > row:checked:selected {
background-color: alpha(@accent_color, 0.3);
}
row.emmental-active-row:selected:hover {
background-color: alpha(@accent_color, 0.28);
listview > row:checked:selected:hover {
background-color: alpha(@accent_color, 0.33);
}
row.emmental-active-row:selected:active {
background-color: alpha(@accent_color, 0.34);
listview > row:checked:selected:active {
background-color: alpha(@accent_color, 0.39);
}
image.emmental-sidebar-arrow {
@ -70,14 +70,6 @@ button.emmental-stop>image {
color: @red_3;
}
columnview.emmental-track-list > header {
background-color: @card_bg_color;
}
columnview.emmental-track-list > listview {
background-color: @card_bg_color;
}
columnview.emmental-track-list > listview > row > cell {
padding: 0px 2px;
min-height: 40px;

View File

@ -61,17 +61,17 @@ class ListRow(GObject.GObject):
@GObject.Property(type=bool, default=False)
def active(self) -> bool:
"""Get the active state of this Row."""
if self.listrow is not None:
return self.listrow.has_css_class("emmental-active-row")
if parent := self.listitem.get_child().get_parent():
return parent.get_state_flags() & Gtk.StateFlags.CHECKED
return False
@active.setter
def active(self, newval: bool) -> None:
if self.listrow is not None:
if parent := self.listitem.get_child().get_parent():
if newval:
self.listrow.add_css_class("emmental-active-row")
parent.set_state_flags(Gtk.StateFlags.CHECKED, False)
else:
self.listrow.remove_css_class("emmental-active-row")
parent.unset_state_flags(Gtk.StateFlags.CHECKED)
@GObject.Property(type=Gtk.Widget)
def child(self) -> Gtk.Widget | None:
@ -87,11 +87,6 @@ class ListRow(GObject.GObject):
"""Get the list item for this Row."""
return self.listitem.get_item()
@GObject.Property(type=Gtk.Widget)
def listrow(self) -> Gtk.Widget:
"""Get the listrow widget that our child widget is contained in."""
return self.listitem.props.child.props.parent
class InscriptionRow(ListRow):
"""A ListRow for displaying Gtk.Inscription widgets."""

View File

@ -17,16 +17,13 @@ gi.importlib.import_module("gi.repository.Gtk")
gi.importlib.import_module("gi.repository.Gst").init(sys.argv)
DEBUG_STR = "-debug" if __debug__ else ""
APPLICATION_ID = f"com.nowheycreamery.emmental{DEBUG_STR}"
APPLICATION_ID = f"com.nowheycreamery.emmental{'-debug' if __debug__ else ''}"
CSS_FILE = pathlib.Path(__file__).parent / "emmental.css"
CSS_PRIORITY = gi.repository.Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
CSS_PROVIDER = gi.repository.Gtk.CssProvider()
CSS_PROVIDER.load_from_path(str(CSS_FILE))
CACHE_DIR = pathlib.Path(xdg.BaseDirectory.save_cache_path("emmental"))
CACHE_DIR = CACHE_DIR / DEBUG_STR.lstrip("-")
DATA_DIR = pathlib.Path(xdg.BaseDirectory.save_data_path("emmental"))
RESOURCE_PATH = "/com/nowheycreamery/emmental"
@ -43,13 +40,6 @@ def add_style():
CSS_PROVIDER, CSS_PRIORITY)
def has_icon(icon_name: str):
"""Check if the icon theme has a specific icon."""
display = gi.repository.Gdk.Display.get_default()
theme = gi.repository.Gtk.IconTheme.get_for_display(display)
return theme.has_icon(icon_name)
def __version_string(subsystem, major, minor, micro):
return f"{subsystem} {major}.{minor}.{micro}"

View File

@ -5,11 +5,9 @@ import typing
from gi.repository import GObject
from gi.repository import Gtk
from gi.repository import Adw
from ..action import ActionEntry
from .. import db
from .. import buttons
from .. import gsetup
from . import listenbrainz
from . import open
from . import replaygain
from . import volume
@ -35,8 +33,6 @@ class Header(Gtk.HeaderBar):
sql = GObject.Property(type=db.Connection)
title = GObject.Property(type=str)
subtitle = GObject.Property(type=str)
listenbrainz_token = GObject.Property(type=str)
show_sidebar = GObject.Property(type=bool, default=False)
bg_enabled = GObject.Property(type=bool, default=False)
bg_volume = GObject.Property(type=float, default=0.5)
rg_enabled = GObject.Property(type=bool, default=False)
@ -46,27 +42,9 @@ class Header(Gtk.HeaderBar):
def __init__(self, sql: db.Connection, title: str):
"""Initialize the HeaderBar."""
super().__init__(title=title, subtitle=SUBTITLE, sql=sql)
self._open = open.Button()
self._title = Adw.WindowTitle(title=self.title, subtitle=self.subtitle,
tooltip_text=gsetup.env_string())
icon = "sidebar-show-symbolic"
self._show_sidebar = Gtk.ToggleButton(icon_name=icon, has_frame=False)
self._open = open.OpenRow()
self._listenbrainz = listenbrainz.ListenBrainzRow()
self._menu_box = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
self._menu_box.add_css_class("boxed-list")
self._menu_box.append(self._open)
self._menu_box.append(self._listenbrainz)
if __debug__:
self._settings = settings.Row(sql)
self._menu_box.append(self._settings)
icon = "open-menu-symbolic"
self._menu_button = buttons.PopoverButton(popover_child=self._menu_box,
icon_name=icon)
self._volume = volume.VolumeRow()
self._volume_icon = Gtk.Image(icon_name=_volume_icon(self.volume))
self._background = volume.BackgroundRow()
@ -77,21 +55,18 @@ class Header(Gtk.HeaderBar):
self._icons.append(self._volume_icon)
self._icons.append(self._background_icon)
self._vol_box = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
self._vol_box.add_css_class("boxed-list")
self._vol_box.append(self._volume)
self._vol_box.append(self._background)
self._vol_box.append(self._replaygain)
self._box = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
self._box.add_css_class("boxed-list")
self._box.append(self._volume)
self._box.append(self._background)
self._box.append(self._replaygain)
self._vol_button = buttons.PopoverButton(popover_child=self._vol_box,
child=self._icons,
has_frame=False, margin_end=6)
self._button = buttons.PopoverButton(popover_child=self._box,
child=self._icons,
has_frame=False, margin_end=6)
self.bind_property("title", self._title, "title")
self.bind_property("subtitle", self._title, "subtitle")
self.bind_property("listenbrainz-token", self._listenbrainz, "text")
self.bind_property("show-sidebar", self._show_sidebar, "active",
GObject.BindingFlags.BIDIRECTIONAL)
self.bind_property("bg-enabled", self._background, "enabled",
GObject.BindingFlags.BIDIRECTIONAL)
self.bind_property("bg-volume", self._background, "volume",
@ -103,15 +78,18 @@ class Header(Gtk.HeaderBar):
self.bind_property("volume", self._volume, "volume",
GObject.BindingFlags.BIDIRECTIONAL)
self.pack_start(self._show_sidebar)
self.pack_start(self._menu_button)
self.pack_start(self._open)
if __debug__:
self._window = settings.Window(sql)
self._settings = Gtk.Button(icon_name="settings-symbolic",
tooltip_text="open settings editor")
self._settings.connect("clicked", self.__run_settings)
self.pack_start(self._settings)
self.pack_end(self._vol_button)
self.pack_end(self._button)
self.set_title_widget(self._title)
self._menu_button.props.popover.connect("closed", self.__menu_closed)
self._open.connect("track-requested", self.__track_requested)
self._listenbrainz.connect("apply", self.__listenbrainz_apply)
self.connect("notify", self.__notify)
def __run_settings(self, button: Gtk.Button) -> None:
@ -134,52 +112,12 @@ class Header(Gtk.HeaderBar):
status = (f"volume: {round(self.volume * 100)}%\n"
f"background listening: {bg_status}\n"
f"normalizing: {rg_status}")
self._vol_button.set_tooltip_text(status)
self._button.set_tooltip_text(status)
def __listenbrainz_apply(self, entry: Adw.PasswordEntryRow) -> None:
self.listenbrainz_token = entry.get_text()
self._menu_button.popdown()
def __menu_closed(self, popover: Gtk.Popover) -> None:
self._listenbrainz.props.text = self.listenbrainz_token
def __track_requested(self, button: open.OpenRow,
def __track_requested(self, button: open.Button,
path: pathlib.Path) -> None:
self.emit("track-requested", path)
@GObject.Property(type=bool, default=True)
def listenbrainz_token_valid(self) -> bool:
"""Check if we think the listenbrainz token is valid."""
return not self._listenbrainz.has_css_class("warning")
@listenbrainz_token_valid.setter
def listenbrainz_token_valid(self, valid: bool) -> None:
if valid:
self._menu_button.remove_css_class("warning")
self._listenbrainz.remove_css_class("warning")
else:
win = self.get_ancestor(Gtk.Window)
win.post_toast("listenbrainz: user token is invalid")
self._menu_button.add_css_class("warning")
self._listenbrainz.add_css_class("warning")
@property
def accelerators(self) -> list[ActionEntry]:
"""Get a list of accelerators for the Header."""
res = [ActionEntry("open-file", self._open.activate, "<Control>o"),
ActionEntry("decrease-volume", self._volume.decrement,
"<Shift><Control>Down"),
ActionEntry("increase-volume", self._volume.increment,
"<Shift><Control>Up"),
ActionEntry("toggle-bg-mode", self._background.activate,
"<Shift><Control>b"),
ActionEntry("toggle-sidebar", self._show_sidebar.activate,
"<Control>bracketright")]
if __debug__:
res.append(ActionEntry("edit-settings", self._settings.activate,
"<Shift><Control>s"))
return res
@GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT,))
def track_requested(self, path: pathlib.Path) -> None:
"""Signal that a track has been requested."""

View File

@ -1,14 +0,0 @@
# Copyright 2024 (c) Anna Schumaker.
"""A custom Adw.PasswordEntryRow to set the user token."""
from gi.repository import Gtk
from gi.repository import Adw
def ListenBrainzRow() -> Adw.PasswordEntryRow:
"""Create a new PasswordEntryRow for entering the user token."""
row = Adw.PasswordEntryRow(title="ListenBrainz User Token",
show_apply_button=True)
row.prefix = Gtk.Image(icon_name="listenbrainz-logo-symbolic")
row.add_prefix(row.prefix)
return row

View File

@ -1,21 +1,19 @@
# Copyright 2023 (c) Anna Schumaker.
"""A custom Adw.ActionRow to select a file for playback."""
"""A custom Button that opens a FileDialog to select a file for playback."""
import pathlib
from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gio
from gi.repository import Gtk
from gi.repository import Adw
class OpenRow(Adw.ActionRow):
"""Our pre-configured open Adw.ActionRow."""
class Button(Gtk.Button):
"""Our pre-configured open button."""
def __init__(self):
"""Initialize our open ActionRow."""
super().__init__(activatable=True, title="Open File",
subtitle="Select a file for playback")
self._prefix = Gtk.Image(icon_name="document-open-symbolic")
"""Initialize our open button."""
super().__init__(icon_name="document-open-symbolic",
tooltip_text="open a file for playback")
self._filters = Gio.ListStore()
self._filter = Gtk.FileFilter(name="Audio Files",
mime_types=["inode/directory",
@ -25,9 +23,6 @@ class OpenRow(Adw.ActionRow):
self._filters.append(self._filter)
self.connect("activated", self.__on_activated)
self.add_prefix(self._prefix)
def __async_ready(self, dialog: Gtk.FileDialog, task: Gio.Task) -> None:
try:
file = dialog.open_finish(task)
@ -35,9 +30,8 @@ class OpenRow(Adw.ActionRow):
except GLib.Error:
pass
def __on_activated(self, row: Adw.ActionRow) -> None:
"""Handle activating an OpenRow."""
self.get_ancestor(Gtk.Popover).popdown()
def do_clicked(self) -> None:
"""Handle a click event."""
self._dialog.open(self.get_ancestor(Gtk.Window), None,
self.__async_ready)

View File

@ -64,21 +64,3 @@ class Window(Adw.Window):
def __filter(self, entry: entry.Filter) -> None:
self._selection.get_model().filter(entry.get_query())
class Row(Adw.ActionRow):
"""An Adw.ActionRow for opening the Settings Window."""
def __init__(self, sql: db.Connection):
"""Initialize our settings ActionRow."""
super().__init__(activatable=True, title="Edit Settings",
subtitle="Open the settings editor (debug only)")
self._prefix = Gtk.Image(icon_name="settings-symbolic")
self._window = Window(sql)
self.connect("activated", self.__on_activated)
self.add_prefix(self._prefix)
def __on_activated(self, row: Adw.ActionRow) -> None:
self.get_ancestor(Gtk.Popover).popdown()
self._window.present()

View File

@ -41,19 +41,17 @@ class VolumeRow(Gtk.ListBoxRow):
self._box.append(self._increment)
self.set_child(self._box)
self._decrement.connect("clicked", self.decrement)
self._decrement.connect("clicked", self.__decrement)
self._scale.connect("value-changed", self.__value_changed)
self._increment.connect("clicked", self.increment)
self._increment.connect("clicked", self.__increment)
self.bind_property("volume", self._adjustment, "value",
GObject.BindingFlags.BIDIRECTIONAL)
def decrement(self, button: Gtk.Button | None = None) -> None:
"""Decrease the volume by STEP_SIZE."""
def __decrement(self, button: Gtk.Button) -> None:
self._scale.set_value(self._scale.get_value() - STEP_SIZE)
def increment(self, button: Gtk.Button | None = None) -> None:
"""Increase the volume by STEP_SIZE."""
def __increment(self, button: Gtk.Button) -> None:
self._scale.set_value(self._scale.get_value() + STEP_SIZE)
def __value_changed(self, range: Gtk.Range) -> None:

View File

@ -1,61 +0,0 @@
# Copyright 2023 (c) Anna Schumaker.
"""Our adaptable layout that can rearrange widgets as the window is resized."""
from gi.repository import GObject
from gi.repository import Gtk
from gi.repository import Adw
MIN_WIDTH = Adw.BreakpointConditionLengthType.MIN_WIDTH
class Layout(Adw.Bin):
"""A widget that can rearrange based on window dimensions."""
show_sidebar = GObject.Property(type=bool, default=False)
wide_view = GObject.Property(type=bool, default=False)
def __init__(self, *, content: Gtk.Widget = None,
sidebar: Gtk.Widget = None):
"""Initialize our Layout widget."""
super().__init__()
self._split_view = Adw.OverlaySplitView(content=content,
sidebar=sidebar,
collapsed=not self.wide_view)
self.props.child = self._split_view
self.bind_property("show-sidebar", self._split_view, "show-sidebar",
GObject.BindingFlags.BIDIRECTIONAL)
self.bind_property("wide-view", self._split_view, "collapsed",
GObject.BindingFlags.INVERT_BOOLEAN)
def __define_breakpoint(self, property: str, value: bool,
length: int) -> Adw.Breakpoint:
condition = Adw.BreakpointCondition.new_length(MIN_WIDTH, length,
Adw.LengthUnit.SP)
breakpoint = Adw.Breakpoint.new(condition)
breakpoint.add_setter(self, property, GObject.Value(bool, value))
return breakpoint
@GObject.Property(type=Gtk.Widget)
def content(self) -> Gtk.Widget:
"""Get the content widget for the Layout."""
return self._split_view.props.content
@content.setter
def content(self, widget: Gtk.Widget) -> None:
self._split_view.props.content = widget
@GObject.Property(type=Gtk.Widget)
def sidebar(self) -> Gtk.Widget:
"""Get the sidebar widget for the Layout."""
return self._split_view.props.sidebar
@sidebar.setter
def sidebar(self, widget: Gtk.Widget) -> None:
self._split_view.props.sidebar = widget
@property
def breakpoints(self) -> list[Adw.Breakpoint]:
"""Get a list of breakpoints supported by the layout."""
return [self.__define_breakpoint("wide-view", True, 1000)]

View File

@ -1,114 +0,0 @@
# Copyright 2024 (c) Anna Schumaker.
"""Our ListenBrainz custom GObject."""
from gi.repository import GObject
from gi.repository import GLib
from .. import db
from . import listen
from . import thread
from . import task
class ListenBrainz(GObject.GObject):
"""Our main ListenBrainz GObject."""
sql = GObject.Property(type=db.Connection)
offline = GObject.Property(type=bool, default=True)
user_token = GObject.Property(type=str)
valid_token = GObject.Property(type=bool, default=True)
now_playing = GObject.Property(type=db.tracks.Track)
def __init__(self, sql: db.Connection):
"""Initialize the ListenBrainz GObject."""
super().__init__(sql=sql)
self._queue = task.Queue()
self._thread = thread.Thread()
self._idle_id = None
self._timeout_id = None
self.connect("notify::offline", self.__notify_offline)
self.connect("notify::user-token", self.__notify_user_token)
self.connect("notify::now-playing", self.__notify_now_playing)
def __check_connected(self) -> bool:
return len(self.user_token) and self.valid_token and not self.offline
def __check_online(self) -> None:
self.notify("user-token")
def __check_result(self) -> None:
if (res := self._thread.get_result()) is not None:
self.valid_token = res.valid
self.offline = res.offline
if res.op == "submit-listens" and self.valid_token \
and not self.offline:
listens = [lsn.listenid for lsn in res.listens]
self.sql.tracks.delete_listens(listens)
def __parse_task(self, op: str, *args) -> bool:
match op:
case "clear-token":
self._thread.clear_user_token()
case "now-playing":
self._thread.submit_now_playing(listen.Listen(*args))
case "set-token":
self._thread.set_user_token(*args)
case "submit-listens":
listens = self.sql.tracks.get_n_listens(50)
if len(listens) == 0:
self._idle_id = None
return GLib.SOURCE_REMOVE
self._thread.submit_listens([listen.Listen(trk, listenid=id,
listened_at=ts)
for (id, trk, ts) in listens])
return GLib.SOURCE_CONTINUE
def __idle_work(self) -> bool:
if self.sql.loaded and self._thread.ready.is_set():
self.__check_result()
return self.__parse_task(*self._queue.pop())
return GLib.SOURCE_CONTINUE
def __idle_start(self) -> None:
if self._idle_id is None:
self._idle_id = GLib.idle_add(self.__idle_work)
def __notify_offline(self, listenbrainz: GObject.GObject,
param: GObject.ParamSpec) -> None:
if self.offline and self._timeout_id is None:
self._timeout_id = GLib.timeout_add_seconds(300,
self.__check_online)
elif not self.offline and self._timeout_id is not None:
self.__source_stop("_timeout_id")
def __notify_user_token(self, listenbrainz: GObject.GObject,
param: GObject.ParamSpec) -> None:
match self.user_token:
case "": self._queue.push("clear-token")
case _: self._queue.push("set-token", self.user_token)
self.__idle_start()
def __notify_now_playing(self, listenbrainz: GObject.GObject,
param: GObject.ParamSpec) -> None:
if self.now_playing is not None:
self._queue.push("now-playing", self.now_playing)
if self.__check_connected():
self.__idle_start()
else:
self._queue.clear("now-playing")
def __source_stop(self, srcid: str) -> None:
if (id := getattr(self, srcid)) is not None:
GLib.source_remove(id)
setattr(self, srcid, None)
def stop(self) -> None:
"""Stop the ListenBrainz thread."""
self.__source_stop("_idle_id")
self.__source_stop("_timeout_id")
self._thread.stop()
def submit_listens(self, *args) -> None:
"""Submit recent listens to ListenBrainz."""
if self.__check_connected():
self.__idle_start()

View File

@ -1,28 +0,0 @@
# Copyright 2024 (c) Anna Schumaker.
"""Convert a db.track.Track to a liblistenbrainz.Listen."""
import datetime
import dateutil.tz
import liblistenbrainz
from .. import db
from .. import gsetup
class Listen(liblistenbrainz.Listen):
"""A single ListenBrainz Listen."""
def __init__(self, track: db.tracks.Track, *, listenid: int = None,
listened_at: datetime.datetime = None):
"""Initialize our Listen class."""
album = track.get_medium().get_album()
artists = [a.mbid for a in track.get_artists() if len(a.mbid) > 0]
album_mbid = album.mbid if len(album.mbid) > 0 else None
super().__init__(track.title, track.artist, release_name=album.name,
artist_mbids=artists, release_group_mbid=album_mbid,
tracknumber=track.number,
additional_info={"media_player":
f"emmental{gsetup.DEBUG_STR}"})
self.listenid = listenid
if listened_at is not None:
when = listened_at.replace(tzinfo=dateutil.tz.tzutc())
self.listened_at = when.astimezone().timestamp()

View File

@ -1,31 +0,0 @@
# Copyright 2024 (c) Anna Schumaker.
"""Our ListenBrainz operation priority queue."""
class Queue:
"""A queue for prioritizing ListenBrainz operations."""
def __init__(self):
"""Initialize the task Queue."""
self._set_token = None
self._now_playing = None
def clear(self, op: str) -> None:
"""Clear a pending operation."""
match op:
case "clear-token" | "set-token": self._set_token = None
case "now-playing": self._now_playing = None
def push(self, op: str, *args) -> None:
"""Push an operation onto the queue."""
match op:
case "clear-token" | "set-token": self._set_token = (op, *args)
case "now-playing": self._now_playing = (op, *args)
def pop(self) -> tuple:
"""Pop an operation off the queue."""
if (res := self._set_token) is not None:
self._set_token = None
elif (res := self._now_playing) is not None:
self._now_playing = None
return res if res is not None else ("submit-listens",)

View File

@ -1,96 +0,0 @@
# Copyright 2024 (c) Anna Schumaker.
"""Our ListenBrainz client thread."""
import liblistenbrainz
import requests
from .. import thread
class Thread(thread.Thread):
"""Thread for submitting listens to ListenBrainz."""
def __init__(self):
"""Initialize the ListenBrainz Thread object."""
super().__init__()
self._client = liblistenbrainz.client.ListenBrainz()
def __print(self, text: str) -> None:
print(f"listenbrainz: {text}")
def __set_user_token(self, token: str) -> None:
try:
self._client.set_auth_token(token)
self.set_result("set-token", token=token)
except liblistenbrainz.errors.InvalidAuthTokenException:
self.set_result("set-token", token=token, valid=False)
except requests.exceptions.ConnectionError:
self.set_result("set-token", token=token, offline=True)
def __submit_now_playing(self, listen: liblistenbrainz.Listen) -> None:
try:
self._client.submit_playing_now(listen)
self.set_result("now-playing")
except liblistenbrainz.errors.ListenBrainzAPIException:
self.set_result("now-playing", valid=False)
except requests.exceptions.ConnectionError:
self.set_result("now-playing", offline=True)
def __submit_listens(self, listens: list[liblistenbrainz.Listen]) -> None:
try:
if len(listens) == 1:
self._client.submit_single_listen(listens[0])
else:
self._client.submit_multiple_listens(listens)
self.set_result("submit-listens", listens=listens)
except liblistenbrainz.errors.ListenBrainzAPIException:
self.set_result("submit-listens", listens=listens, valid=False)
except requests.exceptions.ConnectionError:
self.set_result("submit-listens", listens=listens, offline=True)
def do_run_task(self, task: thread.Data) -> None:
"""Call a specific listenbrainz operation."""
match task.op:
case "clear-token":
self._client.set_auth_token(None, check_validity=False)
self.set_result("clear-token")
case "now-playing":
self.__submit_now_playing(task.listen)
case "set-token":
self.__set_user_token(task.token)
case "submit-listens":
self.__submit_listens(task.listens)
def clear_user_token(self) -> None:
"""Schedule clearing the user token."""
self.__print("clearing user token")
self.set_task(op="clear-token")
def get_result(self, **kwargs) -> thread.Data:
"""Get the result of a listenbrainz task."""
if (res := super().get_result(**kwargs)) is not None:
if not res.valid:
self.__print("user token is invalid")
if res.offline:
self.__print("offline")
return res
def set_result(self, op: str, *, valid: bool = True,
offline: bool = False, **kwargs) -> None:
"""Set the Thread result with a standard format for all ops."""
super().set_result(op=op, valid=valid, offline=offline, **kwargs)
def set_user_token(self, token: str) -> None:
"""Schedule setting the user token."""
self.__print("setting user token")
self.set_task(op="set-token", token=token)
def submit_now_playing(self, listen: liblistenbrainz.Listen) -> None:
"""Schedule setting the now-playing track."""
self.__print(f"now playing '{listen.track_name}' " +
f"by '{listen.artist_name}'")
self.set_task(op="now-playing", listen=listen)
def submit_listens(self, listens: list[liblistenbrainz.Listen]) -> None:
"""Submit listens to listenbrainz."""
num = len(listens)
self.__print(f"submitting {num} listen{'s' if num != 1 else ''}")
self.set_task(op="submit-listens", listens=listens)

View File

@ -2,11 +2,10 @@
"""Implement the MPRIS2 Specification."""
from gi.repository import GObject
from gi.repository import Gio
from .. import gsetup
from . import application
from . import player
MPRIS2_ID = f"org.mpris.MediaPlayer2.emmental{gsetup.DEBUG_STR}"
MPRIS2_ID = f"org.mpris.MediaPlayer2.emmental{'-debug' if __debug__ else ''}"
class Connection(GObject.GObject):

View File

@ -2,7 +2,6 @@
"""A card for displaying information about the currently playing track."""
from gi.repository import GObject
from gi.repository import Gtk
from ..action import ActionEntry
from .. import buttons
from . import artwork
from . import controls
@ -29,7 +28,6 @@ class Card(Gtk.Box):
have_previous = GObject.Property(type=bool, default=False)
have_track = GObject.Property(type=bool, default=False)
have_db_track = GObject.Property(type=bool, default=False)
editing = GObject.Property(type=bool, default=False)
def __init__(self):
"""Initialize a Now Playing Card."""
@ -41,15 +39,12 @@ class Card(Gtk.Box):
self._bottom_box = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 0)
self._favorite = buttons.ImageToggle("heart-filled",
"heart-outline-thick-symbolic",
"remove from 'Favorite Tracks'",
"add to 'Favorite Tracks'",
large_icon=True,
icon_size=Gtk.IconSize.LARGE,
has_frame=False, sensitive=False,
valign=Gtk.Align.CENTER)
self._jump = buttons.Button(icon_name="arrow4-down-symbolic",
tooltip_text="scroll to current track",
large_icon=True, sensitive=False,
has_frame=False, valign=Gtk.Align.CENTER)
self._jump = buttons.Button(icon_name="go-jump", has_frame=False,
icon_size=Gtk.IconSize.LARGE,
valign=Gtk.Align.CENTER, sensitive=False)
self._seeker = seeker.Scale(sensitive=False)
self.bind_property("artwork", self._artwork, "filepath")
@ -57,8 +52,7 @@ class Card(Gtk.Box):
self.bind_property(prop, self._tags, prop)
self.bind_property("prefer-artist", self._tags, "prefer-artist",
GObject.BindingFlags.BIDIRECTIONAL)
for prop in ["playing", "editing", "have-next",
"have-previous", "have-track"]:
for prop in ["playing", "have-next", "have-previous", "have-track"]:
self.bind_property(prop, self._controls, prop)
self.bind_property("have-db-track", self._jump, "sensitive")
self.bind_property("have-db-track", self._favorite, "sensitive")
@ -96,29 +90,6 @@ class Card(Gtk.Box):
value: float) -> None:
self.emit("seek", value)
@property
def accelerators(self) -> list[ActionEntry]:
"""Get a list of accelerators for the Now Playing card."""
return [ActionEntry("toggle-favorite", self._favorite.activate,
"<Control>f", enabled=(self, "have-db-track")),
ActionEntry("goto-current-track", self._jump.activate,
"<Control>g", enabled=(self, "have-db-track")),
ActionEntry("next-track", self._controls.activate_next,
"Return", enabled=(self._controls,
"can-activate-next")),
ActionEntry("previous-track", self._controls.activate_previous,
"BackSpace", enabled=(self._controls,
"can-activate-prev")),
ActionEntry("play-pause", self._controls.activate_play_pause,
"space", enabled=(self._controls,
"can-activate-play-pause")),
ActionEntry("inc-autopause", self._controls.increase_autopause,
"<Control>plus", "<Control>KP_Add",
enabled=(self, "playing")),
ActionEntry("dec-autopause", self._controls.decrease_autopause,
"<Control>minus", "<Control>KP_Subtract",
enabled=(self, "playing"))]
@GObject.Signal
def jump(self) -> None:
"""Signal that the Tracklist should be scrolled."""

View File

@ -8,40 +8,39 @@ from .. import gsetup
FALLBACK_RESOURCE = f"{gsetup.RESOURCE_ICONS}/emmental.svg"
class Artwork(Gtk.Picture):
"""Our custom Album Art widget takes a pathlib.Path."""
class Artwork(Gtk.Frame):
"""Our custom Album Art widget that draws a border around a picture."""
def __init__(self):
"""Initialize the Album Art widget."""
super().__init__(content_fit=Gtk.ContentFit.CONTAIN,
margin_top=6, margin_bottom=6,
margin_start=6, margin_end=6,
halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER)
super().__init__(margin_top=6, margin_bottom=6, margin_start=6,
margin_end=6, halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER)
self._picture = Gtk.Picture(content_fit=Gtk.ContentFit.CONTAIN)
self._fullsize = Gtk.Picture(content_fit=Gtk.ContentFit.FILL)
self._popover = Gtk.Popover(child=self._fullsize)
self._popover.set_parent(self)
self._clicked = Gtk.GestureClick()
self._clicked.connect("released", self.clicked)
self.add_controller(self._clicked)
self.add_css_class("card")
self._popover.set_parent(self)
self.set_child(self._picture)
self.filepath = None
@GObject.Property(type=GObject.TYPE_PYOBJECT)
def filepath(self) -> pathlib.Path:
"""Get the current artwork path."""
name = self.get_file().get_parse_name()
name = self._picture.get_file().get_parse_name()
return None if name.startswith("resource:") else pathlib.Path(name)
@filepath.setter
def filepath(self, path: pathlib.Path) -> None:
if path is not None:
self.set_filename(str(path))
self._picture.set_filename(str(path))
self._fullsize.set_filename(str(path))
else:
self.set_resource(FALLBACK_RESOURCE)
self._picture.set_resource(FALLBACK_RESOURCE)
self._fullsize.set_resource(FALLBACK_RESOURCE)
def clicked(self, gesture: Gtk.GestureClick, n_press: int,

View File

@ -25,8 +25,6 @@ class Entry(Gtk.Entry):
self.connect("icon_release", self.__icon_release)
self.connect("notify::value", self.__update_text)
self.add_css_class("card")
def __set_value(self, newval: int) -> bool:
if -1 <= newval <= 99:
self.value = newval
@ -91,16 +89,6 @@ class Entry(Gtk.Entry):
self.set_icon_sensitive(Gtk.EntryIconPosition.SECONDARY,
self.value < 99)
def decrement(self) -> None:
"""Decrease the autopause count by 1."""
if self.value > -1:
self.value -= 1
def increment(self) -> None:
"""Increase the autopause count by 1."""
if self.value < 99:
self.value += 1
class Button(buttons.PopoverButton):
"""A PopoverButton that displays Autopause count."""
@ -126,11 +114,3 @@ class Button(buttons.PopoverButton):
def __notify_value(self, button: buttons.PopoverButton, param) -> None:
text = str(self.value) if self.value > -1 else ""
self._count.set_markup(f"<small>{text}</small>")
def decrement(self) -> None:
"""Decrease the autopause value."""
self.popover_child.decrement()
def increment(self) -> None:
"""Increase the autopause value."""
self.popover_child.increment()

View File

@ -14,7 +14,7 @@ class PillButton(buttons.Button):
def __init__(self, **kwargs):
"""Initialize a Pill Button."""
super().__init__(large_icon=True, **kwargs)
super().__init__(icon_size=Gtk.IconSize.LARGE, **kwargs)
self.add_css_class("pill")
@ -28,11 +28,6 @@ class Controls(Gtk.Box):
have_previous = GObject.Property(type=bool, default=False)
have_track = GObject.Property(type=bool, default=False)
editing = GObject.Property(type=bool, default=False)
can_activate_next = GObject.Property(type=bool, default=False)
can_activate_prev = GObject.Property(type=bool, default=False)
can_activate_play_pause = GObject.Property(type=bool, default=False)
def __init__(self):
"""Initialize the Controls."""
super().__init__(valign=Gtk.Align.START, homogeneous=True,
@ -42,16 +37,14 @@ class Controls(Gtk.Box):
self._autopause = autopause.Button()
self._prev = PillButton(icon_name="media-skip-backward",
tooltip_text="previous track", sensitive=False)
self._play = PillButton(icon_name="play-large", tooltip_text="play",
sensitive=False)
self._play = PillButton(icon_name="play-large", sensitive=False)
self._pause = buttons.SplitButton(icon_name="pause-large",
large_icon=True,
tooltip_text="pause",
icon_size=Gtk.IconSize.LARGE,
secondary=self._autopause,
visible=False, sensitive=False)
self._next = PillButton(icon_name="media-skip-forward",
tooltip_text="next track", sensitive=False)
sensitive=False)
for button in [self._prev, self._play, self._pause, self._next]:
self.append(button)
@ -70,59 +63,19 @@ class Controls(Gtk.Box):
self.bind_property("have-previous", self._prev, "sensitive")
self.bind_property("have-track", self._play, "sensitive")
self.bind_property("have-track", self._pause, "sensitive")
self.connect("notify", self.__notify)
self.connect("notify::playing", self.__notify_playing)
self.add_css_class("linked")
def __on_click(self, button: Gtk.Button, signal: str) -> None:
self.emit(signal)
def __notify_playing(self, controls: Gtk.Box, param) -> None:
def __notify_playing(self, controls, param) -> None:
if not self.playing and self.autopause != -1:
if win := self.get_ancestor(window.Window):
win.post_toast("Autopause Cancelled")
self.autopause = -1
def __notify(self, controls: Gtk.Box, param) -> None:
match param.name:
case "editing":
allowed = not self.editing
self.can_activate_next = self.have_next and allowed
self.can_activate_prev = self.have_previous and allowed
self.can_activate_play_pause = self.have_track and allowed
case "playing": self.__notify_playing(controls, param)
case "have-next":
self.can_activate_next = self.have_next and not self.editing
case "have-previous":
can_activate = self.have_previous and not self.editing
self.can_activate_prev = can_activate
case "have-track":
can_activate = self.have_track and not self.editing
self.can_activate_play_pause = can_activate
def activate_next(self) -> None:
"""Activate the Next button."""
self._next.activate()
def activate_previous(self) -> None:
"""Activate the Previous button."""
self._prev.activate()
def activate_play_pause(self) -> None:
"""Activate the Play or Pause button."""
if self.playing:
self._pause.activate()
else:
self._play.activate()
def decrease_autopause(self) -> None:
"""Decrease the autopause count."""
self._autopause.decrement()
def increase_autopause(self) -> None:
"""Increase the autopause count."""
self._autopause.increment()
@GObject.Signal
def previous(self) -> None:
"""Signals that the Previous button has been clicked."""

View File

@ -6,7 +6,7 @@ Version = GLib.OptionEntry()
Version.long_name = "version"
Version.short_name = ord("v")
Version.flags = GLib.OptionFlags.NONE
# Version.arg = GLib.OptionArg.NONE
Version.arg = GLib.OptionArg.NONE
Version.arg_data = None
Version.description = "Print version information and exit"
Version.arg_description = None

View File

@ -1,7 +1,6 @@
# Copyright 2022 (c) Anna Schumaker.
"""A card for displaying the list of playlists."""
from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gtk
from . import artist
from . import decade
@ -9,7 +8,6 @@ from . import genre
from . import library
from . import playlist
from . import section
from ..action import ActionEntry
from .. import db
from .. import entry
@ -25,93 +23,58 @@ class Card(Gtk.Box):
"""Set up the Sidebar widget."""
super().__init__(sql=sql, orientation=Gtk.Orientation.VERTICAL,
sensitive=False, **kwargs)
self._header = Gtk.CenterBox()
self._filter = entry.Filter("playlists", hexpand=True)
self._jump = Gtk.Button(icon_name="arrow4-down-symbolic",
tooltip_text="scroll to current playlist")
self._filter = entry.Filter("playlists")
self._playlists = playlist.Section(self.sql.playlists)
self._artists = artist.Section(self.sql.artists, self.sql.albums)
self._genres = genre.Section(self.sql.genres)
self._decades = decade.Section(self.sql.decades, self.sql.years)
self._libraries = library.Section(self.sql.libraries)
self._view = section.View(sql)
self._group = section.Group(sql)
self._header.set_center_widget(self._filter)
self._header.set_end_widget(self._jump)
self.append(self._header)
self.append(self._filter)
for sect in [self._playlists, self._artists, self._genres,
self._decades, self._libraries]:
self._view.add(sect)
self.append(self._view)
self.append(sect)
self._group.add(sect)
self._view.bind_property("selected-playlist",
self, "selected-playlist")
self._group.bind_property("selected-playlist",
self, "selected-playlist")
self.bind_property("show-all-artists", self._artists, "show-all",
GObject.BindingFlags.BIDIRECTIONAL)
self._filter.connect("search-changed", self.__search_changed)
self._jump.connect("clicked", self.__jump_to_playlist)
self.sql.connect("notify::loaded", self.__database_loaded)
self.sql.connect("table-loaded", self.__table_loaded)
self._header.add_css_class("toolbar")
self.add_css_class("background")
self.add_css_class("linked")
self.add_css_class("card")
def __jump_to_playlist(self, jump: Gtk.Button) -> None:
self.select_playlist(self.sql.active_playlist)
def __search_changed(self, entry: entry.Filter) -> None:
self.sql.filter(entry.get_query())
def __database_loaded(self, sql: db.Connection, param: GObject.ParamSpec):
self.set_sensitive(sql.loaded)
if sql.loaded is True:
self.select_playlist(sql.active_playlist, 150)
if len(sql.libraries) == 0:
self._libraries.extra_widget.emit("clicked")
def __table_loaded(self, sql: db.Connection, table: db.table.Table):
if self.get_sensitive() is False:
if False not in {tbl.loaded for tbl in sql.playlist_tables()}:
self.set_sensitive(True)
self.select_playlist(sql.active_playlist)
if len(sql.libraries) == 0:
self._libraries.extra_widget.emit("clicked")
def __select_playlist(self, playlist: db.playlist.Playlist) -> bool:
if playlist is not None:
section = self.table_section(playlist.table)
if not section.active:
section.active = True
return GLib.SOURCE_CONTINUE
section.select_playlist(playlist)
return GLib.SOURCE_REMOVE
def select_playlist(self, playlist: db.playlist.Playlist,
timeout: int = 0) -> None:
def select_playlist(self, playlist: db.playlist.Playlist) -> None:
"""Set the current active playlist."""
GLib.timeout_add(timeout, self.__select_playlist, playlist)
if playlist is not None:
match playlist.table:
case self.sql.playlists:
section = self._playlists
case self.sql.artists | self.sql.albums | self.sql.media:
section = self._artists
case self.sql.genres:
section = self._genres
case self.sql.decades | self.sql.years:
section = self._decades
case self.sql.libraries:
section = self._libraries
def table_section(self, table: db.playlist.Table) -> section.Section:
"""Get the Section associated with a specific Playlist Table."""
match table:
case self.sql.playlists:
return self._playlists
case self.sql.artists | self.sql.albums | self.sql.media:
return self._artists
case self.sql.genres:
return self._genres
case self.sql.decades | self.sql.years:
return self._decades
case self.sql.libraries:
return self._libraries
@property
def accelerators(self) -> list[ActionEntry]:
"""Get a list of accelerators for the Sidebar."""
return [ActionEntry("focus-search-playlist", self._filter.grab_focus,
"<Control>question", enabled=(self, "sensitive")),
ActionEntry("goto-active-playlist", self._jump.activate,
"<Control><Alt>g", enabled=(self, "sensitive")),
ActionEntry("goto-playlists", self._playlists.activate,
"<Shift><Control>p", enabled=(self, "sensitive")),
ActionEntry("goto-artists", self._artists.activate,
"<Shift><Control>a", enabled=(self, "sensitive")),
ActionEntry("goto-genres", self._genres.activate,
"<Shift><Control>g", enabled=(self, "sensitive")),
ActionEntry("goto-decades", self._decades.activate,
"<Shift><Control>d", enabled=(self, "sensitive")),
ActionEntry("goto-libraries", self._libraries.activate,
"<Shift><Control>l", enabled=(self, "sensitive"))]
section.active = True
section.select_playlist(playlist)

View File

@ -1,6 +1,7 @@
# Copyright 2022 (c) Anna Schumaker.
"""Displays our artist and album tree."""
from gi.repository import GObject
from gi.repository import Gtk
from ..buttons import ImageToggle
from .. import db
from . import row
@ -35,9 +36,8 @@ class Section(section.Section):
subtitle="0 artists, 0 albums",
icon_name="library-artists", album_table=album_table)
self.extra_widget = ImageToggle("music-artist", "music-artist2",
"show album artists",
"show all artists",
large_icon=False, has_frame=False)
icon_size=Gtk.IconSize.NORMAL,
has_frame=False)
self.album_table.connect("items-changed", self.__update_subtitle)
self.bind_property("show-all", self.extra_widget, "active",
GObject.BindingFlags.BIDIRECTIONAL)

View File

@ -55,7 +55,7 @@ class Header(Gtk.Box):
self.bind_property("reveal-widget", self._revealer, "child")
self.bind_property("animation", self._revealer, "transition-type")
self._clicked.connect("released", self.activate)
self._clicked.connect("released", self.__clicked)
self.connect("notify::active", self.__notify_active)
self._box.append(self._icon)
@ -70,12 +70,12 @@ class Header(Gtk.Box):
self.append(self._overlay)
self.append(self._revealer)
def __clicked(self, gesture: Gtk.GestureClick, n_press: int,
x: int, y: int) -> None:
self.active = True
def __notify_active(self, header, param) -> None:
if self.active:
self._arrow.set_state_flags(Gtk.StateFlags.CHECKED, False)
else:
self._arrow.unset_state_flags(Gtk.StateFlags.CHECKED)
def activate(self, *args) -> None:
"""Activate the Header."""
self.active = True

View File

@ -4,9 +4,9 @@ import pathlib
from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gio
from gi.repository import Gdk
from gi.repository import Gtk
from gi.repository import Adw
from .. import texture
IMAGE_FILTERS = Gio.ListStore()
@ -37,7 +37,11 @@ class Icon(Adw.Bin):
self.set_child(self._icon)
def __notify_filepath(self, icon: Adw.Bin, param) -> None:
self._icon.set_custom_image(texture.CACHE[self.filepath])
if self.filepath is None:
texture = None
else:
texture = Gdk.Texture.new_from_filename(str(self.filepath))
self._icon.set_custom_image(texture)
class Settable(Icon):
@ -57,9 +61,7 @@ class Settable(Icon):
def __async_ready(self, dialog: Gtk.FileDialog, task: Gio.Task) -> None:
try:
file = dialog.open_finish(task)
path = pathlib.Path(file.get_path())
texture.CACHE.drop(path)
self.filepath = path
self.filepath = pathlib.Path(file.get_path())
except GLib.Error:
self.filepath = None

View File

@ -39,10 +39,9 @@ class Section(section.Section):
def __init__(self, table=db.libraries.Table):
"""Initialize our library path section."""
super().__init__(table, LibraryRow, icon_name="library-music-symbolic",
super().__init__(table, LibraryRow, icon_name="library-music",
title="Library Paths", subtitle="0 library paths")
self.extra_widget = Gtk.Button(icon_name="folder-new", has_frame=False,
tooltip_text="add new library path")
self.extra_widget = Gtk.Button(icon_name="folder-new", has_frame=False)
self._dialog = Gtk.FileDialog(title="Pick a Directory",
filters=DIRECTORY_FILTERS)
self._toast = None

View File

@ -50,19 +50,15 @@ class Section(section.Section):
self._entry = Gtk.Entry(placeholder_text="add new playlist",
primary_icon_name="list-add")
self.extra_widget = buttons.PopoverButton(icon_name="document-new",
tooltip_text=("add new "
"playlist"),
has_frame=False,
popover_child=self._entry)
self._entry.connect("activate", self.__add_new_playlist)
self._entry.connect("changed", self.__entry_changed)
self._entry.connect("icon-release", self.__entry_icon_release)
self._entry.add_css_class("card")
def __add_new_playlist(self, entry: Gtk.Entry) -> None:
if self.table.create(entry.get_text()) is not None:
self.table.sql.commit()
self.extra_widget.popdown()
def __entry_changed(self, entry: Gtk.Entry) -> None:

View File

@ -63,7 +63,6 @@ class PlaylistRow(BaseRow):
self._icon = Settable()
self._title = EditableTitle(margin_start=12, margin_end=12)
self._delete = Gtk.Button(icon_name="big-x-symbolic",
tooltip_text="delete playlist",
valign=Gtk.Align.CENTER,
has_frame=False, visible=False)
@ -110,17 +109,13 @@ class LibraryRow(BaseRow):
super().__init__(**kwargs)
self._box = Gtk.Box()
self._overlay = Gtk.Overlay(child=self._box)
self._switch = Gtk.Switch(active=self.enabled, valign=Gtk.Align.CENTER,
tooltip_text="disable library path")
self._switch = Gtk.Switch(active=self.enabled, valign=Gtk.Align.CENTER)
self._title = PlaylistTitle(margin_start=12, margin_end=12)
self._scan = Gtk.Button(icon_name="update", has_frame=False,
tooltip_text="update library path",
valign=Gtk.Align.CENTER)
self._stop = Gtk.Button(icon_name="stop-sign-large", has_frame=False,
tooltip_text="cancel update",
valign=Gtk.Align.CENTER, visible=False)
self._delete = Gtk.Button(icon_name="big-x-symbolic",
tooltip_text="delete library path",
valign=Gtk.Align.CENTER, has_frame=False)
self._progress = Gtk.ProgressBar(valign=Gtk.Align.END, visible=False)
@ -142,7 +137,6 @@ class LibraryRow(BaseRow):
self._delete.connect("clicked", self.__on_button_press, "delete")
self._scan.connect("clicked", self.__on_button_press, "scan")
self._stop.connect("clicked", self.__on_button_press, "stop")
self._switch.connect("notify::active", self.__on_switch_activated)
self._delete.add_css_class("emmental-delete")
self._stop.add_css_class("emmental-stop")
@ -163,10 +157,6 @@ class LibraryRow(BaseRow):
case "scan": self.playlist.scan()
case "stop": self.playlist.stop()
def __on_switch_activated(self, switch: Gtk.Switch, param) -> None:
state = "disable" if self.enabled else "enable"
self._switch.set_tooltip_text(f"{state} library path")
class TreeRow(factory.TreeRow):
"""A factory Row used for displaying individual playlists."""

View File

@ -2,6 +2,7 @@
"""A sidebar Header attached to a hidden ListView for selecting playlists."""
import typing
from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gtk
from .. import db
from .. import factory
@ -85,7 +86,9 @@ class Section(header.Header):
def select_playlist(self, playlist: db.playlist.Playlist) -> None:
"""Select the requested playlist."""
if (index := self.playlist_index(playlist)) is not None:
self._listview.scroll_to(index, Gtk.ListScrollFlags.SELECT)
self._selection.select_item(index, True)
self._listview.activate_action("list.scroll-to-item",
GLib.Variant.new_uint32(index))
@GObject.Signal(arg_types=(db.playlist.Playlist,))
def playlist_activated(self, playlist: db.playlist.Playlist):
@ -96,8 +99,8 @@ class Section(header.Header):
"""Signal that the selected playlist has changed."""
class View(Gtk.Box):
"""A widget for displaying a group of sections."""
class Group(GObject.GObject):
"""A group of sections."""
sql = GObject.Property(type=db.Connection)
current = GObject.Property(type=Section)
@ -105,8 +108,8 @@ class View(Gtk.Box):
selected_playlist = GObject.Property(type=db.playlist.Playlist)
def __init__(self, sql: db.Connection):
"""Initialize a Section View."""
super().__init__(sql=sql, orientation=Gtk.Orientation.VERTICAL)
"""Initialize a Section Group."""
super().__init__(sql=sql)
self._sections = []
def __on_active(self, section: Section, param: GObject.ParamSpec) -> None:
@ -142,7 +145,6 @@ class View(Gtk.Box):
def add(self, section: Section) -> None:
"""Add a section to the group."""
self._sections.append(section)
self.append(section)
section.connect("notify::active", self.__on_active)
section.connect("playlist-activated", self.__playlist_activated)
section.connect("playlist-selected", self.__playlist_selected)

View File

@ -1,76 +0,0 @@
# Copyright 2023 (c) Anna Schumaker.
"""A cache to hold Gdk.Textures used by cover art."""
import pathlib
import sys
from gi.repository import GLib
from gi.repository import Gdk
if "unittest" in sys.modules:
import tempfile
TEMP_DIR = tempfile.TemporaryDirectory(prefix="emmental-")
CACHE_PATH = pathlib.Path(TEMP_DIR.name)
else:
from . import gsetup
CACHE_PATH = gsetup.CACHE_DIR
CACHE_PATH.mkdir(parents=True, exist_ok=True)
class _TextureCache(dict):
"""A custom dictionary for storing texture files."""
def __check_update_cache(self, path: pathlib.Path) -> Gdk.Texture | None:
if path.is_file() \
and (cache_path := self.__get_cache_path(path)).exists() \
and cache_path.stat().st_mtime < path.stat().st_mtime:
self.__drop(path, cache_path)
return self.__load_new_item(path, cache_path)
def __drop(self, path: pathlib.Path, cache_path: pathlib.Path) -> None:
self.pop(path, None)
cache_path.unlink(missing_ok=True)
def __get_cache_path(self, path: pathlib.Path) -> pathlib.Path:
return CACHE_PATH / path.absolute().relative_to("/")
def __load_cached_item(self, path: pathlib.Path,
cache_path: pathlib.Path) -> Gdk.Texture:
texture = Gdk.Texture.new_from_filename(str(cache_path))
self.__setitem__(path, texture)
return texture
def __load_new_item(self, path: pathlib.Path,
cache_path: pathlib.Path) -> Gdk.Texture:
cache_path.parent.mkdir(parents=True, exist_ok=True)
with path.open("rb") as f_path:
bytes = f_path.read()
with cache_path.open("wb") as f_cache:
f_cache.write(bytes)
texture = Gdk.Texture.new_from_bytes(GLib.Bytes.new(bytes))
self.__setitem__(path, texture)
return texture
def __get_missing_item(self, path: pathlib.Path,
cache_path: pathlib.Path) -> Gdk.Texture:
if cache_path.is_file():
return self.__load_cached_item(path, cache_path)
elif path.is_file():
return self.__load_new_item(path, cache_path)
def __missing__(self, path: pathlib.Path | None) -> Gdk.Texture:
"""Load a cache item from disk or add a new item entirely."""
return self.__get_missing_item(path, self.__get_cache_path(path))
def __getitem__(self, path: pathlib.Path | None) -> Gdk.Texture | None:
"""Get a Gdk.Texture cache item from the cache."""
if path is not None:
texture = self.__check_update_cache(path)
return super().__getitem__(path) if texture is None else texture
def drop(self, path: pathlib.Path | None) -> None:
"""Drop a single cache item from the cache."""
self.__drop(path, self.__get_cache_path(path))
CACHE = _TextureCache()

View File

@ -1,94 +0,0 @@
# Copyright 2024 (c) Anna Schumaker.
"""A Thread class designed to easily sync up with the main thread."""
import threading
class Data:
"""A class for holding generic fields inspired by SimpleNamespace."""
def __init__(self, values_dict: dict = {}, **kwargs):
"""Initialize our Data class."""
self.__dict__.update(values_dict | kwargs)
def __eq__(self, rhs: any) -> bool:
"""Compare two Data classes."""
if isinstance(rhs, Data):
return self.__dict__ == rhs.__dict__
elif isinstance(rhs, dict):
return self.__dict__ == rhs
return False
def __repr__(self) -> str:
"""Get a string representation of the Data."""
items = (f"{k}={v!r}" for k, v in self.__dict__.items())
return f"{type(self).__name__}({', '.join(items)})"
class Thread(threading.Thread):
"""A worker Thread class that is easy to sync up with the main thread."""
def __init__(self):
"""Initialize our worker Thread object."""
super().__init__()
self.ready = threading.Event()
self._condition = threading.Condition()
self._task = None
self._result = None
self.start()
def do_get_result(self, result: Data, **kwargs) -> Data:
"""Get the result of the task."""
return self._result
def do_run_task(self, task: Data) -> None:
"""Run the task."""
self.set_result()
def do_stop(self) -> None:
"""Extra work when stopping the thread."""
def get_result(self, **kwargs) -> Data:
"""Get the result of the current task."""
with self._condition:
if not self.ready.is_set() or self._result is None:
return None
res = self.do_get_result(self._result, **kwargs)
self._result = None
return res
def run(self) -> None:
"""Wait for a task to run."""
with self._condition:
self.ready.set()
while self._condition.wait():
if self._task is None:
self.do_stop()
break
self.do_run_task(self._task)
def set_result(self, **kwargs: dict) -> None:
"""Set the result of the task."""
self._result = Data(kwargs)
self.ready.set()
def __set_task(self, task: Data | None) -> None:
"""Set the task to be run by the thread."""
with self._condition:
self.ready.clear()
self._task = task
self._result = None
self._condition.notify()
def set_task(self, **kwargs: dict) -> None:
"""Set the task to be run by the thread."""
self.__set_task(Data(kwargs))
def stop(self) -> None:
"""Stop the thread."""
self.__set_task(None)
self.join()

View File

@ -4,14 +4,12 @@ from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gio
from gi.repository import Gtk
from ..action import ActionEntry
from ..playlist.playlist import Playlist
from ..playlist.previous import Previous
from .. import db
from .. import entry
from . import buttons
from . import footer
from . import selection
from . import trackview
@ -23,24 +21,23 @@ class Card(Gtk.Box):
def __init__(self, sql: db.Connection, **kwargs):
"""Set up the Tracklist widget."""
super().__init__(sql=sql, orientation=Gtk.Orientation.VERTICAL,
**kwargs)
spacing=6, **kwargs)
self._top_left = Gtk.Box()
self._top_right = Gtk.Box(sensitive=False)
self._top_box = Gtk.CenterBox(margin_start=6, margin_end=6)
self._top_box = Gtk.CenterBox(margin_top=6, margin_start=6,
margin_end=6)
self._filter = entry.Filter("tracks", hexpand=True,
margin_start=100, margin_end=100)
self._trackview = trackview.TrackView(sql)
self._osd = selection.OSD(sql, self._trackview.selection_model,
child=self._trackview)
self._trackview = trackview.TrackView(sql, margin_start=6,
margin_end=6)
self._visible_cols = buttons.VisibleColumns(self._trackview.columns)
self._unselect = Gtk.Button(icon_name="edit-select-none-symbolic",
tooltip_text="unselect all tracks",
has_frame=False, sensitive=False)
self._loop = buttons.LoopButton()
self._shuffle = buttons.ShuffleButton()
self._sort = buttons.SortButton()
self._footer = footer.Footer(margin_start=6, margin_end=6,
margin_top=6, margin_bottom=6)
margin_bottom=6)
self._top_left.append(self._visible_cols)
self._top_left.append(self._unselect)
@ -54,14 +51,9 @@ class Card(Gtk.Box):
self._top_box.set_end_widget(self._top_right)
self.append(self._top_box)
self.append(Gtk.Separator())
self.append(self._osd)
self.append(Gtk.Separator())
self.append(self._trackview)
self.append(self._footer)
self._osd.bind_property("have-selected", self._trackview,
"have-selected")
self._osd.bind_property("n-selected", self._trackview, "n-selected")
self._trackview.bind_property("n-tracks", self._footer, "count")
self._trackview.bind_property("n-selected", self._footer, "selected")
self._trackview.bind_property("runtime", self._footer, "runtime")
@ -70,14 +62,16 @@ class Card(Gtk.Box):
"sensitive")
self._filter.connect("search-changed", self.__search_changed)
self._unselect.connect("clicked", self._osd.clear_selection)
self._unselect.connect("clicked", self.__clear_selection)
self._loop.connect("notify::state", self.__update_loop_state)
self._shuffle.connect("notify::active", self.__update_shuffle_state)
self._sort.connect("notify::sort-order", self.__update_sort_order)
self._top_box.add_css_class("toolbar")
self.add_css_class("card")
def __clear_selection(self, unselect: Gtk.Button) -> None:
self._trackview.clear_selected_tracks()
def __playlist_notify(self, playlist: Playlist, param) -> None:
match param.name:
case "loop":
@ -95,7 +89,7 @@ class Card(Gtk.Box):
self._loop.state = self.playlist.loop
self._shuffle.active = self.playlist.shuffle
self._sort.set_sort_order(self.playlist.sort_order)
self._osd.reset()
self._trackview.reset_osd()
def __update_loop_state(self, loop: buttons.LoopButton, param) -> None:
if self.playlist.loop != loop.state:
@ -138,24 +132,7 @@ class Card(Gtk.Box):
self._trackview.playlist.disconnect_by_func(self.__playlist_notify)
self._trackview.playlist = newval
self._osd.playlist = newval
if newval is not None:
self._top_right.set_sensitive(not isinstance(newval, Previous))
self.__set_button_state()
newval.connect("notify", self.__playlist_notify)
@property
def accelerators(self) -> list[ActionEntry]:
"""Get a list of accelerators for the Tracklist."""
return [ActionEntry("focus-search-track", self._filter.grab_focus,
"<Control>slash"),
ActionEntry("clear-selected-tracks", self._unselect.activate,
"Escape", enabled=(self._unselect, "sensitive")),
ActionEntry("cycle-loop", self._loop.activate,
"<Control>l", enabled=(self._top_right,
"sensitive")),
ActionEntry("toggle-shuffle", self._shuffle.activate,
"<Control>s", enabled=(self._top_right,
"sensitive"))] + \
self._osd.accelerators

View File

@ -5,29 +5,19 @@ from gi.repository import Gio
from gi.repository import Gtk
from . import sorter
from .. import buttons
from .. import gsetup
from .. import factory
class VisibleRow(Gtk.ListBoxRow):
"""A ListBoxRow containing a Gtk.Switch and a title Label."""
class VisibleSwitch(factory.ListRow):
"""A list row containing a Gtk.Switch."""
active = GObject.Property(type=bool, default=True)
title = GObject.Property(type=str)
def __init__(self, listitem: Gtk.ListItem):
"""Initialize a VisibleSwitch ListRow."""
super().__init__(listitem=listitem, child=Gtk.Switch())
def __init__(self, title: str, active: bool):
"""Initialize a VisibleRow ListBoxRow."""
super().__init__(title=title, active=active,
child=Gtk.Box(margin_start=6, margin_end=6,
margin_top=6, margin_bottom=6,
spacing=6))
self._switch = Gtk.Switch(active=active)
self._label = Gtk.Label.new(title)
self.bind_property("active", self._switch, "active",
GObject.BindingFlags.BIDIRECTIONAL)
self.props.child.append(self._switch)
self.props.child.append(self._label)
def do_bind(self) -> None:
"""Bind the visible property to the switch active property."""
self.bind_and_set_property("visible", "active", bidirectional=True)
class VisibleColumns(buttons.PopoverButton):
@ -38,21 +28,21 @@ class VisibleColumns(buttons.PopoverButton):
def __init__(self, columns: Gio.ListModel, **kwargs):
"""Initialize the VisibleColumns button."""
super().__init__(columns=columns, icon_name="columns-symbolic",
tooltip_text="configure visible columns",
has_frame=False, **kwargs)
self.popover_child = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
self.popover_child.bind_model(columns, self.__create_func)
self.popover_child.connect("row-activated", self.__row_activated)
self.popover_child.add_css_class("boxed-list")
self._selection = Gtk.NoSelection(model=self.columns)
self.popover_child = Gtk.ColumnView(model=self._selection,
show_row_separators=True)
self.__append_column(factory.InscriptionFactory("title"),
"Column", width=125)
self.__append_column(factory.Factory(row_type=VisibleSwitch),
"Visible")
self.popover_child.add_css_class("data-table")
def __create_func(self, column: Gtk.ColumnViewColumn) -> VisibleRow:
row = VisibleRow(column.get_title(), column.get_visible())
row.bind_property("active", column, "visible",
GObject.BindingFlags.BIDIRECTIONAL)
return row
def __row_activated(self, box: Gtk.ListBox, row: Gtk.ListBoxRow) -> None:
row.active = not row.active
def __append_column(self, factory: factory.Factory,
title: str, *, width: int = -1) -> None:
column = Gtk.ColumnViewColumn(factory=factory, title=title,
fixed_width=width)
self.popover_child.append_column(column)
class LoopButton(buttons.ImageToggle):
@ -63,10 +53,8 @@ class LoopButton(buttons.ImageToggle):
def __init__(self, **kwargs):
"""Initialize a Loop Button."""
super().__init__(active_icon_name="media-playlist-repeat-song",
active_tooltip_text="loop: track",
inactive_icon_name="media-playlist-repeat",
inactive_tooltip_text="loop: disabled",
large_icon=False, state="None",
icon_size=Gtk.IconSize.NORMAL, state="None",
has_frame=False, **kwargs)
def do_clicked(self):
@ -91,11 +79,9 @@ class LoopButton(buttons.ImageToggle):
case ("None", True):
self.active = False
self.icon_opacity = 0.5
self.inactive_tooltip_text = "loop: disabled"
case ("Playlist", _):
self.active = False
self.icon_opacity = 1.0
self.inactive_tooltip_text = "loop: playlist"
case ("Track", _):
self.active = True
self.icon_opacity = 1.0
@ -107,75 +93,91 @@ class ShuffleButton(buttons.ImageToggle):
def __init__(self, **kwargs):
"""Initialize a Shuffle Button."""
super().__init__(active_icon_name="media-playlist-shuffle",
active_tooltip_text="shuffle: enabled",
inactive_icon_name=self.get_inactive_icon(),
inactive_tooltip_text="shuffle: disabled",
large_icon=False, icon_opacity=0.5,
inactive_icon_name="media-playlist-consecutive",
icon_size=Gtk.IconSize.NORMAL, icon_opacity=0.5,
has_frame=False, **kwargs)
def do_toggled(self):
"""Adjust opacity when active state toggles."""
self.icon_opacity = 1.0 if self.active else 0.5
self.inactive_icon_name = self.get_inactive_icon()
def get_inactive_icon(self) -> str:
"""Return the inactive icon name."""
if gsetup.has_icon("media-playlist-normal"):
return "media-playlist-normal"
return "media-playlist-consecutive"
class SortRow(Gtk.ListBoxRow):
"""A ListBoxRow for managing Sort Order."""
class SortFieldWidget(Gtk.Box):
"""A Widget to display in the Sort Order button popover."""
active = GObject.Property(type=bool, default=False)
sort_field = GObject.Property(type=sorter.SortField)
def __init__(self, sort_field: sorter.SortField):
"""Initialize a Sort Row."""
super().__init__(sort_field=sort_field, active=sort_field.enabled,
child=Gtk.Box(margin_start=6, margin_end=6,
margin_top=6, margin_bottom=6,
spacing=6))
self._switch = Gtk.Switch(active=self.active, valign=Gtk.Align.CENTER)
self._label = Gtk.Label(label=sort_field.name, hexpand=True,
sensitive=self.active, xalign=0.0)
def __init__(self) -> None:
"""Initialize a SortField Widget."""
super().__init__(spacing=6)
self._enabled = Gtk.Switch(valign=Gtk.Align.CENTER)
self._name = Gtk.Label(hexpand=True, sensitive=False)
self._reverse = buttons.ImageToggle("arrow1-up", "arrow1-down",
active=sort_field.reversed,
sensitive=self.active,
has_frame=False)
self._move_box = Gtk.Box(sensitive=self.active)
icon_size=Gtk.IconSize.NORMAL,
sensitive=False)
self._box = Gtk.Box(sensitive=False)
self._move_up = Gtk.Button(icon_name="go-up-symbolic")
self._move_down = Gtk.Button(icon_name="go-down-symbolic")
self._switch.connect("notify::active", self.__toggle_enabled)
self._reverse.connect("toggled", self.__reverse)
self._move_up.connect("clicked", self.__move_up)
self._move_down.connect("clicked", self.__move_down)
self._enabled.bind_property("active", self._name, "sensitive")
self._enabled.bind_property("active", self._reverse, "sensitive")
self._enabled.bind_property("active", self._box, "sensitive")
self.props.child.append(self._switch)
self.props.child.append(self._label)
self.props.child.append(self._reverse)
self.props.child.append(self._move_box)
self._enabled.connect("notify::active", self.__notify_enabled)
self._reverse.connect("clicked", self.__reverse)
self._move_up.connect("clicked", self.__move_item_up)
self._move_down.connect("clicked", self.__move_item_down)
self._move_box.append(self._move_up)
self._move_box.append(self._move_down)
self._move_box.add_css_class("linked")
self.append(self._enabled)
self.append(self._name)
self.append(self._reverse)
self.append(self._box)
def __toggle_enabled(self, switch: Gtk.Switch, param) -> None:
if switch.props.active:
self.sort_field.enable()
else:
self.sort_field.disable()
self._box.append(self._move_up)
self._box.append(self._move_down)
self._box.add_css_class("linked")
def __move_item_down(self, button: Gtk.Button) -> None:
if self.sort_field is not None:
self.sort_field.move_down()
def __move_item_up(self, button: Gtk.Button) -> None:
if self.sort_field is not None:
self.sort_field.move_up()
def __notify_enabled(self, switch: Gtk.Switch, param) -> None:
if self.sort_field is not None:
if switch.get_active():
self.sort_field.enable()
else:
self.sort_field.disable()
def __reverse(self, button: buttons.ImageToggle) -> None:
self.sort_field.reverse()
if self.sort_field is not None:
self.sort_field.reverse()
def __move_up(self, button: Gtk.Button) -> None:
self.sort_field.move_up()
def set_sort_field(self, field: sorter.SortField | None) -> None:
"""Set the Sort Field displayed by this Widget."""
self.sort_field = field
self._name.set_text(field.name if field is not None else "")
self._enabled.set_active(field is not None and field.enabled)
self._reverse.active = field is not None and field.reversed
def __move_down(self, button: Gtk.Button) -> None:
self.sort_field.move_down()
class SortRow(factory.ListRow):
"""A row for managing Sort Order."""
def __init__(self, listitem: Gtk.ListItem):
"""Initialize a Sort Row."""
super().__init__(listitem=listitem, child=SortFieldWidget())
def do_bind(self) -> None:
"""Bind Sort Field properties to the Widget."""
self.child.set_sort_field(self.item)
def do_unbind(self) -> None:
"""Unbind properties from the widget."""
self.child.set_sort_field(None)
class SortButton(buttons.PopoverButton):
@ -187,24 +189,14 @@ class SortButton(buttons.PopoverButton):
def __init__(self, **kwargs):
"""Initialize the Sort button."""
super().__init__(has_frame=False, model=sorter.SortOrderModel(),
tooltip_text="configure playlist sort order",
icon_name="list-compact-symbolic", **kwargs)
self.popover_child = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
self.popover_child.bind_model(self.model, self.__create_func)
self.popover_child.connect("row-activated", self.__row_activated)
self.popover_child.add_css_class("boxed-list")
icon_name="view-list-ordered-symbolic", **kwargs)
self._selection = Gtk.NoSelection(model=self.model)
self._factory = factory.Factory(row_type=SortRow)
self.popover_child = Gtk.ListView(model=self._selection,
factory=self._factory,
show_separators=True)
self.model.bind_property("sort-order", self, "sort-order")
def __create_func(self, sort_field: sorter.SortField) -> SortRow:
return SortRow(sort_field)
def __row_activated(self, box: Gtk.ListBox, row: SortRow) -> None:
if row.active:
row._reverse.active = not row.sort_field.reversed
else:
row.sort_field.enable()
def set_sort_order(self, newval: str) -> None:
"""Directly set the sort order."""
self.model.set_sort_order(newval)

View File

@ -1,7 +1,6 @@
# Copyright 2022 (c) Anna Schumaker.
"""A Footer widget to display below the TrackView."""
from gi.repository import GObject
from gi.repository import Pango
from gi.repository import Gtk
@ -15,11 +14,9 @@ class Footer(Gtk.CenterBox):
def __init__(self, **kwargs):
"""Initialize a Footer widget."""
super().__init__(**kwargs)
self._count = Gtk.Label(label="Showing 0 tracks", xalign=0.0,
ellipsize=Pango.EllipsizeMode.START)
self._selected = Gtk.Label(ellipsize=Pango.EllipsizeMode.MIDDLE)
self._runtime = Gtk.Label(label="0 seconds", xalign=1.0,
ellipsize=Pango.EllipsizeMode.END)
self._count = Gtk.Label(label="Showing 0 tracks", xalign=0.0)
self._selected = Gtk.Label()
self._runtime = Gtk.Label(label="0 seconds", xalign=1.0)
self.set_start_widget(self._count)
self.set_center_widget(self._selected)

View File

@ -4,10 +4,10 @@ import datetime
import dateutil.tz
import pathlib
from gi.repository import GObject
from gi.repository import Gdk
from gi.repository import Gtk
from .. import buttons
from .. import factory
from .. import texture
class TrackRow(factory.ListRow):
@ -63,6 +63,23 @@ class TrackRow(factory.ListRow):
else:
self.bind_album(child_prop)
@GObject.Property(type=bool, default=False)
def active(self) -> bool:
"""Get the active state of this Row."""
if parent := self.listitem.get_child().get_parent():
if parent := parent.get_parent():
return parent.get_state_flags() & Gtk.StateFlags.CHECKED
return False
@active.setter
def active(self, newval: bool) -> None:
if parent := self.listitem.get_child().get_parent():
if parent := parent.get_parent():
if newval:
parent.set_state_flags(Gtk.StateFlags.CHECKED, False)
else:
parent.unset_state_flags(Gtk.StateFlags.CHECKED)
@GObject.Property(type=bool, default=True)
def online(self) -> bool:
"""Get the online state of this Row."""
@ -73,14 +90,6 @@ class TrackRow(factory.ListRow):
self.listitem.set_activatable(newval)
self.child.set_sensitive(newval)
@GObject.Property(type=Gtk.Widget)
def listrow(self) -> Gtk.Widget:
"""Test property for active track styling."""
if child := self.listitem.props.child:
if cell := child.props.parent:
return cell.props.parent
return None
class InscriptionRow(TrackRow):
"""Base class for Track Rows displaying a Gtk.Inscription."""
@ -278,6 +287,8 @@ class MediumString(InscriptionRow):
class AlbumCover(TrackRow):
"""A Track Row to display Album art."""
Cache = dict()
filepath = GObject.Property(type=GObject.TYPE_PYOBJECT)
def __init__(self, listitem: Gtk.ListItem, property: str):
@ -291,14 +302,19 @@ class AlbumCover(TrackRow):
match param.name:
case "mediumid": self.rebind_album("filepath", to_self=True)
case "filepath":
tex = texture.CACHE[self.filepath]
self.child.set_paintable(tex)
self.child.set_has_tooltip(tex is not None)
if self.filepath is None:
texture = None
elif (texture := AlbumCover.Cache.get(self.filepath)) is None:
texture = Gdk.Texture.new_from_filename(str(self.filepath))
AlbumCover.Cache[self.filepath] = texture
self.child.set_paintable(texture)
self.child.set_has_tooltip(texture is not None)
def __query_tooltip(self, child: Gtk.Picture, x: int, y: int,
keyboard_mode: bool, tooltip: Gtk.Tooltip) -> bool:
tex = texture.CACHE[self.filepath]
tooltip.set_custom(Gtk.Picture.new_for_paintable(tex))
texture = AlbumCover.Cache.get(self.filepath)
tooltip.set_custom(Gtk.Picture.new_for_paintable(texture))
return True
def do_bind(self) -> None:
@ -314,8 +330,9 @@ class FavoriteButton(TrackRow):
"""Initialize a Favorite Button."""
super().__init__(listitem, property=property)
self.child = buttons.ImageToggle("heart-filled", "heart-outline-thick",
large_icon=False, has_frame=False,
valign=Gtk.Align.CENTER)
icon_size=Gtk.IconSize.NORMAL,
valign=Gtk.Align.CENTER,
has_frame=False)
def do_bind(self):
"""Bind a track property to the Toggle Button."""

View File

@ -4,39 +4,40 @@ from gi.repository import GObject
from gi.repository import Gdk
from gi.repository import Gtk
from gi.repository import Adw
from ..action import ActionEntry
from ..buttons import PopoverButton
from .. import db
from .. import factory
from .. import playlist
class PlaylistRow(Gtk.ListBoxRow):
"""A ListBoxRow widget for Playlists."""
class PlaylistRowWidget(Gtk.Box):
"""A row widget for Playlists."""
name = GObject.Property(type=str)
image = GObject.Property(type=GObject.TYPE_PYOBJECT)
def __init__(self, name: str, image: GObject.TYPE_PYOBJECT):
"""Initialize a PlaylistRow."""
super().__init__(child=Gtk.Box(margin_start=6, margin_end=6,
margin_top=6, margin_bottom=6,
spacing=6), name=name)
match name:
case "Favorite Tracks": icon_name = "heart-filled-symbolic"
case "Queued Tracks": icon_name = "music-queue-symbolic"
case _: icon_name = "playlist2-symbolic"
self._icon = Adw.Avatar(size=32, text=name, icon_name=icon_name)
self._label = Gtk.Label.new(name)
def __init__(self):
"""Initialize a PlaylistRowWidget."""
super().__init__()
self._icon = Adw.Avatar(size=32)
self._label = Gtk.Label(xalign=0.0)
self.bind_property("name", self._label, "label")
self.bind_property("name", self._icon, "text")
self.connect("notify::name", self.__name_changed)
self.connect("notify::image", self.__image_changed)
self.image = image
self.props.child.append(self._icon)
self.props.child.append(self._label)
self.append(self._icon)
self.append(self._label)
def __image_changed(self, row: Gtk.ListBoxRow,
param: GObject.ParamSpec) -> None:
def __name_changed(self, row: Gtk.Box, param) -> None:
match self.name:
case "Favorite Tracks": icon = "heart-filled-symbolic"
case "Queued Tracks": icon = "music-queue-symbolic"
case _: icon = "playlist2-symbolic"
self._icon.set_icon_name(icon)
def __image_changed(self, row: Gtk.Box, param) -> None:
if self.image is not None and self.image.is_file():
texture = Gdk.Texture.new_from_filename(str(self.image))
else:
@ -44,6 +45,20 @@ class PlaylistRow(Gtk.ListBoxRow):
self._icon.set_custom_image(texture)
class PlaylistRow(factory.ListRow):
"""A list row for displaying Playlists."""
def __init__(self, listitem: Gtk.ListItem):
"""Initialize a PlaylistRow."""
super().__init__(listitem)
self.child = PlaylistRowWidget()
def do_bind(self):
"""Bind a Playlist to this Row."""
self.bind_and_set_property("name", "name")
self.bind_and_set_property("image", "image")
class UserTracksFilter(Gtk.Filter):
"""Filters for tracks with user-tracks set to True."""
@ -62,28 +77,28 @@ class UserTracksFilter(Gtk.Filter):
return playlist.user_tracks and playlist != self.playlist
class PlaylistView(Gtk.ListBox):
class PlaylistView(Gtk.ListView):
"""A ListView for selecting Playlists."""
playlist = GObject.Property(type=db.playlist.Playlist)
def __init__(self, sql: db.Connection):
"""Initialize the PlaylistView."""
super().__init__(selection_mode=Gtk.SelectionMode.NONE)
super().__init__(show_separators=True, single_click_activate=True)
self._filtered = Gtk.FilterListModel(model=sql.playlists,
filter=UserTracksFilter())
self._selection = Gtk.NoSelection(model=self._filtered)
self._factory = factory.Factory(PlaylistRow)
self.connect("activate", self.__playlist_activated)
self.bind_property("playlist", self._filtered.get_filter(), "playlist")
self.bind_model(self._filtered, self.__create_func)
self.connect("row-activated", self.__row_activated)
self.add_css_class("boxed-list")
self.add_css_class("rich-list")
def __row_activated(self, box: Gtk.ListBox, row: PlaylistRow) -> None:
self.emit("playlist-selected", self._filtered[row.get_index()])
self.set_model(self._selection)
self.set_factory(self._factory)
def __create_func(self, playlist: db.playlist.Playlist) -> PlaylistRow:
row = PlaylistRow(playlist.name, playlist.image)
playlist.bind_property("image", row, "image")
return row
def __playlist_activated(self, view: Gtk.ListView, position: int) -> None:
self.emit("playlist-selected", self._selection[position])
@GObject.Signal(arg_types=(db.playlists.Playlist,))
def playlist_selected(self, playlist: db.playlists.Playlist) -> None:
@ -100,10 +115,8 @@ class MoveButtons(Gtk.Box):
"""Initialize the Move Buttons."""
super().__init__(**kwargs)
self._down = Gtk.Button(icon_name="go-down-symbolic",
tooltip_text="move selected track down",
hexpand=True, sensitive=False)
self._up = Gtk.Button(icon_name="go-up-symbolic",
tooltip_text="move selected track up",
hexpand=True, sensitive=False)
self.bind_property("can-move-down", self._down, "sensitive")
@ -139,7 +152,6 @@ class MoveButtons(Gtk.Box):
class OSD(Gtk.Overlay):
"""An Overlay with extra controls for the Tracklist."""
sql = GObject.Property(type=db.Connection)
playlist = GObject.Property(type=playlist.playlist.Playlist)
selection = GObject.Property(type=Gtk.SelectionModel)
@ -149,18 +161,15 @@ class OSD(Gtk.Overlay):
def __init__(self, sql: db.Connection,
selection: Gtk.SelectionModel, **kwargs):
"""Initialize an OSD."""
super().__init__(sql=sql, selection=selection, **kwargs)
super().__init__(selection=selection, **kwargs)
self._add = PopoverButton(child=Adw.ButtonContent(label="Add",
icon_name="list-add-symbolic"),
tooltip_text="add selected tracks "
"to a playlist",
halign=Gtk.Align.START, valign=Gtk.Align.END,
margin_start=16, margin_bottom=16,
direction=Gtk.ArrowType.UP, visible=False,
popover_child=PlaylistView(sql))
self._remove = Gtk.Button(child=Adw.ButtonContent(label="Remove",
icon_name="list-remove-symbolic"),
tooltip_text="remove selected tracks",
halign=Gtk.Align.END, valign=Gtk.Align.END,
margin_end=16, margin_bottom=16,
visible=False)
@ -197,14 +206,12 @@ class OSD(Gtk.Overlay):
playlist: db.playlists.Playlist) -> None:
for track in self.__get_selected_tracks():
playlist.add_track(track)
self.sql.commit()
self.clear_selection()
def __remove_clicked(self, button: Gtk.Button) -> None:
if self.playlist is not None:
for track in self.__get_selected_tracks():
self.playlist.remove_track(track)
self.sql.commit()
self.clear_selection()
def __move_track_down(self, move: MoveButtons) -> None:
@ -212,7 +219,6 @@ class OSD(Gtk.Overlay):
index = self.selection.get_selection().get_nth(0)
self.selection.get_model().set_incremental(False)
self.playlist.move_track_down(self.selection[index])
self.sql.commit()
self.selection.get_model().set_incremental(True)
self.__update_visibility()
@ -221,7 +227,6 @@ class OSD(Gtk.Overlay):
index = self.selection.get_selection().get_nth(0)
self.selection.get_model().set_incremental(False)
self.playlist.move_track_up(self.selection[index])
self.sql.commit()
self.selection.get_model().set_incremental(True)
self.__update_visibility()
@ -258,15 +263,3 @@ class OSD(Gtk.Overlay):
self.__selection_changed(self.selection, 0, 0)
if self.playlist is not None:
self._add.popover_child.playlist = self.playlist.playlist
@property
def accelerators(self) -> list[ActionEntry]:
"""Get a list of accelerators for the OSD."""
return [ActionEntry("remove-selected-tracks", self._remove.activate,
"Delete", enabled=(self._remove, "visible")),
ActionEntry("move-track-up", self._move._up.activate,
"<Control>Up",
enabled=(self._move, "can-move-up")),
ActionEntry("move-track-down", self._move._down.activate,
"<Control>Down",
enabled=(self._move, "can-move-down"))]

View File

@ -7,9 +7,10 @@ from .. import db
from .. import factory
from .. import playlist
from . import row
from . import selection
class TrackView(Gtk.ScrolledWindow):
class TrackView(Gtk.Frame):
"""A Gtk.ColumnView that has been configured to show Tracks."""
playlist = GObject.Property(type=playlist.playlist.Playlist)
@ -29,6 +30,8 @@ class TrackView(Gtk.ScrolledWindow):
show_row_separators=True,
enable_rubberband=True,
model=self._selection)
self._scrollwin = Gtk.ScrolledWindow(child=self._columnview)
self._osd = selection.OSD(sql, self._selection, child=self._scrollwin)
self.__append_column("Art", "cover", row.AlbumCover, resizable=False)
self.__append_column("Fav", "favorite", row.FavoriteButton,
@ -54,13 +57,16 @@ class TrackView(Gtk.ScrolledWindow):
self.__append_column("Filepath", "path", row.PathString, visible=False)
self.bind_property("playlist", self._filtermodel, "model")
self.bind_property("playlist", self._osd, "playlist")
self._osd.bind_property("have-selected", self, "have-selected")
self._osd.bind_property("n-selected", self, "n-selected")
self._selection.bind_property("n-items", self, "n-tracks")
self._selection.connect("items-changed", self.__runtime_changed)
self._columnview.connect("activate", self.__track_activated)
self._columnview.add_css_class("emmental-track-list")
self.set_child(self._columnview)
self.set_child(self._osd)
def __append_column(self, title: str, property: str, row_type: type,
*, width: int = -1, visible: bool = True,
@ -81,16 +87,23 @@ class TrackView(Gtk.ScrolledWindow):
def scroll_to_track(self, track: db.tracks.Track) -> None:
"""Scroll to the requested Track."""
for i in range(self._selection.props.n_items):
if self._selection[i] == track:
self._columnview.scroll_to(i, None, Gtk.ListScrollFlags.NONE)
# This is a workaround until the ColumnView has better scrolling
# support, which seems to be targeted for Gtk 4.10.
adjustment = self._scrollwin.get_vadjustment()
for (i, t) in enumerate(self._selection):
if t == track:
pos = max(i - 3, 0) * adjustment.get_upper()
adjustment.set_value(pos / self._selection.get_n_items())
def clear_selected_tracks(self) -> None:
"""Clear the currently selected tracks."""
self._osd.clear_selection()
def reset_osd(self) -> None:
"""Reset the OSD."""
self._osd.reset()
@GObject.Property(type=Gio.ListModel)
def columns(self) -> Gio.ListModel:
"""Get the ListModel for the columns."""
return self._columnview.get_columns()
@GObject.Property(type=Gio.ListModel)
def selection_model(self) -> Gio.ListModel:
"""Get the SelectionModel for the ColumnView."""
return self._columnview.get_model()

View File

@ -3,8 +3,6 @@
from gi.repository import GObject
from gi.repository import Gtk
from gi.repository import Adw
from .action import ActionEntry
from . import layout
def _make_pane(orientation: Gtk.Orientation, position: int = 0,
@ -13,7 +11,7 @@ def _make_pane(orientation: Gtk.Orientation, position: int = 0,
pane = Gtk.Paned(orientation=orientation, hexpand=True, vexpand=True,
shrink_start_child=False, resize_start_child=False,
start_child=start_child, end_child=end_child,
position=position, margin_start=8)
position=position)
pane.add_css_class("emmental-pane")
return pane
@ -30,53 +28,44 @@ class Window(Adw.Window):
header = GObject.Property(type=Gtk.Widget)
sidebar = GObject.Property(type=Gtk.Widget)
show_sidebar = GObject.Property(type=bool, default=False)
sidebar_size = GObject.Property(type=int, default=300)
now_playing = GObject.Property(type=Gtk.Widget)
now_playing_size = GObject.Property(type=int, default=250)
tracklist = GObject.Property(type=Gtk.Widget)
user_editing = GObject.Property(type=bool, default=False)
def __init__(self, version: str, **kwargs):
"""Initialize our Window."""
super().__init__(icon_name="emmental", title=version,
default_width=1600, default_height=900,
width_request=525, height_request=500, **kwargs)
default_width=1600, default_height=900, **kwargs)
self._box = Gtk.Box.new(Gtk.Orientation.VERTICAL, 0)
self._header = Adw.Bin(child=self.header)
self._inner_pane = _make_pane(Gtk.Orientation.VERTICAL,
position=self.now_playing_size,
start_child=self.now_playing,
end_child=self.tracklist)
self._layout = layout.Layout(content=self._inner_pane,
sidebar=self.sidebar)
self._toast = Adw.ToastOverlay(child=self._layout)
self._outer_pane = _make_pane(Gtk.Orientation.HORIZONTAL,
position=self.sidebar_size,
start_child=self.sidebar,
end_child=self._inner_pane)
self._toast = Adw.ToastOverlay(child=self._outer_pane)
self._layout.add_css_class("emmental-padding")
self._outer_pane.add_css_class("emmental-padding")
if __debug__:
self.add_css_class("devel")
self.bind_property("header", self._header, "child")
self.bind_property("sidebar", self._layout, "sidebar")
self.bind_property("show-sidebar", self._layout, "show-sidebar",
self.bind_property("sidebar", self._outer_pane, "start-child")
self.bind_property("sidebar-size", self._outer_pane, "position",
GObject.BindingFlags.BIDIRECTIONAL)
self.bind_property("now-playing", self._inner_pane, "start-child")
self.bind_property("now-playing-size", self._inner_pane, "position",
GObject.BindingFlags.BIDIRECTIONAL)
self.bind_property("tracklist", self._inner_pane, "end-child")
self.connect("notify::focus-widget", self.__notify_focus_widget)
for breakpoint in self._layout.breakpoints:
self.add_breakpoint(breakpoint)
self._box.append(self._header)
self._box.append(self._toast)
self.set_content(self._box)
def __notify_focus_widget(self, win: Gtk.Window, param) -> None:
self.user_editing = isinstance(win.get_property("focus-widget"),
Gtk.Editable)
def close(self, *args) -> None:
"""Close the window."""
super().close()
@ -90,8 +79,3 @@ class Window(Adw.Window):
def present(self, *args) -> None:
"""Present the window."""
super().present()
@property
def accelerators(self) -> list[ActionEntry]:
"""Get a list of accelerators for the Window."""
return [ActionEntry("reset-focus", self.set_focus, "Escape")]

View File

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16px" viewBox="0 0 16 16" width="16px"><filter id="a" height="100%" width="100%" x="0%" y="0%"><feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/></filter><mask id="b"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.5"/></g></mask><clipPath id="c"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="d"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.7"/></g></mask><clipPath id="e"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="f"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.35"/></g></mask><clipPath id="g"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><g mask="url(#b)"><g clip-path="url(#c)" transform="matrix(1 0 0 1 -920 -120)"><path d="m 550 182 c -0.351562 0.003906 -0.695312 0.101562 -1 0.28125 v 3.4375 c 0.304688 0.179688 0.648438 0.277344 1 0.28125 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 c -0.339844 0 -0.679688 0.058594 -1 0.175781 v 6.824219 h 4 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#d)"><g clip-path="url(#e)" transform="matrix(1 0 0 1 -920 -120)"><path d="m 569 182 v 4 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 v 7 h 3 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#f)"><g clip-path="url(#g)" transform="matrix(1 0 0 1 -920 -120)"><path d="m 573 182.269531 v 3.449219 c 0.613281 -0.355469 0.996094 -1.007812 1 -1.71875 c 0 -0.714844 -0.382812 -1.375 -1 -1.730469 z m 0 4.90625 v 6.824219 h 2 v -4 c 0 -1.269531 -0.800781 -2.402344 -2 -2.824219 z m 0 0"/></g></g><path d="m 7.984375 1 c -0.550781 0 -1 0.449219 -1 1 v 8.585938 l -2.292969 -2.292969 c -0.1875 -0.1875 -0.441406 -0.292969 -0.707031 -0.292969 s -0.519531 0.105469 -0.707031 0.292969 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 l 4 4 c 0.390625 0.390625 1.023437 0.390625 1.414062 0 l 4 -4 c 0.390625 -0.390625 0.390625 -1.023437 0 -1.414062 s -1.023437 -0.390625 -1.414062 0 l -2.292969 2.292969 v -8.585938 c 0 -0.550781 -0.445313 -1 -1 -1 z m 0 0"/></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16px" viewBox="0 0 16 16" width="16px"><filter id="a" height="100%" width="100%" x="0%" y="0%"><feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/></filter><mask id="b"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.5"/></g></mask><clipPath id="c"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="d"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.7"/></g></mask><clipPath id="e"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="f"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.35"/></g></mask><clipPath id="g"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><path d="m 2.386719 3 c -0.214844 0 -0.386719 0.167969 -0.386719 0.378906 v 1.242188 c 0 0.210937 0.171875 0.378906 0.386719 0.378906 h 1.230469 c 0.210937 0 0.382812 -0.167969 0.382812 -0.378906 v -1.242188 c 0 -0.210937 -0.171875 -0.378906 -0.382812 -0.378906 z m 3.613281 0 v 2 h 8 v -2 z m -3.613281 4 c -0.214844 0 -0.386719 0.167969 -0.386719 0.378906 v 1.242188 c 0 0.210937 0.171875 0.378906 0.386719 0.378906 h 1.230469 c 0.210937 0 0.382812 -0.167969 0.382812 -0.378906 v -1.242188 c 0 -0.210937 -0.171875 -0.378906 -0.382812 -0.378906 z m 3.613281 0 v 2 h 8 v -2 z m -3.613281 4 c -0.214844 0 -0.386719 0.167969 -0.386719 0.378906 v 1.242188 c 0 0.210937 0.171875 0.378906 0.386719 0.378906 h 1.230469 c 0.210937 0 0.382812 -0.167969 0.382812 -0.378906 v -1.242188 c 0 -0.210937 -0.171875 -0.378906 -0.382812 -0.378906 z m 3.613281 0 v 2 h 8 v -2 z m 0 0" fill="#222222"/><g mask="url(#b)"><g clip-path="url(#c)" transform="matrix(1 0 0 1 -660 -864)"><path d="m 550 182 c -0.351562 0.003906 -0.695312 0.101562 -1 0.28125 v 3.4375 c 0.304688 0.179688 0.648438 0.277344 1 0.28125 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 c -0.339844 0 -0.679688 0.058594 -1 0.175781 v 6.824219 h 4 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#d)"><g clip-path="url(#e)" transform="matrix(1 0 0 1 -660 -864)"><path d="m 569 182 v 4 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 v 7 h 3 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#f)"><g clip-path="url(#g)" transform="matrix(1 0 0 1 -660 -864)"><path d="m 573 182.269531 v 3.449219 c 0.613281 -0.355469 0.996094 -1.007812 1 -1.71875 c 0 -0.714844 -0.382812 -1.375 -1 -1.730469 z m 0 4.90625 v 6.824219 h 2 v -4 c 0 -1.269531 -0.800781 -2.402344 -2 -2.824219 z m 0 0"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -1,49 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
id="a"
viewBox="0 0 16 16"
version="1.1"
sodipodi:docname="listenbrainz-logo-symbolic.svg"
width="16"
height="16"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview2"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="52.917468"
inkscape:cx="4.2330068"
inkscape:cy="7.9652337"
inkscape:window-width="1920"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="a" />
<defs
id="defs1">
<style
id="style1">.b{fill:#353070;}.c{fill:#eb743b;}</style>
</defs>
<polygon
class="b"
points="13,29 13,1 1,8 1,22 "
id="polygon1"
transform="matrix(0.5,0,0,0.5,1,0.5)"
style="fill:#222222;fill-opacity:1" />
<polygon
class="c"
points="14,29 14,1 26,8 26,22 "
id="polygon2"
transform="matrix(0.399792,0,0,0.42127119,3.5057644,1.6847072)"
style="fill:none;stroke:#222222;stroke-width:3.01583;stroke-dasharray:none;stroke-opacity:1" />
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,47 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
id="a"
viewBox="0 0 16 16"
version="1.1"
sodipodi:docname="listenbrainz-logo.svg"
width="16"
height="16"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview2"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="45.119402"
inkscape:cx="3.9007609"
inkscape:cy="8.3445255"
inkscape:window-width="1920"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="a" />
<defs
id="defs1">
<style
id="style1">.b{fill:#353070;}.c{fill:#eb743b;}</style>
</defs>
<polygon
class="b"
points="1,22 13,29 13,1 1,8 "
id="polygon1"
transform="matrix(0.5,0,0,0.5,1.25,0.5)" />
<polygon
class="c"
points="26,8 26,22 14,29 14,1 "
id="polygon2"
transform="matrix(0.5,0,0,0.5,1.25,0.5)" />
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,7 +0,0 @@
/* Copyright 2023 (c) Anna Schumaker */
CREATE TABLE test (a INT, b INT);
INSERT INTO test VALUES (1, 2);
INSERT INTO test VALUES (3, 4);
INSERT INTO test VALUES (5, 6);
INSERT INTO test VALUES (7, 8);
INSERT INTO test VALUES (9, 0);

View File

@ -41,22 +41,6 @@ class TestAlbumObject(tests.util.TestCase):
self.assertEqual(album2.mbid, "ab-cd-ef")
self.assertEqual(album2.cover, cover)
def test_add_remove_medium(self):
"""Test adding and removing a medium from the Album."""
album = self.table.create("Test Album", "Album Artist", "2023-03")
medium = self.sql.media.create(album, "Test Medium", number=1)
self.assertFalse(medium in self.album.child_set)
self.assertFalse(self.album.has_medium(medium))
self.album.add_medium(medium)
self.assertTrue(medium in self.album.child_set)
self.assertTrue(self.album.has_medium(medium))
self.album.remove_medium(medium)
self.assertFalse(medium in self.album.child_set)
self.assertFalse(self.album.has_medium(medium))
def test_get_artists(self):
"""Test getting the list of artists for this album."""
with unittest.mock.patch.object(self.table, "get_artists",
@ -72,14 +56,22 @@ class TestAlbumObject(tests.util.TestCase):
self.assertListEqual(self.album.get_media(), [1, 2, 3])
mock.assert_called_with(self.album)
def test_children(self):
"""Test the Album's 'children' model is set up properly."""
self.assertIsInstance(self.album.child_set,
emmental.db.table.TableSubset)
def test_media_model(self):
"""Test getting a Gio.ListModel representing this Album's media."""
self.assertIsInstance(self.album.children, Gtk.FilterListModel)
self.assertEqual(self.album.children.get_filter(),
self.sql.media.get_filter())
self.assertEqual(self.album.child_set.table, self.sql.media)
self.assertIsInstance(self.album.children.get_filter(),
Gtk.CustomFilter)
self.assertEqual(self.album.children.get_model(), self.sql.media)
album = self.table.create("Test Album", "Album Artist", "2023-03")
medium = self.sql.media.create(album, "Test Medium", number=1)
self.assertTrue(album.children.get_filter().match(medium))
medium.albumid = album.albumid + 1
self.assertFalse(album.children.get_filter().match(medium))
medium = self.sql.media.create(album, "", number=2)
self.assertFalse(album.children.get_filter().match(medium))
class TestAlbumTable(tests.util.TestCase):
@ -236,11 +228,9 @@ class TestAlbumTable(tests.util.TestCase):
def test_load(self):
"""Test loading the album table."""
album = self.table.create("Album 1", "Album Artist", "2023-03")
self.table.create("Album 1", "Album Artist", "2023-03")
self.table.create("Album 2", "Album Artist", "2023-03",
mbid="ab-cd-ef", cover=tests.util.COVER_JPG)
medium = self.sql.media.create(album, "Test Medium", number=1)
album.add_medium(medium)
albums2 = emmental.db.albums.Table(self.sql)
self.assertEqual(len(albums2), 0)
@ -253,7 +243,6 @@ class TestAlbumTable(tests.util.TestCase):
self.assertEqual(albums2.get_item(0).release, "2023-03")
self.assertEqual(albums2.get_item(0).mbid, "")
self.assertIsNone(albums2.get_item(0).cover)
self.assertSetEqual(albums2.get_item(0).child_set.keyset.keys, {1})
self.assertEqual(albums2.get_item(1).name, "Album 2")
self.assertEqual(albums2.get_item(1).artist, "Album Artist")
@ -261,7 +250,6 @@ class TestAlbumTable(tests.util.TestCase):
self.assertEqual(albums2.get_item(1).mbid, "ab-cd-ef")
self.assertEqual(albums2.get_item(1).cover,
tests.util.COVER_JPG)
self.assertSetEqual(albums2.get_item(1).child_set.keyset.keys, set())
def test_lookup(self):
"""Test looking up album playlists."""
@ -332,6 +320,4 @@ class TestAlbumTable(tests.util.TestCase):
medium1 = self.sql.media.create(album, "", number=1)
medium2 = self.sql.media.create(album, "", number=2)
self.assertSetEqual(self.table.get_mediumids(album),
{medium1.mediumid, medium2.mediumid})
self.assertListEqual(self.table.get_media(album), [medium1, medium2])

View File

@ -20,6 +20,7 @@ class TestArtistObject(tests.util.TestCase):
def test_init(self):
"""Test that the Artist is set up properly."""
self.assertIsInstance(self.artist, emmental.db.playlist.Playlist)
self.assertSetEqual(self.artist.children.get_filter().keys, set())
self.assertEqual(self.artist.table, self.table)
self.assertEqual(self.artist.propertyid, 456)
self.assertEqual(self.artist.artistid, 123)
@ -36,7 +37,8 @@ class TestArtistObject(tests.util.TestCase):
self.artist.add_album(album)
mock_add.assert_called_with(self.artist, album)
self.assertIn(album, self.artist.child_set)
self.assertSetEqual(self.artist.children.get_filter().keys,
{album.albumid})
self.assertTrue(self.artist.has_album(album))
with unittest.mock.patch.object(self.table, "remove_album",
@ -44,17 +46,15 @@ class TestArtistObject(tests.util.TestCase):
self.artist.remove_album(album)
mock_remove.assert_called_with(self.artist, album)
self.assertNotIn(album, self.artist.child_set)
self.assertSetEqual(self.artist.children.get_filter().keys, set())
self.assertFalse(self.artist.has_album(album))
def test_children(self):
"""Test that Albums have been added as Artist playlist children."""
self.assertIsInstance(self.artist.child_set,
emmental.db.table.TableSubset)
self.assertIsInstance(self.artist.children, Gtk.FilterListModel)
self.assertEqual(self.artist.children.get_filter(),
self.sql.albums.get_filter())
self.assertEqual(self.artist.child_set.table, self.sql.albums)
self.assertIsInstance(self.artist.children.get_filter(),
emmental.db.table.Filter)
self.assertEqual(self.artist.children.get_model(), self.sql.albums)
class TestFilter(tests.util.TestCase):
@ -68,7 +68,7 @@ class TestFilter(tests.util.TestCase):
def test_init(self):
"""Test that the filter is initialized properly."""
self.assertIsInstance(self.filter, emmental.db.table.KeySet)
self.assertIsInstance(self.filter, emmental.db.table.Filter)
self.assertFalse(self.filter.show_all)
filter2 = emmental.db.artists.Filter(show_all=True)
@ -219,11 +219,13 @@ class TestArtistTable(tests.util.TestCase):
self.assertEqual(artists2.get_item(0).name, "Artist 1")
self.assertEqual(artists2.get_item(0).mbid, "")
self.assertSetEqual(artists2.get_item(0).child_set.keyset.keys, {1})
self.assertSetEqual(artists2.get_item(0).children.get_filter().keys,
{1})
self.assertEqual(artists2.get_item(1).name, "Artist 2")
self.assertEqual(artists2.get_item(1).mbid, "ab-cd-ef")
self.assertSetEqual(artists2.get_item(1).child_set.keyset.keys, set())
self.assertSetEqual(artists2.get_item(1).children.get_filter().keys,
set())
def test_lookup(self):
"""Test looking up artist playlists."""

View File

@ -79,20 +79,6 @@ class TestConnection(unittest.TestCase):
self.assertEqual(tuple(rows[3]), (4, "d"))
self.assertEqual(tuple(rows[4]), (5, "e"))
@unittest.mock.patch("emmental.db.connection.Connection.commit")
def test_executescript(self, mock_commit: unittest.mock.Mock):
"""Test the executescript function."""
script = pathlib.Path(__file__).parent / "test-script.sql"
cur = self.sql.executescript(script)
self.assertIsInstance(cur, sqlite3.Cursor)
mock_commit.assert_called()
rows = self.sql("SELECT * FROM test").fetchall()
self.assertListEqual([(row["a"], row["b"]) for row in rows],
[(1, 2), (3, 4), (5, 6), (7, 8), (9, 0)])
self.assertIsNone(self.sql.executescript(script.parent / "no-script"))
def test_path_column(self):
"""Test that the PATH column type has been set up."""
self.sql("CREATE TABLE test (path PATH)")

View File

@ -11,10 +11,8 @@ class TestConnection(tests.util.TestCase):
def test_paths(self):
"""Check that path constants are pointing to the right places."""
dir = pathlib.Path(emmental.db.__file__).parent
self.assertEqual(emmental.db.SQL_V1_SCRIPT, dir / "emmental.sql")
self.assertEqual(emmental.db.SQL_V2_SCRIPT, dir / "upgrade-v2.sql")
self.assertEqual(emmental.db.SQL_V3_SCRIPT, dir / "upgrade-v3.sql")
script = pathlib.Path(emmental.db.__file__).parent / "emmental.sql"
self.assertEqual(emmental.db.SQL_SCRIPT, script)
def test_connection(self):
"""Check that the connection manager is initialized properly."""
@ -23,16 +21,16 @@ class TestConnection(tests.util.TestCase):
def test_version(self):
"""Test checking the database schema version."""
cur = self.sql("PRAGMA user_version")
self.assertEqual(cur.fetchone()["user_version"], 3)
self.assertEqual(cur.fetchone()["user_version"], 1)
def test_version_too_new(self):
"""Test failing when the database version is too new."""
self.sql._Connection__check_version()
self.sql("PRAGMA user_version = 4")
self.sql("PRAGMA user_version = 2")
with self.assertRaises(Exception) as e:
self.sql._Connection__check_version()
self.assertEqual(str(e.exception), "Unsupported data version: 4")
self.assertEqual(str(e.exception), "Unsupported data version: 2")
def test_close(self):
"""Check closing the connection."""
@ -73,34 +71,22 @@ class TestConnection(tests.util.TestCase):
def test_load(self):
"""Check that calling load() loads the tables."""
plist_tables = list(self.sql.playlist_tables())
all_tables = [self.sql.settings] + plist_tables + [self.sql.tracks]
idle_tables = [tbl for tbl in self.sql.playlist_tables()] + \
[self.sql.tracks]
table_loaded = unittest.mock.Mock()
self.sql.connect("table-loaded", table_loaded)
self.assertFalse(self.sql.loaded)
notify_loaded = unittest.mock.Mock()
self.sql.connect("notify::loaded", notify_loaded)
self.sql.load()
self.assertTrue(self.sql.settings.loaded)
notify_loaded.assert_not_called()
for tbl in all_tables[1:]:
for tbl in idle_tables:
self.assertFalse(tbl.loaded)
for tbl in plist_tables:
for tbl in idle_tables:
tbl.queue.complete()
self.assertTrue(tbl.loaded)
self.assertFalse(self.sql.loaded)
notify_loaded.assert_not_called()
self.sql.tracks.queue.complete()
self.assertTrue(self.sql.tracks.loaded)
self.assertTrue(self.sql.loaded)
notify_loaded.assert_called()
calls = [unittest.mock.call(self.sql, tbl) for tbl in all_tables]
calls = [unittest.mock.call(self.sql, tbl)
for tbl in [self.sql.settings] + idle_tables]
table_loaded.assert_has_calls(calls)
def test_filter(self):
@ -128,11 +114,6 @@ class TestConnection(tests.util.TestCase):
self.assertFalse(plist1.active)
self.assertTrue(plist2.active)
notify = unittest.mock.Mock()
self.sql.connect("notify::active-playlist", notify)
self.sql.set_active_playlist(plist2)
notify.assert_not_called()
self.sql.set_active_playlist(None)
self.assertIsNone(self.sql.active_playlist)
self.assertFalse(plist2.active)

View File

@ -28,21 +28,6 @@ class TestDecadeObject(tests.util.TestCase):
self.assertEqual(self.decade.name, "The 2020s")
self.assertIsNone(self.decade.parent)
def test_add_remove_year(self):
"""Test adding and removing a year from the decade."""
year = self.sql.years.create(1988)
self.assertFalse(year in self.decade.child_set)
self.assertFalse(self.decade.has_year(year))
self.decade.add_year(year)
self.assertTrue(year in self.decade.child_set)
self.assertTrue(self.decade.has_year(year))
self.decade.remove_year(year)
self.assertFalse(year in self.decade.child_set)
self.assertFalse(self.decade.has_year(year))
def test_get_years(self):
"""Test getting the list of years for this decade."""
with unittest.mock.patch.object(self.table, "get_years",
@ -52,12 +37,16 @@ class TestDecadeObject(tests.util.TestCase):
def test_years_model(self):
"""Test getting a Gio.ListModel representing a Decade's years."""
self.assertIsInstance(self.decade.child_set,
emmental.db.table.TableSubset)
self.assertIsInstance(self.decade.children, Gtk.FilterListModel)
self.assertEqual(self.decade.children.get_filter(),
self.sql.years.get_filter())
self.assertEqual(self.decade.child_set.table, self.sql.years)
self.assertIsInstance(self.decade.children.get_filter(),
Gtk.CustomFilter)
self.assertEqual(self.decade.children.get_model(), self.sql.years)
year = self.sql.years.create(2023)
self.assertTrue(self.decade.children.get_filter().match(year))
year = self.sql.years.create(1988)
self.assertFalse(self.decade.children.get_filter().match(year))
class TestDecadeTable(tests.util.TestCase):
@ -175,10 +164,8 @@ class TestDecadeTable(tests.util.TestCase):
def test_load(self):
"""Load the decade table from the database."""
decade = self.table.create(1980)
self.table.create(1980)
self.table.create(1990)
year = self.sql.years.create(1988)
decade.add_year(year)
decades2 = emmental.db.decades.Table(self.sql)
self.assertEqual(len(decades2), 0)
@ -188,11 +175,9 @@ class TestDecadeTable(tests.util.TestCase):
self.assertEqual(decades2.get_item(0).decade, 1980)
self.assertEqual(decades2.get_item(0).name, "The 1980s")
self.assertSetEqual(decades2.get_item(0).child_set.keyset.keys, {1988})
self.assertEqual(decades2.get_item(1).decade, 1990)
self.assertEqual(decades2.get_item(1).name, "The 1990s")
self.assertSetEqual(decades2.get_item(1).child_set.keyset.keys, set())
def test_lookup(self):
"""Test looking up decade playlists."""
@ -229,5 +214,4 @@ class TestDecadeTable(tests.util.TestCase):
y1985 = self.sql.years.create(1985)
y1988 = self.sql.years.create(1988)
self.assertSetEqual(self.table.get_yearids(decade), {1985, 1988})
self.assertListEqual(self.table.get_years(decade), [y1985, y1988])

View File

@ -51,26 +51,6 @@ class TestIdleQueue(unittest.TestCase):
self.assertEqual(self.queue.total, 0)
self.assertEqual(self.queue.progress, 0.0)
def test_cancel_task(self, mock_idle_add: unittest.mock.Mock,
mock_source_removed: unittest.mock.Mock):
"""Test canceling a specific task."""
self.queue.push(1)
self.queue.push(2)
self.queue.push(1)
self.queue.cancel_task(1)
self.assertListEqual(self.queue._tasks, [(2,)])
self.assertEqual(self.queue.total, 3)
self.assertAlmostEqual(self.queue.progress, 2 / 3)
mock_source_removed.assert_not_called()
self.queue.cancel_task(2)
self.assertListEqual(self.queue._tasks, [])
self.assertIsNone(self.queue._idle_id)
self.assertEqual(self.queue.total, 0)
self.assertEqual(self.queue.progress, 0.0)
mock_source_removed.assert_called_with(42)
def test_complete(self, mock_idle_add: unittest.mock.Mock,
mock_source_removed: unittest.mock.Mock):
"""Test completing queued tasks."""
@ -139,17 +119,6 @@ class TestIdleQueue(unittest.TestCase):
mock_idle_add.assert_not_called()
func.assert_called_with(1)
def test_push_first(self, mock_idle_add: unittest.mock.Mock,
mock_source_removed: unittest.mock.Mock):
"""Test pushing an idle task with first=True."""
self.queue.push(1)
self.queue.push(0, first=True)
self.assertListEqual(self.queue._tasks, [(0,), (1,)])
self.queue.push(2, first=False)
self.assertListEqual(self.queue._tasks, [(0,), (1,), (2,)])
self.queue.push(3)
self.assertListEqual(self.queue._tasks, [(0,), (1,), (2,), (3,)])
def test_push_many_enabled(self, mock_idle_add: unittest.mock.Mock,
mock_source_removed: unittest.mock.Mock):
"""Test adding several calls to one function at one time."""

View File

@ -182,23 +182,23 @@ class TestLibraryObject(tests.util.TestCase):
tagger.tag_file.assert_not_called()
tagger.ready.is_set.return_value = True
tagger.get_result.return_value = None
tagger.get_result.return_value = (None, None)
self.assertFalse(self.library._Library__tag_track(track))
tagger.get_result.assert_called_with(db=self.sql, library=self.library)
tagger.tag_file.assert_called_with(track, mtime=None)
tagger.get_result.assert_called_with(self.sql, self.library)
tagger.tag_file.assert_called_with(track, None)
self.sql.tracks.lookup = unittest.mock.Mock()
self.sql.tracks.lookup.return_value.mtime = 12345
self.assertFalse(self.library._Library__tag_track(track))
tagger.get_result.assert_called_with(db=self.sql, library=self.library)
tagger.tag_file.assert_called_with(track, mtime=12345)
tagger.get_result.assert_called_with(self.sql, self.library)
tagger.tag_file.assert_called_with(track, 12345)
tagger.reset_mock()
tagger.ready.is_set.return_value = True
tagger.get_result.return_value = {"path": track, "tags": tags}
tagger.get_result.return_value = (track, tags)
self.assertTrue(self.library._Library__tag_track(track))
tagger.tag_file.assert_not_called()
tagger.get_result.assert_called_with(db=self.sql, library=self.library)
tagger.get_result.assert_called_with(self.sql, self.library)
@unittest.mock.patch("emmental.db.tagger.untag_track")
def test_scan_check_trackid(self, mock_untag: unittest.mock.Mock()):

View File

@ -4,7 +4,6 @@ import pathlib
import unittest.mock
import emmental.db
import tests.util
from gi.repository import Gtk
class TestMediumObject(tests.util.TestCase):
@ -47,36 +46,6 @@ class TestMediumObject(tests.util.TestCase):
mock_rename.assert_called_with(self.medium, "New Name")
class TestFilter(tests.util.TestCase):
"""Test the medium filter."""
def setUp(self):
"""Set up common variables."""
super().setUp()
self.filter = emmental.db.media.Filter()
def test_init(self):
"""Test that the filter is initialized properly."""
self.assertIsInstance(self.filter, emmental.db.table.KeySet)
def test_strictness(self):
"""Test checking strictness."""
self.filter.keys = None
self.assertEqual(self.filter.get_strictness(), Gtk.FilterMatch.SOME)
self.filter.keys = set()
self.assertEqual(self.filter.get_strictness(), Gtk.FilterMatch.NONE)
self.filter.keys = {1, 2, 3}
self.assertEqual(self.filter.get_strictness(), Gtk.FilterMatch.SOME)
def test_match(self):
"""Test matching a Medium."""
album = self.sql.albums.create("Test Album", "Test Artist", "123")
medium = self.sql.media.create(album, "", number=1)
self.assertFalse(self.filter.match(medium))
medium.name = "abcde"
self.assertTrue(self.filter.match(medium))
class TestMediumsTable(tests.util.TestCase):
"""Tests our mediums table."""
@ -92,8 +61,6 @@ class TestMediumsTable(tests.util.TestCase):
def test_init(self):
"""Test that the medium model is configured corretly."""
self.assertIsInstance(self.table, emmental.db.playlist.Table)
self.assertIsInstance(self.table.get_filter(),
emmental.db.media.Filter)
self.assertEqual(len(self.table), 0)
self.assertTrue(self.table.autodelete)
self.assertFalse(self.table.system_tracks)
@ -132,7 +99,6 @@ class TestMediumsTable(tests.util.TestCase):
self.assertEqual(medium1.number, 1)
self.assertEqual(medium1.type, "")
self.assertEqual(medium1.sort_order, "mediumno, number")
self.assertTrue(self.album.has_medium(medium1))
cur = self.sql("SELECT COUNT(name) FROM media")
self.assertEqual(cur.fetchone()["COUNT(name)"], 1)
@ -157,7 +123,6 @@ class TestMediumsTable(tests.util.TestCase):
medium = self.table.create(self.album, "Medium 1", number=1)
self.assertTrue(medium.delete())
self.assertIsNone(self.table.index(medium))
self.assertFalse(self.album.has_medium(medium))
cur = self.sql("SELECT COUNT(name) FROM media")
self.assertEqual(cur.fetchone()["COUNT(name)"], 0)
@ -207,19 +172,17 @@ class TestMediumsTable(tests.util.TestCase):
self.assertEqual(len(mediums2), 0)
mediums2.load(now=True)
self.assertEqual(len(mediums2.store), 2)
self.assertEqual(len(mediums2), 2)
self.assertEqual(mediums2.store.get_item(0).albumid,
self.album.albumid)
self.assertEqual(mediums2.store.get_item(0).name, "")
self.assertEqual(mediums2.store.get_item(0).number, 1)
self.assertEqual(mediums2.store.get_item(0).type, "")
self.assertEqual(mediums2.get_item(0).albumid, self.album.albumid)
self.assertEqual(mediums2.get_item(0).name, "")
self.assertEqual(mediums2.get_item(0).number, 1)
self.assertEqual(mediums2.get_item(0).type, "")
self.assertEqual(mediums2.store.get_item(1).albumid,
self.album.albumid)
self.assertEqual(mediums2.store.get_item(1).name, "Medium 2")
self.assertEqual(mediums2.store.get_item(1).number, 2)
self.assertEqual(mediums2.store.get_item(1).type, "Digital Media")
self.assertEqual(mediums2.get_item(1).albumid, self.album.albumid)
self.assertEqual(mediums2.get_item(1).name, "Medium 2")
self.assertEqual(mediums2.get_item(1).number, 2)
self.assertEqual(mediums2.get_item(1).type, "Digital Media")
def test_lookup(self):
"""Test looking up medium playlists."""

View File

@ -21,7 +21,6 @@ class TestPlaylistRow(unittest.TestCase):
self.table.move_track_up = unittest.mock.Mock(return_value=True)
self.table.get_trackids = unittest.mock.Mock(return_value={1, 2, 3})
self.table.get_track_order = unittest.mock.Mock()
self.table.refilter = unittest.mock.Mock()
self.table.queue = emmental.db.idle.Queue()
self.table.update = unittest.mock.Mock(return_value=True)
@ -66,28 +65,14 @@ class TestPlaylistRow(unittest.TestCase):
def test_children(self):
"""Test the child playlist properties."""
self.assertIsNone(self.playlist.child_set)
self.assertIsNone(self.playlist.children)
table = emmental.db.table.Table(None)
self.playlist.add_children(table, set())
self.assertIsInstance(self.playlist.child_set,
emmental.db.table.TableSubset)
self.assertEqual(self.playlist.child_set.table, table)
self.assertSetEqual(self.playlist.child_set.keyset.keys, set())
filter = Gtk.Filter()
self.playlist.add_children(self.table, filter)
self.assertIsInstance(self.playlist.children, Gtk.FilterListModel)
self.assertEqual(self.playlist.children.get_filter(),
table.get_filter())
self.assertEqual(self.playlist.children.get_model(),
self.playlist.child_set)
self.assertFalse(self.playlist.children.get_incremental())
playlist2 = emmental.db.playlist.Playlist(table=self.table,
propertyid=2, name="Plist2")
playlist2.add_children(table, {1, 2, 3})
self.assertSetEqual(playlist2.child_set.keyset.keys, {1, 2, 3})
self.assertEqual(self.playlist.children.get_filter(), filter)
self.assertEqual(self.playlist.children.get_model(), self.table)
self.assertTrue(self.playlist.children.get_incremental())
def test_parent(self):
"""Test the parent playlist property."""
@ -112,20 +97,6 @@ class TestPlaylistRow(unittest.TestCase):
self.table.update.assert_called_with(self.playlist,
prop, value)
def test_add_child(self):
"""Test adding a child playlist to the playlist."""
table = emmental.db.table.Table(None)
child1 = tests.util.table.MockRow(table=table, number=1)
child2 = tests.util.table.MockRow(table=table, number=2)
self.playlist.add_children(table, set())
self.playlist.add_child(child1)
self.assertIn(child1, self.playlist.child_set)
self.table.refilter.assert_called_with(Gtk.FilterChange.LESS_STRICT)
self.playlist.add_child(child2)
self.table.refilter.assert_called_once()
def test_add_track(self):
"""Test adding a track to the playlist."""
self.playlist.add_track(self.track, idle=True)
@ -151,18 +122,6 @@ class TestPlaylistRow(unittest.TestCase):
{1: 3, 2: 2, 3: 1})
self.table.get_track_order.assert_called_with(self.playlist)
def test_has_child(self):
"""Test the playlist has_child() function."""
table = emmental.db.table.Table(None)
child = tests.util.table.MockRow(table=table, number=1)
self.playlist.add_children(table, set())
self.assertFalse(self.playlist.has_child(child))
self.playlist.add_child(child)
self.assertTrue(self.playlist.has_child(child))
self.playlist.remove_child(child)
self.assertFalse(self.playlist.has_child(child))
def test_has_track(self):
"""Test the playlist has_track() function."""
self.assertFalse(self.playlist.has_track(self.track))
@ -182,23 +141,6 @@ class TestPlaylistRow(unittest.TestCase):
self.assertTrue(self.playlist.move_track_up(self.track))
self.table.move_track_up.assert_called_with(self.playlist, self.track)
def test_remove_child(self):
"""Test removing a child playlist from the playlist."""
table = emmental.db.table.Table(None)
child1 = tests.util.table.MockRow(table=table, number=1)
child2 = tests.util.table.MockRow(table=table, number=2)
self.playlist.add_children(table, set())
self.playlist.add_child(child1)
self.playlist.add_child(child2)
self.table.refilter.reset_mock()
self.playlist.remove_child(child1)
self.assertFalse(child1 in self.playlist.child_set)
self.table.refilter.assert_not_called()
self.playlist.remove_child(child2)
self.table.refilter.assert_called_with(Gtk.FilterChange.MORE_STRICT)
def test_remove_track(self):
"""Test removing a track from the playlist."""
self.playlist.tracks.trackids.add(self.track.trackid)
@ -416,27 +358,6 @@ class TestPlaylistTable(tests.util.TestCase):
self.table.move_track_up(plist, self.track)
self.assertEqual(plist.sort_order, "user")
def test_refilter(self):
"""Test refiltering the playlist table."""
self.table.queue.push(unittest.mock.Mock())
with unittest.mock.patch.object(self.table.get_filter(),
"changed") as mock_changed:
self.table.refilter(Gtk.FilterChange.MORE_STRICT)
self.assertEqual(self.table.queue[0],
(self.table._Table__refilter,
Gtk.FilterChange.MORE_STRICT))
mock_changed.assert_not_called()
self.table.refilter(Gtk.FilterChange.LESS_STRICT)
self.assertEqual(self.table.queue[0],
(self.table._Table__refilter,
Gtk.FilterChange.LESS_STRICT))
mock_changed.assert_not_called()
self.table.queue.complete()
mock_changed.assert_called_with(Gtk.FilterChange.LESS_STRICT)
def test_remove_track(self):
"""Test adding a track to a playlist."""
self.assertTrue(self.table.system_tracks)

View File

@ -1,6 +1,5 @@
# Copyright 2022 (c) Anna Schumaker
"""Tests our playlist Gio.ListModel."""
import datetime
import pathlib
import unittest.mock
import emmental.db
@ -327,18 +326,6 @@ class TestSystemPlaylists(tests.util.TestCase):
pathlib.Path("/a/b/1.ogg"),
self.medium, self.year)
def test_midnight_alarm(self):
"""Test playlist maintenance run every night at midnight."""
with unittest.mock.patch.object(self.table.new_tracks,
"reload_tracks") as mock_reload:
self.table._Table__at_midnight()
mock_reload.assert_called()
with unittest.mock.patch("emmental.alarm.set_alarm") as mock_set:
table2 = emmental.db.playlists.Table(self.sql)
mock_set.assert_called_with(datetime.time(second=5),
table2._Table__at_midnight)
def test_collection(self):
"""Test the Collection playlist."""
self.assertIsInstance(self.table.collection,
@ -628,11 +615,6 @@ class TestSystemPlaylists(tests.util.TestCase):
self.table.queued.propertyid).fetchall()
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["trackid"], self.track.trackid)
self.assertEqual(self.sql.active_playlist, self.table.queued)
self.sql.set_active_playlist(self.table.collection)
self.table.queued.add_track(self.track)
self.assertEqual(self.sql.active_playlist, self.table.queued)
self.library.deleting = True
self.table.queued.reload_tracks()

View File

@ -50,166 +50,119 @@ class TestRow(unittest.TestCase):
@unittest.mock.patch("gi.repository.Gtk.Filter.changed")
class TestKeySet(unittest.TestCase):
"""Tests our KeySet for holding database Rows."""
class TestFilter(unittest.TestCase):
"""Tests our database row Filter."""
def setUp(self):
"""Set up common variables."""
self.keyset = emmental.db.table.KeySet()
self.filter = emmental.db.table.Filter()
self.table = Gio.ListStore()
self.row1 = tests.util.table.MockRow(number=1, table=self.table)
self.row2 = tests.util.table.MockRow(number=2, table=self.table)
def test_init(self, mock_changed: unittest.mock.Mock):
"""Test that the KeySet is created correctly."""
self.assertIsInstance(self.keyset, Gtk.Filter)
self.assertIsNone(self.keyset._keys, None)
self.assertEqual(self.keyset.n_keys, -1)
"""Test that the filter is created correctly."""
self.assertIsInstance(self.filter, Gtk.Filter)
self.assertIsNone(self.filter._keys, None)
self.assertEqual(self.filter.n_keys, -1)
keyset2 = emmental.db.table.KeySet(keys={1, 2, 3})
self.assertSetEqual(keyset2._keys, {1, 2, 3})
self.assertEqual(keyset2.n_keys, 3)
filter2 = emmental.db.table.Filter(keys={1, 2, 3})
self.assertSetEqual(filter2._keys, {1, 2, 3})
self.assertEqual(filter2.n_keys, 3)
def test_subtract(self, mock_changed: unittest.mock.Mock):
"""Test subtracting two KeySets."""
keyset2 = emmental.db.table.KeySet(keys={2, 3})
self.assertIsNone(self.keyset - self.keyset)
self.assertIsNone(self.keyset - keyset2)
self.assertSetEqual(keyset2 - self.keyset, {2, 3})
"""Test subtracting two filters."""
filter2 = emmental.db.table.Filter(keys={2, 3})
self.assertIsNone(self.filter - self.filter)
self.assertIsNone(self.filter - filter2)
self.assertSetEqual(filter2 - self.filter, {2, 3})
self.keyset.keys = {1, 2, 3, 4, 5}
self.assertSetEqual(self.keyset - keyset2, {1, 4, 5})
self.assertSetEqual(keyset2 - self.keyset, set())
self.filter.keys = {1, 2, 3, 4, 5}
self.assertSetEqual(self.filter - filter2, {1, 4, 5})
self.assertSetEqual(filter2 - self.filter, set())
def test_strictness(self, mock_changed: unittest.mock.Mock):
"""Test checking strictness."""
self.assertEqual(self.keyset.get_strictness(), Gtk.FilterMatch.ALL)
self.keyset._keys = set()
self.assertEqual(self.keyset.get_strictness(), Gtk.FilterMatch.NONE)
self.keyset._keys = {1, 2, 3}
self.assertEqual(self.keyset.get_strictness(), Gtk.FilterMatch.SOME)
self.assertEqual(self.filter.get_strictness(), Gtk.FilterMatch.ALL)
self.filter._keys = set()
self.assertEqual(self.filter.get_strictness(), Gtk.FilterMatch.NONE)
self.filter._keys = {1, 2, 3}
self.assertEqual(self.filter.get_strictness(), Gtk.FilterMatch.SOME)
def test_add_row(self, mock_changed: unittest.mock.Mock):
"""Test adding Rows to the KeySet."""
mock_added = unittest.mock.Mock()
self.keyset.connect("key-added", mock_added)
"""Test adding Rows to the filter."""
self.filter.add_row(self.row1)
self.assertIsNone(self.filter.keys)
self.keyset.add_row(self.row1)
self.assertIsNone(self.keyset.keys)
mock_added.assert_not_called()
self.keyset.keys = set()
self.keyset.add_row(self.row1)
self.assertSetEqual(self.keyset.keys, {1})
self.assertEqual(self.keyset.n_keys, 1)
self.filter.keys = set()
self.filter.add_row(self.row1)
self.assertSetEqual(self.filter.keys, {1})
mock_changed.assert_called_with(Gtk.FilterChange.LESS_STRICT)
mock_added.assert_called_with(self.keyset, 1)
self.assertEqual(self.filter.n_keys, 1)
self.keyset.add_row(self.row2)
self.assertSetEqual(self.keyset.keys, {1, 2})
self.assertEqual(self.keyset.n_keys, 2)
self.filter.add_row(self.row2)
self.assertSetEqual(self.filter.keys, {1, 2})
mock_changed.assert_called_with(Gtk.FilterChange.LESS_STRICT)
mock_added.assert_called_with(self.keyset, 2)
mock_changed.reset_mock()
mock_added.reset_mock()
self.keyset.add_row(self.row2)
self.assertSetEqual(self.keyset.keys, {1, 2})
mock_changed.assert_not_called()
mock_added.assert_not_called()
self.assertEqual(self.filter.n_keys, 2)
def test_remove_row(self, mock_changed: unittest.mock.Mock):
"""Test removing Rows from the KeySet."""
mock_removed = unittest.mock.Mock()
self.keyset.connect("key-removed", mock_removed)
self.keyset.remove_row(self.row1)
"""Test removing Rows from the filter."""
self.filter.remove_row(self.row1)
mock_changed.assert_not_called()
mock_removed.assert_not_called()
self.keyset.keys = {1, 2}
self.keyset.remove_row(self.row1)
self.assertSetEqual(self.keyset._keys, {2})
self.assertEqual(self.keyset.n_keys, 1)
self.filter.keys = {1, 2}
self.filter.remove_row(self.row1)
self.assertSetEqual(self.filter._keys, {2})
mock_changed.assert_called_with(Gtk.FilterChange.MORE_STRICT)
mock_removed.assert_called_with(self.keyset, 1)
self.assertEqual(self.filter.n_keys, 1)
mock_changed.reset_mock()
mock_removed.reset_mock()
self.keyset.remove_row(self.row1)
self.assertSetEqual(self.keyset.keys, {2})
self.assertEqual(self.keyset.n_keys, 1)
mock_changed.assert_not_called()
mock_removed.assert_not_called()
self.keyset.remove_row(self.row2)
self.assertSetEqual(self.keyset._keys, set())
self.assertEqual(self.keyset.n_keys, 0)
self.filter.remove_row(self.row2)
self.assertSetEqual(self.filter._keys, set())
mock_changed.assert_called_with(Gtk.FilterChange.MORE_STRICT)
mock_removed.assert_called_with(self.keyset, 2)
self.assertEqual(self.filter.n_keys, 0)
def test_keys(self, mock_changed: unittest.mock.Mock):
"""Test getting and setting the KeySet.keys property."""
mock_keys_changed = unittest.mock.Mock()
self.keyset.connect("keys-changed", mock_keys_changed)
"""Test setting and getting the filter keys property."""
self.assertIsNone(self.filter.keys)
self.assertIsNone(self.keyset.keys)
self.keyset.keys = None
self.assertIsNone(self.keyset.keys)
mock_changed.assert_not_called()
mock_keys_changed.assert_not_called()
self.keyset.keys = {1, 2, 3}
self.assertSetEqual(self.keyset._keys, {1, 2, 3})
self.assertEqual(self.keyset.n_keys, 3)
self.filter.keys = {1, 2, 3}
self.assertSetEqual(self.filter._keys, {1, 2, 3})
mock_changed.assert_called_with(Gtk.FilterChange.MORE_STRICT)
mock_keys_changed.assert_called_with(self.keyset, set(), {1, 2, 3})
self.assertEqual(self.filter.n_keys, 3)
mock_changed.reset_mock()
self.keyset.keys = {1, 2}
self.assertSetEqual(self.keyset.keys, {1, 2})
self.assertEqual(self.keyset.n_keys, 2)
self.filter.keys = {1, 2}
self.assertSetEqual(self.filter.keys, {1, 2})
mock_changed.assert_called_with(Gtk.FilterChange.MORE_STRICT)
mock_keys_changed.assert_called_with(self.keyset, {3}, set())
self.assertEqual(self.filter.n_keys, 2)
mock_changed.reset_mock()
mock_keys_changed.reset_mock()
self.keyset.keys = {1, 2}
self.filter.keys = {1, 2}
mock_changed.assert_not_called()
mock_keys_changed.assert_not_called()
self.keyset.keys = {1, 2, 3}
self.assertSetEqual(self.keyset.keys, {1, 2, 3})
self.filter.keys = {1, 2, 3}
self.assertSetEqual(self.filter.keys, {1, 2, 3})
mock_changed.assert_called_with(Gtk.FilterChange.LESS_STRICT)
mock_keys_changed.assert_called_with(self.keyset, set(), {3})
self.keyset.keys = {4, 5, 6}
self.assertSetEqual(self.keyset._keys, {4, 5, 6})
self.filter.keys = {4, 5, 6}
self.assertSetEqual(self.filter._keys, {4, 5, 6})
mock_changed.assert_called_with(Gtk.FilterChange.DIFFERENT)
mock_keys_changed.assert_called_with(self.keyset, {1, 2, 3}, {4, 5, 6})
self.keyset.keys = None
self.assertIsNone(self.keyset._keys)
self.assertEqual(self.keyset.n_keys, -1)
self.filter.keys = None
self.assertIsNone(self.filter._keys)
mock_changed.assert_called_with(Gtk.FilterChange.LESS_STRICT)
mock_keys_changed.assert_called_with(self.keyset, {4, 5, 6}, set())
self.assertEqual(self.filter.n_keys, -1)
def test_match_contains(self, mock_changed: unittest.mock.Mock):
"""Test matching Rows and the __contains__() magic method."""
self.assertTrue(self.keyset.match(self.row1))
self.assertTrue(self.row1 in self.keyset)
self.keyset.keys = {1, 2, 3}
self.assertTrue(self.keyset.match(self.row1))
self.assertTrue(self.row1 in self.keyset)
self.keyset.keys = {4, 5, 6}
self.assertFalse(self.keyset.match(self.row1))
self.assertFalse(self.row1 in self.keyset)
self.keyset.keys = set()
self.assertFalse(self.keyset.match(self.row1))
self.assertFalse(self.row1 in self.keyset)
def test_match(self, mock_changed: unittest.mock.Mock):
"""Test matching playlists."""
self.assertTrue(self.filter.match(self.row1))
self.filter.keys = {1, 2, 3}
self.assertTrue(self.filter.match(self.row1))
self.filter.keys = {4, 5, 6}
self.assertFalse(self.filter.match(self.row1))
self.filter.keys = set()
self.assertFalse(self.filter.match(self.row1))
class TestTable(tests.util.TestCase):
@ -225,7 +178,7 @@ class TestTable(tests.util.TestCase):
self.assertIsInstance(self.table, Gtk.FilterListModel)
self.assertIsInstance(self.table.queue, emmental.db.idle.Queue)
self.assertIsInstance(self.table.get_filter(),
emmental.db.table.KeySet)
emmental.db.table.Filter)
self.assertIsInstance(self.table.store, emmental.store.SortedList)
self.assertIsInstance(self.table.rows, dict)
@ -233,9 +186,9 @@ class TestTable(tests.util.TestCase):
self.assertEqual(self.table.get_model(), self.table.store)
self.assertEqual(self.table.store.key_func, self.table.get_sort_key)
self.assertDictEqual(self.table.rows, {})
self.assertFalse(self.table.get_incremental())
self.assertTrue(self.table.get_incremental())
filter2 = emmental.db.table.KeySet()
filter2 = emmental.db.table.Filter()
queue2 = emmental.db.idle.Queue()
table2 = emmental.db.table.Table(self.sql, filter=filter2,
queue=queue2)
@ -355,12 +308,9 @@ class TestTableFunctions(tests.util.TestCase):
def test_delete(self):
"""Test deleting rows."""
row = self.table.create(number=1)
with unittest.mock.patch.object(self.sql, "commit") as mock_commit:
self.assertTrue(row.delete())
self.assertEqual(len(self.table), 0)
self.assertDictEqual(self.table.rows, dict())
mock_commit.assert_called()
self.assertTrue(row.delete())
self.assertEqual(len(self.table), 0)
self.assertDictEqual(self.table.rows, dict())
self.assertFalse(row.delete())
@ -368,13 +318,9 @@ class TestTableFunctions(tests.util.TestCase):
"""Test filtering Rows in the table."""
for n in [1, 121, 212, 333]:
self.table.create(number=n)
self.table.queue.push(unittest.mock.Mock())
self.table.filter("*3*")
self.assertIsNone(self.table.get_filter().keys)
self.assertEqual(self.table.queue[0], (self.table._filter_idle, "*3*"))
self.table.filter("*2*")
self.assertIsNone(self.table.get_filter().keys)
self.assertEqual(self.table.queue[0], (self.table._filter_idle, "*2*"))
self.table.queue.complete()
@ -444,147 +390,3 @@ class TestTableFunctions(tests.util.TestCase):
self.table.create(number=3)
self.assertFalse(self.table.update(row, "number", 3))
class TestTableSubset(tests.util.TestCase):
"""Tests the TableSubset."""
def setUp(self):
"""Set up common variables."""
super().setUp()
self.table = tests.util.table.MockTable(self.sql)
self.subset = emmental.db.table.TableSubset(self.table)
self.rows = [self.table.create(number=i) for i in range(5)]
def test_init(self):
"""Test that the TableSubset was set up properly."""
self.assertIsInstance(self.subset, Gio.ListModel)
self.assertIsInstance(self.subset, GObject.GObject)
self.assertIsInstance(self.subset.keyset, emmental.db.table.KeySet)
self.assertSetEqual(self.subset.keyset.keys, set())
self.assertEqual(self.subset.table, self.table)
subset2 = emmental.db.table.TableSubset(self.table, keys={1, 2, 3})
self.assertSetEqual(subset2.keyset.keys, {1, 2, 3})
def test_get_item_type(self):
"""Test the Gio.ListModel.get_item_type() function."""
self.assertEqual(self.subset.get_item_type(),
emmental.db.table.Row.__gtype__)
def test_get_n_items(self):
"""Test the Gio.ListModel.get_n_items() function."""
self.assertEqual(self.subset.get_n_items(), 0)
self.assertEqual(self.subset.n_rows, 0)
self.subset.add_row(self.rows[0])
self.assertEqual(self.subset.get_n_items(), 0)
self.assertEqual(self.subset.n_rows, 0)
self.table.loaded = True
self.assertEqual(self.subset.get_n_items(), 1)
self.assertEqual(self.subset.n_rows, 1)
self.table.loaded = False
self.assertEqual(self.subset.get_n_items(), 0)
self.assertEqual(self.subset.n_rows, 0)
def test_get_item(self):
"""Test the Gio.ListModel.get_item() function."""
for row in self.rows:
self.subset.add_row(row)
self.assertListEqual(self.subset._items, [])
for i, row in enumerate(self.rows):
with self.subTest(i=i, row=row.number):
self.assertIsNone(self.subset.get_item(i))
self.table.loaded = True
self.assertEqual(self.subset.get_item(i), row)
self.assertEqual(self.subset._items[i], row)
self.table.loaded = False
self.assertIsNone(self.subset.get_item(i))
def test_add_row(self):
"""Test adding a row to the TableSubset."""
expected = set()
self.table.loaded = True
self.assertListEqual(self.subset._items, [])
changed = unittest.mock.Mock()
self.subset.connect("items-changed", changed)
for n, i in enumerate([2, 0, 4, 1, 3], start=1):
row = self.rows[i]
with self.subTest(i=i, row=row.number):
expected.add(i)
self.subset.add_row(row)
self.assertSetEqual(self.subset.keyset.keys, expected)
self.assertEqual(self.subset.n_rows, n)
changed.assert_called_with(self.subset,
sorted(expected).index(i), 0, 1)
self.assertListEqual(self.subset._items, self.rows)
self.assertListEqual(list(self.subset), self.rows)
def test_remove_row(self):
"""Test removing a row from the TableSubset."""
self.table.loaded = True
[self.subset.add_row(row) for row in self.rows]
expected = {row.number for row in self.rows}
changed = unittest.mock.Mock()
self.subset.connect("items-changed", changed)
for n, i in enumerate([2, 0, 4, 1, 3], start=1):
row = self.rows[i]
rm = sorted(expected).index(i)
with self.subTest(i=i, row=row.number):
expected.discard(i)
self.subset.remove_row(row)
self.assertSetEqual(self.subset.keyset.keys, expected)
self.assertEqual(self.subset.n_rows, 5 - n)
changed.assert_called_with(self.subset, rm, 1, 0)
self.assertEqual(self.subset.n_rows, 0)
def test_contains(self):
"""Test the __contains__() magic method."""
self.table.loaded = True
self.assertFalse(self.rows[0] in self.subset)
self.subset.add_row(self.rows[0])
self.assertTrue(self.rows[0] in self.subset)
def test_table_not_loaded(self):
"""Test operations when the table hasn't been loaded."""
self.subset.add_row(self.rows[0])
self.assertListEqual(self.subset._items, [])
self.assertEqual(self.subset.n_rows, 0)
self.assertIsNone(self.subset.get_item(0))
self.subset.remove_row(self.rows[0])
self.assertListEqual(self.subset._items, [])
self.assertEqual(self.subset.n_rows, 0)
def test_table_loaded(self):
"""Test changing the value of Table.loaded."""
changed = unittest.mock.Mock()
self.subset.connect("items-changed", changed)
self.table.loaded = True
changed.assert_not_called()
self.table.loaded = False
changed.assert_not_called()
self.subset.add_row(self.rows[0])
self.subset.add_row(self.rows[1])
self.table.loaded = True
self.assertEqual(self.subset.n_rows, 2)
changed.assert_called_with(self.subset, 0, 0, 2)
self.table.loaded = False
self.assertEqual(self.subset.n_rows, 0)
changed.assert_called_with(self.subset, 0, 2, 0)

View File

@ -1,9 +1,9 @@
# Copyright 2022 (c) Anna Schumaker
"""Tests our Mutagen wrapper."""
import pathlib
import threading
import unittest.mock
import emmental.db.tagger
import emmental.thread
import tests.util
@ -276,8 +276,8 @@ class TestTaggerThread(tests.util.TestCase):
def test_init(self, mock_file: unittest.mock.Mock):
"""Test that the tagger thread was initialized properly."""
self.assertIsInstance(self.tagger, emmental.thread.Thread)
self.assertIsNone(self.tagger._connection)
self.assertIsInstance(self.tagger, threading.Thread)
self.assertIsInstance(self.tagger._condition, threading.Condition)
self.assertTrue(self.tagger.is_alive())
def test_stop(self, mock_file: unittest.mock.Mock):
@ -285,49 +285,74 @@ class TestTaggerThread(tests.util.TestCase):
mock_connection = unittest.mock.Mock()
mock_connection.close = unittest.mock.Mock()
self.tagger._file = "abcde"
self.tagger._mtime = 12345
self.tagger._connection = mock_connection
self.tagger.stop()
with unittest.mock.patch.object(self.tagger._condition, "notify",
wraps=self.tagger._condition.notify) \
as mock_notify:
self.tagger.stop()
self.assertIsNone(self.tagger._file)
self.assertIsNone(self.tagger._mtime)
mock_notify.assert_called()
self.assertFalse(self.tagger.is_alive())
self.assertIsNone(self.tagger._connection)
mock_connection.close.assert_called()
def test_tag_file(self, mock_file: unittest.mock.Mock):
"""Test asking the thread to tag a file."""
path = pathlib.Path("/a/b/c.ogg")
mock_file.return_value = None
self.assertIsInstance(self.tagger.ready, threading.Event)
self.assertIsNone(self.tagger._file)
self.assertIsNone(self.tagger._tags)
self.assertIsNone(self.tagger._mtime)
self.assertTrue(self.tagger.ready.is_set())
mock_file.return_value = None
self.tagger.ready.set()
self.tagger.tag_file(path, mtime=None)
self.assertEqual(self.tagger._task, {"path": path, "mtime": None})
self.tagger._tags = 12345
self.tagger.tag_file(path, None)
self.assertFalse(self.tagger.ready.is_set())
self.assertEqual(self.tagger._file, path)
self.assertIsNone(self.tagger._mtime)
self.assertIsNone(self.tagger._tags)
self.tagger.ready.wait()
self.assertIsNone(self.tagger._tags)
mock_file.assert_called_with(pathlib.Path("/a/b/c.ogg"), None)
mock_file.return_value = self.make_tags(dict())
self.tagger.tag_file(path, mtime=12345)
self.assertEqual(self.tagger._task, {"path": path, "mtime": 12345})
self.tagger.tag_file(path, 12345)
self.assertEqual(self.tagger._mtime, 12345)
self.tagger.ready.wait()
mock_file.assert_called_with(path, 12345)
self.assertIsNotNone(self.tagger._tags)
mock_file.assert_called_with(self.tagger._file, 12345)
def test_get_result(self, mock_file: unittest.mock.Mock):
"""Test creating a Tags structure after tagging."""
mock_file.return_value = None
self.assertIsNone(self.tagger.get_result(db=self.sql,
library=self.library))
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), None)
self.assertTupleEqual(self.tagger.get_result(self.sql, self.library),
(None, None))
track_path = pathlib.Path("/a/b/c.ogg")
self.tagger.tag_file(track_path, mtime=None)
self.tagger.ready.wait()
self.assertTupleEqual(self.tagger.get_result(db=self.sql,
library=self.library),
(track_path, None))
self.assertTupleEqual(self.tagger.get_result(self.sql, self.library),
(pathlib.Path("/a/b/c.ogg"), None))
self.assertIsNone(self.tagger._file)
mock_file.return_value = self.make_tags(dict())
self.tagger.tag_file(track_path, mtime=None)
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), None)
self.tagger.ready.wait()
res = self.tagger.get_result(db=self.sql, library=self.library)
self.assertTupleEqual(res, (track_path, res[1]))
(file, tags) = self.tagger.get_result(self.sql, self.library)
self.assertEqual(file, pathlib.Path("/a/b/c.ogg"))
self.assertIsInstance(tags, emmental.db.tagger.Tags)
self.assertIsNone(self.tagger._file)
self.assertIsNone(self.tagger._mtime)
self.assertIsNone(self.tagger._tags)
@unittest.mock.patch("emmental.db.connection.Connection.__call__")
@unittest.mock.patch("musicbrainzngs.get_artist_by_id")
@ -345,7 +370,7 @@ class TestTaggerThread(tests.util.TestCase):
mock_cursor.fetchone = unittest.mock.Mock(return_value=None)
mock_connection.return_value = mock_cursor
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), mtime=None)
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), None)
self.tagger.ready.wait()
self.assertEqual(audio_tags.artists[0].name, "Some Artist")
self.assertEqual(audio_tags.artists[1].name, "Some Artist")
@ -369,7 +394,7 @@ class TestTaggerThread(tests.util.TestCase):
self.assertIsNone(self.tagger._connection)
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), mtime=None)
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), None)
self.tagger.ready.wait()
self.assertIsInstance(self.tagger._connection,
emmental.db.connection.Connection)

View File

@ -247,14 +247,10 @@ class TestTrackTable(tests.util.TestCase):
def test_create_restore(self):
"""Test restoring saved track data."""
now = datetime.datetime.utcnow()
today = now.date()
yesterday = today - datetime.timedelta(days=1)
now = datetime.datetime.now()
self.sql("""INSERT INTO saved_track_data
(mbid, favorite, playcount,
lastplayed, laststarted, added)
VALUES (?, ?, ?, ? , ?, ?)""",
"ab-cd-ef", True, 42, now, now, yesterday)
(mbid, favorite, playcount, lastplayed, laststarted)
VALUES (?, ?, ?, ? , ?)""", "ab-cd-ef", True, 42, now, now)
track1 = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
self.medium, self.year)
@ -262,7 +258,6 @@ class TestTrackTable(tests.util.TestCase):
self.assertEqual(track1.playcount, 0)
self.assertIsNone(track1.lastplayed)
self.assertIsNone(track1.laststarted)
self.assertEqual(track1.added, today)
row = self.sql("SELECT COUNT(*) FROM saved_track_data").fetchone()
self.assertEqual(row["COUNT(*)"], 1)
@ -273,7 +268,6 @@ class TestTrackTable(tests.util.TestCase):
self.assertEqual(track2.playcount, 42)
self.assertEqual(track2.lastplayed, now)
self.assertEqual(track2.laststarted, now)
self.assertEqual(track2.added, yesterday)
row = self.sql("SELECT COUNT(*) FROM saved_track_data").fetchone()
self.assertEqual(row["COUNT(*)"], 0)
@ -292,20 +286,6 @@ class TestTrackTable(tests.util.TestCase):
self.assertFalse(track.delete())
def test_delete_listens(self):
"""Test deleting listens from the listenbrainz_queue."""
track1 = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
self.medium, self.year, length=10)
track2 = self.tracks.create(self.library, pathlib.Path("/a/b/2.ogg"),
self.medium, self.year, length=10)
for track in [track1, track2]:
track.start()
track.stop(9)
self.tracks.delete_listens([1, 2])
self.assertListEqual(self.tracks.get_n_listens(5), [])
def test_delete_save(self):
"""Test saving track data when a track is deleted."""
now = datetime.datetime.now()
@ -328,7 +308,6 @@ class TestTrackTable(tests.util.TestCase):
self.assertEqual(rows[0]["laststarted"], now)
self.assertEqual(rows[0]["lastplayed"], now)
self.assertEqual(rows[0]["playcount"], 42)
self.assertEqual(rows[0]["added"], datetime.datetime.utcnow().date())
def test_filter(self):
"""Test filtering the Track table."""
@ -499,40 +478,6 @@ class TestTrackTable(tests.util.TestCase):
self.assertListEqual(self.tracks.get_genres(track),
[genre1, genre2])
def test_get_n_listens(self):
"""Test the get_n_listens() function."""
track1 = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
self.medium, self.year, length=10)
track2 = self.tracks.create(self.library, pathlib.Path("/a/b/2.ogg"),
self.medium, self.year, length=12)
self.assertListEqual(self.tracks.get_n_listens(2), [])
track1.start()
track1.stop(8)
ts1 = track1.lastplayed
self.assertListEqual(self.tracks.get_n_listens(2),
[(1, track1, ts1)])
track2.start()
track2.stop(11)
ts2 = track2.lastplayed
self.assertListEqual(self.tracks.get_n_listens(2),
[(2, track2, ts2),
(1, track1, ts1)])
track1.start()
track1.stop(9)
ts3 = track1.lastplayed
self.assertListEqual(self.tracks.get_n_listens(2),
[(3, track1, ts3),
(2, track2, ts2)])
self.assertListEqual(self.tracks.get_n_listens(4),
[(3, track1, ts3),
(2, track2, ts2),
(1, track1, ts1)])
def test_mark_path_active(self):
"""Test marking a path as active."""
self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
@ -563,79 +508,53 @@ class TestTrackTable(tests.util.TestCase):
track = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
self.medium, self.year)
with unittest.mock.patch.object(self.sql, "commit",
wraps=self.sql.commit) as mock_commit:
track.start()
mock_commit.assert_called()
track.start()
row = self.sql("SELECT laststarted FROM tracks WHERE trackid=?",
track.trackid).fetchone()
self.assertTrue(track.active)
self.assertIsNotNone(track.laststarted)
self.assertEqual(track.laststarted, row["laststarted"])
self.assertEqual(self.tracks.current_track, track)
row = self.sql("SELECT laststarted FROM tracks WHERE trackid=?",
track.trackid).fetchone()
self.assertTrue(track.active)
self.assertIsNotNone(track.laststarted)
self.assertEqual(track.laststarted, row["laststarted"])
self.assertEqual(self.tracks.current_track, track)
self.playlists.previous.remove_track.assert_called_with(track)
self.playlists.previous.add_track.assert_called_with(track)
self.playlists.previous.remove_track.assert_called_with(track)
self.playlists.previous.add_track.assert_called_with(track)
def test_stop_started_track(self):
"""Test marking that a Track has stopped playback."""
track = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
self.medium, self.year, length=10)
track_played = unittest.mock.Mock()
self.tracks.connect("track-played", track_played)
track.start()
with unittest.mock.patch.object(self.sql, "commit",
wraps=self.sql.commit) as mock_commit:
track.stop(3)
mock_commit.assert_called()
track.stop(3)
row = self.sql("SELECT lastplayed FROM tracks WHERE trackid=?",
track.trackid).fetchone()
self.assertFalse(track.active)
self.assertEqual(track.playcount, 0)
self.assertIsNone(row["lastplayed"])
self.assertIsNone(track.lastplayed)
self.assertIsNone(self.tracks.current_track)
row = self.sql("SELECT lastplayed FROM tracks WHERE trackid=?",
track.trackid).fetchone()
self.assertFalse(track.active)
self.assertEqual(track.playcount, 0)
self.assertIsNone(row["lastplayed"])
self.assertIsNone(track.lastplayed)
self.assertIsNone(self.tracks.current_track)
cur = self.sql("SELECT trackid, timestamp FROM listenbrainz_queue")
self.assertListEqual(cur.fetchall(), [])
self.playlists.most_played.reload_tracks.assert_not_called()
self.playlists.queued.remove_track.assert_not_called()
self.playlists.unplayed.remove_track.assert_not_called()
track_played.assert_not_called()
self.playlists.most_played.reload_tracks.assert_not_called()
self.playlists.queued.remove_track.assert_not_called()
self.playlists.unplayed.remove_track.assert_not_called()
track.start()
with unittest.mock.patch.object(self.sql, "commit",
wraps=self.sql.commit) as mock_commit:
track.stop(8)
mock_commit.assert_called()
track.stop(8)
row = self.sql("""SELECT lastplayed, playcount FROM tracks
WHERE trackid=?""", track.trackid).fetchone()
self.assertEqual(row["playcount"], 1)
self.assertEqual(track.playcount, 1)
self.assertEqual(row["lastplayed"], track.laststarted)
self.assertEqual(track.lastplayed, track.laststarted)
row = self.sql("""SELECT lastplayed, playcount FROM tracks
WHERE trackid=?""", track.trackid).fetchone()
self.assertEqual(row["playcount"], 1)
self.assertEqual(track.playcount, 1)
self.assertEqual(row["lastplayed"], track.laststarted)
self.assertEqual(track.lastplayed, track.laststarted)
cur = self.sql("SELECT trackid, timestamp FROM listenbrainz_queue")
row = cur.fetchall()[0]
self.assertEqual(row["trackid"], track.trackid)
self.assertEqual(row["timestamp"], track.lastplayed)
self.playlists.most_played.reload_tracks.assert_called()
self.playlists.queued.remove_track.assert_called_with(track)
self.playlists.unplayed.remove_track.assert_called_with(track)
track_played.assert_called_with(self.tracks, track)
self.playlists.most_played.reload_tracks.assert_called()
self.playlists.queued.remove_track.assert_called_with(track)
self.playlists.unplayed.remove_track.assert_called_with(track)
def test_stop_restarted_track(self):
"""Test marking that a restarted Track has stopped playback."""
track = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
self.medium, self.year, length=10)
track_played = unittest.mock.Mock()
self.tracks.connect("track-played", track_played)
track.restart()
track.stop(3)
@ -650,13 +569,9 @@ class TestTrackTable(tests.util.TestCase):
self.assertIsNone(track.restarted)
self.assertIsNone(self.tracks.current_track)
cur = self.sql("SELECT trackid, timestamp FROM listenbrainz_queue")
self.assertListEqual(cur.fetchall(), [])
self.playlists.most_played.reload_tracks.assert_not_called()
self.playlists.queued.remove_track.assert_not_called()
self.playlists.unplayed.remove_track.assert_not_called()
track_played.assert_not_called()
track.restart()
restarted = track.restarted
@ -670,15 +585,9 @@ class TestTrackTable(tests.util.TestCase):
self.assertEqual(row["laststarted"], restarted)
self.assertEqual(track.laststarted, restarted)
cur = self.sql("SELECT trackid, timestamp FROM listenbrainz_queue")
row = cur.fetchall()[0]
self.assertEqual(row["trackid"], track.trackid)
self.assertEqual(row["timestamp"], track.lastplayed)
self.playlists.most_played.reload_tracks.assert_called_with(idle=True)
self.playlists.queued.remove_track.assert_called_with(track)
self.playlists.unplayed.remove_track.assert_called_with(track)
track_played.assert_called_with(self.tracks, track)
def test_current_track(self):
"""Test the current-track and have-current-track properties."""

View File

@ -75,14 +75,12 @@ class TestYearTable(tests.util.TestCase):
def test_create(self):
"""Test creating a year playlist."""
decade = self.sql.decades.create(1980)
year = self.table.create(1988)
self.assertIsInstance(year, emmental.db.years.Year)
self.assertEqual(year.year, 1988)
self.assertEqual(year.name, "1988")
self.assertEqual(year.sort_order,
"release, albumartist, album, mediumno, number")
self.assertTrue(year in decade.child_set)
cur = self.sql("SELECT COUNT(year) FROM years")
self.assertEqual(cur.fetchone()["COUNT(year)"], 1)
@ -95,10 +93,8 @@ class TestYearTable(tests.util.TestCase):
def test_delete(self):
"""Test deleting a year playlist."""
decade = self.sql.decades.create(1980)
year = self.table.create(1988)
self.assertTrue(year.delete())
self.assertFalse(year in decade.child_set)
cur = self.sql("SELECT COUNT(year) FROM years")
self.assertEqual(cur.fetchone()["COUNT(year)"], 0)

View File

@ -36,103 +36,31 @@ class TestHeader(tests.util.TestCase):
self.assertEqual(self.header._title.get_tooltip_text(),
emmental.gsetup.env_string())
def test_show_sidebar(self):
"""Check that the show sidebar button works as expected."""
self.assertIsInstance(self.header._show_sidebar, Gtk.ToggleButton)
self.assertEqual(self.header._show_sidebar.props.icon_name,
"sidebar-show-symbolic")
self.assertFalse(self.header._show_sidebar.props.has_frame)
self.assertFalse(self.header._show_sidebar.props.active)
self.assertFalse(self.header.show_sidebar)
self.header.show_sidebar = True
self.assertTrue(self.header._show_sidebar.props.active)
self.header._show_sidebar.props.active = False
self.assertFalse(self.header.show_sidebar)
def test_open(self):
"""Check that the Open ActionRow works as expected."""
self.assertIsInstance(self.header._open, emmental.header.open.OpenRow)
self.assertEqual(self.header._menu_box.get_row_at_index(0),
self.header._open)
"""Check that the Open button works as expected."""
self.assertIsInstance(self.header._open, emmental.header.open.Button)
signal = unittest.mock.Mock()
self.header.connect("track-requested", signal)
self.header._open.emit("track-requested", pathlib.Path("/a/b/c/1.ogg"))
signal.assert_called_with(self.header, pathlib.Path("/a/b/c/1.ogg"))
def test_listenbrainz(self):
"""Check that the ListenBrainzRow is set up correctly."""
self.assertIsInstance(self.header._listenbrainz, Adw.PasswordEntryRow)
self.assertEqual(self.header._menu_box.get_row_at_index(1),
self.header._listenbrainz)
self.assertEqual(self.header.listenbrainz_token, "")
self.assertEqual(self.header._listenbrainz.props.text, "")
self.header.listenbrainz_token = "abcde"
self.assertEqual(self.header._listenbrainz.props.text, "abcde")
with unittest.mock.patch.object(self.header._menu_button,
"popdown") as mock_popdown:
self.header._listenbrainz.props.text = "fghij"
self.header._listenbrainz.emit("apply")
self.assertEqual(self.header.listenbrainz_token, "fghij")
mock_popdown.assert_called()
self.header._listenbrainz.props.text = "abcde"
self.header._menu_button.get_popover().emit("closed")
self.assertEqual(self.header._listenbrainz.props.text, "fghij")
def test_listenbrainz_token_valid(self):
"""Test the listenbrainz-token-valid property."""
win = Gtk.Window(titlebar=self.header)
win.post_toast = unittest.mock.Mock()
self.assertTrue(self.header.listenbrainz_token_valid)
self.header.listenbrainz_token_valid = False
self.assertTrue(self.header._menu_button.has_css_class("warning"))
self.assertTrue(self.header._listenbrainz.has_css_class("warning"))
self.assertFalse(self.header.listenbrainz_token_valid)
win.post_toast.assert_called_with(
"listenbrainz: user token is invalid")
win.post_toast.reset_mock()
self.header.listenbrainz_token_valid = True
self.assertFalse(self.header._menu_button.has_css_class("warning"))
self.assertFalse(self.header._listenbrainz.has_css_class("warning"))
self.assertTrue(self.header.listenbrainz_token_valid)
win.post_toast.assert_not_called()
def test_settings(self):
"""Check that the SettingsRow is set up correctly."""
self.assertIsInstance(self.header._settings,
emmental.header.settings.Row)
self.assertEqual(self.header._menu_box.get_row_at_index(2),
self.header._settings)
"""Check that the Settings window is set up correctly."""
self.assertIsInstance(self.header._settings, Gtk.Button)
self.assertIsInstance(self.header._window,
emmental.header.settings.Window)
def test_menu_button(self):
"""Check that the menu popover button is set up properly."""
self.assertIsInstance(self.header._menu_button,
emmental.buttons.PopoverButton)
self.assertIsNotNone(self.header._menu_button.props.parent)
self.assertEqual(self.header.sql, self.sql)
self.assertEqual(self.header._settings.get_icon_name(),
"settings-symbolic")
self.assertEqual(self.header._settings.get_tooltip_text(),
"open settings editor")
self.assertEqual(self.header._menu_button.props.icon_name,
"open-menu-symbolic")
self.assertEqual(self.header._menu_button.popover_child,
self.header._menu_box)
def test_menu_popover_child(self):
"""Check that the menu popover button child was set up correctly."""
self.assertIsInstance(self.header._menu_box, Gtk.ListBox)
self.assertEqual(self.header._menu_box.get_selection_mode(),
Gtk.SelectionMode.NONE)
self.assertTrue(self.header._menu_box.has_css_class("boxed-list"))
self.assertEqual(self.header._menu_box.get_row_at_index(0),
self.header._open)
with unittest.mock.patch.object(self.header._window,
"present") as mock_present:
self.header._settings.emit("clicked")
mock_present.assert_called()
def test_volume_icons(self):
"""Check that the volume icons box is set up properly."""
@ -167,7 +95,7 @@ class TestHeader(tests.util.TestCase):
self.assertEqual(self.header._volume.volume, vol)
self.assertEqual(self.header._volume_icon.get_icon_name(),
f"audio-volume-{icon}-symbolic")
self.assertEqual(self.header._vol_button.get_tooltip_text(),
self.assertEqual(self.header._button.get_tooltip_text(),
f"volume: {i*10}%\n"
"background listening: off\nnormalizing: off")
@ -185,19 +113,19 @@ class TestHeader(tests.util.TestCase):
self.assertTrue(self.header._background.enabled)
self.assertEqual(self.header._background_icon.get_icon_name(),
"sound-wave-alt")
self.assertEqual(self.header._vol_button.get_tooltip_text(),
self.assertEqual(self.header._button.get_tooltip_text(),
"volume: 100%\nbackground listening: 50%\n"
"normalizing: off")
self.header.bg_volume = 0.75
self.assertEqual(self.header._background.volume, 0.75)
self.assertEqual(self.header._vol_button.get_tooltip_text(),
self.assertEqual(self.header._button.get_tooltip_text(),
"volume: 100%\nbackground listening: 75%\n"
"normalizing: off")
self.header._background.volume = 0.25
self.assertEqual(self.header.bg_volume, 0.25)
self.assertEqual(self.header._vol_button.get_tooltip_text(),
self.assertEqual(self.header._button.get_tooltip_text(),
"volume: 100%\nbackground listening: 25%\n"
"normalizing: off")
@ -217,7 +145,7 @@ class TestHeader(tests.util.TestCase):
self.header.rg_mode = "track"
self.assertTrue(self.header._replaygain.enabled)
self.assertEqual(self.header._replaygain.mode, "track")
self.assertEqual(self.header._vol_button.get_tooltip_text(),
self.assertEqual(self.header._button.get_tooltip_text(),
"volume: 100%\nbackground listening: off\n"
"normalizing: track mode")
@ -225,58 +153,30 @@ class TestHeader(tests.util.TestCase):
self.header._replaygain.mode = "album"
self.assertFalse(self.header.rg_enabled)
self.assertEqual(self.header.rg_mode, "album")
self.assertEqual(self.header._vol_button.get_tooltip_text(),
self.assertEqual(self.header._button.get_tooltip_text(),
"volume: 100%\nbackground listening: off\n"
"normalizing: off")
def test_volume_popover_button(self):
"""Check that the volume popover button was set up correctly."""
self.assertIsInstance(self.header._vol_button,
def test_popover_button(self):
"""Check that the menu popover button was set up correctly."""
self.assertIsInstance(self.header._button,
emmental.buttons.PopoverButton)
self.assertEqual(self.header._vol_button.popover_child,
self.header._vol_box)
self.assertEqual(self.header._button.popover_child, self.header._box)
self.assertEqual(self.header._vol_button.get_child(),
self.header._icons)
self.assertEqual(self.header._vol_button.get_margin_end(), 6)
self.assertFalse(self.header._vol_button.get_has_frame())
self.assertEqual(self.header._button.get_child(), self.header._icons)
self.assertEqual(self.header._button.get_margin_end(), 6)
self.assertFalse(self.header._button.get_has_frame())
def test_volume_popover_child(self):
"""Check that the volume popover button child was set up correctly."""
self.assertIsInstance(self.header._vol_box, Gtk.ListBox)
self.assertEqual(self.header._vol_box.get_selection_mode(),
def test_popover_child(self):
"""Check that the menu popover button child was set up correctly."""
self.assertIsInstance(self.header._box, Gtk.ListBox)
self.assertEqual(self.header._box.get_selection_mode(),
Gtk.SelectionMode.NONE)
self.assertTrue(self.header._vol_box.has_css_class("boxed-list"))
self.assertTrue(self.header._box.has_css_class("boxed-list"))
self.assertEqual(self.header._vol_box.get_row_at_index(0),
self.assertEqual(self.header._box.get_row_at_index(0),
self.header._volume)
self.assertEqual(self.header._vol_box.get_row_at_index(1),
self.assertEqual(self.header._box.get_row_at_index(1),
self.header._background)
self.assertEqual(self.header._vol_box.get_row_at_index(2),
self.assertEqual(self.header._box.get_row_at_index(2),
self.header._replaygain)
def test_accelerators(self):
"""Check that the accelerators list is set up properly."""
entries = [("open-file", self.header._open.activate, "<Control>o"),
("decrease-volume", self.header._volume.decrement,
"<Shift><Control>Down"),
("increase-volume", self.header._volume.increment,
"<Shift><Control>Up"),
("toggle-bg-mode", self.header._background.activate,
"<Shift><Control>b"),
("toggle-sidebar", self.header._show_sidebar.activate,
"<Control>bracketright"),
("edit-settings", self.header._settings.activate,
"<Shift><Control>s")]
accels = self.header.accelerators
self.assertIsInstance(accels, list)
for i, (name, func, accel) in enumerate(entries):
with self.subTest(name=name):
self.assertIsInstance(accels[i], emmental.action.ActionEntry)
self.assertEqual(accels[i].name, name)
self.assertEqual(accels[i].func, func)
self.assertListEqual(accels[i].accels, [accel])
self.assertEqual(len(accels), i + 1)

View File

@ -1,25 +0,0 @@
# Copyright 2024 (c) Anna Schumaker.
"""Tests our Listenbrainz User Token entry."""
import emmental.header.listenbrainz
import unittest
from gi.repository import Gtk
from gi.repository import Adw
class TestListenbrainzRow(unittest.TestCase):
"""Test the ListenBrainzRow."""
def setUp(self):
"""Set up common variables."""
self.row = emmental.header.listenbrainz.ListenBrainzRow()
def test_init(self):
"""Test that the ListenBrainzRow was set up properly."""
self.assertIsInstance(self.row, Adw.PasswordEntryRow)
self.assertIsInstance(self.row.prefix, Gtk.Image)
self.assertEqual(self.row.props.title, "ListenBrainz User Token")
self.assertTrue(self.row.props.show_apply_button)
self.assertEqual(self.row.prefix.props.icon_name,
"listenbrainz-logo-symbolic")

View File

@ -1,69 +1,58 @@
# Copyright 2023 (c) Anna Schumaker.
"""Tests our Open Adw.ActionRow."""
"""Tests our Open button."""
import emmental.header.open
import pathlib
import unittest
from gi.repository import Gio
from gi.repository import Gtk
from gi.repository import Adw
class TestOpenRow(unittest.TestCase):
"""Test the Open row."""
class TestButton(unittest.TestCase):
"""Test the Open button."""
def setUp(self):
"""Set up common variables."""
self.row = emmental.header.open.OpenRow()
self.button = emmental.header.open.Button()
def test_action_row(self):
"""Check that the action row was set up properly."""
self.assertIsInstance(self.row, Adw.ActionRow)
self.assertIsInstance(self.row._prefix, Gtk.Image)
self.assertEqual(self.row.props.title, "Open File")
self.assertEqual(self.row.props.subtitle, "Select a file for playback")
self.assertTrue(self.row.props.activatable)
self.assertEqual(self.row._prefix.props.icon_name,
"document-open-symbolic")
def test_button(self):
"""Check that the button was set up properly."""
self.assertIsInstance(self.button, Gtk.Button)
self.assertEqual(self.button.get_icon_name(), "document-open-symbolic")
self.assertEqual(self.button.get_tooltip_text(),
"open a file for playback")
def test_filter(self):
"""Check that the file filter is set up properly."""
self.assertIsInstance(self.row._filter, Gtk.FileFilter)
self.assertIsInstance(self.row._filters, Gio.ListStore)
self.assertIsInstance(self.button._filter, Gtk.FileFilter)
self.assertIsInstance(self.button._filters, Gio.ListStore)
self.assertEqual(self.row._filter.get_name(), "Audio Files")
self.assertEqual(self.row._filters[0], self.row._filter)
self.assertEqual(self.button._filter.get_name(), "Audio Files")
self.assertEqual(self.button._filters[0], self.button._filter)
def test_dialog(self):
"""Check that the file dialog is set up properly."""
self.assertIsInstance(self.row._dialog, Gtk.FileDialog)
self.assertEqual(self.row._dialog.get_title(), "Pick a Track")
self.assertEqual(self.row._dialog.get_filters(),
self.row._filters)
self.assertTrue(self.row._dialog.get_modal())
self.assertIsInstance(self.button._dialog, Gtk.FileDialog)
self.assertEqual(self.button._dialog.get_title(), "Pick a Track")
self.assertEqual(self.button._dialog.get_filters(),
self.button._filters)
self.assertTrue(self.button._dialog.get_modal())
def test_activate(self):
"""Test activating an OpenRow."""
listbox = Gtk.ListBox()
popover = Gtk.Popover(child=listbox)
listbox.append(self.row)
def test_clicked(self):
"""Test clicking on the button."""
with unittest.mock.patch.object(self.button._dialog,
"open") as mock_open:
self.button.emit("clicked")
mock_open.assert_called_with(None, None,
self.button._Button__async_ready)
with unittest.mock.patch.object(popover, "popdown") as mock_popdown:
with unittest.mock.patch.object(self.row._dialog,
"open") as mock_open:
self.row.emit("activated")
mock_popdown.assert_called()
mock_open.assert_called_with(None, None,
self.row._OpenRow__async_ready)
with unittest.mock.patch.object(self.row._dialog,
with unittest.mock.patch.object(self.button._dialog,
"open_finish") as mock_finish:
task = Gio.Task()
signal = unittest.mock.Mock()
mock_finish.return_value = Gio.File.new_for_path("/a/b/c/1.ogg")
self.row.connect("track-requested", signal)
self.button.connect("track-requested", signal)
self.row._OpenRow__async_ready(self.row._dialog, task)
self.button._Button__async_ready(self.button._dialog, task)
mock_finish.assert_called_with(task)
signal.assert_called_with(self.row, pathlib.Path("/a/b/c/1.ogg"))
signal.assert_called_with(self.button,
pathlib.Path("/a/b/c/1.ogg"))

View File

@ -141,39 +141,3 @@ class TestWindow(tests.util.TestCase):
emmental.header.settings.ValueRow)
self.assertEqual(columns[1].get_title(), "Value")
self.assertEqual(columns[1].get_fixed_width(), 100)
class TestSettingsRow(tests.util.TestCase):
"""Test the SettingsRow."""
def setUp(self):
"""Set up common variables."""
super().setUp()
self.row = emmental.header.settings.Row(sql=self.sql)
def test_init(self):
"""Test that the SettingsRow was set up properly."""
self.assertIsInstance(self.row, Adw.ActionRow)
self.assertIsInstance(self.row._prefix, Gtk.Image)
self.assertIsInstance(self.row._window,
emmental.header.settings.Window)
self.assertEqual(self.row.props.title, "Edit Settings")
self.assertEqual(self.row.props.subtitle,
"Open the settings editor (debug only)")
self.assertTrue(self.row.props.activatable)
self.assertEqual(self.row._prefix.props.icon_name, "settings-symbolic")
def test_activate(self):
"""Test activating a SettingsRow."""
listbox = Gtk.ListBox()
popover = Gtk.Popover(child=listbox)
listbox.append(self.row)
with unittest.mock.patch.object(popover, "popdown") as mock_popdown:
with unittest.mock.patch.object(self.row._window,
"present") as mock_present:
self.row.emit("activated")
mock_popdown.assert_called()
mock_present.assert_called()

View File

@ -57,11 +57,6 @@ class TestVolumeRow(unittest.TestCase):
self.assertAlmostEqual(self.vol._scale.get_value(), 0.95)
self.assertAlmostEqual(self.value.value, 0.95)
self.vol.decrement()
self.assertAlmostEqual(self.vol.volume, 0.90)
self.assertAlmostEqual(self.vol._scale.get_value(), 0.90)
self.assertAlmostEqual(self.value.value, 0.90)
def test_scale(self):
"""Check that the volume slider has been set up properly."""
self.assertIsInstance(self.vol._adjustment, Gtk.Adjustment)
@ -108,11 +103,6 @@ class TestVolumeRow(unittest.TestCase):
self.assertAlmostEqual(self.vol._scale.get_value(), 0.95)
self.assertAlmostEqual(self.value.value, 0.95)
self.vol.increment()
self.assertAlmostEqual(self.vol.volume, 1.0)
self.assertAlmostEqual(self.vol._scale.get_value(), 1.0)
self.assertAlmostEqual(self.value.value, 1.0)
def test_format_value(self):
"""Check that the scale value is formatted correctly."""
format_value = emmental.header.volume.format_value_func

View File

@ -1,58 +0,0 @@
# Copyright 2024 (c) Anna Schumaker.
"""Test creating a liblistenbrainz.Listen from a Track."""
import datetime
import dateutil.tz
import emmental.listenbrainz.listen
import liblistenbrainz
import pathlib
import tests.util
class TestListen(tests.util.TestCase):
"""ListenBrainz Listen test case."""
def setUp(self):
"""Set up common variables."""
super().setUp()
self.library = self.sql.libraries.create(pathlib.Path("/a/b"))
self.artists = [self.sql.artists.create("Artist 1", mbid="mbid-ar1"),
self.sql.artists.create("Artist 2"),
self.sql.artists.create("Artist 3", mbid="mbid-ar3")]
self.album = self.sql.albums.create("Test Album", "Test Artist",
release="1988-06",
mbid="mbid-release")
self.medium = self.sql.media.create(self.album, "", number=1)
self.year = self.sql.years.create(1988)
self.track = self.sql.tracks.create(self.library,
pathlib.Path("/a/b/c.ogg"),
self.medium, self.year,
title="Track 1", number=1,
artist="Track Artist")
for artist in self.artists:
artist.add_track(self.track)
self.listen = emmental.listenbrainz.listen.Listen(self.track)
def test_init(self):
"""Test initializing our Listen instance."""
self.assertIsInstance(self.listen, liblistenbrainz.Listen)
self.assertEqual(self.listen.track_name, "Track 1")
self.assertEqual(self.listen.artist_name, "Track Artist")
self.assertEqual(self.listen.release_name, "Test Album")
self.assertEqual(self.listen.release_group_mbid, "mbid-release")
self.assertEqual(self.listen.tracknumber, 1)
self.assertDictEqual(self.listen.additional_info,
{"media_player": "emmental-debug"})
self.assertListEqual(self.listen.artist_mbids,
["mbid-ar1", "mbid-ar3"])
self.assertIsNone(self.listen.listened_at)
self.assertIsNone(self.listen.listenid)
utc_now = datetime.datetime.utcnow()
local_now = utc_now.replace(tzinfo=dateutil.tz.tzutc()).astimezone()
listen = emmental.listenbrainz.listen.Listen(self.track,
listenid=1234,
listened_at=utc_now)
self.assertEqual(listen.listenid, 1234)
self.assertEqual(listen.listened_at, local_now.timestamp())

View File

@ -1,315 +0,0 @@
# Copyright 2024 (c) Anna Schumaker.
"""Tests our custom ListenBrainz GObject."""
import datetime
import emmental.listenbrainz
import io
import pathlib
import tests.util
import unittest
from gi.repository import GObject
from gi.repository import GLib
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
@unittest.mock.patch("gi.repository.GLib.source_remove")
@unittest.mock.patch("gi.repository.GLib.idle_add", return_value=42)
class TestListenBrainz(tests.util.TestCase):
"""ListenBrainz GObject test case."""
def setUp(self):
"""Set up common variables."""
super().setUp()
self.listenbrainz = emmental.listenbrainz.ListenBrainz(self.sql)
self.library = self.sql.libraries.create(pathlib.Path("/a/b"))
self.album = self.sql.albums.create("Test Album", "Test Artist",
release="1988-06",
mbid="mbid-release")
self.medium = self.sql.media.create(self.album, "", number=1)
self.year = self.sql.years.create(1988)
self.track = self.sql.tracks.create(self.library,
pathlib.Path("/a/b/c.ogg"),
self.medium, self.year,
title="Track 1", number=1,
artist="Track Artist", length=10)
@unittest.mock.patch("gi.repository.GLib.source_remove")
def tearDown(self, mock_source_remove: unittest.mock.Mock):
"""Clean up."""
self.listenbrainz.stop()
def test_init(self, mock_idle_add: unittest.mock.Mock,
mock_source_remove: unittest.mock.Mock,
mock_stdout: io.StringIO):
"""Test that the ListenBrainz GObject was set up properly."""
self.assertIsInstance(self.listenbrainz, GObject.GObject)
self.assertIsInstance(self.listenbrainz._queue,
emmental.listenbrainz.task.Queue)
self.assertIsInstance(self.listenbrainz._thread,
emmental.listenbrainz.thread.Thread)
self.assertIsNone(self.listenbrainz._idle_id)
self.assertEqual(self.listenbrainz.sql, self.sql)
self.assertIsNone(self.listenbrainz._timeout_id)
def test_early_idle_work(self, mock_idle_add: unittest.mock.Mock,
mock_source_remove: unittest.mock.Mock,
mock_stdout: io.StringIO):
"""Test __idle_work() before the database has finished loading."""
with unittest.mock.patch.object(self.listenbrainz._thread.ready,
"is_set") as mock_is_set:
self.assertEqual(self.listenbrainz._ListenBrainz__idle_work(),
GLib.SOURCE_CONTINUE)
mock_is_set.assert_not_called()
self.sql.loaded = True
self.assertEqual(self.listenbrainz._ListenBrainz__idle_work(),
GLib.SOURCE_REMOVE)
mock_is_set.assert_called()
def test_stop(self, mock_idle_add: unittest.mock.Mock,
mock_source_remove: unittest.mock.Mock,
mock_stdout: io.StringIO):
"""Test stopping the thread during shutdown."""
self.listenbrainz._idle_id = 12345
self.listenbrainz._timeout_id = 67890
self.listenbrainz.stop()
self.assertFalse(self.listenbrainz._thread.is_alive())
self.assertIsNone(self.listenbrainz._idle_id)
self.assertIsNone(self.listenbrainz._timeout_id)
mock_source_remove.assert_has_calls([unittest.mock.call(12345),
unittest.mock.call(67890)])
def test_set_user_token(self, mock_idle_add: unittest.mock.Mock,
mock_source_remove: unittest.mock.Mock,
mock_stdout: io.StringIO):
"""Test setting the user-token property."""
self.assertEqual(self.listenbrainz.user_token, "")
self.assertTrue(self.listenbrainz.valid_token)
self.assertTrue(self.listenbrainz.offline)
self.sql.loaded = True
idle_work = self.listenbrainz._ListenBrainz__idle_work
with unittest.mock.patch.object(self.listenbrainz._thread,
"set_user_token") as mock_set_token:
self.listenbrainz.user_token = "abc"
self.assertEqual(self.listenbrainz._queue._set_token,
("set-token", "abc"))
self.assertEqual(self.listenbrainz._idle_id, 42)
mock_idle_add.assert_called_with(idle_work)
self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE)
mock_set_token.assert_called_with("abc")
mock_idle_add.reset_mock()
self.listenbrainz.user_token = "abcde"
self.assertEqual(self.listenbrainz._queue._set_token,
("set-token", "abcde"))
self.assertEqual(self.listenbrainz._idle_id, 42)
mock_idle_add.assert_not_called()
self.listenbrainz._thread.set_result(op="set-token", token="abc",
valid=True)
self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE)
mock_set_token.assert_called_with("abcde")
self.listenbrainz._thread.ready.clear()
self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE)
for valid, offline in [(False, False), (False, True),
(True, False), (True, True)]:
with self.subTest(valid=valid, offline=offline):
self.listenbrainz._thread.set_result(op="set-token",
token="abcde",
valid=valid,
offline=offline)
self.assertEqual(idle_work(), GLib.SOURCE_REMOVE)
self.assertEqual(self.listenbrainz.valid_token, valid)
self.assertEqual(self.listenbrainz.offline, offline)
self.assertIsNone(self.listenbrainz._idle_id)
def test_clear_user_token(self, mock_idle_add: unittest.mock.Mock,
mock_source_remove: unittest.mock.Mock,
mock_stdout: io.StringIO):
"""Test clearing the user-token property."""
self.sql.loaded = True
idle_work = self.listenbrainz._ListenBrainz__idle_work
with unittest.mock.patch.object(self.listenbrainz._thread,
"clear_user_token") as mock_clear:
self.listenbrainz.valid_token = False
self.listenbrainz.user_token = ""
self.assertEqual(self.listenbrainz._queue._set_token,
("clear-token",))
self.assertEqual(self.listenbrainz._idle_id, 42)
mock_idle_add.assert_called_with(idle_work)
self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE)
mock_clear.assert_called()
self.listenbrainz._thread.set_result(op="clear-token", valid=True)
self.assertEqual(idle_work(), GLib.SOURCE_REMOVE)
self.assertTrue(self.listenbrainz.valid_token)
self.assertIsNone(self.listenbrainz._idle_id)
def test_submit_now_playing(self, mock_idle_add: unittest.mock.Mock,
mock_source_remove: unittest.mock.Mock,
mock_stdout: io.StringIO):
"""Test setting the now-playing property."""
self.assertIsNone(self.listenbrainz.now_playing)
self.sql.loaded = True
self.listenbrainz.user_token = "abcde"
self.listenbrainz.valid_token = True
self.listenbrainz.offline = False
self.listenbrainz._queue.pop()
self.listenbrainz._ListenBrainz__source_stop("_idle_id")
self.listenbrainz.now_playing = self.track
self.assertTupleEqual(self.listenbrainz._queue._now_playing,
("now-playing", self.track))
self.assertEqual(self.listenbrainz._idle_id, 42)
idle_work = self.listenbrainz._ListenBrainz__idle_work
with unittest.mock.patch.object(self.listenbrainz._thread,
"submit_now_playing") as mock_playing:
self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE)
mock_playing.assert_called()
self.assertIsInstance(mock_playing.call_args.args[0],
emmental.listenbrainz.listen.Listen)
for valid, offline in [(False, False), (False, True),
(True, False), (True, True)]:
with self.subTest(valid=valid, offline=offline):
self.listenbrainz._thread.set_result(op="now-playing",
valid=valid,
offline=offline)
self.assertEqual(idle_work(), GLib.SOURCE_REMOVE)
self.assertEqual(self.listenbrainz.valid_token, valid)
self.assertEqual(self.listenbrainz.offline, offline)
def test_submit_now_playing_later(self, mock_idle_add: unittest.mock.Mock,
mock_source_remove: unittest.mock.Mock,
mock_stdout: io.StringIO):
"""Test the now-playing property when ListenBrainz is disconnected."""
self.assertIsNone(self.listenbrainz.now_playing)
self.listenbrainz.now_playing = self.track
self.assertTupleEqual(self.listenbrainz._queue._now_playing,
("now-playing", self.track))
self.assertIsNone(self.listenbrainz._idle_id)
self.listenbrainz.user_token = "abcde"
self.listenbrainz.valid_token = False
self.listenbrainz._queue.pop()
self.listenbrainz._ListenBrainz__source_stop("_idle_id")
self.listenbrainz.now_playing = self.track
self.assertTupleEqual(self.listenbrainz._queue._now_playing,
("now-playing", self.track))
self.assertIsNone(self.listenbrainz._idle_id)
self.listenbrainz.valid_token = True
self.listenbrainz._queue._now_playing = "abcde"
self.listenbrainz.now_playing = None
self.assertIsNone(self.listenbrainz._queue._now_playing)
self.assertIsNone(self.listenbrainz._idle_id)
self.listenbrainz.offline = True
self.listenbrainz.now_playing = self.track
self.assertTupleEqual(self.listenbrainz._queue._now_playing,
("now-playing", self.track))
self.assertIsNone(self.listenbrainz._idle_id)
def test_submit_listens(self, mock_idle_add: unittest.mock.Mock,
mock_source_remove: unittest.mock.Mock,
mock_stdout: io.StringIO):
"""Test submitting recently listened tracks."""
ts1 = datetime.datetime.utcnow()
ts2 = datetime.datetime.utcnow()
idle_work = self.listenbrainz._ListenBrainz__idle_work
listens = [emmental.listenbrainz.listen.Listen(self.track, listenid=1,
listened_at=ts1),
emmental.listenbrainz.listen.Listen(self.track, listenid=2,
listened_at=ts2)]
self.sql.loaded = True
self.listenbrainz.user_token = "abcde"
self.listenbrainz.valid_token = True
self.listenbrainz._queue.pop()
self.listenbrainz._ListenBrainz__source_stop("_idle_id")
self.listenbrainz.offline = False
self.listenbrainz.submit_listens("ignored", "args")
self.assertIsNotNone(self.listenbrainz._idle_id)
with unittest.mock.patch.object(self.sql.tracks,
"get_n_listens") as mock_get_listens:
mock_get_listens.return_value = [(1, self.track, ts1),
(2, self.track, ts2)]
with unittest.mock.patch.object(self.listenbrainz._thread,
"submit_listens") as mock_submit:
self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE)
mock_get_listens.assert_called_with(50)
mock_submit.assert_called()
with unittest.mock.patch.object(self.sql.tracks,
"delete_listens") as mock_delete:
for valid, offline in [(False, False), (False, True),
(True, False), (True, True)]:
mock_delete.reset_mock()
with self.subTest(valid=valid, offline=offline):
self.listenbrainz._thread.set_result(op="submit-listens",
listens=listens,
valid=valid,
offline=offline)
self.assertEqual(idle_work(), GLib.SOURCE_REMOVE)
self.assertEqual(self.listenbrainz.valid_token, valid)
self.assertEqual(self.listenbrainz.offline, offline)
if valid is True and offline is False:
mock_delete.assert_called_with([1, 2])
else:
mock_delete.assert_not_called()
def test_submit_listens_later(self, mock_idle_add: unittest.mock.Mock,
mock_source_remove: unittest.mock.Mock,
mock_stdout: io.StringIO):
"""Test submitting listens when ListenBrainz is disconnected."""
self.listenbrainz.submit_listens("ignored", "args")
self.assertIsNone(self.listenbrainz._idle_id)
self.listenbrainz.user_token = "abcde"
self.listenbrainz.valid_token = False
self.listenbrainz._queue.pop()
self.listenbrainz._idle_id = None
self.listenbrainz.submit_listens("ignored", "args")
self.assertIsNone(self.listenbrainz._idle_id)
self.listenbrainz.valid_token = True
self.listenbrainz.offline = True
self.listenbrainz.submit_listens("ignored", "args")
self.assertIsNone(self.listenbrainz._idle_id)
@unittest.mock.patch("gi.repository.GLib.timeout_add_seconds")
def test_offline_recovery(self, mock_timeout_add: unittest.mock.Mock,
mock_idle_add: unittest.mock.Mock,
mock_source_remove: unittest.mock.Mock,
mock_stdout: io.StringIO):
"""Test handling an offline response."""
self.assertTrue(self.listenbrainz.offline)
check_func = self.listenbrainz._ListenBrainz__check_online
mock_timeout_add.return_value = 67890
self.listenbrainz.offline = True
self.assertEqual(self.listenbrainz._timeout_id, 67890)
mock_timeout_add.assert_called_with(300, check_func)
mock_timeout_add.reset_mock()
mock_timeout_add.return_value = 99999
self.listenbrainz.offline = True
self.assertEqual(self.listenbrainz._timeout_id, 67890)
mock_timeout_add.assert_not_called()
self.listenbrainz.offline = False
mock_source_remove.assert_called_with(67890)
mock_source_remove.reset_mock()
self.listenbrainz.offline = False
mock_source_remove.assert_not_called()

View File

@ -1,68 +0,0 @@
# Copyright 2024 (c) Anna Schumaker.
"""Tests our ListenBrainz priority queue."""
import emmental.listenbrainz.task
import liblistenbrainz
import unittest
class TestTaskQueue(unittest.TestCase):
"""Test the ListenBrainz queue."""
def setUp(self):
"""Set up common variables."""
self.queue = emmental.listenbrainz.task.Queue()
def test_init(self):
"""Test that the queue was set up properly."""
self.assertIsNotNone(self.queue)
self.assertTupleEqual(self.queue.pop(), ("submit-listens",))
def test_push_set_token(self):
"""Test calling push() with the 'set-token' operation."""
self.assertIsNone(self.queue._set_token)
self.queue.push("set-token", "abcde")
self.assertTupleEqual(self.queue._set_token, ("set-token", "abcde"))
self.queue.push("set-token", "fghij")
self.assertTupleEqual(self.queue._set_token, ("set-token", "fghij"))
self.assertTupleEqual(self.queue.pop(), ("set-token", "fghij"))
self.assertIsNone(self.queue._set_token)
self.queue.push("set-token", "abcde")
self.queue.clear("set-token")
self.assertIsNone(self.queue._set_token)
self.assertTupleEqual(self.queue.pop(), ("submit-listens",))
def test_push_clear_token(self):
"""Test calling push() with the 'clear-token' operation."""
self.queue.push("clear-token")
self.assertTupleEqual(self.queue._set_token, ("clear-token",))
self.assertTupleEqual(self.queue.pop(), ("clear-token",))
self.assertIsNone(self.queue._set_token)
self.queue.push("clear-token")
self.queue.clear("clear-token")
self.assertIsNone(self.queue._set_token)
self.assertTupleEqual(self.queue.pop(), ("submit-listens",))
def test_push_now_playing(self):
"""Test the push_now_playing() function."""
self.assertIsNone(self.queue._now_playing)
listen = liblistenbrainz.Listen("Track Name", "Artist Name")
self.queue.push("now-playing", listen)
self.assertTupleEqual(self.queue._now_playing, ("now-playing", listen))
self.queue.push("set-token", "abcde")
self.assertTupleEqual(self.queue.pop(), ("set-token", "abcde"))
self.assertTupleEqual(self.queue.pop(), ("now-playing", listen))
self.assertIsNone(self.queue._now_playing)
self.queue.push("now-playing", listen)
self.queue.clear("now-playing")
self.assertIsNone(self.queue._now_playing)
self.assertTupleEqual(self.queue.pop(), ("submit-listens",))

View File

@ -1,189 +0,0 @@
# Copyright 2024 (c) Anna Schumaker.
"""Tests our ListenBrainz client thread."""
import emmental.listenbrainz.thread
import io
import liblistenbrainz
import requests
import unittest
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
class TestThread(unittest.TestCase):
"""ListenBrainz Thread test case."""
def setUp(self):
"""Set up common variables."""
self.thread = emmental.listenbrainz.thread.Thread()
def tearDown(self):
"""Clean up."""
self.thread.stop()
def test_init(self, mock_stdout: io.StringIO):
"""Test that the ListenBrainz thread was initialized properly."""
self.assertIsInstance(self.thread, emmental.thread.Thread)
self.assertIsInstance(self.thread._client,
liblistenbrainz.client.ListenBrainz)
def test_clear_user_token(self, mock_stdout: io.StringIO):
"""Test clearing the user token."""
with unittest.mock.patch.object(self.thread._client,
"set_auth_token") as mock_set_auth:
self.thread.clear_user_token()
self.assertFalse(self.thread.ready.is_set())
self.assertEqual(self.thread._task, {"op": "clear-token"})
self.assertEqual(mock_stdout.getvalue(),
"listenbrainz: clearing user token\n")
self.thread.ready.wait()
mock_set_auth.assert_called_with(None, check_validity=False)
self.assertEqual(self.thread.get_result(),
{"op": "clear-token", "valid": True,
"offline": False})
def test_set_user_token(self, mock_stdout: io.StringIO):
"""Test setting the user auth token."""
with unittest.mock.patch.object(self.thread._client,
"set_auth_token") as mock_set_auth:
self.thread.set_user_token("abcde")
self.assertFalse(self.thread.ready.is_set())
self.assertEqual(self.thread._task,
{"op": "set-token", "token": "abcde"})
self.assertEqual(mock_stdout.getvalue(),
"listenbrainz: setting user token\n")
self.thread.ready.wait()
mock_set_auth.assert_called_with("abcde")
self.assertEqual(self.thread.get_result(),
{"op": "set-token", "token": "abcde",
"valid": True, "offline": False})
def test_set_user_token_exceptions(self, mock_stdout: io.StringIO):
"""Test exception handling when setting the user auth token."""
with unittest.mock.patch.object(self.thread._client,
"set_auth_token") as mock_set_auth:
mock_set_auth.side_effect = \
liblistenbrainz.errors.InvalidAuthTokenException()
self.thread.set_user_token("abcde")
self.thread.ready.wait()
self.assertEqual(self.thread.get_result(),
{"op": "set-token", "token": "abcde",
"valid": False, "offline": False})
self.assertEqual(mock_stdout.getvalue(),
"listenbrainz: setting user token\n" +
"listenbrainz: user token is invalid\n")
mock_set_auth.side_effect = requests.exceptions.ConnectionError()
self.thread.set_user_token("abcde")
self.thread.ready.wait()
self.assertEqual(self.thread.get_result(),
{"op": "set-token", "token": "abcde",
"valid": True, "offline": True})
self.assertRegex(mock_stdout.getvalue(), "listenbrainz: offline")
def test_submit_now_playing(self, mock_stdout: io.StringIO):
"""Test submitting the now playing track."""
listen = liblistenbrainz.Listen("Track Name", "Artist Name")
with unittest.mock.patch.object(self.thread._client,
"submit_playing_now") as mock_submit:
self.thread.submit_now_playing(listen)
self.assertFalse(self.thread.ready.is_set())
self.assertEqual(self.thread._task, {"op": "now-playing",
"listen": listen})
self.assertEqual(mock_stdout.getvalue(),
"listenbrainz: now playing 'Track Name' " +
"by 'Artist Name'\n")
self.thread.ready.wait()
mock_submit.assert_called_with(listen)
self.assertEqual(self.thread.get_result(),
{"op": "now-playing", "valid": True,
"offline": False})
def test_submit_now_playing_exceptions(self, mock_stdout: io.StringIO):
"""Test exception handling when submitting the now playing track."""
listen = liblistenbrainz.Listen("Track Name", "Artist Name")
with unittest.mock.patch.object(self.thread._client,
"submit_playing_now") as mock_submit:
mock_submit.side_effect = \
liblistenbrainz.errors.ListenBrainzAPIException(401)
self.thread.submit_now_playing(listen)
self.thread.ready.wait()
self.assertEqual(self.thread.get_result(),
{"op": "now-playing", "valid": False,
"offline": False})
self.assertEqual(mock_stdout.getvalue(),
"listenbrainz: now playing 'Track Name' " +
"by 'Artist Name'\n" +
"listenbrainz: user token is invalid\n")
mock_submit.side_effect = requests.exceptions.ConnectionError()
self.thread.submit_now_playing(listen)
self.thread.ready.wait()
self.assertEqual(self.thread.get_result(),
{"op": "now-playing", "valid": True,
"offline": True})
self.assertRegex(mock_stdout.getvalue(), "listenbrainz: offline")
def test_submit_single_listen(self, mock_stdout: io.StringIO):
"""Test submitting a single listen."""
listens = [liblistenbrainz.Listen("Track Name", "Artist Name")]
with unittest.mock.patch.object(self.thread._client,
"submit_single_listen") as mock_submit:
self.thread.submit_listens(listens)
self.assertFalse(self.thread.ready.is_set())
self.assertEqual(self.thread._task, {"op": "submit-listens",
"listens": listens})
self.assertEqual(mock_stdout.getvalue(),
"listenbrainz: submitting 1 listen\n")
self.thread.ready.wait()
mock_submit.assert_called_with(listens[0])
self.assertEqual(self.thread.get_result(),
{"op": "submit-listens", "listens": listens,
"valid": True, "offline": False})
def test_submit_multiple_listens(self, mock_stdout: io.StringIO):
"""Test submitting multiple listens."""
listens = [liblistenbrainz.Listen("Track 1", "Artist"),
liblistenbrainz.Listen("Track 2", "Artist"),
liblistenbrainz.Listen("Track 3", "Artist")]
with unittest.mock.patch.object(self.thread._client,
"submit_multiple_listens") \
as mock_submit:
self.thread.submit_listens(listens)
self.assertFalse(self.thread.ready.is_set())
self.assertEqual(self.thread._task, {"op": "submit-listens",
"listens": listens})
self.assertEqual(mock_stdout.getvalue(),
"listenbrainz: submitting 3 listens\n")
self.thread.ready.wait()
mock_submit.assert_called_with(listens)
self.assertEqual(self.thread.get_result(),
{"op": "submit-listens", "listens": listens,
"valid": True, "offline": False})
def test_submit_listens_exceptions(self, mock_stdout: io.StringIO):
"""Test exception handling when submitting listens."""
listens = [liblistenbrainz.Listen("Track Name", "Artist Name")]
with unittest.mock.patch.object(self.thread._client,
"submit_single_listen") as mock_submit:
mock_submit.side_effect = \
liblistenbrainz.errors.ListenBrainzAPIException(401)
self.thread.submit_listens(listens)
self.thread.ready.wait()
self.assertEqual(self.thread.get_result(),
{"op": "submit-listens", "listens": listens,
"valid": False, "offline": False})
self.assertEqual(mock_stdout.getvalue(),
"listenbrainz: submitting 1 listen\n" +
"listenbrainz: user token is invalid\n")
mock_submit.side_effect = requests.exceptions.ConnectionError()
self.thread.submit_listens(listens)
self.thread.ready.wait()
self.assertEqual(self.thread.get_result(),
{"op": "submit-listens", "listens": listens,
"valid": True, "offline": True})
self.assertRegex(mock_stdout.getvalue(), "listenbrainz: offline")

View File

@ -21,9 +21,7 @@ class TestArtwork(unittest.TestCase):
def test_init(self):
"""Test that the artwork widget is configured correctly."""
self.assertIsInstance(self.artwork, Gtk.Picture)
self.assertEqual(self.artwork.get_content_fit(),
Gtk.ContentFit.CONTAIN)
self.assertIsInstance(self.artwork, Gtk.Frame)
self.assertEqual(self.artwork.get_halign(), Gtk.Align.CENTER)
self.assertEqual(self.artwork.get_valign(), Gtk.Align.CENTER)
@ -33,24 +31,27 @@ class TestArtwork(unittest.TestCase):
self.assertEqual(self.artwork.get_margin_start(), 6)
self.assertEqual(self.artwork.get_margin_end(), 6)
self.assertTrue(self.artwork.has_css_class("card"))
def test_picture(self):
"""Test that the artwork picture is configured correctly."""
self.assertIsInstance(self.artwork._picture, Gtk.Picture)
self.assertEqual(self.artwork._picture.get_content_fit(),
Gtk.ContentFit.CONTAIN)
self.assertEqual(self.artwork.get_child(), self.artwork._picture)
def test_filepath(self):
"""Test that the filepath property works as expected."""
self.assertIsNone(self.artwork.filepath)
self.assertIsNotNone(self.artwork.get_paintable())
self.assertEqual(self.artwork.get_file().get_parse_name(),
self.assertIsNotNone(self.artwork._picture.get_paintable())
self.assertEqual(self.artwork._picture.get_file().get_parse_name(),
f"resource://{self.fallback}")
self.artwork.filepath = tests.util.COVER_JPG
self.assertIsNotNone(self.artwork.get_paintable())
self.assertEqual(self.artwork.get_file().get_parse_name(),
self.assertIsNotNone(self.artwork._picture.get_paintable())
self.assertEqual(self.artwork._picture.get_file().get_parse_name(),
str(tests.util.COVER_JPG))
self.assertEqual(self.artwork.filepath, tests.util.COVER_JPG)
self.artwork.filepath = None
self.assertIsNotNone(self.artwork.get_paintable())
self.assertEqual(self.artwork.get_file().get_parse_name(),
self.assertIsNotNone(self.artwork._picture.get_paintable())
self.assertEqual(self.artwork._picture.get_file().get_parse_name(),
f"resource://{self.fallback}")
def test_fullsize(self):

View File

@ -26,7 +26,6 @@ class TestAutopauseEntry(unittest.TestCase):
self.assertIsInstance(self.entry, Gtk.Entry)
self.assertTupleEqual(self.entry._timeout, (None, None))
self.assertEqual(self.entry.get_max_width_chars(), 20)
self.assertTrue(self.entry.has_css_class("card"))
def test_placeholder_text(self):
"""Test changing the placeholder text with the value."""
@ -148,26 +147,6 @@ class TestAutopauseEntry(unittest.TestCase):
self.entry.emit("activate")
self.assertEqual(self.entry.value, value, f"text=\"{text}\"")
def test_decrement(self):
"""Test the decrement() function."""
self.entry.value = 1
self.entry.decrement()
self.assertEqual(self.entry.value, 0)
self.entry.decrement()
self.assertEqual(self.entry.value, -1)
self.entry.decrement()
self.assertEqual(self.entry.value, -1)
def test_increment(self):
"""Test the increment() function."""
self.entry.value = 97
self.entry.increment()
self.assertEqual(self.entry.value, 98)
self.entry.increment()
self.assertEqual(self.entry.value, 99)
self.entry.increment()
self.assertEqual(self.entry.value, 99)
class TestAutopauseButton(unittest.TestCase):
"""Test our Autopause Popover Button."""
@ -214,17 +193,3 @@ class TestAutopauseButton(unittest.TestCase):
self.button.value = -1
self.assertEqual(self.button._count.get_text(), "")
def test_decrement(self):
"""Test the decrement() function."""
with unittest.mock.patch.object(self.button.popover_child,
"decrement") as mock_decrement:
self.button.decrement()
mock_decrement.assert_called()
def test_increment(self):
"""Test the increment() functions."""
with unittest.mock.patch.object(self.button.popover_child,
"increment") as mock_increment:
self.button.increment()
mock_increment.assert_called()

View File

@ -13,7 +13,7 @@ class TestButtons(unittest.TestCase):
"""Test that the pill button is configured correctly."""
button = emmental.nowplaying.controls.PillButton()
self.assertIsInstance(button, emmental.buttons.Button)
self.assertTrue(button.large_icon)
self.assertEqual(button.icon_size, Gtk.IconSize.LARGE)
self.assertTrue(button.has_css_class("pill"))
@ -47,14 +47,10 @@ class TestControls(unittest.TestCase):
self.assertEqual(self.controls.get_margin_end(),
emmental.nowplaying.controls.MARGIN)
self.assertFalse(self.controls.editing)
def test_previous_button(self):
"""Test the previous button."""
self.assertIsInstance(self.controls._prev,
emmental.nowplaying.controls.PillButton)
self.assertEqual(self.controls._prev.get_tooltip_text(),
"previous track")
self.assertEqual(self.controls._prev.icon_name, "media-skip-backward")
self.assertEqual(self.controls.get_first_child(), self.controls._prev)
@ -62,24 +58,10 @@ class TestControls(unittest.TestCase):
self.controls._prev.emit("clicked")
self.clicked.assert_called_with(self.controls._prev)
def test_activate_previous(self):
"""Test can-activate-prev and the activate_previous() function."""
self.assertFalse(self.controls.can_activate_prev)
self.controls.have_previous = True
self.assertTrue(self.controls.can_activate_prev)
self.controls.editing = True
self.assertFalse(self.controls.can_activate_prev)
activate = unittest.mock.Mock()
self.controls._prev.connect("activate", activate)
self.controls.activate_previous()
activate.assert_called()
def test_play_button(self):
"""Test the play button."""
self.assertIsInstance(self.controls._play,
emmental.nowplaying.controls.PillButton)
self.assertEqual(self.controls._play.get_tooltip_text(), "play")
self.assertEqual(self.controls._play.icon_name, "play-large")
self.assertEqual(self.controls._prev.get_next_sibling(),
self.controls._play)
@ -99,13 +81,12 @@ class TestControls(unittest.TestCase):
"""Test the pause button."""
self.assertIsInstance(self.controls._pause,
emmental.buttons.SplitButton)
self.assertEqual(self.controls._pause.icon_name, "pause-large")
self.assertEqual(self.controls._pause.icon_size,
Gtk.IconSize.LARGE)
self.assertEqual(self.controls._play.get_next_sibling(),
self.controls._pause)
self.assertEqual(self.controls._pause.get_tooltip_text(), "pause")
self.assertEqual(self.controls._pause.icon_name, "pause-large")
self.assertTrue(self.controls._pause.large_icon)
self.assertFalse(self.controls._pause.get_visible())
self.controls.playing = True
self.assertTrue(self.controls._pause.get_visible())
@ -116,25 +97,6 @@ class TestControls(unittest.TestCase):
self.controls._pause.emit("clicked")
self.clicked.assert_called_with(self.controls._pause)
def test_activate_play_pause(self):
"""Test can-activate-play-pause and the activate_play_pause() func."""
self.assertFalse(self.controls.can_activate_play_pause)
self.controls.have_track = True
self.assertTrue(self.controls.can_activate_play_pause)
self.controls.editing = True
self.assertFalse(self.controls.can_activate_play_pause)
play = unittest.mock.Mock()
self.controls._play.connect("activate", play)
self.controls.activate_play_pause()
play.assert_called()
self.controls.playing = True
pause = unittest.mock.Mock()
self.controls._pause.connect("activate-primary", pause)
self.controls.activate_play_pause()
pause.assert_called()
def test_autopause_button(self):
"""Test the autopause button."""
self.assertIsInstance(self.controls._autopause,
@ -154,40 +116,12 @@ class TestControls(unittest.TestCase):
"""Test the next button."""
self.assertIsInstance(self.controls._next,
emmental.nowplaying.controls.PillButton)
self.assertEqual(self.controls._next.get_tooltip_text(), "next track")
self.assertEqual(self.controls._next.icon_name, "media-skip-forward")
self.controls._next.connect("clicked", self.clicked)
self.controls._next.emit("clicked")
self.clicked.assert_called_with(self.controls._next)
def test_activate_next(self):
"""Test can-activate-next and the activate_next() function."""
self.assertFalse(self.controls.can_activate_next)
self.controls.have_next = True
self.assertTrue(self.controls.can_activate_next)
self.controls.editing = True
self.assertFalse(self.controls.can_activate_next)
activate = unittest.mock.Mock()
self.controls._next.connect("activate", activate)
self.controls.activate_next()
activate.assert_called()
def test_decrease_autopause(self):
"""Test the decrease_autopause() function."""
with unittest.mock.patch.object(self.controls._autopause,
"decrement") as mock_decrement:
self.controls.decrease_autopause()
mock_decrement.assert_called()
def test_increase_autopause(self):
"""Test the increase_autopause() function."""
with unittest.mock.patch.object(self.controls._autopause,
"increment") as mock_increment:
self.controls.increase_autopause()
mock_increment.assert_called()
def test_have_properties(self):
"""Test the have_{next, previous, track} properties."""
self.assertFalse(self.controls.have_next)

View File

@ -89,15 +89,11 @@ class TestNowPlaying(unittest.TestCase):
self.card._favorite)
self.assertEqual(self.card._favorite.active_icon_name, "heart-filled")
self.assertEqual(self.card._favorite.active_tooltip_text,
"remove from 'Favorite Tracks'")
self.assertEqual(self.card._favorite.inactive_icon_name,
"heart-outline-thick-symbolic")
self.assertEqual(self.card._favorite.inactive_tooltip_text,
"add to 'Favorite Tracks'")
self.assertEqual(self.card._favorite.icon_size, Gtk.IconSize.LARGE)
self.assertEqual(self.card._favorite.get_valign(), Gtk.Align.CENTER)
self.assertFalse(self.card._favorite.get_has_frame())
self.assertTrue(self.card._favorite.large_icon)
self.assertFalse(self.card._favorite.get_sensitive())
self.card.have_db_track = True
@ -115,12 +111,10 @@ class TestNowPlaying(unittest.TestCase):
self.assertEqual(self.card._favorite.get_next_sibling(),
self.card._jump)
self.assertEqual(self.card._jump.icon_name, "arrow4-down-symbolic")
self.assertEqual(self.card._jump.get_tooltip_text(),
"scroll to current track")
self.assertEqual(self.card._jump.icon_name, "go-jump")
self.assertEqual(self.card._jump.icon_size, Gtk.IconSize.LARGE)
self.assertEqual(self.card._jump.get_valign(), Gtk.Align.CENTER)
self.assertFalse(self.card._jump.get_has_frame())
self.assertTrue(self.card._jump.large_icon)
self.assertFalse(self.card._jump.get_sensitive())
self.card.have_db_track = True
@ -147,14 +141,6 @@ class TestNowPlaying(unittest.TestCase):
self.card._seeker.emit("change-value", Gtk.ScrollType.JUMP, 10)
handler.assert_called_with(self.card, 10)
def test_editing(self):
"""Test the 'editing' property."""
self.assertFalse(self.card.editing)
self.card.editing = True
self.assertTrue(self.card._controls.editing)
self.card.editing = False
self.assertFalse(self.card._controls.editing)
def test_playing(self):
"""Test the 'playing' property."""
self.assertFalse(self.card.playing)
@ -164,7 +150,7 @@ class TestNowPlaying(unittest.TestCase):
self.assertFalse(self.card._controls.playing)
def test_have_properties(self):
"""Test the 'have-{next, previous, track}' properties."""
"""Test the 'have-{next, previous, track} properties."""
for property in ["have-next", "have-previous", "have-track"]:
with self.subTest(property=property):
self.assertFalse(self.card.get_property(property))
@ -192,39 +178,3 @@ class TestNowPlaying(unittest.TestCase):
self.assertEqual(self.card.position, 0)
self.card.position = 0.5
self.assertEqual(self.card._seeker.position, 0.5)
def test_accelerators(self):
"""Check that the accelerators list is set up properly."""
entries = [("toggle-favorite", self.card._favorite.activate,
["<Control>f"], self.card, "have-db-track"),
("goto-current-track", self.card._jump.activate,
["<Control>g"], self.card, "have-db-track"),
("next-track", self.card._controls.activate_next,
["Return"], self.card._controls, "can-activate-next"),
("previous-track", self.card._controls.activate_previous,
["BackSpace"], self.card._controls, "can-activate-prev"),
("play-pause", self.card._controls.activate_play_pause,
["space"], self.card._controls, "can-activate-play-pause"),
("inc-autopause", self.card._controls.increase_autopause,
["<Control>plus", "<Control>KP_Add"],
self.card, "playing"),
("dec-autopause", self.card._controls.decrease_autopause,
["<Control>minus", "<Control>KP_Subtract"],
self.card, "playing")]
accels = self.card.accelerators
self.assertIsInstance(accels, list)
for i, (name, func, accel, gobject, prop) in enumerate(entries):
with self.subTest(action=name):
self.assertIsInstance(accels[i], emmental.action.ActionEntry)
self.assertEqual(accels[i].name, name)
self.assertEqual(accels[i].func, func)
self.assertListEqual(accels[i].accels, accel)
enabled = gobject.get_property(prop)
self.assertEqual(accels[i].enabled, enabled)
gobject.set_property(prop, not enabled)
self.assertEqual(accels[i].enabled, not enabled)
self.assertEqual(len(accels), i + 1)

View File

@ -33,13 +33,10 @@ class TestArtist(tests.util.TestCase):
emmental.buttons.ImageToggle)
self.assertEqual(self.artists.extra_widget.active_icon_name,
"music-artist")
self.assertEqual(self.artists.extra_widget.active_tooltip_text,
"show album artists")
self.assertEqual(self.artists.extra_widget.inactive_icon_name,
"music-artist2")
self.assertEqual(self.artists.extra_widget.inactive_tooltip_text,
"show all artists")
self.assertFalse(self.artists.extra_widget.large_icon)
self.assertEqual(self.artists.extra_widget.icon_size,
Gtk.IconSize.NORMAL)
self.assertFalse(self.artists.extra_widget.get_has_frame())
def test_subtitle(self):

View File

@ -165,9 +165,3 @@ class TestHeader(unittest.TestCase):
self.assertTrue(self.header.active)
flags = self.header._arrow.get_state_flags()
self.assertTrue(flags & Gtk.StateFlags.CHECKED)
def test_activate(self):
"""Test the activate() function."""
self.assertFalse(self.header.active)
self.header.activate()
self.assertTrue(self.header.active)

View File

@ -69,14 +69,18 @@ class TestIcon(unittest.TestCase):
"""Test the filepath property."""
self.assertIsNone(self.icon.filepath)
self.icon.filepath = tests.util.COVER_JPG
texture = self.icon._icon.get_custom_image()
self.assertIsInstance(texture, Gdk.Texture)
self.assertDictEqual(emmental.texture.CACHE,
{tests.util.COVER_JPG: texture})
with unittest.mock.patch("gi.repository.Gdk.Texture.new_from_filename",
wraps=Gdk.Texture.new_from_filename) \
as mock_new:
self.icon.filepath = tests.util.COVER_JPG
mock_new.assert_called_with(str(tests.util.COVER_JPG))
self.assertIsInstance(self.icon._icon.get_custom_image(),
Gdk.Texture)
self.icon.filepath = None
self.assertIsNone(self.icon._icon.get_custom_image())
mock_new.reset_mock()
self.icon.filepath = None
self.assertIsNone(self.icon._icon.get_custom_image())
mock_new.assert_not_called()
class TestSettable(unittest.TestCase):
@ -119,15 +123,11 @@ class TestSettable(unittest.TestCase):
task = Gio.Task()
cover_path = str(tests.util.COVER_JPG)
mock_finish.return_value = Gio.File.new_for_path(cover_path)
emmental.texture.CACHE[tests.util.COVER_JPG] = "abcde"
self.icon._Settable__async_ready(self.icon._dialog, task)
mock_finish.assert_called_with(task)
self.assertEqual(self.icon.filepath, tests.util.COVER_JPG)
texture = emmental.texture.CACHE[tests.util.COVER_JPG]
self.assertIsInstance(texture, Gdk.Texture)
def test_clearing(self):
"""Test clearing the icon by canceling the FileDialog."""
mock_set_initial_file = unittest.mock.Mock()

View File

@ -25,7 +25,7 @@ class TestLibraries(tests.util.TestCase):
emmental.sidebar.library.LibraryRow)
self.assertEqual(self.libraries.table, self.sql.libraries)
self.assertEqual(self.libraries.icon_name, "library-music-symbolic")
self.assertEqual(self.libraries.icon_name, "library-music")
self.assertEqual(self.libraries.title, "Library Paths")
def test_extra_widget(self):
@ -33,8 +33,6 @@ class TestLibraries(tests.util.TestCase):
self.assertIsInstance(self.libraries.extra_widget, Gtk.Button)
self.assertEqual(self.libraries.extra_widget.get_icon_name(),
"folder-new")
self.assertEqual(self.libraries.extra_widget.get_tooltip_text(),
"add new library path")
self.assertFalse(self.libraries.extra_widget.get_has_frame())
mock_set_initial_file = unittest.mock.Mock()

View File

@ -52,8 +52,6 @@ class TestPlaylists(tests.util.TestCase):
emmental.buttons.PopoverButton)
self.assertEqual(self.playlists.extra_widget.get_icon_name(),
"document-new")
self.assertEqual(self.playlists.extra_widget.get_tooltip_text(),
"add new playlist")
self.assertEqual(self.playlists.extra_widget.popover_child,
self.playlists._entry)
self.assertFalse(self.playlists.extra_widget.get_has_frame())
@ -67,32 +65,26 @@ class TestPlaylists(tests.util.TestCase):
Gtk.EntryIconPosition.PRIMARY), "list-add")
self.assertIsNone(self.playlists._entry.get_icon_name(
Gtk.EntryIconPosition.SECONDARY))
self.assertTrue(self.playlists._entry.has_css_class("card"))
with unittest.mock.patch.object(self.playlists.extra_widget,
"popdown") as mock_popdown:
with unittest.mock.patch.object(self.sql, "commit") as mock_commit:
self.playlists._entry.emit("activate")
self.assertEqual(len(self.sql.playlists), 0)
mock_popdown.assert_not_called()
mock_commit.assert_not_called()
self.playlists._entry.emit("activate")
self.assertEqual(len(self.sql.playlists), 0)
mock_popdown.assert_not_called()
self.playlists._entry.set_text("Test 1")
self.playlists._entry.emit("activate")
self.assertEqual(len(self.sql.playlists), 1)
self.assertEqual(self.sql.playlists.get_item(0).name, "Test 1")
mock_popdown.assert_called()
mock_commit.assert_called()
self.playlists._entry.set_text("Test 1")
self.playlists._entry.emit("activate")
self.assertEqual(len(self.sql.playlists), 1)
self.assertEqual(self.sql.playlists.get_item(0).name, "Test 1")
mock_popdown.assert_called()
mock_popdown.reset_mock()
mock_commit.reset_mock()
self.playlists._entry.set_text("Test 2")
self.playlists._entry.emit("icon-release",
Gtk.EntryIconPosition.PRIMARY)
self.assertEqual(len(self.sql.playlists), 2)
self.assertEqual(self.sql.playlists.get_item(1).name, "Test 2")
mock_popdown.assert_called()
mock_commit.assert_called()
mock_popdown.reset_mock()
self.playlists._entry.set_text("Test 2")
self.playlists._entry.emit("icon-release",
Gtk.EntryIconPosition.PRIMARY)
self.assertEqual(len(self.sql.playlists), 2)
self.assertEqual(self.sql.playlists.get_item(1).name, "Test 2")
mock_popdown.assert_called()
self.playlists._entry.set_text("Test 3")
self.assertEqual(self.playlists._entry.get_icon_name(

View File

@ -147,8 +147,6 @@ class TestPlaylistRow(unittest.TestCase):
self.assertEqual(self.row._title.get_next_sibling(), self.row._delete)
self.assertEqual(self.row._delete.get_icon_name(), "big-x-symbolic")
self.assertEqual(self.row._delete.get_tooltip_text(),
"delete playlist")
self.assertEqual(self.row._delete.get_valign(), Gtk.Align.CENTER)
self.assertFalse(self.row._delete.get_has_frame())
self.assertTrue(self.row._delete.has_css_class("emmental-delete"))
@ -211,20 +209,14 @@ class TestLibraryRow(unittest.TestCase):
self.assertTrue(self.row.enabled)
self.assertTrue(self.row._switch.get_active())
self.assertTrue(self.row._title.get_sensitive())
self.assertEqual(self.row._switch.get_tooltip_text(),
"disable library path")
self.row.enabled = False
self.assertFalse(self.row._switch.get_active())
self.assertFalse(self.row._title.get_sensitive())
self.assertEqual(self.row._switch.get_tooltip_text(),
"enable library path")
self.row._switch.set_active(True)
self.assertTrue(self.row.enabled)
self.assertTrue(self.row._title.get_sensitive())
self.assertEqual(self.row._switch.get_tooltip_text(),
"disable library path")
def test_progress(self):
"""Test the progress bar widget and property."""
@ -255,8 +247,6 @@ class TestLibraryRow(unittest.TestCase):
"""Test the scan button."""
self.assertIsInstance(self.row._scan, Gtk.Button)
self.assertEqual(self.row._scan.get_icon_name(), "update")
self.assertEqual(self.row._scan.get_tooltip_text(),
"update library path")
self.assertEqual(self.row._scan.get_valign(), Gtk.Align.CENTER)
self.assertFalse(self.row._scan.get_has_frame())
self.assertEqual(self.row._title.get_next_sibling(), self.row._scan)
@ -272,7 +262,6 @@ class TestLibraryRow(unittest.TestCase):
"""Test the stop button."""
self.assertIsInstance(self.row._stop, Gtk.Button)
self.assertEqual(self.row._stop.get_icon_name(), "stop-sign-large")
self.assertEqual(self.row._stop.get_tooltip_text(), "cancel update")
self.assertEqual(self.row._stop.get_valign(), Gtk.Align.CENTER)
self.assertFalse(self.row._stop.get_has_frame())
self.assertTrue(self.row._stop.has_css_class("emmental-stop"))
@ -289,8 +278,6 @@ class TestLibraryRow(unittest.TestCase):
"""Test the delete button."""
self.assertIsInstance(self.row._delete, Gtk.Button)
self.assertEqual(self.row._delete.get_icon_name(), "big-x-symbolic")
self.assertEqual(self.row._delete.get_tooltip_text(),
"delete library path")
self.assertEqual(self.row._delete.get_valign(), Gtk.Align.CENTER)
self.assertFalse(self.row._delete.get_has_frame())
self.assertTrue(self.row._delete.has_css_class("emmental-delete"))

View File

@ -4,6 +4,8 @@ import emmental.db
import emmental.sidebar.section
import tests.util
import unittest.mock
from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gtk
@ -104,12 +106,18 @@ class TestSection(tests.util.TestCase):
def test_select_playlist(self):
"""Test selecting a specific playlist."""
self.section.do_get_subtitle = unittest.mock.Mock(return_value="")
playlist_selected = unittest.mock.Mock()
self.section.connect("playlist-selected", playlist_selected)
playlist = self.table.create("Test Playlist")
playlist_selected.assert_not_called()
with unittest.mock.patch.object(self.section._listview,
"scroll_to") as mock_scroll_to:
"activate_action") as mock_action:
self.section.select_playlist(playlist)
mock_scroll_to.assert_called_with(0, Gtk.ListScrollFlags.SELECT)
playlist_selected.assert_called_with(self.section, playlist)
mock_action.assert_called_with("list.scroll-to-item",
GLib.Variant.new_uint32(0))
def test_playlist_selected(self):
"""Test selecting a playlist in the list."""
@ -142,7 +150,7 @@ class TestGroup(tests.util.TestCase):
def setUp(self):
"""Set up common variables."""
super().setUp()
self.view = emmental.sidebar.section.View(self.sql)
self.group = emmental.sidebar.section.Group(self.sql)
self.row_type = emmental.sidebar.row.TreeRow
self.section1 = emmental.sidebar.section.Section(self.sql.playlists,
self.row_type)
@ -153,40 +161,35 @@ class TestGroup(tests.util.TestCase):
def test_init(self):
"""Test that the Group is set up properly."""
self.assertIsInstance(self.view, Gtk.Box)
self.assertListEqual(self.view._sections, [])
self.assertEqual(self.view.sql, self.sql)
self.assertEqual(self.view.get_orientation(),
Gtk.Orientation.VERTICAL)
self.assertIsInstance(self.group, GObject.GObject)
self.assertListEqual(self.group._sections, [])
self.assertEqual(self.group.sql, self.sql)
def test_add(self):
"""Test adding sections to the Group."""
self.view.add(self.section1)
self.assertListEqual(self.view._sections, [self.section1])
self.assertEqual(self.view.get_first_child(), self.section1)
self.view.add(self.section2)
self.assertListEqual(self.view._sections,
self.group.add(self.section1)
self.assertListEqual(self.group._sections, [self.section1])
self.group.add(self.section2)
self.assertListEqual(self.group._sections,
[self.section1, self.section2])
self.assertEqual(self.section1.get_next_sibling(), self.section2)
def test_current(self):
"""Test the current section property."""
self.view.add(self.section1)
self.view.add(self.section2)
self.assertIsNone(self.view.current)
self.group.add(self.section1)
self.group.add(self.section2)
self.assertIsNone(self.group.current)
self.section1.active = True
self.assertEqual(self.view.current, self.section1)
self.assertEqual(self.group.current, self.section1)
self.section2.active = True
self.assertEqual(self.view.current, self.section2)
self.assertEqual(self.group.current, self.section2)
self.assertFalse(self.section1.active)
def test_animation(self):
"""Test setting the section animation style."""
self.view.add(self.section1)
self.view.add(self.section2)
self.group.add(self.section1)
self.group.add(self.section2)
self.section1.active = True
self.assertEqual(self.section1.animation,
@ -198,8 +201,8 @@ class TestGroup(tests.util.TestCase):
def test_playlist_activated(self):
"""Test responding to the section playlist-activated signal."""
self.view.add(self.section1)
self.view.add(self.section2)
self.group.add(self.section1)
self.group.add(self.section2)
self.assertIsNone(self.sql.active_playlist)
playlist = self.sql.playlists.create("Test Playlist")
@ -212,16 +215,16 @@ class TestGroup(tests.util.TestCase):
def test_selections(self):
"""Test the selected section & playlist properties."""
self.view.add(self.section1)
self.view.add(self.section2)
self.group.add(self.section1)
self.group.add(self.section2)
self.assertIsNone(self.view.selected_section)
self.assertIsNone(self.view.selected_playlist)
self.assertIsNone(self.group.selected_section)
self.assertIsNone(self.group.selected_playlist)
genre = self.sql.genres.create("Test Genre")
self.section2.emit("playlist-selected", genre)
self.assertEqual(self.view.selected_section, self.section2)
self.assertEqual(self.view.selected_playlist, genre)
self.assertEqual(self.group.selected_section, self.section2)
self.assertEqual(self.group.selected_playlist, genre)
self.section2.active = True
treerow = self.section2._selection.get_selected_item()

Some files were not shown because too many files have changed in this diff Show More