Compare commits

...

56 Commits

Author SHA1 Message Date
Anna Schumaker c3818a2b18 Emmental 3.2 AUR commit
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-20 13:55:33 -04:00
Anna Schumaker 19c47be056 Emmental 3.2
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-20 13:38:09 -04:00
Anna Schumaker dbc60e1c5f emmental: Add the ListenBrainz GObject to the Application
And wire it up.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-20 13:35:18 -04:00
Anna Schumaker 6779535cf1 listenbrainz: Early startup handling
We can't craft a Listen object from a Track before the database is
almost entirely loaded, and attempting to do so will cause the
ListenBrainz thread to crash. So let's just defer any ListenBrainz
operations until the database has marked itself as "loaded" so we know
everything will work.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-20 13:35:18 -04:00
Anna Schumaker 2ae5fd0969 listenbrainz: Offline handling
If we get a connection error from any listenbrainz operation, then we need
to set up an occasional timer to retry connecting to listenbrainz to see
if the connection has been restored.

Implements: #69 ("Add ListenBrainz support")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-20 13:35:18 -04:00
Anna Schumaker b1490fd447 listenbrainz: Submit Listens to ListenBrainz
I query the database for up to 50 tracks to submit at once. If there is
only one track to submit then I use the submit_single_listen() function
as intended by ListenBrainz.

Implements: #69 ("Add ListenBrainz support")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-20 13:35:18 -04:00
Anna Schumaker 4c5d3c78c0 listenbrainz: Submit the currently playing track to ListenBrainz
I do this by creating a new Listen class that is constructed from one of
our db.track.Tracks to convert to something liblistenbrainz understands.
From there, I watch for changes to the "now-playing" property and call
out to the Thread to submit the track to ListenBrainz.

Implements: #69 ("Add ListenBrainz support")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-20 13:35:18 -04:00
Anna Schumaker 84a832389f listenbrainz: Clear the user API token
If the user clears out their token in the settings UI, then we need to
clear it out in our listenbrainz client object as well.

Implements: #69 ("Add ListenBrainz support")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-20 13:35:18 -04:00
Anna Schumaker 924f65fddd listenbrainz: Set the user API token
I added a "user-token" property to the ListenBrainz object, and watch for
changes to query the liblistenbrainz client. I also set up an idle
callback so we can set the "valid-token" property so the user can know
if there is a problem.

Implements: #69 ("Add ListenBrainz support")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-20 13:35:15 -04:00
Anna Schumaker 14c153733d listenbrainz: Add an initial ListenBrainz object
I created the foundation of what I'll need for working with
ListenBrainz. This includes a ListenBrainz GObject, a threading.Thread
implementation that will do the actual work for communicating with
ListenBrainz, and what will become a priority queue to make sure we do
certain operations (such as setting the user's auth token) first.

These are mostly placeholder classes right now, and future patches will
expand on what they can do.

Implements: #69 ("Add ListenBrainz support")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-20 13:35:13 -04:00
Anna Schumaker efe2611422 header: Add a PasswordEntry for inputting the ListenBrainz token
The user can fill this out to connect to their listenbrainz account and
submit listens. I add a listenbrainz logo icon based on their icon from
the website. I also create a symbolic version that I end up using in the
popover menu.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-18 11:17:31 -04:00
Anna Schumaker c49a23b046 header: Create a Settings Adw.ActionRow
This contains all the steps needed to open the settings editor window. I
move it into the "Menu" popover list since it's not a common action, so
it can be hidden from the main UI.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-18 11:17:31 -04:00
Anna Schumaker a944af7f3e header: Convert the Open button to an Adw.ActionRow
And put it in the new Menu button popover list. I don't expect this to
be a common action, so the extra button press is acceptable.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-18 11:17:31 -04:00
Anna Schumaker c5867badae header: Add a menu button
This will be expanded to contain the open file button, the settings
dialog, and eventually a listenbrainz configuration option.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-18 11:17:31 -04:00
Anna Schumaker e85bdcc7f4 header: Rename volume header objects
I'm going to add a second popover button to the header, so I need to
clarify which button these objects are being attached to.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-18 11:17:31 -04:00
Anna Schumaker c7dca6164e db: Create a listenbrainz_queue table in the database
I bump the user_version to 3 at the same time. This table will be used
to hold listenbrainz listens that have not yet been submitted to the
listenbrainz server. I also give the Track table functions to get and
delete listens from this table as needed by the listenbrainz thread.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-18 11:17:30 -04:00
Anna Schumaker eada937b7a db: Give the tracks table a track-played signal
I'm going to need this in ListenBrainz so we can submit the played
Track.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-13 11:30:47 -04:00
Anna Schumaker d373c33283 db: Convert the tagger to the new Thread class
This lets us do a lot of the basic Thread operations through common
code, allowing us to focus on tagging in this file instead of basic
Thread controls.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-13 11:30:47 -04:00
Anna Schumaker 1db187dba5 thread: Create a reusable Thread class
I found that I'm rewriting some of the same features every time I need
to spin up a Thread for something. This is a reusable Thread that can be
inherited for specific work.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-13 11:30:47 -04:00
Anna Schumaker 0d100ec752 thread: Create a generic Data class
This class is desigend to make it easier to pass data to and from a
running Thread. This was inspired by the types.SimpleNamespace object so
we can set generic values and use them as class members.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-13 11:30:47 -04:00
Anna Schumaker c4e827bc5a sidebar: Use the new database.loaded property
Rather than doing the work ourselves to calculate if the database has
been loaded, use the new property to notify us.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-13 11:29:54 -04:00
Anna Schumaker a4e0968ef4 db: Give the database a 'loaded' property
This can be checked or connected to so other parts of the application
can easily know if all database tables have been loaded or not.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-03-13 11:20:15 -04:00
Anna Schumaker 58a1df1d1d db: Test track timestamps using datetime.datetime.utcnow()
I haven't run tests in the evening in a long time, so I never noticed
these failures due to sqlite returning utc timestamps when we expect
localtime.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-01-29 14:38:24 -05:00
Anna Schumaker 3c25dc2c7f tracklist: Change the "shuffle disabled" icon based on icon theme
The Breeze "media-playlist-consecutive" icon looks terrible, and we want
to use "media-playlist-normal" to get the same look as with the Adwaita
icon theme. Unfortunately, Adwaita doesn't have the
"media-playlist-normal" icon. So I created a function to ask the icon
theme if it has a specific icon, and modify the shuffle button to change
the inactive icon when toggled to keep up with icon theme changes.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-01-29 14:38:24 -05:00
Anna Schumaker c37ae94a5d emmental: Icon Updates
These are various icon changes that I noticed after using emmental with
KDE & the Breeze icon theme for a while.

- Replace the go-jump icon with arrow4-down-symbolic
- Replace the view-list-ordered icon with list-compact

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-01-29 14:38:24 -05:00
Anna Schumaker e6a219017d sidebar: Use a symbolic icon for the library section
I was relying on the icon theme to fallback to symbolic icons when I
initially wrote it. Turns out, some icon themes do provide a color icon
for this, so I specifically ask for the symoblic icon for consistency.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-01-29 14:38:24 -05:00
Anna Schumaker 3c15515faf texture: Clear the existing texture cache before testing
This test started failing after updating to pytest 8.0. I fix it by
clearing the cache so the test can begin with a clear slate.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-01-29 14:38:24 -05:00
Anna Schumaker 6c6ebf3676 submodules: Update the git url of the mpris spec
Freedesktop.org uses gitlab now.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2024-01-20 10:47:55 -05:00
Anna Schumaker ad8fd70f9a Emmental 3.1.1 AUR commit
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-30 16:08:17 -04:00
Anna Schumaker 8c316d0126 Emmental 3.1.1
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-30 16:02:29 -04:00
Anna Schumaker 3f153e1423 header: Add a keyboard accelerator for toggling the sidebar
The user can press <Control>] to open and close the sidebar.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-24 11:31:11 -04:00
Anna Schumaker a08273535c emmental: Wire up the show-sidebar properties
Allowing us to show and hide the sidebar by clicking the button in the
header. I also save the current state of the sidebar, although the
Adw.OverlaySplitView might override this when the window is shown.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-24 11:19:12 -04:00
Anna Schumaker ae1c611959 header: Add a show sidebar button to the header
The user will be able to click on this to show and hide the sidebar.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-24 11:04:09 -04:00
Anna Schumaker e73b6c09e7 layout: Add a "wide-view" breakpoint
We un-collapse the sidebar when we detect that we have a wide enough
window.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-24 10:48:59 -04:00
Anna Schumaker b02fd609f7 window: Set the minimum size of the window
This is required by libadwaita breakpoints

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-24 10:48:17 -04:00
Anna Schumaker 3241830c8e window: Remove the sidebar-size property
The new Layout widget isn't user adjustable, so this property is now
unused.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-24 10:48:10 -04:00
Anna Schumaker 97659f212d window: Replace the outer Gtk.Pane with a Layout widget
I keep the sidebar-size property for now, but it will be removed soon.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-24 10:47:31 -04:00
Anna Schumaker d22a9b23a1 layout: Create an adaptable Layout widget
I'm planning to build on this widget over the next several releases.
It'll eventually be fully adaptable to window size changes made by the
user.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-24 10:43:11 -04:00
Anna Schumaker 29693dcf84 tracklist: Set the ellipsize mode of the footer labels
Without this, we start getting a warning on narrow labels when
breakpoints are enabled.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-23 16:15:23 -04:00
Anna Schumaker bee48deac6 Emmental 3.1 AUR commit
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-20 16:51:11 -04:00
Anna Schumaker 5e096fa704 Emmental 3.1
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-20 16:34:05 -04:00
Anna Schumaker 6ebf29a632 tracklist: Use the Texture Cache for album art
Replacing our tracklist-specific one that caches textures, but not to
local disk.

