Compare commits

...

99 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
Anna Schumaker ef99951f74 Emmental 3.0.5
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-30 09:26:14 -04:00
Anna Schumaker 0fd391a9fd sidebar: Add keyboard shortcuts
The following shortcuts are implemented:

- <Control>? (<Shift><Control>/) to focus the "filter playlists" entry
- <Control><Alt>g to go to the current playlist
- <Shift><Control>p to open the Playlists section
- <Shift><Control>a to open the Artists section
- <Shift><Control>g to open the Genres section
- <Shift><Control>d to open the Decades section
- <Shift><Control>l to open the Libraries section

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-30 09:24:10 -04:00
Anna Schumaker bc92e72265 sidebar: Give the Header an activate() function
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-30 09:24:10 -04:00
Anna Schumaker 8dae0ed7bd sidebar: Add a button to jump to the current playlist
Implements: #59 ("Jump to Active Playlist button")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-30 09:23:31 -04:00
Anna Schumaker 1707f87e45 sidebar: Move the Filter into a Gtk.CenterBox
I'm going to add a button to jump to the current playlist, and the first
step is to add an area to put it in the sidebar.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-28 10:09:53 -04:00
Anna Schumaker 7e99fd1ba0 sidebar: Convert the section Group into a View
I change it to inherit from Gtk.Box, and append Sections as they are
added. I also add some stand-alone styling to set it apart as its own
widget.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-28 10:09:53 -04:00
Anna Schumaker a8e7078308 db: Disable incremental filtering
This applies to both the Table and child Playlists models. I'm doing my
own idle handling already for searching, so we can rely on that instead
of needing Gtk to do it. The benefit to this is that we can select
playlists programmatically during startup, since we don't need to worry
about the Table not being fully loaded yet.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-28 10:09:53 -04:00
Anna Schumaker 1d0813f217 db: Give playlist tables a refilter() function
This is used to notify the model that the rows have changed when it's
not automatically being detected. I first noticed this when attempting
to disable incremental filtering, due to the Artist list not getting
refiltered when new child Albums were added to the Artist.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-28 10:09:53 -04:00
Anna Schumaker 725619faf5 db: Improve filtering Tables
I scheduled the filter query with  first=True so it can run without a
long delay during scanning. Additionally, I cancel any pending filter
calls to discard stale arguments that otherwise would be processed.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-28 10:09:53 -04:00
Anna Schumaker 6607e5b0ad db: Give the Idle Queue a way to handle high priority tasks
If we have a high priority task, then we want to push it to the front of
the idle queue so it runs as soon as possible.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-28 10:09:53 -04:00
Anna Schumaker 73019d8eb4 db: Give the Idle Queue a cancel_task() function
It's sometimes desireable to cancel a pending task and re-add it with
new parameters to cut out some unnecessary work.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-28 10:09:53 -04:00
Anna Schumaker 6032e549a5 emmental: Use the gsetup.DEBUG_STR for appending "-debug"
This lets us calculate if we're in debug-mode or not once, and reuse the
result when needed.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-28 10:06:38 -04:00
Anna Schumaker ba4907ec34 Emmental 3.0.4 AUR commit
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-23 16:03:15 -04:00
Anna Schumaker 06771ecab6 Emmental 3.0.4
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-23 15:51:03 -04:00
Anna Schumaker 17b2a82e20 db: Have Playlists use the child_set as the children base model
I combine this with the table's Filter object to show playlists matching
the current search query.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-23 11:37:47 -04:00
Anna Schumaker c0c516fb70 db: Have decade Playlists use the new child_set
I implement add_year(), remove_year(), and has_year() functions and make
sure we load the set of yearids during startup. Additionally, I have
Years add and remove themselves from Decades as they are created and
deleted.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-23 11:37:47 -04:00
Anna Schumaker 3cddde0986 db: Have Album playlists use the new child_set
I implement add_medium(), remove_medium(), and has_medium() functions
and make sure we load the set of mediumids during startup. Additionally,
I have Mediums add and remove themselves from Albums as they are created
and deleted.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-23 11:37:47 -04:00
Anna Schumaker 4f15bde850 db: Have Artist playlists use the new child_set
I switch over to adding and removing Albums using the generic
add_child() and remove_child() functions. I also switch from using a
KeySet filter holding albumids to a Gtk.CustomFilter that calls
Artist.has_album() to check if an Album is in the set.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-23 11:37:47 -04:00
Anna Schumaker 5ee86a9b5e db: Give the Media table a custom Filter
We want to filter out Medium playlists with empty names in the sidebar,
and the easiest way to do that is through a custom filter attached to
the Media table.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-23 11:37:47 -04:00
Anna Schumaker 85c18fb5fe db: Add a child_set TableSubset to the Playlist
It defaults to a None pointer, but calling add_children() will set one
up with an empty set. I also implement functions for adding, removing,
and verifying child playlists.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-22 11:27:40 -04:00
Anna Schumaker 67b508384c db: Create a TableSubset model
This is similar to a Gtk.FilterListModel, except we already know exactly
which rows are part of the model or not. So we can skip the entire
filtering step and show rows directly instead.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-22 11:07:32 -04:00
Anna Schumaker 929beb2a97 db: Add set features to the KeySet
This includes implementing the __contains__() magic method, and adding
signals that are emitted when rows are added, removed, or directly set.
This will allow us to build a model around the rows represented by the
set.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-19 13:42:28 -04:00
Anna Schumaker f400366210 db: Rename the Table.Filter to Table.KeySet
I'm going to expand on this and use it for more than just filtering
Gtk.FilterListModels. Renaming it to something more generic is the first
step.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-19 09:55:11 -04:00
Anna Schumaker 0c66b13209 Emmental 3.0.3 AUR commit
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-16 10:52:27 -04:00
Anna Schumaker e846c957a5 Emmental 3.0.3
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-16 10:30:19 -04:00
Anna Schumaker 37f81825b1 sidebar: Give the New Playlist entry the "card" style class
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-16 10:17:38 -04:00
Anna Schumaker c375d2366a sidebar: Give sidebar widgets tooltip properties
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-10 21:30:43 -04:00
Anna Schumaker d807f8bd36 buttons: Replace the icon-size property with large-icon
It is much easier to pass a single boolean value instead of a Gtk
constant for specifying the icon size.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-10 21:30:41 -04:00
Anna Schumaker a13e481754 buttons: Give the ImageToggle active and inactive tooltip texts
This is used to set the tooltip text to fixed strings based on the
active status of the button. I also update the tooltip text on the
Favorite button in the Now Playing widget at the same time.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-10 20:31:41 -04:00
Anna Schumaker afb0ba5d68 buttons: Add missing docstring to the SplitButton.activate() function
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-10 19:28:24 -04:00
Anna Schumaker 0307fae362 Emmental 3.0.2 AUR commit
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 11:23:18 -04:00
108 changed files with 4370 additions and 1230 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 c6648433c32308bbc6eb7979bce99f61f98f4421
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 = 2
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)
@ -182,6 +187,7 @@ class Application(Adw.Application):
side_bar = sidebar.Card(sql=self.db)
self.db.settings.bind_setting("sidebar.artists.show-all", side_bar,
"show-all-artists")
self.__add_accelerators(side_bar.accelerators)
return side_bar
def build_tracklist(self) -> tracklist.Card:
@ -194,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:
@ -203,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)
@ -248,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",
@ -274,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()
@ -286,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()
@ -311,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

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

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

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

