Compare commits
99 Commits
emmental-3
...
main
Author | SHA1 | Date | |
---|---|---|---|
d3fdf82a93 | |||
28ee637b0a | |||
270be37848 | |||
a0f240a2ad | |||
6dfa841cbd | |||
745301997e | |||
37c74ed5fb | |||
c3818a2b18 | |||
19c47be056 | |||
dbc60e1c5f | |||
6779535cf1 | |||
2ae5fd0969 | |||
b1490fd447 | |||
4c5d3c78c0 | |||
84a832389f | |||
924f65fddd | |||
14c153733d | |||
efe2611422 | |||
c49a23b046 | |||
a944af7f3e | |||
c5867badae | |||
e85bdcc7f4 | |||
c7dca6164e | |||
eada937b7a | |||
d373c33283 | |||
1db187dba5 | |||
0d100ec752 | |||
c4e827bc5a | |||
a4e0968ef4 | |||
58a1df1d1d | |||
3c25dc2c7f | |||
c37ae94a5d | |||
e6a219017d | |||
3c15515faf | |||
6c6ebf3676 | |||
ad8fd70f9a | |||
8c316d0126 | |||
3f153e1423 | |||
a08273535c | |||
ae1c611959 | |||
e73b6c09e7 | |||
b02fd609f7 | |||
3241830c8e | |||
97659f212d | |||
d22a9b23a1 | |||
29693dcf84 | |||
bee48deac6 | |||
5e096fa704 | |||
6ebf29a632 | |||
a4f30d87e6 | |||
51b290e1f0 | |||
fa203a72dd | |||
3b8fb8531e | |||
3e73ce0650 | |||
17e4d85f1b | |||
24675bf202 | |||
072264a77c | |||
e7526f595f | |||
7d2ec00da7 | |||
70d7f5fa70 | |||
2504f4b91d | |||
7358183fef | |||
c195e68216 | |||
1397e6e9e3 | |||
717fdf39cd | |||
9cf980d967 | |||
87d8a2ae3a | |||
ddfd37130b | |||
5011db344e | |||
9f240bbc8b | |||
f6481f0182 | |||
3d6350d7bd | |||
eb6b4d8ef4 | |||
f7349cd864 | |||
30bcd30328 | |||
ef99951f74 | |||
0fd391a9fd | |||
bc92e72265 | |||
8dae0ed7bd | |||
1707f87e45 | |||
7e99fd1ba0 | |||
a8e7078308 | |||
1d0813f217 | |||
725619faf5 | |||
6607e5b0ad | |||
73019d8eb4 | |||
6032e549a5 | |||
ba4907ec34 | |||
06771ecab6 | |||
17b2a82e20 | |||
c0c516fb70 | |||
3cddde0986 | |||
4f15bde850 | |||
5ee86a9b5e | |||
85c18fb5fe | |||
67b508384c | |||
929beb2a97 | |||
f400366210 | |||
0c66b13209 |
2
.gitmodules
vendored
2
.gitmodules
vendored
|
@ -3,4 +3,4 @@
|
||||||
url = ssh://aur@aur.archlinux.org/emmental.git
|
url = ssh://aur@aur.archlinux.org/emmental.git
|
||||||
[submodule "mpris-spec"]
|
[submodule "mpris-spec"]
|
||||||
path = mpris-spec
|
path = mpris-spec
|
||||||
url = https://github.com/freedesktop/mpris-spec.git
|
url = https://gitlab.freedesktop.org/mpris/mpris-spec.git
|
||||||
|
|
|
@ -20,6 +20,7 @@ other playlists run out of tracks.
|
||||||
* Python3
|
* Python3
|
||||||
* dateutil
|
* dateutil
|
||||||
* gobject
|
* gobject
|
||||||
|
* liblistenbrainz
|
||||||
* musicbrainzngs
|
* musicbrainzngs
|
||||||
* mutagen
|
* mutagen
|
||||||
* pyxdg
|
* pyxdg
|
||||||
|
|
2
aur
2
aur
|
@ -1 +1 @@
|
||||||
Subproject commit 9cc0fb407be0bba02996be944caee9553d983879
|
Subproject commit 543220f35990f5b5a69eac5a6c06e69eae6ffb82
|
|
@ -7,6 +7,7 @@ from . import action
|
||||||
from . import audio
|
from . import audio
|
||||||
from . import db
|
from . import db
|
||||||
from . import header
|
from . import header
|
||||||
|
from . import listenbrainz
|
||||||
from . import mpris2
|
from . import mpris2
|
||||||
from . import nowplaying
|
from . import nowplaying
|
||||||
from . import options
|
from . import options
|
||||||
|
@ -20,8 +21,8 @@ from gi.repository import Gio
|
||||||
from gi.repository import Adw
|
from gi.repository import Adw
|
||||||
|
|
||||||
MAJOR_VERSION = 3
|
MAJOR_VERSION = 3
|
||||||
MINOR_VERSION = 0
|
MINOR_VERSION = 2
|
||||||
MICRO_VERSION = 3
|
MICRO_VERSION = 1
|
||||||
|
|
||||||
VERSION_NUMBER = f"{MAJOR_VERSION}.{MINOR_VERSION}.{MICRO_VERSION}"
|
VERSION_NUMBER = f"{MAJOR_VERSION}.{MINOR_VERSION}.{MICRO_VERSION}"
|
||||||
VERSION_STRING = f"Emmental {VERSION_NUMBER}{gsetup.DEBUG_STR}"
|
VERSION_STRING = f"Emmental {VERSION_NUMBER}{gsetup.DEBUG_STR}"
|
||||||
|
@ -34,6 +35,7 @@ class Application(Adw.Application):
|
||||||
factory = GObject.Property(type=playlist.Factory)
|
factory = GObject.Property(type=playlist.Factory)
|
||||||
mpris = GObject.Property(type=mpris2.Connection)
|
mpris = GObject.Property(type=mpris2.Connection)
|
||||||
player = GObject.Property(type=audio.Player)
|
player = GObject.Property(type=audio.Player)
|
||||||
|
lbrainz = GObject.Property(type=listenbrainz.ListenBrainz)
|
||||||
win = GObject.Property(type=window.Window)
|
win = GObject.Property(type=window.Window)
|
||||||
|
|
||||||
autopause = GObject.Property(type=int, default=-1, minimum=-1, maximum=99)
|
autopause = GObject.Property(type=int, default=-1, minimum=-1, maximum=99)
|
||||||
|
@ -135,11 +137,14 @@ class Application(Adw.Application):
|
||||||
hdr = header.Header(sql=self.db, title=VERSION_STRING)
|
hdr = header.Header(sql=self.db, title=VERSION_STRING)
|
||||||
for prop in ["bg-enabled", "bg-volume", "volume"]:
|
for prop in ["bg-enabled", "bg-volume", "volume"]:
|
||||||
hdr.bind_property(prop, self.player, prop)
|
hdr.bind_property(prop, self.player, prop)
|
||||||
|
hdr.bind_property("listenbrainz-token", self.lbrainz, "user-token")
|
||||||
for (setting, property) in [("audio.volume", "volume"),
|
for (setting, property) in [("audio.volume", "volume"),
|
||||||
("audio.background.enabled", "bg-enabled"),
|
("audio.background.enabled", "bg-enabled"),
|
||||||
("audio.background.volume", "bg-volume"),
|
("audio.background.volume", "bg-volume"),
|
||||||
("audio.replaygain.enabled", "rg-enabled"),
|
("audio.replaygain.enabled", "rg-enabled"),
|
||||||
("audio.replaygain.mode", "rg-mode")]:
|
("audio.replaygain.mode", "rg-mode"),
|
||||||
|
("listenbrainz.token",
|
||||||
|
"listenbrainz_token")]:
|
||||||
self.db.settings.bind_setting(setting, hdr, property)
|
self.db.settings.bind_setting(setting, hdr, property)
|
||||||
|
|
||||||
self.__add_accelerators(hdr.accelerators)
|
self.__add_accelerators(hdr.accelerators)
|
||||||
|
@ -182,6 +187,7 @@ class Application(Adw.Application):
|
||||||
side_bar = sidebar.Card(sql=self.db)
|
side_bar = sidebar.Card(sql=self.db)
|
||||||
self.db.settings.bind_setting("sidebar.artists.show-all", side_bar,
|
self.db.settings.bind_setting("sidebar.artists.show-all", side_bar,
|
||||||
"show-all-artists")
|
"show-all-artists")
|
||||||
|
self.__add_accelerators(side_bar.accelerators)
|
||||||
return side_bar
|
return side_bar
|
||||||
|
|
||||||
def build_tracklist(self) -> tracklist.Card:
|
def build_tracklist(self) -> tracklist.Card:
|
||||||
|
@ -194,6 +200,8 @@ class Application(Adw.Application):
|
||||||
self.db.settings.bind_setting(f"tracklist.{name}.visible",
|
self.db.settings.bind_setting(f"tracklist.{name}.visible",
|
||||||
column, "visible")
|
column, "visible")
|
||||||
self.factory.bind_property("visible-playlist", track_list, "playlist")
|
self.factory.bind_property("visible-playlist", track_list, "playlist")
|
||||||
|
|
||||||
|
self.__add_accelerators(track_list.accelerators)
|
||||||
return track_list
|
return track_list
|
||||||
|
|
||||||
def build_window(self) -> window.Window:
|
def build_window(self) -> window.Window:
|
||||||
|
@ -203,12 +211,14 @@ class Application(Adw.Application):
|
||||||
now_playing=self.build_now_playing(),
|
now_playing=self.build_now_playing(),
|
||||||
sidebar=self.build_sidebar(),
|
sidebar=self.build_sidebar(),
|
||||||
tracklist=self.build_tracklist())
|
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")
|
win.bind_property("user-editing", win.now_playing, "editing")
|
||||||
|
|
||||||
for (setting, property) in [("window.width", "default-width"),
|
for (setting, property) in [("window.width", "default-width"),
|
||||||
("window.height", "default-height"),
|
("window.height", "default-height"),
|
||||||
("now-playing.size", "now-playing-size"),
|
("now-playing.size", "now-playing-size"),
|
||||||
("sidebar.size", "sidebar-size")]:
|
("sidebar.show", "show-sidebar")]:
|
||||||
self.db.settings.bind_setting(setting, win, property)
|
self.db.settings.bind_setting(setting, win, property)
|
||||||
|
|
||||||
self.__add_accelerators(win.accelerators)
|
self.__add_accelerators(win.accelerators)
|
||||||
|
@ -248,6 +258,15 @@ class Application(Adw.Application):
|
||||||
self.mpris.player.connect("SetPosition", self.__set_position)
|
self.mpris.player.connect("SetPosition", self.__set_position)
|
||||||
self.mpris.player.connect("Stop", self.player.stop)
|
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:
|
def connect_playlist_factory(self) -> None:
|
||||||
"""Connect the playlist factory properties."""
|
"""Connect the playlist factory properties."""
|
||||||
self.db.playlists.bind_property("previous",
|
self.db.playlists.bind_property("previous",
|
||||||
|
@ -274,6 +293,7 @@ class Application(Adw.Application):
|
||||||
Adw.Application.do_startup(self)
|
Adw.Application.do_startup(self)
|
||||||
self.db = db.Connection()
|
self.db = db.Connection()
|
||||||
self.mpris = mpris2.Connection()
|
self.mpris = mpris2.Connection()
|
||||||
|
self.lbrainz = listenbrainz.ListenBrainz(self.db)
|
||||||
self.factory = playlist.Factory(self.db)
|
self.factory = playlist.Factory(self.db)
|
||||||
self.player = audio.Player()
|
self.player = audio.Player()
|
||||||
|
|
||||||
|
@ -286,6 +306,7 @@ class Application(Adw.Application):
|
||||||
self.win = self.build_window()
|
self.win = self.build_window()
|
||||||
self.add_window(self.win)
|
self.add_window(self.win)
|
||||||
self.connect_mpris2()
|
self.connect_mpris2()
|
||||||
|
self.connect_listenbrainz()
|
||||||
self.connect_playlist_factory()
|
self.connect_playlist_factory()
|
||||||
self.connect_player()
|
self.connect_player()
|
||||||
|
|
||||||
|
@ -311,6 +332,9 @@ class Application(Adw.Application):
|
||||||
if self.win is not None:
|
if self.win is not None:
|
||||||
self.win.close()
|
self.win.close()
|
||||||
self.win = None
|
self.win = None
|
||||||
|
if self.lbrainz is not None:
|
||||||
|
self.lbrainz.stop()
|
||||||
|
self.lbrainz = None
|
||||||
if self.mpris is not None:
|
if self.mpris is not None:
|
||||||
self.mpris.disconnect()
|
self.mpris.disconnect()
|
||||||
self.mpris = None
|
self.mpris = None
|
||||||
|
|
48
emmental/alarm.py
Normal file
48
emmental/alarm.py
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
# 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]
|
|
@ -5,6 +5,7 @@ from gi.repository import GObject
|
||||||
from gi.repository import GLib
|
from gi.repository import GLib
|
||||||
from gi.repository import Gst
|
from gi.repository import Gst
|
||||||
from . import filter
|
from . import filter
|
||||||
|
from . import stopwatch
|
||||||
from .. import path
|
from .. import path
|
||||||
from .. import tmpdir
|
from .. import tmpdir
|
||||||
|
|
||||||
|
@ -32,8 +33,7 @@ class Player(GObject.GObject):
|
||||||
status = GObject.Property(type=str, default="Stopped")
|
status = GObject.Property(type=str, default="Stopped")
|
||||||
have_track = GObject.Property(type=bool, default=False)
|
have_track = GObject.Property(type=bool, default=False)
|
||||||
almost_done = GObject.Property(type=bool, default=False)
|
almost_done = GObject.Property(type=bool, default=False)
|
||||||
playtime = GObject.Property(type=float)
|
stopwatch = GObject.Property(type=stopwatch.StopWatch)
|
||||||
savedtime = GObject.Property(type=float)
|
|
||||||
|
|
||||||
bg_enabled = GObject.Property(type=bool, default=False)
|
bg_enabled = GObject.Property(type=bool, default=False)
|
||||||
bg_volume = GObject.Property(type=float, default=0.5)
|
bg_volume = GObject.Property(type=float, default=0.5)
|
||||||
|
@ -41,7 +41,7 @@ class Player(GObject.GObject):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize the audio Player."""
|
"""Initialize the audio Player."""
|
||||||
super().__init__()
|
super().__init__(stopwatch=stopwatch.StopWatch())
|
||||||
self._filter = filter.Filter()
|
self._filter = filter.Filter()
|
||||||
self._timeout = None
|
self._timeout = None
|
||||||
|
|
||||||
|
@ -70,12 +70,6 @@ class Player(GObject.GObject):
|
||||||
if not self.almost_done:
|
if not self.almost_done:
|
||||||
self.emit("about-to-finish")
|
self.emit("about-to-finish")
|
||||||
|
|
||||||
def __get_current_playtime(self) -> float:
|
|
||||||
if not self._playbin.clock:
|
|
||||||
return 0.0
|
|
||||||
time = self._playbin.clock.get_time() - self._playbin.base_time
|
|
||||||
return time / Gst.SECOND
|
|
||||||
|
|
||||||
def __msg_async_done(self, bus: Gst.Bus, message: Gst.Message) -> None:
|
def __msg_async_done(self, bus: Gst.Bus, message: Gst.Message) -> None:
|
||||||
self.__update_position()
|
self.__update_position()
|
||||||
|
|
||||||
|
@ -95,15 +89,18 @@ class Player(GObject.GObject):
|
||||||
print("audio: state changed to 'playing'")
|
print("audio: state changed to 'playing'")
|
||||||
self.status = "Playing"
|
self.status = "Playing"
|
||||||
self.playing = True
|
self.playing = True
|
||||||
|
self.stopwatch.start()
|
||||||
case (_, Gst.State.PAUSED, Gst.State.VOID_PENDING):
|
case (_, Gst.State.PAUSED, Gst.State.VOID_PENDING):
|
||||||
print("audio: state changed to 'paused'")
|
print("audio: state changed to 'paused'")
|
||||||
self.status = "Paused"
|
self.status = "Paused"
|
||||||
self.playing = False
|
self.playing = False
|
||||||
|
self.stopwatch.stop()
|
||||||
case (_, Gst.State.READY, Gst.State.VOID_PENDING) | \
|
case (_, Gst.State.READY, Gst.State.VOID_PENDING) | \
|
||||||
(_, Gst.State.NULL, Gst.State.VOID_PENDING):
|
(_, Gst.State.NULL, Gst.State.VOID_PENDING):
|
||||||
print("audio: state changed to 'stopped'")
|
print("audio: state changed to 'stopped'")
|
||||||
self.status = "Stopped"
|
self.status = "Stopped"
|
||||||
self.playing = False
|
self.playing = False
|
||||||
|
self.stopwatch.stop()
|
||||||
|
|
||||||
self.__update_timeout()
|
self.__update_timeout()
|
||||||
|
|
||||||
|
@ -141,8 +138,7 @@ class Player(GObject.GObject):
|
||||||
artwork: pathlib.Path | None = None) -> None:
|
artwork: pathlib.Path | None = None) -> None:
|
||||||
for tag in ["artist", "album-artist", "album", "title"]:
|
for tag in ["artist", "album-artist", "album", "title"]:
|
||||||
self.set_property(tag, "")
|
self.set_property(tag, "")
|
||||||
for tag in ["album-disc-number", "track-number",
|
for tag in ["album-disc-number", "track-number", "position"]:
|
||||||
"position", "playtime", "savedtime"]:
|
|
||||||
self.set_property(tag, 0)
|
self.set_property(tag, 0)
|
||||||
|
|
||||||
self.almost_done = False
|
self.almost_done = False
|
||||||
|
@ -153,7 +149,6 @@ class Player(GObject.GObject):
|
||||||
def __update_position(self) -> bool:
|
def __update_position(self) -> bool:
|
||||||
(res, pos) = self._playbin.query_position(Gst.Format.TIME)
|
(res, pos) = self._playbin.query_position(Gst.Format.TIME)
|
||||||
self.position = pos / Gst.USECOND if res else 0
|
self.position = pos / Gst.USECOND if res else 0
|
||||||
self.playtime = self.__get_current_playtime() + self.savedtime
|
|
||||||
self.__check_last_second()
|
self.__check_last_second()
|
||||||
return GLib.SOURCE_CONTINUE
|
return GLib.SOURCE_CONTINUE
|
||||||
|
|
||||||
|
@ -189,7 +184,6 @@ class Player(GObject.GObject):
|
||||||
|
|
||||||
def seek(self, newpos: float, *args) -> None:
|
def seek(self, newpos: float, *args) -> None:
|
||||||
"""Seek to a different point in the stream."""
|
"""Seek to a different point in the stream."""
|
||||||
self.savedtime += self.__get_current_playtime()
|
|
||||||
self._playbin.seek_simple(Gst.Format.TIME, SEEK_FLAGS,
|
self._playbin.seek_simple(Gst.Format.TIME, SEEK_FLAGS,
|
||||||
newpos * Gst.USECOND)
|
newpos * Gst.USECOND)
|
||||||
|
|
||||||
|
@ -210,6 +204,11 @@ class Player(GObject.GObject):
|
||||||
"""Stop playback."""
|
"""Stop playback."""
|
||||||
self.set_state_sync(Gst.State.READY)
|
self.set_state_sync(Gst.State.READY)
|
||||||
|
|
||||||
|
@GObject.Property(type=float)
|
||||||
|
def playtime(self) -> float:
|
||||||
|
"""Get the total playtime of the current track."""
|
||||||
|
return self.stopwatch.elapsed_time()
|
||||||
|
|
||||||
@GObject.Signal
|
@GObject.Signal
|
||||||
def about_to_finish(self) -> None:
|
def about_to_finish(self) -> None:
|
||||||
"""Signal that playback is almost done."""
|
"""Signal that playback is almost done."""
|
||||||
|
@ -226,6 +225,7 @@ class Player(GObject.GObject):
|
||||||
cover = self.file.parent / "cover.jpg"
|
cover = self.file.parent / "cover.jpg"
|
||||||
self.__reset_properties(duration=(dur / Gst.USECOND if res else 0),
|
self.__reset_properties(duration=(dur / Gst.USECOND if res else 0),
|
||||||
artwork=(cover if cover.is_file() else None))
|
artwork=(cover if cover.is_file() else None))
|
||||||
|
self.stopwatch.reset(playing=self.playing)
|
||||||
self.have_track = True
|
self.have_track = True
|
||||||
|
|
||||||
@GObject.Signal
|
@GObject.Signal
|
||||||
|
|
42
emmental/audio/stopwatch.py
Normal file
42
emmental/audio/stopwatch.py
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
# Copyright 2024 (c) Anna Schumaker.
|
||||||
|
"""A custom StopWatch object for tracking play time."""
|
||||||
|
import datetime
|
||||||
|
from gi.repository import GObject
|
||||||
|
|
||||||
|
|
||||||
|
class StopWatch(GObject.GObject):
|
||||||
|
"""A StopWatch object."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize the StopWatch."""
|
||||||
|
super().__init__()
|
||||||
|
self._saved = None
|
||||||
|
self._started = None
|
||||||
|
|
||||||
|
def elapsed_time(self) -> float:
|
||||||
|
"""Get the elapsed time (in seconds)."""
|
||||||
|
total = datetime.timedelta()
|
||||||
|
if self._saved is not None:
|
||||||
|
total += self._saved
|
||||||
|
if self._started is not None:
|
||||||
|
total += datetime.datetime.now() - self._started
|
||||||
|
return total.total_seconds()
|
||||||
|
|
||||||
|
def reset(self, *, playing: bool) -> None:
|
||||||
|
"""Reset the StopWatch."""
|
||||||
|
self._saved = None
|
||||||
|
self._started = datetime.datetime.now() if playing else None
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Start the StopWatch."""
|
||||||
|
self._started = datetime.datetime.now()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop the StopWatch."""
|
||||||
|
if self._started is not None:
|
||||||
|
delta = datetime.datetime.now() - self._started
|
||||||
|
if self._saved is None:
|
||||||
|
self._saved = delta
|
||||||
|
else:
|
||||||
|
self._saved += delta
|
||||||
|
self._started = None
|
|
@ -119,6 +119,13 @@ class ImageToggle(Button):
|
||||||
inactive_tooltip_text=inactive_tooltip_text,
|
inactive_tooltip_text=inactive_tooltip_text,
|
||||||
tooltip_text=inactive_tooltip_text,
|
tooltip_text=inactive_tooltip_text,
|
||||||
active=active, **kwargs)
|
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))
|
||||||
|
|
||||||
def do_clicked(self) -> None:
|
def do_clicked(self) -> None:
|
||||||
"""Handle a click event."""
|
"""Handle a click event."""
|
||||||
|
|
|
@ -18,13 +18,16 @@ from . import tracks
|
||||||
from . import years
|
from . import years
|
||||||
|
|
||||||
|
|
||||||
SQL_SCRIPT = pathlib.Path(__file__).parent / "emmental.sql"
|
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"
|
||||||
|
|
||||||
|
|
||||||
class Connection(connection.Connection):
|
class Connection(connection.Connection):
|
||||||
"""Connect to the database."""
|
"""Connect to the database."""
|
||||||
|
|
||||||
active_playlist = GObject.Property(type=playlist.Playlist)
|
active_playlist = GObject.Property(type=playlist.Playlist)
|
||||||
|
loaded = GObject.Property(type=bool, default=False)
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize a sqlite connection."""
|
"""Initialize a sqlite connection."""
|
||||||
|
@ -43,13 +46,25 @@ class Connection(connection.Connection):
|
||||||
|
|
||||||
self.tracks = tracks.Table(self)
|
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:
|
def __check_version(self) -> None:
|
||||||
user_version = self("PRAGMA user_version").fetchone()["user_version"]
|
user_version = self("PRAGMA user_version").fetchone()["user_version"]
|
||||||
match user_version:
|
match user_version:
|
||||||
case 0:
|
case 0:
|
||||||
with open(SQL_SCRIPT) as f:
|
self.executescript(SQL_V1_SCRIPT)
|
||||||
self._sql.executescript(f.read())
|
self.executescript(SQL_V2_SCRIPT)
|
||||||
case 1: pass
|
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
|
||||||
case _:
|
case _:
|
||||||
raise Exception(f"Unsupported data version: {user_version}")
|
raise Exception(f"Unsupported data version: {user_version}")
|
||||||
|
|
||||||
|
@ -96,3 +111,4 @@ class Connection(connection.Connection):
|
||||||
def table_loaded(self, tbl: table.Table) -> None:
|
def table_loaded(self, tbl: table.Table) -> None:
|
||||||
"""Signal that a table has been loaded."""
|
"""Signal that a table has been loaded."""
|
||||||
tbl.loaded = True
|
tbl.loaded = True
|
||||||
|
self.__check_loaded()
|
||||||
|
|
50
emmental/db/adapter.py
Normal file
50
emmental/db/adapter.py
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
# Copyright 2024 (c) Anna Schumaker.
|
||||||
|
"""Custom sqlite3 adapters."""
|
||||||
|
import datetime
|
||||||
|
import pathlib
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
|
||||||
|
def adapt_date(date: datetime.date) -> str:
|
||||||
|
"""Adapt a date object to IOS 8601 date."""
|
||||||
|
return date.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def adapt_datetime(dt: datetime.datetime) -> str:
|
||||||
|
"""Adapt a datetime object to ISO 8601 timestamp."""
|
||||||
|
if dt.tzinfo is not None:
|
||||||
|
dt = datetime.datetime.combine(dt.date(), dt.time())
|
||||||
|
return dt.isoformat(" ")
|
||||||
|
|
||||||
|
|
||||||
|
def adapt_path(path: pathlib.Path) -> str:
|
||||||
|
"""Adapt a pathlib.Path into a sqlite3 string."""
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_date(date: bytes) -> datetime.date:
|
||||||
|
"""Convert ISO 8601 date to a datetime.date."""
|
||||||
|
return datetime.date.fromisoformat(date.decode())
|
||||||
|
|
||||||
|
|
||||||
|
def convert_datetime(dt: bytes) -> datetime.datetime:
|
||||||
|
"""Convert ISO 8601 timestamp to a datetime.datetime."""
|
||||||
|
if (res := datetime.datetime.fromisoformat(dt.decode())).tzinfo is None:
|
||||||
|
res = datetime.datetime.combine(res.date(), res.time(), datetime.UTC)
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def convert_path(path: bytes) -> pathlib.Path:
|
||||||
|
"""Convert a path string into a pathlib.Path object."""
|
||||||
|
return pathlib.Path(path.decode())
|
||||||
|
|
||||||
|
|
||||||
|
def register() -> None:
|
||||||
|
"""Register our adapters and converters."""
|
||||||
|
sqlite3.register_adapter(datetime.date, adapt_date)
|
||||||
|
sqlite3.register_adapter(datetime.datetime, adapt_datetime)
|
||||||
|
sqlite3.register_adapter(pathlib.PosixPath, adapt_path)
|
||||||
|
|
||||||
|
sqlite3.register_converter("date", convert_date)
|
||||||
|
sqlite3.register_converter("timestamp", convert_datetime)
|
||||||
|
sqlite3.register_converter("path", convert_path)
|
|
@ -3,7 +3,6 @@
|
||||||
import pathlib
|
import pathlib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from gi.repository import GObject
|
from gi.repository import GObject
|
||||||
from gi.repository import Gtk
|
|
||||||
from .media import Medium
|
from .media import Medium
|
||||||
from .. import format
|
from .. import format
|
||||||
from . import playlist
|
from . import playlist
|
||||||
|
@ -23,10 +22,11 @@ class Album(playlist.Playlist):
|
||||||
"""Initialize an Album object."""
|
"""Initialize an Album object."""
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.add_children(self.table.sql.media,
|
self.add_children(self.table.sql.media,
|
||||||
Gtk.CustomFilter.new(self.__match_medium))
|
self.table.get_mediumids(self))
|
||||||
|
|
||||||
def __match_medium(self, medium: Medium) -> bool:
|
def add_medium(self, medium: Medium) -> None:
|
||||||
return medium.albumid == self.albumid and len(medium.name) > 0
|
"""Add a Medium to this Album."""
|
||||||
|
self.add_child(medium)
|
||||||
|
|
||||||
def get_artists(self) -> list[playlist.Playlist]:
|
def get_artists(self) -> list[playlist.Playlist]:
|
||||||
"""Get a list of artists for this album."""
|
"""Get a list of artists for this album."""
|
||||||
|
@ -36,6 +36,14 @@ class Album(playlist.Playlist):
|
||||||
"""Get a list of media for this album."""
|
"""Get a list of media for this album."""
|
||||||
return self.table.get_media(self)
|
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
|
@property
|
||||||
def primary_key(self) -> int:
|
def primary_key(self) -> int:
|
||||||
"""Get the Album primary key."""
|
"""Get the Album primary key."""
|
||||||
|
@ -139,6 +147,11 @@ class Table(playlist.Table):
|
||||||
|
|
||||||
def get_media(self, album: Album) -> list[Medium]:
|
def get_media(self, album: Album) -> list[Medium]:
|
||||||
"""Get the list of media for this album."""
|
"""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=?",
|
rows = self.sql("SELECT mediumid FROM media WHERE albumid=?",
|
||||||
album.albumid)
|
album.albumid)
|
||||||
return [self.sql.media.rows.get(row["mediumid"]) for row in rows]
|
return {row["mediumid"] for row in rows.fetchall()}
|
||||||
|
|
|
@ -19,21 +19,21 @@ class Artist(playlist.Playlist):
|
||||||
"""Initialize an Artist object."""
|
"""Initialize an Artist object."""
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.add_children(self.table.sql.albums,
|
self.add_children(self.table.sql.albums,
|
||||||
table.Filter(self.table.get_albumids(self)))
|
self.table.get_albumids(self))
|
||||||
|
|
||||||
def add_album(self, album: Album) -> None:
|
def add_album(self, album: Album) -> None:
|
||||||
"""Add an Album to this Artist."""
|
"""Add an Album to this Artist."""
|
||||||
if self.table.add_album(self, album):
|
if self.table.add_album(self, album):
|
||||||
self.children.get_filter().add_row(album)
|
self.add_child(album)
|
||||||
|
|
||||||
def has_album(self, album: Album) -> bool:
|
def has_album(self, album: Album) -> bool:
|
||||||
"""Check if the Artist has this Album."""
|
"""Check if the Artist has this Album."""
|
||||||
return self.children.get_filter().match(album)
|
return self.has_child(album)
|
||||||
|
|
||||||
def remove_album(self, album: Album) -> None:
|
def remove_album(self, album: Album) -> None:
|
||||||
"""Remove an album from this Artist."""
|
"""Remove an album from this Artist."""
|
||||||
self.children.get_filter().remove_row(album)
|
|
||||||
self.table.remove_album(self, album)
|
self.table.remove_album(self, album)
|
||||||
|
self.remove_child(album)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def primary_key(self) -> int:
|
def primary_key(self) -> int:
|
||||||
|
@ -41,7 +41,7 @@ class Artist(playlist.Playlist):
|
||||||
return self.artistid
|
return self.artistid
|
||||||
|
|
||||||
|
|
||||||
class Filter(table.Filter):
|
class Filter(table.KeySet):
|
||||||
"""Custom filter to hide artists without albums."""
|
"""Custom filter to hide artists without albums."""
|
||||||
|
|
||||||
show_all = GObject.Property(type=bool, default=False)
|
show_all = GObject.Property(type=bool, default=False)
|
||||||
|
@ -51,7 +51,7 @@ class Filter(table.Filter):
|
||||||
super().__init__(show_all=show_all)
|
super().__init__(show_all=show_all)
|
||||||
self.connect("notify::show-all", self.__notify_show_all)
|
self.connect("notify::show-all", self.__notify_show_all)
|
||||||
|
|
||||||
def __notify_show_all(self, filter: table.Filter, param) -> None:
|
def __notify_show_all(self, filter: table.KeySet, param) -> None:
|
||||||
self.changed(Gtk.FilterChange.LESS_STRICT if self.show_all else
|
self.changed(Gtk.FilterChange.LESS_STRICT if self.show_all else
|
||||||
Gtk.FilterChange.MORE_STRICT)
|
Gtk.FilterChange.MORE_STRICT)
|
||||||
|
|
||||||
|
@ -66,7 +66,7 @@ class Filter(table.Filter):
|
||||||
"""Check if the artist matches the filter."""
|
"""Check if the artist matches the filter."""
|
||||||
res = super().do_match(artist)
|
res = super().do_match(artist)
|
||||||
if not self.show_all and res:
|
if not self.show_all and res:
|
||||||
return artist.children.get_filter().n_keys > 0
|
return artist.child_set.keyset.n_keys > 0
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -4,25 +4,14 @@ import pathlib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
from gi.repository import GObject
|
from gi.repository import GObject
|
||||||
|
from . import adapter
|
||||||
from .. import gsetup
|
from .. import gsetup
|
||||||
|
|
||||||
|
|
||||||
DATA_FILE = gsetup.DATA_DIR / f"emmental{gsetup.DEBUG_STR}.sqlite3"
|
DATA_FILE = gsetup.DATA_DIR / f"emmental{gsetup.DEBUG_STR}.sqlite3"
|
||||||
DATABASE = ":memory:" if "unittest" in sys.modules else DATA_FILE
|
DATABASE = ":memory:" if "unittest" in sys.modules else DATA_FILE
|
||||||
|
|
||||||
|
adapter.register()
|
||||||
def adapt_path(path: pathlib.Path) -> str:
|
|
||||||
"""Adapt a pathlib.Path into a sqlite3 string."""
|
|
||||||
return str(path)
|
|
||||||
|
|
||||||
|
|
||||||
def convert_path(path: bytes) -> pathlib.Path:
|
|
||||||
"""Convert a path string into a pathlib.Path object."""
|
|
||||||
return pathlib.Path(path.decode())
|
|
||||||
|
|
||||||
|
|
||||||
sqlite3.register_adapter(pathlib.PosixPath, adapt_path)
|
|
||||||
sqlite3.register_converter("path", convert_path)
|
|
||||||
|
|
||||||
|
|
||||||
class Connection(GObject.GObject):
|
class Connection(GObject.GObject):
|
||||||
|
@ -85,3 +74,11 @@ class Connection(GObject.GObject):
|
||||||
return self._sql.executemany(statement, args)
|
return self._sql.executemany(statement, args)
|
||||||
except sqlite3.InternalError:
|
except sqlite3.InternalError:
|
||||||
return None
|
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
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
"""A custom Gio.ListModel for working with decades."""
|
"""A custom Gio.ListModel for working with decades."""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from gi.repository import GObject
|
from gi.repository import GObject
|
||||||
from gi.repository import Gtk
|
|
||||||
from .years import Year
|
from .years import Year
|
||||||
from . import playlist
|
from . import playlist
|
||||||
from . import tracks
|
from . import tracks
|
||||||
|
@ -17,15 +16,24 @@ class Decade(playlist.Playlist):
|
||||||
"""Initialize a Decade object."""
|
"""Initialize a Decade object."""
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.add_children(self.table.sql.years,
|
self.add_children(self.table.sql.years,
|
||||||
Gtk.CustomFilter.new(self.__match_year))
|
self.table.get_yearids(self))
|
||||||
|
|
||||||
def __match_year(self, year: Year) -> bool:
|
def add_year(self, year: Year) -> None:
|
||||||
return self.decade == year.year // 10 * 10
|
"""Add a year to this decade."""
|
||||||
|
self.add_child(year)
|
||||||
|
|
||||||
def get_years(self) -> list[Year]:
|
def get_years(self) -> list[Year]:
|
||||||
"""Get a list of years for this decade."""
|
"""Get a list of years for this decade."""
|
||||||
return self.table.get_years(self)
|
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
|
@property
|
||||||
def primary_key(self) -> int:
|
def primary_key(self) -> int:
|
||||||
"""Get the primary key of this Decade."""
|
"""Get the primary key of this Decade."""
|
||||||
|
@ -90,8 +98,12 @@ class Table(playlist.Table):
|
||||||
return self.sql("""SELECT trackid FROM decade_tracks_view
|
return self.sql("""SELECT trackid FROM decade_tracks_view
|
||||||
WHERE decade=?""", decade.decade)
|
WHERE decade=?""", decade.decade)
|
||||||
|
|
||||||
def get_years(self, decade: Decade) -> list[Year]:
|
def get_yearids(self, decade: Decade) -> set[int]:
|
||||||
"""Get the list of years for this decade."""
|
"""Get the set of years for this decade."""
|
||||||
rows = self.sql("SELECT year FROM years WHERE (year / 10 * 10)=?",
|
rows = self.sql("SELECT year FROM years WHERE (year / 10 * 10)=?",
|
||||||
decade.decade)
|
decade.decade)
|
||||||
return [self.sql.years.rows.get(row["year"]) for row in rows]
|
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)]
|
||||||
|
|
|
@ -52,6 +52,11 @@ class Queue(GObject.GObject):
|
||||||
self.running = False
|
self.running = False
|
||||||
self._idle_id = None
|
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:
|
def complete(self) -> None:
|
||||||
"""Complete all pending tasks."""
|
"""Complete all pending tasks."""
|
||||||
if self.running:
|
if self.running:
|
||||||
|
@ -60,12 +65,13 @@ class Queue(GObject.GObject):
|
||||||
self.cancel()
|
self.cancel()
|
||||||
|
|
||||||
def push(self, func: typing.Callable, *args,
|
def push(self, func: typing.Callable, *args,
|
||||||
now: bool = False) -> bool | None:
|
now: bool = False, first: bool = False) -> bool | None:
|
||||||
"""Add a task to the Idle Queue."""
|
"""Add a task to the Idle Queue."""
|
||||||
if not self.enabled or now:
|
if not self.enabled or now:
|
||||||
return func(*args)
|
return func(*args)
|
||||||
|
|
||||||
self._tasks.append((func, *args))
|
pos = 0 if first else len(self._tasks)
|
||||||
|
self._tasks.insert(pos, (func, *args))
|
||||||
self.total += 1
|
self.total += 1
|
||||||
self.__start()
|
self.__start()
|
||||||
|
|
||||||
|
|
|
@ -55,10 +55,11 @@ class Library(playlist.Playlist):
|
||||||
|
|
||||||
def __tag_track(self, path: pathlib.Path) -> bool:
|
def __tag_track(self, path: pathlib.Path) -> bool:
|
||||||
if self.tagger.ready.is_set():
|
if self.tagger.ready.is_set():
|
||||||
(file, tags) = self.tagger.get_result(self.table.sql, self)
|
result = self.tagger.get_result(db=self.table.sql, library=self)
|
||||||
if file is None:
|
if result is None:
|
||||||
track = self.table.sql.tracks.lookup(self, path=path)
|
track = self.table.sql.tracks.lookup(self, path=path)
|
||||||
self.tagger.tag_file(path, track.mtime if track else None)
|
mtime = track.mtime if track else None
|
||||||
|
self.tagger.tag_file(path, mtime=mtime)
|
||||||
else:
|
else:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
|
@ -2,8 +2,10 @@
|
||||||
"""A custom Gio.ListModel for managing individual media in an album."""
|
"""A custom Gio.ListModel for managing individual media in an album."""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from gi.repository import GObject
|
from gi.repository import GObject
|
||||||
|
from gi.repository import Gtk
|
||||||
from .. import format
|
from .. import format
|
||||||
from . import playlist
|
from . import playlist
|
||||||
|
from . import table
|
||||||
from . import tracks
|
from . import tracks
|
||||||
|
|
||||||
|
|
||||||
|
@ -34,12 +36,26 @@ class Medium(playlist.Playlist):
|
||||||
return self.get_album()
|
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):
|
class Table(playlist.Table):
|
||||||
"""Our Media Table."""
|
"""Our Media Table."""
|
||||||
|
|
||||||
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
|
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
|
||||||
"""Initialize the Media Table."""
|
"""Initialize the Media Table."""
|
||||||
super().__init__(sql=sql, autodelete=True,
|
super().__init__(sql=sql, filter=Filter(), autodelete=True,
|
||||||
system_tracks=False, **kwargs)
|
system_tracks=False, **kwargs)
|
||||||
|
|
||||||
def do_construct(self, **kwargs) -> Medium:
|
def do_construct(self, **kwargs) -> Medium:
|
||||||
|
@ -61,6 +77,7 @@ class Table(playlist.Table):
|
||||||
|
|
||||||
def do_sql_delete(self, medium: Medium) -> sqlite3.Cursor:
|
def do_sql_delete(self, medium: Medium) -> sqlite3.Cursor:
|
||||||
"""Delete a medium."""
|
"""Delete a medium."""
|
||||||
|
medium.get_album().remove_medium(medium)
|
||||||
return self.sql("DELETE FROM media WHERE mediumid=?",
|
return self.sql("DELETE FROM media WHERE mediumid=?",
|
||||||
medium.mediumid)
|
medium.mediumid)
|
||||||
|
|
||||||
|
@ -100,6 +117,13 @@ class Table(playlist.Table):
|
||||||
return self.sql(f"UPDATE media SET {column}=? WHERE mediumid=?",
|
return self.sql(f"UPDATE media SET {column}=? WHERE mediumid=?",
|
||||||
newval, medium.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:
|
def rename(self, medium: Medium, new_name: str) -> bool:
|
||||||
"""Rename a medium."""
|
"""Rename a medium."""
|
||||||
if (new_name := new_name.strip()) != medium.name:
|
if (new_name := new_name.strip()) != medium.name:
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
# Copyright 2022 (c) Anna Schumaker
|
# Copyright 2022 (c) Anna Schumaker
|
||||||
"""A customized Gio.ListStore for tracking Playlist GObjects."""
|
"""A customized Gio.ListStore for tracking Playlist GObjects."""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import typing
|
||||||
from gi.repository import GObject
|
from gi.repository import GObject
|
||||||
from gi.repository import Gio
|
from gi.repository import Gio
|
||||||
from gi.repository import Gtk
|
from gi.repository import Gtk
|
||||||
|
@ -28,6 +29,7 @@ class Playlist(table.Row):
|
||||||
tracks_movable = GObject.Property(type=bool, default=False)
|
tracks_movable = GObject.Property(type=bool, default=False)
|
||||||
current_trackid = GObject.Property(type=int)
|
current_trackid = GObject.Property(type=int)
|
||||||
|
|
||||||
|
child_set = GObject.Property(type=table.TableSubset)
|
||||||
children = GObject.Property(type=Gtk.FilterListModel)
|
children = GObject.Property(type=Gtk.FilterListModel)
|
||||||
|
|
||||||
def __init__(self, table: Gio.ListModel, propertyid: int,
|
def __init__(self, table: Gio.ListModel, propertyid: int,
|
||||||
|
@ -48,20 +50,27 @@ class Playlist(table.Row):
|
||||||
self.table.remove_track(self, track)
|
self.table.remove_track(self, track)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def add_children(self, child_table: table.Table,
|
def add_children(self, child_table: table.Table, child_keys: set) -> None:
|
||||||
child_filter: Gtk.Filter) -> None:
|
|
||||||
"""Create a FilterListModel for this playlist's children."""
|
"""Create a FilterListModel for this playlist's children."""
|
||||||
self.children = Gtk.FilterListModel.new(child_table, child_filter)
|
self.child_set = table.TableSubset(child_table, keys=child_keys)
|
||||||
self.children.set_incremental(True)
|
self.children = Gtk.FilterListModel.new(self.child_set,
|
||||||
|
child_table.get_filter())
|
||||||
|
|
||||||
def do_update(self, column: str) -> bool:
|
def do_update(self, column: str) -> bool:
|
||||||
"""Update a Playlist object."""
|
"""Update a Playlist object."""
|
||||||
match column:
|
match column:
|
||||||
case "propertyid" | "name" | "n-tracks" | "children" | \
|
case "propertyid" | "name" | "n-tracks" | "child-set" | \
|
||||||
"user-tracks" | "tracks-loaded" | "tracks-movable": pass
|
"children" | "user-tracks" | "tracks-loaded" | \
|
||||||
|
"tracks-movable": pass
|
||||||
case _: return super().do_update(column)
|
case _: return super().do_update(column)
|
||||||
return True
|
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:
|
def add_track(self, track: Track, *, idle: bool = False) -> None:
|
||||||
"""Add a Track to this Playlist."""
|
"""Add a Track to this Playlist."""
|
||||||
if self.table.add_track(self, track):
|
if self.table.add_track(self, track):
|
||||||
|
@ -71,6 +80,10 @@ class Playlist(table.Row):
|
||||||
"""Get a dictionary mapping for trackid -> sorted position."""
|
"""Get a dictionary mapping for trackid -> sorted position."""
|
||||||
return self.table.get_track_order(self)
|
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:
|
def has_track(self, track: Track) -> bool:
|
||||||
"""Check if a Track is on this Playlist."""
|
"""Check if a Track is on this Playlist."""
|
||||||
return track in self.tracks
|
return track in self.tracks
|
||||||
|
@ -95,6 +108,12 @@ class Playlist(table.Row):
|
||||||
self.tracks_loaded = False
|
self.tracks_loaded = False
|
||||||
self.table.queue.push(self.load_tracks, now=not idle)
|
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:
|
def remove_track(self, track: table.Row, *, idle: bool = False) -> None:
|
||||||
"""Remove a Track from this Playlist."""
|
"""Remove a Track from this Playlist."""
|
||||||
self.table.queue.push(self.__remove_track, track, now=not idle)
|
self.table.queue.push(self.__remove_track, track, now=not idle)
|
||||||
|
@ -138,6 +157,10 @@ class Table(table.Table):
|
||||||
def __create_tree(self, plist: Playlist) -> Gtk.FilterListModel | None:
|
def __create_tree(self, plist: Playlist) -> Gtk.FilterListModel | None:
|
||||||
return plist.children
|
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:
|
def do_add_track(self, playlist: Playlist, track: Track) -> bool:
|
||||||
"""Add a Track to the Playlist."""
|
"""Add a Track to the Playlist."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
@ -239,6 +262,11 @@ class Table(table.Table):
|
||||||
playlist.sort_order = "user"
|
playlist.sort_order = "user"
|
||||||
return res
|
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:
|
def remove_system_track(self, playlist: Playlist, track: Track) -> bool:
|
||||||
"""Remove a Track from a system Playlist."""
|
"""Remove a Track from a system Playlist."""
|
||||||
return self.sql("""DELETE FROM system_tracks
|
return self.sql("""DELETE FROM system_tracks
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
# Copyright 2022 (c) Anna Schumaker
|
# Copyright 2022 (c) Anna Schumaker
|
||||||
"""A custom Gio.ListModel for working with playlists."""
|
"""A custom Gio.ListModel for working with playlists."""
|
||||||
|
import datetime
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from gi.repository import GObject
|
from gi.repository import GObject
|
||||||
|
from .. import alarm
|
||||||
from . import playlist
|
from . import playlist
|
||||||
from . import tracks
|
from . import tracks
|
||||||
|
|
||||||
|
@ -57,6 +59,11 @@ class Table(playlist.Table):
|
||||||
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
|
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
|
||||||
"""Initialize the Playlists Table."""
|
"""Initialize the Playlists Table."""
|
||||||
super().__init__(sql=sql, system_tracks=False, **kwargs)
|
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,
|
def __move_user_trackid(self, playlist: Playlist, trackid: int,
|
||||||
*, offset: int) -> bool:
|
*, offset: int) -> bool:
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
# Copyright 2022 (c) Anna Schumaker
|
# Copyright 2022 (c) Anna Schumaker
|
||||||
"""Base classes for database objects."""
|
"""Base classes for database objects."""
|
||||||
|
import bisect
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from gi.repository import GObject
|
from gi.repository import GObject
|
||||||
from gi.repository import Gio
|
from gi.repository import Gio
|
||||||
|
@ -37,44 +38,52 @@ class Row(GObject.GObject):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class Filter(Gtk.Filter):
|
class KeySet(Gtk.Filter):
|
||||||
"""A Filter that can be used to search playlists."""
|
"""A Gtk.Filter that also acts as a Python Set."""
|
||||||
|
|
||||||
n_keys = GObject.Property(type=int)
|
n_keys = GObject.Property(type=int)
|
||||||
|
|
||||||
def __init__(self, keys: set | None = None, **kwargs):
|
def __init__(self, keys: set | None = None, **kwargs):
|
||||||
"""Set up our Filter."""
|
"""Set up our KeySet."""
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self._keys = keys
|
self._keys = keys
|
||||||
self.n_keys = len(keys) if keys is not None else -1
|
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]:
|
def __sub__(self, rhs: Gtk.Filter) -> set[int]:
|
||||||
"""Subtract two Filters and return the result."""
|
"""Subtract two KeySets and return the result."""
|
||||||
match (self._keys, rhs._keys):
|
match (self._keys, rhs._keys):
|
||||||
case (None, _): return None
|
case (None, _): return None
|
||||||
case (_, None): return self._keys
|
case (_, None): return self._keys
|
||||||
case (_, _): return self._keys - rhs._keys
|
case (_, _): return self._keys - rhs._keys
|
||||||
|
|
||||||
def __find_change(self, keys: set[any] | None) -> Gtk.FilterChange | None:
|
def __find_difference(self, new: set[any] | None) \
|
||||||
if keys == self._keys:
|
-> tuple[set, set, Gtk.FilterChange | None]:
|
||||||
return None
|
if self._keys is None:
|
||||||
elif keys is None:
|
if new is None:
|
||||||
return Gtk.FilterChange.LESS_STRICT
|
return (set(), set(), None)
|
||||||
elif self._keys is None:
|
return (set(), new, Gtk.FilterChange.MORE_STRICT)
|
||||||
return Gtk.FilterChange.MORE_STRICT
|
elif new is None:
|
||||||
elif keys.issuperset(self._keys):
|
return (self._keys, set(), Gtk.FilterChange.LESS_STRICT)
|
||||||
return Gtk.FilterChange.LESS_STRICT
|
|
||||||
elif keys.issubset(self._keys):
|
removed = self._keys - new
|
||||||
return Gtk.FilterChange.MORE_STRICT
|
added = new - self._keys
|
||||||
return Gtk.FilterChange.DIFFERENT
|
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 changed(self, how: Gtk.FilterChange) -> None:
|
def changed(self, how: Gtk.FilterChange) -> None:
|
||||||
"""Notify that the filter has changed."""
|
"""Notify that the KeySet has changed."""
|
||||||
self.n_keys = len(self._keys) if self._keys is not None else -1
|
self.n_keys = len(self._keys) if self._keys is not None else -1
|
||||||
super().changed(how)
|
super().changed(how)
|
||||||
|
|
||||||
def do_get_strictness(self) -> Gtk.FilterMatch:
|
def do_get_strictness(self) -> Gtk.FilterMatch:
|
||||||
"""Get the strictness of the filter."""
|
"""Get the strictness of the Gtk.Filter."""
|
||||||
if self._keys is None:
|
if self._keys is None:
|
||||||
return Gtk.FilterMatch.ALL
|
return Gtk.FilterMatch.ALL
|
||||||
if len(self._keys) == 0:
|
if len(self._keys) == 0:
|
||||||
|
@ -82,19 +91,21 @@ class Filter(Gtk.Filter):
|
||||||
return Gtk.FilterMatch.SOME
|
return Gtk.FilterMatch.SOME
|
||||||
|
|
||||||
def do_match(self, row: Row) -> bool:
|
def do_match(self, row: Row) -> bool:
|
||||||
"""Check if the Row matches the filter."""
|
"""Check if the Row is in the KeySet."""
|
||||||
return self._keys is None or row.primary_key in self._keys
|
return self._keys is None or row.primary_key in self._keys
|
||||||
|
|
||||||
def add_row(self, row: Row) -> None:
|
def add_row(self, row: Row) -> None:
|
||||||
"""Add a Row to the Filter."""
|
"""Add a Row to the KeySet."""
|
||||||
if self._keys is not None:
|
if row not in self:
|
||||||
self._keys.add(row.primary_key)
|
self._keys.add(row.primary_key)
|
||||||
|
self.emit("key-added", row.primary_key)
|
||||||
self.changed(Gtk.FilterChange.LESS_STRICT)
|
self.changed(Gtk.FilterChange.LESS_STRICT)
|
||||||
|
|
||||||
def remove_row(self, row: Row) -> None:
|
def remove_row(self, row: Row) -> None:
|
||||||
"""Remove a Row from the Filter."""
|
"""Remove a Row from the KeySet."""
|
||||||
if self._keys is not None:
|
if self._keys is not None and row in self:
|
||||||
self._keys.discard(row.primary_key)
|
self._keys.discard(row.primary_key)
|
||||||
|
self.emit("key-removed", row.primary_key)
|
||||||
self.changed(Gtk.FilterChange.MORE_STRICT)
|
self.changed(Gtk.FilterChange.MORE_STRICT)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -105,9 +116,23 @@ class Filter(Gtk.Filter):
|
||||||
@keys.setter
|
@keys.setter
|
||||||
def keys(self, keys: set[any] | None) -> None:
|
def keys(self, keys: set[any] | None) -> None:
|
||||||
"""Set the matching primary keys."""
|
"""Set the matching primary keys."""
|
||||||
if (how := self.__find_change(keys)) is not None:
|
(removed, added, change) = self.__find_difference(keys)
|
||||||
|
if change is not None:
|
||||||
self._keys = keys
|
self._keys = keys
|
||||||
self.changed(how)
|
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."""
|
||||||
|
|
||||||
|
|
||||||
class Table(Gtk.FilterListModel):
|
class Table(Gtk.FilterListModel):
|
||||||
|
@ -121,12 +146,12 @@ class Table(Gtk.FilterListModel):
|
||||||
loaded = GObject.Property(type=bool, default=False)
|
loaded = GObject.Property(type=bool, default=False)
|
||||||
|
|
||||||
def __init__(self, sql: GObject.TYPE_PYOBJECT,
|
def __init__(self, sql: GObject.TYPE_PYOBJECT,
|
||||||
filter: Filter | None = None,
|
filter: KeySet | None = None,
|
||||||
queue: Queue | None = None, **kwargs):
|
queue: Queue | None = None, **kwargs):
|
||||||
"""Set up our Table object."""
|
"""Set up our Table object."""
|
||||||
super().__init__(sql=sql, rows=dict(), incremental=True,
|
super().__init__(sql=sql, rows=dict(),
|
||||||
store=store.SortedList(self.get_sort_key),
|
store=store.SortedList(self.get_sort_key),
|
||||||
filter=(filter if filter else Filter()),
|
filter=(filter if filter else KeySet()),
|
||||||
queue=(queue if queue else Queue()), **kwargs)
|
queue=(queue if queue else Queue()), **kwargs)
|
||||||
self.set_model(self.store)
|
self.set_model(self.store)
|
||||||
|
|
||||||
|
@ -202,7 +227,8 @@ class Table(Gtk.FilterListModel):
|
||||||
def filter(self, glob: str | None, *, now: bool = False) -> None:
|
def filter(self, glob: str | None, *, now: bool = False) -> None:
|
||||||
"""Filter the displayed Rows."""
|
"""Filter the displayed Rows."""
|
||||||
if glob is not None:
|
if glob is not None:
|
||||||
self.queue.push(self._filter_idle, glob, now=now)
|
self.queue.cancel_task(self._filter_idle)
|
||||||
|
self.queue.push(self._filter_idle, glob, now=now, first=True)
|
||||||
else:
|
else:
|
||||||
self.get_filter().keys = None
|
self.get_filter().keys = None
|
||||||
|
|
||||||
|
@ -247,3 +273,75 @@ class Table(Gtk.FilterListModel):
|
||||||
def update(self, row: Row, column: str, newval) -> bool:
|
def update(self, row: Row, column: str, newval) -> bool:
|
||||||
"""Update a Row."""
|
"""Update a Row."""
|
||||||
return self.do_sql_update(row, column, newval) is not None
|
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)
|
||||||
|
|
|
@ -3,9 +3,9 @@
|
||||||
import emmental.audio.tagger
|
import emmental.audio.tagger
|
||||||
import musicbrainzngs
|
import musicbrainzngs
|
||||||
import pathlib
|
import pathlib
|
||||||
import threading
|
|
||||||
from gi.repository import GObject
|
from gi.repository import GObject
|
||||||
from .. import audio
|
from .. import audio
|
||||||
|
from .. import thread
|
||||||
from . import albums
|
from . import albums
|
||||||
from . import artists
|
from . import artists
|
||||||
from . import connection
|
from . import connection
|
||||||
|
@ -178,24 +178,12 @@ class Tags:
|
||||||
return year if year else self.db.years.create(raw_year)
|
return year if year else self.db.years.create(raw_year)
|
||||||
|
|
||||||
|
|
||||||
class Thread(threading.Thread):
|
class Thread(thread.Thread):
|
||||||
"""A thread for tagging files without blocking the UI."""
|
"""A thread for tagging files without blocking the UI."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize the Tagger Thread."""
|
"""Initialize the Tagger Thread."""
|
||||||
super().__init__()
|
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
|
self._connection = None
|
||||||
|
|
||||||
def __get_connection(self) -> connection.Connection:
|
def __get_connection(self) -> connection.Connection:
|
||||||
|
@ -213,55 +201,31 @@ class Thread(threading.Thread):
|
||||||
mb_res = musicbrainzngs.get_artist_by_id(artist.mbid)
|
mb_res = musicbrainzngs.get_artist_by_id(artist.mbid)
|
||||||
artist.name = mb_res["artist"]["name"]
|
artist.name = mb_res["artist"]["name"]
|
||||||
|
|
||||||
def get_result(self, db: GObject.TYPE_PYOBJECT,
|
def do_get_result(self, result: thread.Data, db: GObject.TYPE_PYOBJECT,
|
||||||
library: playlist.Playlist) \
|
library: playlist.Playlist) -> tuple:
|
||||||
-> tuple[pathlib.Path | None, Tags | None]:
|
|
||||||
"""Return the resulting Tags structure."""
|
"""Return the resulting Tags structure."""
|
||||||
with self._condition:
|
tags = None if result.tags is None else Tags(db, result.tags, library)
|
||||||
if not self.ready.is_set():
|
return (result.path, tags)
|
||||||
return (None, None)
|
|
||||||
|
|
||||||
tags = Tags(db, self._tags, library) if self._tags else None
|
def do_run_task(self, task: thread.Data) -> None:
|
||||||
res = (self._file, tags)
|
"""Tag a file."""
|
||||||
self._file = None
|
tags = emmental.audio.tagger.tag_file(task.path, task.mtime)
|
||||||
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:
|
if tags is not None:
|
||||||
for artist in tags.artists:
|
for artist in tags.artists:
|
||||||
self.__check_artist(artist)
|
self.__check_artist(artist)
|
||||||
|
|
||||||
self._tags = tags
|
self.set_result(path=task.path, tags=tags)
|
||||||
self.ready.set()
|
|
||||||
|
|
||||||
self.__close_connection()
|
def do_stop(self) -> None:
|
||||||
|
"""Close the connection before stopping."""
|
||||||
|
if self._connection:
|
||||||
|
self._connection.close()
|
||||||
|
self._connection = None
|
||||||
|
|
||||||
def stop(self) -> None:
|
def tag_file(self, path: pathlib.Path,
|
||||||
"""Stop the thread."""
|
*, mtime: float | None = None) -> None:
|
||||||
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."""
|
"""Tag a file."""
|
||||||
with self._condition:
|
self.set_task(path=path, mtime=mtime)
|
||||||
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:
|
def untag_track(db: GObject.TYPE_PYOBJECT, track: tracks.Track) -> None:
|
||||||
|
|
|
@ -90,7 +90,7 @@ class Track(table.Row):
|
||||||
return self.trackid
|
return self.trackid
|
||||||
|
|
||||||
|
|
||||||
class Filter(table.Filter):
|
class Filter(table.KeySet):
|
||||||
"""A customized Filter that never sets strictness to FilterMatch.All."""
|
"""A customized Filter that never sets strictness to FilterMatch.All."""
|
||||||
|
|
||||||
def do_get_strictness(self) -> Gtk.FilterMatch:
|
def do_get_strictness(self) -> Gtk.FilterMatch:
|
||||||
|
@ -200,6 +200,12 @@ class Table(table.Table):
|
||||||
return self.sql(f"UPDATE tracks SET {column}=? WHERE trackid=?",
|
return self.sql(f"UPDATE tracks SET {column}=? WHERE trackid=?",
|
||||||
newval, track.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]:
|
def get_artists(self, track: Track) -> list[table.Row]:
|
||||||
"""Get the set of Artists for a specific Track."""
|
"""Get the set of Artists for a specific Track."""
|
||||||
rows = self.sql("""SELECT artistid FROM artist_tracks_view
|
rows = self.sql("""SELECT artistid FROM artist_tracks_view
|
||||||
|
@ -212,6 +218,14 @@ class Table(table.Table):
|
||||||
WHERE trackid=?""", track.trackid).fetchall()
|
WHERE trackid=?""", track.trackid).fetchall()
|
||||||
return [self.sql.genres.rows.get(row["genreid"]) for row in rows]
|
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]:
|
def map_sort_order(self, ordering: str) -> dict[int, int]:
|
||||||
"""Get a lookup table for Track sort keys."""
|
"""Get a lookup table for Track sort keys."""
|
||||||
ordering = ordering if len(ordering) > 0 else "trackid"
|
ordering = ordering if len(ordering) > 0 else "trackid"
|
||||||
|
@ -228,7 +242,7 @@ class Table(table.Table):
|
||||||
def restart_track(self, track: Track) -> None:
|
def restart_track(self, track: Track) -> None:
|
||||||
"""Mark that a Track has been restarted."""
|
"""Mark that a Track has been restarted."""
|
||||||
track.active = True
|
track.active = True
|
||||||
track.restarted = datetime.datetime.utcnow()
|
track.restarted = datetime.datetime.now(datetime.UTC)
|
||||||
self.current_track = track
|
self.current_track = track
|
||||||
|
|
||||||
def start_track(self, track: Track) -> None:
|
def start_track(self, track: Track) -> None:
|
||||||
|
@ -237,7 +251,7 @@ class Table(table.Table):
|
||||||
|
|
||||||
cur = self.sql("""UPDATE tracks SET active=TRUE, laststarted=?
|
cur = self.sql("""UPDATE tracks SET active=TRUE, laststarted=?
|
||||||
WHERE trackid=? RETURNING laststarted""",
|
WHERE trackid=? RETURNING laststarted""",
|
||||||
datetime.datetime.utcnow(), track.trackid)
|
datetime.datetime.now(datetime.UTC), track.trackid)
|
||||||
track.active = True
|
track.active = True
|
||||||
track.laststarted = cur.fetchone()["laststarted"]
|
track.laststarted = cur.fetchone()["laststarted"]
|
||||||
self.current_track = track
|
self.current_track = track
|
||||||
|
@ -270,9 +284,17 @@ class Table(table.Table):
|
||||||
self.sql.playlists.most_played.reload_tracks(idle=True)
|
self.sql.playlists.most_played.reload_tracks(idle=True)
|
||||||
self.sql.playlists.queued.remove_track(track)
|
self.sql.playlists.queued.remove_track(track)
|
||||||
self.sql.playlists.unplayed.remove_track(track)
|
self.sql.playlists.unplayed.remove_track(track)
|
||||||
|
self.emit("track-played", track)
|
||||||
|
|
||||||
self.sql.commit()
|
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):
|
class TrackidSet(GObject.GObject):
|
||||||
"""Manage a set of Track IDs."""
|
"""Manage a set of Track IDs."""
|
||||||
|
|
38
emmental/db/upgrade-v2.sql
Normal file
38
emmental/db/upgrade-v2.sql
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
/* 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;
|
25
emmental/db/upgrade-v3.sql
Normal file
25
emmental/db/upgrade-v3.sql
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
/* 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;
|
|
@ -48,6 +48,8 @@ class Table(playlist.Table):
|
||||||
|
|
||||||
def do_sql_delete(self, year: Year) -> sqlite3.Cursor:
|
def do_sql_delete(self, year: Year) -> sqlite3.Cursor:
|
||||||
"""Delete a year."""
|
"""Delete a year."""
|
||||||
|
if year.parent is not None:
|
||||||
|
year.parent.remove_year(year)
|
||||||
return self.sql("DELETE FROM years WHERE year=?", year.year)
|
return self.sql("DELETE FROM years WHERE year=?", year.year)
|
||||||
|
|
||||||
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
|
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
|
||||||
|
@ -71,3 +73,10 @@ class Table(playlist.Table):
|
||||||
"""Load a Year's Tracks from the database."""
|
"""Load a Year's Tracks from the database."""
|
||||||
return self.sql("""SELECT trackid FROM year_tracks_view
|
return self.sql("""SELECT trackid FROM year_tracks_view
|
||||||
WHERE year=?""", year.year)
|
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
|
||||||
|
|
|
@ -21,29 +21,29 @@ box.emmental-splitbutton>menubutton>button {
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
listview > row:checked {
|
row.emmental-active-row {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
background-color: alpha(@accent_color, 0.2);
|
background-color: alpha(@accent_color, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
listview > row:checked:hover {
|
row.emmental-active-row:hover {
|
||||||
background-color: alpha(@accent_color, 0.27);
|
background-color: alpha(@accent_color, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
listview > row:checked:active {
|
row.emmental-active-row:active {
|
||||||
background-color: alpha(@accent_color, 0.36);
|
background-color: alpha(@accent_color, 0.31);
|
||||||
}
|
}
|
||||||
|
|
||||||
listview > row:checked:selected {
|
row.emmental-active-row:selected {
|
||||||
background-color: alpha(@accent_color, 0.3);
|
background-color: alpha(@accent_color, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
listview > row:checked:selected:hover {
|
row.emmental-active-row:selected:hover {
|
||||||
background-color: alpha(@accent_color, 0.33);
|
background-color: alpha(@accent_color, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
listview > row:checked:selected:active {
|
row.emmental-active-row:selected:active {
|
||||||
background-color: alpha(@accent_color, 0.39);
|
background-color: alpha(@accent_color, 0.34);
|
||||||
}
|
}
|
||||||
|
|
||||||
image.emmental-sidebar-arrow {
|
image.emmental-sidebar-arrow {
|
||||||
|
@ -70,6 +70,14 @@ button.emmental-stop>image {
|
||||||
color: @red_3;
|
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 {
|
columnview.emmental-track-list > listview > row > cell {
|
||||||
padding: 0px 2px;
|
padding: 0px 2px;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
|
|
|
@ -61,17 +61,17 @@ class ListRow(GObject.GObject):
|
||||||
@GObject.Property(type=bool, default=False)
|
@GObject.Property(type=bool, default=False)
|
||||||
def active(self) -> bool:
|
def active(self) -> bool:
|
||||||
"""Get the active state of this Row."""
|
"""Get the active state of this Row."""
|
||||||
if parent := self.listitem.get_child().get_parent():
|
if self.listrow is not None:
|
||||||
return parent.get_state_flags() & Gtk.StateFlags.CHECKED
|
return self.listrow.has_css_class("emmental-active-row")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@active.setter
|
@active.setter
|
||||||
def active(self, newval: bool) -> None:
|
def active(self, newval: bool) -> None:
|
||||||
if parent := self.listitem.get_child().get_parent():
|
if self.listrow is not None:
|
||||||
if newval:
|
if newval:
|
||||||
parent.set_state_flags(Gtk.StateFlags.CHECKED, False)
|
self.listrow.add_css_class("emmental-active-row")
|
||||||
else:
|
else:
|
||||||
parent.unset_state_flags(Gtk.StateFlags.CHECKED)
|
self.listrow.remove_css_class("emmental-active-row")
|
||||||
|
|
||||||
@GObject.Property(type=Gtk.Widget)
|
@GObject.Property(type=Gtk.Widget)
|
||||||
def child(self) -> Gtk.Widget | None:
|
def child(self) -> Gtk.Widget | None:
|
||||||
|
@ -87,6 +87,11 @@ class ListRow(GObject.GObject):
|
||||||
"""Get the list item for this Row."""
|
"""Get the list item for this Row."""
|
||||||
return self.listitem.get_item()
|
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):
|
class InscriptionRow(ListRow):
|
||||||
"""A ListRow for displaying Gtk.Inscription widgets."""
|
"""A ListRow for displaying Gtk.Inscription widgets."""
|
||||||
|
|
|
@ -17,13 +17,16 @@ gi.importlib.import_module("gi.repository.Gtk")
|
||||||
gi.importlib.import_module("gi.repository.Gst").init(sys.argv)
|
gi.importlib.import_module("gi.repository.Gst").init(sys.argv)
|
||||||
|
|
||||||
DEBUG_STR = "-debug" if __debug__ else ""
|
DEBUG_STR = "-debug" if __debug__ else ""
|
||||||
APPLICATION_ID = f"com.nowheycreamery.emmental{'-debug' if __debug__ else ''}"
|
APPLICATION_ID = f"com.nowheycreamery.emmental{DEBUG_STR}"
|
||||||
|
|
||||||
CSS_FILE = pathlib.Path(__file__).parent / "emmental.css"
|
CSS_FILE = pathlib.Path(__file__).parent / "emmental.css"
|
||||||
CSS_PRIORITY = gi.repository.Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
CSS_PRIORITY = gi.repository.Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
||||||
CSS_PROVIDER = gi.repository.Gtk.CssProvider()
|
CSS_PROVIDER = gi.repository.Gtk.CssProvider()
|
||||||
CSS_PROVIDER.load_from_path(str(CSS_FILE))
|
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"))
|
DATA_DIR = pathlib.Path(xdg.BaseDirectory.save_data_path("emmental"))
|
||||||
|
|
||||||
RESOURCE_PATH = "/com/nowheycreamery/emmental"
|
RESOURCE_PATH = "/com/nowheycreamery/emmental"
|
||||||
|
@ -40,6 +43,13 @@ def add_style():
|
||||||
CSS_PROVIDER, CSS_PRIORITY)
|
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):
|
def __version_string(subsystem, major, minor, micro):
|
||||||
return f" ⋅ {subsystem} {major}.{minor}.{micro}"
|
return f" ⋅ {subsystem} {major}.{minor}.{micro}"
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ from ..action import ActionEntry
|
||||||
from .. import db
|
from .. import db
|
||||||
from .. import buttons
|
from .. import buttons
|
||||||
from .. import gsetup
|
from .. import gsetup
|
||||||
|
from . import listenbrainz
|
||||||
from . import open
|
from . import open
|
||||||
from . import replaygain
|
from . import replaygain
|
||||||
from . import volume
|
from . import volume
|
||||||
|
@ -34,6 +35,8 @@ class Header(Gtk.HeaderBar):
|
||||||
sql = GObject.Property(type=db.Connection)
|
sql = GObject.Property(type=db.Connection)
|
||||||
title = GObject.Property(type=str)
|
title = GObject.Property(type=str)
|
||||||
subtitle = 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_enabled = GObject.Property(type=bool, default=False)
|
||||||
bg_volume = GObject.Property(type=float, default=0.5)
|
bg_volume = GObject.Property(type=float, default=0.5)
|
||||||
rg_enabled = GObject.Property(type=bool, default=False)
|
rg_enabled = GObject.Property(type=bool, default=False)
|
||||||
|
@ -43,9 +46,27 @@ class Header(Gtk.HeaderBar):
|
||||||
def __init__(self, sql: db.Connection, title: str):
|
def __init__(self, sql: db.Connection, title: str):
|
||||||
"""Initialize the HeaderBar."""
|
"""Initialize the HeaderBar."""
|
||||||
super().__init__(title=title, subtitle=SUBTITLE, sql=sql)
|
super().__init__(title=title, subtitle=SUBTITLE, sql=sql)
|
||||||
self._open = open.Button()
|
|
||||||
self._title = Adw.WindowTitle(title=self.title, subtitle=self.subtitle,
|
self._title = Adw.WindowTitle(title=self.title, subtitle=self.subtitle,
|
||||||
tooltip_text=gsetup.env_string())
|
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 = volume.VolumeRow()
|
||||||
self._volume_icon = Gtk.Image(icon_name=_volume_icon(self.volume))
|
self._volume_icon = Gtk.Image(icon_name=_volume_icon(self.volume))
|
||||||
self._background = volume.BackgroundRow()
|
self._background = volume.BackgroundRow()
|
||||||
|
@ -56,18 +77,21 @@ class Header(Gtk.HeaderBar):
|
||||||
self._icons.append(self._volume_icon)
|
self._icons.append(self._volume_icon)
|
||||||
self._icons.append(self._background_icon)
|
self._icons.append(self._background_icon)
|
||||||
|
|
||||||
self._box = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
|
self._vol_box = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
|
||||||
self._box.add_css_class("boxed-list")
|
self._vol_box.add_css_class("boxed-list")
|
||||||
self._box.append(self._volume)
|
self._vol_box.append(self._volume)
|
||||||
self._box.append(self._background)
|
self._vol_box.append(self._background)
|
||||||
self._box.append(self._replaygain)
|
self._vol_box.append(self._replaygain)
|
||||||
|
|
||||||
self._button = buttons.PopoverButton(popover_child=self._box,
|
self._vol_button = buttons.PopoverButton(popover_child=self._vol_box,
|
||||||
child=self._icons,
|
child=self._icons,
|
||||||
has_frame=False, margin_end=6)
|
has_frame=False, margin_end=6)
|
||||||
|
|
||||||
self.bind_property("title", self._title, "title")
|
self.bind_property("title", self._title, "title")
|
||||||
self.bind_property("subtitle", self._title, "subtitle")
|
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",
|
self.bind_property("bg-enabled", self._background, "enabled",
|
||||||
GObject.BindingFlags.BIDIRECTIONAL)
|
GObject.BindingFlags.BIDIRECTIONAL)
|
||||||
self.bind_property("bg-volume", self._background, "volume",
|
self.bind_property("bg-volume", self._background, "volume",
|
||||||
|
@ -79,18 +103,15 @@ class Header(Gtk.HeaderBar):
|
||||||
self.bind_property("volume", self._volume, "volume",
|
self.bind_property("volume", self._volume, "volume",
|
||||||
GObject.BindingFlags.BIDIRECTIONAL)
|
GObject.BindingFlags.BIDIRECTIONAL)
|
||||||
|
|
||||||
self.pack_start(self._open)
|
self.pack_start(self._show_sidebar)
|
||||||
if __debug__:
|
self.pack_start(self._menu_button)
|
||||||
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._button)
|
self.pack_end(self._vol_button)
|
||||||
self.set_title_widget(self._title)
|
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._open.connect("track-requested", self.__track_requested)
|
||||||
|
self._listenbrainz.connect("apply", self.__listenbrainz_apply)
|
||||||
self.connect("notify", self.__notify)
|
self.connect("notify", self.__notify)
|
||||||
|
|
||||||
def __run_settings(self, button: Gtk.Button) -> None:
|
def __run_settings(self, button: Gtk.Button) -> None:
|
||||||
|
@ -113,22 +134,47 @@ class Header(Gtk.HeaderBar):
|
||||||
status = (f"volume: {round(self.volume * 100)}%\n"
|
status = (f"volume: {round(self.volume * 100)}%\n"
|
||||||
f"background listening: {bg_status}\n"
|
f"background listening: {bg_status}\n"
|
||||||
f"normalizing: {rg_status}")
|
f"normalizing: {rg_status}")
|
||||||
self._button.set_tooltip_text(status)
|
self._vol_button.set_tooltip_text(status)
|
||||||
|
|
||||||
def __track_requested(self, button: open.Button,
|
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,
|
||||||
path: pathlib.Path) -> None:
|
path: pathlib.Path) -> None:
|
||||||
self.emit("track-requested", path)
|
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
|
@property
|
||||||
def accelerators(self) -> list[ActionEntry]:
|
def accelerators(self) -> list[ActionEntry]:
|
||||||
"""Get a list of accelerators for the Header."""
|
"""Get a list of accelerators for the Header."""
|
||||||
res = [ActionEntry("open-file", self._open.activate, "<Control>o"),
|
res = [ActionEntry("open-file", self._open.activate, "<Control>o"),
|
||||||
ActionEntry("decrease-volume", self._volume.decrement,
|
ActionEntry("decrease-volume", self._volume.decrement,
|
||||||
"<Control>Down"),
|
"<Shift><Control>Down"),
|
||||||
ActionEntry("increase-volume", self._volume.increment,
|
ActionEntry("increase-volume", self._volume.increment,
|
||||||
"<Control>Up"),
|
"<Shift><Control>Up"),
|
||||||
ActionEntry("toggle-bg-mode", self._background.activate,
|
ActionEntry("toggle-bg-mode", self._background.activate,
|
||||||
"<Shift><Control>b")]
|
"<Shift><Control>b"),
|
||||||
|
ActionEntry("toggle-sidebar", self._show_sidebar.activate,
|
||||||
|
"<Control>bracketright")]
|
||||||
if __debug__:
|
if __debug__:
|
||||||
res.append(ActionEntry("edit-settings", self._settings.activate,
|
res.append(ActionEntry("edit-settings", self._settings.activate,
|
||||||
"<Shift><Control>s"))
|
"<Shift><Control>s"))
|
||||||
|
|
14
emmental/header/listenbrainz.py
Normal file
14
emmental/header/listenbrainz.py
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
# 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
|
|
@ -1,19 +1,21 @@
|
||||||
# Copyright 2023 (c) Anna Schumaker.
|
# Copyright 2023 (c) Anna Schumaker.
|
||||||
"""A custom Button that opens a FileDialog to select a file for playback."""
|
"""A custom Adw.ActionRow to select a file for playback."""
|
||||||
import pathlib
|
import pathlib
|
||||||
from gi.repository import GObject
|
from gi.repository import GObject
|
||||||
from gi.repository import GLib
|
from gi.repository import GLib
|
||||||
from gi.repository import Gio
|
from gi.repository import Gio
|
||||||
from gi.repository import Gtk
|
from gi.repository import Gtk
|
||||||
|
from gi.repository import Adw
|
||||||
|
|
||||||
|
|
||||||
class Button(Gtk.Button):
|
class OpenRow(Adw.ActionRow):
|
||||||
"""Our pre-configured open button."""
|
"""Our pre-configured open Adw.ActionRow."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize our open button."""
|
"""Initialize our open ActionRow."""
|
||||||
super().__init__(icon_name="document-open-symbolic",
|
super().__init__(activatable=True, title="Open File",
|
||||||
tooltip_text="open a file for playback")
|
subtitle="Select a file for playback")
|
||||||
|
self._prefix = Gtk.Image(icon_name="document-open-symbolic")
|
||||||
self._filters = Gio.ListStore()
|
self._filters = Gio.ListStore()
|
||||||
self._filter = Gtk.FileFilter(name="Audio Files",
|
self._filter = Gtk.FileFilter(name="Audio Files",
|
||||||
mime_types=["inode/directory",
|
mime_types=["inode/directory",
|
||||||
|
@ -23,6 +25,9 @@ class Button(Gtk.Button):
|
||||||
|
|
||||||
self._filters.append(self._filter)
|
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:
|
def __async_ready(self, dialog: Gtk.FileDialog, task: Gio.Task) -> None:
|
||||||
try:
|
try:
|
||||||
file = dialog.open_finish(task)
|
file = dialog.open_finish(task)
|
||||||
|
@ -30,8 +35,9 @@ class Button(Gtk.Button):
|
||||||
except GLib.Error:
|
except GLib.Error:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def do_clicked(self) -> None:
|
def __on_activated(self, row: Adw.ActionRow) -> None:
|
||||||
"""Handle a click event."""
|
"""Handle activating an OpenRow."""
|
||||||
|
self.get_ancestor(Gtk.Popover).popdown()
|
||||||
self._dialog.open(self.get_ancestor(Gtk.Window), None,
|
self._dialog.open(self.get_ancestor(Gtk.Window), None,
|
||||||
self.__async_ready)
|
self.__async_ready)
|
||||||
|
|
||||||
|
|
|
@ -64,3 +64,21 @@ class Window(Adw.Window):
|
||||||
|
|
||||||
def __filter(self, entry: entry.Filter) -> None:
|
def __filter(self, entry: entry.Filter) -> None:
|
||||||
self._selection.get_model().filter(entry.get_query())
|
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()
|
||||||
|
|
61
emmental/layout.py
Normal file
61
emmental/layout.py
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
# 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)]
|
114
emmental/listenbrainz/__init__.py
Normal file
114
emmental/listenbrainz/__init__.py
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
# 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()
|
28
emmental/listenbrainz/listen.py
Normal file
28
emmental/listenbrainz/listen.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
# 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()
|
31
emmental/listenbrainz/task.py
Normal file
31
emmental/listenbrainz/task.py
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
# 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",)
|
96
emmental/listenbrainz/thread.py
Normal file
96
emmental/listenbrainz/thread.py
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
# 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)
|
|
@ -2,10 +2,11 @@
|
||||||
"""Implement the MPRIS2 Specification."""
|
"""Implement the MPRIS2 Specification."""
|
||||||
from gi.repository import GObject
|
from gi.repository import GObject
|
||||||
from gi.repository import Gio
|
from gi.repository import Gio
|
||||||
|
from .. import gsetup
|
||||||
from . import application
|
from . import application
|
||||||
from . import player
|
from . import player
|
||||||
|
|
||||||
MPRIS2_ID = f"org.mpris.MediaPlayer2.emmental{'-debug' if __debug__ else ''}"
|
MPRIS2_ID = f"org.mpris.MediaPlayer2.emmental{gsetup.DEBUG_STR}"
|
||||||
|
|
||||||
|
|
||||||
class Connection(GObject.GObject):
|
class Connection(GObject.GObject):
|
||||||
|
|
|
@ -46,10 +46,10 @@ class Card(Gtk.Box):
|
||||||
large_icon=True,
|
large_icon=True,
|
||||||
has_frame=False, sensitive=False,
|
has_frame=False, sensitive=False,
|
||||||
valign=Gtk.Align.CENTER)
|
valign=Gtk.Align.CENTER)
|
||||||
self._jump = buttons.Button(icon_name="go-jump", has_frame=False,
|
self._jump = buttons.Button(icon_name="arrow4-down-symbolic",
|
||||||
tooltip_text="scroll to current track",
|
tooltip_text="scroll to current track",
|
||||||
large_icon=True, sensitive=False,
|
large_icon=True, sensitive=False,
|
||||||
valign=Gtk.Align.CENTER)
|
has_frame=False, valign=Gtk.Align.CENTER)
|
||||||
self._seeker = seeker.Scale(sensitive=False)
|
self._seeker = seeker.Scale(sensitive=False)
|
||||||
|
|
||||||
self.bind_property("artwork", self._artwork, "filepath")
|
self.bind_property("artwork", self._artwork, "filepath")
|
||||||
|
|
|
@ -6,7 +6,7 @@ Version = GLib.OptionEntry()
|
||||||
Version.long_name = "version"
|
Version.long_name = "version"
|
||||||
Version.short_name = ord("v")
|
Version.short_name = ord("v")
|
||||||
Version.flags = GLib.OptionFlags.NONE
|
Version.flags = GLib.OptionFlags.NONE
|
||||||
Version.arg = GLib.OptionArg.NONE
|
# Version.arg = GLib.OptionArg.NONE
|
||||||
Version.arg_data = None
|
Version.arg_data = None
|
||||||
Version.description = "Print version information and exit"
|
Version.description = "Print version information and exit"
|
||||||
Version.arg_description = None
|
Version.arg_description = None
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
# Copyright 2022 (c) Anna Schumaker.
|
# Copyright 2022 (c) Anna Schumaker.
|
||||||
"""A card for displaying the list of playlists."""
|
"""A card for displaying the list of playlists."""
|
||||||
from gi.repository import GObject
|
from gi.repository import GObject
|
||||||
|
from gi.repository import GLib
|
||||||
from gi.repository import Gtk
|
from gi.repository import Gtk
|
||||||
from . import artist
|
from . import artist
|
||||||
from . import decade
|
from . import decade
|
||||||
|
@ -8,6 +9,7 @@ from . import genre
|
||||||
from . import library
|
from . import library
|
||||||
from . import playlist
|
from . import playlist
|
||||||
from . import section
|
from . import section
|
||||||
|
from ..action import ActionEntry
|
||||||
from .. import db
|
from .. import db
|
||||||
from .. import entry
|
from .. import entry
|
||||||
|
|
||||||
|
@ -23,58 +25,93 @@ class Card(Gtk.Box):
|
||||||
"""Set up the Sidebar widget."""
|
"""Set up the Sidebar widget."""
|
||||||
super().__init__(sql=sql, orientation=Gtk.Orientation.VERTICAL,
|
super().__init__(sql=sql, orientation=Gtk.Orientation.VERTICAL,
|
||||||
sensitive=False, **kwargs)
|
sensitive=False, **kwargs)
|
||||||
self._filter = entry.Filter("playlists")
|
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._playlists = playlist.Section(self.sql.playlists)
|
self._playlists = playlist.Section(self.sql.playlists)
|
||||||
self._artists = artist.Section(self.sql.artists, self.sql.albums)
|
self._artists = artist.Section(self.sql.artists, self.sql.albums)
|
||||||
self._genres = genre.Section(self.sql.genres)
|
self._genres = genre.Section(self.sql.genres)
|
||||||
self._decades = decade.Section(self.sql.decades, self.sql.years)
|
self._decades = decade.Section(self.sql.decades, self.sql.years)
|
||||||
self._libraries = library.Section(self.sql.libraries)
|
self._libraries = library.Section(self.sql.libraries)
|
||||||
self._group = section.Group(sql)
|
self._view = section.View(sql)
|
||||||
|
|
||||||
self.append(self._filter)
|
self._header.set_center_widget(self._filter)
|
||||||
|
self._header.set_end_widget(self._jump)
|
||||||
|
self.append(self._header)
|
||||||
|
|
||||||
for sect in [self._playlists, self._artists, self._genres,
|
for sect in [self._playlists, self._artists, self._genres,
|
||||||
self._decades, self._libraries]:
|
self._decades, self._libraries]:
|
||||||
self.append(sect)
|
self._view.add(sect)
|
||||||
self._group.add(sect)
|
self.append(self._view)
|
||||||
|
|
||||||
self._group.bind_property("selected-playlist",
|
self._view.bind_property("selected-playlist",
|
||||||
self, "selected-playlist")
|
self, "selected-playlist")
|
||||||
self.bind_property("show-all-artists", self._artists, "show-all",
|
self.bind_property("show-all-artists", self._artists, "show-all",
|
||||||
GObject.BindingFlags.BIDIRECTIONAL)
|
GObject.BindingFlags.BIDIRECTIONAL)
|
||||||
|
|
||||||
self._filter.connect("search-changed", self.__search_changed)
|
self._filter.connect("search-changed", self.__search_changed)
|
||||||
self.sql.connect("table-loaded", self.__table_loaded)
|
self._jump.connect("clicked", self.__jump_to_playlist)
|
||||||
|
self.sql.connect("notify::loaded", self.__database_loaded)
|
||||||
|
|
||||||
self.add_css_class("background")
|
self._header.add_css_class("toolbar")
|
||||||
self.add_css_class("linked")
|
|
||||||
self.add_css_class("card")
|
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:
|
def __search_changed(self, entry: entry.Filter) -> None:
|
||||||
self.sql.filter(entry.get_query())
|
self.sql.filter(entry.get_query())
|
||||||
|
|
||||||
def __table_loaded(self, sql: db.Connection, table: db.table.Table):
|
def __database_loaded(self, sql: db.Connection, param: GObject.ParamSpec):
|
||||||
if self.get_sensitive() is False:
|
self.set_sensitive(sql.loaded)
|
||||||
if False not in {tbl.loaded for tbl in sql.playlist_tables()}:
|
if sql.loaded is True:
|
||||||
self.set_sensitive(True)
|
self.select_playlist(sql.active_playlist, 150)
|
||||||
self.select_playlist(sql.active_playlist)
|
|
||||||
if len(sql.libraries) == 0:
|
if len(sql.libraries) == 0:
|
||||||
self._libraries.extra_widget.emit("clicked")
|
self._libraries.extra_widget.emit("clicked")
|
||||||
|
|
||||||
def select_playlist(self, playlist: db.playlist.Playlist) -> None:
|
def __select_playlist(self, playlist: db.playlist.Playlist) -> bool:
|
||||||
"""Set the current active playlist."""
|
|
||||||
if playlist is not None:
|
if playlist is not None:
|
||||||
match playlist.table:
|
section = self.table_section(playlist.table)
|
||||||
case self.sql.playlists:
|
if not section.active:
|
||||||
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
|
|
||||||
|
|
||||||
section.active = True
|
section.active = True
|
||||||
|
return GLib.SOURCE_CONTINUE
|
||||||
section.select_playlist(playlist)
|
section.select_playlist(playlist)
|
||||||
|
return GLib.SOURCE_REMOVE
|
||||||
|
|
||||||
|
def select_playlist(self, playlist: db.playlist.Playlist,
|
||||||
|
timeout: int = 0) -> None:
|
||||||
|
"""Set the current active playlist."""
|
||||||
|
GLib.timeout_add(timeout, self.__select_playlist, playlist)
|
||||||
|
|
||||||
|
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"))]
|
||||||
|
|
|
@ -55,7 +55,7 @@ class Header(Gtk.Box):
|
||||||
self.bind_property("reveal-widget", self._revealer, "child")
|
self.bind_property("reveal-widget", self._revealer, "child")
|
||||||
self.bind_property("animation", self._revealer, "transition-type")
|
self.bind_property("animation", self._revealer, "transition-type")
|
||||||
|
|
||||||
self._clicked.connect("released", self.__clicked)
|
self._clicked.connect("released", self.activate)
|
||||||
self.connect("notify::active", self.__notify_active)
|
self.connect("notify::active", self.__notify_active)
|
||||||
|
|
||||||
self._box.append(self._icon)
|
self._box.append(self._icon)
|
||||||
|
@ -70,12 +70,12 @@ class Header(Gtk.Box):
|
||||||
self.append(self._overlay)
|
self.append(self._overlay)
|
||||||
self.append(self._revealer)
|
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:
|
def __notify_active(self, header, param) -> None:
|
||||||
if self.active:
|
if self.active:
|
||||||
self._arrow.set_state_flags(Gtk.StateFlags.CHECKED, False)
|
self._arrow.set_state_flags(Gtk.StateFlags.CHECKED, False)
|
||||||
else:
|
else:
|
||||||
self._arrow.unset_state_flags(Gtk.StateFlags.CHECKED)
|
self._arrow.unset_state_flags(Gtk.StateFlags.CHECKED)
|
||||||
|
|
||||||
|
def activate(self, *args) -> None:
|
||||||
|
"""Activate the Header."""
|
||||||
|
self.active = True
|
||||||
|
|
|
@ -4,9 +4,9 @@ import pathlib
|
||||||
from gi.repository import GObject
|
from gi.repository import GObject
|
||||||
from gi.repository import GLib
|
from gi.repository import GLib
|
||||||
from gi.repository import Gio
|
from gi.repository import Gio
|
||||||
from gi.repository import Gdk
|
|
||||||
from gi.repository import Gtk
|
from gi.repository import Gtk
|
||||||
from gi.repository import Adw
|
from gi.repository import Adw
|
||||||
|
from .. import texture
|
||||||
|
|
||||||
|
|
||||||
IMAGE_FILTERS = Gio.ListStore()
|
IMAGE_FILTERS = Gio.ListStore()
|
||||||
|
@ -37,11 +37,7 @@ class Icon(Adw.Bin):
|
||||||
self.set_child(self._icon)
|
self.set_child(self._icon)
|
||||||
|
|
||||||
def __notify_filepath(self, icon: Adw.Bin, param) -> None:
|
def __notify_filepath(self, icon: Adw.Bin, param) -> None:
|
||||||
if self.filepath is None:
|
self._icon.set_custom_image(texture.CACHE[self.filepath])
|
||||||
texture = None
|
|
||||||
else:
|
|
||||||
texture = Gdk.Texture.new_from_filename(str(self.filepath))
|
|
||||||
self._icon.set_custom_image(texture)
|
|
||||||
|
|
||||||
|
|
||||||
class Settable(Icon):
|
class Settable(Icon):
|
||||||
|
@ -61,7 +57,9 @@ class Settable(Icon):
|
||||||
def __async_ready(self, dialog: Gtk.FileDialog, task: Gio.Task) -> None:
|
def __async_ready(self, dialog: Gtk.FileDialog, task: Gio.Task) -> None:
|
||||||
try:
|
try:
|
||||||
file = dialog.open_finish(task)
|
file = dialog.open_finish(task)
|
||||||
self.filepath = pathlib.Path(file.get_path())
|
path = pathlib.Path(file.get_path())
|
||||||
|
texture.CACHE.drop(path)
|
||||||
|
self.filepath = path
|
||||||
except GLib.Error:
|
except GLib.Error:
|
||||||
self.filepath = None
|
self.filepath = None
|
||||||
|
|
||||||
|
|
|
@ -39,7 +39,7 @@ class Section(section.Section):
|
||||||
|
|
||||||
def __init__(self, table=db.libraries.Table):
|
def __init__(self, table=db.libraries.Table):
|
||||||
"""Initialize our library path section."""
|
"""Initialize our library path section."""
|
||||||
super().__init__(table, LibraryRow, icon_name="library-music",
|
super().__init__(table, LibraryRow, icon_name="library-music-symbolic",
|
||||||
title="Library Paths", subtitle="0 library paths")
|
title="Library Paths", subtitle="0 library paths")
|
||||||
self.extra_widget = Gtk.Button(icon_name="folder-new", has_frame=False,
|
self.extra_widget = Gtk.Button(icon_name="folder-new", has_frame=False,
|
||||||
tooltip_text="add new library path")
|
tooltip_text="add new library path")
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
"""A sidebar Header attached to a hidden ListView for selecting playlists."""
|
"""A sidebar Header attached to a hidden ListView for selecting playlists."""
|
||||||
import typing
|
import typing
|
||||||
from gi.repository import GObject
|
from gi.repository import GObject
|
||||||
from gi.repository import GLib
|
|
||||||
from gi.repository import Gtk
|
from gi.repository import Gtk
|
||||||
from .. import db
|
from .. import db
|
||||||
from .. import factory
|
from .. import factory
|
||||||
|
@ -86,9 +85,7 @@ class Section(header.Header):
|
||||||
def select_playlist(self, playlist: db.playlist.Playlist) -> None:
|
def select_playlist(self, playlist: db.playlist.Playlist) -> None:
|
||||||
"""Select the requested playlist."""
|
"""Select the requested playlist."""
|
||||||
if (index := self.playlist_index(playlist)) is not None:
|
if (index := self.playlist_index(playlist)) is not None:
|
||||||
self._selection.select_item(index, True)
|
self._listview.scroll_to(index, Gtk.ListScrollFlags.SELECT)
|
||||||
self._listview.activate_action("list.scroll-to-item",
|
|
||||||
GLib.Variant.new_uint32(index))
|
|
||||||
|
|
||||||
@GObject.Signal(arg_types=(db.playlist.Playlist,))
|
@GObject.Signal(arg_types=(db.playlist.Playlist,))
|
||||||
def playlist_activated(self, playlist: db.playlist.Playlist):
|
def playlist_activated(self, playlist: db.playlist.Playlist):
|
||||||
|
@ -99,8 +96,8 @@ class Section(header.Header):
|
||||||
"""Signal that the selected playlist has changed."""
|
"""Signal that the selected playlist has changed."""
|
||||||
|
|
||||||
|
|
||||||
class Group(GObject.GObject):
|
class View(Gtk.Box):
|
||||||
"""A group of sections."""
|
"""A widget for displaying a group of sections."""
|
||||||
|
|
||||||
sql = GObject.Property(type=db.Connection)
|
sql = GObject.Property(type=db.Connection)
|
||||||
current = GObject.Property(type=Section)
|
current = GObject.Property(type=Section)
|
||||||
|
@ -108,8 +105,8 @@ class Group(GObject.GObject):
|
||||||
selected_playlist = GObject.Property(type=db.playlist.Playlist)
|
selected_playlist = GObject.Property(type=db.playlist.Playlist)
|
||||||
|
|
||||||
def __init__(self, sql: db.Connection):
|
def __init__(self, sql: db.Connection):
|
||||||
"""Initialize a Section Group."""
|
"""Initialize a Section View."""
|
||||||
super().__init__(sql=sql)
|
super().__init__(sql=sql, orientation=Gtk.Orientation.VERTICAL)
|
||||||
self._sections = []
|
self._sections = []
|
||||||
|
|
||||||
def __on_active(self, section: Section, param: GObject.ParamSpec) -> None:
|
def __on_active(self, section: Section, param: GObject.ParamSpec) -> None:
|
||||||
|
@ -145,6 +142,7 @@ class Group(GObject.GObject):
|
||||||
def add(self, section: Section) -> None:
|
def add(self, section: Section) -> None:
|
||||||
"""Add a section to the group."""
|
"""Add a section to the group."""
|
||||||
self._sections.append(section)
|
self._sections.append(section)
|
||||||
|
self.append(section)
|
||||||
section.connect("notify::active", self.__on_active)
|
section.connect("notify::active", self.__on_active)
|
||||||
section.connect("playlist-activated", self.__playlist_activated)
|
section.connect("playlist-activated", self.__playlist_activated)
|
||||||
section.connect("playlist-selected", self.__playlist_selected)
|
section.connect("playlist-selected", self.__playlist_selected)
|
||||||
|
|
76
emmental/texture.py
Normal file
76
emmental/texture.py
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
# 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()
|
94
emmental/thread.py
Normal file
94
emmental/thread.py
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
# 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()
|
|
@ -4,12 +4,14 @@ from gi.repository import GObject
|
||||||
from gi.repository import GLib
|
from gi.repository import GLib
|
||||||
from gi.repository import Gio
|
from gi.repository import Gio
|
||||||
from gi.repository import Gtk
|
from gi.repository import Gtk
|
||||||
|
from ..action import ActionEntry
|
||||||
from ..playlist.playlist import Playlist
|
from ..playlist.playlist import Playlist
|
||||||
from ..playlist.previous import Previous
|
from ..playlist.previous import Previous
|
||||||
from .. import db
|
from .. import db
|
||||||
from .. import entry
|
from .. import entry
|
||||||
from . import buttons
|
from . import buttons
|
||||||
from . import footer
|
from . import footer
|
||||||
|
from . import selection
|
||||||
from . import trackview
|
from . import trackview
|
||||||
|
|
||||||
|
|
||||||
|
@ -21,23 +23,24 @@ class Card(Gtk.Box):
|
||||||
def __init__(self, sql: db.Connection, **kwargs):
|
def __init__(self, sql: db.Connection, **kwargs):
|
||||||
"""Set up the Tracklist widget."""
|
"""Set up the Tracklist widget."""
|
||||||
super().__init__(sql=sql, orientation=Gtk.Orientation.VERTICAL,
|
super().__init__(sql=sql, orientation=Gtk.Orientation.VERTICAL,
|
||||||
spacing=6, **kwargs)
|
**kwargs)
|
||||||
self._top_left = Gtk.Box()
|
self._top_left = Gtk.Box()
|
||||||
self._top_right = Gtk.Box(sensitive=False)
|
self._top_right = Gtk.Box(sensitive=False)
|
||||||
self._top_box = Gtk.CenterBox(margin_top=6, margin_start=6,
|
self._top_box = Gtk.CenterBox(margin_start=6, margin_end=6)
|
||||||
margin_end=6)
|
|
||||||
self._filter = entry.Filter("tracks", hexpand=True,
|
self._filter = entry.Filter("tracks", hexpand=True,
|
||||||
margin_start=100, margin_end=100)
|
margin_start=100, margin_end=100)
|
||||||
self._trackview = trackview.TrackView(sql, margin_start=6,
|
self._trackview = trackview.TrackView(sql)
|
||||||
margin_end=6)
|
self._osd = selection.OSD(sql, self._trackview.selection_model,
|
||||||
|
child=self._trackview)
|
||||||
self._visible_cols = buttons.VisibleColumns(self._trackview.columns)
|
self._visible_cols = buttons.VisibleColumns(self._trackview.columns)
|
||||||
self._unselect = Gtk.Button(icon_name="edit-select-none-symbolic",
|
self._unselect = Gtk.Button(icon_name="edit-select-none-symbolic",
|
||||||
|
tooltip_text="unselect all tracks",
|
||||||
has_frame=False, sensitive=False)
|
has_frame=False, sensitive=False)
|
||||||
self._loop = buttons.LoopButton()
|
self._loop = buttons.LoopButton()
|
||||||
self._shuffle = buttons.ShuffleButton()
|
self._shuffle = buttons.ShuffleButton()
|
||||||
self._sort = buttons.SortButton()
|
self._sort = buttons.SortButton()
|
||||||
self._footer = footer.Footer(margin_start=6, margin_end=6,
|
self._footer = footer.Footer(margin_start=6, margin_end=6,
|
||||||
margin_bottom=6)
|
margin_top=6, margin_bottom=6)
|
||||||
|
|
||||||
self._top_left.append(self._visible_cols)
|
self._top_left.append(self._visible_cols)
|
||||||
self._top_left.append(self._unselect)
|
self._top_left.append(self._unselect)
|
||||||
|
@ -51,9 +54,14 @@ class Card(Gtk.Box):
|
||||||
self._top_box.set_end_widget(self._top_right)
|
self._top_box.set_end_widget(self._top_right)
|
||||||
|
|
||||||
self.append(self._top_box)
|
self.append(self._top_box)
|
||||||
self.append(self._trackview)
|
self.append(Gtk.Separator())
|
||||||
|
self.append(self._osd)
|
||||||
|
self.append(Gtk.Separator())
|
||||||
self.append(self._footer)
|
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-tracks", self._footer, "count")
|
||||||
self._trackview.bind_property("n-selected", self._footer, "selected")
|
self._trackview.bind_property("n-selected", self._footer, "selected")
|
||||||
self._trackview.bind_property("runtime", self._footer, "runtime")
|
self._trackview.bind_property("runtime", self._footer, "runtime")
|
||||||
|
@ -62,16 +70,14 @@ class Card(Gtk.Box):
|
||||||
"sensitive")
|
"sensitive")
|
||||||
|
|
||||||
self._filter.connect("search-changed", self.__search_changed)
|
self._filter.connect("search-changed", self.__search_changed)
|
||||||
self._unselect.connect("clicked", self.__clear_selection)
|
self._unselect.connect("clicked", self._osd.clear_selection)
|
||||||
self._loop.connect("notify::state", self.__update_loop_state)
|
self._loop.connect("notify::state", self.__update_loop_state)
|
||||||
self._shuffle.connect("notify::active", self.__update_shuffle_state)
|
self._shuffle.connect("notify::active", self.__update_shuffle_state)
|
||||||
self._sort.connect("notify::sort-order", self.__update_sort_order)
|
self._sort.connect("notify::sort-order", self.__update_sort_order)
|
||||||
|
|
||||||
|
self._top_box.add_css_class("toolbar")
|
||||||
self.add_css_class("card")
|
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:
|
def __playlist_notify(self, playlist: Playlist, param) -> None:
|
||||||
match param.name:
|
match param.name:
|
||||||
case "loop":
|
case "loop":
|
||||||
|
@ -89,7 +95,7 @@ class Card(Gtk.Box):
|
||||||
self._loop.state = self.playlist.loop
|
self._loop.state = self.playlist.loop
|
||||||
self._shuffle.active = self.playlist.shuffle
|
self._shuffle.active = self.playlist.shuffle
|
||||||
self._sort.set_sort_order(self.playlist.sort_order)
|
self._sort.set_sort_order(self.playlist.sort_order)
|
||||||
self._trackview.reset_osd()
|
self._osd.reset()
|
||||||
|
|
||||||
def __update_loop_state(self, loop: buttons.LoopButton, param) -> None:
|
def __update_loop_state(self, loop: buttons.LoopButton, param) -> None:
|
||||||
if self.playlist.loop != loop.state:
|
if self.playlist.loop != loop.state:
|
||||||
|
@ -132,7 +138,24 @@ class Card(Gtk.Box):
|
||||||
self._trackview.playlist.disconnect_by_func(self.__playlist_notify)
|
self._trackview.playlist.disconnect_by_func(self.__playlist_notify)
|
||||||
|
|
||||||
self._trackview.playlist = newval
|
self._trackview.playlist = newval
|
||||||
|
self._osd.playlist = newval
|
||||||
|
|
||||||
if newval is not None:
|
if newval is not None:
|
||||||
self._top_right.set_sensitive(not isinstance(newval, Previous))
|
self._top_right.set_sensitive(not isinstance(newval, Previous))
|
||||||
self.__set_button_state()
|
self.__set_button_state()
|
||||||
newval.connect("notify", self.__playlist_notify)
|
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
|
||||||
|
|
|
@ -5,19 +5,29 @@ from gi.repository import Gio
|
||||||
from gi.repository import Gtk
|
from gi.repository import Gtk
|
||||||
from . import sorter
|
from . import sorter
|
||||||
from .. import buttons
|
from .. import buttons
|
||||||
from .. import factory
|
from .. import gsetup
|
||||||
|
|
||||||
|
|
||||||
class VisibleSwitch(factory.ListRow):
|
class VisibleRow(Gtk.ListBoxRow):
|
||||||
"""A list row containing a Gtk.Switch."""
|
"""A ListBoxRow containing a Gtk.Switch and a title Label."""
|
||||||
|
|
||||||
def __init__(self, listitem: Gtk.ListItem):
|
active = GObject.Property(type=bool, default=True)
|
||||||
"""Initialize a VisibleSwitch ListRow."""
|
title = GObject.Property(type=str)
|
||||||
super().__init__(listitem=listitem, child=Gtk.Switch())
|
|
||||||
|
|
||||||
def do_bind(self) -> None:
|
def __init__(self, title: str, active: bool):
|
||||||
"""Bind the visible property to the switch active property."""
|
"""Initialize a VisibleRow ListBoxRow."""
|
||||||
self.bind_and_set_property("visible", "active", bidirectional=True)
|
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)
|
||||||
|
|
||||||
|
|
||||||
class VisibleColumns(buttons.PopoverButton):
|
class VisibleColumns(buttons.PopoverButton):
|
||||||
|
@ -28,21 +38,21 @@ class VisibleColumns(buttons.PopoverButton):
|
||||||
def __init__(self, columns: Gio.ListModel, **kwargs):
|
def __init__(self, columns: Gio.ListModel, **kwargs):
|
||||||
"""Initialize the VisibleColumns button."""
|
"""Initialize the VisibleColumns button."""
|
||||||
super().__init__(columns=columns, icon_name="columns-symbolic",
|
super().__init__(columns=columns, icon_name="columns-symbolic",
|
||||||
|
tooltip_text="configure visible columns",
|
||||||
has_frame=False, **kwargs)
|
has_frame=False, **kwargs)
|
||||||
self._selection = Gtk.NoSelection(model=self.columns)
|
self.popover_child = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
|
||||||
self.popover_child = Gtk.ColumnView(model=self._selection,
|
self.popover_child.bind_model(columns, self.__create_func)
|
||||||
show_row_separators=True)
|
self.popover_child.connect("row-activated", self.__row_activated)
|
||||||
self.__append_column(factory.InscriptionFactory("title"),
|
self.popover_child.add_css_class("boxed-list")
|
||||||
"Column", width=125)
|
|
||||||
self.__append_column(factory.Factory(row_type=VisibleSwitch),
|
|
||||||
"Visible")
|
|
||||||
self.popover_child.add_css_class("data-table")
|
|
||||||
|
|
||||||
def __append_column(self, factory: factory.Factory,
|
def __create_func(self, column: Gtk.ColumnViewColumn) -> VisibleRow:
|
||||||
title: str, *, width: int = -1) -> None:
|
row = VisibleRow(column.get_title(), column.get_visible())
|
||||||
column = Gtk.ColumnViewColumn(factory=factory, title=title,
|
row.bind_property("active", column, "visible",
|
||||||
fixed_width=width)
|
GObject.BindingFlags.BIDIRECTIONAL)
|
||||||
self.popover_child.append_column(column)
|
return row
|
||||||
|
|
||||||
|
def __row_activated(self, box: Gtk.ListBox, row: Gtk.ListBoxRow) -> None:
|
||||||
|
row.active = not row.active
|
||||||
|
|
||||||
|
|
||||||
class LoopButton(buttons.ImageToggle):
|
class LoopButton(buttons.ImageToggle):
|
||||||
|
@ -53,7 +63,9 @@ class LoopButton(buttons.ImageToggle):
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
"""Initialize a Loop Button."""
|
"""Initialize a Loop Button."""
|
||||||
super().__init__(active_icon_name="media-playlist-repeat-song",
|
super().__init__(active_icon_name="media-playlist-repeat-song",
|
||||||
|
active_tooltip_text="loop: track",
|
||||||
inactive_icon_name="media-playlist-repeat",
|
inactive_icon_name="media-playlist-repeat",
|
||||||
|
inactive_tooltip_text="loop: disabled",
|
||||||
large_icon=False, state="None",
|
large_icon=False, state="None",
|
||||||
has_frame=False, **kwargs)
|
has_frame=False, **kwargs)
|
||||||
|
|
||||||
|
@ -79,9 +91,11 @@ class LoopButton(buttons.ImageToggle):
|
||||||
case ("None", True):
|
case ("None", True):
|
||||||
self.active = False
|
self.active = False
|
||||||
self.icon_opacity = 0.5
|
self.icon_opacity = 0.5
|
||||||
|
self.inactive_tooltip_text = "loop: disabled"
|
||||||
case ("Playlist", _):
|
case ("Playlist", _):
|
||||||
self.active = False
|
self.active = False
|
||||||
self.icon_opacity = 1.0
|
self.icon_opacity = 1.0
|
||||||
|
self.inactive_tooltip_text = "loop: playlist"
|
||||||
case ("Track", _):
|
case ("Track", _):
|
||||||
self.active = True
|
self.active = True
|
||||||
self.icon_opacity = 1.0
|
self.icon_opacity = 1.0
|
||||||
|
@ -93,90 +107,75 @@ class ShuffleButton(buttons.ImageToggle):
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
"""Initialize a Shuffle Button."""
|
"""Initialize a Shuffle Button."""
|
||||||
super().__init__(active_icon_name="media-playlist-shuffle",
|
super().__init__(active_icon_name="media-playlist-shuffle",
|
||||||
inactive_icon_name="media-playlist-consecutive",
|
active_tooltip_text="shuffle: enabled",
|
||||||
|
inactive_icon_name=self.get_inactive_icon(),
|
||||||
|
inactive_tooltip_text="shuffle: disabled",
|
||||||
large_icon=False, icon_opacity=0.5,
|
large_icon=False, icon_opacity=0.5,
|
||||||
has_frame=False, **kwargs)
|
has_frame=False, **kwargs)
|
||||||
|
|
||||||
def do_toggled(self):
|
def do_toggled(self):
|
||||||
"""Adjust opacity when active state toggles."""
|
"""Adjust opacity when active state toggles."""
|
||||||
self.icon_opacity = 1.0 if self.active else 0.5
|
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 SortFieldWidget(Gtk.Box):
|
class SortRow(Gtk.ListBoxRow):
|
||||||
"""A Widget to display in the Sort Order button popover."""
|
"""A ListBoxRow for managing Sort Order."""
|
||||||
|
|
||||||
|
active = GObject.Property(type=bool, default=False)
|
||||||
sort_field = GObject.Property(type=sorter.SortField)
|
sort_field = GObject.Property(type=sorter.SortField)
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, sort_field: sorter.SortField):
|
||||||
"""Initialize a SortField Widget."""
|
"""Initialize a Sort Row."""
|
||||||
super().__init__(spacing=6)
|
super().__init__(sort_field=sort_field, active=sort_field.enabled,
|
||||||
self._enabled = Gtk.Switch(valign=Gtk.Align.CENTER)
|
child=Gtk.Box(margin_start=6, margin_end=6,
|
||||||
self._name = Gtk.Label(hexpand=True, sensitive=False)
|
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)
|
||||||
self._reverse = buttons.ImageToggle("arrow1-up", "arrow1-down",
|
self._reverse = buttons.ImageToggle("arrow1-up", "arrow1-down",
|
||||||
large_icon=False, sensitive=False)
|
active=sort_field.reversed,
|
||||||
self._box = Gtk.Box(sensitive=False)
|
sensitive=self.active,
|
||||||
|
has_frame=False)
|
||||||
|
self._move_box = Gtk.Box(sensitive=self.active)
|
||||||
self._move_up = Gtk.Button(icon_name="go-up-symbolic")
|
self._move_up = Gtk.Button(icon_name="go-up-symbolic")
|
||||||
self._move_down = Gtk.Button(icon_name="go-down-symbolic")
|
self._move_down = Gtk.Button(icon_name="go-down-symbolic")
|
||||||
|
|
||||||
self._enabled.bind_property("active", self._name, "sensitive")
|
self._switch.connect("notify::active", self.__toggle_enabled)
|
||||||
self._enabled.bind_property("active", self._reverse, "sensitive")
|
self._reverse.connect("toggled", self.__reverse)
|
||||||
self._enabled.bind_property("active", self._box, "sensitive")
|
self._move_up.connect("clicked", self.__move_up)
|
||||||
|
self._move_down.connect("clicked", self.__move_down)
|
||||||
|
|
||||||
self._enabled.connect("notify::active", self.__notify_enabled)
|
self.props.child.append(self._switch)
|
||||||
self._reverse.connect("clicked", self.__reverse)
|
self.props.child.append(self._label)
|
||||||
self._move_up.connect("clicked", self.__move_item_up)
|
self.props.child.append(self._reverse)
|
||||||
self._move_down.connect("clicked", self.__move_item_down)
|
self.props.child.append(self._move_box)
|
||||||
|
|
||||||
self.append(self._enabled)
|
self._move_box.append(self._move_up)
|
||||||
self.append(self._name)
|
self._move_box.append(self._move_down)
|
||||||
self.append(self._reverse)
|
self._move_box.add_css_class("linked")
|
||||||
self.append(self._box)
|
|
||||||
|
|
||||||
self._box.append(self._move_up)
|
def __toggle_enabled(self, switch: Gtk.Switch, param) -> None:
|
||||||
self._box.append(self._move_down)
|
if switch.props.active:
|
||||||
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()
|
self.sort_field.enable()
|
||||||
else:
|
else:
|
||||||
self.sort_field.disable()
|
self.sort_field.disable()
|
||||||
|
|
||||||
def __reverse(self, button: buttons.ImageToggle) -> None:
|
def __reverse(self, button: buttons.ImageToggle) -> None:
|
||||||
if self.sort_field is not None:
|
|
||||||
self.sort_field.reverse()
|
self.sort_field.reverse()
|
||||||
|
|
||||||
def set_sort_field(self, field: sorter.SortField | None) -> None:
|
def __move_up(self, button: Gtk.Button) -> None:
|
||||||
"""Set the Sort Field displayed by this Widget."""
|
self.sort_field.move_up()
|
||||||
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:
|
||||||
class SortRow(factory.ListRow):
|
self.sort_field.move_down()
|
||||||
"""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):
|
class SortButton(buttons.PopoverButton):
|
||||||
|
@ -188,14 +187,24 @@ class SortButton(buttons.PopoverButton):
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
"""Initialize the Sort button."""
|
"""Initialize the Sort button."""
|
||||||
super().__init__(has_frame=False, model=sorter.SortOrderModel(),
|
super().__init__(has_frame=False, model=sorter.SortOrderModel(),
|
||||||
icon_name="view-list-ordered-symbolic", **kwargs)
|
tooltip_text="configure playlist sort order",
|
||||||
self._selection = Gtk.NoSelection(model=self.model)
|
icon_name="list-compact-symbolic", **kwargs)
|
||||||
self._factory = factory.Factory(row_type=SortRow)
|
self.popover_child = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
|
||||||
self.popover_child = Gtk.ListView(model=self._selection,
|
self.popover_child.bind_model(self.model, self.__create_func)
|
||||||
factory=self._factory,
|
self.popover_child.connect("row-activated", self.__row_activated)
|
||||||
show_separators=True)
|
self.popover_child.add_css_class("boxed-list")
|
||||||
|
|
||||||
self.model.bind_property("sort-order", self, "sort-order")
|
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:
|
def set_sort_order(self, newval: str) -> None:
|
||||||
"""Directly set the sort order."""
|
"""Directly set the sort order."""
|
||||||
self.model.set_sort_order(newval)
|
self.model.set_sort_order(newval)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
# Copyright 2022 (c) Anna Schumaker.
|
# Copyright 2022 (c) Anna Schumaker.
|
||||||
"""A Footer widget to display below the TrackView."""
|
"""A Footer widget to display below the TrackView."""
|
||||||
from gi.repository import GObject
|
from gi.repository import GObject
|
||||||
|
from gi.repository import Pango
|
||||||
from gi.repository import Gtk
|
from gi.repository import Gtk
|
||||||
|
|
||||||
|
|
||||||
|
@ -14,9 +15,11 @@ class Footer(Gtk.CenterBox):
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
"""Initialize a Footer widget."""
|
"""Initialize a Footer widget."""
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self._count = Gtk.Label(label="Showing 0 tracks", xalign=0.0)
|
self._count = Gtk.Label(label="Showing 0 tracks", xalign=0.0,
|
||||||
self._selected = Gtk.Label()
|
ellipsize=Pango.EllipsizeMode.START)
|
||||||
self._runtime = Gtk.Label(label="0 seconds", xalign=1.0)
|
self._selected = Gtk.Label(ellipsize=Pango.EllipsizeMode.MIDDLE)
|
||||||
|
self._runtime = Gtk.Label(label="0 seconds", xalign=1.0,
|
||||||
|
ellipsize=Pango.EllipsizeMode.END)
|
||||||
|
|
||||||
self.set_start_widget(self._count)
|
self.set_start_widget(self._count)
|
||||||
self.set_center_widget(self._selected)
|
self.set_center_widget(self._selected)
|
||||||
|
|
|
@ -4,10 +4,10 @@ import datetime
|
||||||
import dateutil.tz
|
import dateutil.tz
|
||||||
import pathlib
|
import pathlib
|
||||||
from gi.repository import GObject
|
from gi.repository import GObject
|
||||||
from gi.repository import Gdk
|
|
||||||
from gi.repository import Gtk
|
from gi.repository import Gtk
|
||||||
from .. import buttons
|
from .. import buttons
|
||||||
from .. import factory
|
from .. import factory
|
||||||
|
from .. import texture
|
||||||
|
|
||||||
|
|
||||||
class TrackRow(factory.ListRow):
|
class TrackRow(factory.ListRow):
|
||||||
|
@ -63,23 +63,6 @@ class TrackRow(factory.ListRow):
|
||||||
else:
|
else:
|
||||||
self.bind_album(child_prop)
|
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)
|
@GObject.Property(type=bool, default=True)
|
||||||
def online(self) -> bool:
|
def online(self) -> bool:
|
||||||
"""Get the online state of this Row."""
|
"""Get the online state of this Row."""
|
||||||
|
@ -90,6 +73,14 @@ class TrackRow(factory.ListRow):
|
||||||
self.listitem.set_activatable(newval)
|
self.listitem.set_activatable(newval)
|
||||||
self.child.set_sensitive(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):
|
class InscriptionRow(TrackRow):
|
||||||
"""Base class for Track Rows displaying a Gtk.Inscription."""
|
"""Base class for Track Rows displaying a Gtk.Inscription."""
|
||||||
|
@ -287,8 +278,6 @@ class MediumString(InscriptionRow):
|
||||||
class AlbumCover(TrackRow):
|
class AlbumCover(TrackRow):
|
||||||
"""A Track Row to display Album art."""
|
"""A Track Row to display Album art."""
|
||||||
|
|
||||||
Cache = dict()
|
|
||||||
|
|
||||||
filepath = GObject.Property(type=GObject.TYPE_PYOBJECT)
|
filepath = GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||||
|
|
||||||
def __init__(self, listitem: Gtk.ListItem, property: str):
|
def __init__(self, listitem: Gtk.ListItem, property: str):
|
||||||
|
@ -302,19 +291,14 @@ class AlbumCover(TrackRow):
|
||||||
match param.name:
|
match param.name:
|
||||||
case "mediumid": self.rebind_album("filepath", to_self=True)
|
case "mediumid": self.rebind_album("filepath", to_self=True)
|
||||||
case "filepath":
|
case "filepath":
|
||||||
if self.filepath is None:
|
tex = texture.CACHE[self.filepath]
|
||||||
texture = None
|
self.child.set_paintable(tex)
|
||||||
elif (texture := AlbumCover.Cache.get(self.filepath)) is None:
|
self.child.set_has_tooltip(tex is not 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,
|
def __query_tooltip(self, child: Gtk.Picture, x: int, y: int,
|
||||||
keyboard_mode: bool, tooltip: Gtk.Tooltip) -> bool:
|
keyboard_mode: bool, tooltip: Gtk.Tooltip) -> bool:
|
||||||
texture = AlbumCover.Cache.get(self.filepath)
|
tex = texture.CACHE[self.filepath]
|
||||||
tooltip.set_custom(Gtk.Picture.new_for_paintable(texture))
|
tooltip.set_custom(Gtk.Picture.new_for_paintable(tex))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def do_bind(self) -> None:
|
def do_bind(self) -> None:
|
||||||
|
|
|
@ -4,40 +4,39 @@ from gi.repository import GObject
|
||||||
from gi.repository import Gdk
|
from gi.repository import Gdk
|
||||||
from gi.repository import Gtk
|
from gi.repository import Gtk
|
||||||
from gi.repository import Adw
|
from gi.repository import Adw
|
||||||
|
from ..action import ActionEntry
|
||||||
from ..buttons import PopoverButton
|
from ..buttons import PopoverButton
|
||||||
from .. import db
|
from .. import db
|
||||||
from .. import factory
|
|
||||||
from .. import playlist
|
from .. import playlist
|
||||||
|
|
||||||
|
|
||||||
class PlaylistRowWidget(Gtk.Box):
|
class PlaylistRow(Gtk.ListBoxRow):
|
||||||
"""A row widget for Playlists."""
|
"""A ListBoxRow widget for Playlists."""
|
||||||
|
|
||||||
name = GObject.Property(type=str)
|
name = GObject.Property(type=str)
|
||||||
image = GObject.Property(type=GObject.TYPE_PYOBJECT)
|
image = GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, name: str, image: GObject.TYPE_PYOBJECT):
|
||||||
"""Initialize a PlaylistRowWidget."""
|
"""Initialize a PlaylistRow."""
|
||||||
super().__init__()
|
super().__init__(child=Gtk.Box(margin_start=6, margin_end=6,
|
||||||
self._icon = Adw.Avatar(size=32)
|
margin_top=6, margin_bottom=6,
|
||||||
self._label = Gtk.Label(xalign=0.0)
|
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)
|
||||||
|
|
||||||
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.connect("notify::image", self.__image_changed)
|
||||||
|
self.image = image
|
||||||
|
|
||||||
self.append(self._icon)
|
self.props.child.append(self._icon)
|
||||||
self.append(self._label)
|
self.props.child.append(self._label)
|
||||||
|
|
||||||
def __name_changed(self, row: Gtk.Box, param) -> None:
|
def __image_changed(self, row: Gtk.ListBoxRow,
|
||||||
match self.name:
|
param: GObject.ParamSpec) -> None:
|
||||||
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():
|
if self.image is not None and self.image.is_file():
|
||||||
texture = Gdk.Texture.new_from_filename(str(self.image))
|
texture = Gdk.Texture.new_from_filename(str(self.image))
|
||||||
else:
|
else:
|
||||||
|
@ -45,20 +44,6 @@ class PlaylistRowWidget(Gtk.Box):
|
||||||
self._icon.set_custom_image(texture)
|
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):
|
class UserTracksFilter(Gtk.Filter):
|
||||||
"""Filters for tracks with user-tracks set to True."""
|
"""Filters for tracks with user-tracks set to True."""
|
||||||
|
|
||||||
|
@ -77,28 +62,28 @@ class UserTracksFilter(Gtk.Filter):
|
||||||
return playlist.user_tracks and playlist != self.playlist
|
return playlist.user_tracks and playlist != self.playlist
|
||||||
|
|
||||||
|
|
||||||
class PlaylistView(Gtk.ListView):
|
class PlaylistView(Gtk.ListBox):
|
||||||
"""A ListView for selecting Playlists."""
|
"""A ListView for selecting Playlists."""
|
||||||
|
|
||||||
playlist = GObject.Property(type=db.playlist.Playlist)
|
playlist = GObject.Property(type=db.playlist.Playlist)
|
||||||
|
|
||||||
def __init__(self, sql: db.Connection):
|
def __init__(self, sql: db.Connection):
|
||||||
"""Initialize the PlaylistView."""
|
"""Initialize the PlaylistView."""
|
||||||
super().__init__(show_separators=True, single_click_activate=True)
|
super().__init__(selection_mode=Gtk.SelectionMode.NONE)
|
||||||
self._filtered = Gtk.FilterListModel(model=sql.playlists,
|
self._filtered = Gtk.FilterListModel(model=sql.playlists,
|
||||||
filter=UserTracksFilter())
|
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_property("playlist", self._filtered.get_filter(), "playlist")
|
||||||
self.add_css_class("rich-list")
|
self.bind_model(self._filtered, self.__create_func)
|
||||||
|
self.connect("row-activated", self.__row_activated)
|
||||||
|
self.add_css_class("boxed-list")
|
||||||
|
|
||||||
self.set_model(self._selection)
|
def __row_activated(self, box: Gtk.ListBox, row: PlaylistRow) -> None:
|
||||||
self.set_factory(self._factory)
|
self.emit("playlist-selected", self._filtered[row.get_index()])
|
||||||
|
|
||||||
def __playlist_activated(self, view: Gtk.ListView, position: int) -> None:
|
def __create_func(self, playlist: db.playlist.Playlist) -> PlaylistRow:
|
||||||
self.emit("playlist-selected", self._selection[position])
|
row = PlaylistRow(playlist.name, playlist.image)
|
||||||
|
playlist.bind_property("image", row, "image")
|
||||||
|
return row
|
||||||
|
|
||||||
@GObject.Signal(arg_types=(db.playlists.Playlist,))
|
@GObject.Signal(arg_types=(db.playlists.Playlist,))
|
||||||
def playlist_selected(self, playlist: db.playlists.Playlist) -> None:
|
def playlist_selected(self, playlist: db.playlists.Playlist) -> None:
|
||||||
|
@ -115,8 +100,10 @@ class MoveButtons(Gtk.Box):
|
||||||
"""Initialize the Move Buttons."""
|
"""Initialize the Move Buttons."""
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self._down = Gtk.Button(icon_name="go-down-symbolic",
|
self._down = Gtk.Button(icon_name="go-down-symbolic",
|
||||||
|
tooltip_text="move selected track down",
|
||||||
hexpand=True, sensitive=False)
|
hexpand=True, sensitive=False)
|
||||||
self._up = Gtk.Button(icon_name="go-up-symbolic",
|
self._up = Gtk.Button(icon_name="go-up-symbolic",
|
||||||
|
tooltip_text="move selected track up",
|
||||||
hexpand=True, sensitive=False)
|
hexpand=True, sensitive=False)
|
||||||
|
|
||||||
self.bind_property("can-move-down", self._down, "sensitive")
|
self.bind_property("can-move-down", self._down, "sensitive")
|
||||||
|
@ -165,12 +152,15 @@ class OSD(Gtk.Overlay):
|
||||||
super().__init__(sql=sql, selection=selection, **kwargs)
|
super().__init__(sql=sql, selection=selection, **kwargs)
|
||||||
self._add = PopoverButton(child=Adw.ButtonContent(label="Add",
|
self._add = PopoverButton(child=Adw.ButtonContent(label="Add",
|
||||||
icon_name="list-add-symbolic"),
|
icon_name="list-add-symbolic"),
|
||||||
|
tooltip_text="add selected tracks "
|
||||||
|
"to a playlist",
|
||||||
halign=Gtk.Align.START, valign=Gtk.Align.END,
|
halign=Gtk.Align.START, valign=Gtk.Align.END,
|
||||||
margin_start=16, margin_bottom=16,
|
margin_start=16, margin_bottom=16,
|
||||||
direction=Gtk.ArrowType.UP, visible=False,
|
direction=Gtk.ArrowType.UP, visible=False,
|
||||||
popover_child=PlaylistView(sql))
|
popover_child=PlaylistView(sql))
|
||||||
self._remove = Gtk.Button(child=Adw.ButtonContent(label="Remove",
|
self._remove = Gtk.Button(child=Adw.ButtonContent(label="Remove",
|
||||||
icon_name="list-remove-symbolic"),
|
icon_name="list-remove-symbolic"),
|
||||||
|
tooltip_text="remove selected tracks",
|
||||||
halign=Gtk.Align.END, valign=Gtk.Align.END,
|
halign=Gtk.Align.END, valign=Gtk.Align.END,
|
||||||
margin_end=16, margin_bottom=16,
|
margin_end=16, margin_bottom=16,
|
||||||
visible=False)
|
visible=False)
|
||||||
|
@ -268,3 +258,15 @@ class OSD(Gtk.Overlay):
|
||||||
self.__selection_changed(self.selection, 0, 0)
|
self.__selection_changed(self.selection, 0, 0)
|
||||||
if self.playlist is not None:
|
if self.playlist is not None:
|
||||||
self._add.popover_child.playlist = self.playlist.playlist
|
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"))]
|
||||||
|
|
|
@ -7,10 +7,9 @@ from .. import db
|
||||||
from .. import factory
|
from .. import factory
|
||||||
from .. import playlist
|
from .. import playlist
|
||||||
from . import row
|
from . import row
|
||||||
from . import selection
|
|
||||||
|
|
||||||
|
|
||||||
class TrackView(Gtk.Frame):
|
class TrackView(Gtk.ScrolledWindow):
|
||||||
"""A Gtk.ColumnView that has been configured to show Tracks."""
|
"""A Gtk.ColumnView that has been configured to show Tracks."""
|
||||||
|
|
||||||
playlist = GObject.Property(type=playlist.playlist.Playlist)
|
playlist = GObject.Property(type=playlist.playlist.Playlist)
|
||||||
|
@ -30,8 +29,6 @@ class TrackView(Gtk.Frame):
|
||||||
show_row_separators=True,
|
show_row_separators=True,
|
||||||
enable_rubberband=True,
|
enable_rubberband=True,
|
||||||
model=self._selection)
|
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("Art", "cover", row.AlbumCover, resizable=False)
|
||||||
self.__append_column("Fav", "favorite", row.FavoriteButton,
|
self.__append_column("Fav", "favorite", row.FavoriteButton,
|
||||||
|
@ -57,16 +54,13 @@ class TrackView(Gtk.Frame):
|
||||||
self.__append_column("Filepath", "path", row.PathString, visible=False)
|
self.__append_column("Filepath", "path", row.PathString, visible=False)
|
||||||
|
|
||||||
self.bind_property("playlist", self._filtermodel, "model")
|
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.bind_property("n-items", self, "n-tracks")
|
||||||
|
|
||||||
self._selection.connect("items-changed", self.__runtime_changed)
|
self._selection.connect("items-changed", self.__runtime_changed)
|
||||||
self._columnview.connect("activate", self.__track_activated)
|
self._columnview.connect("activate", self.__track_activated)
|
||||||
self._columnview.add_css_class("emmental-track-list")
|
self._columnview.add_css_class("emmental-track-list")
|
||||||
|
|
||||||
self.set_child(self._osd)
|
self.set_child(self._columnview)
|
||||||
|
|
||||||
def __append_column(self, title: str, property: str, row_type: type,
|
def __append_column(self, title: str, property: str, row_type: type,
|
||||||
*, width: int = -1, visible: bool = True,
|
*, width: int = -1, visible: bool = True,
|
||||||
|
@ -87,23 +81,16 @@ class TrackView(Gtk.Frame):
|
||||||
|
|
||||||
def scroll_to_track(self, track: db.tracks.Track) -> None:
|
def scroll_to_track(self, track: db.tracks.Track) -> None:
|
||||||
"""Scroll to the requested Track."""
|
"""Scroll to the requested Track."""
|
||||||
# This is a workaround until the ColumnView has better scrolling
|
for i in range(self._selection.props.n_items):
|
||||||
# support, which seems to be targeted for Gtk 4.10.
|
if self._selection[i] == track:
|
||||||
adjustment = self._scrollwin.get_vadjustment()
|
self._columnview.scroll_to(i, None, Gtk.ListScrollFlags.NONE)
|
||||||
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)
|
@GObject.Property(type=Gio.ListModel)
|
||||||
def columns(self) -> Gio.ListModel:
|
def columns(self) -> Gio.ListModel:
|
||||||
"""Get the ListModel for the columns."""
|
"""Get the ListModel for the columns."""
|
||||||
return self._columnview.get_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()
|
||||||
|
|
|
@ -4,6 +4,7 @@ from gi.repository import GObject
|
||||||
from gi.repository import Gtk
|
from gi.repository import Gtk
|
||||||
from gi.repository import Adw
|
from gi.repository import Adw
|
||||||
from .action import ActionEntry
|
from .action import ActionEntry
|
||||||
|
from . import layout
|
||||||
|
|
||||||
|
|
||||||
def _make_pane(orientation: Gtk.Orientation, position: int = 0,
|
def _make_pane(orientation: Gtk.Orientation, position: int = 0,
|
||||||
|
@ -12,7 +13,7 @@ def _make_pane(orientation: Gtk.Orientation, position: int = 0,
|
||||||
pane = Gtk.Paned(orientation=orientation, hexpand=True, vexpand=True,
|
pane = Gtk.Paned(orientation=orientation, hexpand=True, vexpand=True,
|
||||||
shrink_start_child=False, resize_start_child=False,
|
shrink_start_child=False, resize_start_child=False,
|
||||||
start_child=start_child, end_child=end_child,
|
start_child=start_child, end_child=end_child,
|
||||||
position=position)
|
position=position, margin_start=8)
|
||||||
pane.add_css_class("emmental-pane")
|
pane.add_css_class("emmental-pane")
|
||||||
return pane
|
return pane
|
||||||
|
|
||||||
|
@ -29,7 +30,7 @@ class Window(Adw.Window):
|
||||||
|
|
||||||
header = GObject.Property(type=Gtk.Widget)
|
header = GObject.Property(type=Gtk.Widget)
|
||||||
sidebar = GObject.Property(type=Gtk.Widget)
|
sidebar = GObject.Property(type=Gtk.Widget)
|
||||||
sidebar_size = GObject.Property(type=int, default=300)
|
show_sidebar = GObject.Property(type=bool, default=False)
|
||||||
now_playing = GObject.Property(type=Gtk.Widget)
|
now_playing = GObject.Property(type=Gtk.Widget)
|
||||||
now_playing_size = GObject.Property(type=int, default=250)
|
now_playing_size = GObject.Property(type=int, default=250)
|
||||||
tracklist = GObject.Property(type=Gtk.Widget)
|
tracklist = GObject.Property(type=Gtk.Widget)
|
||||||
|
@ -38,26 +39,25 @@ class Window(Adw.Window):
|
||||||
def __init__(self, version: str, **kwargs):
|
def __init__(self, version: str, **kwargs):
|
||||||
"""Initialize our Window."""
|
"""Initialize our Window."""
|
||||||
super().__init__(icon_name="emmental", title=version,
|
super().__init__(icon_name="emmental", title=version,
|
||||||
default_width=1600, default_height=900, **kwargs)
|
default_width=1600, default_height=900,
|
||||||
|
width_request=525, height_request=500, **kwargs)
|
||||||
self._box = Gtk.Box.new(Gtk.Orientation.VERTICAL, 0)
|
self._box = Gtk.Box.new(Gtk.Orientation.VERTICAL, 0)
|
||||||
self._header = Adw.Bin(child=self.header)
|
self._header = Adw.Bin(child=self.header)
|
||||||
self._inner_pane = _make_pane(Gtk.Orientation.VERTICAL,
|
self._inner_pane = _make_pane(Gtk.Orientation.VERTICAL,
|
||||||
position=self.now_playing_size,
|
position=self.now_playing_size,
|
||||||
start_child=self.now_playing,
|
start_child=self.now_playing,
|
||||||
end_child=self.tracklist)
|
end_child=self.tracklist)
|
||||||
self._outer_pane = _make_pane(Gtk.Orientation.HORIZONTAL,
|
self._layout = layout.Layout(content=self._inner_pane,
|
||||||
position=self.sidebar_size,
|
sidebar=self.sidebar)
|
||||||
start_child=self.sidebar,
|
self._toast = Adw.ToastOverlay(child=self._layout)
|
||||||
end_child=self._inner_pane)
|
|
||||||
self._toast = Adw.ToastOverlay(child=self._outer_pane)
|
|
||||||
|
|
||||||
self._outer_pane.add_css_class("emmental-padding")
|
self._layout.add_css_class("emmental-padding")
|
||||||
if __debug__:
|
if __debug__:
|
||||||
self.add_css_class("devel")
|
self.add_css_class("devel")
|
||||||
|
|
||||||
self.bind_property("header", self._header, "child")
|
self.bind_property("header", self._header, "child")
|
||||||
self.bind_property("sidebar", self._outer_pane, "start-child")
|
self.bind_property("sidebar", self._layout, "sidebar")
|
||||||
self.bind_property("sidebar-size", self._outer_pane, "position",
|
self.bind_property("show-sidebar", self._layout, "show-sidebar",
|
||||||
GObject.BindingFlags.BIDIRECTIONAL)
|
GObject.BindingFlags.BIDIRECTIONAL)
|
||||||
self.bind_property("now-playing", self._inner_pane, "start-child")
|
self.bind_property("now-playing", self._inner_pane, "start-child")
|
||||||
self.bind_property("now-playing-size", self._inner_pane, "position",
|
self.bind_property("now-playing-size", self._inner_pane, "position",
|
||||||
|
@ -66,6 +66,9 @@ class Window(Adw.Window):
|
||||||
|
|
||||||
self.connect("notify::focus-widget", self.__notify_focus_widget)
|
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._header)
|
||||||
self._box.append(self._toast)
|
self._box.append(self._toast)
|
||||||
self.set_content(self._box)
|
self.set_content(self._box)
|
||||||
|
|
2
icons/scalable/actions/arrow4-down-symbolic.svg
Normal file
2
icons/scalable/actions/arrow4-down-symbolic.svg
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?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>
|
After Width: | Height: | Size: 2.2 KiB |
2
icons/scalable/actions/list-compact-symbolic.svg
Normal file
2
icons/scalable/actions/list-compact-symbolic.svg
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?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>
|
After Width: | Height: | Size: 2.6 KiB |
49
icons/scalable/actions/listenbrainz-logo-symbolic.svg
Normal file
49
icons/scalable/actions/listenbrainz-logo-symbolic.svg
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
<?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>
|
After Width: | Height: | Size: 1.5 KiB |
47
icons/scalable/actions/listenbrainz-logo.svg
Normal file
47
icons/scalable/actions/listenbrainz-logo.svg
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
<?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>
|
After Width: | Height: | Size: 1.3 KiB |
|
@ -1,5 +1,6 @@
|
||||||
# Copyright 2022 (c) Anna Schumaker.
|
# Copyright 2022 (c) Anna Schumaker.
|
||||||
"""Tests our GObject audio player wrapping a GStreamer Playbin element."""
|
"""Tests our GObject audio player wrapping a GStreamer Playbin element."""
|
||||||
|
import datetime
|
||||||
import io
|
import io
|
||||||
import pathlib
|
import pathlib
|
||||||
import unittest
|
import unittest
|
||||||
|
@ -80,10 +81,10 @@ class TestAudio(unittest.TestCase):
|
||||||
self.player.file = tests.util.TRACK_OGG
|
self.player.file = tests.util.TRACK_OGG
|
||||||
self.player.duration = 10
|
self.player.duration = 10
|
||||||
self.player.position = 8
|
self.player.position = 8
|
||||||
self.player.playtime = 6
|
|
||||||
self.player.savedtime = 4
|
|
||||||
self.player.almost_done = True
|
self.player.almost_done = True
|
||||||
self.player.artwork = pathlib.Path("/a/b/c.jpg")
|
self.player.artwork = pathlib.Path("/a/b/c.jpg")
|
||||||
|
self.player.stopwatch.reset = unittest.mock.Mock()
|
||||||
|
self.player.stopwatch._saved = datetime.timedelta(seconds=6)
|
||||||
|
|
||||||
eos = Gst.Message.new_eos(self.player._playbin)
|
eos = Gst.Message.new_eos(self.player._playbin)
|
||||||
self.player._playbin.get_bus().post(eos)
|
self.player._playbin.get_bus().post(eos)
|
||||||
|
@ -94,9 +95,11 @@ class TestAudio(unittest.TestCase):
|
||||||
for prop in ["artist", "album-artist", "album", "title"]:
|
for prop in ["artist", "album-artist", "album", "title"]:
|
||||||
self.assertEqual(self.player.get_property(prop), "")
|
self.assertEqual(self.player.get_property(prop), "")
|
||||||
for prop in ["album-disc-number", "track-number",
|
for prop in ["album-disc-number", "track-number",
|
||||||
"position", "duration", "playtime", "savedtime"]:
|
"position", "duration"]:
|
||||||
self.assertEqual(self.player.get_property(prop), 0)
|
self.assertEqual(self.player.get_property(prop), 0)
|
||||||
self.assertIsNone(self.player.artwork)
|
self.assertIsNone(self.player.artwork)
|
||||||
|
self.player.stopwatch.reset.assert_not_called()
|
||||||
|
self.assertEqual(self.player.playtime, 6)
|
||||||
|
|
||||||
self.assertEqual(self.player.get_state(), Gst.State.READY)
|
self.assertEqual(self.player.get_state(), Gst.State.READY)
|
||||||
self.assertEqual(self.player.status, "Stopped")
|
self.assertEqual(self.player.status, "Stopped")
|
||||||
|
@ -150,22 +153,28 @@ class TestAudio(unittest.TestCase):
|
||||||
"audio: file loaded\n"
|
"audio: file loaded\n"
|
||||||
"audio: state changed to 'paused'\n")
|
"audio: state changed to 'paused'\n")
|
||||||
|
|
||||||
self.player.playtime = 6
|
self.player.stopwatch._saved = datetime.timedelta(seconds=6)
|
||||||
self.player.savedtime = 4
|
|
||||||
self.player.emit("file-loaded", tests.util.TRACK_OGG)
|
self.player.emit("file-loaded", tests.util.TRACK_OGG)
|
||||||
for prop in ["artist", "album-artist", "album", "title"]:
|
for prop in ["artist", "album-artist", "album", "title"]:
|
||||||
self.assertEqual(self.player.get_property(prop), "")
|
self.assertEqual(self.player.get_property(prop), "")
|
||||||
for prop in ["album-disc-number", "track-number",
|
for prop in ["album-disc-number", "track-number", "playtime"]:
|
||||||
"playtime", "savedtime"]:
|
|
||||||
self.assertEqual(self.player.get_property(prop), 0)
|
self.assertEqual(self.player.get_property(prop), 0)
|
||||||
|
self.assertIsNone(self.player.stopwatch._started)
|
||||||
|
|
||||||
|
self.player.playing = True
|
||||||
|
self.player.emit("file-loaded", tests.util.TRACK_OGG)
|
||||||
|
self.assertIsNotNone(self.player.stopwatch._started)
|
||||||
|
|
||||||
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
||||||
def test_play(self, mock_stdout: io.StringIO):
|
def test_play(self, mock_stdout: io.StringIO):
|
||||||
"""Test that the play() function works as expected."""
|
"""Test that the play() function works as expected."""
|
||||||
|
self.player.stopwatch.start = unittest.mock.Mock()
|
||||||
|
|
||||||
self.player.play()
|
self.player.play()
|
||||||
self.main_loop()
|
self.main_loop()
|
||||||
self.assertFalse(self.player.playing)
|
self.assertFalse(self.player.playing)
|
||||||
self.assertEqual(self.player.status, "Stopped")
|
self.assertEqual(self.player.status, "Stopped")
|
||||||
|
self.player.stopwatch.start.assert_not_called()
|
||||||
|
|
||||||
self.player.file = tests.util.TRACK_OGG
|
self.player.file = tests.util.TRACK_OGG
|
||||||
self.player.play()
|
self.player.play()
|
||||||
|
@ -175,13 +184,17 @@ class TestAudio(unittest.TestCase):
|
||||||
self.assertEqual(self.player.get_state(), Gst.State.PLAYING)
|
self.assertEqual(self.player.get_state(), Gst.State.PLAYING)
|
||||||
self.assertRegex(mock_stdout.getvalue(),
|
self.assertRegex(mock_stdout.getvalue(),
|
||||||
"audio: state changed to 'playing'$")
|
"audio: state changed to 'playing'$")
|
||||||
|
self.player.stopwatch.start.assert_called()
|
||||||
|
|
||||||
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
||||||
def test_pause(self, mock_stdout: io.StringIO):
|
def test_pause(self, mock_stdout: io.StringIO):
|
||||||
"""Test that the pause() function works as expected."""
|
"""Test that the pause() function works as expected."""
|
||||||
|
self.player.stopwatch.stop = unittest.mock.Mock()
|
||||||
|
|
||||||
self.player.pause()
|
self.player.pause()
|
||||||
self.main_loop()
|
self.main_loop()
|
||||||
self.assertEqual(self.player.status, "Stopped")
|
self.assertEqual(self.player.status, "Stopped")
|
||||||
|
self.player.stopwatch.stop.assert_not_called()
|
||||||
|
|
||||||
self.player.playing = True
|
self.player.playing = True
|
||||||
self.player.file = tests.util.TRACK_OGG
|
self.player.file = tests.util.TRACK_OGG
|
||||||
|
@ -192,6 +205,7 @@ class TestAudio(unittest.TestCase):
|
||||||
self.assertEqual(self.player.get_state(), Gst.State.PAUSED)
|
self.assertEqual(self.player.get_state(), Gst.State.PAUSED)
|
||||||
self.assertRegex(mock_stdout.getvalue(),
|
self.assertRegex(mock_stdout.getvalue(),
|
||||||
"audio: state changed to 'paused'$")
|
"audio: state changed to 'paused'$")
|
||||||
|
self.player.stopwatch.stop.assert_called()
|
||||||
|
|
||||||
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
||||||
def test_play_pause(self, mock_stdout: io.StringIO):
|
def test_play_pause(self, mock_stdout: io.StringIO):
|
||||||
|
@ -222,6 +236,7 @@ class TestAudio(unittest.TestCase):
|
||||||
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
||||||
def test_stop(self, mock_stdout: io.StringIO):
|
def test_stop(self, mock_stdout: io.StringIO):
|
||||||
"""Test that the stop() function works as expected."""
|
"""Test that the stop() function works as expected."""
|
||||||
|
self.player.stopwatch.stop = unittest.mock.Mock()
|
||||||
self.player.file = tests.util.TRACK_OGG
|
self.player.file = tests.util.TRACK_OGG
|
||||||
self.player.play()
|
self.player.play()
|
||||||
self.main_loop()
|
self.main_loop()
|
||||||
|
@ -234,6 +249,7 @@ class TestAudio(unittest.TestCase):
|
||||||
self.assertRegex(mock_stdout.getvalue(),
|
self.assertRegex(mock_stdout.getvalue(),
|
||||||
"audio: state changed to 'playing'\n"
|
"audio: state changed to 'playing'\n"
|
||||||
"audio: state changed to 'stopped'\n$")
|
"audio: state changed to 'stopped'\n$")
|
||||||
|
self.player.stopwatch.stop.assert_called()
|
||||||
|
|
||||||
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
||||||
def test_tags(self, mock_stdout: io.StringIO):
|
def test_tags(self, mock_stdout: io.StringIO):
|
||||||
|
@ -282,30 +298,14 @@ class TestAudio(unittest.TestCase):
|
||||||
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
||||||
def test_playtime(self, mock_stdout: io.StringIO):
|
def test_playtime(self, mock_stdout: io.StringIO):
|
||||||
"""Test the play time property."""
|
"""Test the play time property."""
|
||||||
|
self.assertIsInstance(self.player.stopwatch,
|
||||||
|
emmental.audio.stopwatch.StopWatch)
|
||||||
self.assertEqual(self.player.playtime, 0.0)
|
self.assertEqual(self.player.playtime, 0.0)
|
||||||
self.assertEqual(self.player.savedtime, 0.0)
|
|
||||||
self.assertEqual(self.player._Player__get_current_playtime(), 0.0)
|
|
||||||
|
|
||||||
self.player.file = tests.util.TRACK_OGG
|
with unittest.mock.patch.object(self.player.stopwatch,
|
||||||
self.player.play()
|
"elapsed_time") as mock_elapsed:
|
||||||
self.main_loop()
|
mock_elapsed.return_value = 2.0
|
||||||
self.assertIsNotNone(self.player._playbin.clock)
|
self.assertEqual(self.player.playtime, 2.0)
|
||||||
|
|
||||||
base_time = unittest.mock.PropertyMock(return_value=Gst.SECOND)
|
|
||||||
type(self.player._playbin).base_time = base_time
|
|
||||||
get_time = unittest.mock.Mock(return_value=3 * Gst.SECOND)
|
|
||||||
self.player._playbin.clock.get_time = get_time
|
|
||||||
|
|
||||||
self.assertEqual(self.player._Player__get_current_playtime(), 2.0)
|
|
||||||
self.player._Player__update_position()
|
|
||||||
self.assertEqual(self.player.playtime, 2)
|
|
||||||
|
|
||||||
with unittest.mock.patch.object(self.player._playbin, "seek_simple"):
|
|
||||||
self.player.seek(5)
|
|
||||||
self.assertEqual(self.player.savedtime, 2)
|
|
||||||
|
|
||||||
self.player._Player__update_position()
|
|
||||||
self.assertEqual(self.player.playtime, 4)
|
|
||||||
|
|
||||||
def test_volume(self):
|
def test_volume(self):
|
||||||
"""Test that the volume property works as expected."""
|
"""Test that the volume property works as expected."""
|
||||||
|
|
91
tests/audio/test_stopwatch.py
Normal file
91
tests/audio/test_stopwatch.py
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
# Copyright 2024 (c) Anna Schumaker.
|
||||||
|
"""Tests our StopWatch object."""
|
||||||
|
import datetime
|
||||||
|
import emmental.audio.stopwatch
|
||||||
|
import unittest
|
||||||
|
from gi.repository import GObject
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.mock.patch.object(emmental.audio.stopwatch, "datetime")
|
||||||
|
class TestStopwatch(unittest.TestCase):
|
||||||
|
"""Our stopwatch test case."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up common variables."""
|
||||||
|
self.now = datetime.datetime.now()
|
||||||
|
self.newdelta = datetime.timedelta
|
||||||
|
self.stopwatch = emmental.audio.stopwatch.StopWatch()
|
||||||
|
|
||||||
|
def test_init(self, mock_datetime: unittest.mock.Mock):
|
||||||
|
"""Test that the StopWatch was created properly."""
|
||||||
|
self.assertIsInstance(self.stopwatch, GObject.GObject)
|
||||||
|
|
||||||
|
def test_elapsed_time(self, mock_datetime: unittest.mock.Mock):
|
||||||
|
"""Test the elapsed_time() function."""
|
||||||
|
mock_datetime.timedelta = self.newdelta
|
||||||
|
self.assertEqual(self.stopwatch.elapsed_time(), 0.0)
|
||||||
|
|
||||||
|
mock_datetime.datetime.now.return_value = self.now
|
||||||
|
self.stopwatch.start()
|
||||||
|
|
||||||
|
soon = self.now + datetime.timedelta(seconds=12.345)
|
||||||
|
mock_datetime.datetime.now.return_value = soon
|
||||||
|
self.assertEqual(self.stopwatch.elapsed_time(), 12.345)
|
||||||
|
|
||||||
|
self.stopwatch._saved = datetime.timedelta(seconds=2)
|
||||||
|
self.assertEqual(self.stopwatch.elapsed_time(), 14.345)
|
||||||
|
|
||||||
|
self.stopwatch.stop()
|
||||||
|
self.assertEqual(self.stopwatch.elapsed_time(), 14.345)
|
||||||
|
|
||||||
|
self.stopwatch.reset(playing=False)
|
||||||
|
self.assertEqual(self.stopwatch.elapsed_time(), 0.0)
|
||||||
|
|
||||||
|
def test_reset(self, mock_datetime: unittest.mock.Mock):
|
||||||
|
"""Test resetting the StopWatch."""
|
||||||
|
mock_datetime.datetime.now.return_value = self.now
|
||||||
|
self.stopwatch.start()
|
||||||
|
soon = self.now + datetime.timedelta(seconds=12.345)
|
||||||
|
mock_datetime.datetime.now.return_value = soon
|
||||||
|
self.stopwatch.stop()
|
||||||
|
|
||||||
|
self.stopwatch.reset(playing=False)
|
||||||
|
self.assertIsNone(self.stopwatch._saved)
|
||||||
|
self.assertIsNone(self.stopwatch._started)
|
||||||
|
|
||||||
|
self.stopwatch.reset(playing=True)
|
||||||
|
self.assertIsNone(self.stopwatch._saved)
|
||||||
|
self.assertEqual(self.stopwatch._started, soon)
|
||||||
|
|
||||||
|
def test_start(self, mock_datetime: unittest.mock.Mock):
|
||||||
|
"""Test starting the StopWatch."""
|
||||||
|
self.assertIsNone(self.stopwatch._started)
|
||||||
|
|
||||||
|
mock_datetime.datetime.now.return_value = self.now
|
||||||
|
self.stopwatch.start()
|
||||||
|
self.assertEqual(self.stopwatch._started, self.now)
|
||||||
|
|
||||||
|
def test_stop(self, mock_datetime: unittest.mock.Mock):
|
||||||
|
"""Test stopping the StopWatch."""
|
||||||
|
self.assertIsNone(self.stopwatch._saved)
|
||||||
|
self.stopwatch.stop()
|
||||||
|
self.assertIsNone(self.stopwatch._saved)
|
||||||
|
|
||||||
|
mock_datetime.datetime.now.return_value = self.now
|
||||||
|
self.stopwatch.start()
|
||||||
|
delta1 = datetime.timedelta(seconds=12.345)
|
||||||
|
mock_datetime.datetime.now.return_value = self.now + delta1
|
||||||
|
self.stopwatch.stop()
|
||||||
|
self.assertEqual(self.stopwatch._saved, delta1)
|
||||||
|
self.assertIsNone(self.stopwatch._started)
|
||||||
|
|
||||||
|
now = self.now + delta1 + datetime.timedelta(seconds=2)
|
||||||
|
mock_datetime.datetime.now.return_value = now
|
||||||
|
self.stopwatch.start()
|
||||||
|
delta2 = datetime.timedelta(seconds=3)
|
||||||
|
mock_datetime.datetime.now.return_value = now + delta2
|
||||||
|
self.stopwatch.stop()
|
||||||
|
self.assertEqual(self.stopwatch._saved, delta1 + delta2)
|
||||||
|
|
||||||
|
self.stopwatch.stop()
|
||||||
|
self.assertEqual(self.stopwatch._saved, delta1 + delta2)
|
7
tests/db/test-script.sql
Normal file
7
tests/db/test-script.sql
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
/* 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);
|
60
tests/db/test_adapter.py
Normal file
60
tests/db/test_adapter.py
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
# Copyright 2024 (c) Anna Schumaker.
|
||||||
|
"""Test our custom sqlite3 adapters."""
|
||||||
|
import datetime
|
||||||
|
import emmental.db.adapter
|
||||||
|
import pathlib
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdapters(unittest.TestCase):
|
||||||
|
"""Test case for our database adapters."""
|
||||||
|
|
||||||
|
def test_date(self):
|
||||||
|
"""Test adapting and converting a date object."""
|
||||||
|
date = datetime.date(2024, 6, 5)
|
||||||
|
|
||||||
|
self.assertEqual(emmental.db.adapter.adapt_date(date),
|
||||||
|
"2024-06-05")
|
||||||
|
self.assertEqual(emmental.db.adapter.convert_date(b"2024-06-05"),
|
||||||
|
date)
|
||||||
|
|
||||||
|
def test_datetime(self):
|
||||||
|
"""Test adapting and converting a datetime object."""
|
||||||
|
dt_utc = datetime.datetime.now(datetime.UTC)
|
||||||
|
dt_naive = datetime.datetime.combine(dt_utc.date(), dt_utc.time())
|
||||||
|
iso_naive = dt_naive.isoformat(" ").encode()
|
||||||
|
|
||||||
|
self.assertEqual(emmental.db.adapter.adapt_datetime(dt_utc),
|
||||||
|
iso_naive.decode())
|
||||||
|
self.assertEqual(emmental.db.adapter.adapt_datetime(dt_naive),
|
||||||
|
iso_naive.decode())
|
||||||
|
|
||||||
|
self.assertEqual(emmental.db.adapter.convert_datetime(iso_naive),
|
||||||
|
dt_utc)
|
||||||
|
|
||||||
|
def test_path(self):
|
||||||
|
"""Test adapting and converting a pathlib.Path object."""
|
||||||
|
path = pathlib.Path("/my/test/path")
|
||||||
|
self.assertEqual(emmental.db.adapter.adapt_path(path),
|
||||||
|
"/my/test/path")
|
||||||
|
self.assertEqual(emmental.db.adapter.convert_path(b"/my/test/path"),
|
||||||
|
path)
|
||||||
|
|
||||||
|
@unittest.mock.patch("sqlite3.register_converter")
|
||||||
|
@unittest.mock.patch("sqlite3.register_adapter")
|
||||||
|
def test_register(self, register_adapter: unittest.mock.Mock,
|
||||||
|
register_converter: unittest.mock.Mock):
|
||||||
|
"""Test registering adapters and converters."""
|
||||||
|
emmental.db.adapter.register()
|
||||||
|
|
||||||
|
adapters = [(datetime.date, emmental.db.adapter.adapt_date),
|
||||||
|
(datetime.datetime, emmental.db.adapter.adapt_datetime),
|
||||||
|
(pathlib.PosixPath, emmental.db.adapter.adapt_path)]
|
||||||
|
register_adapter.assert_has_calls([unittest.mock.call(*a)
|
||||||
|
for a in adapters])
|
||||||
|
|
||||||
|
converters = [("date", emmental.db.adapter.convert_date),
|
||||||
|
("timestamp", emmental.db.adapter.convert_datetime),
|
||||||
|
("path", emmental.db.adapter.convert_path)]
|
||||||
|
register_converter.assert_has_calls([unittest.mock.call(*c)
|
||||||
|
for c in converters])
|
|
@ -41,6 +41,22 @@ class TestAlbumObject(tests.util.TestCase):
|
||||||
self.assertEqual(album2.mbid, "ab-cd-ef")
|
self.assertEqual(album2.mbid, "ab-cd-ef")
|
||||||
self.assertEqual(album2.cover, cover)
|
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):
|
def test_get_artists(self):
|
||||||
"""Test getting the list of artists for this album."""
|
"""Test getting the list of artists for this album."""
|
||||||
with unittest.mock.patch.object(self.table, "get_artists",
|
with unittest.mock.patch.object(self.table, "get_artists",
|
||||||
|
@ -56,22 +72,14 @@ class TestAlbumObject(tests.util.TestCase):
|
||||||
self.assertListEqual(self.album.get_media(), [1, 2, 3])
|
self.assertListEqual(self.album.get_media(), [1, 2, 3])
|
||||||
mock.assert_called_with(self.album)
|
mock.assert_called_with(self.album)
|
||||||
|
|
||||||
def test_media_model(self):
|
def test_children(self):
|
||||||
"""Test getting a Gio.ListModel representing this Album's media."""
|
"""Test the Album's 'children' model is set up properly."""
|
||||||
|
self.assertIsInstance(self.album.child_set,
|
||||||
|
emmental.db.table.TableSubset)
|
||||||
self.assertIsInstance(self.album.children, Gtk.FilterListModel)
|
self.assertIsInstance(self.album.children, Gtk.FilterListModel)
|
||||||
self.assertIsInstance(self.album.children.get_filter(),
|
self.assertEqual(self.album.children.get_filter(),
|
||||||
Gtk.CustomFilter)
|
self.sql.media.get_filter())
|
||||||
self.assertEqual(self.album.children.get_model(), self.sql.media)
|
self.assertEqual(self.album.child_set.table, 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):
|
class TestAlbumTable(tests.util.TestCase):
|
||||||
|
@ -228,9 +236,11 @@ class TestAlbumTable(tests.util.TestCase):
|
||||||
|
|
||||||
def test_load(self):
|
def test_load(self):
|
||||||
"""Test loading the album table."""
|
"""Test loading the album table."""
|
||||||
self.table.create("Album 1", "Album Artist", "2023-03")
|
album = self.table.create("Album 1", "Album Artist", "2023-03")
|
||||||
self.table.create("Album 2", "Album Artist", "2023-03",
|
self.table.create("Album 2", "Album Artist", "2023-03",
|
||||||
mbid="ab-cd-ef", cover=tests.util.COVER_JPG)
|
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)
|
albums2 = emmental.db.albums.Table(self.sql)
|
||||||
self.assertEqual(len(albums2), 0)
|
self.assertEqual(len(albums2), 0)
|
||||||
|
@ -243,6 +253,7 @@ class TestAlbumTable(tests.util.TestCase):
|
||||||
self.assertEqual(albums2.get_item(0).release, "2023-03")
|
self.assertEqual(albums2.get_item(0).release, "2023-03")
|
||||||
self.assertEqual(albums2.get_item(0).mbid, "")
|
self.assertEqual(albums2.get_item(0).mbid, "")
|
||||||
self.assertIsNone(albums2.get_item(0).cover)
|
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).name, "Album 2")
|
||||||
self.assertEqual(albums2.get_item(1).artist, "Album Artist")
|
self.assertEqual(albums2.get_item(1).artist, "Album Artist")
|
||||||
|
@ -250,6 +261,7 @@ class TestAlbumTable(tests.util.TestCase):
|
||||||
self.assertEqual(albums2.get_item(1).mbid, "ab-cd-ef")
|
self.assertEqual(albums2.get_item(1).mbid, "ab-cd-ef")
|
||||||
self.assertEqual(albums2.get_item(1).cover,
|
self.assertEqual(albums2.get_item(1).cover,
|
||||||
tests.util.COVER_JPG)
|
tests.util.COVER_JPG)
|
||||||
|
self.assertSetEqual(albums2.get_item(1).child_set.keyset.keys, set())
|
||||||
|
|
||||||
def test_lookup(self):
|
def test_lookup(self):
|
||||||
"""Test looking up album playlists."""
|
"""Test looking up album playlists."""
|
||||||
|
@ -320,4 +332,6 @@ class TestAlbumTable(tests.util.TestCase):
|
||||||
medium1 = self.sql.media.create(album, "", number=1)
|
medium1 = self.sql.media.create(album, "", number=1)
|
||||||
medium2 = self.sql.media.create(album, "", number=2)
|
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])
|
self.assertListEqual(self.table.get_media(album), [medium1, medium2])
|
||||||
|
|
|
@ -20,7 +20,6 @@ class TestArtistObject(tests.util.TestCase):
|
||||||
def test_init(self):
|
def test_init(self):
|
||||||
"""Test that the Artist is set up properly."""
|
"""Test that the Artist is set up properly."""
|
||||||
self.assertIsInstance(self.artist, emmental.db.playlist.Playlist)
|
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.table, self.table)
|
||||||
self.assertEqual(self.artist.propertyid, 456)
|
self.assertEqual(self.artist.propertyid, 456)
|
||||||
self.assertEqual(self.artist.artistid, 123)
|
self.assertEqual(self.artist.artistid, 123)
|
||||||
|
@ -37,8 +36,7 @@ class TestArtistObject(tests.util.TestCase):
|
||||||
self.artist.add_album(album)
|
self.artist.add_album(album)
|
||||||
|
|
||||||
mock_add.assert_called_with(self.artist, album)
|
mock_add.assert_called_with(self.artist, album)
|
||||||
self.assertSetEqual(self.artist.children.get_filter().keys,
|
self.assertIn(album, self.artist.child_set)
|
||||||
{album.albumid})
|
|
||||||
self.assertTrue(self.artist.has_album(album))
|
self.assertTrue(self.artist.has_album(album))
|
||||||
|
|
||||||
with unittest.mock.patch.object(self.table, "remove_album",
|
with unittest.mock.patch.object(self.table, "remove_album",
|
||||||
|
@ -46,15 +44,17 @@ class TestArtistObject(tests.util.TestCase):
|
||||||
self.artist.remove_album(album)
|
self.artist.remove_album(album)
|
||||||
|
|
||||||
mock_remove.assert_called_with(self.artist, album)
|
mock_remove.assert_called_with(self.artist, album)
|
||||||
self.assertSetEqual(self.artist.children.get_filter().keys, set())
|
self.assertNotIn(album, self.artist.child_set)
|
||||||
self.assertFalse(self.artist.has_album(album))
|
self.assertFalse(self.artist.has_album(album))
|
||||||
|
|
||||||
def test_children(self):
|
def test_children(self):
|
||||||
"""Test that Albums have been added as Artist playlist children."""
|
"""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.assertIsInstance(self.artist.children, Gtk.FilterListModel)
|
||||||
self.assertIsInstance(self.artist.children.get_filter(),
|
self.assertEqual(self.artist.children.get_filter(),
|
||||||
emmental.db.table.Filter)
|
self.sql.albums.get_filter())
|
||||||
self.assertEqual(self.artist.children.get_model(), self.sql.albums)
|
self.assertEqual(self.artist.child_set.table, self.sql.albums)
|
||||||
|
|
||||||
|
|
||||||
class TestFilter(tests.util.TestCase):
|
class TestFilter(tests.util.TestCase):
|
||||||
|
@ -68,7 +68,7 @@ class TestFilter(tests.util.TestCase):
|
||||||
|
|
||||||
def test_init(self):
|
def test_init(self):
|
||||||
"""Test that the filter is initialized properly."""
|
"""Test that the filter is initialized properly."""
|
||||||
self.assertIsInstance(self.filter, emmental.db.table.Filter)
|
self.assertIsInstance(self.filter, emmental.db.table.KeySet)
|
||||||
self.assertFalse(self.filter.show_all)
|
self.assertFalse(self.filter.show_all)
|
||||||
|
|
||||||
filter2 = emmental.db.artists.Filter(show_all=True)
|
filter2 = emmental.db.artists.Filter(show_all=True)
|
||||||
|
@ -219,13 +219,11 @@ class TestArtistTable(tests.util.TestCase):
|
||||||
|
|
||||||
self.assertEqual(artists2.get_item(0).name, "Artist 1")
|
self.assertEqual(artists2.get_item(0).name, "Artist 1")
|
||||||
self.assertEqual(artists2.get_item(0).mbid, "")
|
self.assertEqual(artists2.get_item(0).mbid, "")
|
||||||
self.assertSetEqual(artists2.get_item(0).children.get_filter().keys,
|
self.assertSetEqual(artists2.get_item(0).child_set.keyset.keys, {1})
|
||||||
{1})
|
|
||||||
|
|
||||||
self.assertEqual(artists2.get_item(1).name, "Artist 2")
|
self.assertEqual(artists2.get_item(1).name, "Artist 2")
|
||||||
self.assertEqual(artists2.get_item(1).mbid, "ab-cd-ef")
|
self.assertEqual(artists2.get_item(1).mbid, "ab-cd-ef")
|
||||||
self.assertSetEqual(artists2.get_item(1).children.get_filter().keys,
|
self.assertSetEqual(artists2.get_item(1).child_set.keyset.keys, set())
|
||||||
set())
|
|
||||||
|
|
||||||
def test_lookup(self):
|
def test_lookup(self):
|
||||||
"""Test looking up artist playlists."""
|
"""Test looking up artist playlists."""
|
||||||
|
|
|
@ -79,6 +79,20 @@ class TestConnection(unittest.TestCase):
|
||||||
self.assertEqual(tuple(rows[3]), (4, "d"))
|
self.assertEqual(tuple(rows[3]), (4, "d"))
|
||||||
self.assertEqual(tuple(rows[4]), (5, "e"))
|
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):
|
def test_path_column(self):
|
||||||
"""Test that the PATH column type has been set up."""
|
"""Test that the PATH column type has been set up."""
|
||||||
self.sql("CREATE TABLE test (path PATH)")
|
self.sql("CREATE TABLE test (path PATH)")
|
||||||
|
|
|
@ -11,8 +11,10 @@ class TestConnection(tests.util.TestCase):
|
||||||
|
|
||||||
def test_paths(self):
|
def test_paths(self):
|
||||||
"""Check that path constants are pointing to the right places."""
|
"""Check that path constants are pointing to the right places."""
|
||||||
script = pathlib.Path(emmental.db.__file__).parent / "emmental.sql"
|
dir = pathlib.Path(emmental.db.__file__).parent
|
||||||
self.assertEqual(emmental.db.SQL_SCRIPT, script)
|
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")
|
||||||
|
|
||||||
def test_connection(self):
|
def test_connection(self):
|
||||||
"""Check that the connection manager is initialized properly."""
|
"""Check that the connection manager is initialized properly."""
|
||||||
|
@ -21,16 +23,16 @@ class TestConnection(tests.util.TestCase):
|
||||||
def test_version(self):
|
def test_version(self):
|
||||||
"""Test checking the database schema version."""
|
"""Test checking the database schema version."""
|
||||||
cur = self.sql("PRAGMA user_version")
|
cur = self.sql("PRAGMA user_version")
|
||||||
self.assertEqual(cur.fetchone()["user_version"], 1)
|
self.assertEqual(cur.fetchone()["user_version"], 3)
|
||||||
|
|
||||||
def test_version_too_new(self):
|
def test_version_too_new(self):
|
||||||
"""Test failing when the database version is too new."""
|
"""Test failing when the database version is too new."""
|
||||||
self.sql._Connection__check_version()
|
self.sql._Connection__check_version()
|
||||||
|
|
||||||
self.sql("PRAGMA user_version = 2")
|
self.sql("PRAGMA user_version = 4")
|
||||||
with self.assertRaises(Exception) as e:
|
with self.assertRaises(Exception) as e:
|
||||||
self.sql._Connection__check_version()
|
self.sql._Connection__check_version()
|
||||||
self.assertEqual(str(e.exception), "Unsupported data version: 2")
|
self.assertEqual(str(e.exception), "Unsupported data version: 4")
|
||||||
|
|
||||||
def test_close(self):
|
def test_close(self):
|
||||||
"""Check closing the connection."""
|
"""Check closing the connection."""
|
||||||
|
@ -71,22 +73,34 @@ class TestConnection(tests.util.TestCase):
|
||||||
|
|
||||||
def test_load(self):
|
def test_load(self):
|
||||||
"""Check that calling load() loads the tables."""
|
"""Check that calling load() loads the tables."""
|
||||||
idle_tables = [tbl for tbl in self.sql.playlist_tables()] + \
|
plist_tables = list(self.sql.playlist_tables())
|
||||||
[self.sql.tracks]
|
all_tables = [self.sql.settings] + plist_tables + [self.sql.tracks]
|
||||||
|
|
||||||
table_loaded = unittest.mock.Mock()
|
table_loaded = unittest.mock.Mock()
|
||||||
self.sql.connect("table-loaded", table_loaded)
|
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.sql.load()
|
||||||
self.assertTrue(self.sql.settings.loaded)
|
self.assertTrue(self.sql.settings.loaded)
|
||||||
for tbl in idle_tables:
|
notify_loaded.assert_not_called()
|
||||||
|
|
||||||
|
for tbl in all_tables[1:]:
|
||||||
self.assertFalse(tbl.loaded)
|
self.assertFalse(tbl.loaded)
|
||||||
for tbl in idle_tables:
|
for tbl in plist_tables:
|
||||||
tbl.queue.complete()
|
tbl.queue.complete()
|
||||||
self.assertTrue(tbl.loaded)
|
self.assertTrue(tbl.loaded)
|
||||||
|
self.assertFalse(self.sql.loaded)
|
||||||
|
notify_loaded.assert_not_called()
|
||||||
|
|
||||||
calls = [unittest.mock.call(self.sql, tbl)
|
self.sql.tracks.queue.complete()
|
||||||
for tbl in [self.sql.settings] + idle_tables]
|
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]
|
||||||
table_loaded.assert_has_calls(calls)
|
table_loaded.assert_has_calls(calls)
|
||||||
|
|
||||||
def test_filter(self):
|
def test_filter(self):
|
||||||
|
|
|
@ -28,6 +28,21 @@ class TestDecadeObject(tests.util.TestCase):
|
||||||
self.assertEqual(self.decade.name, "The 2020s")
|
self.assertEqual(self.decade.name, "The 2020s")
|
||||||
self.assertIsNone(self.decade.parent)
|
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):
|
def test_get_years(self):
|
||||||
"""Test getting the list of years for this decade."""
|
"""Test getting the list of years for this decade."""
|
||||||
with unittest.mock.patch.object(self.table, "get_years",
|
with unittest.mock.patch.object(self.table, "get_years",
|
||||||
|
@ -37,16 +52,12 @@ class TestDecadeObject(tests.util.TestCase):
|
||||||
|
|
||||||
def test_years_model(self):
|
def test_years_model(self):
|
||||||
"""Test getting a Gio.ListModel representing a Decade's years."""
|
"""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.assertIsInstance(self.decade.children, Gtk.FilterListModel)
|
||||||
self.assertIsInstance(self.decade.children.get_filter(),
|
self.assertEqual(self.decade.children.get_filter(),
|
||||||
Gtk.CustomFilter)
|
self.sql.years.get_filter())
|
||||||
self.assertEqual(self.decade.children.get_model(), self.sql.years)
|
self.assertEqual(self.decade.child_set.table, 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):
|
class TestDecadeTable(tests.util.TestCase):
|
||||||
|
@ -164,8 +175,10 @@ class TestDecadeTable(tests.util.TestCase):
|
||||||
|
|
||||||
def test_load(self):
|
def test_load(self):
|
||||||
"""Load the decade table from the database."""
|
"""Load the decade table from the database."""
|
||||||
self.table.create(1980)
|
decade = self.table.create(1980)
|
||||||
self.table.create(1990)
|
self.table.create(1990)
|
||||||
|
year = self.sql.years.create(1988)
|
||||||
|
decade.add_year(year)
|
||||||
|
|
||||||
decades2 = emmental.db.decades.Table(self.sql)
|
decades2 = emmental.db.decades.Table(self.sql)
|
||||||
self.assertEqual(len(decades2), 0)
|
self.assertEqual(len(decades2), 0)
|
||||||
|
@ -175,9 +188,11 @@ class TestDecadeTable(tests.util.TestCase):
|
||||||
|
|
||||||
self.assertEqual(decades2.get_item(0).decade, 1980)
|
self.assertEqual(decades2.get_item(0).decade, 1980)
|
||||||
self.assertEqual(decades2.get_item(0).name, "The 1980s")
|
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).decade, 1990)
|
||||||
self.assertEqual(decades2.get_item(1).name, "The 1990s")
|
self.assertEqual(decades2.get_item(1).name, "The 1990s")
|
||||||
|
self.assertSetEqual(decades2.get_item(1).child_set.keyset.keys, set())
|
||||||
|
|
||||||
def test_lookup(self):
|
def test_lookup(self):
|
||||||
"""Test looking up decade playlists."""
|
"""Test looking up decade playlists."""
|
||||||
|
@ -214,4 +229,5 @@ class TestDecadeTable(tests.util.TestCase):
|
||||||
y1985 = self.sql.years.create(1985)
|
y1985 = self.sql.years.create(1985)
|
||||||
y1988 = self.sql.years.create(1988)
|
y1988 = self.sql.years.create(1988)
|
||||||
|
|
||||||
|
self.assertSetEqual(self.table.get_yearids(decade), {1985, 1988})
|
||||||
self.assertListEqual(self.table.get_years(decade), [y1985, y1988])
|
self.assertListEqual(self.table.get_years(decade), [y1985, y1988])
|
||||||
|
|
|
@ -51,6 +51,26 @@ class TestIdleQueue(unittest.TestCase):
|
||||||
self.assertEqual(self.queue.total, 0)
|
self.assertEqual(self.queue.total, 0)
|
||||||
self.assertEqual(self.queue.progress, 0.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,
|
def test_complete(self, mock_idle_add: unittest.mock.Mock,
|
||||||
mock_source_removed: unittest.mock.Mock):
|
mock_source_removed: unittest.mock.Mock):
|
||||||
"""Test completing queued tasks."""
|
"""Test completing queued tasks."""
|
||||||
|
@ -119,6 +139,17 @@ class TestIdleQueue(unittest.TestCase):
|
||||||
mock_idle_add.assert_not_called()
|
mock_idle_add.assert_not_called()
|
||||||
func.assert_called_with(1)
|
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,
|
def test_push_many_enabled(self, mock_idle_add: unittest.mock.Mock,
|
||||||
mock_source_removed: unittest.mock.Mock):
|
mock_source_removed: unittest.mock.Mock):
|
||||||
"""Test adding several calls to one function at one time."""
|
"""Test adding several calls to one function at one time."""
|
||||||
|
|
|
@ -182,23 +182,23 @@ class TestLibraryObject(tests.util.TestCase):
|
||||||
tagger.tag_file.assert_not_called()
|
tagger.tag_file.assert_not_called()
|
||||||
|
|
||||||
tagger.ready.is_set.return_value = True
|
tagger.ready.is_set.return_value = True
|
||||||
tagger.get_result.return_value = (None, None)
|
tagger.get_result.return_value = None
|
||||||
self.assertFalse(self.library._Library__tag_track(track))
|
self.assertFalse(self.library._Library__tag_track(track))
|
||||||
tagger.get_result.assert_called_with(self.sql, self.library)
|
tagger.get_result.assert_called_with(db=self.sql, library=self.library)
|
||||||
tagger.tag_file.assert_called_with(track, None)
|
tagger.tag_file.assert_called_with(track, mtime=None)
|
||||||
|
|
||||||
self.sql.tracks.lookup = unittest.mock.Mock()
|
self.sql.tracks.lookup = unittest.mock.Mock()
|
||||||
self.sql.tracks.lookup.return_value.mtime = 12345
|
self.sql.tracks.lookup.return_value.mtime = 12345
|
||||||
self.assertFalse(self.library._Library__tag_track(track))
|
self.assertFalse(self.library._Library__tag_track(track))
|
||||||
tagger.get_result.assert_called_with(self.sql, self.library)
|
tagger.get_result.assert_called_with(db=self.sql, library=self.library)
|
||||||
tagger.tag_file.assert_called_with(track, 12345)
|
tagger.tag_file.assert_called_with(track, mtime=12345)
|
||||||
|
|
||||||
tagger.reset_mock()
|
tagger.reset_mock()
|
||||||
tagger.ready.is_set.return_value = True
|
tagger.ready.is_set.return_value = True
|
||||||
tagger.get_result.return_value = (track, tags)
|
tagger.get_result.return_value = {"path": track, "tags": tags}
|
||||||
self.assertTrue(self.library._Library__tag_track(track))
|
self.assertTrue(self.library._Library__tag_track(track))
|
||||||
tagger.tag_file.assert_not_called()
|
tagger.tag_file.assert_not_called()
|
||||||
tagger.get_result.assert_called_with(self.sql, self.library)
|
tagger.get_result.assert_called_with(db=self.sql, library=self.library)
|
||||||
|
|
||||||
@unittest.mock.patch("emmental.db.tagger.untag_track")
|
@unittest.mock.patch("emmental.db.tagger.untag_track")
|
||||||
def test_scan_check_trackid(self, mock_untag: unittest.mock.Mock()):
|
def test_scan_check_trackid(self, mock_untag: unittest.mock.Mock()):
|
||||||
|
|
|
@ -4,6 +4,7 @@ import pathlib
|
||||||
import unittest.mock
|
import unittest.mock
|
||||||
import emmental.db
|
import emmental.db
|
||||||
import tests.util
|
import tests.util
|
||||||
|
from gi.repository import Gtk
|
||||||
|
|
||||||
|
|
||||||
class TestMediumObject(tests.util.TestCase):
|
class TestMediumObject(tests.util.TestCase):
|
||||||
|
@ -46,6 +47,36 @@ class TestMediumObject(tests.util.TestCase):
|
||||||
mock_rename.assert_called_with(self.medium, "New Name")
|
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):
|
class TestMediumsTable(tests.util.TestCase):
|
||||||
"""Tests our mediums table."""
|
"""Tests our mediums table."""
|
||||||
|
|
||||||
|
@ -61,6 +92,8 @@ class TestMediumsTable(tests.util.TestCase):
|
||||||
def test_init(self):
|
def test_init(self):
|
||||||
"""Test that the medium model is configured corretly."""
|
"""Test that the medium model is configured corretly."""
|
||||||
self.assertIsInstance(self.table, emmental.db.playlist.Table)
|
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.assertEqual(len(self.table), 0)
|
||||||
self.assertTrue(self.table.autodelete)
|
self.assertTrue(self.table.autodelete)
|
||||||
self.assertFalse(self.table.system_tracks)
|
self.assertFalse(self.table.system_tracks)
|
||||||
|
@ -99,6 +132,7 @@ class TestMediumsTable(tests.util.TestCase):
|
||||||
self.assertEqual(medium1.number, 1)
|
self.assertEqual(medium1.number, 1)
|
||||||
self.assertEqual(medium1.type, "")
|
self.assertEqual(medium1.type, "")
|
||||||
self.assertEqual(medium1.sort_order, "mediumno, number")
|
self.assertEqual(medium1.sort_order, "mediumno, number")
|
||||||
|
self.assertTrue(self.album.has_medium(medium1))
|
||||||
|
|
||||||
cur = self.sql("SELECT COUNT(name) FROM media")
|
cur = self.sql("SELECT COUNT(name) FROM media")
|
||||||
self.assertEqual(cur.fetchone()["COUNT(name)"], 1)
|
self.assertEqual(cur.fetchone()["COUNT(name)"], 1)
|
||||||
|
@ -123,6 +157,7 @@ class TestMediumsTable(tests.util.TestCase):
|
||||||
medium = self.table.create(self.album, "Medium 1", number=1)
|
medium = self.table.create(self.album, "Medium 1", number=1)
|
||||||
self.assertTrue(medium.delete())
|
self.assertTrue(medium.delete())
|
||||||
self.assertIsNone(self.table.index(medium))
|
self.assertIsNone(self.table.index(medium))
|
||||||
|
self.assertFalse(self.album.has_medium(medium))
|
||||||
|
|
||||||
cur = self.sql("SELECT COUNT(name) FROM media")
|
cur = self.sql("SELECT COUNT(name) FROM media")
|
||||||
self.assertEqual(cur.fetchone()["COUNT(name)"], 0)
|
self.assertEqual(cur.fetchone()["COUNT(name)"], 0)
|
||||||
|
@ -172,17 +207,19 @@ class TestMediumsTable(tests.util.TestCase):
|
||||||
self.assertEqual(len(mediums2), 0)
|
self.assertEqual(len(mediums2), 0)
|
||||||
|
|
||||||
mediums2.load(now=True)
|
mediums2.load(now=True)
|
||||||
self.assertEqual(len(mediums2), 2)
|
self.assertEqual(len(mediums2.store), 2)
|
||||||
|
|
||||||
self.assertEqual(mediums2.get_item(0).albumid, self.album.albumid)
|
self.assertEqual(mediums2.store.get_item(0).albumid,
|
||||||
self.assertEqual(mediums2.get_item(0).name, "")
|
self.album.albumid)
|
||||||
self.assertEqual(mediums2.get_item(0).number, 1)
|
self.assertEqual(mediums2.store.get_item(0).name, "")
|
||||||
self.assertEqual(mediums2.get_item(0).type, "")
|
self.assertEqual(mediums2.store.get_item(0).number, 1)
|
||||||
|
self.assertEqual(mediums2.store.get_item(0).type, "")
|
||||||
|
|
||||||
self.assertEqual(mediums2.get_item(1).albumid, self.album.albumid)
|
self.assertEqual(mediums2.store.get_item(1).albumid,
|
||||||
self.assertEqual(mediums2.get_item(1).name, "Medium 2")
|
self.album.albumid)
|
||||||
self.assertEqual(mediums2.get_item(1).number, 2)
|
self.assertEqual(mediums2.store.get_item(1).name, "Medium 2")
|
||||||
self.assertEqual(mediums2.get_item(1).type, "Digital Media")
|
self.assertEqual(mediums2.store.get_item(1).number, 2)
|
||||||
|
self.assertEqual(mediums2.store.get_item(1).type, "Digital Media")
|
||||||
|
|
||||||
def test_lookup(self):
|
def test_lookup(self):
|
||||||
"""Test looking up medium playlists."""
|
"""Test looking up medium playlists."""
|
||||||
|
|
|
@ -21,6 +21,7 @@ class TestPlaylistRow(unittest.TestCase):
|
||||||
self.table.move_track_up = unittest.mock.Mock(return_value=True)
|
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_trackids = unittest.mock.Mock(return_value={1, 2, 3})
|
||||||
self.table.get_track_order = unittest.mock.Mock()
|
self.table.get_track_order = unittest.mock.Mock()
|
||||||
|
self.table.refilter = unittest.mock.Mock()
|
||||||
self.table.queue = emmental.db.idle.Queue()
|
self.table.queue = emmental.db.idle.Queue()
|
||||||
self.table.update = unittest.mock.Mock(return_value=True)
|
self.table.update = unittest.mock.Mock(return_value=True)
|
||||||
|
|
||||||
|
@ -65,14 +66,28 @@ class TestPlaylistRow(unittest.TestCase):
|
||||||
|
|
||||||
def test_children(self):
|
def test_children(self):
|
||||||
"""Test the child playlist properties."""
|
"""Test the child playlist properties."""
|
||||||
|
self.assertIsNone(self.playlist.child_set)
|
||||||
self.assertIsNone(self.playlist.children)
|
self.assertIsNone(self.playlist.children)
|
||||||
|
|
||||||
filter = Gtk.Filter()
|
table = emmental.db.table.Table(None)
|
||||||
self.playlist.add_children(self.table, filter)
|
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())
|
||||||
|
|
||||||
self.assertIsInstance(self.playlist.children, Gtk.FilterListModel)
|
self.assertIsInstance(self.playlist.children, Gtk.FilterListModel)
|
||||||
self.assertEqual(self.playlist.children.get_filter(), filter)
|
self.assertEqual(self.playlist.children.get_filter(),
|
||||||
self.assertEqual(self.playlist.children.get_model(), self.table)
|
table.get_filter())
|
||||||
self.assertTrue(self.playlist.children.get_incremental())
|
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})
|
||||||
|
|
||||||
def test_parent(self):
|
def test_parent(self):
|
||||||
"""Test the parent playlist property."""
|
"""Test the parent playlist property."""
|
||||||
|
@ -97,6 +112,20 @@ class TestPlaylistRow(unittest.TestCase):
|
||||||
self.table.update.assert_called_with(self.playlist,
|
self.table.update.assert_called_with(self.playlist,
|
||||||
prop, value)
|
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):
|
def test_add_track(self):
|
||||||
"""Test adding a track to the playlist."""
|
"""Test adding a track to the playlist."""
|
||||||
self.playlist.add_track(self.track, idle=True)
|
self.playlist.add_track(self.track, idle=True)
|
||||||
|
@ -122,6 +151,18 @@ class TestPlaylistRow(unittest.TestCase):
|
||||||
{1: 3, 2: 2, 3: 1})
|
{1: 3, 2: 2, 3: 1})
|
||||||
self.table.get_track_order.assert_called_with(self.playlist)
|
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):
|
def test_has_track(self):
|
||||||
"""Test the playlist has_track() function."""
|
"""Test the playlist has_track() function."""
|
||||||
self.assertFalse(self.playlist.has_track(self.track))
|
self.assertFalse(self.playlist.has_track(self.track))
|
||||||
|
@ -141,6 +182,23 @@ class TestPlaylistRow(unittest.TestCase):
|
||||||
self.assertTrue(self.playlist.move_track_up(self.track))
|
self.assertTrue(self.playlist.move_track_up(self.track))
|
||||||
self.table.move_track_up.assert_called_with(self.playlist, 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):
|
def test_remove_track(self):
|
||||||
"""Test removing a track from the playlist."""
|
"""Test removing a track from the playlist."""
|
||||||
self.playlist.tracks.trackids.add(self.track.trackid)
|
self.playlist.tracks.trackids.add(self.track.trackid)
|
||||||
|
@ -358,6 +416,27 @@ class TestPlaylistTable(tests.util.TestCase):
|
||||||
self.table.move_track_up(plist, self.track)
|
self.table.move_track_up(plist, self.track)
|
||||||
self.assertEqual(plist.sort_order, "user")
|
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):
|
def test_remove_track(self):
|
||||||
"""Test adding a track to a playlist."""
|
"""Test adding a track to a playlist."""
|
||||||
self.assertTrue(self.table.system_tracks)
|
self.assertTrue(self.table.system_tracks)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
# Copyright 2022 (c) Anna Schumaker
|
# Copyright 2022 (c) Anna Schumaker
|
||||||
"""Tests our playlist Gio.ListModel."""
|
"""Tests our playlist Gio.ListModel."""
|
||||||
|
import datetime
|
||||||
import pathlib
|
import pathlib
|
||||||
import unittest.mock
|
import unittest.mock
|
||||||
import emmental.db
|
import emmental.db
|
||||||
|
@ -326,6 +327,18 @@ class TestSystemPlaylists(tests.util.TestCase):
|
||||||
pathlib.Path("/a/b/1.ogg"),
|
pathlib.Path("/a/b/1.ogg"),
|
||||||
self.medium, self.year)
|
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):
|
def test_collection(self):
|
||||||
"""Test the Collection playlist."""
|
"""Test the Collection playlist."""
|
||||||
self.assertIsInstance(self.table.collection,
|
self.assertIsInstance(self.table.collection,
|
||||||
|
|
|
@ -50,119 +50,166 @@ class TestRow(unittest.TestCase):
|
||||||
|
|
||||||
|
|
||||||
@unittest.mock.patch("gi.repository.Gtk.Filter.changed")
|
@unittest.mock.patch("gi.repository.Gtk.Filter.changed")
|
||||||
class TestFilter(unittest.TestCase):
|
class TestKeySet(unittest.TestCase):
|
||||||
"""Tests our database row Filter."""
|
"""Tests our KeySet for holding database Rows."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""Set up common variables."""
|
"""Set up common variables."""
|
||||||
self.filter = emmental.db.table.Filter()
|
self.keyset = emmental.db.table.KeySet()
|
||||||
self.table = Gio.ListStore()
|
self.table = Gio.ListStore()
|
||||||
self.row1 = tests.util.table.MockRow(number=1, table=self.table)
|
self.row1 = tests.util.table.MockRow(number=1, table=self.table)
|
||||||
self.row2 = tests.util.table.MockRow(number=2, table=self.table)
|
self.row2 = tests.util.table.MockRow(number=2, table=self.table)
|
||||||
|
|
||||||
def test_init(self, mock_changed: unittest.mock.Mock):
|
def test_init(self, mock_changed: unittest.mock.Mock):
|
||||||
"""Test that the filter is created correctly."""
|
"""Test that the KeySet is created correctly."""
|
||||||
self.assertIsInstance(self.filter, Gtk.Filter)
|
self.assertIsInstance(self.keyset, Gtk.Filter)
|
||||||
self.assertIsNone(self.filter._keys, None)
|
self.assertIsNone(self.keyset._keys, None)
|
||||||
self.assertEqual(self.filter.n_keys, -1)
|
self.assertEqual(self.keyset.n_keys, -1)
|
||||||
|
|
||||||
filter2 = emmental.db.table.Filter(keys={1, 2, 3})
|
keyset2 = emmental.db.table.KeySet(keys={1, 2, 3})
|
||||||
self.assertSetEqual(filter2._keys, {1, 2, 3})
|
self.assertSetEqual(keyset2._keys, {1, 2, 3})
|
||||||
self.assertEqual(filter2.n_keys, 3)
|
self.assertEqual(keyset2.n_keys, 3)
|
||||||
|
|
||||||
def test_subtract(self, mock_changed: unittest.mock.Mock):
|
def test_subtract(self, mock_changed: unittest.mock.Mock):
|
||||||
"""Test subtracting two filters."""
|
"""Test subtracting two KeySets."""
|
||||||
filter2 = emmental.db.table.Filter(keys={2, 3})
|
keyset2 = emmental.db.table.KeySet(keys={2, 3})
|
||||||
self.assertIsNone(self.filter - self.filter)
|
self.assertIsNone(self.keyset - self.keyset)
|
||||||
self.assertIsNone(self.filter - filter2)
|
self.assertIsNone(self.keyset - keyset2)
|
||||||
self.assertSetEqual(filter2 - self.filter, {2, 3})
|
self.assertSetEqual(keyset2 - self.keyset, {2, 3})
|
||||||
|
|
||||||
self.filter.keys = {1, 2, 3, 4, 5}
|
self.keyset.keys = {1, 2, 3, 4, 5}
|
||||||
self.assertSetEqual(self.filter - filter2, {1, 4, 5})
|
self.assertSetEqual(self.keyset - keyset2, {1, 4, 5})
|
||||||
self.assertSetEqual(filter2 - self.filter, set())
|
self.assertSetEqual(keyset2 - self.keyset, set())
|
||||||
|
|
||||||
def test_strictness(self, mock_changed: unittest.mock.Mock):
|
def test_strictness(self, mock_changed: unittest.mock.Mock):
|
||||||
"""Test checking strictness."""
|
"""Test checking strictness."""
|
||||||
self.assertEqual(self.filter.get_strictness(), Gtk.FilterMatch.ALL)
|
self.assertEqual(self.keyset.get_strictness(), Gtk.FilterMatch.ALL)
|
||||||
self.filter._keys = set()
|
self.keyset._keys = set()
|
||||||
self.assertEqual(self.filter.get_strictness(), Gtk.FilterMatch.NONE)
|
self.assertEqual(self.keyset.get_strictness(), Gtk.FilterMatch.NONE)
|
||||||
self.filter._keys = {1, 2, 3}
|
self.keyset._keys = {1, 2, 3}
|
||||||
self.assertEqual(self.filter.get_strictness(), Gtk.FilterMatch.SOME)
|
self.assertEqual(self.keyset.get_strictness(), Gtk.FilterMatch.SOME)
|
||||||
|
|
||||||
def test_add_row(self, mock_changed: unittest.mock.Mock):
|
def test_add_row(self, mock_changed: unittest.mock.Mock):
|
||||||
"""Test adding Rows to the filter."""
|
"""Test adding Rows to the KeySet."""
|
||||||
self.filter.add_row(self.row1)
|
mock_added = unittest.mock.Mock()
|
||||||
self.assertIsNone(self.filter.keys)
|
self.keyset.connect("key-added", mock_added)
|
||||||
|
|
||||||
self.filter.keys = set()
|
self.keyset.add_row(self.row1)
|
||||||
self.filter.add_row(self.row1)
|
self.assertIsNone(self.keyset.keys)
|
||||||
self.assertSetEqual(self.filter.keys, {1})
|
mock_added.assert_not_called()
|
||||||
mock_changed.assert_called_with(Gtk.FilterChange.LESS_STRICT)
|
|
||||||
self.assertEqual(self.filter.n_keys, 1)
|
|
||||||
|
|
||||||
self.filter.add_row(self.row2)
|
self.keyset.keys = set()
|
||||||
self.assertSetEqual(self.filter.keys, {1, 2})
|
self.keyset.add_row(self.row1)
|
||||||
|
self.assertSetEqual(self.keyset.keys, {1})
|
||||||
|
self.assertEqual(self.keyset.n_keys, 1)
|
||||||
mock_changed.assert_called_with(Gtk.FilterChange.LESS_STRICT)
|
mock_changed.assert_called_with(Gtk.FilterChange.LESS_STRICT)
|
||||||
self.assertEqual(self.filter.n_keys, 2)
|
mock_added.assert_called_with(self.keyset, 1)
|
||||||
|
|
||||||
|
self.keyset.add_row(self.row2)
|
||||||
|
self.assertSetEqual(self.keyset.keys, {1, 2})
|
||||||
|
self.assertEqual(self.keyset.n_keys, 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()
|
||||||
|
|
||||||
def test_remove_row(self, mock_changed: unittest.mock.Mock):
|
def test_remove_row(self, mock_changed: unittest.mock.Mock):
|
||||||
"""Test removing Rows from the filter."""
|
"""Test removing Rows from the KeySet."""
|
||||||
self.filter.remove_row(self.row1)
|
mock_removed = unittest.mock.Mock()
|
||||||
mock_changed.assert_not_called()
|
self.keyset.connect("key-removed", mock_removed)
|
||||||
|
|
||||||
self.filter.keys = {1, 2}
|
self.keyset.remove_row(self.row1)
|
||||||
self.filter.remove_row(self.row1)
|
mock_changed.assert_not_called()
|
||||||
self.assertSetEqual(self.filter._keys, {2})
|
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)
|
||||||
mock_changed.assert_called_with(Gtk.FilterChange.MORE_STRICT)
|
mock_changed.assert_called_with(Gtk.FilterChange.MORE_STRICT)
|
||||||
self.assertEqual(self.filter.n_keys, 1)
|
mock_removed.assert_called_with(self.keyset, 1)
|
||||||
|
|
||||||
mock_changed.reset_mock()
|
mock_changed.reset_mock()
|
||||||
self.filter.remove_row(self.row2)
|
mock_removed.reset_mock()
|
||||||
self.assertSetEqual(self.filter._keys, set())
|
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)
|
||||||
mock_changed.assert_called_with(Gtk.FilterChange.MORE_STRICT)
|
mock_changed.assert_called_with(Gtk.FilterChange.MORE_STRICT)
|
||||||
self.assertEqual(self.filter.n_keys, 0)
|
mock_removed.assert_called_with(self.keyset, 2)
|
||||||
|
|
||||||
def test_keys(self, mock_changed: unittest.mock.Mock):
|
def test_keys(self, mock_changed: unittest.mock.Mock):
|
||||||
"""Test setting and getting the filter keys property."""
|
"""Test getting and setting the KeySet.keys property."""
|
||||||
self.assertIsNone(self.filter.keys)
|
mock_keys_changed = unittest.mock.Mock()
|
||||||
|
self.keyset.connect("keys-changed", mock_keys_changed)
|
||||||
|
|
||||||
self.filter.keys = {1, 2, 3}
|
self.assertIsNone(self.keyset.keys)
|
||||||
self.assertSetEqual(self.filter._keys, {1, 2, 3})
|
self.keyset.keys = None
|
||||||
mock_changed.assert_called_with(Gtk.FilterChange.MORE_STRICT)
|
self.assertIsNone(self.keyset.keys)
|
||||||
self.assertEqual(self.filter.n_keys, 3)
|
|
||||||
|
|
||||||
mock_changed.reset_mock()
|
|
||||||
self.filter.keys = {1, 2}
|
|
||||||
self.assertSetEqual(self.filter.keys, {1, 2})
|
|
||||||
mock_changed.assert_called_with(Gtk.FilterChange.MORE_STRICT)
|
|
||||||
self.assertEqual(self.filter.n_keys, 2)
|
|
||||||
|
|
||||||
mock_changed.reset_mock()
|
|
||||||
self.filter.keys = {1, 2}
|
|
||||||
mock_changed.assert_not_called()
|
mock_changed.assert_not_called()
|
||||||
|
mock_keys_changed.assert_not_called()
|
||||||
|
|
||||||
self.filter.keys = {1, 2, 3}
|
self.keyset.keys = {1, 2, 3}
|
||||||
self.assertSetEqual(self.filter.keys, {1, 2, 3})
|
self.assertSetEqual(self.keyset._keys, {1, 2, 3})
|
||||||
|
self.assertEqual(self.keyset.n_keys, 3)
|
||||||
|
mock_changed.assert_called_with(Gtk.FilterChange.MORE_STRICT)
|
||||||
|
mock_keys_changed.assert_called_with(self.keyset, set(), {1, 2, 3})
|
||||||
|
|
||||||
|
mock_changed.reset_mock()
|
||||||
|
self.keyset.keys = {1, 2}
|
||||||
|
self.assertSetEqual(self.keyset.keys, {1, 2})
|
||||||
|
self.assertEqual(self.keyset.n_keys, 2)
|
||||||
|
mock_changed.assert_called_with(Gtk.FilterChange.MORE_STRICT)
|
||||||
|
mock_keys_changed.assert_called_with(self.keyset, {3}, set())
|
||||||
|
|
||||||
|
mock_changed.reset_mock()
|
||||||
|
mock_keys_changed.reset_mock()
|
||||||
|
self.keyset.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})
|
||||||
mock_changed.assert_called_with(Gtk.FilterChange.LESS_STRICT)
|
mock_changed.assert_called_with(Gtk.FilterChange.LESS_STRICT)
|
||||||
|
mock_keys_changed.assert_called_with(self.keyset, set(), {3})
|
||||||
|
|
||||||
self.filter.keys = {4, 5, 6}
|
self.keyset.keys = {4, 5, 6}
|
||||||
self.assertSetEqual(self.filter._keys, {4, 5, 6})
|
self.assertSetEqual(self.keyset._keys, {4, 5, 6})
|
||||||
mock_changed.assert_called_with(Gtk.FilterChange.DIFFERENT)
|
mock_changed.assert_called_with(Gtk.FilterChange.DIFFERENT)
|
||||||
|
mock_keys_changed.assert_called_with(self.keyset, {1, 2, 3}, {4, 5, 6})
|
||||||
|
|
||||||
self.filter.keys = None
|
self.keyset.keys = None
|
||||||
self.assertIsNone(self.filter._keys)
|
self.assertIsNone(self.keyset._keys)
|
||||||
|
self.assertEqual(self.keyset.n_keys, -1)
|
||||||
mock_changed.assert_called_with(Gtk.FilterChange.LESS_STRICT)
|
mock_changed.assert_called_with(Gtk.FilterChange.LESS_STRICT)
|
||||||
self.assertEqual(self.filter.n_keys, -1)
|
mock_keys_changed.assert_called_with(self.keyset, {4, 5, 6}, set())
|
||||||
|
|
||||||
def test_match(self, mock_changed: unittest.mock.Mock):
|
def test_match_contains(self, mock_changed: unittest.mock.Mock):
|
||||||
"""Test matching playlists."""
|
"""Test matching Rows and the __contains__() magic method."""
|
||||||
self.assertTrue(self.filter.match(self.row1))
|
self.assertTrue(self.keyset.match(self.row1))
|
||||||
self.filter.keys = {1, 2, 3}
|
self.assertTrue(self.row1 in self.keyset)
|
||||||
self.assertTrue(self.filter.match(self.row1))
|
|
||||||
self.filter.keys = {4, 5, 6}
|
self.keyset.keys = {1, 2, 3}
|
||||||
self.assertFalse(self.filter.match(self.row1))
|
self.assertTrue(self.keyset.match(self.row1))
|
||||||
self.filter.keys = set()
|
self.assertTrue(self.row1 in self.keyset)
|
||||||
self.assertFalse(self.filter.match(self.row1))
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
class TestTable(tests.util.TestCase):
|
class TestTable(tests.util.TestCase):
|
||||||
|
@ -178,7 +225,7 @@ class TestTable(tests.util.TestCase):
|
||||||
self.assertIsInstance(self.table, Gtk.FilterListModel)
|
self.assertIsInstance(self.table, Gtk.FilterListModel)
|
||||||
self.assertIsInstance(self.table.queue, emmental.db.idle.Queue)
|
self.assertIsInstance(self.table.queue, emmental.db.idle.Queue)
|
||||||
self.assertIsInstance(self.table.get_filter(),
|
self.assertIsInstance(self.table.get_filter(),
|
||||||
emmental.db.table.Filter)
|
emmental.db.table.KeySet)
|
||||||
self.assertIsInstance(self.table.store, emmental.store.SortedList)
|
self.assertIsInstance(self.table.store, emmental.store.SortedList)
|
||||||
self.assertIsInstance(self.table.rows, dict)
|
self.assertIsInstance(self.table.rows, dict)
|
||||||
|
|
||||||
|
@ -186,9 +233,9 @@ class TestTable(tests.util.TestCase):
|
||||||
self.assertEqual(self.table.get_model(), self.table.store)
|
self.assertEqual(self.table.get_model(), self.table.store)
|
||||||
self.assertEqual(self.table.store.key_func, self.table.get_sort_key)
|
self.assertEqual(self.table.store.key_func, self.table.get_sort_key)
|
||||||
self.assertDictEqual(self.table.rows, {})
|
self.assertDictEqual(self.table.rows, {})
|
||||||
self.assertTrue(self.table.get_incremental())
|
self.assertFalse(self.table.get_incremental())
|
||||||
|
|
||||||
filter2 = emmental.db.table.Filter()
|
filter2 = emmental.db.table.KeySet()
|
||||||
queue2 = emmental.db.idle.Queue()
|
queue2 = emmental.db.idle.Queue()
|
||||||
table2 = emmental.db.table.Table(self.sql, filter=filter2,
|
table2 = emmental.db.table.Table(self.sql, filter=filter2,
|
||||||
queue=queue2)
|
queue=queue2)
|
||||||
|
@ -321,9 +368,13 @@ class TestTableFunctions(tests.util.TestCase):
|
||||||
"""Test filtering Rows in the table."""
|
"""Test filtering Rows in the table."""
|
||||||
for n in [1, 121, 212, 333]:
|
for n in [1, 121, 212, 333]:
|
||||||
self.table.create(number=n)
|
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.table.filter("*2*")
|
||||||
self.assertIsNone(self.table.get_filter().keys)
|
|
||||||
self.assertEqual(self.table.queue[0], (self.table._filter_idle, "*2*"))
|
self.assertEqual(self.table.queue[0], (self.table._filter_idle, "*2*"))
|
||||||
|
|
||||||
self.table.queue.complete()
|
self.table.queue.complete()
|
||||||
|
@ -393,3 +444,147 @@ class TestTableFunctions(tests.util.TestCase):
|
||||||
|
|
||||||
self.table.create(number=3)
|
self.table.create(number=3)
|
||||||
self.assertFalse(self.table.update(row, "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)
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
# Copyright 2022 (c) Anna Schumaker
|
# Copyright 2022 (c) Anna Schumaker
|
||||||
"""Tests our Mutagen wrapper."""
|
"""Tests our Mutagen wrapper."""
|
||||||
import pathlib
|
import pathlib
|
||||||
import threading
|
|
||||||
import unittest.mock
|
import unittest.mock
|
||||||
import emmental.db.tagger
|
import emmental.db.tagger
|
||||||
|
import emmental.thread
|
||||||
import tests.util
|
import tests.util
|
||||||
|
|
||||||
|
|
||||||
|
@ -276,8 +276,8 @@ class TestTaggerThread(tests.util.TestCase):
|
||||||
|
|
||||||
def test_init(self, mock_file: unittest.mock.Mock):
|
def test_init(self, mock_file: unittest.mock.Mock):
|
||||||
"""Test that the tagger thread was initialized properly."""
|
"""Test that the tagger thread was initialized properly."""
|
||||||
self.assertIsInstance(self.tagger, threading.Thread)
|
self.assertIsInstance(self.tagger, emmental.thread.Thread)
|
||||||
self.assertIsInstance(self.tagger._condition, threading.Condition)
|
self.assertIsNone(self.tagger._connection)
|
||||||
self.assertTrue(self.tagger.is_alive())
|
self.assertTrue(self.tagger.is_alive())
|
||||||
|
|
||||||
def test_stop(self, mock_file: unittest.mock.Mock):
|
def test_stop(self, mock_file: unittest.mock.Mock):
|
||||||
|
@ -285,74 +285,49 @@ class TestTaggerThread(tests.util.TestCase):
|
||||||
mock_connection = unittest.mock.Mock()
|
mock_connection = unittest.mock.Mock()
|
||||||
mock_connection.close = unittest.mock.Mock()
|
mock_connection.close = unittest.mock.Mock()
|
||||||
|
|
||||||
self.tagger._file = "abcde"
|
|
||||||
self.tagger._mtime = 12345
|
|
||||||
self.tagger._connection = mock_connection
|
self.tagger._connection = mock_connection
|
||||||
|
|
||||||
with unittest.mock.patch.object(self.tagger._condition, "notify",
|
|
||||||
wraps=self.tagger._condition.notify) \
|
|
||||||
as mock_notify:
|
|
||||||
self.tagger.stop()
|
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)
|
self.assertIsNone(self.tagger._connection)
|
||||||
mock_connection.close.assert_called()
|
mock_connection.close.assert_called()
|
||||||
|
|
||||||
def test_tag_file(self, mock_file: unittest.mock.Mock):
|
def test_tag_file(self, mock_file: unittest.mock.Mock):
|
||||||
"""Test asking the thread to tag a file."""
|
"""Test asking the thread to tag a file."""
|
||||||
path = pathlib.Path("/a/b/c.ogg")
|
path = pathlib.Path("/a/b/c.ogg")
|
||||||
|
|
||||||
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
|
mock_file.return_value = None
|
||||||
|
|
||||||
self.tagger.ready.set()
|
self.tagger.ready.set()
|
||||||
self.tagger._tags = 12345
|
self.tagger.tag_file(path, mtime=None)
|
||||||
self.tagger.tag_file(path, None)
|
self.assertEqual(self.tagger._task, {"path": path, "mtime": 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.tagger.ready.wait()
|
||||||
self.assertIsNone(self.tagger._tags)
|
|
||||||
mock_file.assert_called_with(pathlib.Path("/a/b/c.ogg"), None)
|
mock_file.assert_called_with(pathlib.Path("/a/b/c.ogg"), None)
|
||||||
|
|
||||||
mock_file.return_value = self.make_tags(dict())
|
mock_file.return_value = self.make_tags(dict())
|
||||||
self.tagger.tag_file(path, 12345)
|
self.tagger.tag_file(path, mtime=12345)
|
||||||
self.assertEqual(self.tagger._mtime, 12345)
|
self.assertEqual(self.tagger._task, {"path": path, "mtime": 12345})
|
||||||
|
|
||||||
self.tagger.ready.wait()
|
self.tagger.ready.wait()
|
||||||
self.assertIsNotNone(self.tagger._tags)
|
mock_file.assert_called_with(path, 12345)
|
||||||
mock_file.assert_called_with(self.tagger._file, 12345)
|
|
||||||
|
|
||||||
def test_get_result(self, mock_file: unittest.mock.Mock):
|
def test_get_result(self, mock_file: unittest.mock.Mock):
|
||||||
"""Test creating a Tags structure after tagging."""
|
"""Test creating a Tags structure after tagging."""
|
||||||
mock_file.return_value = None
|
mock_file.return_value = None
|
||||||
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), None)
|
self.assertIsNone(self.tagger.get_result(db=self.sql,
|
||||||
self.assertTupleEqual(self.tagger.get_result(self.sql, self.library),
|
library=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.tagger.ready.wait()
|
||||||
self.assertTupleEqual(self.tagger.get_result(self.sql, self.library),
|
self.assertTupleEqual(self.tagger.get_result(db=self.sql,
|
||||||
(pathlib.Path("/a/b/c.ogg"), None))
|
library=self.library),
|
||||||
self.assertIsNone(self.tagger._file)
|
(track_path, None))
|
||||||
|
|
||||||
mock_file.return_value = self.make_tags(dict())
|
mock_file.return_value = self.make_tags(dict())
|
||||||
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), None)
|
self.tagger.tag_file(track_path, mtime=None)
|
||||||
self.tagger.ready.wait()
|
self.tagger.ready.wait()
|
||||||
(file, tags) = self.tagger.get_result(self.sql, self.library)
|
res = self.tagger.get_result(db=self.sql, library=self.library)
|
||||||
self.assertEqual(file, pathlib.Path("/a/b/c.ogg"))
|
self.assertTupleEqual(res, (track_path, res[1]))
|
||||||
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("emmental.db.connection.Connection.__call__")
|
||||||
@unittest.mock.patch("musicbrainzngs.get_artist_by_id")
|
@unittest.mock.patch("musicbrainzngs.get_artist_by_id")
|
||||||
|
@ -370,7 +345,7 @@ class TestTaggerThread(tests.util.TestCase):
|
||||||
mock_cursor.fetchone = unittest.mock.Mock(return_value=None)
|
mock_cursor.fetchone = unittest.mock.Mock(return_value=None)
|
||||||
mock_connection.return_value = mock_cursor
|
mock_connection.return_value = mock_cursor
|
||||||
|
|
||||||
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), None)
|
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), mtime=None)
|
||||||
self.tagger.ready.wait()
|
self.tagger.ready.wait()
|
||||||
self.assertEqual(audio_tags.artists[0].name, "Some Artist")
|
self.assertEqual(audio_tags.artists[0].name, "Some Artist")
|
||||||
self.assertEqual(audio_tags.artists[1].name, "Some Artist")
|
self.assertEqual(audio_tags.artists[1].name, "Some Artist")
|
||||||
|
@ -394,7 +369,7 @@ class TestTaggerThread(tests.util.TestCase):
|
||||||
|
|
||||||
self.assertIsNone(self.tagger._connection)
|
self.assertIsNone(self.tagger._connection)
|
||||||
|
|
||||||
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), None)
|
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), mtime=None)
|
||||||
self.tagger.ready.wait()
|
self.tagger.ready.wait()
|
||||||
self.assertIsInstance(self.tagger._connection,
|
self.assertIsInstance(self.tagger._connection,
|
||||||
emmental.db.connection.Connection)
|
emmental.db.connection.Connection)
|
||||||
|
|
|
@ -214,6 +214,7 @@ class TestTrackTable(tests.util.TestCase):
|
||||||
|
|
||||||
def test_create(self):
|
def test_create(self):
|
||||||
"""Test creating a new Track."""
|
"""Test creating a new Track."""
|
||||||
|
now = datetime.datetime.now(datetime.UTC)
|
||||||
track = self.tracks.create(self.library, pathlib.Path("/a/b/c.ogg"),
|
track = self.tracks.create(self.library, pathlib.Path("/a/b/c.ogg"),
|
||||||
self.medium, self.year)
|
self.medium, self.year)
|
||||||
self.assertIsInstance(track, emmental.db.tracks.Track)
|
self.assertIsInstance(track, emmental.db.tracks.Track)
|
||||||
|
@ -221,7 +222,7 @@ class TestTrackTable(tests.util.TestCase):
|
||||||
self.assertEqual(track.mediumid, self.medium.mediumid)
|
self.assertEqual(track.mediumid, self.medium.mediumid)
|
||||||
self.assertEqual(track.year, 1988)
|
self.assertEqual(track.year, 1988)
|
||||||
self.assertEqual(track.path, pathlib.Path("/a/b/c.ogg"))
|
self.assertEqual(track.path, pathlib.Path("/a/b/c.ogg"))
|
||||||
self.assertEqual(track.added, datetime.datetime.utcnow().date())
|
self.assertEqual(track.added, now.date())
|
||||||
|
|
||||||
track2 = self.tracks.create(self.library, pathlib.Path("/a/b/d.ogg"),
|
track2 = self.tracks.create(self.library, pathlib.Path("/a/b/d.ogg"),
|
||||||
self.medium, self.year, title="Test Track",
|
self.medium, self.year, title="Test Track",
|
||||||
|
@ -247,10 +248,14 @@ class TestTrackTable(tests.util.TestCase):
|
||||||
|
|
||||||
def test_create_restore(self):
|
def test_create_restore(self):
|
||||||
"""Test restoring saved track data."""
|
"""Test restoring saved track data."""
|
||||||
now = datetime.datetime.now()
|
now = datetime.datetime.now(datetime.UTC)
|
||||||
|
today = now.date()
|
||||||
|
yesterday = today - datetime.timedelta(days=1)
|
||||||
self.sql("""INSERT INTO saved_track_data
|
self.sql("""INSERT INTO saved_track_data
|
||||||
(mbid, favorite, playcount, lastplayed, laststarted)
|
(mbid, favorite, playcount,
|
||||||
VALUES (?, ?, ?, ? , ?)""", "ab-cd-ef", True, 42, now, now)
|
lastplayed, laststarted, added)
|
||||||
|
VALUES (?, ?, ?, ? , ?, ?)""",
|
||||||
|
"ab-cd-ef", True, 42, now, now, yesterday)
|
||||||
|
|
||||||
track1 = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
track1 = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
||||||
self.medium, self.year)
|
self.medium, self.year)
|
||||||
|
@ -258,6 +263,7 @@ class TestTrackTable(tests.util.TestCase):
|
||||||
self.assertEqual(track1.playcount, 0)
|
self.assertEqual(track1.playcount, 0)
|
||||||
self.assertIsNone(track1.lastplayed)
|
self.assertIsNone(track1.lastplayed)
|
||||||
self.assertIsNone(track1.laststarted)
|
self.assertIsNone(track1.laststarted)
|
||||||
|
self.assertEqual(track1.added, today)
|
||||||
|
|
||||||
row = self.sql("SELECT COUNT(*) FROM saved_track_data").fetchone()
|
row = self.sql("SELECT COUNT(*) FROM saved_track_data").fetchone()
|
||||||
self.assertEqual(row["COUNT(*)"], 1)
|
self.assertEqual(row["COUNT(*)"], 1)
|
||||||
|
@ -268,6 +274,7 @@ class TestTrackTable(tests.util.TestCase):
|
||||||
self.assertEqual(track2.playcount, 42)
|
self.assertEqual(track2.playcount, 42)
|
||||||
self.assertEqual(track2.lastplayed, now)
|
self.assertEqual(track2.lastplayed, now)
|
||||||
self.assertEqual(track2.laststarted, now)
|
self.assertEqual(track2.laststarted, now)
|
||||||
|
self.assertEqual(track2.added, yesterday)
|
||||||
|
|
||||||
row = self.sql("SELECT COUNT(*) FROM saved_track_data").fetchone()
|
row = self.sql("SELECT COUNT(*) FROM saved_track_data").fetchone()
|
||||||
self.assertEqual(row["COUNT(*)"], 0)
|
self.assertEqual(row["COUNT(*)"], 0)
|
||||||
|
@ -286,9 +293,23 @@ class TestTrackTable(tests.util.TestCase):
|
||||||
|
|
||||||
self.assertFalse(track.delete())
|
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):
|
def test_delete_save(self):
|
||||||
"""Test saving track data when a track is deleted."""
|
"""Test saving track data when a track is deleted."""
|
||||||
now = datetime.datetime.now()
|
now = datetime.datetime.now(datetime.UTC)
|
||||||
track1 = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
track1 = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
||||||
self.medium, self.year, mbid="ab-cd-ef")
|
self.medium, self.year, mbid="ab-cd-ef")
|
||||||
track2 = self.tracks.create(self.library, pathlib.Path("/a/b/2.ogg"),
|
track2 = self.tracks.create(self.library, pathlib.Path("/a/b/2.ogg"),
|
||||||
|
@ -308,6 +329,7 @@ class TestTrackTable(tests.util.TestCase):
|
||||||
self.assertEqual(rows[0]["laststarted"], now)
|
self.assertEqual(rows[0]["laststarted"], now)
|
||||||
self.assertEqual(rows[0]["lastplayed"], now)
|
self.assertEqual(rows[0]["lastplayed"], now)
|
||||||
self.assertEqual(rows[0]["playcount"], 42)
|
self.assertEqual(rows[0]["playcount"], 42)
|
||||||
|
self.assertEqual(rows[0]["added"], now.date())
|
||||||
|
|
||||||
def test_filter(self):
|
def test_filter(self):
|
||||||
"""Test filtering the Track table."""
|
"""Test filtering the Track table."""
|
||||||
|
@ -333,7 +355,7 @@ class TestTrackTable(tests.util.TestCase):
|
||||||
|
|
||||||
def test_load(self):
|
def test_load(self):
|
||||||
"""Test loading tracks from the database."""
|
"""Test loading tracks from the database."""
|
||||||
now = datetime.datetime.now()
|
now = datetime.datetime.now(datetime.UTC)
|
||||||
self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
||||||
self.medium, self.year)
|
self.medium, self.year)
|
||||||
self.tracks.create(self.library, pathlib.Path("/a/b/2.ogg"),
|
self.tracks.create(self.library, pathlib.Path("/a/b/2.ogg"),
|
||||||
|
@ -478,6 +500,40 @@ class TestTrackTable(tests.util.TestCase):
|
||||||
self.assertListEqual(self.tracks.get_genres(track),
|
self.assertListEqual(self.tracks.get_genres(track),
|
||||||
[genre1, genre2])
|
[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):
|
def test_mark_path_active(self):
|
||||||
"""Test marking a path as active."""
|
"""Test marking a path as active."""
|
||||||
self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
||||||
|
@ -498,7 +554,8 @@ class TestTrackTable(tests.util.TestCase):
|
||||||
|
|
||||||
track.restart()
|
track.restart()
|
||||||
self.assertTrue(track.active)
|
self.assertTrue(track.active)
|
||||||
self.assertGreater(datetime.datetime.utcnow(), track.restarted)
|
self.assertGreater(datetime.datetime.now(datetime.UTC),
|
||||||
|
track.restarted)
|
||||||
self.assertEqual(self.tracks.current_track, track)
|
self.assertEqual(self.tracks.current_track, track)
|
||||||
|
|
||||||
self.playlists.previous.remove_track.assert_not_called()
|
self.playlists.previous.remove_track.assert_not_called()
|
||||||
|
@ -527,6 +584,8 @@ class TestTrackTable(tests.util.TestCase):
|
||||||
"""Test marking that a Track has stopped playback."""
|
"""Test marking that a Track has stopped playback."""
|
||||||
track = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
track = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
||||||
self.medium, self.year, length=10)
|
self.medium, self.year, length=10)
|
||||||
|
track_played = unittest.mock.Mock()
|
||||||
|
self.tracks.connect("track-played", track_played)
|
||||||
|
|
||||||
track.start()
|
track.start()
|
||||||
with unittest.mock.patch.object(self.sql, "commit",
|
with unittest.mock.patch.object(self.sql, "commit",
|
||||||
|
@ -542,9 +601,13 @@ class TestTrackTable(tests.util.TestCase):
|
||||||
self.assertIsNone(track.lastplayed)
|
self.assertIsNone(track.lastplayed)
|
||||||
self.assertIsNone(self.tracks.current_track)
|
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.most_played.reload_tracks.assert_not_called()
|
||||||
self.playlists.queued.remove_track.assert_not_called()
|
self.playlists.queued.remove_track.assert_not_called()
|
||||||
self.playlists.unplayed.remove_track.assert_not_called()
|
self.playlists.unplayed.remove_track.assert_not_called()
|
||||||
|
track_played.assert_not_called()
|
||||||
|
|
||||||
track.start()
|
track.start()
|
||||||
with unittest.mock.patch.object(self.sql, "commit",
|
with unittest.mock.patch.object(self.sql, "commit",
|
||||||
|
@ -559,14 +622,22 @@ class TestTrackTable(tests.util.TestCase):
|
||||||
self.assertEqual(row["lastplayed"], track.laststarted)
|
self.assertEqual(row["lastplayed"], track.laststarted)
|
||||||
self.assertEqual(track.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.most_played.reload_tracks.assert_called()
|
||||||
self.playlists.queued.remove_track.assert_called_with(track)
|
self.playlists.queued.remove_track.assert_called_with(track)
|
||||||
self.playlists.unplayed.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_stop_restarted_track(self):
|
def test_stop_restarted_track(self):
|
||||||
"""Test marking that a restarted Track has stopped playback."""
|
"""Test marking that a restarted Track has stopped playback."""
|
||||||
track = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
track = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
||||||
self.medium, self.year, length=10)
|
self.medium, self.year, length=10)
|
||||||
|
track_played = unittest.mock.Mock()
|
||||||
|
self.tracks.connect("track-played", track_played)
|
||||||
|
|
||||||
track.restart()
|
track.restart()
|
||||||
track.stop(3)
|
track.stop(3)
|
||||||
|
@ -581,9 +652,13 @@ class TestTrackTable(tests.util.TestCase):
|
||||||
self.assertIsNone(track.restarted)
|
self.assertIsNone(track.restarted)
|
||||||
self.assertIsNone(self.tracks.current_track)
|
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.most_played.reload_tracks.assert_not_called()
|
||||||
self.playlists.queued.remove_track.assert_not_called()
|
self.playlists.queued.remove_track.assert_not_called()
|
||||||
self.playlists.unplayed.remove_track.assert_not_called()
|
self.playlists.unplayed.remove_track.assert_not_called()
|
||||||
|
track_played.assert_not_called()
|
||||||
|
|
||||||
track.restart()
|
track.restart()
|
||||||
restarted = track.restarted
|
restarted = track.restarted
|
||||||
|
@ -597,9 +672,15 @@ class TestTrackTable(tests.util.TestCase):
|
||||||
self.assertEqual(row["laststarted"], restarted)
|
self.assertEqual(row["laststarted"], restarted)
|
||||||
self.assertEqual(track.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.most_played.reload_tracks.assert_called_with(idle=True)
|
||||||
self.playlists.queued.remove_track.assert_called_with(track)
|
self.playlists.queued.remove_track.assert_called_with(track)
|
||||||
self.playlists.unplayed.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):
|
def test_current_track(self):
|
||||||
"""Test the current-track and have-current-track properties."""
|
"""Test the current-track and have-current-track properties."""
|
||||||
|
|
|
@ -75,12 +75,14 @@ class TestYearTable(tests.util.TestCase):
|
||||||
|
|
||||||
def test_create(self):
|
def test_create(self):
|
||||||
"""Test creating a year playlist."""
|
"""Test creating a year playlist."""
|
||||||
|
decade = self.sql.decades.create(1980)
|
||||||
year = self.table.create(1988)
|
year = self.table.create(1988)
|
||||||
self.assertIsInstance(year, emmental.db.years.Year)
|
self.assertIsInstance(year, emmental.db.years.Year)
|
||||||
self.assertEqual(year.year, 1988)
|
self.assertEqual(year.year, 1988)
|
||||||
self.assertEqual(year.name, "1988")
|
self.assertEqual(year.name, "1988")
|
||||||
self.assertEqual(year.sort_order,
|
self.assertEqual(year.sort_order,
|
||||||
"release, albumartist, album, mediumno, number")
|
"release, albumartist, album, mediumno, number")
|
||||||
|
self.assertTrue(year in decade.child_set)
|
||||||
|
|
||||||
cur = self.sql("SELECT COUNT(year) FROM years")
|
cur = self.sql("SELECT COUNT(year) FROM years")
|
||||||
self.assertEqual(cur.fetchone()["COUNT(year)"], 1)
|
self.assertEqual(cur.fetchone()["COUNT(year)"], 1)
|
||||||
|
@ -93,8 +95,10 @@ class TestYearTable(tests.util.TestCase):
|
||||||
|
|
||||||
def test_delete(self):
|
def test_delete(self):
|
||||||
"""Test deleting a year playlist."""
|
"""Test deleting a year playlist."""
|
||||||
|
decade = self.sql.decades.create(1980)
|
||||||
year = self.table.create(1988)
|
year = self.table.create(1988)
|
||||||
self.assertTrue(year.delete())
|
self.assertTrue(year.delete())
|
||||||
|
self.assertFalse(year in decade.child_set)
|
||||||
|
|
||||||
cur = self.sql("SELECT COUNT(year) FROM years")
|
cur = self.sql("SELECT COUNT(year) FROM years")
|
||||||
self.assertEqual(cur.fetchone()["COUNT(year)"], 0)
|
self.assertEqual(cur.fetchone()["COUNT(year)"], 0)
|
||||||
|
|
|
@ -36,31 +36,103 @@ class TestHeader(tests.util.TestCase):
|
||||||
self.assertEqual(self.header._title.get_tooltip_text(),
|
self.assertEqual(self.header._title.get_tooltip_text(),
|
||||||
emmental.gsetup.env_string())
|
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):
|
def test_open(self):
|
||||||
"""Check that the Open button works as expected."""
|
"""Check that the Open ActionRow works as expected."""
|
||||||
self.assertIsInstance(self.header._open, emmental.header.open.Button)
|
self.assertIsInstance(self.header._open, emmental.header.open.OpenRow)
|
||||||
|
self.assertEqual(self.header._menu_box.get_row_at_index(0),
|
||||||
|
self.header._open)
|
||||||
|
|
||||||
signal = unittest.mock.Mock()
|
signal = unittest.mock.Mock()
|
||||||
self.header.connect("track-requested", signal)
|
self.header.connect("track-requested", signal)
|
||||||
self.header._open.emit("track-requested", pathlib.Path("/a/b/c/1.ogg"))
|
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"))
|
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):
|
def test_settings(self):
|
||||||
"""Check that the Settings window is set up correctly."""
|
"""Check that the SettingsRow is set up correctly."""
|
||||||
self.assertIsInstance(self.header._settings, Gtk.Button)
|
self.assertIsInstance(self.header._settings,
|
||||||
self.assertIsInstance(self.header._window,
|
emmental.header.settings.Row)
|
||||||
emmental.header.settings.Window)
|
self.assertEqual(self.header._menu_box.get_row_at_index(2),
|
||||||
|
self.header._settings)
|
||||||
|
|
||||||
self.assertEqual(self.header.sql, self.sql)
|
def test_menu_button(self):
|
||||||
self.assertEqual(self.header._settings.get_icon_name(),
|
"""Check that the menu popover button is set up properly."""
|
||||||
"settings-symbolic")
|
self.assertIsInstance(self.header._menu_button,
|
||||||
self.assertEqual(self.header._settings.get_tooltip_text(),
|
emmental.buttons.PopoverButton)
|
||||||
"open settings editor")
|
self.assertIsNotNone(self.header._menu_button.props.parent)
|
||||||
|
|
||||||
with unittest.mock.patch.object(self.header._window,
|
self.assertEqual(self.header._menu_button.props.icon_name,
|
||||||
"present") as mock_present:
|
"open-menu-symbolic")
|
||||||
self.header._settings.emit("clicked")
|
self.assertEqual(self.header._menu_button.popover_child,
|
||||||
mock_present.assert_called()
|
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)
|
||||||
|
|
||||||
def test_volume_icons(self):
|
def test_volume_icons(self):
|
||||||
"""Check that the volume icons box is set up properly."""
|
"""Check that the volume icons box is set up properly."""
|
||||||
|
@ -95,7 +167,7 @@ class TestHeader(tests.util.TestCase):
|
||||||
self.assertEqual(self.header._volume.volume, vol)
|
self.assertEqual(self.header._volume.volume, vol)
|
||||||
self.assertEqual(self.header._volume_icon.get_icon_name(),
|
self.assertEqual(self.header._volume_icon.get_icon_name(),
|
||||||
f"audio-volume-{icon}-symbolic")
|
f"audio-volume-{icon}-symbolic")
|
||||||
self.assertEqual(self.header._button.get_tooltip_text(),
|
self.assertEqual(self.header._vol_button.get_tooltip_text(),
|
||||||
f"volume: {i*10}%\n"
|
f"volume: {i*10}%\n"
|
||||||
"background listening: off\nnormalizing: off")
|
"background listening: off\nnormalizing: off")
|
||||||
|
|
||||||
|
@ -113,19 +185,19 @@ class TestHeader(tests.util.TestCase):
|
||||||
self.assertTrue(self.header._background.enabled)
|
self.assertTrue(self.header._background.enabled)
|
||||||
self.assertEqual(self.header._background_icon.get_icon_name(),
|
self.assertEqual(self.header._background_icon.get_icon_name(),
|
||||||
"sound-wave-alt")
|
"sound-wave-alt")
|
||||||
self.assertEqual(self.header._button.get_tooltip_text(),
|
self.assertEqual(self.header._vol_button.get_tooltip_text(),
|
||||||
"volume: 100%\nbackground listening: 50%\n"
|
"volume: 100%\nbackground listening: 50%\n"
|
||||||
"normalizing: off")
|
"normalizing: off")
|
||||||
|
|
||||||
self.header.bg_volume = 0.75
|
self.header.bg_volume = 0.75
|
||||||
self.assertEqual(self.header._background.volume, 0.75)
|
self.assertEqual(self.header._background.volume, 0.75)
|
||||||
self.assertEqual(self.header._button.get_tooltip_text(),
|
self.assertEqual(self.header._vol_button.get_tooltip_text(),
|
||||||
"volume: 100%\nbackground listening: 75%\n"
|
"volume: 100%\nbackground listening: 75%\n"
|
||||||
"normalizing: off")
|
"normalizing: off")
|
||||||
|
|
||||||
self.header._background.volume = 0.25
|
self.header._background.volume = 0.25
|
||||||
self.assertEqual(self.header.bg_volume, 0.25)
|
self.assertEqual(self.header.bg_volume, 0.25)
|
||||||
self.assertEqual(self.header._button.get_tooltip_text(),
|
self.assertEqual(self.header._vol_button.get_tooltip_text(),
|
||||||
"volume: 100%\nbackground listening: 25%\n"
|
"volume: 100%\nbackground listening: 25%\n"
|
||||||
"normalizing: off")
|
"normalizing: off")
|
||||||
|
|
||||||
|
@ -145,7 +217,7 @@ class TestHeader(tests.util.TestCase):
|
||||||
self.header.rg_mode = "track"
|
self.header.rg_mode = "track"
|
||||||
self.assertTrue(self.header._replaygain.enabled)
|
self.assertTrue(self.header._replaygain.enabled)
|
||||||
self.assertEqual(self.header._replaygain.mode, "track")
|
self.assertEqual(self.header._replaygain.mode, "track")
|
||||||
self.assertEqual(self.header._button.get_tooltip_text(),
|
self.assertEqual(self.header._vol_button.get_tooltip_text(),
|
||||||
"volume: 100%\nbackground listening: off\n"
|
"volume: 100%\nbackground listening: off\n"
|
||||||
"normalizing: track mode")
|
"normalizing: track mode")
|
||||||
|
|
||||||
|
@ -153,43 +225,47 @@ class TestHeader(tests.util.TestCase):
|
||||||
self.header._replaygain.mode = "album"
|
self.header._replaygain.mode = "album"
|
||||||
self.assertFalse(self.header.rg_enabled)
|
self.assertFalse(self.header.rg_enabled)
|
||||||
self.assertEqual(self.header.rg_mode, "album")
|
self.assertEqual(self.header.rg_mode, "album")
|
||||||
self.assertEqual(self.header._button.get_tooltip_text(),
|
self.assertEqual(self.header._vol_button.get_tooltip_text(),
|
||||||
"volume: 100%\nbackground listening: off\n"
|
"volume: 100%\nbackground listening: off\n"
|
||||||
"normalizing: off")
|
"normalizing: off")
|
||||||
|
|
||||||
def test_popover_button(self):
|
def test_volume_popover_button(self):
|
||||||
"""Check that the menu popover button was set up correctly."""
|
"""Check that the volume popover button was set up correctly."""
|
||||||
self.assertIsInstance(self.header._button,
|
self.assertIsInstance(self.header._vol_button,
|
||||||
emmental.buttons.PopoverButton)
|
emmental.buttons.PopoverButton)
|
||||||
self.assertEqual(self.header._button.popover_child, self.header._box)
|
self.assertEqual(self.header._vol_button.popover_child,
|
||||||
|
self.header._vol_box)
|
||||||
|
|
||||||
self.assertEqual(self.header._button.get_child(), self.header._icons)
|
self.assertEqual(self.header._vol_button.get_child(),
|
||||||
self.assertEqual(self.header._button.get_margin_end(), 6)
|
self.header._icons)
|
||||||
self.assertFalse(self.header._button.get_has_frame())
|
self.assertEqual(self.header._vol_button.get_margin_end(), 6)
|
||||||
|
self.assertFalse(self.header._vol_button.get_has_frame())
|
||||||
|
|
||||||
def test_popover_child(self):
|
def test_volume_popover_child(self):
|
||||||
"""Check that the menu popover button child was set up correctly."""
|
"""Check that the volume popover button child was set up correctly."""
|
||||||
self.assertIsInstance(self.header._box, Gtk.ListBox)
|
self.assertIsInstance(self.header._vol_box, Gtk.ListBox)
|
||||||
self.assertEqual(self.header._box.get_selection_mode(),
|
self.assertEqual(self.header._vol_box.get_selection_mode(),
|
||||||
Gtk.SelectionMode.NONE)
|
Gtk.SelectionMode.NONE)
|
||||||
self.assertTrue(self.header._box.has_css_class("boxed-list"))
|
self.assertTrue(self.header._vol_box.has_css_class("boxed-list"))
|
||||||
|
|
||||||
self.assertEqual(self.header._box.get_row_at_index(0),
|
self.assertEqual(self.header._vol_box.get_row_at_index(0),
|
||||||
self.header._volume)
|
self.header._volume)
|
||||||
self.assertEqual(self.header._box.get_row_at_index(1),
|
self.assertEqual(self.header._vol_box.get_row_at_index(1),
|
||||||
self.header._background)
|
self.header._background)
|
||||||
self.assertEqual(self.header._box.get_row_at_index(2),
|
self.assertEqual(self.header._vol_box.get_row_at_index(2),
|
||||||
self.header._replaygain)
|
self.header._replaygain)
|
||||||
|
|
||||||
def test_accelerators(self):
|
def test_accelerators(self):
|
||||||
"""Check that the accelerators list is set up properly."""
|
"""Check that the accelerators list is set up properly."""
|
||||||
entries = [("open-file", self.header._open.activate, "<Control>o"),
|
entries = [("open-file", self.header._open.activate, "<Control>o"),
|
||||||
("decrease-volume", self.header._volume.decrement,
|
("decrease-volume", self.header._volume.decrement,
|
||||||
"<Control>Down"),
|
"<Shift><Control>Down"),
|
||||||
("increase-volume", self.header._volume.increment,
|
("increase-volume", self.header._volume.increment,
|
||||||
"<Control>Up"),
|
"<Shift><Control>Up"),
|
||||||
("toggle-bg-mode", self.header._background.activate,
|
("toggle-bg-mode", self.header._background.activate,
|
||||||
"<Shift><Control>b"),
|
"<Shift><Control>b"),
|
||||||
|
("toggle-sidebar", self.header._show_sidebar.activate,
|
||||||
|
"<Control>bracketright"),
|
||||||
("edit-settings", self.header._settings.activate,
|
("edit-settings", self.header._settings.activate,
|
||||||
"<Shift><Control>s")]
|
"<Shift><Control>s")]
|
||||||
|
|
||||||
|
|
25
tests/header/test_listenbrainz.py
Normal file
25
tests/header/test_listenbrainz.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
# 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")
|
|
@ -1,58 +1,69 @@
|
||||||
# Copyright 2023 (c) Anna Schumaker.
|
# Copyright 2023 (c) Anna Schumaker.
|
||||||
"""Tests our Open button."""
|
"""Tests our Open Adw.ActionRow."""
|
||||||
import emmental.header.open
|
import emmental.header.open
|
||||||
import pathlib
|
import pathlib
|
||||||
import unittest
|
import unittest
|
||||||
from gi.repository import Gio
|
from gi.repository import Gio
|
||||||
from gi.repository import Gtk
|
from gi.repository import Gtk
|
||||||
|
from gi.repository import Adw
|
||||||
|
|
||||||
|
|
||||||
class TestButton(unittest.TestCase):
|
class TestOpenRow(unittest.TestCase):
|
||||||
"""Test the Open button."""
|
"""Test the Open row."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""Set up common variables."""
|
"""Set up common variables."""
|
||||||
self.button = emmental.header.open.Button()
|
self.row = emmental.header.open.OpenRow()
|
||||||
|
|
||||||
def test_button(self):
|
def test_action_row(self):
|
||||||
"""Check that the button was set up properly."""
|
"""Check that the action row was set up properly."""
|
||||||
self.assertIsInstance(self.button, Gtk.Button)
|
self.assertIsInstance(self.row, Adw.ActionRow)
|
||||||
self.assertEqual(self.button.get_icon_name(), "document-open-symbolic")
|
self.assertIsInstance(self.row._prefix, Gtk.Image)
|
||||||
self.assertEqual(self.button.get_tooltip_text(),
|
|
||||||
"open a file for playback")
|
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_filter(self):
|
def test_filter(self):
|
||||||
"""Check that the file filter is set up properly."""
|
"""Check that the file filter is set up properly."""
|
||||||
self.assertIsInstance(self.button._filter, Gtk.FileFilter)
|
self.assertIsInstance(self.row._filter, Gtk.FileFilter)
|
||||||
self.assertIsInstance(self.button._filters, Gio.ListStore)
|
self.assertIsInstance(self.row._filters, Gio.ListStore)
|
||||||
|
|
||||||
self.assertEqual(self.button._filter.get_name(), "Audio Files")
|
self.assertEqual(self.row._filter.get_name(), "Audio Files")
|
||||||
self.assertEqual(self.button._filters[0], self.button._filter)
|
self.assertEqual(self.row._filters[0], self.row._filter)
|
||||||
|
|
||||||
def test_dialog(self):
|
def test_dialog(self):
|
||||||
"""Check that the file dialog is set up properly."""
|
"""Check that the file dialog is set up properly."""
|
||||||
self.assertIsInstance(self.button._dialog, Gtk.FileDialog)
|
self.assertIsInstance(self.row._dialog, Gtk.FileDialog)
|
||||||
self.assertEqual(self.button._dialog.get_title(), "Pick a Track")
|
self.assertEqual(self.row._dialog.get_title(), "Pick a Track")
|
||||||
self.assertEqual(self.button._dialog.get_filters(),
|
self.assertEqual(self.row._dialog.get_filters(),
|
||||||
self.button._filters)
|
self.row._filters)
|
||||||
self.assertTrue(self.button._dialog.get_modal())
|
self.assertTrue(self.row._dialog.get_modal())
|
||||||
|
|
||||||
def test_clicked(self):
|
def test_activate(self):
|
||||||
"""Test clicking on the button."""
|
"""Test activating an OpenRow."""
|
||||||
with unittest.mock.patch.object(self.button._dialog,
|
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._dialog,
|
||||||
"open") as mock_open:
|
"open") as mock_open:
|
||||||
self.button.emit("clicked")
|
self.row.emit("activated")
|
||||||
|
mock_popdown.assert_called()
|
||||||
mock_open.assert_called_with(None, None,
|
mock_open.assert_called_with(None, None,
|
||||||
self.button._Button__async_ready)
|
self.row._OpenRow__async_ready)
|
||||||
|
|
||||||
with unittest.mock.patch.object(self.button._dialog,
|
with unittest.mock.patch.object(self.row._dialog,
|
||||||
"open_finish") as mock_finish:
|
"open_finish") as mock_finish:
|
||||||
task = Gio.Task()
|
task = Gio.Task()
|
||||||
signal = unittest.mock.Mock()
|
signal = unittest.mock.Mock()
|
||||||
mock_finish.return_value = Gio.File.new_for_path("/a/b/c/1.ogg")
|
mock_finish.return_value = Gio.File.new_for_path("/a/b/c/1.ogg")
|
||||||
self.button.connect("track-requested", signal)
|
self.row.connect("track-requested", signal)
|
||||||
|
|
||||||
self.button._Button__async_ready(self.button._dialog, task)
|
self.row._OpenRow__async_ready(self.row._dialog, task)
|
||||||
mock_finish.assert_called_with(task)
|
mock_finish.assert_called_with(task)
|
||||||
signal.assert_called_with(self.button,
|
signal.assert_called_with(self.row, pathlib.Path("/a/b/c/1.ogg"))
|
||||||
pathlib.Path("/a/b/c/1.ogg"))
|
|
||||||
|
|
|
@ -141,3 +141,39 @@ class TestWindow(tests.util.TestCase):
|
||||||
emmental.header.settings.ValueRow)
|
emmental.header.settings.ValueRow)
|
||||||
self.assertEqual(columns[1].get_title(), "Value")
|
self.assertEqual(columns[1].get_title(), "Value")
|
||||||
self.assertEqual(columns[1].get_fixed_width(), 100)
|
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()
|
||||||
|
|
58
tests/listenbrainz/test_listen.py
Normal file
58
tests/listenbrainz/test_listen.py
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
# 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.now(datetime.UTC)
|
||||||
|
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())
|
315
tests/listenbrainz/test_listenbrainz.py
Normal file
315
tests/listenbrainz/test_listenbrainz.py
Normal file
|
@ -0,0 +1,315 @@
|
||||||
|
# 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.now(datetime.UTC)
|
||||||
|
ts2 = datetime.datetime.now(datetime.UTC)
|
||||||
|
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()
|
68
tests/listenbrainz/test_task.py
Normal file
68
tests/listenbrainz/test_task.py
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
# 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",))
|
189
tests/listenbrainz/test_thread.py
Normal file
189
tests/listenbrainz/test_thread.py
Normal file
|
@ -0,0 +1,189 @@
|
||||||
|
# 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")
|
|
@ -115,7 +115,7 @@ class TestNowPlaying(unittest.TestCase):
|
||||||
self.assertEqual(self.card._favorite.get_next_sibling(),
|
self.assertEqual(self.card._favorite.get_next_sibling(),
|
||||||
self.card._jump)
|
self.card._jump)
|
||||||
|
|
||||||
self.assertEqual(self.card._jump.icon_name, "go-jump")
|
self.assertEqual(self.card._jump.icon_name, "arrow4-down-symbolic")
|
||||||
self.assertEqual(self.card._jump.get_tooltip_text(),
|
self.assertEqual(self.card._jump.get_tooltip_text(),
|
||||||
"scroll to current track")
|
"scroll to current track")
|
||||||
self.assertEqual(self.card._jump.get_valign(), Gtk.Align.CENTER)
|
self.assertEqual(self.card._jump.get_valign(), Gtk.Align.CENTER)
|
||||||
|
|
|
@ -165,3 +165,9 @@ class TestHeader(unittest.TestCase):
|
||||||
self.assertTrue(self.header.active)
|
self.assertTrue(self.header.active)
|
||||||
flags = self.header._arrow.get_state_flags()
|
flags = self.header._arrow.get_state_flags()
|
||||||
self.assertTrue(flags & Gtk.StateFlags.CHECKED)
|
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)
|
||||||
|
|
|
@ -69,18 +69,14 @@ class TestIcon(unittest.TestCase):
|
||||||
"""Test the filepath property."""
|
"""Test the filepath property."""
|
||||||
self.assertIsNone(self.icon.filepath)
|
self.assertIsNone(self.icon.filepath)
|
||||||
|
|
||||||
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
|
self.icon.filepath = tests.util.COVER_JPG
|
||||||
mock_new.assert_called_with(str(tests.util.COVER_JPG))
|
texture = self.icon._icon.get_custom_image()
|
||||||
self.assertIsInstance(self.icon._icon.get_custom_image(),
|
self.assertIsInstance(texture, Gdk.Texture)
|
||||||
Gdk.Texture)
|
self.assertDictEqual(emmental.texture.CACHE,
|
||||||
|
{tests.util.COVER_JPG: texture})
|
||||||
|
|
||||||
mock_new.reset_mock()
|
|
||||||
self.icon.filepath = None
|
self.icon.filepath = None
|
||||||
self.assertIsNone(self.icon._icon.get_custom_image())
|
self.assertIsNone(self.icon._icon.get_custom_image())
|
||||||
mock_new.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestSettable(unittest.TestCase):
|
class TestSettable(unittest.TestCase):
|
||||||
|
@ -123,11 +119,15 @@ class TestSettable(unittest.TestCase):
|
||||||
task = Gio.Task()
|
task = Gio.Task()
|
||||||
cover_path = str(tests.util.COVER_JPG)
|
cover_path = str(tests.util.COVER_JPG)
|
||||||
mock_finish.return_value = Gio.File.new_for_path(cover_path)
|
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)
|
self.icon._Settable__async_ready(self.icon._dialog, task)
|
||||||
mock_finish.assert_called_with(task)
|
mock_finish.assert_called_with(task)
|
||||||
self.assertEqual(self.icon.filepath, tests.util.COVER_JPG)
|
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):
|
def test_clearing(self):
|
||||||
"""Test clearing the icon by canceling the FileDialog."""
|
"""Test clearing the icon by canceling the FileDialog."""
|
||||||
mock_set_initial_file = unittest.mock.Mock()
|
mock_set_initial_file = unittest.mock.Mock()
|
||||||
|
|
|
@ -25,7 +25,7 @@ class TestLibraries(tests.util.TestCase):
|
||||||
emmental.sidebar.library.LibraryRow)
|
emmental.sidebar.library.LibraryRow)
|
||||||
|
|
||||||
self.assertEqual(self.libraries.table, self.sql.libraries)
|
self.assertEqual(self.libraries.table, self.sql.libraries)
|
||||||
self.assertEqual(self.libraries.icon_name, "library-music")
|
self.assertEqual(self.libraries.icon_name, "library-music-symbolic")
|
||||||
self.assertEqual(self.libraries.title, "Library Paths")
|
self.assertEqual(self.libraries.title, "Library Paths")
|
||||||
|
|
||||||
def test_extra_widget(self):
|
def test_extra_widget(self):
|
||||||
|
|
|
@ -4,8 +4,6 @@ import emmental.db
|
||||||
import emmental.sidebar.section
|
import emmental.sidebar.section
|
||||||
import tests.util
|
import tests.util
|
||||||
import unittest.mock
|
import unittest.mock
|
||||||
from gi.repository import GObject
|
|
||||||
from gi.repository import GLib
|
|
||||||
from gi.repository import Gtk
|
from gi.repository import Gtk
|
||||||
|
|
||||||
|
|
||||||
|
@ -106,18 +104,12 @@ class TestSection(tests.util.TestCase):
|
||||||
def test_select_playlist(self):
|
def test_select_playlist(self):
|
||||||
"""Test selecting a specific playlist."""
|
"""Test selecting a specific playlist."""
|
||||||
self.section.do_get_subtitle = unittest.mock.Mock(return_value="")
|
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 = self.table.create("Test Playlist")
|
||||||
playlist_selected.assert_not_called()
|
|
||||||
|
|
||||||
with unittest.mock.patch.object(self.section._listview,
|
with unittest.mock.patch.object(self.section._listview,
|
||||||
"activate_action") as mock_action:
|
"scroll_to") as mock_scroll_to:
|
||||||
self.section.select_playlist(playlist)
|
self.section.select_playlist(playlist)
|
||||||
playlist_selected.assert_called_with(self.section, playlist)
|
mock_scroll_to.assert_called_with(0, Gtk.ListScrollFlags.SELECT)
|
||||||
mock_action.assert_called_with("list.scroll-to-item",
|
|
||||||
GLib.Variant.new_uint32(0))
|
|
||||||
|
|
||||||
def test_playlist_selected(self):
|
def test_playlist_selected(self):
|
||||||
"""Test selecting a playlist in the list."""
|
"""Test selecting a playlist in the list."""
|
||||||
|
@ -150,7 +142,7 @@ class TestGroup(tests.util.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""Set up common variables."""
|
"""Set up common variables."""
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.group = emmental.sidebar.section.Group(self.sql)
|
self.view = emmental.sidebar.section.View(self.sql)
|
||||||
self.row_type = emmental.sidebar.row.TreeRow
|
self.row_type = emmental.sidebar.row.TreeRow
|
||||||
self.section1 = emmental.sidebar.section.Section(self.sql.playlists,
|
self.section1 = emmental.sidebar.section.Section(self.sql.playlists,
|
||||||
self.row_type)
|
self.row_type)
|
||||||
|
@ -161,35 +153,40 @@ class TestGroup(tests.util.TestCase):
|
||||||
|
|
||||||
def test_init(self):
|
def test_init(self):
|
||||||
"""Test that the Group is set up properly."""
|
"""Test that the Group is set up properly."""
|
||||||
self.assertIsInstance(self.group, GObject.GObject)
|
self.assertIsInstance(self.view, Gtk.Box)
|
||||||
self.assertListEqual(self.group._sections, [])
|
self.assertListEqual(self.view._sections, [])
|
||||||
self.assertEqual(self.group.sql, self.sql)
|
self.assertEqual(self.view.sql, self.sql)
|
||||||
|
self.assertEqual(self.view.get_orientation(),
|
||||||
|
Gtk.Orientation.VERTICAL)
|
||||||
|
|
||||||
def test_add(self):
|
def test_add(self):
|
||||||
"""Test adding sections to the Group."""
|
"""Test adding sections to the Group."""
|
||||||
self.group.add(self.section1)
|
self.view.add(self.section1)
|
||||||
self.assertListEqual(self.group._sections, [self.section1])
|
self.assertListEqual(self.view._sections, [self.section1])
|
||||||
self.group.add(self.section2)
|
self.assertEqual(self.view.get_first_child(), self.section1)
|
||||||
self.assertListEqual(self.group._sections,
|
|
||||||
|
self.view.add(self.section2)
|
||||||
|
self.assertListEqual(self.view._sections,
|
||||||
[self.section1, self.section2])
|
[self.section1, self.section2])
|
||||||
|
self.assertEqual(self.section1.get_next_sibling(), self.section2)
|
||||||
|
|
||||||
def test_current(self):
|
def test_current(self):
|
||||||
"""Test the current section property."""
|
"""Test the current section property."""
|
||||||
self.group.add(self.section1)
|
self.view.add(self.section1)
|
||||||
self.group.add(self.section2)
|
self.view.add(self.section2)
|
||||||
self.assertIsNone(self.group.current)
|
self.assertIsNone(self.view.current)
|
||||||
|
|
||||||
self.section1.active = True
|
self.section1.active = True
|
||||||
self.assertEqual(self.group.current, self.section1)
|
self.assertEqual(self.view.current, self.section1)
|
||||||
|
|
||||||
self.section2.active = True
|
self.section2.active = True
|
||||||
self.assertEqual(self.group.current, self.section2)
|
self.assertEqual(self.view.current, self.section2)
|
||||||
self.assertFalse(self.section1.active)
|
self.assertFalse(self.section1.active)
|
||||||
|
|
||||||
def test_animation(self):
|
def test_animation(self):
|
||||||
"""Test setting the section animation style."""
|
"""Test setting the section animation style."""
|
||||||
self.group.add(self.section1)
|
self.view.add(self.section1)
|
||||||
self.group.add(self.section2)
|
self.view.add(self.section2)
|
||||||
|
|
||||||
self.section1.active = True
|
self.section1.active = True
|
||||||
self.assertEqual(self.section1.animation,
|
self.assertEqual(self.section1.animation,
|
||||||
|
@ -201,8 +198,8 @@ class TestGroup(tests.util.TestCase):
|
||||||
|
|
||||||
def test_playlist_activated(self):
|
def test_playlist_activated(self):
|
||||||
"""Test responding to the section playlist-activated signal."""
|
"""Test responding to the section playlist-activated signal."""
|
||||||
self.group.add(self.section1)
|
self.view.add(self.section1)
|
||||||
self.group.add(self.section2)
|
self.view.add(self.section2)
|
||||||
self.assertIsNone(self.sql.active_playlist)
|
self.assertIsNone(self.sql.active_playlist)
|
||||||
|
|
||||||
playlist = self.sql.playlists.create("Test Playlist")
|
playlist = self.sql.playlists.create("Test Playlist")
|
||||||
|
@ -215,16 +212,16 @@ class TestGroup(tests.util.TestCase):
|
||||||
|
|
||||||
def test_selections(self):
|
def test_selections(self):
|
||||||
"""Test the selected section & playlist properties."""
|
"""Test the selected section & playlist properties."""
|
||||||
self.group.add(self.section1)
|
self.view.add(self.section1)
|
||||||
self.group.add(self.section2)
|
self.view.add(self.section2)
|
||||||
|
|
||||||
self.assertIsNone(self.group.selected_section)
|
self.assertIsNone(self.view.selected_section)
|
||||||
self.assertIsNone(self.group.selected_playlist)
|
self.assertIsNone(self.view.selected_playlist)
|
||||||
|
|
||||||
genre = self.sql.genres.create("Test Genre")
|
genre = self.sql.genres.create("Test Genre")
|
||||||
self.section2.emit("playlist-selected", genre)
|
self.section2.emit("playlist-selected", genre)
|
||||||
self.assertEqual(self.group.selected_section, self.section2)
|
self.assertEqual(self.view.selected_section, self.section2)
|
||||||
self.assertEqual(self.group.selected_playlist, genre)
|
self.assertEqual(self.view.selected_playlist, genre)
|
||||||
|
|
||||||
self.section2.active = True
|
self.section2.active = True
|
||||||
treerow = self.section2._selection.get_selected_item()
|
treerow = self.section2._selection.get_selected_item()
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
import emmental.sidebar
|
import emmental.sidebar
|
||||||
import tests.util
|
import tests.util
|
||||||
import unittest.mock
|
import unittest.mock
|
||||||
|
from gi.repository import GLib
|
||||||
from gi.repository import Gtk
|
from gi.repository import Gtk
|
||||||
|
|
||||||
|
|
||||||
|
@ -23,41 +24,62 @@ class TestSidebar(tests.util.TestCase):
|
||||||
Gtk.Orientation.VERTICAL)
|
Gtk.Orientation.VERTICAL)
|
||||||
self.assertFalse(self.sidebar.get_sensitive())
|
self.assertFalse(self.sidebar.get_sensitive())
|
||||||
|
|
||||||
self.assertTrue(self.sidebar.has_css_class("background"))
|
|
||||||
self.assertTrue(self.sidebar.has_css_class("linked"))
|
|
||||||
self.assertTrue(self.sidebar.has_css_class("card"))
|
self.assertTrue(self.sidebar.has_css_class("card"))
|
||||||
|
|
||||||
|
def test_header(self):
|
||||||
|
"""Test the Sidebar header."""
|
||||||
|
self.assertIsInstance(self.sidebar._header, Gtk.CenterBox)
|
||||||
|
self.assertEqual(self.sidebar.get_first_child(), self.sidebar._header)
|
||||||
|
self.assertTrue(self.sidebar._header.has_css_class("toolbar"))
|
||||||
|
|
||||||
def test_filter(self):
|
def test_filter(self):
|
||||||
"""Test the Sidebar filter entry."""
|
"""Test the Sidebar filter entry."""
|
||||||
self.assertIsInstance(self.sidebar._filter, emmental.entry.Filter)
|
self.assertIsInstance(self.sidebar._filter, emmental.entry.Filter)
|
||||||
|
|
||||||
self.assertEqual(self.sidebar.get_first_child(), self.sidebar._filter)
|
self.assertEqual(self.sidebar._header.get_center_widget(),
|
||||||
|
self.sidebar._filter)
|
||||||
self.assertEqual(self.sidebar._filter.get_placeholder_text(),
|
self.assertEqual(self.sidebar._filter.get_placeholder_text(),
|
||||||
"type to filter playlists")
|
"type to filter playlists")
|
||||||
|
self.assertTrue(self.sidebar._filter.get_hexpand())
|
||||||
|
|
||||||
with unittest.mock.patch.object(self.sql, "filter") as mock_filter:
|
with unittest.mock.patch.object(self.sql, "filter") as mock_filter:
|
||||||
self.sidebar._filter.set_text("test text")
|
self.sidebar._filter.set_text("test text")
|
||||||
self.sidebar._filter.emit("search-changed")
|
self.sidebar._filter.emit("search-changed")
|
||||||
mock_filter.assert_called_with("*test text*")
|
mock_filter.assert_called_with("*test text*")
|
||||||
|
|
||||||
|
def test_jump(self):
|
||||||
|
"""Test the jump button."""
|
||||||
|
self.assertIsInstance(self.sidebar._jump, Gtk.Button)
|
||||||
|
self.assertEqual(self.sidebar._header.get_end_widget(),
|
||||||
|
self.sidebar._jump)
|
||||||
|
|
||||||
|
self.assertEqual(self.sidebar._jump.get_icon_name(),
|
||||||
|
"arrow4-down-symbolic")
|
||||||
|
self.assertEqual(self.sidebar._jump.get_tooltip_text(),
|
||||||
|
"scroll to current playlist")
|
||||||
|
|
||||||
|
self.sql.playlists.load(now=True)
|
||||||
|
with unittest.mock.patch.object(self.sidebar,
|
||||||
|
"select_playlist") as mock_select:
|
||||||
|
self.sidebar._jump.emit("clicked")
|
||||||
|
mock_select.assert_called_with(self.sql.active_playlist)
|
||||||
|
|
||||||
def test_sensitivity_and_startup(self):
|
def test_sensitivity_and_startup(self):
|
||||||
"""Test setting the sidebar sensitivity when all tables have loaded."""
|
"""Test setting the sidebar sensitivity when all tables have loaded."""
|
||||||
tables = [t for t in self.sql.playlist_tables()]
|
|
||||||
self.sidebar.select_playlist = unittest.mock.Mock()
|
self.sidebar.select_playlist = unittest.mock.Mock()
|
||||||
self.sidebar._libraries.extra_widget.emit = unittest.mock.Mock()
|
self.sidebar._libraries.extra_widget.emit = unittest.mock.Mock()
|
||||||
|
|
||||||
for table in tables:
|
|
||||||
self.assertFalse(self.sidebar.get_sensitive())
|
self.assertFalse(self.sidebar.get_sensitive())
|
||||||
self.sidebar.select_playlist.assert_not_called()
|
self.sql.loaded = True
|
||||||
self.sidebar._libraries.extra_widget.emit.assert_not_called()
|
|
||||||
self.sql.emit("table-loaded", table)
|
|
||||||
|
|
||||||
self.assertTrue(self.sidebar.get_sensitive())
|
self.assertTrue(self.sidebar.get_sensitive())
|
||||||
self.sidebar.select_playlist.assert_called()
|
|
||||||
|
playlist = self.sql.playlists.collection
|
||||||
|
self.sidebar.select_playlist.assert_called_with(playlist, 150)
|
||||||
self.sidebar._libraries.extra_widget.emit.assert_called_with("clicked")
|
self.sidebar._libraries.extra_widget.emit.assert_called_with("clicked")
|
||||||
|
|
||||||
self.sidebar.select_playlist.reset_mock()
|
self.sidebar.select_playlist.reset_mock()
|
||||||
self.sql.emit("table-loaded", tables[0])
|
self.sql.loaded = False
|
||||||
|
self.assertFalse(self.sidebar.get_sensitive())
|
||||||
self.sidebar.select_playlist.assert_not_called()
|
self.sidebar.select_playlist.assert_not_called()
|
||||||
|
|
||||||
def test_show_all_artists(self):
|
def test_show_all_artists(self):
|
||||||
|
@ -73,15 +95,17 @@ class TestSidebar(tests.util.TestCase):
|
||||||
self.assertIsNone(self.sidebar.selected_playlist)
|
self.assertIsNone(self.sidebar.selected_playlist)
|
||||||
|
|
||||||
playlist1 = self.sql.playlists.create("Playlist 1")
|
playlist1 = self.sql.playlists.create("Playlist 1")
|
||||||
self.sidebar._group.selected_playlist = playlist1
|
self.sidebar._view.selected_playlist = playlist1
|
||||||
self.assertEqual(self.sidebar.selected_playlist, playlist1)
|
self.assertEqual(self.sidebar.selected_playlist, playlist1)
|
||||||
|
|
||||||
def test_group(self):
|
def test_view(self):
|
||||||
"""Test that sidebar sections are part of the same Group."""
|
"""Test that sidebar sections are in the View."""
|
||||||
self.assertIsInstance(self.sidebar._group,
|
self.assertIsInstance(self.sidebar._view,
|
||||||
emmental.sidebar.section.Group)
|
emmental.sidebar.section.View)
|
||||||
|
self.assertEqual(self.sidebar._header.get_next_sibling(),
|
||||||
|
self.sidebar._view)
|
||||||
|
|
||||||
self.assertListEqual(self.sidebar._group._sections,
|
self.assertListEqual(self.sidebar._view._sections,
|
||||||
[self.sidebar._playlists,
|
[self.sidebar._playlists,
|
||||||
self.sidebar._artists,
|
self.sidebar._artists,
|
||||||
self.sidebar._genres,
|
self.sidebar._genres,
|
||||||
|
@ -101,7 +125,7 @@ class TestSidebar(tests.util.TestCase):
|
||||||
self.assertIsInstance(self.sidebar._libraries,
|
self.assertIsInstance(self.sidebar._libraries,
|
||||||
emmental.sidebar.library.Section)
|
emmental.sidebar.library.Section)
|
||||||
|
|
||||||
self.assertEqual(self.sidebar._filter.get_next_sibling(),
|
self.assertEqual(self.sidebar._view.get_first_child(),
|
||||||
self.sidebar._playlists)
|
self.sidebar._playlists)
|
||||||
self.assertEqual(self.sidebar._playlists.get_next_sibling(),
|
self.assertEqual(self.sidebar._playlists.get_next_sibling(),
|
||||||
self.sidebar._artists)
|
self.sidebar._artists)
|
||||||
|
@ -122,42 +146,79 @@ class TestSidebar(tests.util.TestCase):
|
||||||
|
|
||||||
def test_select_playlist(self):
|
def test_select_playlist(self):
|
||||||
"""Test setting the active playlist."""
|
"""Test setting the active playlist."""
|
||||||
|
self.assertEqual(self.sidebar._Card__select_playlist(None),
|
||||||
|
GLib.SOURCE_REMOVE)
|
||||||
|
|
||||||
playlist = self.sql.playlists.create("Test Playlist")
|
playlist = self.sql.playlists.create("Test Playlist")
|
||||||
self.sidebar.select_playlist(playlist)
|
with unittest.mock.patch.object(self.sidebar._playlists,
|
||||||
|
"select_playlist") as mock_select:
|
||||||
|
self.assertEqual(self.sidebar._Card__select_playlist(playlist),
|
||||||
|
GLib.SOURCE_CONTINUE)
|
||||||
self.assertTrue(self.sidebar._playlists.active)
|
self.assertTrue(self.sidebar._playlists.active)
|
||||||
self.assertEqual(self.sidebar.selected_playlist, playlist)
|
mock_select.assert_not_called()
|
||||||
|
|
||||||
artist = self.sql.artists.create("Test Artist")
|
self.assertEqual(self.sidebar._Card__select_playlist(playlist),
|
||||||
album = self.sql.albums.create("Test Album", "Test Artist", "2023")
|
GLib.SOURCE_REMOVE)
|
||||||
medium = self.sql.media.create(album, "Test Medium", number=1)
|
mock_select.assert_called_with(playlist)
|
||||||
|
|
||||||
self.sidebar._artists.select_playlist = unittest.mock.Mock()
|
with unittest.mock.patch.object(GLib, "timeout_add") as mock_to:
|
||||||
for plist in [artist, album, medium]:
|
self.sidebar.select_playlist(playlist)
|
||||||
self.sidebar._artists.select_playlist.reset_mock()
|
mock_to.assert_called_with(0, self.sidebar._Card__select_playlist,
|
||||||
self.sidebar._artists.active = False
|
playlist)
|
||||||
|
self.sidebar.select_playlist(playlist, 42)
|
||||||
|
mock_to.assert_called_with(42, self.sidebar._Card__select_playlist,
|
||||||
|
playlist)
|
||||||
|
|
||||||
self.sidebar.select_playlist(plist)
|
def test_table_section(self):
|
||||||
self.assertTrue(self.sidebar._artists.active)
|
"""Test converting a Playlist database table into a Section."""
|
||||||
self.sidebar._artists.select_playlist.assert_called_with(plist)
|
self.assertEqual(self.sidebar.table_section(self.sql.playlists),
|
||||||
|
self.sidebar._playlists)
|
||||||
|
self.assertEqual(self.sidebar.table_section(self.sql.artists),
|
||||||
|
self.sidebar._artists)
|
||||||
|
self.assertEqual(self.sidebar.table_section(self.sql.albums),
|
||||||
|
self.sidebar._artists)
|
||||||
|
self.assertEqual(self.sidebar.table_section(self.sql.media),
|
||||||
|
self.sidebar._artists)
|
||||||
|
self.assertEqual(self.sidebar.table_section(self.sql.genres),
|
||||||
|
self.sidebar._genres)
|
||||||
|
self.assertEqual(self.sidebar.table_section(self.sql.decades),
|
||||||
|
self.sidebar._decades)
|
||||||
|
self.assertEqual(self.sidebar.table_section(self.sql.years),
|
||||||
|
self.sidebar._decades)
|
||||||
|
self.assertEqual(self.sidebar.table_section(self.sql.libraries),
|
||||||
|
self.sidebar._libraries)
|
||||||
|
self.assertIsNone(self.sidebar.table_section(None))
|
||||||
|
|
||||||
genre = self.sql.genres.create("Test Genre")
|
def test_accelerators(self):
|
||||||
self.sidebar.select_playlist(genre)
|
"""Check that the accelerators list is set up properly."""
|
||||||
self.assertTrue(self.sidebar._genres.active)
|
entries = [("focus-search-playlist", self.sidebar._filter.grab_focus,
|
||||||
self.assertEqual(self.sidebar.selected_playlist, genre)
|
["<Control>question"]),
|
||||||
|
("goto-active-playlist", self.sidebar._jump.activate,
|
||||||
|
["<Control><Alt>g"]),
|
||||||
|
("goto-playlists", self.sidebar._playlists.activate,
|
||||||
|
["<Shift><Control>p"]),
|
||||||
|
("goto-artists", self.sidebar._artists.activate,
|
||||||
|
["<Shift><Control>a"]),
|
||||||
|
("goto-genres", self.sidebar._genres.activate,
|
||||||
|
["<Shift><Control>g"]),
|
||||||
|
("goto-decades", self.sidebar._decades.activate,
|
||||||
|
["<Shift><Control>d"]),
|
||||||
|
("goto-libraries", self.sidebar._libraries.activate,
|
||||||
|
["<Shift><Control>l"])]
|
||||||
|
|
||||||
decade = self.sql.decades.create(1990)
|
accels = self.sidebar.accelerators
|
||||||
year = self.sql.years.create(1990)
|
self.assertIsInstance(accels, list)
|
||||||
|
|
||||||
self.sidebar._decades.select_playlist = unittest.mock.Mock()
|
for i, (name, func, accel) in enumerate(entries):
|
||||||
for plist in [decade, year]:
|
with self.subTest(action=name):
|
||||||
self.sidebar._decades.select_playlist.reset_mock()
|
self.assertIsInstance(accels[i], emmental.action.ActionEntry)
|
||||||
self.sidebar._decades.active = False
|
self.assertEqual(accels[i].name, name)
|
||||||
|
self.assertEqual(accels[i].func, func)
|
||||||
|
self.assertListEqual(accels[i].accels, accel)
|
||||||
|
|
||||||
self.sidebar.select_playlist(plist)
|
enabled = self.sidebar.get_sensitive()
|
||||||
self.assertTrue(self.sidebar._decades.active)
|
self.assertEqual(accels[i].enabled, enabled)
|
||||||
self.sidebar._decades.select_playlist.assert_called_with(plist)
|
self.sidebar.set_sensitive(not enabled)
|
||||||
|
self.assertEqual(accels[i].enabled, not enabled)
|
||||||
|
|
||||||
library = self.sql.libraries.create("/a/b/c")
|
self.assertEqual(len(accels), i + 1)
|
||||||
self.sidebar.select_playlist(library)
|
|
||||||
self.assertTrue(self.sidebar._libraries.active)
|
|
||||||
self.assertEqual(self.sidebar.selected_playlist, library)
|
|
||||||
|
|
72
tests/test_alarm.py
Normal file
72
tests/test_alarm.py
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
# Copyright 2023 (c) Anna Schumaker.
|
||||||
|
"""Test our functions for callbacks at a specific time."""
|
||||||
|
import datetime
|
||||||
|
import unittest.mock
|
||||||
|
import emmental.alarm
|
||||||
|
from gi.repository import GLib
|
||||||
|
|
||||||
|
|
||||||
|
class TestAlarm(unittest.TestCase):
|
||||||
|
"""Test case for callbacks at a specific time."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up common variables."""
|
||||||
|
emmental.alarm._GSOURCE_MAPPING.clear()
|
||||||
|
emmental.alarm._NEXT_ALARM_ID = 1
|
||||||
|
self.midnight = datetime.time(hour=0, minute=0, second=0)
|
||||||
|
|
||||||
|
def test_state(self):
|
||||||
|
"""Test our global state."""
|
||||||
|
self.assertDictEqual(emmental.alarm._GSOURCE_MAPPING, {})
|
||||||
|
self.assertEqual(emmental.alarm._NEXT_ALARM_ID, 1)
|
||||||
|
|
||||||
|
def test_calc_seconds(self):
|
||||||
|
"""Test calculating the seconds until the next alarm."""
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
time = (now + datetime.timedelta(minutes=2)).time()
|
||||||
|
self.assertEqual(emmental.alarm._calc_seconds(time), 120)
|
||||||
|
|
||||||
|
time = (now - datetime.timedelta(minutes=2)).time()
|
||||||
|
self.assertEqual(emmental.alarm._calc_seconds(time), 86280)
|
||||||
|
|
||||||
|
@unittest.mock.patch("gi.repository.GLib.timeout_add_seconds")
|
||||||
|
def test_set_alarm(self, mock_timeout_add: unittest.mock.Mock):
|
||||||
|
"""Test setting an alarm."""
|
||||||
|
callback = unittest.mock.Mock()
|
||||||
|
seconds = emmental.alarm._calc_seconds(self.midnight)
|
||||||
|
mock_timeout_add.return_value = 42
|
||||||
|
|
||||||
|
srcid = emmental.alarm.set_alarm(self.midnight, callback)
|
||||||
|
mock_timeout_add.assert_called_with(seconds, emmental.alarm._do_alarm,
|
||||||
|
self.midnight, callback, 1)
|
||||||
|
self.assertEqual(srcid, 1)
|
||||||
|
self.assertEqual(emmental.alarm._NEXT_ALARM_ID, 2)
|
||||||
|
self.assertEqual(emmental.alarm._GSOURCE_MAPPING[1], 42)
|
||||||
|
|
||||||
|
@unittest.mock.patch("gi.repository.GLib.source_remove")
|
||||||
|
@unittest.mock.patch("gi.repository.GLib.timeout_add_seconds")
|
||||||
|
def test_cancel_alarm(self, mock_timeout_add: unittest.mock.Mock,
|
||||||
|
mock_source_remove: unittest.mock.Mock):
|
||||||
|
"""Test cancelling an alarm."""
|
||||||
|
callback = unittest.mock.Mock()
|
||||||
|
mock_timeout_add.return_value = 42
|
||||||
|
srcid = emmental.alarm.set_alarm(self.midnight, callback)
|
||||||
|
|
||||||
|
emmental.alarm.cancel_alarm(srcid)
|
||||||
|
mock_source_remove.assert_called_with(42)
|
||||||
|
self.assertNotIn(srcid, emmental.alarm._GSOURCE_MAPPING)
|
||||||
|
|
||||||
|
@unittest.mock.patch("gi.repository.GLib.timeout_add_seconds")
|
||||||
|
def test_do_alarm(self, mock_timeout_add: unittest.mock.Mock):
|
||||||
|
"""Test triggering an alarm."""
|
||||||
|
callback = unittest.mock.Mock()
|
||||||
|
seconds = emmental.alarm._calc_seconds(self.midnight)
|
||||||
|
emmental.alarm._GSOURCE_MAPPING[1] = 2
|
||||||
|
mock_timeout_add.return_value = 42
|
||||||
|
|
||||||
|
self.assertEqual(emmental.alarm._do_alarm(self.midnight, callback, 1),
|
||||||
|
GLib.SOURCE_REMOVE)
|
||||||
|
callback.assert_called()
|
||||||
|
mock_timeout_add.assert_called_with(seconds, emmental.alarm._do_alarm,
|
||||||
|
self.midnight, callback, 1)
|
||||||
|
self.assertEqual(emmental.alarm._GSOURCE_MAPPING[1], 42)
|
|
@ -211,6 +211,17 @@ class TestImageToggle(unittest.TestCase):
|
||||||
button2.active = False
|
button2.active = False
|
||||||
self.assertEqual(button2.get_tooltip_text(), "inactive tooltip text")
|
self.assertEqual(button2.get_tooltip_text(), "inactive tooltip text")
|
||||||
|
|
||||||
|
def test_changing_tooltip_text(self):
|
||||||
|
"""Test changing the tooltip text for the current state."""
|
||||||
|
self.assertEqual(self.button.props.tooltip_text, None)
|
||||||
|
self.button.inactive_tooltip_text = "inactive tooltip"
|
||||||
|
self.assertEqual(self.button.props.tooltip_text, "inactive tooltip")
|
||||||
|
|
||||||
|
self.button.active = True
|
||||||
|
self.assertEqual(self.button.props.tooltip_text, None)
|
||||||
|
self.button.active_tooltip_text = "active tooltip"
|
||||||
|
self.assertEqual(self.button.props.tooltip_text, "active tooltip")
|
||||||
|
|
||||||
def test_toggle(self):
|
def test_toggle(self):
|
||||||
"""Test the toggle signal."""
|
"""Test the toggle signal."""
|
||||||
toggled = unittest.mock.Mock()
|
toggled = unittest.mock.Mock()
|
||||||
|
|
|
@ -21,10 +21,10 @@ class TestEmmental(unittest.TestCase):
|
||||||
def test_version(self):
|
def test_version(self):
|
||||||
"""Check that version constants have been set properly."""
|
"""Check that version constants have been set properly."""
|
||||||
self.assertEqual(emmental.MAJOR_VERSION, 3)
|
self.assertEqual(emmental.MAJOR_VERSION, 3)
|
||||||
self.assertEqual(emmental.MINOR_VERSION, 0)
|
self.assertEqual(emmental.MINOR_VERSION, 2)
|
||||||
self.assertEqual(emmental.MICRO_VERSION, 3)
|
self.assertEqual(emmental.MICRO_VERSION, 1)
|
||||||
self.assertEqual(emmental.VERSION_NUMBER, "3.0.3")
|
self.assertEqual(emmental.VERSION_NUMBER, "3.2.1")
|
||||||
self.assertEqual(emmental.VERSION_STRING, "Emmental 3.0.3-debug")
|
self.assertEqual(emmental.VERSION_STRING, "Emmental 3.2.1-debug")
|
||||||
|
|
||||||
def test_application(self):
|
def test_application(self):
|
||||||
"""Check that the application instance is initialized properly."""
|
"""Check that the application instance is initialized properly."""
|
||||||
|
@ -49,12 +49,15 @@ class TestEmmental(unittest.TestCase):
|
||||||
self.assertIsNone(self.application.mpris)
|
self.assertIsNone(self.application.mpris)
|
||||||
self.assertIsNone(self.application.factory)
|
self.assertIsNone(self.application.factory)
|
||||||
self.assertIsNone(self.application.player)
|
self.assertIsNone(self.application.player)
|
||||||
|
self.assertIsNone(self.application.lbrainz)
|
||||||
self.assertIsNone(self.application.win)
|
self.assertIsNone(self.application.win)
|
||||||
|
|
||||||
self.application.emit("startup")
|
self.application.emit("startup")
|
||||||
self.assertIsInstance(self.application.db, emmental.db.Connection)
|
self.assertIsInstance(self.application.db, emmental.db.Connection)
|
||||||
self.assertIsInstance(self.application.mpris,
|
self.assertIsInstance(self.application.mpris,
|
||||||
emmental.mpris2.Connection)
|
emmental.mpris2.Connection)
|
||||||
|
self.assertIsInstance(self.application.lbrainz,
|
||||||
|
emmental.listenbrainz.ListenBrainz)
|
||||||
self.assertIsInstance(self.application.player, emmental.audio.Player)
|
self.assertIsInstance(self.application.player, emmental.audio.Player)
|
||||||
self.assertIsInstance(self.application.factory,
|
self.assertIsInstance(self.application.factory,
|
||||||
emmental.playlist.Factory)
|
emmental.playlist.Factory)
|
||||||
|
@ -63,7 +66,7 @@ class TestEmmental(unittest.TestCase):
|
||||||
mock_startup.assert_called()
|
mock_startup.assert_called()
|
||||||
mock_load.assert_called()
|
mock_load.assert_called()
|
||||||
mock_add_window.assert_called_with(self.application.win)
|
mock_add_window.assert_called_with(self.application.win)
|
||||||
mock_set_useragent.assert_called_with("emmental-debug", "3.0.3")
|
mock_set_useragent.assert_called_with("emmental-debug", "3.2.1")
|
||||||
|
|
||||||
@unittest.mock.patch("sys.stdout")
|
@unittest.mock.patch("sys.stdout")
|
||||||
@unittest.mock.patch("gi.repository.Adw.Application.add_window")
|
@unittest.mock.patch("gi.repository.Adw.Application.add_window")
|
||||||
|
@ -84,12 +87,16 @@ class TestEmmental(unittest.TestCase):
|
||||||
"""Test that the shutdown signal works as expected."""
|
"""Test that the shutdown signal works as expected."""
|
||||||
db = self.application.db = emmental.db.Connection()
|
db = self.application.db = emmental.db.Connection()
|
||||||
mpris = self.application.mpris = emmental.mpris2.Connection()
|
mpris = self.application.mpris = emmental.mpris2.Connection()
|
||||||
|
lbrainz = self.application.lbrainz = \
|
||||||
|
emmental.listenbrainz.ListenBrainz(
|
||||||
|
self.application.db)
|
||||||
self.application.win = emmental.window.Window("Test 1.2.3")
|
self.application.win = emmental.window.Window("Test 1.2.3")
|
||||||
player = self.application.player = emmental.audio.Player()
|
player = self.application.player = emmental.audio.Player()
|
||||||
|
|
||||||
self.application.emit("shutdown")
|
self.application.emit("shutdown")
|
||||||
self.assertIsNone(self.application.db)
|
self.assertIsNone(self.application.db)
|
||||||
self.assertIsNone(self.application.mpris)
|
self.assertIsNone(self.application.mpris)
|
||||||
|
self.assertIsNone(self.application.lbrainz)
|
||||||
self.assertIsNone(self.application.player)
|
self.assertIsNone(self.application.player)
|
||||||
self.assertIsNone(self.application.win)
|
self.assertIsNone(self.application.win)
|
||||||
|
|
||||||
|
@ -97,6 +104,7 @@ class TestEmmental(unittest.TestCase):
|
||||||
self.assertFalse(db.connected)
|
self.assertFalse(db.connected)
|
||||||
self.assertEqual(player.get_state(), gi.repository.Gst.State.NULL)
|
self.assertEqual(player.get_state(), gi.repository.Gst.State.NULL)
|
||||||
mock_close.assert_called()
|
mock_close.assert_called()
|
||||||
|
self.assertFalse(lbrainz._thread.is_alive())
|
||||||
|
|
||||||
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
||||||
def test_window_widgets(self, mock_stdout: io.StringIO):
|
def test_window_widgets(self, mock_stdout: io.StringIO):
|
||||||
|
@ -105,6 +113,8 @@ class TestEmmental(unittest.TestCase):
|
||||||
self.application.factory = emmental.playlist.Factory(
|
self.application.factory = emmental.playlist.Factory(
|
||||||
self.application.db)
|
self.application.db)
|
||||||
self.application.player = emmental.audio.Player()
|
self.application.player = emmental.audio.Player()
|
||||||
|
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
|
||||||
|
self.application.db)
|
||||||
win = self.application.build_window()
|
win = self.application.build_window()
|
||||||
|
|
||||||
self.assertIsInstance(win, emmental.window.Window)
|
self.assertIsInstance(win, emmental.window.Window)
|
||||||
|
@ -125,12 +135,15 @@ class TestEmmental(unittest.TestCase):
|
||||||
self.application.factory = emmental.playlist.Factory(
|
self.application.factory = emmental.playlist.Factory(
|
||||||
self.application.db)
|
self.application.db)
|
||||||
self.application.player = emmental.audio.Player()
|
self.application.player = emmental.audio.Player()
|
||||||
|
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
|
||||||
|
self.application.db)
|
||||||
self.application.build_window()
|
self.application.build_window()
|
||||||
|
|
||||||
for action, accel in [("app.open-file", "<Control>o"),
|
for action, accel in [("app.open-file", "<Control>o"),
|
||||||
("app.decrease-volume", "<Control>Down"),
|
("app.decrease-volume", "<Shift><Control>Down"),
|
||||||
("app.increase-volume", "<Control>Up"),
|
("app.increase-volume", "<Shift><Control>Up"),
|
||||||
("app.toggle-bg-mode", "<Shift><Control>b"),
|
("app.toggle-bg-mode", "<Shift><Control>b"),
|
||||||
|
("app.toggle-sidebar", "<Control>bracketright"),
|
||||||
("app.edit-settings", "<Shift><Control>s")]:
|
("app.edit-settings", "<Shift><Control>s")]:
|
||||||
self.assertEqual(self.application.get_accels_for_action(action),
|
self.assertEqual(self.application.get_accels_for_action(action),
|
||||||
[accel])
|
[accel])
|
||||||
|
@ -146,6 +159,8 @@ class TestEmmental(unittest.TestCase):
|
||||||
self.application.factory = emmental.playlist.Factory(
|
self.application.factory = emmental.playlist.Factory(
|
||||||
self.application.db)
|
self.application.db)
|
||||||
self.application.player = emmental.audio.Player()
|
self.application.player = emmental.audio.Player()
|
||||||
|
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
|
||||||
|
self.application.db)
|
||||||
win = self.application.build_window()
|
win = self.application.build_window()
|
||||||
|
|
||||||
for action, accel in [("app.toggle-favorite", ["<Control>f"]),
|
for action, accel in [("app.toggle-favorite", ["<Control>f"]),
|
||||||
|
@ -203,8 +218,21 @@ class TestEmmental(unittest.TestCase):
|
||||||
self.application.factory = emmental.playlist.Factory(
|
self.application.factory = emmental.playlist.Factory(
|
||||||
self.application.db)
|
self.application.db)
|
||||||
self.application.player = emmental.audio.Player()
|
self.application.player = emmental.audio.Player()
|
||||||
|
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
|
||||||
|
self.application.db)
|
||||||
win = self.application.build_window()
|
win = self.application.build_window()
|
||||||
|
|
||||||
|
for action, accel in [("app.focus-search-playlist",
|
||||||
|
"<Control>question"),
|
||||||
|
("app.goto-active-playlist", "<Control><Alt>g"),
|
||||||
|
("app.goto-playlists", "<Shift><Control>p"),
|
||||||
|
("app.goto-artists", "<Shift><Control>a"),
|
||||||
|
("app.goto-genres", "<Shift><Control>g"),
|
||||||
|
("app.goto-decades", "<Shift><Control>d"),
|
||||||
|
("app.goto-libraries", "<Shift><Control>l")]:
|
||||||
|
self.assertEqual(self.application.get_accels_for_action(action),
|
||||||
|
[accel])
|
||||||
|
|
||||||
self.assertEqual(win.sidebar.sql, self.application.db)
|
self.assertEqual(win.sidebar.sql, self.application.db)
|
||||||
|
|
||||||
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
||||||
|
@ -214,8 +242,20 @@ class TestEmmental(unittest.TestCase):
|
||||||
self.application.factory = emmental.playlist.Factory(
|
self.application.factory = emmental.playlist.Factory(
|
||||||
self.application.db)
|
self.application.db)
|
||||||
self.application.player = emmental.audio.Player()
|
self.application.player = emmental.audio.Player()
|
||||||
|
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
|
||||||
|
self.application.db)
|
||||||
win = self.application.build_window()
|
win = self.application.build_window()
|
||||||
|
|
||||||
|
for action, accel in [("app.focus-search-track", "<Control>slash"),
|
||||||
|
("app.clear-selected-tracks", "Escape"),
|
||||||
|
("app.cycle-loop", "<Control>l"),
|
||||||
|
("app.toggle-shuffle", "<Control>s"),
|
||||||
|
("app.remove-selected-tracks", "Delete"),
|
||||||
|
("app.move-track-up", "<Control>Up"),
|
||||||
|
("app.move-track-down", "<Control>Down")]:
|
||||||
|
self.assertEqual(self.application.get_accels_for_action(action),
|
||||||
|
[accel])
|
||||||
|
|
||||||
self.assertEqual(win.tracklist.sql, self.application.db)
|
self.assertEqual(win.tracklist.sql, self.application.db)
|
||||||
|
|
||||||
playlist = self.application.db.playlists.create("Test Playlist")
|
playlist = self.application.db.playlists.create("Test Playlist")
|
||||||
|
@ -228,6 +268,8 @@ class TestEmmental(unittest.TestCase):
|
||||||
"""Test that the Playlist Factory is wired up properly."""
|
"""Test that the Playlist Factory is wired up properly."""
|
||||||
self.application.db = emmental.db.Connection()
|
self.application.db = emmental.db.Connection()
|
||||||
self.application.player = emmental.audio.Player()
|
self.application.player = emmental.audio.Player()
|
||||||
|
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
|
||||||
|
self.application.db)
|
||||||
self.application.factory = emmental.playlist.Factory(
|
self.application.factory = emmental.playlist.Factory(
|
||||||
self.application.db)
|
self.application.db)
|
||||||
self.application.win = self.application.build_window()
|
self.application.win = self.application.build_window()
|
||||||
|
@ -242,6 +284,31 @@ class TestEmmental(unittest.TestCase):
|
||||||
self.assertEqual(self.application.factory.db_previous,
|
self.assertEqual(self.application.factory.db_previous,
|
||||||
self.application.db.playlists.previous)
|
self.application.db.playlists.previous)
|
||||||
|
|
||||||
|
def test_listenbrainz(self):
|
||||||
|
"""Test that listenbrainz is wired up properly."""
|
||||||
|
self.application.db = emmental.db.Connection()
|
||||||
|
self.application.player = emmental.audio.Player()
|
||||||
|
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
|
||||||
|
self.application.db)
|
||||||
|
self.application.factory = emmental.playlist.Factory(
|
||||||
|
self.application.db)
|
||||||
|
self.application.win = self.application.build_window()
|
||||||
|
|
||||||
|
with unittest.mock.patch.object(self.application.lbrainz,
|
||||||
|
"submit_listens") as mock_submit:
|
||||||
|
self.application.connect_listenbrainz()
|
||||||
|
|
||||||
|
self.application.db.tracks.emit("track-played", None)
|
||||||
|
mock_submit.assert_called()
|
||||||
|
|
||||||
|
self.application.lbrainz.stop()
|
||||||
|
|
||||||
|
self.application.win.header.listenbrainz_token = "abcde"
|
||||||
|
self.assertEqual(self.application.lbrainz.user_token, "abcde")
|
||||||
|
|
||||||
|
self.application.lbrainz.valid_token = False
|
||||||
|
self.assertFalse(self.application.win.header.listenbrainz_token_valid)
|
||||||
|
|
||||||
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
||||||
def test_replaygain(self, mock_stdout: io.StringIO):
|
def test_replaygain(self, mock_stdout: io.StringIO):
|
||||||
"""Test setting replaygain modes."""
|
"""Test setting replaygain modes."""
|
||||||
|
@ -249,6 +316,8 @@ class TestEmmental(unittest.TestCase):
|
||||||
self.application.factory = emmental.playlist.Factory(
|
self.application.factory = emmental.playlist.Factory(
|
||||||
self.application.db)
|
self.application.db)
|
||||||
self.application.player = emmental.audio.Player()
|
self.application.player = emmental.audio.Player()
|
||||||
|
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
|
||||||
|
self.application.db)
|
||||||
win = self.application.build_window()
|
win = self.application.build_window()
|
||||||
player = self.application.player
|
player = self.application.player
|
||||||
|
|
||||||
|
@ -264,6 +333,8 @@ class TestEmmental(unittest.TestCase):
|
||||||
self.application.factory = emmental.playlist.Factory(
|
self.application.factory = emmental.playlist.Factory(
|
||||||
self.application.db)
|
self.application.db)
|
||||||
self.application.player = emmental.audio.Player()
|
self.application.player = emmental.audio.Player()
|
||||||
|
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
|
||||||
|
self.application.db)
|
||||||
win = self.application.build_window()
|
win = self.application.build_window()
|
||||||
player = self.application.player
|
player = self.application.player
|
||||||
|
|
||||||
|
@ -271,3 +342,19 @@ class TestEmmental(unittest.TestCase):
|
||||||
win.header.bg_volume = 0.5
|
win.header.bg_volume = 0.5
|
||||||
self.assertTrue(player.bg_enabled)
|
self.assertTrue(player.bg_enabled)
|
||||||
self.assertEqual(player.bg_volume, 0.5)
|
self.assertEqual(player.bg_volume, 0.5)
|
||||||
|
|
||||||
|
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
||||||
|
def test_show_sidebar(self, mock_stdout: io.StringIO):
|
||||||
|
"""Test showing the sidebar."""
|
||||||
|
self.application.db = emmental.db.Connection()
|
||||||
|
self.application.factory = emmental.playlist.Factory(
|
||||||
|
self.application.db)
|
||||||
|
self.application.player = emmental.audio.Player()
|
||||||
|
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
|
||||||
|
self.application.db)
|
||||||
|
win = self.application.build_window()
|
||||||
|
|
||||||
|
win.show_sidebar = True
|
||||||
|
self.assertTrue(win.header.show_sidebar)
|
||||||
|
win.header.show_sidebar = False
|
||||||
|
self.assertFalse(win.show_sidebar)
|
||||||
|
|
|
@ -39,16 +39,21 @@ class TestListRow(unittest.TestCase):
|
||||||
|
|
||||||
def test_bind_active(self):
|
def test_bind_active(self):
|
||||||
"""Test binding a property to the Row's active property."""
|
"""Test binding a property to the Row's active property."""
|
||||||
|
self.assertIsNone(self.row.listrow)
|
||||||
|
self.row.active = True
|
||||||
|
self.assertFalse(self.row.active)
|
||||||
|
|
||||||
parent = Gtk.Box()
|
parent = Gtk.Box()
|
||||||
parent.append(self.row.child)
|
parent.append(self.row.child)
|
||||||
|
self.assertEqual(self.row.listrow, parent)
|
||||||
|
|
||||||
self.row.bind_active("sensitive")
|
self.row.bind_active("sensitive")
|
||||||
self.assertEqual(len(self.row.bindings), 1)
|
self.assertEqual(len(self.row.bindings), 1)
|
||||||
self.assertTrue(parent.get_state_flags() & Gtk.StateFlags.CHECKED)
|
self.assertTrue(parent.has_css_class("emmental-active-row"))
|
||||||
self.assertTrue(self.row.active)
|
self.assertTrue(self.row.active)
|
||||||
|
|
||||||
self.item.set_sensitive(False)
|
self.item.set_sensitive(False)
|
||||||
self.assertFalse(parent.get_state_flags() & Gtk.StateFlags.CHECKED)
|
self.assertFalse(parent.has_css_class("emmental-active-row"))
|
||||||
self.assertFalse(self.row.active)
|
self.assertFalse(self.row.active)
|
||||||
|
|
||||||
def test_bind_and_set_property(self):
|
def test_bind_and_set_property(self):
|
||||||
|
|
|
@ -62,11 +62,22 @@ class TestGSetup(unittest.TestCase):
|
||||||
self.assertIsInstance(emmental.gsetup.RESOURCE,
|
self.assertIsInstance(emmental.gsetup.RESOURCE,
|
||||||
gi.repository.Gio.Resource)
|
gi.repository.Gio.Resource)
|
||||||
|
|
||||||
|
def test_cache_dir(self):
|
||||||
|
"""Check that the CACHE_DIR points to the right place."""
|
||||||
|
cache_path = xdg.BaseDirectory.save_cache_path("emmental")
|
||||||
|
self.assertEqual(emmental.gsetup.CACHE_DIR,
|
||||||
|
pathlib.Path(cache_path) / "debug")
|
||||||
|
|
||||||
def test_data_dir(self):
|
def test_data_dir(self):
|
||||||
"""Check that the DATA_DIR points to the right place."""
|
"""Check that the DATA_DIR points to the right place."""
|
||||||
data_path = xdg.BaseDirectory.save_data_path("emmental")
|
data_path = xdg.BaseDirectory.save_data_path("emmental")
|
||||||
self.assertEqual(emmental.gsetup.DATA_DIR, pathlib.Path(data_path))
|
self.assertEqual(emmental.gsetup.DATA_DIR, pathlib.Path(data_path))
|
||||||
|
|
||||||
|
def test_has_icon(self):
|
||||||
|
"""Check that has_icon() works as expected."""
|
||||||
|
self.assertTrue(emmental.gsetup.has_icon("media-playback-start"))
|
||||||
|
self.assertFalse(emmental.gsetup.has_icon("no-such-icon"))
|
||||||
|
|
||||||
def test_env_string(self):
|
def test_env_string(self):
|
||||||
"""Check that the env_string() function works as expected."""
|
"""Check that the env_string() function works as expected."""
|
||||||
self.assertRegex(emmental.gsetup.env_string(),
|
self.assertRegex(emmental.gsetup.env_string(),
|
||||||
|
|
87
tests/test_layout.py
Normal file
87
tests/test_layout.py
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
# Copyright 2023 (c) Anna Schumaker.
|
||||||
|
"""Tests our adaptable layout widget."""
|
||||||
|
import unittest
|
||||||
|
import emmental.layout
|
||||||
|
from gi.repository import Gtk
|
||||||
|
from gi.repository import Adw
|
||||||
|
|
||||||
|
|
||||||
|
class TestLayout(unittest.TestCase):
|
||||||
|
"""Test case for our adaptable layout."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up common variables."""
|
||||||
|
self.layout = emmental.layout.Layout()
|
||||||
|
|
||||||
|
def test_constants(self):
|
||||||
|
"""Check constant variables."""
|
||||||
|
self.assertEqual(emmental.layout.MIN_WIDTH,
|
||||||
|
Adw.BreakpointConditionLengthType.MIN_WIDTH)
|
||||||
|
|
||||||
|
def test_init(self):
|
||||||
|
"""Check that the layout is set up properly."""
|
||||||
|
self.assertIsInstance(self.layout, Adw.Bin)
|
||||||
|
self.assertIsInstance(self.layout._split_view, Adw.OverlaySplitView)
|
||||||
|
self.assertTrue(self.layout._split_view.props.collapsed)
|
||||||
|
|
||||||
|
def test_wide_view(self):
|
||||||
|
"""Test the layout when we have a wide window."""
|
||||||
|
self.assertFalse(self.layout.wide_view)
|
||||||
|
self.assertEqual(self.layout.props.child, self.layout._split_view)
|
||||||
|
|
||||||
|
self.layout.wide_view = True
|
||||||
|
self.assertFalse(self.layout._split_view.props.collapsed)
|
||||||
|
|
||||||
|
def test_content(self):
|
||||||
|
"""Test the content widget property."""
|
||||||
|
self.assertIsNone(self.layout.content)
|
||||||
|
|
||||||
|
widget = Gtk.Label()
|
||||||
|
self.layout.content = widget
|
||||||
|
self.assertEqual(self.layout._split_view.props.content, widget)
|
||||||
|
self.assertEqual(self.layout.content, widget)
|
||||||
|
|
||||||
|
widget2 = Gtk.Label()
|
||||||
|
layout2 = emmental.layout.Layout(content=widget2)
|
||||||
|
self.assertEqual(layout2.content, widget2)
|
||||||
|
|
||||||
|
def test_sidebar(self):
|
||||||
|
"""Test the sidebar widget property."""
|
||||||
|
self.assertIsNone(self.layout.sidebar)
|
||||||
|
|
||||||
|
widget = Gtk.Label()
|
||||||
|
self.layout.sidebar = widget
|
||||||
|
self.assertEqual(self.layout._split_view.props.sidebar, widget)
|
||||||
|
self.assertEqual(self.layout.sidebar, widget)
|
||||||
|
|
||||||
|
widget2 = Gtk.Label()
|
||||||
|
layout2 = emmental.layout.Layout(sidebar=widget2)
|
||||||
|
self.assertEqual(layout2.sidebar, widget2)
|
||||||
|
|
||||||
|
def test_show_sidebar(self):
|
||||||
|
"""Test the show-sidebar property."""
|
||||||
|
self.assertFalse(self.layout.show_sidebar)
|
||||||
|
self.assertFalse(self.layout._split_view.props.show_sidebar)
|
||||||
|
|
||||||
|
self.layout.show_sidebar = True
|
||||||
|
self.assertTrue(self.layout._split_view.props.show_sidebar)
|
||||||
|
|
||||||
|
self.layout._split_view.props.show_sidebar = False
|
||||||
|
self.assertFalse(self.layout.show_sidebar)
|
||||||
|
|
||||||
|
@unittest.mock.patch("gi.repository.Adw.Breakpoint.add_setter")
|
||||||
|
def test_breakpoints(self, mock_add_setter: unittest.mock.Mock):
|
||||||
|
"""Test the layout breakpoints property."""
|
||||||
|
points = self.layout.breakpoints
|
||||||
|
self.assertEqual(len(points), 1)
|
||||||
|
|
||||||
|
self.assertIsInstance(points[0], Adw.Breakpoint)
|
||||||
|
condition = points[0].props.condition
|
||||||
|
self.assertIsInstance(condition, Adw.BreakpointCondition)
|
||||||
|
self.assertEqual(condition.to_string(), "min-width: 1000sp")
|
||||||
|
|
||||||
|
mock_add_setter.assert_called_once()
|
||||||
|
args = mock_add_setter.mock_calls[0].args
|
||||||
|
self.assertEqual(args[0], self.layout)
|
||||||
|
self.assertEqual(args[1], "wide-view")
|
||||||
|
self.assertTrue(args[2].get_boolean())
|
|
@ -21,11 +21,23 @@ class TestSettings(unittest.TestCase):
|
||||||
self.settings = self.app.db.settings
|
self.settings = self.app.db.settings
|
||||||
self.win = self.app.win
|
self.win = self.app.win
|
||||||
self.player = self.app.player
|
self.player = self.app.player
|
||||||
|
self.lbrainz = self.app.lbrainz
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
"""Clean up."""
|
"""Clean up."""
|
||||||
self.app.do_shutdown()
|
self.app.do_shutdown()
|
||||||
|
|
||||||
|
def test_save_listenbrainz_token(self, new_callable=io.StringIO):
|
||||||
|
"""Check saving and loading the listenbrainz token."""
|
||||||
|
self.assertEqual(self.settings["listenbrainz.token"], "")
|
||||||
|
self.assertEqual(self.win.header.listenbrainz_token, "")
|
||||||
|
|
||||||
|
self.win.header.listenbrainz_token = "abcde"
|
||||||
|
self.assertEqual(self.settings["listenbrainz.token"], "abcde")
|
||||||
|
|
||||||
|
win = self.app.build_window()
|
||||||
|
self.assertEqual(win.header.listenbrainz_token, "abcde")
|
||||||
|
|
||||||
def test_save_window_size(self, new_callable=io.StringIO):
|
def test_save_window_size(self, new_callable=io.StringIO):
|
||||||
"""Check saving and loading window size from the database."""
|
"""Check saving and loading window size from the database."""
|
||||||
self.assertEqual(self.settings["window.width"], 1600)
|
self.assertEqual(self.settings["window.width"], 1600)
|
||||||
|
@ -38,6 +50,16 @@ class TestSettings(unittest.TestCase):
|
||||||
win = self.app.build_window()
|
win = self.app.build_window()
|
||||||
self.assertEqual(win.get_default_size(), (100, 200))
|
self.assertEqual(win.get_default_size(), (100, 200))
|
||||||
|
|
||||||
|
def test_save_show_sidebar(self, mock_stdout: io.StringIO):
|
||||||
|
"""Check saving and loading the show-sidebar property."""
|
||||||
|
self.assertFalse(self.settings["sidebar.show"])
|
||||||
|
|
||||||
|
self.win.show_sidebar = True
|
||||||
|
self.assertTrue(self.settings["sidebar.show"])
|
||||||
|
|
||||||
|
win = self.app.build_window()
|
||||||
|
self.assertTrue(win.show_sidebar)
|
||||||
|
|
||||||
def test_save_volume(self, mock_stdout: io.StringIO):
|
def test_save_volume(self, mock_stdout: io.StringIO):
|
||||||
"""Check saving and loading volume from the database."""
|
"""Check saving and loading volume from the database."""
|
||||||
self.assertEqual(self.settings["audio.volume"], 1.0)
|
self.assertEqual(self.settings["audio.volume"], 1.0)
|
||||||
|
@ -111,16 +133,6 @@ class TestSettings(unittest.TestCase):
|
||||||
|
|
||||||
self.assertFalse(self.app.build_window().now_playing.prefer_artist)
|
self.assertFalse(self.app.build_window().now_playing.prefer_artist)
|
||||||
|
|
||||||
def test_save_sidebar_size(self, mock_stdout: io.StringIO):
|
|
||||||
"""Check saving and loading the sidebar widget size."""
|
|
||||||
self.assertEqual(self.win.sidebar_size, 300)
|
|
||||||
self.assertEqual(self.settings["sidebar.size"], 300)
|
|
||||||
|
|
||||||
self.win.sidebar_size = 400
|
|
||||||
self.assertEqual(self.settings["sidebar.size"], 400)
|
|
||||||
|
|
||||||
self.assertEqual(self.app.build_window().sidebar_size, 400)
|
|
||||||
|
|
||||||
def test_save_sidebar_show_all_artists(self, mock_stdout: io.StringIO):
|
def test_save_sidebar_show_all_artists(self, mock_stdout: io.StringIO):
|
||||||
"""Check saving and loading the show-all artists setting."""
|
"""Check saving and loading the show-all artists setting."""
|
||||||
self.assertFalse(self.win.sidebar.show_all_artists)
|
self.assertFalse(self.win.sidebar.show_all_artists)
|
||||||
|
|
99
tests/test_texture.py
Normal file
99
tests/test_texture.py
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
# Copyright 2023 (c) Anna Schumaker.
|
||||||
|
"""Tests our Gdk.Texture cache."""
|
||||||
|
import emmental.texture
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import tempfile
|
||||||
|
import tests.util
|
||||||
|
import unittest
|
||||||
|
from gi.repository import Gdk
|
||||||
|
|
||||||
|
|
||||||
|
class TestTextureCache(unittest.TestCase):
|
||||||
|
"""Test our custom cache dictionary."""
|
||||||
|
|
||||||
|
def setUpClass():
|
||||||
|
"""Clear the existing cache before testing."""
|
||||||
|
emmental.texture.CACHE.clear()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up common variables."""
|
||||||
|
cover = tests.util.COVER_JPG.absolute().relative_to("/")
|
||||||
|
self.target = emmental.texture.CACHE_PATH / cover
|
||||||
|
self.target2 = self.target.with_name("cover2.jpg")
|
||||||
|
self.cache = emmental.texture._TextureCache()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Clean up."""
|
||||||
|
self.target2.unlink(missing_ok=True)
|
||||||
|
(path := self.target).unlink(missing_ok=True)
|
||||||
|
while (path := path.parent) != emmental.texture.CACHE_PATH:
|
||||||
|
if path.is_dir():
|
||||||
|
path.rmdir()
|
||||||
|
|
||||||
|
def test_path(self):
|
||||||
|
"""Test the on-disk path of the texture cache."""
|
||||||
|
self.assertIsInstance(emmental.texture.TEMP_DIR,
|
||||||
|
tempfile.TemporaryDirectory)
|
||||||
|
self.assertEqual(emmental.texture.CACHE_PATH,
|
||||||
|
pathlib.Path(emmental.texture.TEMP_DIR.name))
|
||||||
|
self.assertTrue(emmental.texture.CACHE_PATH.is_dir())
|
||||||
|
|
||||||
|
def test_init(self):
|
||||||
|
"""Test that the cache dict is initialized properly."""
|
||||||
|
self.assertIsInstance(emmental.texture.CACHE,
|
||||||
|
emmental.texture._TextureCache)
|
||||||
|
self.assertDictEqual(emmental.texture.CACHE, {})
|
||||||
|
|
||||||
|
self.assertIsInstance(self.cache, dict)
|
||||||
|
self.assertDictEqual(self.cache, {})
|
||||||
|
|
||||||
|
def test_drop(self):
|
||||||
|
"""Test dropping items from the cache."""
|
||||||
|
self.cache[tests.util.COVER_JPG]
|
||||||
|
self.cache.drop(tests.util.COVER_JPG)
|
||||||
|
self.assertDictEqual(self.cache, {})
|
||||||
|
self.assertFalse(self.target.exists())
|
||||||
|
|
||||||
|
self.cache[tests.util.COVER_JPG]
|
||||||
|
self.cache.clear()
|
||||||
|
self.cache.drop(tests.util.COVER_JPG)
|
||||||
|
self.assertFalse(self.target.exists())
|
||||||
|
|
||||||
|
def test_getitem(self):
|
||||||
|
"""Test getting and creating items in the cache dict."""
|
||||||
|
self.assertIsNone(self.cache[None])
|
||||||
|
self.assertIsNone(self.cache[pathlib.Path("/no/such/path")])
|
||||||
|
self.assertDictEqual(self.cache, {})
|
||||||
|
self.assertListEqual(list(emmental.texture.CACHE_PATH.iterdir()), [])
|
||||||
|
|
||||||
|
texture = self.cache[tests.util.COVER_JPG]
|
||||||
|
self.assertIsInstance(texture, Gdk.Texture)
|
||||||
|
self.assertDictEqual(self.cache, {tests.util.COVER_JPG: texture})
|
||||||
|
self.assertEqual(self.cache[tests.util.COVER_JPG], texture)
|
||||||
|
self.assertTrue(self.target.is_file())
|
||||||
|
|
||||||
|
self.cache.clear()
|
||||||
|
self.assertIsInstance(self.cache[tests.util.COVER_JPG], Gdk.Texture)
|
||||||
|
|
||||||
|
def test_getitem_cache_only(self):
|
||||||
|
"""Test getting a cached item with deleted source path."""
|
||||||
|
cover2 = tests.util.COVER_JPG.with_name("cover2.jpg")
|
||||||
|
texture = self.cache[tests.util.COVER_JPG]
|
||||||
|
self.cache[cover2] = texture
|
||||||
|
del self.cache[tests.util.COVER_JPG]
|
||||||
|
|
||||||
|
self.assertEqual(self.cache[cover2], texture)
|
||||||
|
|
||||||
|
self.cache.clear()
|
||||||
|
self.target.rename(self.target2)
|
||||||
|
self.assertIsInstance(self.cache[cover2], Gdk.Texture)
|
||||||
|
|
||||||
|
def test_mtime_update(self):
|
||||||
|
"""Test updating an item in the cache."""
|
||||||
|
texture = self.cache[tests.util.COVER_JPG]
|
||||||
|
os.utime(self.target, (123456789, 123456789))
|
||||||
|
|
||||||
|
new = self.cache[tests.util.COVER_JPG]
|
||||||
|
self.assertIsInstance(new, Gdk.Texture)
|
||||||
|
self.assertNotEqual(new, texture)
|
118
tests/test_thread.py
Normal file
118
tests/test_thread.py
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
# Copyright 2024 (c) Anna Schumaker.
|
||||||
|
"""Tests our common Thread class."""
|
||||||
|
import emmental.thread
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class TestData(unittest.TestCase):
|
||||||
|
"""Tests our thread Data class."""
|
||||||
|
|
||||||
|
def test_init_kwargs(self):
|
||||||
|
"""Tests initializing the data class with keyword args."""
|
||||||
|
data = emmental.thread.Data(a=1, b=2)
|
||||||
|
self.assertEqual(data.a, 1)
|
||||||
|
self.assertEqual(data.b, 2)
|
||||||
|
self.assertEqual(repr(data), "Data(a=1, b=2)")
|
||||||
|
|
||||||
|
def test_init_values_dict(self):
|
||||||
|
"""Test initializing the data class with a dictionary of values."""
|
||||||
|
data = emmental.thread.Data({"a": 1, "b": 2})
|
||||||
|
self.assertEqual(data.a, 1)
|
||||||
|
self.assertEqual(data.b, 2)
|
||||||
|
self.assertEqual(repr(data), "Data(a=1, b=2)")
|
||||||
|
|
||||||
|
def test_init_both(self):
|
||||||
|
"""Test initializing the data class with both."""
|
||||||
|
data = emmental.thread.Data({"a": 1, "b": 2}, b=3, c='4')
|
||||||
|
self.assertEqual(data.a, 1)
|
||||||
|
self.assertEqual(data.b, 3)
|
||||||
|
self.assertEqual(data.c, '4')
|
||||||
|
self.assertEqual(repr(data), "Data(a=1, b=3, c='4')")
|
||||||
|
|
||||||
|
def test_compare(self):
|
||||||
|
"""Test comparing two data classes."""
|
||||||
|
data1 = emmental.thread.Data({"a": 1, "b": 2})
|
||||||
|
data2 = emmental.thread.Data({"c": 3, "d": 4})
|
||||||
|
self.assertTrue(data1 == data1)
|
||||||
|
self.assertTrue(data1 == {"a": 1, "b": 2})
|
||||||
|
self.assertFalse(data1 == data2)
|
||||||
|
self.assertFalse(data1 == {"c": 2, "d": 4})
|
||||||
|
self.assertFalse(data1 == 3)
|
||||||
|
|
||||||
|
|
||||||
|
class TestThread(unittest.TestCase):
|
||||||
|
"""Tests our Thread class."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up common variables."""
|
||||||
|
self.thread = emmental.thread.Thread()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Clean up."""
|
||||||
|
self.thread.stop()
|
||||||
|
|
||||||
|
def test_init(self):
|
||||||
|
"""Check that the Thread was initialized properly."""
|
||||||
|
self.assertIsInstance(self.thread, threading.Thread)
|
||||||
|
self.assertIsInstance(self.thread.ready, threading.Event)
|
||||||
|
self.assertIsInstance(self.thread._condition, threading.Condition)
|
||||||
|
|
||||||
|
self.assertIsNone(self.thread._task)
|
||||||
|
self.assertIsNone(self.thread._result)
|
||||||
|
|
||||||
|
self.assertTrue(self.thread.is_alive())
|
||||||
|
self.assertTrue(self.thread.ready.is_set())
|
||||||
|
|
||||||
|
def test_set_get_result(self):
|
||||||
|
"""Test the set_result() and get_result() functions."""
|
||||||
|
with unittest.mock.patch.object(self.thread, "do_get_result",
|
||||||
|
wraps=self.thread.do_get_result) \
|
||||||
|
as mock_get_result:
|
||||||
|
self.assertIsNone(self.thread.get_result())
|
||||||
|
mock_get_result.assert_not_called()
|
||||||
|
|
||||||
|
self.thread.ready.clear()
|
||||||
|
self.thread._result = {"res": "abcde"}
|
||||||
|
self.assertIsNone(self.thread.get_result())
|
||||||
|
mock_get_result.assert_not_called()
|
||||||
|
|
||||||
|
self.thread.set_result(res="fghij")
|
||||||
|
self.assertTrue(self.thread.ready.is_set())
|
||||||
|
self.assertIsInstance(self.thread._result, emmental.thread.Data)
|
||||||
|
self.assertEqual(self.thread._result, {"res": "fghij"})
|
||||||
|
self.assertEqual(self.thread.get_result(), {"res": "fghij"})
|
||||||
|
self.assertIsNone(self.thread._result)
|
||||||
|
mock_get_result.assert_called_with({"res": "fghij"})
|
||||||
|
|
||||||
|
result = {"res1": "klmno", "res2": "pqrst"}
|
||||||
|
self.thread.set_result(**result)
|
||||||
|
self.assertEqual(self.thread.get_result(other="other", arg="arg"),
|
||||||
|
result)
|
||||||
|
mock_get_result.assert_called_with(result,
|
||||||
|
other="other", arg="arg")
|
||||||
|
|
||||||
|
def test_set_task(self):
|
||||||
|
"""Test the set_task() function."""
|
||||||
|
self.thread._result = "abcde"
|
||||||
|
|
||||||
|
with unittest.mock.patch.object(self.thread, "do_run_task",
|
||||||
|
wraps=self.thread.do_run_task) \
|
||||||
|
as mock_run_task:
|
||||||
|
self.thread.set_task(arg="test")
|
||||||
|
self.assertIsInstance(self.thread._task, emmental.thread.Data)
|
||||||
|
self.assertEqual(self.thread._task, {"arg": "test"})
|
||||||
|
self.assertIsNone(self.thread._result)
|
||||||
|
self.thread.ready.wait()
|
||||||
|
mock_run_task.assert_called_with(self.thread._task)
|
||||||
|
|
||||||
|
def test_stop(self):
|
||||||
|
"""Test stopping the Thread."""
|
||||||
|
self.thread._task = ("test", "task")
|
||||||
|
|
||||||
|
with unittest.mock.patch.object(self.thread, "do_stop") as mock_stop:
|
||||||
|
self.thread.stop()
|
||||||
|
self.assertFalse(self.thread.is_alive())
|
||||||
|
self.assertFalse(self.thread.ready.is_set())
|
||||||
|
self.assertIsNone(self.thread._task)
|
||||||
|
mock_stop.assert_called()
|
|
@ -22,8 +22,8 @@ class TestWindow(unittest.TestCase):
|
||||||
self.assertIsInstance(self.window, Adw.Window)
|
self.assertIsInstance(self.window, Adw.Window)
|
||||||
self.assertIsInstance(self.window._box, Gtk.Box)
|
self.assertIsInstance(self.window._box, Gtk.Box)
|
||||||
self.assertIsInstance(self.window._header, Adw.Bin)
|
self.assertIsInstance(self.window._header, Adw.Bin)
|
||||||
self.assertIsInstance(self.window._outer_pane, Gtk.Paned)
|
|
||||||
self.assertIsInstance(self.window._inner_pane, Gtk.Paned)
|
self.assertIsInstance(self.window._inner_pane, Gtk.Paned)
|
||||||
|
self.assertIsInstance(self.window._layout, emmental.layout.Layout)
|
||||||
self.assertIsInstance(self.window._toast, Adw.ToastOverlay)
|
self.assertIsInstance(self.window._toast, Adw.ToastOverlay)
|
||||||
self.assertTrue(self.window.has_css_class("devel"))
|
self.assertTrue(self.window.has_css_class("devel"))
|
||||||
|
|
||||||
|
@ -31,6 +31,9 @@ class TestWindow(unittest.TestCase):
|
||||||
self.assertEqual(self.window.get_title(), "Test 1.2.3")
|
self.assertEqual(self.window.get_title(), "Test 1.2.3")
|
||||||
self.assertEqual(self.window.get_default_size(), (1600, 900))
|
self.assertEqual(self.window.get_default_size(), (1600, 900))
|
||||||
|
|
||||||
|
self.assertEqual(self.window.props.width_request, 525)
|
||||||
|
self.assertEqual(self.window.props.height_request, 500)
|
||||||
|
|
||||||
def test_content(self):
|
def test_content(self):
|
||||||
"""Check that the Window content is set up properly."""
|
"""Check that the Window content is set up properly."""
|
||||||
self.assertEqual(self.window._box.get_orientation(),
|
self.assertEqual(self.window._box.get_orientation(),
|
||||||
|
@ -41,22 +44,19 @@ class TestWindow(unittest.TestCase):
|
||||||
self.window._header)
|
self.window._header)
|
||||||
self.assertEqual(self.window._header.get_next_sibling(),
|
self.assertEqual(self.window._header.get_next_sibling(),
|
||||||
self.window._toast)
|
self.window._toast)
|
||||||
self.assertEqual(self.window._toast.get_child(),
|
self.assertEqual(self.window._toast.get_child(), self.window._layout)
|
||||||
self.window._outer_pane)
|
self.assertEqual(self.window._layout.content, self.window._inner_pane)
|
||||||
self.assertEqual(self.window._outer_pane.get_end_child(),
|
self.assertTrue(self.window._layout.has_css_class(
|
||||||
self.window._inner_pane)
|
|
||||||
self.assertTrue(self.window._outer_pane.has_css_class(
|
|
||||||
"emmental-padding"))
|
"emmental-padding"))
|
||||||
|
|
||||||
subtests = [(self.window._outer_pane, Gtk.Orientation.HORIZONTAL),
|
self.assertEqual(self.window._inner_pane.get_orientation(),
|
||||||
(self.window._inner_pane, Gtk.Orientation.VERTICAL)]
|
Gtk.Orientation.VERTICAL)
|
||||||
for pane, orientation in subtests:
|
self.assertEqual(self.window._inner_pane.get_margin_start(), 8)
|
||||||
self.assertEqual(pane.get_orientation(), orientation)
|
self.assertFalse(self.window._inner_pane.get_shrink_start_child())
|
||||||
self.assertFalse(pane.get_shrink_start_child())
|
self.assertFalse(self.window._inner_pane.get_resize_start_child())
|
||||||
self.assertFalse(pane.get_resize_start_child())
|
self.assertTrue(self.window._inner_pane.get_hexpand())
|
||||||
self.assertTrue(pane.get_hexpand())
|
self.assertTrue(self.window._inner_pane.get_vexpand())
|
||||||
self.assertTrue(pane.get_vexpand())
|
self.assertTrue(self.window._inner_pane.has_css_class("emmental-pane"))
|
||||||
self.assertTrue(pane.has_css_class("emmental-pane"))
|
|
||||||
|
|
||||||
def test_header(self):
|
def test_header(self):
|
||||||
"""Check setting a widget to the header area."""
|
"""Check setting a widget to the header area."""
|
||||||
|
@ -72,26 +72,22 @@ class TestWindow(unittest.TestCase):
|
||||||
"""Check setting a widget to the sidebar area."""
|
"""Check setting a widget to the sidebar area."""
|
||||||
self.assertIsNone(self.window.sidebar)
|
self.assertIsNone(self.window.sidebar)
|
||||||
self.window.sidebar = Gtk.Label()
|
self.window.sidebar = Gtk.Label()
|
||||||
self.assertEqual(self.window._outer_pane.get_start_child(),
|
self.assertEqual(self.window._layout.sidebar, self.window.sidebar)
|
||||||
self.window.sidebar)
|
|
||||||
|
|
||||||
window2 = emmental.window.Window(version="1.2.3", sidebar=Gtk.Label())
|
window2 = emmental.window.Window(version="1.2.3", sidebar=Gtk.Label())
|
||||||
self.assertIsInstance(window2.sidebar, Gtk.Label)
|
self.assertIsInstance(window2.sidebar, Gtk.Label)
|
||||||
self.assertEqual(window2._outer_pane.get_start_child(),
|
self.assertEqual(window2._layout.sidebar, window2.sidebar)
|
||||||
window2.sidebar)
|
|
||||||
|
|
||||||
def test_sidebar_size(self):
|
def test_show_sidebar(self):
|
||||||
"""Check setting the size of the sidebar area."""
|
"""Check setting the show-sidebar property."""
|
||||||
self.assertEqual(self.window.sidebar_size, 300)
|
self.assertFalse(self.window.show_sidebar)
|
||||||
self.assertEqual(self.window._outer_pane.get_position(), 300)
|
self.assertFalse(self.window._layout.show_sidebar)
|
||||||
|
|
||||||
self.window.sidebar_size = 100
|
self.window.show_sidebar = True
|
||||||
self.assertEqual(self.window.sidebar_size, 100)
|
self.assertTrue(self.window._layout.show_sidebar)
|
||||||
self.assertEqual(self.window._outer_pane.get_position(), 100)
|
|
||||||
|
|
||||||
self.window._outer_pane.set_position(200)
|
self.window._layout.show_sidebar = False
|
||||||
self.assertEqual(self.window.sidebar_size, 200)
|
self.assertFalse(self.window.show_sidebar)
|
||||||
self.assertEqual(self.window._outer_pane.get_position(), 200)
|
|
||||||
|
|
||||||
def test_now_playing(self):
|
def test_now_playing(self):
|
||||||
"""Check setting a widget to the now_playing area."""
|
"""Check setting a widget to the now_playing area."""
|
||||||
|
@ -170,3 +166,10 @@ class TestWindow(unittest.TestCase):
|
||||||
self.assertEqual(accels[0].name, "reset-focus")
|
self.assertEqual(accels[0].name, "reset-focus")
|
||||||
self.assertEqual(accels[0].func, self.window.set_focus)
|
self.assertEqual(accels[0].func, self.window.set_focus)
|
||||||
self.assertListEqual(accels[0].accels, ["Escape"])
|
self.assertListEqual(accels[0].accels, ["Escape"])
|
||||||
|
|
||||||
|
@unittest.mock.patch("emmental.window.Window.add_breakpoint")
|
||||||
|
def test_breakpoints(self, mock_add_breakpoint: unittest.mock.Mock):
|
||||||
|
"""Test that the Window breakpoints are set up properly."""
|
||||||
|
window2 = emmental.window.Window(version="1.2.3")
|
||||||
|
self.assertEqual(len(mock_add_breakpoint.mock_calls),
|
||||||
|
len(window2._layout.breakpoints))
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user