Implements #53 ("Convert the TrackList to use the Texture Cache")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-20 16:31:43 -04:00
Anna Schumaker a4f30d87e6 sidebar: Use the Texture Cache for album art and user icons
I make sure to clear an existing texture before setting a new one in
case the user downloads a new file with the same path. Otherwise we'll
end up using a stale texture in the list.

Implements: #54 ("Convert the SideBar to use the Texture Cache")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-20 16:31:37 -04:00
Anna Schumaker 51b290e1f0 texture: Save the Texture Cache items to disk
I use the filepath of the requested item to derive a cache file name in
the user's xdg cachedirectory. I also add a way to update items in the
cache if we detect that the mtime has changed, and support loading items
from the cache if the source file has been deleted.

Implements #52 ("Save the Texture Cache textures to the .cache directory")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-20 16:31:31 -04:00
Anna Schumaker fa203a72dd texture: Add a Texture Cache
The Texture Cache will be used to map filenames to Gdk.Textures loaded
into memory. The application can then re-use textures instead of making
expensive filesystem calls and loading the same images multiple times.

Implements: #51 ("Texture Cache")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-20 16:31:24 -04:00
Anna Schumaker 3b8fb8531e gsetup: Add a CACHE_DIR path
This points to the user's ~/.cache directory where Emmental can store
files.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-20 16:31:24 -04:00
Anna Schumaker 3e73ce0650 db: Reload the New Tracks playlist at midnight
The New Tracks playlist shows tracks that have been added within the
past week. We should automatically reload it a few seconds after
midnight to keep it up to date as tracks drop off the list.

Implements: #58 ("Reload New Tracks playlist at midnight")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-20 16:31:16 -04:00
Anna Schumaker 17e4d85f1b alarm: Add functions for setting an alarm
An alarm is a callback that triggers at a specific time, rather than at
a specific interval. I build this using GLib.timeout_add_seconds() and
wrapping it with logic to calculate the amount of time until the alarm
should be triggered next.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-20 16:31:16 -04:00
Anna Schumaker 24675bf202 db: Save and restore the track added date when deleting a Track
I found that deleted and restorted tracks were incorrectly showing up in
the "New Tracks" playlist. I can fix this by saving the track added date
when the tracks is deleted. The only thing I can't do easily is get the
added date for tracks that have already been deleted, so I set this to
the date of the database upgrade.

Fixes: #64 ("Save the tracks.added date when deleting")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-20 16:31:09 -04:00
Anna Schumaker 072264a77c db: Upgrade the database version to 2
Prepare for database modifications. The first step is to bump the
database version, and it's cleaner to do that in a separate patch.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-18 11:50:29 -04:00
Anna Schumaker e7526f595f db: Give the db an executescript() function
This is a wrapper function that takes a pathlib.Path object, reads it,
and calls the sqlite3 executescript() function. I update the main
db.Connection object to call this function to set up our database tables
while I'm at it.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-18 10:51:43 -04:00
Anna Schumaker 7d2ec00da7 options: Don't set GLib.OptionEntry.arg
Attempting to set this field gives me "expected enumeration type void,
but got PyGLibOptionArg instead". I'm not sure what's wanted here, so
I'm commenting out this line for now and can revisit later.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-10-18 10:00:35 -04:00
Anna Schumaker 70d7f5fa70 tracklist: Use the Gtk.ColumnView.scroll_to() function for scrolling
Rather than trying to implement this myself through manually moving the
scrolled window. It's much easier to simply let Gtk do the work for us.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-08-30 13:40:22 -04:00
Anna Schumaker 2504f4b91d sidebar: Add a timeout when selecting playlists on load
I've found that during startup, we sometimes try to select the current
playlist before the Gtk sidebar widgets are completely loaded. This
results showing the right section, but not actually selecting a
playlist. We can fix this by selecting the playlist after a short
timeout to give everything a chance to load.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-08-30 13:22:28 -04:00
Anna Schumaker 7358183fef sidebar: Use the Gtk.ListView.scroll_to() function for scrolling
Rather than activating an action through a GLib.Variant, we can use the
newly added scroll_to() function to do most of the work for us.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-08-30 11:39:52 -04:00
Anna Schumaker c195e68216 Emmental 3.0.6 AUR commit
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-08-24 09:53:19 -04:00
71 changed files with 2667 additions and 432 deletions

2
.gitmodules vendored
View File

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

View File

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

2
aur

@ -1 +1 @@
Subproject commit 562b8d043c5f78ed3b2f62677414117f0af509a6
Subproject commit 543220f35990f5b5a69eac5a6c06e69eae6ffb82

View File