View File

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

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

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

View File

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

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

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

View File

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

View File

@ -1,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

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

View File

@ -3,9 +3,9 @@
import emmental.audio.tagger
import musicbrainzngs
import pathlib
import threading
from gi.repository import GObject
from .. import audio
from .. import thread
from . import albums
from . import artists
from . import connection
@ -178,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

@ -90,7 +90,7 @@ class Track(table.Row):
return self.trackid
class Filter(table.Filter):
class Filter(table.KeySet):
"""A customized Filter that never sets strictness to FilterMatch.All."""
def do_get_strictness(self) -> Gtk.FilterMatch:
@ -200,6 +200,12 @@ class Table(table.Table):
return self.sql(f"UPDATE tracks SET {column}=? WHERE trackid=?",
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

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

View File

@ -21,29 +21,29 @@ box.emmental-splitbutton>menubutton>button {
padding: 6px;
}
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

@ -17,13 +17,16 @@ gi.importlib.import_module("gi.repository.Gtk")
gi.importlib.import_module("gi.repository.Gst").init(sys.argv)
DEBUG_STR = "-debug" if __debug__ else ""
APPLICATION_ID = f"com.nowheycreamery.emmental{'-debug' if __debug__ else ''}"
APPLICATION_ID = f"com.nowheycreamery.emmental{DEBUG_STR}"
CSS_FILE = pathlib.Path(__file__).parent / "emmental.css"
CSS_PRIORITY = gi.repository.Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
CSS_PROVIDER = gi.repository.Gtk.CssProvider()
CSS_PROVIDER.load_from_path(str(CSS_FILE))
CACHE_DIR = pathlib.Path(xdg.BaseDirectory.save_cache_path("emmental"))
CACHE_DIR = CACHE_DIR / DEBUG_STR.lstrip("-")
DATA_DIR = pathlib.Path(xdg.BaseDirectory.save_data_path("emmental"))
RESOURCE_PATH = "/com/nowheycreamery/emmental"
@ -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

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

View File

@ -41,15 +41,15 @@ class Card(Gtk.Box):
self._bottom_box = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 0)
self._favorite = buttons.ImageToggle("heart-filled",
"heart-outline-thick-symbolic",
tooltip_text="add to "
"'Favorite Tracks'",
icon_size=Gtk.IconSize.LARGE,
"remove from 'Favorite Tracks'",
"add to 'Favorite Tracks'",
large_icon=True,
has_frame=False, sensitive=False,
valign=Gtk.Align.CENTER)
self._jump = buttons.Button(icon_name="go-jump", has_frame=False,
icon_size=Gtk.IconSize.LARGE,
self._jump = buttons.Button(icon_name="arrow4-down-symbolic",
tooltip_text="scroll to current track",
valign=Gtk.Align.CENTER, sensitive=False)
large_icon=True, sensitive=False,
has_frame=False, valign=Gtk.Align.CENTER)
self._seeker = seeker.Scale(sensitive=False)
self.bind_property("artwork", self._artwork, "filepath")

