Compare commits

...

68 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
Anna Schumaker 1397e6e9e3 Emmental 3.0.6
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-08-24 09:28:22 -04:00
Anna Schumaker 717fdf39cd tracklist: Add keyboard shortcuts
The following shortcuts are implemented:

- Escape to unselect any selected tracks
- Delete to remove selected tracks from the current playlist
- <Control>/ to focus the "filter tracks" entry
- <Control>l to cycle the loop state of the current playlist
- <Control>s to toggle the shuffle state of the current playlist
- <Control>Up to move the selected track up one position
- <Control>Down to move the selected track down one position

I also change the volume up and down shortcuts to use the <Shift>
modifier. This matches how other Header shortcuts are triggered, and
frees up the non-shifted versions to use here.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-08-24 09:28:22 -04:00
Anna Schumaker 9cf980d967 emmental: Adjust active-row styling
Make the active row background color a little more transparent.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-08-24 09:28:22 -04:00
Anna Schumaker 87d8a2ae3a tracklist: Add tooltips to tracklist buttons
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-08-24 09:28:22 -04:00
Anna Schumaker ddfd37130b tracklist: Move the OSD out of the TrackView
This simplifies the code a lot by letting the TrackList directly call
OSD functions without going through the TrackView. I can also simplify
the TrackView to just contain our columnview.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-08-24 09:28:22 -04:00
Anna Schumaker 5011db344e tracklist: Rework the Add Tracks to Playlist button to use a ListBox
I also convert my PlaylistRowWidget into a Gtk.ListBoxRow that has the
same functionality. This looks a little nicer, and lets us keep the same
style as the rest of the app.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-08-24 09:28:22 -04:00
Anna Schumaker 9f240bbc8b tracklist: Rework the Sort Order button to use a ListBox
I convert my SortRow widget into a Gtk.ListBoxRow that has the same
functionality. The main benefit is that it looks nicer in the
Gtk.Popover compared to the Gtk.ListView that I had been using.

I also connect to the listbox "row-activated" signal so I can handle
clicking a specific sort row in the list. Clicking a disabled sort row
will enable it, and clicking an enabled one will reverse the sort order.
I think this is what feels the most natural to the user.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-08-24 09:28:22 -04:00
Anna Schumaker f6481f0182 tracklist: Rework the VisibleColumns button to use a ListBox
I create a custom Gtk.ListBoxRow for displaying each individual column
name and visibility status. I then bind it to a listbox placed as the
popover button's popover child. This lets me set the 'boxed-list' style
class on the listbox to give it a nicer appearance, and clicking the
label will also toggle column visibility.

Implements: #57 ("Rework visible columns button")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-08-24 09:28:06 -04:00
Anna Schumaker 3d6350d7bd tracklist: Give the Top Box the "toolbar" style class
And adjust widget spacing for to keep our nice look.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-08-22 13:29:53 -04:00
Anna Schumaker eb6b4d8ef4 buttons: Watch for ImageToggle tooltip text changes
If the application changes the active or inactive tooltip text, then we
want to apply that to the button depending on what state it currently
has.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-08-22 13:29:53 -04:00
Anna Schumaker f7349cd864 factory: Don't change Gtk ListRowWidget state flags
I was using this to set some custom styling for the active playlist and
track inside a ListView. I can accomplish the same thing by adding and
removing a style class from the ListRowWidget, and this doesn't break
Gtk internal stuff that changed in the 4.12 release.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-08-22 12:09:20 -04:00
Anna Schumaker 30bcd30328 Emmental 3.0.5 AUR commit
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-30 09:31:50 -04:00
81 changed files with 3323 additions and 934 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 cf5f60a47d51e52743d470bf71587c6e6b81c4aa
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 = 5
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)
@ -195,6 +200,8 @@ class Application(Adw.Application):
self.db.settings.bind_setting(f"tracklist.{name}.visible",
column, "visible")
self.factory.bind_property("visible-playlist", track_list, "playlist")
self.__add_accelerators(track_list.accelerators)
return track_list
def build_window(self) -> window.Window:
@ -204,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)
@ -249,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",
@ -275,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()
@ -287,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()
@ -312,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

@ -119,6 +119,13 @@ class ImageToggle(Button):
inactive_tooltip_text=inactive_tooltip_text,
tooltip_text=inactive_tooltip_text,
active=active, **kwargs)
self.connect("notify", self.__notify)
def __notify(self, toggle: Button, param: GObject.ParamSpec) -> None:
match (param.name, self.active):
case ("active-tooltip-text", True) | \
("inactive-tooltip-text", False):
self.set_tooltip_text(self.get_property(param.name))
def do_clicked(self) -> None:
"""Handle a click event."""

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

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

View File

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

View File