@ -7,6 +7,7 @@ from . import action
from . import audio
from . import db
from . import header
from . import listenbrainz
from . import mpris2
from . import nowplaying
from . import options
@ -20,8 +21,8 @@ from gi.repository import Gio
from gi.repository import Adw
MAJOR_VERSION = 3
MINOR_VERSION = 0
MICRO_VERSION = 6
MINOR_VERSION = 2
MICRO_VERSION = 0
VERSION_NUMBER = f"{MAJOR_VERSION}.{MINOR_VERSION}.{MICRO_VERSION}"
VERSION_STRING = f"Emmental {VERSION_NUMBER}{gsetup.DEBUG_STR}"
@ -34,6 +35,7 @@ class Application(Adw.Application):
factory = GObject.Property(type=playlist.Factory)
mpris = GObject.Property(type=mpris2.Connection)
player = GObject.Property(type=audio.Player)
lbrainz = GObject.Property(type=listenbrainz.ListenBrainz)
win = GObject.Property(type=window.Window)
autopause = GObject.Property(type=int, default=-1, minimum=-1, maximum=99)
@ -135,11 +137,14 @@ class Application(Adw.Application):
hdr = header.Header(sql=self.db, title=VERSION_STRING)
for prop in ["bg-enabled", "bg-volume", "volume"]:
hdr.bind_property(prop, self.player, prop)
hdr.bind_property("listenbrainz-token", self.lbrainz, "user-token")
for (setting, property) in [("audio.volume", "volume"),
("audio.background.enabled", "bg-enabled"),
("audio.background.volume", "bg-volume"),
("audio.replaygain.enabled", "rg-enabled"),
("audio.replaygain.mode", "rg-mode")]:
("audio.replaygain.mode", "rg-mode"),
("listenbrainz.token",
"listenbrainz_token")]:
self.db.settings.bind_setting(setting, hdr, property)
self.__add_accelerators(hdr.accelerators)
@ -206,12 +211,14 @@ class Application(Adw.Application):
now_playing=self.build_now_playing(),
sidebar=self.build_sidebar(),
tracklist=self.build_tracklist())
win.bind_property("show-sidebar", win.header, "show-sidebar",
GObject.BindingFlags.BIDIRECTIONAL)
win.bind_property("user-editing", win.now_playing, "editing")
for (setting, property) in [("window.width", "default-width"),
("window.height", "default-height"),
("now-playing.size", "now-playing-size"),
("sidebar.size", "sidebar-size")]:
("sidebar.show", "show-sidebar")]:
self.db.settings.bind_setting(setting, win, property)
self.__add_accelerators(win.accelerators)
@ -251,6 +258,15 @@ class Application(Adw.Application):
self.mpris.player.connect("SetPosition", self.__set_position)
self.mpris.player.connect("Stop", self.player.stop)
def connect_listenbrainz(self) -> None:
"""Connect the listenbrainz client."""
self.db.tracks.bind_property("current-track",
self.lbrainz, "now-playing")
self.lbrainz.bind_property("valid-token", self.win.header,
"listenbrainz-token-valid")
self.db.tracks.connect("track-played", self.lbrainz.submit_listens)
def connect_playlist_factory(self) -> None:
"""Connect the playlist factory properties."""
self.db.playlists.bind_property("previous",
@ -277,6 +293,7 @@ class Application(Adw.Application):
Adw.Application.do_startup(self)
self.db = db.Connection()
self.mpris = mpris2.Connection()
self.lbrainz = listenbrainz.ListenBrainz(self.db)
self.factory = playlist.Factory(self.db)
self.player = audio.Player()
@ -289,6 +306,7 @@ class Application(Adw.Application):
self.win = self.build_window()
self.add_window(self.win)
self.connect_mpris2()
self.connect_listenbrainz()
self.connect_playlist_factory()
self.connect_player()
@ -314,6 +332,9 @@ class Application(Adw.Application):
if self.win is not None:
self.win.close()
self.win = None
if self.lbrainz is not None:
self.lbrainz.stop()
self.lbrainz = None
if self.mpris is not None:
self.mpris.disconnect()
self.mpris = None

48
emmental/alarm.py Normal file
View 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]

View File

@ -18,13 +18,16 @@ from . import tracks
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):
"""Connect to the database."""
active_playlist = GObject.Property(type=playlist.Playlist)
loaded = GObject.Property(type=bool, default=False)
def __init__(self):
"""Initialize a sqlite connection."""
@ -43,13 +46,25 @@ class Connection(connection.Connection):
self.tracks = tracks.Table(self)
def __check_loaded(self) -> None:
for tbl in list(self.playlist_tables()) + [self.tracks]:
if tbl.loaded is False:
return
self.loaded = True
def __check_version(self) -> None:
user_version = self("PRAGMA user_version").fetchone()["user_version"]
match user_version:
case 0:
with open(SQL_SCRIPT) as f:
self._sql.executescript(f.read())
case 1: pass
self.executescript(SQL_V1_SCRIPT)
self.executescript(SQL_V2_SCRIPT)
self.executescript(SQL_V3_SCRIPT)
case 1:
self.executescript(SQL_V2_SCRIPT)
self.executescript(SQL_V3_SCRIPT)
case 2:
self.executescript(SQL_V3_SCRIPT)
case 3: pass
case _:
raise Exception(f"Unsupported data version: {user_version}")
@ -96,3 +111,4 @@ class Connection(connection.Connection):
def table_loaded(self, tbl: table.Table) -> None:
"""Signal that a table has been loaded."""
tbl.loaded = True
self.__check_loaded()

View File

@ -85,3 +85,11 @@ class Connection(GObject.GObject):
return self._sql.executemany(statement, args)
except sqlite3.InternalError:
return None
def executescript(self, script: pathlib.Path) -> sqlite3.Cursor | None:
"""Execute a SQL script."""
if script.is_file():
with open(script) as f:
cur = self._sql.executescript(f.read())
self.commit()
return cur

View File

@ -55,10 +55,11 @@ class Library(playlist.Playlist):
def __tag_track(self, path: pathlib.Path) -> bool:
if self.tagger.ready.is_set():
(file, tags) = self.tagger.get_result(self.table.sql, self)
if file is None:
result = self.tagger.get_result(db=self.table.sql, library=self)
if result is None:
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:
return True
return False

View File

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

View File

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

View File

@ -200,6 +200,12 @@ class Table(table.Table):
return self.sql(f"UPDATE tracks SET {column}=? WHERE trackid=?",
newval, track.trackid)
def delete_listens(self, listenids: list[int]) -> None:
"""Delete the listens indicated by the provided listenids."""
self.sql.executemany("""DELETE FROM listenbrainz_queue
WHERE listenid=?""",
*[(id,) for id in listenids])
def get_artists(self, track: Track) -> list[table.Row]:
"""Get the set of Artists for a specific Track."""
rows = self.sql("""SELECT artistid FROM artist_tracks_view
@ -212,6 +218,14 @@ class Table(table.Table):
WHERE trackid=?""", track.trackid).fetchall()
return [self.sql.genres.rows.get(row["genreid"]) for row in rows]
def get_n_listens(self, n: int) -> list[tuple]:
"""Get the n most recent listens from the listenbrainz queue."""
cur = self.sql("""SELECT listenid, trackid, timestamp
FROM listenbrainz_queue ORDER BY timestamp DESC
LIMIT ?""", n)
return [(row["listenid"], self.rows[row["trackid"]], row["timestamp"])
for row in cur.fetchall()]
def map_sort_order(self, ordering: str) -> dict[int, int]:
"""Get a lookup table for Track sort keys."""
ordering = ordering if len(ordering) > 0 else "trackid"
@ -270,9 +284,17 @@ class Table(table.Table):
self.sql.playlists.most_played.reload_tracks(idle=True)
self.sql.playlists.queued.remove_track(track)
self.sql.playlists.unplayed.remove_track(track)
self.emit("track-played", track)
self.sql.commit()
@GObject.Signal(arg_types=(Track,))
def track_played(self, track: Track) -> None:
"""Signal that a Track was played."""
if track is not None:
self.sql("""INSERT INTO listenbrainz_queue (trackid, timestamp)
VALUES (?, ?)""", track.trackid, track.lastplayed)
class TrackidSet(GObject.GObject):
"""Manage a set of Track IDs."""

View 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;

View 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;

View File