View File

@ -14,7 +14,7 @@ class PillButton(buttons.Button):
def __init__(self, **kwargs):
"""Initialize a Pill Button."""
super().__init__(icon_size=Gtk.IconSize.LARGE, **kwargs)
super().__init__(large_icon=True, **kwargs)
self.add_css_class("pill")
@ -46,7 +46,7 @@ class Controls(Gtk.Box):
self._play = PillButton(icon_name="play-large", tooltip_text="play",
sensitive=False)
self._pause = buttons.SplitButton(icon_name="pause-large",
icon_size=Gtk.IconSize.LARGE,
large_icon=True,
tooltip_text="pause",
secondary=self._autopause,
visible=False, sensitive=False)

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
@ -8,6 +9,7 @@ from . import genre
from . import library
from . import playlist
from . import section
from ..action import ActionEntry
from .. import db
from .. import entry
@ -23,58 +25,93 @@ class Card(Gtk.Box):
"""Set up the Sidebar widget."""
super().__init__(sql=sql, orientation=Gtk.Orientation.VERTICAL,
sensitive=False, **kwargs)
self._filter = entry.Filter("playlists")
self._header = Gtk.CenterBox()
self._filter = entry.Filter("playlists", hexpand=True)
self._jump = Gtk.Button(icon_name="arrow4-down-symbolic",
tooltip_text="scroll to current playlist")
self._playlists = playlist.Section(self.sql.playlists)
self._artists = artist.Section(self.sql.artists, self.sql.albums)
self._genres = genre.Section(self.sql.genres)
self._decades = decade.Section(self.sql.decades, self.sql.years)
self._libraries = library.Section(self.sql.libraries)
self._group = section.Group(sql)
self._view = section.View(sql)
self.append(self._filter)
self._header.set_center_widget(self._filter)
self._header.set_end_widget(self._jump)
self.append(self._header)
for sect in [self._playlists, self._artists, self._genres,
self._decades, self._libraries]:
self.append(sect)
self._group.add(sect)
self._view.add(sect)
self.append(self._view)
self._group.bind_property("selected-playlist",
self, "selected-playlist")
self._view.bind_property("selected-playlist",
self, "selected-playlist")
self.bind_property("show-all-artists", self._artists, "show-all",
GObject.BindingFlags.BIDIRECTIONAL)
self._filter.connect("search-changed", self.__search_changed)
self.sql.connect("table-loaded", self.__table_loaded)
self._jump.connect("clicked", self.__jump_to_playlist)
self.sql.connect("notify::loaded", self.__database_loaded)
self.add_css_class("background")
self.add_css_class("linked")
self._header.add_css_class("toolbar")
self.add_css_class("card")
def __jump_to_playlist(self, jump: Gtk.Button) -> None:
self.select_playlist(self.sql.active_playlist)
def __search_changed(self, entry: entry.Filter) -> None:
self.sql.filter(entry.get_query())
def __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]:
"""Get a list of accelerators for the Sidebar."""
return [ActionEntry("focus-search-playlist", self._filter.grab_focus,
"<Control>question", enabled=(self, "sensitive")),
ActionEntry("goto-active-playlist", self._jump.activate,
"<Control><Alt>g", enabled=(self, "sensitive")),
ActionEntry("goto-playlists", self._playlists.activate,
"<Shift><Control>p", enabled=(self, "sensitive")),
ActionEntry("goto-artists", self._artists.activate,
"<Shift><Control>a", enabled=(self, "sensitive")),
ActionEntry("goto-genres", self._genres.activate,
"<Shift><Control>g", enabled=(self, "sensitive")),
ActionEntry("goto-decades", self._decades.activate,
"<Shift><Control>d", enabled=(self, "sensitive")),
ActionEntry("goto-libraries", self._libraries.activate,
"<Shift><Control>l", enabled=(self, "sensitive"))]