@ -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,22 +134,47 @@ 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."""
res = [ActionEntry("open-file", self._open.activate, "<Control>o"),
ActionEntry("decrease-volume", self._volume.decrement,
"<Control>Down"),
"<Shift><Control>Down"),
ActionEntry("increase-volume", self._volume.increment,
"<Control>Up"),
"<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

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

View File

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

View File

@ -1,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):
@ -63,23 +63,6 @@ class TrackRow(factory.ListRow):
else:
self.bind_album(child_prop)
@GObject.Property(type=bool, default=False)
def active(self) -> bool:
"""Get the active state of this Row."""
if parent := self.listitem.get_child().get_parent():
if parent := parent.get_parent():
return parent.get_state_flags() & Gtk.StateFlags.CHECKED
return False
@active.setter
def active(self, newval: bool) -> None:
if parent := self.listitem.get_child().get_parent():
if parent := parent.get_parent():
if newval:
parent.set_state_flags(Gtk.StateFlags.CHECKED, False)
else:
parent.unset_state_flags(Gtk.StateFlags.CHECKED)
@GObject.Property(type=bool, default=True)
def online(self) -> bool:
"""Get the online state of this Row."""
@ -90,6 +73,14 @@ class TrackRow(factory.ListRow):
self.listitem.set_activatable(newval)
self.child.set_sensitive(newval)
@GObject.Property(type=Gtk.Widget)
def listrow(self) -> Gtk.Widget:
"""Test property for active track styling."""
if child := self.listitem.props.child:
if cell := child.props.parent:
return cell.props.parent
return None
class InscriptionRow(TrackRow):
"""Base class for Track Rows displaying a Gtk.Inscription."""
@ -287,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):
@ -302,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

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

View File

@ -7,10 +7,9 @@ from .. import db
from .. import factory
from .. import playlist
from . import row
from . import selection
class TrackView(Gtk.Frame):
class TrackView(Gtk.ScrolledWindow):
"""A Gtk.ColumnView that has been configured to show Tracks."""
playlist = GObject.Property(type=playlist.playlist.Playlist)
@ -30,8 +29,6 @@ class TrackView(Gtk.Frame):
show_row_separators=True,
enable_rubberband=True,
model=self._selection)
self._scrollwin = Gtk.ScrolledWindow(child=self._columnview)
self._osd = selection.OSD(sql, self._selection, child=self._scrollwin)
self.__append_column("Art", "cover", row.AlbumCover, resizable=False)
self.__append_column("Fav", "favorite", row.FavoriteButton,
@ -57,16 +54,13 @@ class TrackView(Gtk.Frame):
self.__append_column("Filepath", "path", row.PathString, visible=False)
self.bind_property("playlist", self._filtermodel, "model")
self.bind_property("playlist", self._osd, "playlist")
self._osd.bind_property("have-selected", self, "have-selected")
self._osd.bind_property("n-selected", self, "n-selected")
self._selection.bind_property("n-items", self, "n-tracks")
self._selection.connect("items-changed", self.__runtime_changed)
self._columnview.connect("activate", self.__track_activated)
self._columnview.add_css_class("emmental-track-list")
self.set_child(self._osd)
self.set_child(self._columnview)
def __append_column(self, title: str, property: str, row_type: type,
*, width: int = -1, visible: bool = True,
@ -87,23 +81,16 @@ class TrackView(Gtk.Frame):
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())
def clear_selected_tracks(self) -> None:
"""Clear the currently selected tracks."""
self._osd.clear_selection()
def reset_osd(self) -> None:
"""Reset the OSD."""
self._osd.reset()
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:
"""Get the ListModel for the columns."""
return self._columnview.get_columns()
@GObject.Property(type=Gio.ListModel)
def selection_model(self) -> Gio.ListModel:
"""Get the SelectionModel for the ColumnView."""
return self._columnview.get_model()

View File

@ -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,43 +225,47 @@ 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):
"""Check that the accelerators list is set up properly."""
entries = [("open-file", self.header._open.activate, "<Control>o"),
("decrease-volume", self.header._volume.decrement,
"<Control>Down"),
"<Shift><Control>Down"),
("increase-volume", self.header._volume.increment,
"<Control>Up"),
"<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

@ -211,6 +211,17 @@ class TestImageToggle(unittest.TestCase):
button2.active = False
self.assertEqual(button2.get_tooltip_text(), "inactive tooltip text")
def test_changing_tooltip_text(self):
"""Test changing the tooltip text for the current state."""
self.assertEqual(self.button.props.tooltip_text, None)
self.button.inactive_tooltip_text = "inactive tooltip"
self.assertEqual(self.button.props.tooltip_text, "inactive tooltip")
self.button.active = True
self.assertEqual(self.button.props.tooltip_text, None)
self.button.active_tooltip_text = "active tooltip"
self.assertEqual(self.button.props.tooltip_text, "active tooltip")
def test_toggle(self):
"""Test the toggle signal."""
toggled = unittest.mock.Mock()

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, 5)
self.assertEqual(emmental.VERSION_NUMBER, "3.0.5")
self.assertEqual(emmental.VERSION_STRING, "Emmental 3.0.5-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.5")
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", "<Control>Down"),
("app.increase-volume", "<Control>Up"),
("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,8 +242,20 @@ 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"),
("app.clear-selected-tracks", "Escape"),
("app.cycle-loop", "<Control>l"),
("app.toggle-shuffle", "<Control>s"),
("app.remove-selected-tracks", "Delete"),
("app.move-track-up", "<Control>Up"),
("app.move-track-down", "<Control>Down")]:
self.assertEqual(self.application.get_accels_for_action(action),
[accel])
self.assertEqual(win.tracklist.sql, self.application.db)
playlist = self.application.db.playlists.create("Test Playlist")
@ -239,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()
@ -253,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."""
@ -260,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
@ -275,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
@ -282,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

@ -39,16 +39,21 @@ class TestListRow(unittest.TestCase):
def test_bind_active(self):
"""Test binding a property to the Row's active property."""
self.assertIsNone(self.row.listrow)
self.row.active = True
self.assertFalse(self.row.active)
parent = Gtk.Box()
parent.append(self.row.child)
self.assertEqual(self.row.listrow, parent)
self.row.bind_active("sensitive")
self.assertEqual(len(self.row.bindings), 1)
self.assertTrue(parent.get_state_flags() & Gtk.StateFlags.CHECKED)
self.assertTrue(parent.has_css_class("emmental-active-row"))
self.assertTrue(self.row.active)
self.item.set_sensitive(False)
self.assertFalse(parent.get_state_flags() & Gtk.StateFlags.CHECKED)
self.assertFalse(parent.has_css_class("emmental-active-row"))
self.assertFalse(self.row.active)
def test_bind_and_set_property(self):

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