@ -24,6 +24,9 @@ CSS_PRIORITY = gi.repository.Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
CSS_PROVIDER = gi.repository.Gtk.CssProvider()
CSS_PROVIDER.load_from_path(str(CSS_FILE))
CACHE_DIR = pathlib.Path(xdg.BaseDirectory.save_cache_path("emmental"))
CACHE_DIR = CACHE_DIR / DEBUG_STR.lstrip("-")
DATA_DIR = pathlib.Path(xdg.BaseDirectory.save_data_path("emmental"))
RESOURCE_PATH = "/com/nowheycreamery/emmental"
@ -40,6 +43,13 @@ def add_style():
CSS_PROVIDER, CSS_PRIORITY)
def has_icon(icon_name: str):
"""Check if the icon theme has a specific icon."""
display = gi.repository.Gdk.Display.get_default()
theme = gi.repository.Gtk.IconTheme.get_for_display(display)
return theme.has_icon(icon_name)
def __version_string(subsystem, major, minor, micro):
return f"{subsystem} {major}.{minor}.{micro}"

View File

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

View 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

View File

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

View File

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

61
emmental/layout.py Normal file
View 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)]

View 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()

View 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()

View 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",)

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

View File

@ -46,10 +46,10 @@ class Card(Gtk.Box):
large_icon=True,
has_frame=False, sensitive=False,
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",
large_icon=True, sensitive=False,
valign=Gtk.Align.CENTER)
has_frame=False, valign=Gtk.Align.CENTER)
self._seeker = seeker.Scale(sensitive=False)
self.bind_property("artwork", self._artwork, "filepath")

View File

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

View File

@ -1,6 +1,7 @@
# Copyright 2022 (c) Anna Schumaker.
"""A card for displaying the list of playlists."""
from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gtk
from . import artist
from . import decade
@ -26,7 +27,7 @@ class Card(Gtk.Box):
sensitive=False, **kwargs)
self._header = Gtk.CenterBox()
self._filter = entry.Filter("playlists", hexpand=True)
self._jump = Gtk.Button(icon_name="go-jump-symbolic",
self._jump = Gtk.Button(icon_name="arrow4-down-symbolic",
tooltip_text="scroll to current playlist")
self._playlists = playlist.Section(self.sql.playlists)
self._artists = artist.Section(self.sql.artists, self.sql.albums)
@ -51,7 +52,7 @@ class Card(Gtk.Box):
self._filter.connect("search-changed", self.__search_changed)
self._jump.connect("clicked", self.__jump_to_playlist)
self.sql.connect("table-loaded", self.__table_loaded)
self.sql.connect("notify::loaded", self.__database_loaded)
self._header.add_css_class("toolbar")
self.add_css_class("card")
@ -62,31 +63,40 @@ class Card(Gtk.Box):
def __search_changed(self, entry: entry.Filter) -> None:
self.sql.filter(entry.get_query())
def __table_loaded(self, sql: db.Connection, table: db.table.Table):
if self.get_sensitive() is False:
if False not in {tbl.loaded for tbl in sql.playlist_tables()}:
self.set_sensitive(True)
self.select_playlist(sql.active_playlist)
if len(sql.libraries) == 0:
self._libraries.extra_widget.emit("clicked")
def __database_loaded(self, sql: db.Connection, param: GObject.ParamSpec):
self.set_sensitive(sql.loaded)
if sql.loaded is True:
self.select_playlist(sql.active_playlist, 150)
if len(sql.libraries) == 0:
self._libraries.extra_widget.emit("clicked")
def select_playlist(self, playlist: db.playlist.Playlist) -> None:
"""Set the current active playlist."""
def __select_playlist(self, playlist: db.playlist.Playlist) -> bool:
if playlist is not None:
match playlist.table:
case self.sql.playlists:
section = self._playlists
case self.sql.artists | self.sql.albums | self.sql.media:
section = self._artists
case self.sql.genres:
section = self._genres
case self.sql.decades | self.sql.years:
section = self._decades
case self.sql.libraries:
section = self._libraries
section.active = True
section = self.table_section(playlist.table)
if not section.active:
section.active = True
return GLib.SOURCE_CONTINUE
section.select_playlist(playlist)
return GLib.SOURCE_REMOVE
def select_playlist(self, playlist: db.playlist.Playlist,
timeout: int = 0) -> None:
"""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]:

View File

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

View File

@ -39,7 +39,7 @@ class Section(section.Section):
def __init__(self, table=db.libraries.Table):
"""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")
self.extra_widget = Gtk.Button(icon_name="folder-new", has_frame=False,
tooltip_text="add new library path")

View File

@ -2,7 +2,6 @@
"""A sidebar Header attached to a hidden ListView for selecting playlists."""
import typing
from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gtk
from .. import db
from .. import factory
@ -86,9 +85,7 @@ class Section(header.Header):
def select_playlist(self, playlist: db.playlist.Playlist) -> None:
"""Select the requested playlist."""
if (index := self.playlist_index(playlist)) is not None:
self._selection.select_item(index, True)
self._listview.activate_action("list.scroll-to-item",
GLib.Variant.new_uint32(index))
self._listview.scroll_to(index, Gtk.ListScrollFlags.SELECT)
@GObject.Signal(arg_types=(db.playlist.Playlist,))
def playlist_activated(self, playlist: db.playlist.Playlist):

76
emmental/texture.py Normal file
View 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
View 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()

View File

@ -5,6 +5,7 @@ from gi.repository import Gio
from gi.repository import Gtk
from . import sorter
from .. import buttons
from .. import gsetup
class VisibleRow(Gtk.ListBoxRow):
@ -107,7 +108,7 @@ class ShuffleButton(buttons.ImageToggle):
"""Initialize a Shuffle Button."""
super().__init__(active_icon_name="media-playlist-shuffle",
active_tooltip_text="shuffle: enabled",
inactive_icon_name="media-playlist-consecutive",
inactive_icon_name=self.get_inactive_icon(),
inactive_tooltip_text="shuffle: disabled",
large_icon=False, icon_opacity=0.5,
has_frame=False, **kwargs)
@ -115,6 +116,13 @@ class ShuffleButton(buttons.ImageToggle):
def do_toggled(self):
"""Adjust opacity when active state toggles."""
self.icon_opacity = 1.0 if self.active else 0.5
self.inactive_icon_name = self.get_inactive_icon()
def get_inactive_icon(self) -> str:
"""Return the inactive icon name."""
if gsetup.has_icon("media-playlist-normal"):
return "media-playlist-normal"
return "media-playlist-consecutive"
class SortRow(Gtk.ListBoxRow):
@ -180,7 +188,7 @@ class SortButton(buttons.PopoverButton):
"""Initialize the Sort button."""
super().__init__(has_frame=False, model=sorter.SortOrderModel(),
tooltip_text="configure playlist sort order",
icon_name="view-list-ordered-symbolic", **kwargs)
icon_name="list-compact-symbolic", **kwargs)
self.popover_child = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
self.popover_child.bind_model(self.model, self.__create_func)
self.popover_child.connect("row-activated", self.__row_activated)

View File

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

View File