View File

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

View File

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

View File

@ -4,9 +4,9 @@ import pathlib
from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gio
from gi.repository import Gdk
from gi.repository import Gtk
from gi.repository import Adw
from .. import texture
IMAGE_FILTERS = Gio.ListStore()
@ -37,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,9 +39,10 @@ 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)
self.extra_widget = Gtk.Button(icon_name="folder-new", has_frame=False,
tooltip_text="add new library path")
self._dialog = Gtk.FileDialog(title="Pick a Directory",
filters=DIRECTORY_FILTERS)
self._toast = None

View File

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

View File

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

View File

@ -2,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):
@ -99,8 +96,8 @@ class Section(header.Header):
"""Signal that the selected playlist has changed."""
class Group(GObject.GObject):
"""A group of sections."""
class View(Gtk.Box):
"""A widget for displaying a group of sections."""
sql = GObject.Property(type=db.Connection)
current = GObject.Property(type=Section)
@ -108,8 +105,8 @@ class Group(GObject.GObject):
selected_playlist = GObject.Property(type=db.playlist.Playlist)
def __init__(self, sql: db.Connection):
"""Initialize a Section Group."""
super().__init__(sql=sql)
"""Initialize a Section View."""
super().__init__(sql=sql, orientation=Gtk.Orientation.VERTICAL)
self._sections = []
def __on_active(self, section: Section, param: GObject.ParamSpec) -> None:
@ -145,6 +142,7 @@ class Group(GObject.GObject):
def add(self, section: Section) -> None:
"""Add a section to the group."""
self._sections.append(section)
self.append(section)
section.connect("notify::active", self.__on_active)
section.connect("playlist-activated", self.__playlist_activated)
section.connect("playlist-selected", self.__playlist_selected)

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,8 +63,10 @@ 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",
icon_size=Gtk.IconSize.NORMAL, state="None",
inactive_tooltip_text="loop: disabled",
large_icon=False, state="None",
has_frame=False, **kwargs)
def do_clicked(self):
@ -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,91 +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",
icon_size=Gtk.IconSize.NORMAL, icon_opacity=0.5,
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",
icon_size=Gtk.IconSize.NORMAL,
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):
@ -189,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:
@ -330,9 +314,8 @@ class FavoriteButton(TrackRow):
"""Initialize a Favorite Button."""
super().__init__(listitem, property=property)
self.child = buttons.ImageToggle("heart-filled", "heart-outline-thick",
icon_size=Gtk.IconSize.NORMAL,
valign=Gtk.Align.CENTER,
has_frame=False)
large_icon=False, has_frame=False,
valign=Gtk.Align.CENTER)
def do_bind(self):
"""Bind a track property to the Toggle Button."""

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

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

View File

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

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

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

View File

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

View File