@ -7,12 +7,62 @@ from gi.repository import Gio
from gi.repository import Gtk
class TestVisibleColumnRow(unittest.TestCase):
"""Test the Visible Column ListBoxRow."""
def setUp(self):
"""Set up common variables."""
self.row = emmental.tracklist.buttons.VisibleRow("title", True)
def test_init(self):
"""Test that the VisibleRow was set up properly."""
self.assertIsInstance(self.row, Gtk.ListBoxRow)
self.assertIsInstance(self.row.props.child, Gtk.Box)
self.assertEqual(self.row.title, "title")
self.assertTrue(self.row.active)
self.assertEqual(self.row.props.child.props.margin_start, 6)
self.assertEqual(self.row.props.child.props.margin_end, 6)
self.assertEqual(self.row.props.child.props.margin_top, 6)
self.assertEqual(self.row.props.child.props.margin_bottom, 6)
self.assertEqual(self.row.props.child.props.spacing, 6)
row2 = emmental.tracklist.buttons.VisibleRow("title2", False)
self.assertEqual(row2.title, "title2")
self.assertFalse(row2.active)
def test_switch(self):
"""Test the VisibleRow switch."""
self.assertIsInstance(self.row._switch, Gtk.Switch)
self.assertEqual(self.row._switch.props.parent, self.row.props.child)
self.assertTrue(self.row._switch.props.active)
self.row.active = False
self.assertFalse(self.row._switch.props.active)
self.row._switch.props.active = True
self.assertTrue(self.row.active)
row2 = emmental.tracklist.buttons.VisibleRow("title2", False)
self.assertFalse(row2._switch.props.active)
def test_label(self):
"""Test the VisibleRow title label."""
self.assertIsInstance(self.row._label, Gtk.Label)
self.assertEqual(self.row._label.props.label, "title")
self.assertEqual(self.row._switch.get_next_sibling(),
self.row._label)
class TestVisibleColumns(unittest.TestCase):
"""Test the Visible Columns button."""
def setUp(self):
"""Set up common variables."""
self.columns = Gio.ListStore()
self.columns.append(Gtk.ColumnViewColumn(title="title", visible=True))
self.columns.append(Gtk.ColumnViewColumn(title="title2",
visible=False))
self.button = emmental.tracklist.buttons.VisibleColumns(self.columns)
def test_init(self):
@ -20,55 +70,43 @@ class TestVisibleColumns(unittest.TestCase):
self.assertIsInstance(self.button, emmental.buttons.PopoverButton)
self.assertFalse(self.button.get_has_frame())
self.assertEqual(self.button.get_icon_name(), "columns-symbolic")
self.assertEqual(self.button.get_tooltip_text(),
"configure visible columns")
self.assertEqual(self.button.columns, self.columns)
def test_popover_child(self):
"""Test that the popover_child was set up properly."""
self.assertIsInstance(self.button.popover_child, Gtk.ColumnView)
self.assertIsInstance(self.button._selection, Gtk.NoSelection)
self.assertTrue(self.button.popover_child.get_show_row_separators())
self.assertTrue(self.button.popover_child.has_css_class("data-table"))
self.assertIsInstance(self.button.popover_child, Gtk.ListBox)
self.assertEqual(self.button.popover_child.props.selection_mode,
Gtk.SelectionMode.NONE)
self.assertTrue(self.button.popover_child.has_css_class("boxed-list"))
self.assertEqual(self.button.popover_child.get_model(),
self.button._selection)
self.assertEqual(self.button._selection.get_model(),
self.button.columns)
def test_create_func(self):
"""Test that the Gtk.ListBox creates VisibleRows correctly."""
row = self.button.popover_child.get_row_at_index(0)
self.assertIsInstance(row, emmental.tracklist.buttons.VisibleRow)
self.assertEqual(row.title, "title")
self.assertTrue(row.active)
def test_columns(self):
"""Test the popover_child columns."""
columns = self.button.popover_child.get_columns()
self.assertEqual(len(columns), 2)
row.active = False
self.assertFalse(self.columns[0].get_visible())
row.active = True
self.assertTrue(self.columns[0].get_visible())
self.columns[0].set_visible(False)
self.assertFalse(row.active)
self.assertIsInstance(columns[0].get_factory(),
emmental.factory.InscriptionFactory)
self.assertEqual(columns[0].get_title(), "Column")
self.assertEqual(columns[0].get_fixed_width(), 125)
row = self.button.popover_child.get_row_at_index(1)
self.assertIsInstance(row, emmental.tracklist.buttons.VisibleRow)
self.assertEqual(row.title, "title2")
self.assertFalse(row.active)
self.assertIsInstance(columns[1].get_factory(),
emmental.factory.Factory)
self.assertEqual(columns[1].get_factory().row_type,
emmental.tracklist.buttons.VisibleSwitch)
self.assertEqual(columns[1].get_title(), "Visible")
self.assertEqual(columns[1].get_fixed_width(), -1)
def test_visible_switch(self):
"""Test the visible switch widget."""
item = Gtk.Label()
listitem = Gtk.ListItem()
listitem.get_item = unittest.mock.Mock(return_value=item)
switch = emmental.tracklist.buttons.VisibleSwitch(listitem)
self.assertIsInstance(switch, emmental.factory.ListRow)
self.assertIsInstance(switch.child, Gtk.Switch)
switch.bind()
self.assertEqual(len(switch.bindings), 1)
self.assertTrue(switch.child.get_active())
item.set_visible(False)
self.assertFalse(switch.child.get_active())
switch.child.set_active(True)
item.set_visible(True)
def test_activate(self):
"""Test activating a Gtk.ListBox row."""
row = self.button.popover_child.get_row_at_index(0)
self.button.popover_child.emit("row-activated", row)
self.assertFalse(row.active)
self.button.popover_child.emit("row-activated", row)
self.assertTrue(row.active)
class TestLoopButton(unittest.TestCase):
@ -83,7 +121,9 @@ class TestLoopButton(unittest.TestCase):
self.assertIsInstance(self.loop, emmental.buttons.ImageToggle)
self.assertEqual(self.loop.active_icon_name,
"media-playlist-repeat-song")
self.assertEqual(self.loop.active_tooltip_text, "loop: track")
self.assertEqual(self.loop.inactive_icon_name, "media-playlist-repeat")
self.assertEqual(self.loop.inactive_tooltip_text, "loop: disabled")
self.assertFalse(self.loop.large_icon)
self.assertFalse(self.loop.get_has_frame())
@ -93,26 +133,31 @@ class TestLoopButton(unittest.TestCase):
self.assertEqual(self.loop.state, "None")
self.assertAlmostEqual(self.loop.icon_opacity, 0.5, delta=0.005)
self.assertEqual(self.loop.props.tooltip_text, "loop: disabled")
self.assertFalse(self.loop.active)
self.loop.state = "Playlist"
self.assertEqual(self.loop.state, "Playlist")
self.assertEqual(self.loop.icon_opacity, 1.0)
self.assertEqual(self.loop.props.tooltip_text, "loop: playlist")
self.assertFalse(self.loop.active)
self.loop.state = "Track"
self.assertEqual(self.loop.state, "Track")
self.assertEqual(self.loop.icon_opacity, 1.0)
self.assertEqual(self.loop.props.tooltip_text, "loop: track")
self.assertTrue(self.loop.active)
self.loop.can_disable = False
self.loop.state = "None"
self.assertEqual(self.loop.state, "Track")
self.assertEqual(self.loop.props.tooltip_text, "loop: track")
self.assertTrue(self.loop.active)
self.loop.can_disable = True
self.loop.state = "None"
self.assertAlmostEqual(self.loop.icon_opacity, 0.5, delta=0.005)
self.assertEqual(self.loop.inactive_tooltip_text, "loop: disabled")
self.assertFalse(self.loop.active)
def test_click(self):
@ -143,14 +188,57 @@ class TestShuffleButtons(unittest.TestCase):
def test_init(self):
"""Test that the shuffle button is configured correctly."""
self.assertIsInstance(self.shuffle, emmental.buttons.ImageToggle)
self.assertEqual(self.shuffle.active_icon_name,
"media-playlist-shuffle")
self.assertEqual(self.shuffle.inactive_icon_name,
"media-playlist-consecutive")
self.assertAlmostEqual(self.shuffle.icon_opacity, 0.5, delta=0.005)
self.assertFalse(self.shuffle.large_icon)
self.assertFalse(self.shuffle.get_has_frame())
self.assertEqual(self.shuffle.active_icon_name,
"media-playlist-shuffle")
self.assertEqual(self.shuffle.active_tooltip_text, "shuffle: enabled")
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
@ -159,123 +247,123 @@ class TestShuffleButtons(unittest.TestCase):
self.assertEqual(self.shuffle.icon_opacity, 0.5)
class TestSortFieldWidget(unittest.TestCase):
"""Test the Sort Field widget."""
class TestSortRow(unittest.TestCase):
"""Test the Sort Row ListBoxRow."""
def setUp(self):
"""Set up common variables."""
self.sort = emmental.tracklist.buttons.SortFieldWidget()
self.model = emmental.tracklist.sorter.SortOrderModel()
self.model[0].enable()
self.model[0].reverse()
self.model[1].reverse()
self.row1 = emmental.tracklist.buttons.SortRow(self.model[0])
self.row2 = emmental.tracklist.buttons.SortRow(self.model[1])
def test_init(self):
"""Test that the Sort Field Widget is configured correctly."""
self.assertIsInstance(self.sort, Gtk.Box)
self.assertIsInstance(self.sort._box, Gtk.Box)
self.assertIsInstance(self.sort._name, Gtk.Label)
self.assertIsInstance(self.row1, Gtk.ListBoxRow)
self.assertIsInstance(self.row1.props.child, Gtk.Box)
self.assertTrue(self.sort._name.get_hexpand())
self.assertEqual(self.row1.props.child.props.margin_start, 6)
self.assertEqual(self.row1.props.child.props.margin_end, 6)
self.assertEqual(self.row1.props.child.props.margin_top, 6)
self.assertEqual(self.row1.props.child.props.margin_bottom, 6)
self.assertEqual(self.row1.props.child.props.spacing, 6)
self.assertEqual(self.sort.get_spacing(), 6)
self.assertEqual(self.sort._enabled.get_next_sibling(),
self.sort._name)
self.assertEqual(self.sort._reverse.get_next_sibling(),
self.sort._box)
self.assertTrue(self.sort._box.has_css_class("linked"))
self.assertEqual(self.row1.sort_field, self.model[0])
self.assertTrue(self.row1.active)
def test_set_sort_field(self):
"""Test setting a sort field to the Sort Field Widget."""
self.assertIsNone(self.sort.sort_field)
self.assertEqual(self.row2.sort_field, self.model[1])
self.assertFalse(self.row2.active)
self.sort.set_sort_field(self.model[0])
self.assertEqual(self.sort.sort_field, self.model[0])
self.assertEqual(self.sort._name.get_text(), self.model[0].name)
self.assertTrue(self.sort._enabled.get_active())
self.assertTrue(self.sort._reverse.active)
def test_switch(self):
"""Test the SortRow switch."""
self.assertIsInstance(self.row1._switch, Gtk.Switch)
self.assertEqual(self.row1._switch.props.valign, Gtk.Align.CENTER)
self.assertEqual(self.row1._switch.props.parent, self.row1.props.child)
self.sort.set_sort_field(None)
self.assertIsNone(self.sort.sort_field)
self.assertEqual(self.sort._name.get_text(), "")
self.assertFalse(self.sort._enabled.get_active())
self.assertFalse(self.sort._reverse.active)
self.assertTrue(self.row1._switch.props.active)
self.assertFalse(self.row2._switch.props.active)
def test_enabled(self):
"""Test enabling and disabling a sort field."""
self.assertIsInstance(self.sort._enabled, Gtk.Switch)
self.assertEqual(self.sort._enabled.get_valign(), Gtk.Align.CENTER)
self.assertEqual(self.sort.get_first_child(), self.sort._enabled)
with unittest.mock.patch.object(self.model[0],
"disable") as mock_disable:
self.row1._switch.props.active = False
mock_disable.assert_called()
self.sort._enabled.set_active(True)
with unittest.mock.patch.object(self.model[0],
"enable") as mock_enable:
self.row1._switch.props.active = True
mock_enable.assert_called()
self.sort.set_sort_field(self.model[1])
self.assertFalse(self.sort._name.get_sensitive())
self.assertFalse(self.sort._box.get_sensitive())
self.assertFalse(self.sort._reverse.get_sensitive())
def test_label(self):
"""Test the SortRow title label."""
self.assertIsInstance(self.row1._label, Gtk.Label)
self.assertEqual(self.row1._switch.get_next_sibling(),
self.row1._label)
self.sort._enabled.set_active(True)
self.assertTrue(self.model[1].enabled)
self.assertTrue(self.sort._name.get_sensitive())
self.assertTrue(self.sort._box.get_sensitive())
self.assertTrue(self.sort._reverse.get_sensitive())
self.assertEqual(self.row1._label.props.label, self.model[0].name)
self.assertEqual(self.row1._label.props.xalign, 0.0)
self.assertTrue(self.row1._label.props.hexpand)
self.sort._enabled.set_active(False)
self.assertFalse(self.model[1].enabled)
self.assertFalse(self.sort._name.get_sensitive())
self.assertFalse(self.sort._box.get_sensitive())
self.assertFalse(self.sort._reverse.get_sensitive())
def test_move_down(self):
"""Test the moving a sort field down."""
self.assertIsInstance(self.sort._move_down, Gtk.Button)
self.assertEqual(self.sort._move_down.get_icon_name(),
"go-down-symbolic")
self.assertEqual(self.sort._move_up.get_next_sibling(),
self.sort._move_down)
self.sort._move_down.emit("clicked")
(field := self.model[0]).enable()
self.model[1].enable()
self.sort.set_sort_field(field)
self.sort._move_down.emit("clicked")
self.assertEqual(self.model.index(field), 1)
def test_move_up(self):
"""Test the moving a sort field."""
self.assertIsInstance(self.sort._move_up, Gtk.Button)
self.assertEqual(self.sort._move_up.get_icon_name(), "go-up-symbolic")
self.assertEqual(self.sort._box.get_first_child(),
self.sort._move_up)
self.sort._move_up.emit("clicked")
self.model[0].enable()
(field := self.model[1]).enable()
self.sort.set_sort_field(field)
self.sort._move_up.emit("clicked")
self.assertEqual(self.model.index(field), 0)
self.assertTrue(self.row1._label.props.sensitive)
self.assertFalse(self.row2._label.props.sensitive)
def test_reverse(self):
"""Test reversing a sort field."""
self.assertIsInstance(self.sort._reverse, emmental.buttons.ImageToggle)
self.assertEqual(self.sort._name.get_next_sibling(),
self.sort._reverse)
"""Test the SortRow reverse button."""
self.assertIsInstance(self.row1._reverse, emmental.buttons.ImageToggle)
self.assertEqual(self.row1._label.get_next_sibling(),
self.row1._reverse)
self.assertEqual(self.sort._reverse.active_icon_name, "arrow1-up")
self.assertEqual(self.sort._reverse.inactive_icon_name, "arrow1-down")
self.assertFalse(self.sort._reverse.large_icon)
self.assertEqual(self.row1._reverse.active_icon_name, "arrow1-up")
self.assertEqual(self.row1._reverse.inactive_icon_name, "arrow1-down")
self.assertFalse(self.row1._reverse.props.has_frame)
self.assertFalse(self.row1._reverse.large_icon)
self.sort._reverse.emit("clicked")
self.assertFalse(self.row1._reverse.props.active)
self.assertTrue(self.row1._reverse.props.sensitive)
self.sort.set_sort_field(self.model[0])
self.sort._reverse.emit("clicked")
self.assertFalse(self.model[0].reversed)
self.sort._reverse.emit("clicked")
self.assertTrue(self.model[0].reversed)
self.assertTrue(self.row2._reverse.props.active)
self.assertFalse(self.row2._reverse.props.sensitive)
with unittest.mock.patch.object(self.model[0],
"reverse") as mock_reverse:
self.row1._reverse.emit("toggled")
mock_reverse.assert_called()
def test_move_box(self):
"""Test the box containing the move up & down buttons."""
self.assertIsInstance(self.row1._move_box, Gtk.Box)
self.assertEqual(self.row1._reverse.get_next_sibling(),
self.row1._move_box)
self.assertTrue(self.row1._move_box.has_css_class("linked"))
self.assertTrue(self.row1._move_box.props.sensitive)
self.assertFalse(self.row2._move_box.props.sensitive)
def test_move_up(self):
"""Test the move up button."""
self.assertIsInstance(self.row1._move_up, Gtk.Button)
self.assertEqual(self.row1._move_up.get_icon_name(),
"go-up-symbolic")
self.assertEqual(self.row1._move_up.props.parent,
self.row1._move_box)
with unittest.mock.patch.object(self.model[0],
"move_up") as mock_move_up:
self.row1._move_up.emit("clicked")
mock_move_up.assert_called()
def test_move_down(self):
"""Test the move down button."""
self.assertIsInstance(self.row1._move_down, Gtk.Button)
self.assertEqual(self.row1._move_down.get_icon_name(),
"go-down-symbolic")
self.assertEqual(self.row1._move_up.get_next_sibling(),
self.row1._move_down)
with unittest.mock.patch.object(self.model[0],
"move_down") as mock_move_down:
self.row1._move_down.emit("clicked")
mock_move_down.assert_called()
class TestSortButton(unittest.TestCase):
@ -288,45 +376,44 @@ class TestSortButton(unittest.TestCase):
def test_init(self):
"""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())
def test_popover_child(self):
"""Test that the popover_child is configured correctly."""
self.assertIsInstance(self.sort.popover_child, Gtk.ListView)
self.assertIsInstance(self.sort.model,
emmental.tracklist.sorter.SortOrderModel)
self.assertIsInstance(self.sort._selection, Gtk.NoSelection)
self.assertIsInstance(self.sort._factory, emmental.factory.Factory)
self.assertIsInstance(self.sort.popover_child, Gtk.ListBox)
self.assertEqual(self.sort.popover_child.props.selection_mode,
Gtk.SelectionMode.NONE)
self.assertTrue(self.sort.popover_child.has_css_class("boxed-list"))
self.assertTrue(self.sort.popover_child.get_show_separators())
def test_create_func(self):
"""Test that the Gtk.ListBox creates SortRows correctly."""
row = self.sort.popover_child.get_row_at_index(0)
self.assertIsInstance(row, emmental.tracklist.buttons.SortRow)
self.assertEqual(row.sort_field, self.sort.model[0])
self.assertEqual(self.sort.popover_child.get_model(),
self.sort._selection)
self.assertEqual(self.sort._selection.get_model(), self.sort.model)
self.assertEqual(self.sort.popover_child.get_factory(),
self.sort._factory)
self.assertEqual(self.sort._factory.row_type,
emmental.tracklist.buttons.SortRow)
def test_activate(self):
"""Test activating a Gtk.ListBox sort row."""
row = self.sort.popover_child.get_row_at_index(0)
field = row.sort_field
self.assertFalse(field.enabled)
self.assertFalse(field.reversed)
def test_sort_row(self):
"""Test the Sort Row object."""
(field := self.sort.model[0]).enable()
listitem = Gtk.ListItem()
listitem.get_item = lambda: field
self.sort.model[1].enable()
with unittest.mock.patch.object(field, "enable") as mock_enable:
self.sort.popover_child.emit("row-activated", row)
mock_enable.assert_called()
row = emmental.tracklist.buttons.SortRow(listitem)
self.assertIsInstance(row, emmental.factory.ListRow)
self.assertIsInstance(row.child,
emmental.tracklist.buttons.SortFieldWidget)
mock_enable.reset_mock()
row.active = True
row.bind()
self.assertEqual(row.child.sort_field, field)
row.unbind()
self.assertIsNone(row.child.sort_field)
with unittest.mock.patch.object(field, "reverse") as mock_reverse:
self.sort.popover_child.emit("row-activated", row)
self.assertTrue(row._reverse.active)
mock_enable.assert_not_called()
mock_reverse.assert_called()
def test_sort_order(self):
"""Test the sort-order property."""

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
@ -41,11 +40,13 @@ class TestTrackRowWidgets(tests.util.TestCase):
row = emmental.tracklist.row.TrackRow(self.listitem, "property")
self.assertIsInstance(row, emmental.factory.ListRow)
self.assertIsNone(row.album_binding)
self.assertIsNone(row.listrow)
self.assertEqual(row.property, "property")
self.assertEqual(row.mediumid, 0)
row.child = Gtk.Label()
self.columnrow.set_child(row.child)
self.assertEqual(row.listrow, self.listrow)
self.library.online = False
self.track.active = True
@ -54,8 +55,7 @@ class TestTrackRowWidgets(tests.util.TestCase):
self.assertFalse(self.listitem.get_activatable())
self.assertFalse(row.child.get_sensitive())
self.assertTrue(row.active)
self.assertTrue(self.listrow.get_state_flags() &
Gtk.StateFlags.CHECKED)
self.assertTrue(self.listrow.has_css_class("emmental-active-row"))
self.library.online = True
self.track.active = False
@ -63,8 +63,7 @@ class TestTrackRowWidgets(tests.util.TestCase):
self.assertTrue(self.listitem.get_activatable())
self.assertTrue(row.child.get_sensitive())
self.assertFalse(row.active)
self.assertFalse(self.listrow.get_state_flags() &
Gtk.StateFlags.CHECKED)
self.assertFalse(self.listrow.has_css_class("emmental-active-row"))
def test_inscription_row(self):
"""Test the base Inscription Row."""
@ -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

View File

@ -10,83 +10,65 @@ from gi.repository import Gtk
from gi.repository import Adw
class TestPlaylistRowWidget(unittest.TestCase):
"""Test the Playlist Row Widget."""
class TestPlaylistRow(unittest.TestCase):
"""Test the PlaylistRow Widget."""
def setUp(self):
"""Set up common variables."""
self.widget = emmental.tracklist.selection.PlaylistRowWidget()
cover = tests.util.COVER_JPG
self.row = emmental.tracklist.selection.PlaylistRow("name", cover)
def test_init(self):
"""Test that the Playlist Row Widget is set up properly."""
self.assertIsInstance(self.widget, Gtk.Box)
"""Test that the PlaylistRow Widget is set up properly."""
self.assertIsInstance(self.row, Gtk.ListBoxRow)
self.assertIsInstance(self.row.props.child, Gtk.Box)
self.assertEqual(self.row.name, "name")
self.assertEqual(self.row.image, tests.util.COVER_JPG)
self.assertEqual(self.row.props.child.props.margin_start, 6)
self.assertEqual(self.row.props.child.props.margin_end, 6)
self.assertEqual(self.row.props.child.props.margin_top, 6)
self.assertEqual(self.row.props.child.props.margin_bottom, 6)
self.assertEqual(self.row.props.child.props.spacing, 6)
def test_label(self):
"""Test the Playlist Row Widget label."""
self.widget.name = "Test Playlist Name"
self.assertIsInstance(self.widget._label, Gtk.Label)
self.assertEqual(self.widget._label.get_text(), "Test Playlist Name")
self.assertEqual(self.widget._label.get_xalign(), 0.0)
self.assertEqual(self.widget._icon.get_next_sibling(),
self.widget._label)
"""Test the PlaylistRow Widget label."""
self.assertIsInstance(self.row._label, Gtk.Label)
self.assertEqual(self.row._label.props.label, "name")
self.assertEqual(self.row._icon.get_next_sibling(), self.row._label)
def test_icon(self):
"""Test the Playlist Row Widget icon."""
self.assertIsInstance(self.widget._icon, Adw.Avatar)
self.assertEqual(self.widget.get_first_child(), self.widget._icon)
self.assertEqual(self.widget._icon.get_size(), 32)
self.widget.name = "Favorite Tracks"
self.assertEqual(self.widget._icon.get_icon_name(),
"heart-filled-symbolic")
self.assertEqual(self.widget._icon.get_text(), "Favorite Tracks")
self.widget.name = "Queued Tracks"
self.assertEqual(self.widget._icon.get_icon_name(),
"music-queue-symbolic")
self.assertEqual(self.widget._icon.get_text(), "Queued Tracks")
self.widget.name = "Any Other Name"
self.assertEqual(self.widget._icon.get_icon_name(),
"""Test the PlaylistRow Widget icon."""
self.assertIsInstance(self.row._icon, Adw.Avatar)
self.assertEqual(self.row.props.child.get_first_child(),
self.row._icon)
self.assertEqual(self.row._icon.get_size(), 32)
self.assertEqual(self.row._icon.get_text(), "name")
self.assertEqual(self.row._icon.get_icon_name(),
"playlist2-symbolic")
self.assertEqual(self.widget._icon.get_text(), "Any Other Name")
fav = emmental.tracklist.selection.PlaylistRow("Favorite Tracks", None)
self.assertEqual(fav._icon.props.icon_name, "heart-filled-symbolic")
queue = emmental.tracklist.selection.PlaylistRow("Queued Tracks", None)
self.assertEqual(queue._icon.props.icon_name, "music-queue-symbolic")
def test_image(self):
"""Test the Playlist Row Widget image."""
self.assertIsNone(self.widget.image)
self.widget.image = tests.util.COVER_JPG
self.assertIsNotNone(self.widget._icon.get_custom_image())
self.widget.image = None
self.assertIsNone(self.widget._icon.get_custom_image())
self.widget.image = pathlib.Path("/a/b/c.jpg")
self.assertIsNone(self.widget._icon.get_custom_image())
"""Test the PlaylistRow Widget image."""
self.assertIsNotNone(self.row._icon.props.custom_image)
none = emmental.tracklist.selection.PlaylistRow("none", None)
self.assertIsNone(none.image)
self.assertIsNone(none._icon.props.custom_image)
class TestPlaylistRow(tests.util.TestCase):
"""Test the PlaylistRow widget."""
later = emmental.tracklist.selection.PlaylistRow("later", None)
later.image = tests.util.COVER_JPG
self.assertIsNotNone(later._icon.props.custom_image)
def setUp(self):
"""Set up common variables."""
super().setUp()
self.playlist = self.sql.playlists.create("Test Playlist")
self.listitem = Gtk.ListItem()
self.listitem.get_item = unittest.mock.Mock(return_value=self.playlist)
self.row = emmental.tracklist.selection.PlaylistRow(self.listitem)
def test_init(self):
"""Test that the PlaylistRow is initialized properly."""
self.assertIsInstance(self.row, emmental.factory.ListRow)
self.assertIsInstance(self.row.child,
emmental.tracklist.selection.PlaylistRowWidget)
def test_bind(self):
"""Test binding the PlaylistRow."""
self.row.bind()
self.assertEqual(self.row.child.name, "Test Playlist")
self.assertIsNone(self.row.child.image)
self.playlist.image = pathlib.Path("/a/b/c.jpg")
self.assertEqual(self.row.child.image, pathlib.Path("/a/b/c.jpg"))
path = pathlib.Path("/a/b/c.jpg")
inval = emmental.tracklist.selection.PlaylistRow("inval", path)
self.assertIsNone(inval._icon.props.custom_image)
class TestUserTracksFilter(tests.util.TestCase):
@ -134,35 +116,38 @@ class TestPlaylistView(tests.util.TestCase):
def test_init(self):
"""Test that the Playlist View is set up properly."""
self.assertIsInstance(self.view, Gtk.ListView)
self.assertIsInstance(self.view, Gtk.ListBox)
self.assertEqual(self.view.props.selection_mode,
Gtk.SelectionMode.NONE)
self.assertTrue(self.view.has_css_class("boxed-list"))
self.assertTrue(self.view.get_show_separators())
self.assertTrue(self.view.get_single_click_activate())
self.assertTrue(self.view.has_css_class("rich-list"))
def test_models(self):
"""Test that the models have been connected correctly."""
self.assertIsInstance(self.view._selection, Gtk.NoSelection)
def test_filter_model(self):
"""Test that the filter model has been connected correctly."""
self.assertIsInstance(self.view._filtered, Gtk.FilterListModel)
self.assertIsInstance(self.view._filtered.get_filter(),
emmental.tracklist.selection.UserTracksFilter)
self.assertEqual(self.view.get_model(), self.view._selection)
self.assertEqual(self.view._selection.get_model(), self.view._filtered)
self.assertEqual(self.view._filtered.get_model(), self.sql.playlists)
def test_factory(self):
"""Test that the factory has been configured correctly."""
self.assertIsInstance(self.view._factory, emmental.factory.Factory)
self.assertEqual(self.view.get_factory(), self.view._factory)
self.assertEqual(self.view._factory.row_type,
emmental.tracklist.selection.PlaylistRow)
self.view.playlist = self.sql.playlists.collection
self.assertEqual(self.view._filtered.get_filter().playlist,
self.sql.playlists.collection)
def test_create_func(self):
"""Test that the PlaylistView creates PlaylistRows correctly."""
row = self.view.get_row_at_index(0)
self.assertIsInstance(row, emmental.tracklist.selection.PlaylistRow)
self.assertEqual(row.name, "Favorite Tracks")
self.assertEqual(row.image, None)
self.sql.playlists.favorites.image = tests.util.COVER_JPG
self.assertEqual(row.image, tests.util.COVER_JPG)
def test_activate(self):
"""Test activating a Playlist Row for adding tracks."""
selected = unittest.mock.Mock()
self.view.connect("playlist-selected", selected)
self.view.emit("activate", 0)
self.view.emit("row-activated", self.view.get_row_at_index(0))
selected.assert_called_with(self.view, self.sql.playlists.favorites)
@ -184,6 +169,8 @@ class TestMoveButtons(unittest.TestCase):
"""Test the move down button."""
self.assertIsInstance(self.move._down, Gtk.Button)
self.assertEqual(self.move._down.get_icon_name(), "go-down-symbolic")
self.assertEqual(self.move._down.get_tooltip_text(),
"move selected track down")
self.assertTrue(self.move._down.has_css_class("opaque"))
self.assertTrue(self.move._down.has_css_class("pill"))
self.assertTrue(self.move._down.get_hexpand())
@ -209,6 +196,8 @@ class TestMoveButtons(unittest.TestCase):
"""Test the move up button."""
self.assertIsInstance(self.move._up, Gtk.Button)
self.assertEqual(self.move._up.get_icon_name(), "go-up-symbolic")
self.assertEqual(self.move._up.get_tooltip_text(),
"move selected track up")
self.assertTrue(self.move._up.has_css_class("opaque"))
self.assertTrue(self.move._up.has_css_class("pill"))
self.assertTrue(self.move._up.get_hexpand())
@ -264,6 +253,8 @@ class TestOsd(tests.util.TestCase):
self.assertEqual(self.osd._add.get_child().get_icon_name(),
"list-add-symbolic")
self.assertEqual(self.osd._add.get_child().get_label(), "Add")
self.assertEqual(self.osd._add.get_tooltip_text(),
"add selected tracks to a playlist")
self.assertEqual(self.osd._add.get_halign(), Gtk.Align.START)
self.assertEqual(self.osd._add.get_valign(), Gtk.Align.END)
self.assertEqual(self.osd._add.get_margin_start(), 16)
@ -313,6 +304,8 @@ class TestOsd(tests.util.TestCase):
self.assertEqual(self.osd._remove.get_child().get_icon_name(),
"list-remove-symbolic")
self.assertEqual(self.osd._remove.get_child().get_label(), "Remove")
self.assertEqual(self.osd._remove.get_tooltip_text(),
"remove selected tracks")
self.assertEqual(self.osd._remove.get_halign(), Gtk.Align.END)
self.assertEqual(self.osd._remove.get_valign(), Gtk.Align.END)
self.assertEqual(self.osd._remove.get_margin_end(), 16)
@ -463,3 +456,28 @@ class TestOsd(tests.util.TestCase):
self.osd.reset()
mock_unselect.assert_called()
self.assertFalse(self.osd.have_selected)
def test_accelerators(self):
"""Test that the accelerators list is set up properly."""
entries = [("remove-selected-tracks", self.osd._remove.activate,
["Delete"], self.osd._remove, "visible"),
("move-track-up", self.osd._move._up.activate,
["<Control>Up"], self.osd._move, "can-move-up"),
("move-track-down", self.osd._move._down.activate,
["<Control>Down"], self.osd._move, "can-move-down")]
accels = self.osd.accelerators
self.assertIsInstance(accels, list)
for i, (name, func, accel, gobject, prop) in enumerate(entries):
with self.subTest(action=name):
self.assertIsInstance(accels[i], emmental.action.ActionEntry)
self.assertEqual(accels[i].name, name)
self.assertEqual(accels[i].func, func)
self.assertEqual(accels[i].accels, accel)
if gobject and prop:
enabled = gobject.get_property(prop)
self.assertEqual(accels[i].enabled, enabled)
gobject.set_property(prop, not enabled)
self.assertEqual(accels[i].enabled, not enabled)

View File

@ -27,11 +27,9 @@ class TestTracklist(tests.util.TestCase):
self.assertIsInstance(self.tracklist._top_right, Gtk.Box)
self.assertEqual(self.tracklist.sql, self.sql)
self.assertEqual(self.tracklist.get_spacing(), 6)
self.assertEqual(self.tracklist.get_orientation(),
Gtk.Orientation.VERTICAL)
self.assertEqual(self.tracklist._top_box.get_margin_top(), 6)
self.assertEqual(self.tracklist._top_box.get_margin_start(), 6)
self.assertEqual(self.tracklist._top_box.get_margin_end(), 6)
self.assertEqual(self.tracklist._top_box.get_orientation(),
@ -44,6 +42,7 @@ class TestTracklist(tests.util.TestCase):
self.assertEqual(self.tracklist._top_box.get_end_widget(),
self.tracklist._top_right)
self.assertTrue(self.tracklist._top_box.has_css_class("toolbar"))
self.assertTrue(self.tracklist.has_css_class("card"))
def test_visible_columns(self):
@ -61,14 +60,16 @@ class TestTracklist(tests.util.TestCase):
self.assertIsInstance(self.tracklist._unselect, Gtk.Button)
self.assertEqual(self.tracklist._unselect.get_icon_name(),
"edit-select-none-symbolic")
self.assertEqual(self.tracklist._unselect.get_tooltip_text(),
"unselect all tracks")
self.assertFalse(self.tracklist._unselect.get_has_frame())
self.assertFalse(self.tracklist._unselect.get_sensitive())
self.tracklist._trackview.have_selected = True
self.assertTrue(self.tracklist._unselect.get_sensitive())
with unittest.mock.patch.object(self.tracklist._trackview,
"clear_selected_tracks") as mock_clear:
with unittest.mock.patch.object(self.tracklist._osd.selection,
"unselect_all") as mock_clear:
self.tracklist._unselect.emit("clicked")
mock_clear.assert_called()
@ -161,23 +162,45 @@ class TestTracklist(tests.util.TestCase):
"""Test the Trackview widget."""
self.assertIsInstance(self.tracklist._trackview,
emmental.tracklist.trackview.TrackView)
self.assertEqual(self.tracklist._top_box.get_next_sibling(),
self.tracklist._trackview)
self.assertEqual(self.tracklist._trackview.get_margin_start(), 6)
self.assertEqual(self.tracklist._trackview.get_margin_end(), 6)
self.assertEqual(self.tracklist.columns,
self.tracklist._trackview.columns)
sep = self.tracklist._top_box.get_next_sibling()
self.assertIsInstance(sep, Gtk.Separator)
self.assertEqual(sep.get_orientation(), Gtk.Orientation.HORIZONTAL)
self.assertEqual(sep.get_next_sibling(), self.tracklist._osd)
self.assertEqual(self.tracklist._osd.get_child(),
self.tracklist._trackview)
def test_osd(self):
"""Test the OSD widget."""
self.assertIsInstance(self.tracklist._osd,
emmental.tracklist.selection.OSD)
self.assertEqual(self.tracklist._osd.sql, self.sql)
self.assertEqual(self.tracklist._osd.selection,
self.tracklist._trackview.selection_model)
self.assertFalse(self.tracklist._trackview.have_selected)
self.tracklist._osd.have_selected = True
self.assertTrue(self.tracklist._trackview.have_selected)
self.assertEqual(self.tracklist._trackview.n_selected, 0)
self.tracklist._osd.n_selected = 4
self.assertEqual(self.tracklist._trackview.n_selected, 4)
def test_footer(self):
"""Test that the footer is wired up properly."""
self.assertIsInstance(self.tracklist._footer,
emmental.tracklist.footer.Footer)
self.assertEqual(self.tracklist._footer.get_margin_start(), 6)
self.assertEqual(self.tracklist._footer.get_margin_end(), 6)
self.assertEqual(self.tracklist._footer.get_margin_top(), 6)
self.assertEqual(self.tracklist._footer.get_margin_bottom(), 6)
self.assertEqual(self.tracklist._trackview.get_next_sibling(),
self.tracklist._footer)
sep = self.tracklist._osd.get_next_sibling()
self.assertIsInstance(sep, Gtk.Separator)
self.assertEqual(sep.get_orientation(), Gtk.Orientation.HORIZONTAL)
self.assertEqual(sep.get_next_sibling(), self.tracklist._footer)
self.tracklist._trackview.n_tracks = 5
self.tracklist._trackview.n_selected = 3
@ -192,11 +215,12 @@ class TestTracklist(tests.util.TestCase):
self.assertIsNone(self.tracklist.playlist)
self.assertFalse(self.tracklist._top_right.get_sensitive())
with unittest.mock.patch.object(self.tracklist._trackview,
"reset_osd") as mock_reset_osd:
with unittest.mock.patch.object(self.tracklist._osd,
"reset") as mock_reset_osd:
self.tracklist.playlist = self.playlist
self.assertEqual(self.tracklist.playlist, self.playlist)
self.assertEqual(self.tracklist._trackview.playlist, self.playlist)
self.assertEqual(self.tracklist._osd.playlist, self.playlist)
self.assertTrue(self.tracklist._top_right.get_sensitive())
mock_reset_osd.assert_called()
@ -226,3 +250,40 @@ class TestTracklist(tests.util.TestCase):
self.assertEqual(self.tracklist._Card__scroll_idle(None),
GLib.SOURCE_REMOVE)
mock_scroll.assert_called_with(None)
def test_accelerators(self):
"""Check that the accelerators list is set up properly."""
entries = [("focus-search-track", self.tracklist._filter.grab_focus,
["<Control>slash"], None, None),
("clear-selected-tracks", self.tracklist._unselect.activate,
["Escape"], self.tracklist._unselect, "sensitive"),
("cycle-loop", self.tracklist._loop.activate,
["<Control>l"], self.tracklist._top_right, "sensitive"),
("toggle-shuffle", self.tracklist._shuffle.activate,
["<Control>s"], self.tracklist._top_right, "sensitive")]
accels = self.tracklist.accelerators
self.assertIsInstance(accels, list)
for i, (name, func, accel, gobject, prop) in enumerate(entries):
with self.subTest(action=name):
self.assertIsInstance(accels[i], emmental.action.ActionEntry)
self.assertEqual(accels[i].name, name)
self.assertEqual(accels[i].func, func)
self.assertListEqual(accels[i].accels, accel)
if gobject and prop:
enabled = gobject.get_property(prop)
self.assertEqual(accels[i].enabled, enabled)
gobject.set_property(prop, not enabled)
self.assertEqual(accels[i].enabled, not enabled)
start = len(entries)
osd_accels = self.tracklist._osd.accelerators
for i, accel in enumerate(osd_accels):
with self.subTest(name=accel.name):
self.assertIsInstance(accels[start + i],
emmental.action.ActionEntry)
self.assertEqual(accels[start + i].name, accel.name)
self.assertEqual(accels[start + i].func, accel.func)
self.assertListEqual(accels[start + i].accels, accel.accels)

View File

@ -28,15 +28,8 @@ class TestTrackView(tests.util.TestCase):
def test_init(self):
"""Test that the TrackView is initialized properly."""
self.assertIsInstance(self.trackview, Gtk.Frame)
self.assertIsInstance(self.trackview._scrollwin, Gtk.ScrolledWindow)
self.assertIsInstance(self.trackview._osd,
emmental.tracklist.selection.OSD)
self.assertEqual(self.trackview.get_child(), self.trackview._osd)
self.assertEqual(self.trackview._osd.get_child(),
self.trackview._scrollwin)
self.assertEqual(self.trackview._scrollwin.get_child(),
self.assertIsInstance(self.trackview, Gtk.ScrolledWindow)
self.assertEqual(self.trackview.get_child(),
self.trackview._columnview)
def test_list_models(self):
@ -50,6 +43,8 @@ class TestTrackView(tests.util.TestCase):
self.assertEqual(self.trackview._selection.get_model(),
self.trackview._filtermodel)
self.assertEqual(self.trackview.selection_model,
self.trackview._selection)
def test_columnview(self):
"""Test the columnview."""
@ -80,27 +75,12 @@ class TestTrackView(tests.util.TestCase):
requested.assert_called_with(self.playlist, self.track)
mock_unselect.assert_called()
def test_clear_selected_tracks(self):
"""Test the clear_selected_tracks() function."""
with unittest.mock.patch.object(self.trackview._osd,
"clear_selection") as mock_clear:
self.trackview.clear_selected_tracks()
mock_clear.assert_called()
def test_reset_osd(self):
"""Test the reset_osd() function."""
with unittest.mock.patch.object(self.trackview._osd,
"reset") as mock_reset:
self.trackview.reset_osd()
mock_reset.assert_called()
def test_playlist(self):
"""Test the playlist property."""
self.assertIsNone(self.trackview.playlist)
self.trackview.playlist = self.playlist
self.assertEqual(self.trackview._filtermodel.get_model(),
self.playlist)
self.assertEqual(self.trackview._osd.playlist, self.playlist)
def test_n_tracks(self):
"""Test the n-tracks property."""
@ -116,17 +96,6 @@ class TestTrackView(tests.util.TestCase):
self.db_plist.add_track(self.track)
self.assertEqual(self.trackview.runtime, 10.0)
def test_n_selected(self):
"""Test the n-selected and have-selected properties."""
self.assertEqual(self.trackview.n_selected, 0)
self.assertFalse(self.trackview.have_selected)
self.trackview._osd.n_selected = 4
self.trackview._osd.have_selected = True
self.assertEqual(self.trackview.n_selected, 4)
self.assertTrue(self.trackview.have_selected)
class TestTrackViewColumns(tests.util.TestCase):
"""Test the TrackView Columns."""