@ -4,10 +4,10 @@ import datetime
import dateutil.tz
import pathlib
from gi.repository import GObject
from gi.repository import Gdk
from gi.repository import Gtk
from .. import buttons
from .. import factory
from .. import texture
class TrackRow(factory.ListRow):
@ -278,8 +278,6 @@ class MediumString(InscriptionRow):
class AlbumCover(TrackRow):
"""A Track Row to display Album art."""
Cache = dict()
filepath = GObject.Property(type=GObject.TYPE_PYOBJECT)
def __init__(self, listitem: Gtk.ListItem, property: str):
@ -293,19 +291,14 @@ class AlbumCover(TrackRow):
match param.name:
case "mediumid": self.rebind_album("filepath", to_self=True)
case "filepath":
if self.filepath is None:
texture = None
elif (texture := AlbumCover.Cache.get(self.filepath)) is None:
texture = Gdk.Texture.new_from_filename(str(self.filepath))
AlbumCover.Cache[self.filepath] = texture
self.child.set_paintable(texture)
self.child.set_has_tooltip(texture is not None)
tex = texture.CACHE[self.filepath]
self.child.set_paintable(tex)
self.child.set_has_tooltip(tex is not None)
def __query_tooltip(self, child: Gtk.Picture, x: int, y: int,
keyboard_mode: bool, tooltip: Gtk.Tooltip) -> bool:
texture = AlbumCover.Cache.get(self.filepath)
tooltip.set_custom(Gtk.Picture.new_for_paintable(texture))
tex = texture.CACHE[self.filepath]
tooltip.set_custom(Gtk.Picture.new_for_paintable(tex))
return True
def do_bind(self) -> None:

View File

@ -81,13 +81,9 @@ class TrackView(Gtk.ScrolledWindow):
def scroll_to_track(self, track: db.tracks.Track) -> None:
"""Scroll to the requested Track."""
# This is a workaround until the ColumnView has better scrolling
# support, which seems to be targeted for Gtk 4.10.
adjustment = self._scrollwin.get_vadjustment()
for (i, t) in enumerate(self._selection):
if t == track:
pos = max(i - 3, 0) * adjustment.get_upper()
adjustment.set_value(pos / self._selection.get_n_items())
for i in range(self._selection.props.n_items):
if self._selection[i] == track:
self._columnview.scroll_to(i, None, Gtk.ListScrollFlags.NONE)
@GObject.Property(type=Gio.ListModel)
def columns(self) -> Gio.ListModel:

View File

@ -4,6 +4,7 @@ from gi.repository import GObject
from gi.repository import Gtk
from gi.repository import Adw
from .action import ActionEntry
from . import layout
def _make_pane(orientation: Gtk.Orientation, position: int = 0,
@ -12,7 +13,7 @@ def _make_pane(orientation: Gtk.Orientation, position: int = 0,
pane = Gtk.Paned(orientation=orientation, hexpand=True, vexpand=True,
shrink_start_child=False, resize_start_child=False,
start_child=start_child, end_child=end_child,
position=position)
position=position, margin_start=8)
pane.add_css_class("emmental-pane")
return pane
@ -29,7 +30,7 @@ class Window(Adw.Window):
header = 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_size = GObject.Property(type=int, default=250)
tracklist = GObject.Property(type=Gtk.Widget)
@ -38,26 +39,25 @@ class Window(Adw.Window):
def __init__(self, version: str, **kwargs):
"""Initialize our Window."""
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._header = Adw.Bin(child=self.header)
self._inner_pane = _make_pane(Gtk.Orientation.VERTICAL,
position=self.now_playing_size,
start_child=self.now_playing,
end_child=self.tracklist)
self._outer_pane = _make_pane(Gtk.Orientation.HORIZONTAL,
position=self.sidebar_size,
start_child=self.sidebar,
end_child=self._inner_pane)
self._toast = Adw.ToastOverlay(child=self._outer_pane)
self._layout = layout.Layout(content=self._inner_pane,
sidebar=self.sidebar)
self._toast = Adw.ToastOverlay(child=self._layout)
self._outer_pane.add_css_class("emmental-padding")
self._layout.add_css_class("emmental-padding")
if __debug__:
self.add_css_class("devel")
self.bind_property("header", self._header, "child")
self.bind_property("sidebar", self._outer_pane, "start-child")
self.bind_property("sidebar-size", self._outer_pane, "position",
self.bind_property("sidebar", self._layout, "sidebar")
self.bind_property("show-sidebar", self._layout, "show-sidebar",
GObject.BindingFlags.BIDIRECTIONAL)
self.bind_property("now-playing", self._inner_pane, "start-child")
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)
for breakpoint in self._layout.breakpoints:
self.add_breakpoint(breakpoint)
self._box.append(self._header)
self._box.append(self._toast)
self.set_content(self._box)

View 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

View 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

View 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

View 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

7
tests/db/test-script.sql Normal file
View 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);

View File

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

View File

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

View File

@ -182,23 +182,23 @@ class TestLibraryObject(tests.util.TestCase):
tagger.tag_file.assert_not_called()
tagger.ready.is_set.return_value = True
tagger.get_result.return_value = (None, None)
tagger.get_result.return_value = None
self.assertFalse(self.library._Library__tag_track(track))
tagger.get_result.assert_called_with(self.sql, self.library)
tagger.tag_file.assert_called_with(track, None)
tagger.get_result.assert_called_with(db=self.sql, library=self.library)
tagger.tag_file.assert_called_with(track, mtime=None)
self.sql.tracks.lookup = unittest.mock.Mock()
self.sql.tracks.lookup.return_value.mtime = 12345
self.assertFalse(self.library._Library__tag_track(track))
tagger.get_result.assert_called_with(self.sql, self.library)
tagger.tag_file.assert_called_with(track, 12345)
tagger.get_result.assert_called_with(db=self.sql, library=self.library)
tagger.tag_file.assert_called_with(track, mtime=12345)
tagger.reset_mock()
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))
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")
def test_scan_check_trackid(self, mock_untag: unittest.mock.Mock()):

View File

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

View File

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

View File

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

View File

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

View 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")

View File

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

View File

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

View File

@ -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.utcnow()
local_now = utc_now.replace(tzinfo=dateutil.tz.tzutc()).astimezone()
listen = emmental.listenbrainz.listen.Listen(self.track,
listenid=1234,
listened_at=utc_now)
self.assertEqual(listen.listenid, 1234)
self.assertEqual(listen.listened_at, local_now.timestamp())

View File