@ -182,23 +182,23 @@ class TestLibraryObject(tests.util.TestCase):
tagger.tag_file.assert_not_called()
tagger.ready.is_set.return_value = True
tagger.get_result.return_value = (None, 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

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

View File

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

View File

@ -1,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

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

View File

@ -1,9 +1,9 @@
# Copyright 2022 (c) Anna Schumaker
"""Tests our Mutagen wrapper."""
import pathlib
import threading
import unittest.mock
import emmental.db.tagger
import emmental.thread
import tests.util
@ -276,8 +276,8 @@ class TestTaggerThread(tests.util.TestCase):
def test_init(self, mock_file: unittest.mock.Mock):
"""Test that the tagger thread was initialized properly."""
self.assertIsInstance(self.tagger, 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

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

View File

@ -36,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

@ -13,7 +13,7 @@ class TestButtons(unittest.TestCase):
"""Test that the pill button is configured correctly."""
button = emmental.nowplaying.controls.PillButton()
self.assertIsInstance(button, emmental.buttons.Button)
self.assertEqual(button.icon_size, Gtk.IconSize.LARGE)
self.assertTrue(button.large_icon)
self.assertTrue(button.has_css_class("pill"))
@ -99,13 +99,13 @@ class TestControls(unittest.TestCase):
"""Test the pause button."""
self.assertIsInstance(self.controls._pause,
emmental.buttons.SplitButton)
self.assertEqual(self.controls._pause.get_tooltip_text(), "pause")
self.assertEqual(self.controls._pause.icon_name, "pause-large")
self.assertEqual(self.controls._pause.icon_size,
Gtk.IconSize.LARGE)
self.assertEqual(self.controls._play.get_next_sibling(),
self.controls._pause)
self.assertEqual(self.controls._pause.get_tooltip_text(), "pause")
self.assertEqual(self.controls._pause.icon_name, "pause-large")
self.assertTrue(self.controls._pause.large_icon)
self.assertFalse(self.controls._pause.get_visible())
self.controls.playing = True
self.assertTrue(self.controls._pause.get_visible())

View File

@ -89,13 +89,15 @@ class TestNowPlaying(unittest.TestCase):
self.card._favorite)
self.assertEqual(self.card._favorite.active_icon_name, "heart-filled")
self.assertEqual(self.card._favorite.active_tooltip_text,
"remove from 'Favorite Tracks'")
self.assertEqual(self.card._favorite.inactive_icon_name,
"heart-outline-thick-symbolic")
self.assertEqual(self.card._favorite.get_tooltip_text(),
self.assertEqual(self.card._favorite.inactive_tooltip_text,
"add to 'Favorite Tracks'")
self.assertEqual(self.card._favorite.icon_size, Gtk.IconSize.LARGE)
self.assertEqual(self.card._favorite.get_valign(), Gtk.Align.CENTER)
self.assertFalse(self.card._favorite.get_has_frame())
self.assertTrue(self.card._favorite.large_icon)
self.assertFalse(self.card._favorite.get_sensitive())
self.card.have_db_track = True
@ -113,12 +115,12 @@ 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.icon_size, Gtk.IconSize.LARGE)
self.assertEqual(self.card._jump.get_valign(), Gtk.Align.CENTER)
self.assertFalse(self.card._jump.get_has_frame())
self.assertTrue(self.card._jump.large_icon)
self.assertFalse(self.card._jump.get_sensitive())
self.card.have_db_track = True

View File

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

View File

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

View File

@ -69,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):
@ -33,6 +33,8 @@ class TestLibraries(tests.util.TestCase):
self.assertIsInstance(self.libraries.extra_widget, Gtk.Button)
self.assertEqual(self.libraries.extra_widget.get_icon_name(),
"folder-new")
self.assertEqual(self.libraries.extra_widget.get_tooltip_text(),
"add new library path")
self.assertFalse(self.libraries.extra_widget.get_has_frame())
mock_set_initial_file = unittest.mock.Mock()

View File

@ -52,6 +52,8 @@ class TestPlaylists(tests.util.TestCase):
emmental.buttons.PopoverButton)
self.assertEqual(self.playlists.extra_widget.get_icon_name(),
"document-new")
self.assertEqual(self.playlists.extra_widget.get_tooltip_text(),
"add new playlist")
self.assertEqual(self.playlists.extra_widget.popover_child,
self.playlists._entry)
self.assertFalse(self.playlists.extra_widget.get_has_frame())
@ -65,6 +67,7 @@ class TestPlaylists(tests.util.TestCase):
Gtk.EntryIconPosition.PRIMARY), "list-add")
self.assertIsNone(self.playlists._entry.get_icon_name(
Gtk.EntryIconPosition.SECONDARY))
self.assertTrue(self.playlists._entry.has_css_class("card"))
with unittest.mock.patch.object(self.playlists.extra_widget,
"popdown") as mock_popdown:

View File

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

View File