@ -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.utcnow()
ts2 = datetime.datetime.utcnow()
idle_work = self.listenbrainz._ListenBrainz__idle_work
listens = [emmental.listenbrainz.listen.Listen(self.track, listenid=1,
listened_at=ts1),
emmental.listenbrainz.listen.Listen(self.track, listenid=2,
listened_at=ts2)]
self.sql.loaded = True
self.listenbrainz.user_token = "abcde"
self.listenbrainz.valid_token = True
self.listenbrainz._queue.pop()
self.listenbrainz._ListenBrainz__source_stop("_idle_id")
self.listenbrainz.offline = False
self.listenbrainz.submit_listens("ignored", "args")
self.assertIsNotNone(self.listenbrainz._idle_id)
with unittest.mock.patch.object(self.sql.tracks,
"get_n_listens") as mock_get_listens:
mock_get_listens.return_value = [(1, self.track, ts1),
(2, self.track, ts2)]
with unittest.mock.patch.object(self.listenbrainz._thread,
"submit_listens") as mock_submit:
self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE)
mock_get_listens.assert_called_with(50)
mock_submit.assert_called()
with unittest.mock.patch.object(self.sql.tracks,
"delete_listens") as mock_delete:
for valid, offline in [(False, False), (False, True),
(True, False), (True, True)]:
mock_delete.reset_mock()
with self.subTest(valid=valid, offline=offline):
self.listenbrainz._thread.set_result(op="submit-listens",
listens=listens,
valid=valid,
offline=offline)
self.assertEqual(idle_work(), GLib.SOURCE_REMOVE)
self.assertEqual(self.listenbrainz.valid_token, valid)
self.assertEqual(self.listenbrainz.offline, offline)
if valid is True and offline is False:
mock_delete.assert_called_with([1, 2])
else:
mock_delete.assert_not_called()
def test_submit_listens_later(self, mock_idle_add: unittest.mock.Mock,
mock_source_remove: unittest.mock.Mock,
mock_stdout: io.StringIO):
"""Test submitting listens when ListenBrainz is disconnected."""
self.listenbrainz.submit_listens("ignored", "args")
self.assertIsNone(self.listenbrainz._idle_id)
self.listenbrainz.user_token = "abcde"
self.listenbrainz.valid_token = False
self.listenbrainz._queue.pop()
self.listenbrainz._idle_id = None
self.listenbrainz.submit_listens("ignored", "args")
self.assertIsNone(self.listenbrainz._idle_id)
self.listenbrainz.valid_token = True
self.listenbrainz.offline = True
self.listenbrainz.submit_listens("ignored", "args")
self.assertIsNone(self.listenbrainz._idle_id)
@unittest.mock.patch("gi.repository.GLib.timeout_add_seconds")
def test_offline_recovery(self, mock_timeout_add: unittest.mock.Mock,
mock_idle_add: unittest.mock.Mock,
mock_source_remove: unittest.mock.Mock,
mock_stdout: io.StringIO):
"""Test handling an offline response."""
self.assertTrue(self.listenbrainz.offline)
check_func = self.listenbrainz._ListenBrainz__check_online
mock_timeout_add.return_value = 67890
self.listenbrainz.offline = True
self.assertEqual(self.listenbrainz._timeout_id, 67890)
mock_timeout_add.assert_called_with(300, check_func)
mock_timeout_add.reset_mock()
mock_timeout_add.return_value = 99999
self.listenbrainz.offline = True
self.assertEqual(self.listenbrainz._timeout_id, 67890)
mock_timeout_add.assert_not_called()
self.listenbrainz.offline = False
mock_source_remove.assert_called_with(67890)
mock_source_remove.reset_mock()
self.listenbrainz.offline = False
mock_source_remove.assert_not_called()

View File

@ -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",))

View 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")

View File

@ -115,7 +115,7 @@ class TestNowPlaying(unittest.TestCase):
self.assertEqual(self.card._favorite.get_next_sibling(),
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(),
"scroll to current track")
self.assertEqual(self.card._jump.get_valign(), Gtk.Align.CENTER)

View File

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

View File

@ -25,7 +25,7 @@ class TestLibraries(tests.util.TestCase):
emmental.sidebar.library.LibraryRow)
self.assertEqual(self.libraries.table, self.sql.libraries)
self.assertEqual(self.libraries.icon_name, "library-music")
self.assertEqual(self.libraries.icon_name, "library-music-symbolic")
self.assertEqual(self.libraries.title, "Library Paths")
def test_extra_widget(self):

View File

@ -4,7 +4,6 @@ import emmental.db
import emmental.sidebar.section
import tests.util
import unittest.mock
from gi.repository import GLib
from gi.repository import Gtk
@ -105,18 +104,12 @@ class TestSection(tests.util.TestCase):
def test_select_playlist(self):
"""Test selecting a specific playlist."""
self.section.do_get_subtitle = unittest.mock.Mock(return_value="")
playlist_selected = unittest.mock.Mock()
self.section.connect("playlist-selected", playlist_selected)
playlist = self.table.create("Test Playlist")
playlist_selected.assert_not_called()
with unittest.mock.patch.object(self.section._listview,
"activate_action") as mock_action:
"scroll_to") as mock_scroll_to:
self.section.select_playlist(playlist)
playlist_selected.assert_called_with(self.section, playlist)
mock_action.assert_called_with("list.scroll-to-item",
GLib.Variant.new_uint32(0))
mock_scroll_to.assert_called_with(0, Gtk.ListScrollFlags.SELECT)
def test_playlist_selected(self):
"""Test selecting a playlist in the list."""

View File