@ -4,8 +4,6 @@ import emmental.db
import emmental.sidebar.section
import tests.util
import unittest.mock
from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gtk
@ -106,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."""
@ -150,7 +142,7 @@ class TestGroup(tests.util.TestCase):
def setUp(self):
"""Set up common variables."""
super().setUp()
self.group = emmental.sidebar.section.Group(self.sql)
self.view = emmental.sidebar.section.View(self.sql)
self.row_type = emmental.sidebar.row.TreeRow
self.section1 = emmental.sidebar.section.Section(self.sql.playlists,
self.row_type)
@ -161,35 +153,40 @@ class TestGroup(tests.util.TestCase):
def test_init(self):
"""Test that the Group is set up properly."""
self.assertIsInstance(self.group, GObject.GObject)
self.assertListEqual(self.group._sections, [])
self.assertEqual(self.group.sql, self.sql)
self.assertIsInstance(self.view, Gtk.Box)
self.assertListEqual(self.view._sections, [])
self.assertEqual(self.view.sql, self.sql)
self.assertEqual(self.view.get_orientation(),
Gtk.Orientation.VERTICAL)
def test_add(self):
"""Test adding sections to the Group."""
self.group.add(self.section1)
self.assertListEqual(self.group._sections, [self.section1])
self.group.add(self.section2)
self.assertListEqual(self.group._sections,
self.view.add(self.section1)
self.assertListEqual(self.view._sections, [self.section1])
self.assertEqual(self.view.get_first_child(), self.section1)
self.view.add(self.section2)
self.assertListEqual(self.view._sections,
[self.section1, self.section2])
self.assertEqual(self.section1.get_next_sibling(), self.section2)
def test_current(self):
"""Test the current section property."""
self.group.add(self.section1)
self.group.add(self.section2)
self.assertIsNone(self.group.current)
self.view.add(self.section1)
self.view.add(self.section2)
self.assertIsNone(self.view.current)
self.section1.active = True
self.assertEqual(self.group.current, self.section1)
self.assertEqual(self.view.current, self.section1)
self.section2.active = True
self.assertEqual(self.group.current, self.section2)
self.assertEqual(self.view.current, self.section2)
self.assertFalse(self.section1.active)
def test_animation(self):
"""Test setting the section animation style."""
self.group.add(self.section1)
self.group.add(self.section2)
self.view.add(self.section1)
self.view.add(self.section2)
self.section1.active = True
self.assertEqual(self.section1.animation,
@ -201,8 +198,8 @@ class TestGroup(tests.util.TestCase):
def test_playlist_activated(self):
"""Test responding to the section playlist-activated signal."""
self.group.add(self.section1)
self.group.add(self.section2)
self.view.add(self.section1)
self.view.add(self.section2)
self.assertIsNone(self.sql.active_playlist)
playlist = self.sql.playlists.create("Test Playlist")
@ -215,16 +212,16 @@ class TestGroup(tests.util.TestCase):
def test_selections(self):
"""Test the selected section & playlist properties."""
self.group.add(self.section1)
self.group.add(self.section2)
self.view.add(self.section1)
self.view.add(self.section2)
self.assertIsNone(self.group.selected_section)
self.assertIsNone(self.group.selected_playlist)
self.assertIsNone(self.view.selected_section)
self.assertIsNone(self.view.selected_playlist)
genre = self.sql.genres.create("Test Genre")
self.section2.emit("playlist-selected", genre)
self.assertEqual(self.group.selected_section, self.section2)
self.assertEqual(self.group.selected_playlist, genre)
self.assertEqual(self.view.selected_section, self.section2)
self.assertEqual(self.view.selected_playlist, genre)
self.section2.active = True
treerow = self.section2._selection.get_selected_item()

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
@ -23,41 +24,62 @@ class TestSidebar(tests.util.TestCase):
Gtk.Orientation.VERTICAL)
self.assertFalse(self.sidebar.get_sensitive())
self.assertTrue(self.sidebar.has_css_class("background"))
self.assertTrue(self.sidebar.has_css_class("linked"))
self.assertTrue(self.sidebar.has_css_class("card"))
def test_header(self):
"""Test the Sidebar header."""
self.assertIsInstance(self.sidebar._header, Gtk.CenterBox)
self.assertEqual(self.sidebar.get_first_child(), self.sidebar._header)
self.assertTrue(self.sidebar._header.has_css_class("toolbar"))
def test_filter(self):
"""Test the Sidebar filter entry."""
self.assertIsInstance(self.sidebar._filter, emmental.entry.Filter)
self.assertEqual(self.sidebar.get_first_child(), self.sidebar._filter)
self.assertEqual(self.sidebar._header.get_center_widget(),
self.sidebar._filter)
self.assertEqual(self.sidebar._filter.get_placeholder_text(),
"type to filter playlists")
self.assertTrue(self.sidebar._filter.get_hexpand())
with unittest.mock.patch.object(self.sql, "filter") as mock_filter:
self.sidebar._filter.set_text("test text")
self.sidebar._filter.emit("search-changed")
mock_filter.assert_called_with("*test text*")
def test_jump(self):
"""Test the jump button."""
self.assertIsInstance(self.sidebar._jump, Gtk.Button)
self.assertEqual(self.sidebar._header.get_end_widget(),
self.sidebar._jump)
self.assertEqual(self.sidebar._jump.get_icon_name(),
"arrow4-down-symbolic")
self.assertEqual(self.sidebar._jump.get_tooltip_text(),
"scroll to current playlist")
self.sql.playlists.load(now=True)
with unittest.mock.patch.object(self.sidebar,
"select_playlist") as mock_select:
self.sidebar._jump.emit("clicked")
mock_select.assert_called_with(self.sql.active_playlist)
def test_sensitivity_and_startup(self):
"""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):
@ -73,15 +95,17 @@ class TestSidebar(tests.util.TestCase):
self.assertIsNone(self.sidebar.selected_playlist)
playlist1 = self.sql.playlists.create("Playlist 1")
self.sidebar._group.selected_playlist = playlist1
self.sidebar._view.selected_playlist = playlist1
self.assertEqual(self.sidebar.selected_playlist, playlist1)
def test_group(self):
"""Test that sidebar sections are part of the same Group."""
self.assertIsInstance(self.sidebar._group,
emmental.sidebar.section.Group)
def test_view(self):
"""Test that sidebar sections are in the View."""
self.assertIsInstance(self.sidebar._view,
emmental.sidebar.section.View)
self.assertEqual(self.sidebar._header.get_next_sibling(),
self.sidebar._view)
self.assertListEqual(self.sidebar._group._sections,
self.assertListEqual(self.sidebar._view._sections,
[self.sidebar._playlists,
self.sidebar._artists,
self.sidebar._genres,
@ -101,7 +125,7 @@ class TestSidebar(tests.util.TestCase):
self.assertIsInstance(self.sidebar._libraries,
emmental.sidebar.library.Section)
self.assertEqual(self.sidebar._filter.get_next_sibling(),
self.assertEqual(self.sidebar._view.get_first_child(),
self.sidebar._playlists)
self.assertEqual(self.sidebar._playlists.get_next_sibling(),
self.sidebar._artists)
@ -122,42 +146,79 @@ 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)
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))
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)
def test_accelerators(self):
"""Check that the accelerators list is set up properly."""
entries = [("focus-search-playlist", self.sidebar._filter.grab_focus,
["<Control>question"]),
("goto-active-playlist", self.sidebar._jump.activate,
["<Control><Alt>g"]),
("goto-playlists", self.sidebar._playlists.activate,
["<Shift><Control>p"]),
("goto-artists", self.sidebar._artists.activate,
["<Shift><Control>a"]),
("goto-genres", self.sidebar._genres.activate,
["<Shift><Control>g"]),
("goto-decades", self.sidebar._decades.activate,
["<Shift><Control>d"]),
("goto-libraries", self.sidebar._libraries.activate,
["<Shift><Control>l"])]
decade = self.sql.decades.create(1990)
year = self.sql.years.create(1990)
accels = self.sidebar.accelerators
self.assertIsInstance(accels, list)
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
for i, (name, func, accel) 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)
self.sidebar.select_playlist(plist)
self.assertTrue(self.sidebar._decades.active)
self.sidebar._decades.select_playlist.assert_called_with(plist)
enabled = self.sidebar.get_sensitive()
self.assertEqual(accels[i].enabled, enabled)
self.sidebar.set_sensitive(not enabled)
self.assertEqual(accels[i].enabled, not enabled)
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)
self.assertEqual(len(accels), i + 1)

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