@ -3,6 +3,7 @@
import emmental.sidebar
import tests.util
import unittest.mock
from gi.repository import GLib
from gi.repository import Gtk
@ -53,7 +54,7 @@ class TestSidebar(tests.util.TestCase):
self.sidebar._jump)
self.assertEqual(self.sidebar._jump.get_icon_name(),
"go-jump-symbolic")
"arrow4-down-symbolic")
self.assertEqual(self.sidebar._jump.get_tooltip_text(),
"scroll to current playlist")
@ -65,22 +66,20 @@ class TestSidebar(tests.util.TestCase):
def test_sensitivity_and_startup(self):
"""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._libraries.extra_widget.emit = unittest.mock.Mock()
for table in tables:
self.assertFalse(self.sidebar.get_sensitive())
self.sidebar.select_playlist.assert_not_called()
self.sidebar._libraries.extra_widget.emit.assert_not_called()
self.sql.emit("table-loaded", table)
self.assertFalse(self.sidebar.get_sensitive())
self.sql.loaded = True
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.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()
def test_show_all_artists(self):
@ -147,45 +146,48 @@ class TestSidebar(tests.util.TestCase):
def test_select_playlist(self):
"""Test setting the active playlist."""
self.assertEqual(self.sidebar._Card__select_playlist(None),
GLib.SOURCE_REMOVE)
playlist = self.sql.playlists.create("Test Playlist")
self.sidebar.select_playlist(playlist)
self.assertTrue(self.sidebar._playlists.active)
self.assertEqual(self.sidebar.selected_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)
mock_select.assert_not_called()
artist = self.sql.artists.create("Test Artist")
album = self.sql.albums.create("Test Album", "Test Artist", "2023")
medium = self.sql.media.create(album, "Test Medium", number=1)
self.assertEqual(self.sidebar._Card__select_playlist(playlist),
GLib.SOURCE_REMOVE)
mock_select.assert_called_with(playlist)
self.sidebar._artists.select_playlist = unittest.mock.Mock()
for plist in [artist, album, medium]:
self.sidebar._artists.select_playlist.reset_mock()
self.sidebar._artists.active = False
with unittest.mock.patch.object(GLib, "timeout_add") as mock_to:
self.sidebar.select_playlist(playlist)
mock_to.assert_called_with(0, self.sidebar._Card__select_playlist,
playlist)
self.sidebar.select_playlist(playlist, 42)
mock_to.assert_called_with(42, self.sidebar._Card__select_playlist,
playlist)
self.sidebar.select_playlist(plist)
self.assertTrue(self.sidebar._artists.active)
self.sidebar._artists.select_playlist.assert_called_with(plist)
genre = self.sql.genres.create("Test Genre")
self.sidebar.select_playlist(genre)
self.assertTrue(self.sidebar._genres.active)
self.assertEqual(self.sidebar.selected_playlist, genre)
decade = self.sql.decades.create(1990)
year = self.sql.years.create(1990)
self.sidebar._decades.select_playlist = unittest.mock.Mock()
for plist in [decade, year]:
self.sidebar._decades.select_playlist.reset_mock()
self.sidebar._decades.active = False
self.sidebar.select_playlist(plist)
self.assertTrue(self.sidebar._decades.active)
self.sidebar._decades.select_playlist.assert_called_with(plist)
library = self.sql.libraries.create("/a/b/c")
self.sidebar.select_playlist(library)
self.assertTrue(self.sidebar._libraries.active)
self.assertEqual(self.sidebar.selected_playlist, library)
def test_table_section(self):
"""Test converting a Playlist database table into a Section."""
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))
def test_accelerators(self):
"""Check that the accelerators list is set up properly."""

72
tests/test_alarm.py Normal file
View 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)

View File

@ -21,10 +21,10 @@ class TestEmmental(unittest.TestCase):
def test_version(self):
"""Check that version constants have been set properly."""
self.assertEqual(emmental.MAJOR_VERSION, 3)
self.assertEqual(emmental.MINOR_VERSION, 0)
self.assertEqual(emmental.MICRO_VERSION, 6)
self.assertEqual(emmental.VERSION_NUMBER, "3.0.6")
self.assertEqual(emmental.VERSION_STRING, "Emmental 3.0.6-debug")
self.assertEqual(emmental.MINOR_VERSION, 2)
self.assertEqual(emmental.MICRO_VERSION, 0)
self.assertEqual(emmental.VERSION_NUMBER, "3.2.0")
self.assertEqual(emmental.VERSION_STRING, "Emmental 3.2.0-debug")
def test_application(self):
"""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.factory)
self.assertIsNone(self.application.player)
self.assertIsNone(self.application.lbrainz)
self.assertIsNone(self.application.win)
self.application.emit("startup")
self.assertIsInstance(self.application.db, emmental.db.Connection)
self.assertIsInstance(self.application.mpris,
emmental.mpris2.Connection)
self.assertIsInstance(self.application.lbrainz,
emmental.listenbrainz.ListenBrainz)
self.assertIsInstance(self.application.player, emmental.audio.Player)
self.assertIsInstance(self.application.factory,
emmental.playlist.Factory)
@ -63,7 +66,7 @@ class TestEmmental(unittest.TestCase):
mock_startup.assert_called()
mock_load.assert_called()
mock_add_window.assert_called_with(self.application.win)
mock_set_useragent.assert_called_with("emmental-debug", "3.0.6")
mock_set_useragent.assert_called_with("emmental-debug", "3.2.0")
@unittest.mock.patch("sys.stdout")
@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."""
db = self.application.db = emmental.db.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")
player = self.application.player = emmental.audio.Player()
self.application.emit("shutdown")
self.assertIsNone(self.application.db)
self.assertIsNone(self.application.mpris)
self.assertIsNone(self.application.lbrainz)
self.assertIsNone(self.application.player)
self.assertIsNone(self.application.win)
@ -97,6 +104,7 @@ class TestEmmental(unittest.TestCase):
self.assertFalse(db.connected)
self.assertEqual(player.get_state(), gi.repository.Gst.State.NULL)
mock_close.assert_called()
self.assertFalse(lbrainz._thread.is_alive())
@unittest.mock.patch("sys.stdout", new_callable=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.db)
self.application.player = emmental.audio.Player()
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
self.application.db)
win = self.application.build_window()
self.assertIsInstance(win, emmental.window.Window)
@ -125,12 +135,15 @@ class TestEmmental(unittest.TestCase):
self.application.factory = emmental.playlist.Factory(
self.application.db)
self.application.player = emmental.audio.Player()
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
self.application.db)
self.application.build_window()
for action, accel in [("app.open-file", "<Control>o"),
("app.decrease-volume", "<Shift><Control>Down"),
("app.increase-volume", "<Shift><Control>Up"),
("app.toggle-bg-mode", "<Shift><Control>b"),
("app.toggle-sidebar", "<Control>bracketright"),
("app.edit-settings", "<Shift><Control>s")]:
self.assertEqual(self.application.get_accels_for_action(action),
[accel])
@ -146,6 +159,8 @@ class TestEmmental(unittest.TestCase):
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()
for action, accel in [("app.toggle-favorite", ["<Control>f"]),
@ -203,6 +218,8 @@ class TestEmmental(unittest.TestCase):
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()
for action, accel in [("app.focus-search-playlist",
@ -225,6 +242,8 @@ class TestEmmental(unittest.TestCase):
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()
for action, accel in [("app.focus-search-track", "<Control>slash"),
@ -249,6 +268,8 @@ class TestEmmental(unittest.TestCase):
"""Test that the Playlist Factory 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()
@ -263,6 +284,31 @@ class TestEmmental(unittest.TestCase):
self.assertEqual(self.application.factory.db_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)
def test_replaygain(self, mock_stdout: io.StringIO):
"""Test setting replaygain modes."""
@ -270,6 +316,8 @@ class TestEmmental(unittest.TestCase):
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()
player = self.application.player
@ -285,6 +333,8 @@ class TestEmmental(unittest.TestCase):
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()
player = self.application.player
@ -292,3 +342,19 @@ class TestEmmental(unittest.TestCase):
win.header.bg_volume = 0.5
self.assertTrue(player.bg_enabled)
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)

View File

@ -62,11 +62,22 @@ class TestGSetup(unittest.TestCase):
self.assertIsInstance(emmental.gsetup.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):
"""Check that the DATA_DIR points to the right place."""
data_path = xdg.BaseDirectory.save_data_path("emmental")
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):
"""Check that the env_string() function works as expected."""
self.assertRegex(emmental.gsetup.env_string(),

87
tests/test_layout.py Normal file
View 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())

View File

@ -21,11 +21,23 @@ class TestSettings(unittest.TestCase):
self.settings = self.app.db.settings
self.win = self.app.win
self.player = self.app.player
self.lbrainz = self.app.lbrainz
def tearDown(self):
"""Clean up."""
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):
"""Check saving and loading window size from the database."""
self.assertEqual(self.settings["window.width"], 1600)
@ -38,6 +50,16 @@ class TestSettings(unittest.TestCase):
win = self.app.build_window()
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):
"""Check saving and loading volume from the database."""
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)
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):
"""Check saving and loading the show-all artists setting."""
self.assertFalse(self.win.sidebar.show_all_artists)

99
tests/test_texture.py Normal file
View 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
View 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()

View File