@ -30,17 +30,6 @@ class TestButton(unittest.TestCase):
self.assertEqual(button2.icon_name, "icon-name")
self.assertEqual(button2._image.get_icon_name(), "icon-name")
def test_icon_size(self):
"""Test the icon-size property."""
self.assertEqual(self.button.icon_size, Gtk.IconSize.NORMAL)
self.button.icon_size = Gtk.IconSize.LARGE
self.assertEqual(self.button._image.get_icon_size(),
Gtk.IconSize.LARGE)
button2 = emmental.buttons.Button(icon_size=Gtk.IconSize.LARGE)
self.assertEqual(button2.icon_size, Gtk.IconSize.LARGE)
self.assertEqual(button2._image.get_icon_size(), Gtk.IconSize.LARGE)
def test_icon_opacity(self):
"""Test the icon-opacity property."""
self.assertEqual(self.button.icon_opacity, 1.0)
@ -52,6 +41,20 @@ class TestButton(unittest.TestCase):
self.assertAlmostEqual(button2.icon_opacity, 0.25, delta=0.005)
self.assertAlmostEqual(button2._image.get_opacity(), 0.25, delta=0.005)
def test_large_icon(self):
"""Test the large-icon property."""
self.assertFalse(self.button.large_icon)
self.assertEqual(self.button._image.get_icon_size(),
Gtk.IconSize.NORMAL)
self.button.large_icon = True
self.assertEqual(self.button._image.get_icon_size(),
Gtk.IconSize.LARGE)
button2 = emmental.buttons.Button(large_icon=True)
self.assertTrue(button2.large_icon)
self.assertEqual(button2._image.get_icon_size(), Gtk.IconSize.LARGE)
class TestPopoverButton(unittest.TestCase):
"""Test a Popover Button."""
@ -123,16 +126,16 @@ class TestSplitButton(unittest.TestCase):
self.assertEqual(button2.icon_name, "icon-name")
self.assertEqual(button2._primary.icon_name, "icon-name")
def test_icon_size(self):
"""Test the icon size property."""
self.assertEqual(self.button.icon_size, Gtk.IconSize.NORMAL)
self.button.icon_size = Gtk.IconSize.LARGE
self.assertEqual(self.button._primary.icon_size, Gtk.IconSize.LARGE)
def test_large_icon(self):
"""Test the large icon property."""
self.assertFalse(self.button.large_icon)
self.button.large_icon = True
self.assertTrue(self.button._primary.large_icon)
button2 = emmental.buttons.SplitButton(icon_size=Gtk.IconSize.LARGE,
button2 = emmental.buttons.SplitButton(large_icon=True,
secondary=Gtk.Button())
self.assertEqual(button2.icon_size, Gtk.IconSize.LARGE)
self.assertEqual(button2._primary.icon_size, Gtk.IconSize.LARGE)
self.assertTrue(button2.large_icon)
self.assertTrue(button2._primary.large_icon)
def test_secondary(self):
"""Test the secondary property."""
@ -190,6 +193,35 @@ class TestImageToggle(unittest.TestCase):
active=True)
self.assertTrue(button2.active)
def test_tooltip_text(self):
"""Test the active and inactive tooltip text."""
self.assertEqual(self.button.active_tooltip_text, None)
self.assertEqual(self.button.inactive_tooltip_text, None)
button2 = emmental.buttons.ImageToggle("active", "inactive",
"active tooltip text",
"inactive tooltip text")
self.assertEqual(button2.active_tooltip_text, "active tooltip text")
self.assertEqual(button2.inactive_tooltip_text,
"inactive tooltip text")
self.assertEqual(button2.get_tooltip_text(), "inactive tooltip text")
button2.active = True
self.assertEqual(button2.get_tooltip_text(), "active tooltip text")
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, 2)
self.assertEqual(emmental.VERSION_NUMBER, "3.0.2")
self.assertEqual(emmental.VERSION_STRING, "Emmental 3.0.2-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.2")
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,8 +218,21 @@ 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",
"<Control>question"),
("app.goto-active-playlist", "<Control><Alt>g"),
("app.goto-playlists", "<Shift><Control>p"),
("app.goto-artists", "<Shift><Control>a"),
("app.goto-genres", "<Shift><Control>g"),
("app.goto-decades", "<Shift><Control>d"),
("app.goto-libraries", "<Shift><Control>l")]:
self.assertEqual(self.application.get_accels_for_action(action),
[accel])
self.assertEqual(win.sidebar.sql, self.application.db)
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
@ -214,8 +242,20 @@ class TestEmmental(unittest.TestCase):
self.application.factory = emmental.playlist.Factory(
self.application.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")
@ -228,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()
@ -242,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."""
@ -249,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
@ -264,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
@ -271,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)

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