@ -22,8 +22,8 @@ class TestWindow(unittest.TestCase):
self.assertIsInstance(self.window, Adw.Window)
self.assertIsInstance(self.window._box, Gtk.Box)
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._layout, emmental.layout.Layout)
self.assertIsInstance(self.window._toast, Adw.ToastOverlay)
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_default_size(), (1600, 900))
self.assertEqual(self.window.props.width_request, 525)
self.assertEqual(self.window.props.height_request, 500)
def test_content(self):
"""Check that the Window content is set up properly."""
self.assertEqual(self.window._box.get_orientation(),
@ -41,22 +44,19 @@ class TestWindow(unittest.TestCase):
self.window._header)
self.assertEqual(self.window._header.get_next_sibling(),
self.window._toast)
self.assertEqual(self.window._toast.get_child(),
self.window._outer_pane)
self.assertEqual(self.window._outer_pane.get_end_child(),
self.window._inner_pane)
self.assertTrue(self.window._outer_pane.has_css_class(
self.assertEqual(self.window._toast.get_child(), self.window._layout)
self.assertEqual(self.window._layout.content, self.window._inner_pane)
self.assertTrue(self.window._layout.has_css_class(
"emmental-padding"))
subtests = [(self.window._outer_pane, Gtk.Orientation.HORIZONTAL),
(self.window._inner_pane, Gtk.Orientation.VERTICAL)]
for pane, orientation in subtests:
self.assertEqual(pane.get_orientation(), orientation)
self.assertFalse(pane.get_shrink_start_child())
self.assertFalse(pane.get_resize_start_child())
self.assertTrue(pane.get_hexpand())
self.assertTrue(pane.get_vexpand())
self.assertTrue(pane.has_css_class("emmental-pane"))
self.assertEqual(self.window._inner_pane.get_orientation(),
Gtk.Orientation.VERTICAL)
self.assertEqual(self.window._inner_pane.get_margin_start(), 8)
self.assertFalse(self.window._inner_pane.get_shrink_start_child())
self.assertFalse(self.window._inner_pane.get_resize_start_child())
self.assertTrue(self.window._inner_pane.get_hexpand())
self.assertTrue(self.window._inner_pane.get_vexpand())
self.assertTrue(self.window._inner_pane.has_css_class("emmental-pane"))
def test_header(self):
"""Check setting a widget to the header area."""
@ -72,26 +72,22 @@ class TestWindow(unittest.TestCase):
"""Check setting a widget to the sidebar area."""
self.assertIsNone(self.window.sidebar)
self.window.sidebar = Gtk.Label()
self.assertEqual(self.window._outer_pane.get_start_child(),
self.window.sidebar)
self.assertEqual(self.window._layout.sidebar, self.window.sidebar)
window2 = emmental.window.Window(version="1.2.3", sidebar=Gtk.Label())
self.assertIsInstance(window2.sidebar, Gtk.Label)
self.assertEqual(window2._outer_pane.get_start_child(),
window2.sidebar)
self.assertEqual(window2._layout.sidebar, window2.sidebar)
def test_sidebar_size(self):
"""Check setting the size of the sidebar area."""
self.assertEqual(self.window.sidebar_size, 300)
self.assertEqual(self.window._outer_pane.get_position(), 300)
def test_show_sidebar(self):
"""Check setting the show-sidebar property."""
self.assertFalse(self.window.show_sidebar)
self.assertFalse(self.window._layout.show_sidebar)
self.window.sidebar_size = 100
self.assertEqual(self.window.sidebar_size, 100)
self.assertEqual(self.window._outer_pane.get_position(), 100)
self.window.show_sidebar = True
self.assertTrue(self.window._layout.show_sidebar)
self.window._outer_pane.set_position(200)
self.assertEqual(self.window.sidebar_size, 200)
self.assertEqual(self.window._outer_pane.get_position(), 200)
self.window._layout.show_sidebar = False
self.assertFalse(self.window.show_sidebar)
def test_now_playing(self):
"""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].func, self.window.set_focus)
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))

View File

@ -195,13 +195,50 @@ class TestShuffleButtons(unittest.TestCase):
"media-playlist-shuffle")
self.assertEqual(self.shuffle.active_tooltip_text, "shuffle: enabled")
self.assertEqual(self.shuffle.inactive_icon_name,
"media-playlist-consecutive")
self.assertEqual(self.shuffle.inactive_tooltip_text,
"shuffle: disabled")
self.assertAlmostEqual(self.shuffle.icon_opacity, 0.5, delta=0.005)
@unittest.mock.patch("emmental.gsetup.has_icon")
def test_get_inactive_icon(self, mock_has_icon: unittest.mock.Mock):
"""Test the get_inactive_icon() function."""
mock_has_icon.return_value = True
self.assertEqual(self.shuffle.get_inactive_icon(),
"media-playlist-normal")
mock_has_icon.assert_called()
mock_has_icon.return_value = False
self.assertEqual(self.shuffle.get_inactive_icon(),
"media-playlist-consecutive")
@unittest.mock.patch("emmental.gsetup.has_icon")
def test_inactive_icon_name(self, mock_has_icon: unittest.mock.Mock):
"""Test setting the inactive icon name."""
mock_has_icon.return_value = True
button = emmental.tracklist.buttons.ShuffleButton()
mock_has_icon.assert_called_with("media-playlist-normal")
self.assertEqual(button.inactive_icon_name, "media-playlist-normal")
mock_has_icon.return_value = False
button = emmental.tracklist.buttons.ShuffleButton()
self.assertEqual(button.inactive_icon_name,
"media-playlist-consecutive")
@unittest.mock.patch("emmental.gsetup.has_icon")
def test_toggled(self, mock_has_icon: unittest.mock.Mock):
"""Test changing the icon when toggled."""
mock_has_icon.return_value = True
self.shuffle.active = True
self.assertEqual(self.shuffle.inactive_icon_name,
"media-playlist-normal")
mock_has_icon.assert_called()
mock_has_icon.return_value = False
self.shuffle.active = False
self.assertEqual(self.shuffle.inactive_icon_name,
"media-playlist-consecutive")
def test_opacity(self):
"""Test adjusting the opacity based on active state."""
self.shuffle.active = True
@ -340,7 +377,7 @@ class TestSortButton(unittest.TestCase):
"""Test that the Sort button is configured correctly."""
self.assertIsInstance(self.sort, emmental.buttons.PopoverButton)
self.assertEqual(self.sort.get_icon_name(),
"view-list-ordered-symbolic")
"list-compact-symbolic")
self.assertEqual(self.sort.get_tooltip_text(),
"configure playlist sort order")
self.assertFalse(self.sort.get_has_frame())

View File

@ -2,6 +2,7 @@
"""Tests our Tracklist Footer."""
import unittest
import emmental.tracklist.footer
from gi.repository import Pango
from gi.repository import Gtk
@ -22,6 +23,8 @@ class TestFooter(unittest.TestCase):
self.assertIsInstance(self.footer._count, Gtk.Label)
self.assertEqual(self.footer._count.get_xalign(), 0.0)
self.assertEqual(self.footer.get_start_widget(), self.footer._count)
self.assertEqual(self.footer._count.get_ellipsize(),
Pango.EllipsizeMode.START)
self.assertEqual(self.footer.count, 0)
self.assertEqual(self.footer._count.get_text(), "Showing 0 tracks")
@ -36,6 +39,8 @@ class TestFooter(unittest.TestCase):
self.assertEqual(self.footer._selected.get_xalign(), 0.5)
self.assertEqual(self.footer.get_center_widget(),
self.footer._selected)
self.assertEqual(self.footer._selected.get_ellipsize(),
Pango.EllipsizeMode.MIDDLE)
self.assertEqual(self.footer.selected, 0)
self.assertEqual(self.footer._selected.get_text(), "")
@ -51,6 +56,8 @@ class TestFooter(unittest.TestCase):
self.assertIsInstance(self.footer._runtime, Gtk.Label)
self.assertEqual(self.footer._runtime.get_xalign(), 1.0)
self.assertEqual(self.footer.get_end_widget(), self.footer._runtime)
self.assertEqual(self.footer._runtime.get_ellipsize(),
Pango.EllipsizeMode.END)
self.assertEqual(self.footer.runtime, 0.0)
self.assertEqual(self.footer._runtime.get_text(),

View File

@ -8,7 +8,6 @@ import emmental.tracklist.row
import tests.util
import unittest.mock
from gi.repository import GObject
from gi.repository import Gdk
from gi.repository import Gtk
from gi.repository import Adw
@ -193,9 +192,6 @@ class TestTrackRowWidgets(tests.util.TestCase):
def test_album_cover(self):
"""Test the Album Cover widget."""
self.assertDictEqual(emmental.tracklist.row.AlbumCover.Cache, {})
cache = emmental.tracklist.row.AlbumCover.Cache
row = emmental.tracklist.row.AlbumCover(self.listitem, "cover")
self.assertIsInstance(row, emmental.tracklist.row.TrackRow)
self.assertIsInstance(row.child, Gtk.Picture)
@ -206,10 +202,9 @@ class TestTrackRowWidgets(tests.util.TestCase):
row.bind()
self.assertEqual(row.filepath, tests.util.COVER_JPG)
self.assertEqual(len(cache), 1)
self.assertIsInstance(cache[tests.util.COVER_JPG], Gdk.Texture)
self.assertEqual(len(emmental.texture.CACHE), 1)
self.assertEqual(row.child.get_paintable(),
cache[tests.util.COVER_JPG])
emmental.texture.CACHE[tests.util.COVER_JPG])
self.assertTrue(row.child.get_has_tooltip())
self.album.cover = None