Compare commits

...

338 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
Anna Schumaker a6cd453c63 Emmental 3.0.2
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 10:31:35 -04:00
Anna Schumaker 7079076857 nowplaying: Add keyboard accelerators
I add accelerators for play, pause, next track, previous track, setting
autopause, adding the current track to the favorites playlist, and
scrolling to the current track in the tracklist.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 10:25:00 -04:00
Anna Schumaker 8d72e1375f nowplaying: Give the Controls accelerator functions and properties
I create can-activate-* properties to indicate if a specific accelerator
can be activated. At the same time, I introduce functions intended to be
called by accelerators to activate each of our widgets.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 10:04:56 -04:00
Anna Schumaker 87b92ffc90 nowplaying: Give the autopause widgets {inc,dec}rement() functions
These will be used for keyboard accelerators to set the autopause value.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 10:04:51 -04:00
Anna Schumaker bb4ca1e9c4 nowplaying: Give the autopause entry the "card" style class
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 10:04:45 -04:00
Anna Schumaker 087c378e59 nowplaying: Remove the Gtk.Frame from the Artwork
All I needed the frame for was to add rounded corners to the
Gtk.Picture, but this had some problems with the Frame expanding beyond
the edges of the picture in some cases. I can get the same effect by
adding the "card" css class to the Picture so hopefully this will work
better.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 10:04:40 -04:00
Anna Schumaker 1f434358de nowplaying: Add tooltips to the Now Playing Card
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 10:04:34 -04:00
Anna Schumaker 41cb325ad0 buttons: Give the SplitButton an activate() function
This is a keybinding function that calls into the primary button
activate() function. At the same time, I add an "activate-primary"
signal that is emitted when the primary button is activated.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 10:04:28 -04:00
Anna Schumaker 0c1e5fcace header: Add keyboard accelerators
I add accelerators for opening files, changing the volume, toggling
background mode, and running the settings editor.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 10:04:25 -04:00
Anna Schumaker 9cb927aabb header: Give the volume button public {inc,dec}rement() funcs
I made these functions part of the public interface with option
arguments so they can be used in accelerator callbacks for increasing or
decreasing the volume.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 09:50:22 -04:00
Anna Schumaker 397c693aef window: Add a keyboard accelerator
I use the "Escape" key as a shortcut for resetting the currently focused
widget.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 09:50:19 -04:00
Anna Schumaker eb162154b5 window: Add a "user-editing" property
I use the current focus widget to set the "user-editing" property, which
can be used to enable or disable accelerator actions.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 09:46:10 -04:00
Anna Schumaker 2b5cdaa197 action: Add an ActionEntry class
This is inspried by the Gio.ActionEntry struct, which I can't figure out
how to get working in Python. I add on a few extra helpful features,
such as:

  - Automatically creating a Gio.SimpleAction
  - Tracking the desired accelerator keys
  - Binding the "enabled" state to a specificed property at construction

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-09 09:45:58 -04:00
Anna Schumaker c5f9608c49 tracklist: Commit the database after adding, moving, or removing tracks
Otherwise we could lose the changes if the app crashes.

Fixes: #63 ("The database isn't being committed enough")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-07 14:58:19 -04:00
Anna Schumaker cae93cae11 sidebar: Commit the database after creating a new playlist
Otherwise we could have data loss if the application crashes.

Fixes: #63 ("The database isn't being committed enough")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-07 14:58:19 -04:00
Anna Schumaker 01a37dbbc1 db: Commit the database after deleting a Table Row
Fixes: #63 ("The database isn't being committed enough")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-07 14:54:32 -04:00
Anna Schumaker 14c487c295 db: Commit the database when a Track has been started or stopped
Leaving the database in a dirty state could cause unintentional data
loss if the app crashes.

Fixes: #63 ("The database isn't being committed enough")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-07 13:26:55 -04:00
Anna Schumaker 57dd2c280e db: Don't notify if the active-playlist doesn't change
This lets us skip a handful of database calls if we detect that we're
setting the active playlist to the same value.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-06 09:29:16 -04:00
Anna Schumaker 59fb7d12f3 db: Set the Queued Tracks playlist as active when adding tracks
I do this unconditionally, so even if the track is already on the
playlist we set it as active. I think this is what the user would
expect, but I'm open to revising this approach later.

Fixes: #62 ("Queueing tracks doesn't activate the Queued Tracks playlist")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-06 09:22:22 -04:00
Anna Schumaker 93cdd9137a Remove 'data/' directory
This should have been done with the 3.0 release, but was overlooked.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-01 11:47:04 -04:00
Anna Schumaker 45e95cc8c1 Rename emmental.desktop
The Makefile was updated to point to
com.nowheycreamery.emmental.desktop, but the file was never renamed.
Oops.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-01 11:46:10 -04:00
Anna Schumaker fa5cd55fce Emmental 3.0.1 AUR commit
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-01 11:34:24 -04:00
Anna Schumaker f307c92edb Emmental 3.0.1
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-01 11:14:40 -04:00
Anna Schumaker 8afd1a6240 audio: Replace the playbin audio-filter with the new filter
And wire up the bg-enabled and bg-volume properties from the header to
the playbin properties with the same name.

Implements: #50 ("Background Music Mode")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-01 11:13:48 -04:00
Anna Schumaker 84fbd94aa1 audio: Create a new audio filter for ReplayGain and Background Mode
This links together our ReplayGain filter with a volume element that is
set to the user configured background volume when background listening
mode is enabled, and 100% when background listening mode is disabled.

Implements: #50 ("Background Music Mode")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-01 11:13:46 -04:00
Anna Schumaker 7155fa9db5 header: Add an icon for background mode to the Header
I also update the button tooltip to display the background listening
status along with the current volume level.

Implements: #50 ("Background Music Mode")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-01 11:13:44 -04:00
Anna Schumaker 0e40e6a4e8 header: Add a BackgroundRow to the popover menu
And wire up the properties so we can save the user's current setting.

Implements: #50 ("Background Music Mode")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-01 11:13:42 -04:00
Anna Schumaker deea9caa37 header: Create a BackgroundRow ListBox row
This widget will be added to the Volume popover and allow configuring a
lower volume for background listening while focusing on tasks.

Implements: #50 ("Background Music Mode")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-06-01 11:13:39 -04:00
Anna Schumaker a626a1f3c4 header: Convert the volume button box to a Gtk.ListBox
And give it the boxed-list style class so it looks nice.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-31 16:31:31 -04:00
Anna Schumaker dae588bfaf header: Convert the ReplayGain selector into an Adw.ExpanderRow
This will be added to a ListBox with the volume controls. Expanding the
row will enable ReplayGain and give the user a menu to select ReplayGain
mode.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-31 16:31:31 -04:00
Anna Schumaker 03e5b9ad1b header: Convert the volume controls into a ListBoxRow
The plan is to convert the volume control panel into a Gtk.ListBox for a
more modern appearance.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-31 16:31:30 -04:00
Anna Schumaker 5b0a0f54e4 header: Add tooltips to the Header widgets
This is nice to have so users know what to expect from each button. I do
take some liberties, such as putting version numbers of our dependencies
as the tooltip for the Title widget. I also display the current volume
level in the volume button tooltip.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-30 13:54:21 -04:00
Anna Schumaker dd9d6268ff gsetup: Rework version string handling
I split the function into two. Calling env_string() will return the
string we generate with the versions of the various modules we depend
on. Calling print_env() will print this string.

I need env_string() so we can set the environment as a tooltip in the
header.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-30 13:19:51 -04:00
Anna Schumaker a86ce6165d Emmental: Add a MICRO_VERSION
And set it to '1' to start, since we have a few extra commits on top of
3.0 already.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-25 10:19:51 -04:00
Anna Schumaker e624566919 Makefile: Makefile updates
- Install emmental.desktop as com.nowheycreamery.emmental.desktop
- Fix up variables used in the 'uninstall' target

Fixes: #61 (Rename .desktop file)
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-24 15:05:10 -04:00
Anna Schumaker b9a25ce5af emmental.desktop: Set the StartupWMClass key
We need to set this to emmental.py so the Gnome dock knows what to do
with emmental.py when it is started by our launcher shell script.

Fixes: #60 (Set StartupWMClass in the emmental.desktop file)
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-24 15:05:03 -04:00
Anna Schumaker bb9ecdbb5d Emmental 3.0 AUR commit
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-24 14:48:15 -04:00
Anna Schumaker 8b249b4b3e README: Write a README for v3.0
Expand on the current README.md file to include information about
Emmental and its dependencies.

Implements: #49 (Write README)
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-23 10:53:24 -04:00
Anna Schumaker 55d7eb3d45 db: Raise an exception if the user_version is too new
Future proof. If we update the database schema, then we'll bump the
user_version field. If the user then tries to open the new database with
an old Emmental version then there could be a lot of issues. Let's
detect this and raise an error with a description of the problem.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-23 09:16:52 -04:00
Anna Schumaker 8b1be777c1 install: Update the install target for Emmental 3.x
Instead of calling out to a separate tools, I can use the `find` and
`install` programs to easily install the files to the right place.

I also take this opportunity to update the emmental.desktop file for v3,
including filling out as many audio-related mime types as I can find and
using the `desktop-file-install` command to not only install the file
but properly set the "Exec" and "Icon" fields based on the PREFIX=
passed to `make`

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-23 09:16:52 -04:00
Anna Schumaker 50474c7fd1 Rename emmental3.py --> emmental.py
Now that we are getting ready for a release, update the launcher script
name.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:42 -04:00
Anna Schumaker 6e4e83cb40 Remove obsolete Emmental 2.x code
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:42 -04:00
Anna Schumaker 78ea2904a1 tracklist: Add Move Down and Move Up buttons to the OSD
These are used to manually rearrange the Tracks in the Playlist. The
buttons are only marked sensitive if one item is selected.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker a6f59d9378 tracklist: Add an Add Tracks button to the TrackView OSD
The Add Tracks button is a popover button configured to display a list
of playlists that tracks could be added to. I take some extra care to
make sure we only display playlists that have their user-tracks property
set to True, and to hide the currently visible playlist from the list.

Additionally, I create a horizontal size group so the Add and Remove
buttons are the same size.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker ff9724a274 tracklist: Add a Remove Tracks button to the TrackView
The button is placed inside a Gtk.Overlay, and is hidden by default. The
button will be shown when tracks are selected if the current Playlist
has its "user-tracks" property set to True.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker 911aeb84a1 tracklist: Add a Footer widget
This widget displays the number of visible tracks, number of selected
tracks, and runtime of the current playlist. I wire it up to these
properties from the trackview.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker ee1152bcc4 tracklist: Add a SortButton
This is a Popover Button containing a Gtk.ListView to display the Sort
Order Model for the currently visible Playlist.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker ed4a484a31 tracklist: Add a SortFieldWidget
This Widget is intended to be used as the child widget in a Gtk.ListView
to display and change the sort order of the current playlist. I use
arrow buttons from the icon library to represent sort order

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker 83355f7e96 tracklist: Add a SortOrderModel
This is a Gio.ListStore with some extra functions for enabling,
disabling, rearranging, and reversing sort fields. It also has a
sort-string property for getting the current sort order to save
to the database.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker b326320156 tracklist: Add a SortField object
The SortField object is used to represent a single column in the
Tracklist that the user could sort by.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker 0c77e509c3 tracklist: Add a ShuffleButton
This is an ImageToggle button that adjusts its opacity based on if it is
active or not.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker ed1d990e74 tracklist: Add a LoopButton
This is an ImageToggle button that has been configured to cycle between
3 states corresponding to no looping, playlist looping, and track
looping.

I also update the Tracklist to look for changes in the visible Playlist
to update the Loop button.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker 2d19d78eb6 tracklist: Add an Unselect All button
This button will unselect any selected tracks when clicked.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker f1e18549ff tracklist: Add a VisibleColumns button
This button shows a popover menu to set the visibility of our columns.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker ff1d772a05 tracklist: Add a Selection OSD to the TrackView
The OSD will eventually contain buttons for modifying playlists, but for
now it just has functions and properties for mananging the current track
selection.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker a485a3806b tracklist: Scroll to the requested Track
And wire this up to not only the Now Playing "jump" signal, but also the
next track pickers so we scroll when tracks are changed.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker 481c4856c7 tracklist: Request a Track when it is activated
And wire up a handler for the factory track-requested signal in the main
application code.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker 61dfc2a586 tracklist: Create a MediumName TrackRow
The MediumName TrackRow is used to combine the album and medium names
into a single string. This means we won't need to have a separate medium
name column that is empty for most tracks.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker 97bf9d48db tracklist: Create an AlbumCover TrackRow
The AlbumCover shows a cover.jpg image for a specific Album in a column.
I also need to do some special handling so generate a tooltip to show a
larger version of the image.

I try to cache the AlbumCover Texture to cut down on disk accesses,
since we'll usually end up loading the same image several times for each
track in an album.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker b3dcd3c0b9 tracklist: Create an AlbumString TrackRow
This is an InscriptionRow that binds a Track's Album's property to the
Gtk.Inscription. I use it to display album artist and release information
in columns.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker 1ffc300eda tracklist: Create a TracknoString TrackRow
This is an InscriptionRow that combines a Track's number with its
Medium's number.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker bed518cd77 tracklist: Create a FavoriteButton TrackRow
This button shows an ImageToggle button connected to the "favorite"
property of the Track.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker 915a59a46b tracklist: Create LengthString, PlayCountString, TimestampString, and PathString TrackRows
These are specially configured TrackRows that take a non-string Track
property and convert it into a string displayed in the Inscription. I
use them to add Length, Play Count, Last Started, Last Played, and
Filepath columns to the TrackView.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker 9edfc4a5b0 tracklist: Create TrackRow, InscriptionRow, and TrackString widgets
The TrackRow widget is used to bind Tracks to a generic Widget. The
InscriptionRow builds on this to create a Gtk.Inscription that can be
used in derived classes. Finally, the TrackString widget implements
binding a string Track property directly to the Inscription.

I use these widgets to create a Title and Artist column in the
TrackView.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker 999a3eb523 tracklist: Create a TrackView
The TrackView sets up a scrollable Gtk.ColumnView inside a nice looking
frame. It also creates a FilterListModel for filtering tracks in a
separate layer from the Playlist so we don't affect choosing the next
track.

Finally, I add the TrackView to the TrackList Card.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker 2c2462c3d6 tracklist: Create a basic Tracklist widget
For now it only has the Gtk.CenterBox child with an entry.Filter widget,
but this will be expanded on in future patches. I also take the chance
to bind the factory:visible-playlist property to the playlist displayed
in the tracklist.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:27 -04:00
Anna Schumaker 0d27a09233 emmental: Wire up the Next and Previous buttons
And connect to the Player EOS and about-to-finish signals so we can
select the next track when the current one finishes (or slightly before
for gapless playback).

Implements: #7 (Add MPRIS2 Support)
Implements: #48 (Implement Intelligent ReplayGain)
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:17 -04:00
Anna Schumaker a687b564a9 playlist: Give the Factory a track-requested signal
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:17 -04:00
Anna Schumaker 6bbc423193 playlist: Give the Factory a next_track() function
It takes an additional user= parameter to indicate if the user is asking
for the next track or if it is coming from the audio player (such as an
EOS / about-to-finish signal). This lets us handle the next_track() call
slightly differently for the user case.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:17 -04:00
Anna Schumaker cf056d6ec5 playlist: Give the Factory a previous_track() function
And also a can-go-previous property to set the correct sensitivity on
the "Previous" button and for notifying MPRIS.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:17 -04:00
Anna Schumaker fd584e516a playlist: Have the Factory create a previous Playlist
And add some special handling so a previous.Previous() playlist type is
created for the db_previous playlist.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:17 -04:00
Anna Schumaker 820eda4c46 playlist: Have the Factory create an active Playlist
And add some special handling for setting the active playlist and
visible playlist to the same db playlist. I also add active-loop and
active-shuffle properties that are wired up to the MPRIS2 "Shuffle" and
"LoopStatus" player properties.

Implements: #7 (Add MPRIS2 Support)
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:17 -04:00
Anna Schumaker b0734a41d0 playlist: Have the Factory create a visible Playlist
I either create or reuse an existing Playlist object when the
db-visible property changes to a new value.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:17 -04:00
Anna Schumaker f5ef144419 playlist: Create a Previous Playlist type
This Playlist implements some special handling for the Previous
Playlist. This includes adding a previous_tracks() function and
properties to indicate if the Playlist has a previous or next track.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:17 -04:00
Anna Schumaker 55486c20c3 playlist: Give Playlists a track-requested signal
We need to do some bookkeeping inside the Playlist before notifying the
Factory that a track has been requested.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:17 -04:00
Anna Schumaker 14bcef6e52 playlist: Give Playlists a can-go-next property
This property is updated whenever the items in the Playlist change or if
the current-track property is changed. It can be used to know in advance
if calling next_track() can be expected to return a valid Track
instance.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:17 -04:00
Anna Schumaker 6592b97cbd playlist: Give the next_track() function shuffle support
And add a "shuffle" property to the Playlist class. I use a TrackidSet
instance to keep track of which trackids have already been picked, and
to select a random trackid from the remaining options.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:17 -04:00
Anna Schumaker 2d18ce422e playlist: Give Playlists a sequential next_track() function
I add a "loop" property to the Playlist class that can be set to "None",
"Playlist", or "Track" (to match the MPRIS2 loop property). I can then
tune the behavior of next_track() based on how loop is configured.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:17 -04:00
Anna Schumaker 58934d9b46 playlist: Give playlists a current_track property
This sets the current_trackid field on the database playlist object to
the trackid of the provided track.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:17 -04:00
Anna Schumaker 45ddb22cc7 playlist: Give playlists functions for moving individual tracks
The move_track_up() and move_track_down() functions are used to manually
arrange the tracks in a playlist.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:17 -04:00
Anna Schumaker edaa275ba5 playlist: Give the Playlist model a sort-order property
And make sure we re-sort the tracks when it changes to match the new
order.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:17 -04:00
Anna Schumaker 298b58a54e playlist: Add a Playlist model
This model builds on the TrackidModel to make it more Playlist and Track
focused instead of trackid centric.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:17 -04:00
Anna Schumaker 4ce571ebf8 playlist: Create a TrackidModel Gio.ListModel
The TrackidModel takes a TrackidSet and presents it as a Gio.ListModel
that maps trackids into Track objects. Tracks can be found by value
using the bisect() function, which sorts the trackids by number by
default (this can be changed by overriding the do_get_sort_key()
function).

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:17 -04:00
Anna Schumaker fdfc12fbd2 playlist: Create a Playlist Factory object
The playlist factory has properties for the currently selected,
currently active, and previous playlists. It will eventually create
Gio.ListModel instances representing the tracks in each of these playlists,
in sorted order.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:17 -04:00
Anna Schumaker 7d26d89405 audio: Give the player a pause-on-load property
Setting this to True will cause the Player to change state to PAUSED
during the next emission of the "file-loaded" signal.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:42:17 -04:00
Anna Schumaker 9a3d095081 emmental: Wire up the current track properties
I attach these to the Now Playing card so the "add to favorites" button
can be correctly marked as sensitive and enabled when a Track is
selected for playback.

Additionally, I look for a notification from the Track table to say it
has been loaded. This lets me set the Player to load up the current_track
if one is set.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:31:33 -04:00
Anna Schumaker 1aec9df0a8 db: Reload Playlists before deleting a Library path
This is much faster than removing tracks from their playlists one at a
time. I also clear and reload the Tracks table to clear out pointers to
old Tracks.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:31:33 -04:00
Anna Schumaker eea763f133 db: Detect deleted Tracks during Library scanning
And use the tagger.untag_track() function to clean up and remove them
from the database.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:31:33 -04:00
Anna Schumaker 3cda4caa76 db: Give the Tagger a function for untagging Tracks
This is used to remove a Track from each of its Playlists before
deleting.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:31:33 -04:00
Anna Schumaker e0e7b556be db: Add Track support to the Tagger class
This includes both creating new Tracks and updating existing Tracks when
their tags have changed. New tracks are added to playlists using
idle=True so Gtk can spread out UI updates for each playlist so we don't
slow things down too much for the user.

This patch also adds a library argument to the Tagger thread
get_result() function which we pass to the Tagger class to be used by
Tracks.creat(). I also add an mtime argument to the Tagger thread
tag_file() function to pass down to the audio.tagger layer so we can
skip updating tracks that have not changed since the last scan.

Implements: #41 (Check for new or modified tags during startup)
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:31:33 -04:00
Anna Schumaker 7920b3d5a8 db: Give Libraries knowledge about their Tracks & Properties
I expand on the libraries_view to include additional playlist
properties, and configure the default sort order to sort by filepath.

I then set up the library_tracks_view to make it easy to select tracks
that belong to a specific Library.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:31:33 -04:00
Anna Schumaker e39d128488 db: Give Years knowledge about their Tracks & Properties
I expand on the years_view to include additional playlist properties,
and configure the default sort order to stort by release date first.

I then set up the year_tracks_view to make it easy to select tracks that
belong to a specific year.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:31:33 -04:00
Anna Schumaker 99eb4abee3 db: Give Decades knowledge about their Tracks & Properties
I expand on the decades_view to include additional playlist properties,
and configure the default sort order to sort by year first.

I then set up the decade_tracks_view to make it easy to select tracks
that belong to a specific decade.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:31:33 -04:00
Anna Schumaker 0524085602 db: Give Genres knowledge about their Tracks & Properties
I expand on the genres_view to include additional playlist properties,
and configure the default sort order to sort by artist, album, and track
number.

I then configure the Genres table to use the system_tracks table to
manage each genre's associated tracks and set up the genre_tracks_view
to make it easy for Tracks to find their Genres.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:31:33 -04:00
Anna Schumaker 36ca0b9818 db: Give Media knowledge about their Tracks & Properties
I expand on the media_view to include additional playlist properties,
and configure the default sort order to sort by track number.

I then set up the medium_tracks_view to make it easy to select tracks
that belong to a specific medium.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:31:33 -04:00
Anna Schumaker 8e55de26d1 db: Give Albums knowledge about their Tracks & Properties
I expand on the albums_view to include additional playlist properties,
and configure the default sort order to sort by track numbers in an
intuitive way.

I then set up the album_tracks_view to make it easy to select tracks
that belong to a specific album.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:31:33 -04:00
Anna Schumaker b4d8a7cfaa db: Give Artists knowledge about their Tracks & Properties
I expand on the artists_view to include additional playlist properties,
and configure the default sort_order to sort albums in an intuitive way.

I then configure the Artists table to us the system_tracks table to
manage each artist's associated tracks and set up the artist_tracks_view
to make it easy for Tracks to find their Artists.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:31:33 -04:00
Anna Schumaker 6762916899 db: Give System Playlists knowledge about their Tracks
I need to do something slightly different for each Playlist.

* Collection: I load tracks from the collection_view, which filters
      tracks to those where the library is enabled but not deleting.
* Favorite Tracks: I load tracks from the favorites_view, which filters
      tracks based on the tracks.favorite and library.deleting column.
* Most Played Tracks: I load tracks with a playcount greater than the
      average playcount of all tracks (rounded up to the nearest integer).
* New Tracks: I load tracks that have been added within the last week.
* Previous Tracks: I load tracks that have been played since startup
      using the system_tracks table.  I take care to clear these entries
      in the table during startup.
* Queued Tracks: Load tracks from the user_tracks table.
* Unplayed Tracks: I load tracks with a playcount equal to 0 and remove
      when they are played.
* User-Defined Playlists: Load tracks from the track_playlist_link
      table.

Additionally, I implement move_track_up() and move_track_down() support
for user playlists and queued tracks.

Finally, I update the have-next-track property to take into account if
the Collection has tracks too.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:31:33 -04:00
Anna Schumaker 99496ca8bf db: Give System Playlists special property handling
I need to do something slightly different for each system Playlist:

* Collection: I disallow setting loop to "None".
* Favorite Tracks: I set the user-tracks property to "True"
* Most Played Tracks: I add playcount as the first sort field.
* Previous Tracks: I disallow changing loop, shuffle, and sort-order.
* Queued Tracks: I set the user-tracks and tracks-movable properties to "True"

User created playlists also set the user-tracks and tracks-movable
properties to "True".  I also disable autodelete on the Table so
playlists aren't deleted unexpectedly.

New Tracks and Unplayed Tracks have no special properties set.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:31:33 -04:00
Anna Schumaker 85c42216ab db: Give Playlists extra properties
* loop is based on the mpris loop property, and can be set to "None",
      "Track", or "Playlist".
* shuffle is a boolean True / False value.
* sort-order saves a user-configured sort order for each playlist.
* current-trackid is an integer referring to the trackid of the currently
      playing track.
* user-tracks: is a boolean representing if the user should be allowed
      to manually add and remove tracks.

I also use the sort-order property to implement a get_track_order()
function to get the sort keys for tracks in a Playlist.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:31:33 -04:00
Anna Schumaker 6eec4dbfc3 db: Add Track support to Playlist Tables
I implement add_track(), get_trackids(), and remove_track() functions that
either modify the 'system_playlist_tracks' table or call a virtual function
depending on the value of the 'system-tracks' property.

I also add the "autodelete" property. When set to True, Playlists will
be deleted when they have 0 tracks remaining.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:31:33 -04:00
Anna Schumaker 1b9458c278 db: Add Track support to Playlist Objects
Playlists use a tracks.TrackidSet to manage a set of trackids
representing the Tracks in this Playlist.

I have two functions for loading tracks: load_tracks() and
reload_tracks(). Calling load_tracks() checks if the tracks have been
loaded first before doing any work, but calling reload_tracks() will
force the Playlist to go to the database to load the latest tracks.

Finally, I add a have-next-track property to the main database
connection. This is set to True whenever the active playlist has one or
more tracks.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:31:33 -04:00
Anna Schumaker 11560d781e db: Add a TrackidSet
The TrackidSet is intened to be used by Playlists to keep track of which
Tracks have been added without much overhead.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:31:33 -04:00
Anna Schumaker ff835832c8 db: Save Track data when deleting
This includes the favorite status, playcount, last played timestamp, and
last started timestamp. These values will be restored if a Track with
the same mbid is created at a later time.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 17:31:32 -04:00
Anna Schumaker 8a16b4e05f db: Add favorite track accounting
This patch adds extra handling for changing the value of the
Track:favorite property, if the Track is the current track. This lets us
have the Now Playing card bind to the current-favorite property to
update the UI (and have the UI update the Track).

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 14:42:58 -04:00
Anna Schumaker dc8ccff311 db: Add current track accounting
Tracks now have start(), stop(), and restart() functions that can be
used by the application to update the laststarted, lastplayed,
playcount, active, and restarted properties.

The track Table implements their half of these functions in addition to
a mark_path_active() function so opening Emmental with a filepath can
update the current track before the database is loaded. The Table also
adjusts the necessary system playlists when tracks are marked as played.

Finally, the Table now has have-current-track and current-track
properties that can be wired up to the Now Playing card.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 14:42:58 -04:00
Anna Schumaker 08687882a3 db: Add a Track Table
The Track Table does all the work for saving, loading, and managing
Track objects. I also create a SQLite View to link tracks to their
associated artists, albums, and mediums.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-05-10 14:42:57 -04:00
Anna Schumaker 24cb87d298 db: Add a Track object
The Track object represents a single track in the Library along with all
their corresponding metadata.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 15:09:08 -04:00
Anna Schumaker afb599dcf4 audio: Add Track support to the tagger
I extract the artist, length, mbid, mtime, tracknumber, and title from
the tags to use when constructing Tracks.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 15:09:08 -04:00
Anna Schumaker 2c629c887c audio: Notify when playback is almost done
The application can use this to pre-load the next track for gapless
playback.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 15:09:08 -04:00
Anna Schumaker 4be92c7326 audio: Calculate the play time for the current track
I am going to use this to determine if a track has been played or not.
Gstreamer resets the clock when seeking, so I do some extra work to save
the play time just before seeking and add it back later.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 15:09:08 -04:00
Anna Schumaker 1c0712e673 sidebar: Add the Library section to the sidebar
And show the directory chooser if the application is started without any
library paths.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 15:08:10 -04:00
Anna Schumaker 0dcdcd4a68 sidebar: Create a Section for Libraries
This section shows a list of library path playlists. I also add an extra
widget that opens a Gtk.FileDialogso users can add music to their
collections.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 15:07:07 -04:00
Anna Schumaker 0de8089d59 sidebar: Add the Decade section to the sidebar
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 15:07:02 -04:00
Anna Schumaker 3ba46db064 sidebar: Create a Section for Decades
This section shows a tree of Decade and Year playlists. I use the
year-alt icon from the gnome icon-library as the section header.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 15:06:58 -04:00
Anna Schumaker f8494cf47b sidebar: Add the Genre section to the sidebar
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 15:06:53 -04:00
Anna Schumaker 0f2a5aee9d sidebar: Create a Section for Genres
This section uses the default Row for displaying genre playlists. I use
the theater icon from the gnome icon-library as the section header.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 15:06:49 -04:00
Anna Schumaker c396839316 sidebar: Add the Artist section to the sidebar
I make sure to save the "show-all" property to the settings so it can be
preserved across sessions.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 15:06:43 -04:00
Anna Schumaker 2f239bd94d sidebar: Create a Section for Artists
This section shows a tree of Artist and Album playlists. I use the
library-artists icon from the gnome icon-library as the section header,
and the music-artist / music-artist2 icons for the "show all" button to
indicate state..

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 15:05:18 -04:00
Anna Schumaker 711c04cb29 sidebar: Add the Playlist section to the Sidebar
I created a section Group with this section as the only member for now, and
bind the "selected-playlist" property to the sidebar.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 15:05:17 -04:00
Anna Schumaker d49b033b0d sidebar: Create a Section for Playlists
This section creates PlaylistRows for displaying user and system
playlists.  We also hook into the database playlist table to provide a
way to create new playlists. I add several new icons from the
icon-library to use for the section header and playlists.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 14:18:43 -04:00
Anna Schumaker eb0b005c75 sidebar: Create a Section Group
This group manages a list of sections so only one is active at a time.
Additionally, it adjusts the animation property of each section to match
the direction the header moves when activated.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 14:18:43 -04:00
Anna Schumaker b316184bf1 sidebar: Create a Section class
This class combines a header with an initially hidden ListView that can
be configured to list our playlists. It also implements
"playlist-activated" and "playlist-selected" signals to signal user
interaction.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 14:18:43 -04:00
Anna Schumaker 976f465cec sidebar: Create a TreeRow
The TreeRow is a factory.TreeRow configured for binding playlists to a
child widget. Individual sections are expected to inherit from this to
set up their section-specific widgets and bind any extra properties.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 14:18:43 -04:00
Anna Schumaker 70799fa50f sidebar: Create a LibraryRow
This is a preconfigured row for displaying library paths. It includes a
switch to enable & disable the path, buttons for removing and updating a
path, and a progress bar for displaying scan progress.

I use the "update-symbolic" and "stop-sign-large-symbolic" icons from
the gnome icon library for this widget.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 14:18:43 -04:00
Anna Schumaker dcd63015b8 sidebar: Create a PlaylistRow
This row is configured for showing user and system playlists. This means
we use a SettableIcon, and EditableTitle, and an extra button for
deleting playlists.  I use the big-x-symbolic icon from the gnome icon
library for deleting playlists.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 14:18:43 -04:00
Anna Schumaker 0adb0d472b sidebar: Create a Row widget
This is a basic Row widget with an Icon as a prefix widget, and no
postfix widget. You can set the "year" property to set a year value to
the icon text.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 14:18:43 -04:00
Anna Schumaker bf4fa68991 sidebar: Create a BaseRow widget
This is intended to be used as a base class for our playlist Row
widgets, and sets up some common variables needed by both.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 14:18:43 -04:00
Anna Schumaker b25ca24dc3 sidebar: Create a Header class
This will be used to display different types of playlists in the
sidebar, such as artist or genre. It also has a revealer that shows its
child when the header is active.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 14:18:43 -04:00
Anna Schumaker 2542a6cbd7 sidebar: Create a Settable Icon widget
This widget allows users to change the displayed image by selecting a
new one from the filesystem.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 14:18:43 -04:00
Anna Schumaker 0584a2398a sidebar: Create an Icon widget
This is mostly a wrapper around an Adw.Avatar to make it easier to load
images from a file.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 14:18:43 -04:00
Anna Schumaker 673c6910e9 sidebar: Create an EditableTitle widget
This widget gives users the ability to change the title of the displayed
playlist.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 14:18:43 -04:00
Anna Schumaker cdae9541e9 sidebar: Create a PlaylistTitle widget
This widget sets the Title::subtitle property to a nicely formatted
string based on the number set to the 'count' property.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 14:18:43 -04:00
Anna Schumaker cd4caf7df8 sidebar: Create row Title widgets
This widget displays a title and subtitle line, with some added styling.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 14:18:43 -04:00
Anna Schumaker 710f3fba80 factory: Give ListRows an "active" property
And style them so the row is highlighted and the text is bold.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 14:18:43 -04:00
Anna Schumaker 9d7763a730 factory: Create a TreeRow
The TreeRow is used to display rows from a Gtk.TreeListModel. I adjust
the TreeRow "item" and "child" properties so they still access the
underlying item or child widget instead of the Gtk.TreeRow or
Gtk.TreeExpander.

I also give the TreeRow widget "n-children" and "have-children" properties
which are used to dynamically show or hide the expander arrow when there
aren't any children.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 14:18:43 -04:00
Anna Schumaker 40c463da81 sidebar: Create a basic Sidebar widget
It only contains a FilterEntry for filtering future playlists. The
application will save its size when resized.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 14:18:43 -04:00
Anna Schumaker 5545cb106d db: Give Libraries an idle Tagger
This uses a combination of an Idle Queue, ReaddirThread, and tagger
Thread to scan a directory path and tag the audio files found within. I
do this by adding a scan() function to each Library object to begin
scanning (if not already running).

Libraries also have a stop() function to cancel any pending idle tasks
and stop any running threads. The Library table makes sure to stop each
Library object during shutdown so we don't leave any hanging threads.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 14:18:17 -04:00
Anna Schumaker 6131640e25 db: Create a tagger Thread
This Thread uses the audio.tagger.tag_file() function to find the tags
for a specific file without hanging the UI. There may be cases where we
have an Artist MBID but not the matching Artist name. When this happens,
I do my best to first check the database and then query the musicbrainz
server.

I take some care to only connect to the database once, and to close the
connection when the thread exits.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 14:18:16 -04:00
Anna Schumaker 300ee18569 db: Add a Tagger tool
This tool wraps around a mutagen.File to read tags and translate them
into our database playlists.

Implements: #41 ("Check for new or modified tags during startup")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:44:34 -04:00
Anna Schumaker 24c1a31367 db: Add a Library Table
This table allows us to work with Library playlists that are represented
by a filesystem path. The user can manually enable or disable library
paths to prevent their tracks from showing up in the Collection
playlist. Additionally, library paths have an online property to
determine if the library still exists in the filesystem to prevent us
from removing tracks due to a broken NFS mount or symlink.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:44:34 -04:00
Anna Schumaker b7f1a05967 db: Give Decades knowledge about their Years
Similar to the Artists tree structure. I create a filter on the Year
table for each Decade object and adjust filtering so a Decade remains
visible if one or more years match the query.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:44:34 -04:00
Anna Schumaker c0edbd9bff db: Add a Year Table
This table allows us to work with Year playlists that are represented
only by the year.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:44:34 -04:00
Anna Schumaker 0cf5f80eb4 db: Add a Decade Table
This table allows us to work with Decade playlists that can be created
or looked up by an individual year in that decade. I also add a few
custom functions to SQLite to make working with decades easier.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:44:34 -04:00
Anna Schumaker 2dc5d9ed0a db: Add a Genre Table
This table allows us to work with Genre playlists.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:44:34 -04:00
Anna Schumaker aeeee1417a db: Give Artists knowledge about their Media
I also adjust how filtering Artists works so an Artist remains visible
if one of its Media matches the query.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:44:34 -04:00
Anna Schumaker 87606f8fac db: Give Albums knowledge about their Media
We create a filter on the Media table for each Album object, but
only match Media that have a name set. I also adjust filtering to
display Albums that have a matching Medium.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:44:34 -04:00
Anna Schumaker 51a13a8a04 db: Add a Media Table
This table represents an individual medium in an album (such as a single
CD). Each medium has an associated album, number, type, and (optional)
title. This means we can have multiple media for a given album as long
as they each have a different number or type.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:44:34 -04:00
Anna Schumaker 1730b7e92c db: Create a link between Artists and Albums
I use a sql link table to accomplish this so a single album can be added
to multiple album-artists. Additionally, I set up a view on Artists and
Albums to make filtering easier without needing to use a complicated
join every time.

Additionally, I use the Playlist.add_children() function to set up a
filter on the Album list model for each Artist's albums.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:44:34 -04:00
Anna Schumaker 1b38c4d6ec db: Add an Album Table
This table allows us to work with Album playlists that have a name,
album artist, release date, (optional) mbid, and (optional) cover.
Note that we can insert multiple albums with the same name as long as
their mbid, artist, or release date is different.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:44:34 -04:00
Anna Schumaker d3bdaaa063 db: Add an Artist Table
This table allows us to work with Artist playlists that have a name and
(optional) mbid. Note that we can insert multiple artists with the same
name into the database as long as they have different mbids.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:44:34 -04:00
Anna Schumaker d57509425b db: Add a Playlists Table
This inherits from our base playlist Table class, and implements
functions for creating and renaming playlists. Additionally, the
Playlist object allows for setting a custom image to display as an icon
in the sidebar.

Finally, I add in a custom sqlite3 adapter and converter to support
pathlib.Path types in the database.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:44:34 -04:00
Anna Schumaker 8a9c90a7ff db: Add a base class for Playlist Tables
This is an implementation of an emmental.db.table.Table that adds
support for creating Playlists, updating playlist properties, sorting
based on playlist name, and displaying playlists with their child
playlists in a tree structure.

Additionally, I create a playlist_properties table in the database to store
properties that all playlists have.

Implements: #17 ("Save currently selected playlist")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:44:34 -04:00
Anna Schumaker d22b3c0ce2 db: Add a base class for Playlist Objects
This object inherits from the table.Row base class. It adds in
properties for name, active state, and propertyid and a rename()
function for updating the Table sort order during a rename.

Additionally, child playlists can be enabled by calling add_children().
This will set up a Gtk.FilterListModel using the provide child Table and
Filter.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:44:34 -04:00
Anna Schumaker 9944d07bba db: Give Tables an idle Queue
Tables use the idle queue to load their data or filter rows in the
background. Tables will create a new queue by default, but can accept a
pre-constructed queue through the queue= parameter.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:44:27 -04:00
Anna Schumaker e502a7e8cb db: Add an idle Queue
This is intended to be used as a base class for other task-specific
Queues, and runs each task under a single transaction. Queue
implementations should override the do_run_task() function for their
implementation-specific work.

The push_many() function can be used to efficiently add several tasks to
the Queue at the same time.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:42:14 -04:00
Anna Schumaker 1832a56786 audio: Implement a file tagger
This class reads the tags in an audio file and parses them into a format
we can use later to build our database playlist objects.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:42:14 -04:00
Anna Schumaker ad3d4840e8 path: Add a readdir_async() function
This function creates a ReaddirThread object to read the directory in
a secondary thread. This will let us poll for results without hanging the
UI in the case where a music library is on very slow storage (such as
NFS).

The ReaddirThread will accumulate a list of files that can be polled
using the poll_result() function, which will return the files found since
the last call to poll_result().

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:42:14 -04:00
Anna Schumaker ec5c4ddd2c format: Add a function for formatting sort keys
This function casefolds the input string and makes a series of
substitutions before splitting the string into a tuple of strings that
can be compared against.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:42:14 -04:00
Anna Schumaker 6cade5d779 nowplaying: Add a Jump button
This button will be used to scroll the displayed playlist to the
currently playing track.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker 00f6ee9238 nowplaying: Add a Favorite button
This button is an ImageToggle configured to show a filled-in heart when
active and an outline when inactive. I added some icons from the Gnome
icon-library to represent the different states.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker 011dbd114b buttons: Create an ImageToggle button
This button is inspired by the Gtk.ToggleButton button, except it
changes the displayed icon when active.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker f85cdb5b49 nowplaying: Add an Artwork widget to the Now Playing card
Implements: #45 (Create a new NowPlaying widget)
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker b3d04805d7 nowplaying: Add an Album Art class
This displays the current track's album art with a fancy frame drawn
around it. Clicking the image opens a popover showing the artwork at
full size.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker ee8db58fb2 audio: Add an 'artwork' property
And use it to track the existence of artwork for the current file. This
could either be a cover.jpg file in the same directory as the currently
playing track or embedded artwork found in the Gst.TagList.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker 7ff1a3d60c emmental: Add support for an application-specific temporary directory
And include a function to help extract embedded cover.jpg files.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker 000dbd7018 nowplaying: Add a Seeker to the Now Playing card
And wire it up to the Player through the Application.

Implements: #45 (Create a new NowPlaying widget)
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker 2ff03bba18 nowplaying: Create a Seeker
This is a Gtk.Scale configured to be used to display track progress and
seek inside the track.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker e0becbb059 audio: Add a 'position' property to the player
And schedule a repeating callback to update the UI.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker a5db116d42 audio: Add a 'duration' property to the player
And make sure we are able to watch for changes when tracks are loaded. I
also export it as mpris' mpris:length metadata field.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker 2ed34d3465 nowplaying: Add Controls to the Now Playing card
And wire up signals between the Now Playing card and the player.

Implements: #45 (Create a new NowPlaying widget)
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker 3157c53423 nowplaying: Add Control buttons
Complete with signals so we'll know when the user wants us to do
something. I also clear the autopause property when the user manually
pauses the player. I use large versions of the play and pause icons from
the Gnome Icon Library for the buttons.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker bf8d7fac1b nowplaying: Create an Autopause Button
This is a PopoverButton that has an autopause.Entry set as the child. I
also override the displayed icon to show the current autopause count.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker 7cd77d3aed nowplaying: Create an Autopause Entry
This entry is inspired by the Gtk.SpinButton, but lets us set
placeholder text to display the current autopause value to the user.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker 69c59438c2 buttons: Create a SplitButton
This is inspired by the Adw.SplitButton, except it allows for
configuring the secondary button so we can show the current autopause
count.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker b3c2dd25fb buttons: Add a generic Button class
This Button is like a Gtk.Button, except it provides ways to set the
icon-size. I also default to large buttons, since that'll be a good
portion of the users.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker 8ec5239acc audio: Add play(), pause(), play_pause(), and stop() functions
These functions are set up to take an unused argument list so they can
be connected to signals directly. I also add a 'playing' property to
track the current state of the playbin and a 'status' property to
translate 'playing' into something mpris understands.

Implements: #7 (Implement MPRIS2 Support)
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker 4bced82b1f nowplaying: Add a TagInfo window to the Now Playing card
And bind the Player tag properties to the Now Playing card.

Implements: #45 (Create a new NowPlaying widget)
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker dbc2ec03f2 nowplaying: Add a widget for displaying the current track's tags
And expose properties for setting their values.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker c434f6672e nowplaying: Add the PreferArtistMenu to the ArtistLabel
This menu is used to select between showing the artist or album artist
tag.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker 997b1de012 nowplaying: Create a PreferArtistMenu
For selecting between showing the artist or album-artist tag.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker 81fdfe66cb nowplaying: Create an ArtistLabel
This label has properties for both Artist and Album Artist, and chooses
which to display based on the prefer-artist property and which tags have
been set.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker c899c15c42 nowplaying: Create a custom Label for displaying tags
This Label supports setting a prefix that is applied to the displayed
string and setting a font size in pixels.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker 318b2564ce audio: Add properties for track tags
I set these properties when the bus sends us tag messages, and wire them
up do the mpris2.Player object to notify dbus of their values.

These properties are cleared on both EOS and when a new file is started.
This is to account for the user changing the file mid-playback.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker 35d0d815ca audio: Load a track requested by the user
Either through the command line, mpris2, or the open button in the
header.

Implements: #7 (Add MPRIS2 Support)
Implements: #47 (Signal that the track has changed when it actually changes)
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker 51096104ce nowplaying: Add a basic Now Playing widget
It doesn't have any children yet, the application will save its size
when the user resizes it.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:58 -04:00
Anna Schumaker 88e4fa4b0c emmental: Add a Player instance to the application
And wire it up to the Header and Mpris.Player so we can apply volume &
replaygain changes as they happen.

Implements: #42 ("Remove global audio.Player instance")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker d105b15e02 mpris2: Add a Player object
This begins to implement the MediaPlayer2.Player interface. The
properties and signals are there, and I expect to fully implement them
as Emmental development goes on.

Implements: #7 ("Add MPRIS2 Support")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker 93dc476706 audio: Add ReplayGain support to the Gst.Playbin
And add functions for setting and quering the state.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker d134b303ab audio/replaygain: Add a ReplayGain filter
With options for album mode, track mode, and completely disabled.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker 47bc858630 audio: Create an audio player GObject
Right now it only supports the volume property, but will be expanded on
as we go forward.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker 5d0d522e3c header/open: Create an Open button
This button will be used by the user to open and play a track from the
filesystem.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker bb1d01f951 header: Add ReplayGain selector to the Header
And modify the Application to store ReplayGain settings in the database.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker 3f28799437 header/replaygain: Add widgets for selecting ReplayGain mode
This includes a set of Gtk.CheckButtons to choose what ReplayGain mode
to use, and a Gtk.Switch to enable or disable ReplayGain

Implements: #46 ("Create new Volume controls")
Implements: #48 ("Implement Intelligent ReplayGain")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker 081cae4cd8 header: Add new volume control widgets to the Header class
They live in a Gtk.MenuButton with a custom popover box that can have
replaygain options added to it. I also modify the Application to save
the volume when it is changed.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker 847c15f64b header/volume: Create new volume control widgets
I can't use a Gtk.VolumeButton because I want to add ReplayGain controls
under the popover menu as well.

Implements: #46 ("Create new Volume controls")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker 4cd1e89493 header/dialog: Create a Settings Dialog
This dialog is used to manually edit the settings in the database. I
bind the properties in such a way that changes are seen instantly.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker 50270bd04c header: Add a custom Header class
The header currently contains just the title & subtitle information, but
will be expanded to add volume controls in the next patch.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker 236a1e60c2 factory: Create a LabelRow and LabelFactory
The LabelRow is an implementation of the ListRow for the common case of
displaying text to the user. It has some convenience properties for
setting the xalign property and adding the "numeric" class to the Gtk.Label.

The LabelFactory creates LabelRows.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker 711fa0da5b factory: Create ListRow and Factory classes
The ListRow class is intended to be used as a base class for displaying
individual Gtk.ListView rows. The implement some helpful functionality
to make it easier to bind list items to child widgets.

The Factory class is designed to create ListRow widgets.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker 5d3fb980af entry: Create entries for displaying specific types of values
I implement Integer, Float, and String entries that update their "value"
property based on the user-provided text when the Enter key is pressed.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker 47d5f0c0c6 entry: Create an entry for Filtering
This entry provides some helper functionality around a Gtk.SearchEntry
to make filtering lists easier.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker 3cf730a5cc format: Add a function for formatting search strings
This takes the input string, casefolds it, and then adds some extra glob
operators to it so we can do a case insensitive substring search by
default.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker 6ede296ba6 buttons: Create a PopoverButton
This is a MenuButton that already has a popover attached and a property
for setting the popover child directly.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker a73063a04c emmental: Add our Window to the application
Bind the width and height properties to the settings so they are
restored on startup and bind the fullscreen property to mpris.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker 5d1c11e64e window: Add a custom Window class
This window is set up with specific areas for our header, sidebar, now
playing info, and tracklist. It also implements a post_toast() function
so toast notifications can be displayed to the user.

Implements: #44 ("Create a new 3WayPane widget")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker 767f0c1584 mpris2: Add an Mpris2 DBus Connection
And implement the MediaPlayer2 interface on top of it.

Implements: #7 ("Add MPRIS2 Support")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker b1cd1706ed db/settings: Create a Settings table
This creates a new class to dynamically create GObject Properties, save
them to the database, and make it easy to bind application properties to
specific settings properties.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:42 -04:00
Anna Schumaker 788ca374a8 db: Create a Table base class
This is a Gtk.FilterListModel containing a store.SortedList to store
individual rows in sorted order. I also implemented some convenience
functions to make it easier to add, remove, look up, and filter rows.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 10:41:41 -04:00
Anna Schumaker 651f24672b db: Create a database row Filter
This filter takes a set of primary keys for rows that should be visible
during filtering. Passing None as a value means that all rows are shown.
It also has functions for adding or removing individual rows from the
filter.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 09:26:27 -04:00
Anna Schumaker 2eef68f76f db: Create a database Row base class
This will be shared between settings, playlists, and tracks so we have a
common interface for working with database rows.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 09:26:26 -04:00
Anna Schumaker 8c8135fc23 db: Create a full-featured Connection manager
This inherits from the base Connection manager and adds in reading our
sql script to set up the database. It will also eventually hold pointers
to table objects that we can access directly.

Finally, I add a db property to the Application instance. The db is
connected during the ::startup signal handler, and disconnected during
the ::shutdown signal handler.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 09:26:25 -04:00
Anna Schumaker deb4f3d252 db: Create a base Connection manager
This is a wrapper around the sqlite3.Connection objct that adds some
nice functionality to make working with SQL easier.

I defined the following magic methods:
  * __enter__() to manually begin a transaction
  * __exit__() to commit or rollback a manual transaction
  * __call__() to execute a SQL statement with either positional or
    keyword arguments.

Additionally:
  * I define a "CASEFOLD" function that can be used in queries
    to lowercase unicode text when searching.
  * I set foreign_keys = ON so foreign keys checking is always enabled
  * I provide an executemany() function for running running the same
    statement multiple times with different arguments.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-04-12 09:23:14 -04:00
Anna Schumaker 61fc252172 store: Add a SortedList store
This ListStore implementation uses a key function to keep the items
sorted at all times.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-03-07 11:19:30 -05:00
Anna Schumaker 482a199731 store: Add a Python-based ListStore
This is implemented as an alternative to the Gio.ListStore that uses a
Python list object to hold items.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-03-07 11:19:29 -05:00
Anna Schumaker 4be26c5fee gsetup: Load a Gio Resource with our application icons
We can put all our icons into a single resource bundle that gets loaded
and only exists for our app.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-03-07 11:19:29 -05:00
Anna Schumaker 5cd5d2640d gsetup: Load our application CSS file
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-03-07 11:19:27 -05:00
Anna Schumaker 4072ea97d4 emmental3.py: Start Emmental 3.0
I'm going to put all of the main source code into a single subdirectory
under emmental/ and separate out tests into a separate tests/ directory.

Additionally, I have switched over to pytest for running tests to give
me better output (with color!)

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2023-03-07 11:18:46 -05:00
Anna Schumaker 08ea7342dc Emmental 2.10 AUR Commit
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2022-02-25 13:34:26 -05:00
Anna Schumaker 18743f05c4 Emmental 2.10
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2022-02-25 13:32:32 -05:00
Anna Schumaker ab6eb556ad db: Fix track_adjusts_current() when the track has been removed
In this case, the call to get_track_index() returns None which can't be
used for the comparisons we're doing. Make sure we handle the None
result explicitely.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2022-02-22 10:08:34 -05:00
Anna Schumaker 1296857189 playlist: Don't leave the Subtitle column blank
Instead, fill in something generated from the disc number but make it
dimmed.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2022-01-10 11:36:42 -05:00
Anna Schumaker 73ba296d74 playlist: Use the new match / case statement in __init__.py:key_pressed()
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2021-12-28 17:33:26 -05:00
Anna Schumaker f9cec5e1b3 audio: Use the new match / case statement in scale.py:format_value()
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2021-12-28 17:24:09 -05:00
Anna Schumaker 0c7a4a4a4c db: Use the new match / case statement in user.py:do_factory()
This is cleaner than using a bunch of elif-s to pick the right playlist
type

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2021-12-28 17:23:57 -05:00
Anna Schumaker 289420e504 playlist: Add a Favorite toggle button
For adding / removing the currently playing track from the Favorites
playlist

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2021-12-28 15:50:11 -05:00
Anna Schumaker 15059db59a playlist: Create a TrackBox containing the JumpButton
And put it before the filter entry in the ui

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2021-12-28 15:50:11 -05:00
Anna Schumaker c4adea15bb playlist: Split out the ControlBox as a base class
And create a PlaylistBox inheriting from it containing the buttons.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2021-12-28 15:50:11 -05:00
Anna Schumaker 328dce0be2 scanner: Fix scanner.update_library() function
We were scheduling the CheckTask, but not following up with a
DirectoryTask to scan for new files. I use this function during the
Gtk.Application startup to automatically update the libraries.

Implements #31 (Automatically update the database during startup)
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2021-12-28 15:50:11 -05:00
Anna Schumaker 295202443f scanner: Add a function for clearing the TaskQueue
And call this function when closing the player

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2021-12-28 15:50:08 -05:00
Anna Schumaker b245b2073e scanner: Rename tests
To remove a bunch of redundant names from the test functions

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2021-12-28 15:49:52 -05:00
Anna Schumaker 7b89f54e8b scanner: Remove the ImportTask
It is no longer needed now that we have updated from the tagdb

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2021-12-28 15:49:52 -05:00
Anna Schumaker b768d74928 sidebar: Don't change displayed playlist to None
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2021-12-28 15:49:52 -05:00
Anna Schumaker beca08b833 Implement the Gtk.Application instance in emmental.py
We don't use this during testing, so put it here instead of in a
submodule.

Implements #26 (Move the EmmentalApplication into emmental.py)
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2021-12-28 15:49:50 -05:00
Anna Schumaker db2d122211 lib: Replace version.DEBUG with the __debug__ constant
Implements #27 (Check __debug__ constant instead of a .debug file)
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2021-12-26 13:18:24 -05:00
Anna Schumaker 10c5fd4cef lib: set version.TESTING based on if the unittest module is loaded
This is cleaner than needing to set an environment variable before
running unit tests.

Implements #28 (Check if unittest is loaded to determine if we are testing)
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2021-12-26 13:05:08 -05:00
Anna Schumaker 2daefa932c lib: Remove unused files
These are no longer needed now that tagdb has been removed

Implements #24 (Clean up lib/)
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2021-12-26 10:18:09 -05:00
Anna Schumaker 915e3c8340 Remove unused tagdb module
Implements #23 (Remove tagdb/ code)
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2021-12-26 10:08:33 -05:00
Anna Schumaker 04dc67a097 Emmental 2.9 AUR Commit
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
2021-12-21 15:48:06 -05:00
351 changed files with 27553 additions and 12228 deletions

4
.gitignore vendored
View File

@ -3,4 +3,8 @@
*.coverage
*.ui~
*.txt
*.patch
*.tar.gz
PKGBUILD
emmental.gresource*
emmental/mpris2/*.xml

3
.gitmodules vendored
View File

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

View File

@ -5,36 +5,90 @@ export PREFIX = /usr/local
export EMMENTAL_LIB = ${PREFIX}/lib/emmental
export EMMENTAL_BIN = ${PREFIX}/bin
export EMMENTAL_SHARE = ${PREFIX}/share
export EMMENTAL_DESKTOP = ${EMMENTAL_SHARE}/applications/com.nowheycreamery.emmental.desktop
export EMMENTAL_MAJOR = $(shell grep \^MAJOR lib/version.py | awk -F= '{ gsub(/ /,""); print $$2}')
export EMMENTAL_MINOR = $(shell grep \^MINOR lib/version.py | awk -F= '{ gsub(/ /,""); print $$2}')
export EMMENTAL_TARGZ = https://git.nowheycreamery.com/anna/emmental/archive/emmental-${EMMENTAL_MAJOR}.${EMMENTAL_MINOR}.tar.gz
export EMMENTAL_CSUM = $(shell curl -s ${EMMENTAL_TARGZ} | sha256sum | awk '{print $$1}')
all: emmental.gresource mpris2 flake8
clean:
find . -type f -name "*gresource*" -exec rm {} \+
find . -type d -name __pycache__ -exec rm -r {} \+
find data/ -type d -name "Test Album" -exec rm -r {} \+
find data/ -type d -name "Test Library" -exec rm -r {} \+
find emmental/mpris2/ -type f -name "*.xml" -exec rm {} \+
.PHONY:flake8
flake8:
flake8 emmental/ tests/
mpris-spec/Makefile:
git submodule init mpris-spec
git submodule update
emmental/mpris2/MediaPlayer2.xml: mpris-spec/Makefile
cp mpris-spec/spec/org.mpris.MediaPlayer2.xml emmental/mpris2/MediaPlayer2.xml
emmental/mpris2/Player.xml: mpris-spec/Makefile
cp mpris-spec/spec/org.mpris.MediaPlayer2.Player.xml emmental/mpris2/Player.xml
.PHONY: mpris2
mpris2: emmental/mpris2/MediaPlayer2.xml emmental/mpris2/Player.xml
.PHONY: emmental.gresource.xml
emmental.gresource.xml:
exec tools/find-resources.py
.PHONY: emmental.gresource
emmental.gresource: emmental.gresource.xml
glib-compile-resources emmental.gresource.xml
.PHONY: install.app
install.app:
find ./emmental -type f -not -path "*/__pycache__/*" \
-exec install -v -C -D -m 755 "{}" "$(EMMENTAL_LIB)/{}" \;
install -C -v -m 644 emmental.py $(EMMENTAL_LIB)/emmental.py
.PHONY: install.icons
install.icons:
install -C -v -m 644 emmental.gresource $(EMMENTAL_LIB)/emmental.gresource
install -C -v -m 644 icons/scalable/apps/emmental.svg $(EMMENTAL_LIB)/emmental.svg
.PHONY: install.desktop
install.desktop:
desktop-file-install --set-key=Exec --set-value $(EMMENTAL_BIN)/emmental \
--set-key=Icon --set-value=$(EMMENTAL_LIB)/emmental.svg \
--rebuild-mime-info-cache \
--dir=$(EMMENTAL_SHARE)/applications com.nowheycreamery.emmental.desktop
.PHONY: install
install:
exec tools/install.sh
install: emmental.gresource mpris2 install.app install.icons install.desktop
mkdir -p $(EMMENTAL_BIN)
echo -e "#!/bin/bash\npython -O $(EMMENTAL_LIB)/emmental.py \$$*" > $(EMMENTAL_BIN)/emmental
chmod 655 $(EMMENTAL_BIN)/emmental
.PHONY: uninstall
uninstall:
rm -fv ${EMMENTAL_BIN}/emmental
rm -rfv ${EMMENTAL_LIB}
rm -fv ${EMMENTAL_SHARE}/icons/hicolor/scalable/apps/emmental*.svg
rm -fv ${EMMENTAL_SHARE}/applications/emmental.desktop
rm -f ${EMMENTAL_SHARE}/applications/com.nowheycreamery.emmental.desktop
rm -f ${EMMENTAL_BIN}/emmental
rm -rf ${EMMENTAL_LIB}/emmental/
.PHONY: pkgbuild.pkgver
pkgbuild.pkgver:
$(eval MAJOR := $(shell grep \^MAJOR_VERSION emmental/__init__.py | awk -F= '{ gsub(/ /,""); print $$2}'))
$(eval MINOR := $(shell grep \^MINOR_VERSION emmental/__init__.py | awk -F= '{ gsub(/ /,""); print $$2}'))
$(eval MICRO := $(shell grep \^MICRO_VERSION emmental/__init__.py | awk -F= '{ gsub(/ /,""); print $$2}'))
sed -i 's/^pkgver=.*/pkgver=${MAJOR}.${MINOR}.${MICRO}/' aur/PKGBUILD
.PHONY: pkgbuild.sha256sum
pkgbuild.sha256sum:
$(eval TAG := $(shell git describe --abbrev=0))
$(eval CHECKSUM := $(shell git archive --prefix=emmental/ --format tar.gz $(TAG) | sha256sum | awk '{print $$1}'))
sed -i 's/^sha256sums=.*/sha256sums=(${CHECKSUM})/' aur/PKGBUILD
.PHONY: pkgbuild
pkgbuild:
cp data/PKGBUILD aur/
sed -i 's|{MAJOR}.{MINOR}|${EMMENTAL_MAJOR}.${EMMENTAL_MINOR}|' aur/PKGBUILD
sed -i 's|{SHA256SUM}|${EMMENTAL_CSUM}|' aur/PKGBUILD
pkgbuild: pkgbuild.pkgver pkgbuild.sha256sum
cd aur && makepkg --printsrcinfo > .SRCINFO
.PHONY: pytest
pytest: emmental.gresource mpris2
pytest
.PHONY: tests
tests:
python tools/generate_tracks.py
EMMENTAL_TESTING=1 python -m unittest discover -v
tests: pytest flake8

View File

@ -1,3 +1,66 @@
# emmental
# Emmental
Emmental is a music player built using Python, GStreamer, and GTK.
It tries to make it really easy to listen to your music, the default
"Collection" playlist contains all your music files and is a fallback when
other playlists run out of tracks.
A new music player built around Python and GTK
## Features
* MPRIS2
* ReplayGain
* Gapless playback
* Background listening mode
* Automatically pause after a user-configured number of tracks
* Playlist creation and management
* Automatic playlists based on Artists, Albums, Genres, Decades, and Years
* Multiple library path support
* Plays all audio formats supported by GStreamer
* Renamed and updated tracks detection (using MusicBrainzIDs)
## Dependencies
* Python3
* dateutil
* gobject
* liblistenbrainz
* musicbrainzngs
* mutagen
* pyxdg
* GStreamer
* GStreamer good plugins (optional)
* GStreamer bad plugins (optional)
* GStreamer ugly plugins (optional)
* GTK4
* xdg-user-dirs-gtk
* Libadwaita
## Installing
Running `make install` will install Emmental to `/usr/local` by default.
This can be changed during install:
```
PREFIX=/usr make install
```
ArchLinux users can also install Emmental though the
[AUR](https://aur.archlinux.org/packages/emmental)
## Q & A
### 1. What's with the name? Why 'emmental'?
Emmental was the cheese used in a
[late-2018 experiment](https://www.smithsonianmag.com/smart-news/hip-hop-and-mozart-improve-flavor-swiss-cheese-180971721/)
to learn if playing music while a cheese ages has an impact on flavor
(spoiler alert: it did). I have a habit of naming projects and computers after
cheeses, so when I started this project I named it after the cheese used in
this experiment.
### 2. How do I edit my tracks?
Emmental doesn't have a built-in tag editor but it can detect when track
files have been edited in an external program. I highly recommend using a
dedicated tag editing program like
[MusicBrainz Picard](https://picard.musicbrainz.org/), it does a much better
job at tag editing than I ever will.
### 3. What is the ReplayGain "Decide automatically" option?
ReplayGain has two operating modes, "track" and "album", that the user can
select between. Emmental builds on this and can automatically choose between
the two based on the source playlist for a given track. This means if the
current track comes from an Album playlist, ReplayGain will use "album mode"
and if it comes from other playlists it will use "track mode".

View File

@ -1,26 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import tagdb
from gi.repository import Gtk
from . import artwork
from . import controls
from . import nowplaying
from . import player
from . import scale
Player = player.Player()
def Artwork(): return artwork.Artwork(Player)
class Header(Gtk.HeaderBar):
def __init__(self):
Gtk.HeaderBar.__init__(self)
self.pack_start(controls.AudioControls(Player, Player.Autopause))
self.pack_end(scale.ScaleButtonBox(scale.SeekScale(Player)))
self.set_title_widget(nowplaying.NowPlaying(Player))
def play_track(track):
if track == Player.track:
return False
Player.play_track(track)
return True

View File

@ -1,46 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
from gi.repository import Gtk, GdkPixbuf, Gst
class Artwork(Gtk.AspectFrame):
def __init__(self, player):
Gtk.AspectFrame.__init__(self)
self.picture = Gtk.Picture()
self.frame = Gtk.Frame()
self.frame.set_child(self.picture)
self.set_child(self.frame)
self.set_obey_child(False)
self.set_margin_start(5)
self.set_margin_end(5)
self.set_margin_top(5)
self.set_margin_bottom(5)
self.set_ratio(1.0)
self.player = player
self.player.connect("artwork", self.on_artwork)
self.player.connect("track-changed", self.on_track_changed)
self.on_track_changed(player, None, player.track)
def __set_from_cover_jpg__(self, track):
cover = track.path.parent / "cover.jpg"
if cover.exists():
self.picture.set_filename(str(cover))
return True
return False
def on_artwork(self, player, sample):
buffer = sample.get_buffer()
(res, map) = buffer.map(Gst.MapFlags.READ)
if res:
loader = GdkPixbuf.PixbufLoader()
loader.write(map.data)
self.picture.set_pixbuf(loader.get_pixbuf())
loader.close()
buffer.unmap(map)
def on_track_changed(self, player, prev, new):
if not (new and self.__set_from_cover_jpg__(new)):
display = self.picture.get_display()
theme = Gtk.IconTheme.get_for_display(display)
icon = theme.lookup_icon("emmental", [ ], 1024, 1, 0, 0)
self.picture.set_file(icon.get_file())

View File

@ -1,163 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import gi
gi.require_version("Gst", "1.0")
import lib
import sys
from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gst
Gst.init(sys.argv)
from . import replaygain
TIMEOUT = 100
class BassPlayer(GObject.GObject):
def __init__(self):
GObject.GObject.__init__(self)
lib.settings.initialize("audio.replaygain", "disabled")
lib.settings.initialize("audio.volume", 1.0)
self.audio = replaygain.ReplayGainSink()
self.video = Gst.ElementFactory.make("fakesink")
self.playbin = Gst.ElementFactory.make("playbin")
self.playbin.set_property("audio-sink", self.audio)
self.playbin.set_property("video-sink", self.video)
self.playbin.set_property("volume", lib.settings.get_float("audio.volume"))
self.playbin.set_state(Gst.State.READY)
self.set_property("replaygain", lib.settings.get("audio.replaygain"))
self.bus.add_signal_watch()
self.bus.connect("message::eos", self.__eos__)
self.bus.connect("message::state-changed", self.state_changed)
self.bus.connect("message::stream-start", self.stream_start)
self.bus.connect("message::state-changed", self.state_changed)
self.bus.connect("message::tag", self.tag)
self.timeout = None
def __eos__(self, bus, message):
self.emit("eos")
@GObject.Property
def bus(self):
return self.playbin.get_bus()
@GObject.Property
def duration(self):
(res, dur) = self.playbin.query_duration(Gst.Format.TIME)
return dur if res == True else 0
@GObject.Property
def playing(self):
(ret, state, pending) = self.playbin.get_state(Gst.CLOCK_TIME_NONE)
return state == Gst.State.PLAYING
@playing.setter
def playing(self, playing):
state = Gst.State.PLAYING if playing else Gst.State.PAUSED
self.playbin.set_state(state)
@GObject.Property
def play_percent(self):
if self.playbin.clock == None or self.duration == 0:
return 0
runtime = self.playbin.clock.get_time() - self.playbin.base_time
return runtime / self.duration
@GObject.Property
def position(self):
(res, pos) = self.playbin.query_position(Gst.Format.TIME)
return pos if res == True else 0
@position.setter
def position(self, pos):
self.playbin.seek_simple(Gst.Format.TIME,
Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT,
pos)
@GObject.Property
def replaygain(self):
return self.audio.get_property("mode")
@replaygain.setter
def replaygain(self, mode):
lib.settings.set("audio.replaygain", mode)
self.audio.set_property("mode", mode)
@GObject.Property
def uri(self):
return self.playbin.get_property("uri")
@uri.setter
def uri(self, uri):
if uri:
self.playbin.set_property("uri", uri)
else:
self.playbin.set_state(Gst.State.READY)
@GObject.Property
def volume(self):
return self.playbin.get_property("volume")
@volume.setter
def volume(self, vol):
self.playbin.set_property("volume", vol)
lib.settings.set("audio.volume", vol)
def state_changed(self, bus, message):
if message.src == self.playbin:
(old, new, pending) = message.parse_state_changed()
if new == Gst.State.PLAYING:
self.emit("playback-start")
else:
self.emit("playback-paused")
def stream_start(self, bus, message):
self.emit("duration-changed")
def tag(self, bus, message):
(res, sample) = message.parse_tag().get_sample("image")
if res:
self.emit("artwork", sample)
def timeout_function(self):
self.emit("position-changed")
return GLib.SOURCE_CONTINUE
@GObject.Signal
def about_to_finish(self):
pass
@GObject.Signal(arg_types=(Gst.Sample,))
def artwork(self, sample):
pass
@GObject.Signal
def duration_changed(self):
pass
@GObject.Signal
def eos(self):
pass
@GObject.Signal
def playback_start(self):
if not self.timeout:
self.timeout = GLib.timeout_add(TIMEOUT, self.timeout_function)
@GObject.Signal
def playback_paused(self):
if self.timeout:
GLib.source_remove(self.timeout)
self.timeout = None
@GObject.Signal
def position_changed(self):
remaining = self.duration - self.position
if remaining < 2 * Gst.SECOND:
if remaining + (TIMEOUT * Gst.MSECOND) >= 2 * Gst.SECOND:
self.emit("about-to-finish")

View File

@ -1,152 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
from gi.repository import Gtk
from . import scale
class ControlButton(Gtk.Button):
def __init__(self, player, icon):
Gtk.Button.__init__(self)
self.add_css_class("large-icons")
self.set_icon_name(icon)
self.player = player
class PreviousButton(ControlButton):
def __init__(self, player):
ControlButton.__init__(self, player, "media-skip-backward")
def do_clicked(self):
self.player.previous()
class NextButton(ControlButton):
def __init__(self, player):
ControlButton.__init__(self, player, "media-skip-forward")
def do_clicked(self):
self.player.next()
class PlayPauseButton(ControlButton):
def __init__(self, player):
ControlButton.__init__(self, player, "media-playback-start")
self.player.connect("playback-start", self.playback_start)
self.player.connect("playback-paused", self.playback_paused)
def do_clicked(self):
self.player.playpause()
def playback_start(self, player):
self.set_icon_name("media-playback-pause")
def playback_paused(self, player):
self.set_icon_name("media-playback-start")
class ControlScaleBox(scale.ScaleButtonBox):
def __init__(self, scalectrl):
icon = Gtk.Image()
icon.add_css_class("large-icons")
scalectrl.connect("value-changed", self.on_value_changed, icon)
scale.ScaleButtonBox.__init__(self, scalectrl)
self.on_value_changed(scalectrl, icon)
self.prepend(icon)
def on_value_changed(self, scale, icon):
pass
class AutoPauseControlBox(ControlScaleBox):
def __init__(self, apscale):
apscale.unparent()
ControlScaleBox.__init__(self, apscale)
def on_value_changed(self, scale, icon):
name = "start" if scale.get_value() == -1 else "pause"
icon.set_from_icon_name(f"media-playback-{name}")
class VolumeControlBox(ControlScaleBox):
def __init__(self, player):
ControlScaleBox.__init__(self, scale.VolumeScale(player))
def on_value_changed(self, scale, icon):
value = scale.get_value()
if value == 0: name = "muted"
elif value <= 1/3: name = "low"
elif value <= 2/3: name = "medium"
else: name = "high"
icon.set_from_icon_name(f"audio-volume-{name}-symbolic")
class ReplayGainComboBox(Gtk.ComboBoxText):
def __init__(self, player):
Gtk.ComboBoxText.__init__(self)
self.modes = [ "disabled", "track", "album" ]
self.player = player
self.append_text("ReplayGain Disabled")
self.append_text("ReplayGain Track Mode")
self.append_text("ReplayGain Album Mode")
self.set_active(self.modes.index(player.replaygain))
self.set_can_focus(False)
def do_changed(self):
self.player.set_property("replaygain", self.modes[self.get_active()])
class ReplayGainControl(Gtk.Box):
def __init__(self, player):
Gtk.Box.__init__(self)
self.icon = Gtk.Image.new_from_icon_name("audio-headphones")
self.icon.add_css_class("large-icons")
self.rgcombo = ReplayGainComboBox(player)
self.append(self.icon)
self.append(self.rgcombo)
class ControlsPopover(Gtk.Popover):
def __init__(self, player, apscale):
Gtk.Popover.__init__(self)
self.box = Gtk.Box.new(Gtk.Orientation.VERTICAL, 0)
self.box.append(AutoPauseControlBox(apscale))
self.box.append(VolumeControlBox(player))
self.box.append(ReplayGainControl(player))
self.set_child(self.box)
class MenuIcon(Gtk.Overlay):
def __init__(self, apscale):
Gtk.Overlay.__init__(self)
self.icon = Gtk.Image.new_from_icon_name("pan-down-symbolic")
self.icon.set_margin_top(5)
self.label = Gtk.Label()
self.label.set_markup("<small> </small>")
self.label.set_yalign(0)
apscale.connect("value-changed", self.on_value_changed)
self.add_overlay(self.icon)
self.add_overlay(self.label)
def on_value_changed(self, scale):
value = int(scale.get_value())
text = str(value) if value > -1 else " "
self.label.set_markup(f"<small>{text}</small>")
class MenuButton(Gtk.MenuButton):
def __init__(self, player, apscale):
Gtk.MenuButton.__init__(self)
self.set_popover(ControlsPopover(player, apscale))
self.get_first_child().set_child(MenuIcon(apscale))
class AudioControls(Gtk.Box):
def __init__(self, player, apscale):
Gtk.Box.__init__(self)
self.add_css_class("linked")
self.append(PreviousButton(player))
self.append(PlayPauseButton(player))
self.append(NextButton(player))
self.append(MenuButton(player, apscale))

View File

@ -1,39 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
from gi.repository import GLib
from gi.repository import Gtk
class TrackTitle(Gtk.Label):
def __init__(self, player):
Gtk.Label.__init__(self)
player.connect("track-changed", self.on_track_changed)
self.on_track_changed(player, None, player.track)
self.add_css_class("title")
def on_track_changed(self, player, old, new):
text = new.title if new else "Emmental"
self.set_markup(f"<big>{GLib.markup_escape_text(text)}</big>")
class TrackArtist(Gtk.Label):
def __init__(self, player):
Gtk.Label.__init__(self)
player.connect("track-changed", self.on_track_changed)
self.on_track_changed(player, None, player.track)
self.add_css_class("subtitle")
def on_track_changed(self, player, old, new):
text = f"by {new.artist.name}" if new else "The Cheesy Music Player"
self.set_markup(f"<big>{GLib.markup_escape_text(text)}</big>")
class NowPlaying(Gtk.ScrolledWindow):
def __init__(self, player):
Gtk.ScrolledWindow.__init__(self)
self.box = Gtk.Box.new(Gtk.Orientation.VERTICAL, 0)
self.box.append(TrackTitle(player))
self.box.append(TrackArtist(player))
self.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.NEVER)
self.set_valign(Gtk.Align.CENTER)
self.set_hexpand(True)
self.set_child(self.box)

View File

@ -1,78 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import db
import lib
from gi.repository import GObject
from . import bass
from . import scale
class Player(bass.BassPlayer):
def __init__(self):
bass.BassPlayer.__init__(self)
self.Autopause = scale.AutoPauseScale()
self.playlist = None
self.track = None
self.set_playlist(db.find_playlist(lib.settings.get_int("audio.plstateid")))
self.change_track(db.track.Table.get(lib.settings.get_int("audio.trackid")))
def change_track(self, track, reset=False, add_prev=True):
if self.track and self.play_percent > (2 / 3):
self.track.played()
if reset:
self.uri = None
self.emit("track-changed", self.track, track)
if track and add_prev:
db.user.Table.find("Previous").add_track(track)
def do_about_to_finish(self):
if self.Autopause.get_value() != 0:
self.Autopause.decrement()
self.change_track(self.playlist.next_track())
def do_eos(self):
self.Autopause.decrement()
self.change_track(self.playlist.next_track(), reset=True)
self.playing = self.Autopause.keep_playing
def play(self): self.playing = True
def pause(self): self.playing = False
def playpause(self): self.playing = not self.playing
def play_track(self, track, add_prev=True):
self.change_track(track, reset=True, add_prev=add_prev)
self.play()
def next(self):
if track := db.user.Table.find("Previous").next_track():
self.play_track(track, add_prev=False)
else:
if (track := self.playlist.next_track()) == None:
self.set_playlist(db.user.Table.find("Collection"))
track = self.playlist.next_track()
self.play_track(track)
def previous(self):
if track := db.user.Table.find("Previous").previous_track():
self.play_track(track, add_prev=False)
def set_playlist(self, plist):
if plist is None:
plist = db.user.Table.find("Collection")
if plist != self.playlist:
self.emit("playlist-changed", self.playlist, plist)
@GObject.Signal(arg_types=(db.track.Track, db.track.Track))
def track_changed(self, prev, new):
self.track = new
if self.track:
lib.settings.set("audio.trackid", new.rowid)
self.uri = new.path.absolute().as_uri()
@GObject.Signal(arg_types=(db.playlist.Playlist, db.playlist.Playlist))
def playlist_changed(self, prev, new):
self.playlist = new
if self.playlist:
if new.current >= new.get_n_tracks() - 1:
new.current = -1
lib.settings.set("audio.plstateid", new.plstateid)

View File

@ -1,52 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
from gi.repository import GObject
from gi.repository import Gst
class ReplayGainSink(Gst.Bin):
def __init__(self):
Gst.Bin.__init__(self)
self.selector = Gst.ElementFactory.make("output-selector")
self.funnel = Gst.ElementFactory.make("funnel")
self.audiosink = Gst.ElementFactory.make("autoaudiosink")
self.rgvolume = Gst.ElementFactory.make("rgvolume")
self.rglimiter = Gst.ElementFactory.make("rglimiter")
for element in [ self.selector, self.funnel, self.audiosink,
self.rgvolume, self.rglimiter ]:
self.add(element)
# No ReplayGain: selector -> funnel -> audiosink
self.shortcut = self.selector.get_request_pad("src_%u")
self.shortcut.link(self.funnel.get_request_pad("sink_%u"))
self.funnel.link(self.audiosink)
# Replaygain: selector -> rgvolume -> rglimiter -> funnel -> audiosink
self.replaygain = self.selector.get_request_pad("src_%u")
self.replaygain.link(self.rgvolume.get_static_pad("sink"))
self.rgvolume.link(self.rglimiter)
self.rglimiter.get_static_pad("src").link(
self.funnel.get_request_pad("sink_%u"))
self.selector.set_property("pad-negotiation-mode", 1)
self.selector.set_property("active-pad", self.shortcut)
pad = self.selector.get_static_pad("sink")
ghost = Gst.GhostPad.new("sink", pad)
ghost.set_active(True)
self.add_pad(ghost)
@GObject.Property
def mode(self):
if self.selector.get_property("active-pad") == self.shortcut:
return "disabled"
album_mode = self.rgvolume.get_property("album-mode")
return "album" if album_mode else "track"
@mode.setter
def mode(self, mode):
if mode == "disabled":
self.selector.set_property("active-pad", self.shortcut)
else:
self.rgvolume.set_property("album-mode", mode == "album")
self.selector.set_property("active-pad", self.replaygain)

View File

@ -1,132 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
from gi.repository import GLib
from gi.repository import Gtk
from gi.repository import Gst
class ScalePlus(Gtk.Scale):
def __init__(self, min, max, step, page):
Gtk.Scale.__init__(self)
self.set_range(min, max)
self.set_increments(step, page)
self.set_value(min)
self.set_draw_value(True)
self.set_hexpand(True)
self.set_format_value_func(self.format_value)
def __change_value__(self, n, scroll):
value = self.get_value()
self.set_value(value + n)
new = self.get_value()
if value == new:
return None
self.emit("change-value", scroll, new)
return new
def decrement(self):
adjustment = self.get_adjustment()
return self.__change_value__(-adjustment.get_step_increment(),
Gtk.ScrollType.STEP_BACKWARD)
def increment(self):
adjustment = self.get_adjustment()
return self.__change_value__(adjustment.get_step_increment(),
Gtk.ScrollType.STEP_FORWARD)
def format_value(self, scale, value):
return str(value)
class SeekScale(ScalePlus):
def __init__(self, player):
ScalePlus.__init__(self, 0, player.duration,
5 * Gst.SECOND, 30 * Gst.SECOND)
self.set_size_request(200, -1)
self.player = player
self.player.connect("duration-changed", self.duration_changed)
self.player.connect("position-changed", self.position_changed)
def do_change_value(self, scroll, value):
self.player.position = value
def duration_changed(self, player):
self.set_range(0, player.duration)
def position_changed(self, player):
self.set_value(player.position)
def format_value(self, scale, value):
position = int(value / Gst.SECOND)
duration = int(self.get_adjustment().get_upper() / Gst.SECOND)
(p_m, p_s) = divmod(position, 60)
(r_m, r_s) = divmod(duration - position, 60)
return f"{p_m:02}:{p_s:02} / {r_m:02}:{r_s:02}"
class AutoPauseScale(ScalePlus):
def __init__(self):
ScalePlus.__init__(self, -1, 99, 1, 5)
self.keep_playing = True
self.set_digits(0)
def about_to_pause(self):
return self.get_value() == 0
def format_value(self, scale, value):
value = int(value)
if value == -1: return "Keep Playing"
elif value == 0: return "This Track"
elif value == 1: return "Next Track"
return f"{value} Tracks"
def decrement(self):
self.keep_playing = not self.about_to_pause()
super().decrement()
class VolumeScale(ScalePlus):
def __init__(self, player):
ScalePlus.__init__(self, 0.0, 1.0, 0.05, 0.25)
self.player = player
self.set_value(player.volume)
def do_change_value(self, scroll, value):
self.set_value(value)
self.player.volume = value
def format_value(self, scale, value):
return f"{int(value * 100)}%"
class ScaleButton(Gtk.Button):
def __init__(self, scale, icon):
Gtk.Button.__init__(self)
self.add_css_class("normal-icons")
self.add_css_class("flat")
self.set_valign(Gtk.Align.END)
self.set_icon_name(icon)
self.scale = scale
class DecrementButton(ScaleButton):
def __init__(self, scale):
ScaleButton.__init__(self, scale, "list-remove-symbolic")
def do_clicked(self):
self.scale.decrement()
class IncrementButton(ScaleButton):
def __init__(self, scale):
ScaleButton.__init__(self, scale, "list-add-symbolic")
def do_clicked(self):
self.scale.increment()
class ScaleButtonBox(Gtk.Box):
def __init__(self, scale):
Gtk.Box.__init__(self)
self.set_orientation(Gtk.Orientation.HORIZONTAL)
self.append(DecrementButton(scale))
self.append(scale)
self.append(IncrementButton(scale))

View File

@ -1,13 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import tagdb
from gi.repository import GObject
class Selector(GObject.GObject):
def __init__(self): GObject.GObject.__init__(self)
def next(self): return None
def previous(self): return None
class TagdbSelector(Selector):
def next(self): return tagdb.Stack.next()[0]
def previous(self): return tagdb.Stack.previous()

View File

@ -1,29 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import unittest
from gi.repository import Gtk
from . import artwork
class FakePlayer:
def __init__(self):
self.track = None
def connect(self, name, cb): pass
class TestAudioArtwork(unittest.TestCase):
def test_audio_artwork_init(self):
fake = FakePlayer()
art = artwork.Artwork(fake)
self.assertIsInstance(art, Gtk.AspectFrame)
self.assertIsInstance(art.frame, Gtk.Frame)
self.assertIsInstance(art.picture, Gtk.Picture)
self.assertEqual(art.player, fake)
self.assertEqual(art.get_child(), art.frame)
self.assertEqual(art.frame.get_child(), art.picture)
self.assertEqual(art.get_obey_child(), False)
self.assertEqual(art.get_ratio(), 1.0)
self.assertEqual(art.get_margin_start(), 5)
self.assertEqual(art.get_margin_end(), 5)
self.assertEqual(art.get_margin_top(), 5)
self.assertEqual(art.get_margin_bottom(), 5)

View File

@ -1,32 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import audio
import db
import pathlib
import unittest
from gi.repository import Gtk
test_album = pathlib.Path("./data/Test Album/")
test_track = test_album / "01 - Test Track.ogg"
class TestAudio(unittest.TestCase):
def test_init(self):
self.assertIsInstance(audio.Player, audio.player.Player)
def test_play_track(self):
db.reset()
track = db.make_fake_track(1, 10, "Test Track", test_track, test_album)
self.assertTrue(audio.play_track(track))
self.assertTrue(audio.Player.playing)
self.assertFalse(audio.play_track(track))
audio.Player.playing = False
def test_header(self):
header = audio.Header()
self.assertIsInstance(header, Gtk.HeaderBar)
self.assertIsInstance(header.get_title_widget(),
audio.nowplaying.NowPlaying)
def test_widgets(self):
self.assertIsInstance(audio.Artwork(),
audio.artwork.Artwork)

View File

@ -1,137 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import lib
import pathlib
import time
import unittest
from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gst
from . import bass
from . import replaygain
main_context = GLib.main_context_default()
test_album = pathlib.Path("./data/Test Album/")
test_track = test_album / "01 - Test Track.ogg"
test_uri = test_track.absolute().as_uri()
class TestBassPlayer(unittest.TestCase):
def setUp(self):
self.duration_changed = None
def on_duration_changed(self, player): self.duration_changed = player.duration
def on_playback_start(self, player): self.playing = True
def on_playback_paused(self, player): self.playing = False
def test_bass_player_init(self):
base = bass.BassPlayer()
self.assertIsInstance(base, GObject.GObject)
self.assertIsInstance(base.audio, replaygain.ReplayGainSink)
self.assertIsInstance(base.video, Gst.Element)
self.assertIsInstance(base.playbin, Gst.Element)
self.assertEqual(base.playbin.get_property("audio-sink"), base.audio)
self.assertEqual(base.playbin.get_property("video-sink"), base.video)
self.assertEqual(base.playbin.get_state(Gst.CLOCK_TIME_NONE)[1],
Gst.State.READY)
self.assertIsNone(base.timeout)
def test_bass_player_bus(self):
base = bass.BassPlayer()
self.assertIsInstance(base.bus, Gst.Bus)
def test_bass_player_duration(self):
base = bass.BassPlayer()
base.connect("duration-changed", self.on_duration_changed)
self.assertEqual(base.get_property("duration"), 0)
base.set_property("uri", test_uri)
base.set_property("playing", False)
iterations = 0
while self.duration_changed == None:
main_context.iteration(may_block=False)
if (iterations := iterations +1) == 100000:
break
self.assertEqual(base.get_property("duration"), 10 * Gst.SECOND)
self.assertEqual(self.duration_changed, 10 * Gst.SECOND)
def test_bass_player_playing(self):
base = bass.BassPlayer()
base.connect("playback-start", self.on_playback_start)
base.connect("playback-paused", self.on_playback_paused)
base.set_property("uri", test_uri)
self.assertFalse(base.get_property("playing"))
base.set_property("playing", True)
(ret, state, pending) = base.playbin.get_state(Gst.CLOCK_TIME_NONE)
self.assertEqual(state, Gst.State.PLAYING)
self.assertTrue(base.get_property("playing"))
while main_context.iteration(may_block=False): pass
self.assertIsNotNone(base.timeout)
base.set_property("playing", False)
(ret, state, pending) = base.playbin.get_state(Gst.CLOCK_TIME_NONE)
self.assertEqual(state, Gst.State.PAUSED)
self.assertFalse(base.get_property("playing"))
while main_context.iteration(may_block=False): pass
self.assertIsNone(base.timeout)
def test_basic_player_position(self):
base = bass.BassPlayer()
self.assertEqual(base.get_property("position"), 0)
base.set_property("uri", test_uri)
base.set_property("playing", False)
time.sleep(0.1)
while main_context.iteration(may_block=False): time.sleep(0.005)
base.set_property("position", 5 * Gst.SECOND)
time.sleep(0.2)
while main_context.iteration(may_block=False): time.sleep(0.005)
self.assertGreater(base.get_property("position"), 0)
def test_bass_player_replaygain(self):
lib.settings.reset()
base = bass.BassPlayer()
self.assertEqual(lib.settings.get("audio.replaygain"), "disabled")
self.assertEqual(base.get_property("replaygain"), "disabled")
base.set_property("replaygain", "track")
self.assertEqual(base.audio.get_property("mode"), "track")
self.assertEqual(base.get_property("replaygain"), "track")
self.assertEqual(lib.settings.get("audio.replaygain"), "track")
base.set_property("replaygain", "disabled")
self.assertEqual(base.audio.get_property("mode"), "disabled")
self.assertEqual(base.get_property("replaygain"), "disabled")
self.assertEqual(lib.settings.get("audio.replaygain"), "disabled")
base.set_property("replaygain", "album")
self.assertEqual(base.audio.get_property("mode"), "album")
self.assertEqual(base.get_property("replaygain"), "album")
self.assertEqual(lib.settings.get("audio.replaygain"), "album")
def test_bass_player_uri(self):
base = bass.BassPlayer()
self.assertIsNone(base.get_property("uri"))
base.set_property("uri", test_uri)
self.assertEqual(base.get_property("uri"), test_uri)
base.playbin.set_state(Gst.State.PAUSED)
base.set_property("uri", None)
self.assertEqual(base.playbin.get_state(Gst.CLOCK_TIME_NONE)[1],
Gst.State.READY)
def test_bass_player_volume(self):
lib.settings.reset()
base = bass.BassPlayer()
self.assertEqual(lib.settings.get_float("audio.volume"), 1.0)
self.assertEqual(base.get_property("volume"), 1.0)
base.set_property("volume", 0.5)
self.assertEqual(base.get_property("volume"), 0.5)
self.assertEqual(lib.settings.get_float("audio.volume"), 0.5)
base2 = bass.BassPlayer()
self.assertEqual(base2.get_property("volume"), 0.5)

View File

@ -1,241 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import unittest
from gi.repository import GObject
from gi.repository import Gtk
from gi.repository import Gst
from . import controls
from . import scale
class FakePlayer(GObject.GObject):
def __init__(self):
GObject.GObject.__init__(self)
self.prev = False
self.nxt = False
self.play = False
self.vol = 1.0
self.rgain = "disabled"
@GObject.Property
def playing(self): return self.play
@playing.setter
def playing(self, newval):
self.play = newval
self.emit("playback-start" if newval else "playback-paused")
def previous(self): self.prev = True
def next(self): self.nxt = True
@GObject.Property
def volume(self): return self.vol
def playpause(self):
self.playing = not self.playing
@GObject.Property
def replaygain(self): return self.rgain
@replaygain.setter
def replaygain(self, newval): self.rgain = newval
@GObject.Signal
def playback_start(self): pass
@GObject.Signal
def playback_paused(self): pass
class TestControlButton(unittest.TestCase):
def test_control_button(self):
fake = FakePlayer()
ctrl = controls.ControlButton(fake, "missing-icon")
self.assertIsInstance(ctrl, Gtk.Button)
self.assertTrue(ctrl.has_css_class("large-icons"))
self.assertEqual(ctrl.get_icon_name(), "missing-icon")
self.assertEqual(ctrl.player, fake)
class TestPreviousButton(unittest.TestCase):
def test_previous_button(self):
fake = FakePlayer()
prev = controls.PreviousButton(fake)
self.assertIsInstance(prev, controls.ControlButton)
self.assertEqual(prev.get_icon_name(), "media-skip-backward")
prev.emit("clicked")
self.assertTrue(fake.prev)
class TestNextButton(unittest.TestCase):
def test_next_button(self):
fake = FakePlayer()
next = controls.NextButton(fake)
self.assertIsInstance(next, controls.ControlButton)
self.assertEqual(next.get_icon_name(), "media-skip-forward")
next.emit("clicked")
self.assertTrue(fake.nxt)
class TestPlayPauseButton(unittest.TestCase):
def test_play_pause_button(self):
fake = FakePlayer()
play = controls.PlayPauseButton(fake)
self.assertIsInstance(play, controls.ControlButton)
self.assertEqual(play.get_icon_name(), "media-playback-start")
play.emit("clicked")
self.assertTrue(fake.play)
self.assertEqual(play.get_icon_name(), "media-playback-pause")
play.emit("clicked")
self.assertFalse(fake.play)
self.assertEqual(play.get_icon_name(), "media-playback-start")
class TestControlScaleBox(unittest.TestCase):
def test_control_scale_box(self):
splus = scale.ScalePlus(1, 10, 1, 5)
ctrlbox = controls.ControlScaleBox(splus)
self.assertIsInstance(ctrlbox, scale.ScaleButtonBox)
self.assertIsInstance(ctrlbox.get_first_child(), Gtk.Image)
self.assertTrue(ctrlbox.get_first_child().has_css_class("large-icons"))
class TestAutoPauseControlBox(unittest.TestCase):
def test_autopause_control_box(self):
apscale = scale.AutoPauseScale()
apbox = controls.AutoPauseControlBox(apscale)
icon = apbox.get_first_child()
self.assertIsInstance(apbox, controls.ControlScaleBox)
apscale.set_value(-1)
self.assertEqual(icon.get_icon_name(), "media-playback-start")
apscale.set_value(0)
self.assertEqual(icon.get_icon_name(), "media-playback-pause")
class TestVolumeControlbox(unittest.TestCase):
def test_volume_control_box(self):
fake = FakePlayer()
vcb = controls.VolumeControlBox(fake)
icon = vcb.get_first_child()
volume = icon.get_next_sibling().scale
self.assertIsInstance(vcb, controls.ControlScaleBox)
self.assertIsInstance(volume, scale.VolumeScale)
volume.set_value(0)
self.assertEqual(icon.get_icon_name(), "audio-volume-muted-symbolic")
volume.set_value(0.3)
self.assertEqual(icon.get_icon_name(), "audio-volume-low-symbolic")
volume.set_value(0.6)
self.assertEqual(icon.get_icon_name(), "audio-volume-medium-symbolic")
volume.set_value(0.9)
self.assertEqual(icon.get_icon_name(), "audio-volume-high-symbolic")
class TestReplayGainComboBox(unittest.TestCase):
def test_replay_gain_combobox(self):
fake = FakePlayer()
combo = controls.ReplayGainComboBox(fake)
self.assertIsInstance(combo, Gtk.ComboBoxText)
self.assertEqual(combo.player, fake)
self.assertEqual(combo.modes, [ "disabled", "track", "album" ])
self.assertEqual(combo.get_active_text(), "ReplayGain Disabled")
combo.set_active(1)
self.assertEqual(combo.get_active_text(), "ReplayGain Track Mode")
self.assertEqual(fake.replaygain, "track")
combo.set_active(2)
self.assertEqual(combo.get_active_text(), "ReplayGain Album Mode")
self.assertEqual(fake.replaygain, "album")
class TestReplayGainControl(unittest.TestCase):
def test_replay_gain_control(self):
fake = FakePlayer()
rgc = controls.ReplayGainControl(fake)
self.assertIsInstance(rgc, Gtk.Box)
self.assertIsInstance(rgc.icon, Gtk.Image)
self.assertIsInstance(rgc.rgcombo, controls.ReplayGainComboBox)
self.assertEqual(rgc.get_orientation(), Gtk.Orientation.HORIZONTAL)
self.assertEqual(rgc.icon.get_icon_name(), "audio-headphones")
self.assertEqual(rgc.get_first_child(), rgc.icon)
self.assertEqual(rgc.icon.get_next_sibling(), rgc.rgcombo)
self.assertTrue(rgc.icon.has_css_class("large-icons"))
class TestControlsPopover(unittest.TestCase):
def test_controls_popover(self):
fake = FakePlayer()
apscale = scale.AutoPauseScale()
pop = controls.ControlsPopover(fake, apscale)
self.assertIsInstance(pop, Gtk.Popover)
self.assertIsInstance(pop.box, Gtk.Box)
child = pop.box.get_first_child()
self.assertIsInstance(child, controls.AutoPauseControlBox)
child = child.get_next_sibling()
self.assertIsInstance(child, controls.VolumeControlBox)
child = child.get_next_sibling()
self.assertIsInstance(child, controls.ReplayGainControl)
self.assertEqual(pop.get_child(), pop.box)
self.assertEqual(pop.box.get_orientation(), Gtk.Orientation.VERTICAL)
class TestControlsMenuIcon(unittest.TestCase):
def test_controls_menu_icon(self):
apscale = scale.AutoPauseScale()
icon = controls.MenuIcon(apscale)
self.assertIsInstance(icon, Gtk.Overlay)
self.assertIsInstance(icon.icon, Gtk.Image)
self.assertIsInstance(icon.label, Gtk.Label)
self.assertEqual(icon.icon.get_icon_name(), "pan-down-symbolic")
self.assertEqual(icon.icon.get_margin_top(), 5)
self.assertEqual(icon.label.get_text(), " ")
self.assertEqual(icon.label.get_yalign(), 0)
apscale.set_value(0)
self.assertEqual(icon.label.get_text(), "0")
self.assertIn(icon.icon, icon)
self.assertIn(icon.label, icon)
class TestControlsMenuButton(unittest.TestCase):
def test_controls_menu_button(self):
fake = FakePlayer()
apscale = scale.AutoPauseScale()
menu = controls.MenuButton(fake, apscale)
self.assertIsInstance(menu, Gtk.MenuButton)
self.assertIsInstance(menu.get_popover(),
controls.ControlsPopover)
self.assertIsInstance(menu.get_first_child().get_child(),
controls.MenuIcon)
class TestAudioControls(unittest.TestCase):
def test_audio_controls(self):
fake = FakePlayer()
apscale = scale.AutoPauseScale()
ctrl = controls.AudioControls(fake, apscale)
self.assertIsInstance(ctrl, Gtk.Box)
self.assertEqual(ctrl.get_orientation(), Gtk.Orientation.HORIZONTAL)
self.assertTrue(ctrl.has_css_class("linked"))
child = ctrl.get_first_child()
self.assertIsInstance(child, controls.PreviousButton)
child = child.get_next_sibling()
self.assertIsInstance(child, controls.PlayPauseButton)
child = child.get_next_sibling()
self.assertIsInstance(child, controls.NextButton)
child = child.get_next_sibling()
self.assertIsInstance(child, controls.MenuButton)

View File

@ -1,70 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import db
import unittest
from gi.repository import GObject
from gi.repository import Gtk
from . import nowplaying
class FakeArtist(GObject.GObject):
def __init__(self):
self.name = "Test Artist"
class FakeTrack(GObject.GObject):
def __init__(self):
GObject.GObject.__init__(self)
self.title = "Test Title"
self.artist = FakeArtist()
class FakePlayer(GObject.GObject):
def __init__(self):
GObject.GObject.__init__(self)
self.track = None
@GObject.Signal(arg_types=(FakeTrack,FakeTrack))
def track_changed(self, prev, new): pass
class TestAudioTrackTitle(unittest.TestCase):
def test_track_title(self):
fake = FakePlayer()
title = nowplaying.TrackTitle(fake)
self.assertIsInstance(title, Gtk.Label)
self.assertTrue(title.has_css_class("title"))
self.assertEqual(title.get_text(), "Emmental")
fake.emit("track-changed", None, FakeTrack())
self.assertEqual(title.get_text(), "Test Title")
class TestAudioTrackArtist(unittest.TestCase):
def test_track_artist(self):
fake = FakePlayer()
artist = nowplaying.TrackArtist(fake)
self.assertIsInstance(artist, Gtk.Label)
self.assertTrue(artist.has_css_class("subtitle"))
self.assertEqual(artist.get_text(), "The Cheesy Music Player")
fake.emit("track-changed", None, FakeTrack())
self.assertEqual(artist.get_text(), "by Test Artist")
class TestNowPlaying(unittest.TestCase):
def test_now_playing(self):
fake = FakePlayer()
now = nowplaying.NowPlaying(fake)
child = now.box.get_first_child()
viewport = now.get_child()
self.assertIsInstance(now, Gtk.ScrolledWindow)
self.assertIsInstance(now.box, Gtk.Box)
self.assertIsInstance(child, nowplaying.TrackTitle)
self.assertIsInstance(child.get_next_sibling(), nowplaying.TrackArtist)
self.assertEqual(viewport.get_child(), now.box)
self.assertEqual(now.get_valign(), Gtk.Align.CENTER)
self.assertEqual(now.get_policy(), (Gtk.PolicyType.AUTOMATIC,
Gtk.PolicyType.NEVER))
self.assertTrue(now.get_hexpand())

View File

@ -1,146 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import db
import lib
import pathlib
import scanner
import unittest
from gi.repository import GLib
from gi.repository import Gst
from . import bass
from . import player
from . import scale
main_context = GLib.main_context_default()
test_album = pathlib.Path("./data/Test Album/")
test_track = test_album / "01 - Test Track.ogg"
test_uri = test_track.absolute().as_uri()
class TestPlayer(unittest.TestCase):
def setUpClass():
db.reset()
lib = db.library.Table.find(test_album)
scanner.Queue.push(scanner.task.DirectoryTask(lib, test_album))
while scanner.Queue.run() == GLib.SOURCE_CONTINUE: pass
def setUp(self):
self.changed = None
lib.settings.reset()
self.library = db.library.Table.lookup(test_album)
self.track = db.track.Table.lookup(test_track)
def on_track_changed(self, player, prev, new):
self.changed = (prev, new)
def on_playlist_changed(self, player, prev, new):
self.changed = (prev, new)
def test_init(self):
play = player.Player()
self.assertIsInstance(play, bass.BassPlayer)
self.assertIsInstance(play.bus, Gst.Bus)
self.assertIsInstance(play.Autopause, scale.AutoPauseScale)
self.assertIsNone(play.track)
self.assertEqual(play.playlist, db.user.Table.find("Collection"))
self.assertEqual(lib.settings.get_int("audio.plstateid"),
db.user.Table.find("Collection").plstateid)
def test_set_playlist(self):
collection = db.user.Table.find("Collection")
plist = db.user.Table.find("Test Playlist")
play = player.Player()
play.connect("playlist-changed", self.on_playlist_changed)
play.set_playlist(plist)
self.assertEqual(play.playlist, plist)
self.assertEqual(self.changed, (collection, plist))
self.assertEqual(lib.settings.get_int("audio.plstateid"), plist.plstateid)
self.changed = None
play.set_playlist(plist)
self.assertIsNone(self.changed)
play2 = player.Player()
self.assertEqual(play2.playlist, plist)
play2.set_playlist(None)
self.assertEqual(play2.playlist, collection)
def test_set_playlist_reset(self):
plist = db.user.Table.find("Test Playlist")
plist.add_track(db.make_fake_track(1, 1, "Test 1", "/a/b/c/1.ogg"))
plist.current = 0
play = player.Player()
play.set_playlist(plist)
self.assertEqual(plist.current, -1)
def test_change_track(self):
play = player.Player()
play.connect("track-changed", self.on_track_changed)
self.assertEqual(play.get_property("uri"), None)
play.change_track(self.track, reset=True)
self.assertEqual(play.track, self.track)
self.assertEqual(lib.settings.get_int("audio.trackid"), self.track.rowid)
self.assertEqual(self.changed, (None, self.track) )
db.sql.execute("DELETE FROM temp_playlist_map")
play2 = player.Player()
self.assertEqual(play2.track, self.track)
self.assertEqual(db.user.Table.find("Previous").get_track(0), self.track)
def test_play_track(self):
play = player.Player()
play.play_track(self.track)
self.assertEqual(play.track, self.track)
self.assertTrue(play.playing)
play.pause()
def test_play_pause(self):
play = player.Player()
play.play_track(self.track)
self.assertEqual(play.track, self.track)
play.pause()
self.assertFalse(play.playing)
play.play()
self.assertTrue(play.playing)
play.playpause()
self.assertFalse(play.playing)
play.playpause()
self.assertTrue(play.playing)
play.pause()
self.assertFalse(play.playing)
def test_next_previous(self):
collection = db.user.Table.find("Collection")
play = player.Player()
play.set_playlist(collection)
play.connect("track-changed", self.on_track_changed)
track0 = collection.get_track(0)
track1 = collection.get_track(1)
play.next()
self.assertEqual(play.track, track0)
self.assertEqual(self.changed, (None, track0))
play.next()
self.assertEqual(play.track, track1)
self.assertEqual(self.changed, (track0, track1))
play.previous()
self.assertEqual(play.track, track0)
self.assertEqual(self.changed, (track1, track0))
play.next()
self.assertEqual(play.track, track1)
self.assertEqual(self.changed, (track0, track1))

View File

@ -1,38 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import unittest
from gi.repository import Gst
from . import replaygain
class TestReplayGainSink(unittest.TestCase):
def test_replay_gain_sink_init(self):
rgsink = replaygain.ReplayGainSink()
self.assertIsInstance(rgsink, Gst.Bin)
self.assertIsInstance(rgsink.selector, Gst.Element)
self.assertIsInstance(rgsink.funnel, Gst.Element)
self.assertIsInstance(rgsink.audiosink, Gst.Element)
self.assertIsInstance(rgsink.rgvolume, Gst.Element)
self.assertIsInstance(rgsink.rglimiter, Gst.Element)
self.assertIsInstance(rgsink.shortcut, Gst.Pad)
self.assertIsInstance(rgsink.replaygain, Gst.Pad)
self.assertIsInstance(rgsink.get_static_pad("sink"), Gst.GhostPad)
def test_replay_gain_sink_mode(self):
rgsink = replaygain.ReplayGainSink()
self.assertEqual(rgsink.get_property("mode"), "disabled")
rgsink.set_property("mode", "album")
self.assertEqual(rgsink.get_property("mode"), "album")
self.assertEqual(rgsink.selector.get_property("active-pad"),
rgsink.replaygain)
rgsink.set_property("mode", "track")
self.assertEqual(rgsink.get_property("mode"), "track")
self.assertEqual(rgsink.selector.get_property("active-pad"),
rgsink.replaygain)
rgsink.set_property("mode", "disabled")
self.assertEqual(rgsink.get_property("mode"), "disabled")
self.assertEqual(rgsink.selector.get_property("active-pad"),
rgsink.shortcut)

View File

@ -1,256 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import unittest
from gi.repository import GObject
from gi.repository import Gtk
from gi.repository import Gst
from . import scale
class FakePlayer(GObject.GObject):
def __init__(self, position, duration, volume):
GObject.GObject.__init__(self)
self.pos = position
self.dur = duration
self.vol = volume
@GObject.Property
def position(self): return self.pos
@position.setter
def position(self, newval):
self.pos = newval
self.emit("position-changed")
@GObject.Signal
def position_changed(self): pass
@GObject.Property
def duration(self): return self.dur
@GObject.Signal
def duration_changed(self): pass
def seek(self, value): self.seek_val = value
@GObject.Property
def volume(self): return self.vol
@volume.setter
def volume(self, volume): self.vol = volume
class TestScalePlus(unittest.TestCase):
def on_change_value(self, scale, scroll, value):
self.change_value = (scroll, value)
def test_scale_plus_init(self):
splus = scale.ScalePlus(1, 10, 2, 5)
adj = splus.get_adjustment()
self.assertIsInstance(splus, Gtk.Scale)
self.assertTrue(splus.get_draw_value())
self.assertTrue(splus.get_hexpand())
self.assertEqual(splus.get_value_pos(), Gtk.PositionType.TOP)
self.assertEqual(adj.get_value(), 1)
self.assertEqual(adj.get_lower(), 1)
self.assertEqual(adj.get_upper(), 10)
self.assertEqual(adj.get_step_increment(), 2)
self.assertEqual(adj.get_page_increment(), 5)
def test_scale_plus_decrement(self):
splus = scale.ScalePlus(1, 10, 2, 5)
splus.set_value(10)
splus.connect("change-value", self.on_change_value)
for i in [ 8, 6, 4, 2, 1 ]:
self.assertEqual(splus.decrement(), i)
self.assertEqual(splus.format_value(splus, i), str(i))
self.assertEqual(self.change_value,
(Gtk.ScrollType.STEP_BACKWARD, i))
self.change_value = None
self.assertIsNone(splus.decrement())
self.assertIsNone(self.change_value)
def test_scale_plus_increment(self):
splus = scale.ScalePlus(1, 10, 2, 5)
splus.connect("change-value", self.on_change_value)
for i in [ 3, 5, 7, 9, 10 ]:
self.assertEqual(splus.increment(), i)
self.assertEqual(splus.format_value(splus, i), str(i))
self.assertEqual(self.change_value,
(Gtk.ScrollType.STEP_FORWARD, i))
self.change_value = None
self.assertIsNone(splus.increment())
self.assertIsNone(self.change_value)
class TestSeekScale(unittest.TestCase):
def test_seek_scale_init(self):
fake = FakePlayer(0, 5 * Gst.SECOND, 1)
seek = scale.SeekScale(fake)
adj = seek.get_adjustment()
self.assertIsInstance(seek, scale.ScalePlus)
self.assertEqual(seek.get_size_request(), (200, -1))
self.assertEqual(seek.player, fake)
self.assertEqual(adj.get_value(), 0)
self.assertEqual(adj.get_lower(), 0)
self.assertEqual(adj.get_upper(), 5 * Gst.SECOND)
self.assertEqual(adj.get_step_increment(), 5 * Gst.SECOND)
self.assertEqual(adj.get_page_increment(), 30 * Gst.SECOND)
def test_seek_scale_duration(self):
fake = FakePlayer(0, 2 * Gst.SECOND, 1)
seek = scale.SeekScale(fake)
adj = seek.get_adjustment()
self.assertEqual(adj.get_upper(), 2 * Gst.SECOND)
fake.dur = 3 * Gst.SECOND
fake.emit("duration-changed")
self.assertEqual(adj.get_upper(), 3 * Gst.SECOND)
def test_seek_scale_position(self):
fake = FakePlayer(0, 15 * Gst.SECOND, 1)
seek = scale.SeekScale(fake)
adj = seek.get_adjustment()
fake.position = 3 * Gst.SECOND
self.assertEqual(seek.player, fake)
self.assertEqual(adj.get_value(), 3 * Gst.SECOND)
self.assertEqual(adj.get_lower(), 0)
def test_seek_scale_values(self):
fake = FakePlayer(0, 15 * Gst.SECOND, 1)
seek = scale.SeekScale(fake)
seek.increment()
self.assertEqual(fake.pos, 5 * Gst.SECOND)
self.assertEqual(seek.format_value(seek, 5 * Gst.SECOND),
"00:05 / 00:10")
seek.decrement()
self.assertEqual(fake.pos, 0)
class TestAutoPauseScale(unittest.TestCase):
def test_autopause_scale_init(self):
pause = scale.AutoPauseScale()
adj = pause.get_adjustment()
self.assertIsInstance(pause, scale.ScalePlus)
self.assertEqual(pause.get_digits(), 0)
self.assertEqual(adj.get_value(), -1)
self.assertEqual(adj.get_lower(), -1)
self.assertEqual(adj.get_upper(), 99)
self.assertEqual(adj.get_step_increment(), 1)
self.assertEqual(adj.get_page_increment(), 5)
self.assertTrue(pause.keep_playing)
def test_autopause_scale_values(self):
pause = scale.AutoPauseScale()
self.assertEqual(pause.format_value(pause, -1), "Keep Playing")
self.assertEqual(pause.format_value(pause, 0), "This Track")
self.assertEqual(pause.format_value(pause, 1), "Next Track")
self.assertEqual(pause.format_value(pause, 2), "2 Tracks")
def test_keep_playing(self):
pause = scale.AutoPauseScale()
pause.set_value(2)
pause.decrement()
self.assertEqual(pause.get_value(), 1)
self.assertFalse(pause.about_to_pause())
self.assertTrue(pause.keep_playing)
pause.decrement()
self.assertEqual(pause.get_value(), 0)
self.assertTrue(pause.about_to_pause())
self.assertTrue(pause.keep_playing)
pause.decrement()
self.assertEqual(pause.get_value(), -1)
self.assertFalse(pause.about_to_pause())
self.assertFalse(pause.keep_playing)
pause.decrement()
self.assertEqual(pause.get_value(), -1)
self.assertFalse(pause.about_to_pause())
self.assertTrue(pause.keep_playing)
class TestVolumeScale(unittest.TestCase):
def test_volume_scale_init(self):
fake = FakePlayer(0, 5 * Gst.SECOND, 1.0)
volume = scale.VolumeScale(fake)
adj = volume.get_adjustment()
self.assertIsInstance(volume, scale.ScalePlus)
self.assertEqual(volume.player, fake)
self.assertEqual(adj.get_value(), 1.0)
self.assertEqual(adj.get_lower(), 0.0)
self.assertEqual(adj.get_upper(), 1.0)
self.assertEqual(adj.get_step_increment(), 0.05)
self.assertEqual(adj.get_page_increment(), 0.25)
fake.volume = 0.5
vol2 = scale.VolumeScale(fake)
self.assertEqual(vol2.get_value(), 0.5)
def test_volume_scale_values(self):
fake = FakePlayer(0, 15 * Gst.SECOND, 0.5)
volume = scale.VolumeScale(fake)
volume.increment()
self.assertEqual(fake.volume, 0.55)
self.assertEqual(volume.format_value(volume, 0.55), "55%")
volume.decrement()
self.assertEqual(fake.volume, 0.50)
self.assertEqual(volume.format_value(volume, 0.50), "50%")
class TestScaleButton(unittest.TestCase):
def test_scale_button(self):
splus = scale.ScalePlus(0, 5, 1, 1)
sbutt = scale.ScaleButton(splus, "missing-icon")
self.assertIsInstance(sbutt, Gtk.Button)
self.assertEqual(sbutt.get_valign(), Gtk.Align.END)
self.assertTrue(sbutt.has_css_class("normal-icons"))
self.assertTrue(sbutt.has_css_class("flat"))
class TestDecrementButton(unittest.TestCase):
def test_decrement_button(self):
splus = scale.ScalePlus(0, 5, 1, 1)
dec = scale.DecrementButton(splus)
splus.set_value(1)
self.assertIsInstance(dec, scale.ScaleButton)
self.assertEqual(dec.get_icon_name(), "list-remove-symbolic")
dec.emit("clicked")
self.assertEqual(splus.get_value(), 0)
class TestIncrementButton(unittest.TestCase):
def test_increment_button(self):
splus = scale.ScalePlus(0, 5, 1, 1)
inc = scale.IncrementButton(splus)
self.assertIsInstance(inc, scale.ScaleButton)
self.assertEqual(inc.get_icon_name(), "list-add-symbolic")
inc.emit("clicked")
self.assertEqual(splus.get_value(), 1)
class TestScaleButtonBox(unittest.TestCase):
def test_scale_button_box(self):
splus = scale.ScalePlus(0, 5, 1, 1)
sbox = scale.ScaleButtonBox(splus)
self.assertIsInstance(sbox, Gtk.Box)
self.assertEqual(sbox.get_orientation(), Gtk.Orientation.HORIZONTAL)
self.assertIsInstance(sbox.get_first_child(), scale.DecrementButton)
self.assertIsInstance(sbox.get_last_child(), scale.IncrementButton)
self.assertIsInstance(splus.get_next_sibling(), scale.IncrementButton)

View File

@ -1,11 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import unittest
from gi.repository import GObject
from . import selector
class TestAudioSelector(unittest.TestCase):
def test_audio_selector_init(self):
select = selector.Selector()
self.assertIsInstance(select, GObject.GObject)
self.assertIsNone(select.next())
self.assertIsNone(select.previous())

2
aur

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

View File

@ -0,0 +1,12 @@
[Desktop Entry]
Type=Application
Version=1.5
Name=Emmental
GenericName=Music Player
Comment=The Cheesy Music Player
DBusActivatable=false
Terminal=false
MimeType=application/musepack;application/ogg;application/x-ape;application/x-flac;application/x-id3;application/x-musepack;application/x-ogg;application/x-ogm-audio;audio/aac;audio/ape;audio/flac;audio/mp;audio/mp3;audio/mp4;audio/mpc;audio/mpeg;audio/mpeg3;audio/mpegurl;audio/musepack;audio/ogg;audio/vnd.rn-realaudio;audio/vorbis;audio/x-ape;audio/x-flac;audio/x-it;audio/x-m4a;audio/x-mod;audio/x-mp;audio/x-mp3;audio/x-mpc;audio/x-mpeg;audio/x-mpeg-3;audio/x-mpegurl;audio/x-ms-wma;audio/x-musepack;audio/x-ogg;audio/x-oggflac;audio/x-pn-realaudio;audio/x-s3m;audio/x-scpls;audio/x-speex;audio/x-stm;audio/x-vorbis;audio/x-vorbis+ogg;audio/x-wav;audio/x-xm;
Categories=AudioVideo;Audio;Music;Player;GTK;GNOME;
SingleMainWindow=true
StartupWMClass=emmental.py

View File

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

View File

@ -1,3 +0,0 @@
#!/bin/bash
python {EMMENTAL_LIB}/emmental.py $*

View File

@ -1,10 +0,0 @@
[Desktop Entry]
Type=Application
Version=1.5
Name=Emmental
GenericName=Music Player
Comment=Listen to your music
Exec={EMMENTAL_BIN}/emmental
Icon=emmental
Terminal=false
Categories=AudioVideo;Audio;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

View File

@ -1,60 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 135.46666 135.46666"
version="1.1"
id="svg3294"
inkscape:version="1.1 (c4e8f9ed74, 2021-05-24)"
sodipodi:docname="emmental-favorites.svg"
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="namedview3296"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:document-units="px"
showgrid="false"
width="512mm"
units="px"
inkscape:zoom="1"
inkscape:cx="416.5"
inkscape:cy="263.5"
inkscape:window-width="1920"
inkscape:window-height="1005"
inkscape:window-x="0"
inkscape:window-y="49"
inkscape:window-maximized="1"
inkscape:current-layer="layer2"
showborder="true"
borderlayer="false" />
<defs
id="defs3291">
<linearGradient
id="linearGradient3404"
inkscape:swatch="solid">
<stop
style="stop-color:#ff0000;stop-opacity:1;"
offset="0"
id="stop3402" />
</linearGradient>
</defs>
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="Layer 2"
style="display:inline">
<path
id="rect928"
style="mix-blend-mode:multiply;fill:#ff0000;fill-opacity:1;fill-rule:nonzero;stroke:#004600;stroke-width:1.13263;stroke-dasharray:0, 12.4589;stroke-opacity:1;paint-order:markers fill stroke"
d="M 8.3491862,17.674821 A 48.298388,38.079963 55.167577 0 0 0.47527528,35.642866 48.298388,38.079963 55.167577 0 0 15.646486,80.346627 l 13.02172,14.124094 26.043443,28.248199 c 7.214034,7.82476 18.829408,7.82476 26.043442,0 L 106.79853,94.470721 119.82026,80.346627 A 38.079963,48.298388 34.832423 0 0 126.33111,16.788177 38.079963,48.298388 34.832423 0 0 67.73337,23.850231 48.298388,38.079963 55.167577 0 0 26.518717,7.3946768 48.298388,38.079963 55.167577 0 0 8.3491862,17.674821 Z" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -1,66 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import datetime
import lib
import pathlib
def new_db():
from . import sql
try:
return sql.execute("SELECT COUNT(*) from tracks").fetchone()[0] == 0
except:
return True
from . import artist
from . import album
from . import disc
from . import genre
from . import decade
from . import year
from . import library
from . import track
from . import state
from . import user
from . import sql
def _search_table(table, key, search):
if row := sql.execute(f"SELECT {key} FROM {table} "
f"WHERE plstateid=?", [ search ]).fetchone():
return row[0]
def find_playlist(plstateid):
if playlistid := _search_table("playlists", "playlistid", plstateid):
return user.Table.get(playlistid)
if artistid := _search_table("artists", "artistid", plstateid):
return artist.Table.get(artistid)
if albumid := _search_table("albums", "albumid", plstateid):
return album.Table.get(albumid)
if discid := _search_table("discs", "discid", plstateid):
return disc.Table.get(discid)
if genreid := _search_table("genres", "genreid", plstateid):
return genre.Table.get(genreid)
if decadeid := _search_table("decades", "decadeid", plstateid):
return decade.Table.get(decadeid)
if yearid := _search_table("years", "yearid", plstateid):
return year.Table.get(yearid)
if libraryid := _search_table("libraries", "libraryid", plstateid):
return library.Table.get(libraryid)
return None
def make_fake_track(trackno, length, title, path, lib="/a/b/c", art="Test Artist",
alb="Test Album", disk=1, subtitle=None, yeer=2021, mnth=3, dy=18):
lib = library.Table.find(pathlib.Path(lib))
art = artist.Table.find(art, art)
alb = art.find_album(alb, datetime.date(yeer, mnth, dy))
disk = alb.find_disc(disk, subtitle)
dec = decade.Table.find((yeer // 10) * 10)
yeer = dec.find_year(yeer)
return track.Table.insert(lib, art, alb, disk, dec, yeer, trackno,
length, title, pathlib.Path(path))
def reset():
mods = [ track, state, user, artist, album,
disc, genre, decade, year, library ]
for mod in mods: mod.Table.reset()
if lib.version.TESTING: reset()

View File

@ -1,64 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
#
# Table: albums
# +---------+----------+-----------+------+------+
# | albumid | artistid | plstateid | name | sort |
# +---------+----------+-----------+------+------+
from gi.repository import GObject
from . import disc
from . import playlist
from . import sql
class Album(playlist.ParentPlaylist):
def __init__(self, row):
playlist.ParentPlaylist.__init__(self, row, "media-optical-cd-audio")
self._name = row["name"]
self._release = row["release"]
@GObject.Property
def name(self): return self._name
@GObject.Property
def release(self): return self._release
def delete(self): Table.delete(self)
def find_disc(self, number, subtitle):
return self.find_child(number, subtitle)
def get_child_table(self): return disc.Table
def lookup_child(self, number, subtitle):
return disc.Table.lookup(self, number)
class AlbumTable(playlist.ChildModel):
def __init__(self):
playlist.ChildModel.__init__(self, "albums", "artistid", "sort")
def do_create(self):
sql.execute("CREATE TABLE IF NOT EXISTS albums "
"(albumid INTEGER PRIMARY KEY, "
" artistid INTEGER, "
" plstateid INTEGER NOT NULL, "
" release DATE NOT NULL, "
" name TEXT, "
" sort TEXT, "
" FOREIGN KEY(artistid) REFERENCES artists(artistid), "
" FOREIGN KEY(plstateid) REFERENCES playlist_states(plstateid), "
" UNIQUE(artistid, release, name))")
def do_factory(self, row):
return Album(row)
def do_insert(self, plstate, artist, name, release):
return sql.execute("INSERT INTO albums (artistid, plstateid, release, name, sort) "
"VALUES (?, ?, ?, ?, ?)",
[ artist.rowid, plstate.rowid, release, name, name.casefold() ])
def do_lookup(self, artist, name, release):
return sql.execute("SELECT * FROM albums "
"WHERE (artistid=? AND name=? AND release=?)",
[ artist.rowid, name, release ])
Table = AlbumTable()

View File

@ -1,54 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
#
# Table: artists
# +----------+-----------+------+------+
# | artistid | plstateid | name | sort |
# +----------+-----------+------+------+
from gi.repository import GObject
from . import album
from . import playlist
from . import sql
class Artist(playlist.ParentPlaylist):
def __init__(self, row):
playlist.ParentPlaylist.__init__(self, row, "avatar-default-symbolic")
self._name = row["name"]
@GObject.Property
def name(self): return self._name
def delete(self): Table.delete(self)
def find_album(self, name, release): return self.find_child(name, release)
def get_child_table(self): return album.Table
class ArtistTable(playlist.Model):
def __init__(self):
playlist.Model.__init__(self, "artists", "sort")
def do_create(self):
sql.execute("CREATE TABLE IF NOT EXISTS artists "
"(artistid INTEGER PRIMARY KEY, "
" plstateid INTEGER NOT NULL, "
" name TEXT UNIQUE, "
" sort TEXT, "
" FOREIGN KEY(plstateid) REFERENCES playlist_states(plstateid))")
def do_factory(self, row):
return Artist(row)
def do_insert(self, plstate, name, sort):
return sql.execute("INSERT INTO artists (plstateid, name, sort) "
"VALUES (?, ?, ?)",
[ plstate.rowid, name, sort.casefold() ])
def do_lookup(self, name):
return sql.execute("SELECT * FROM artists WHERE name=?", [ name ])
def find(self, name, sort):
return res if (res := self.lookup(name)) else self.insert(name, sort)
Table = ArtistTable()

View File

@ -1,51 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
#
# Table: decades
# +----------+-----------+--------+
# | decadeid | plstateid | decade |
# +----------+-----------+--------+
from gi.repository import GObject
from . import playlist
from . import sql
from . import year
class Decade(playlist.ParentPlaylist):
def __init__(self, row):
playlist.ParentPlaylist.__init__(self, row, "x-office-calendar")
self._decade = row["decade"]
@GObject.Property
def decade(self): return self._decade
@GObject.Property
def name(self): return f"{self._decade}s"
def delete(self): Table.delete(self)
def find_year(self, yr): return self.find_child(yr)
def get_child_table(self): return year.Table
def lookup_child(self, yr): return year.Table.lookup(yr)
class DecadeTable(playlist.Model):
def __init__(self):
playlist.Model.__init__(self, "decades", "decade")
def do_create(self):
sql.execute("CREATE TABLE IF NOT EXISTS decades "
"(decadeid INTEGER PRIMARY KEY, "
" plstateid INTEGER NOT NULL, "
" decade INTEGER UNIQUE, "
" FOREIGN KEY(plstateid) REFERENCES playlist_states(plstateid))")
def do_factory(self, row):
return Decade(row)
def do_insert(self, plstate, decade):
return sql.execute("INSERT INTO decades (plstateid,decade) "
"VALUES (?, ?)", [ plstate.rowid, decade ])
def do_lookup(self, decade):
return sql.execute("SELECT * FROM decades WHERE decade=?", [ decade ])
Table = DecadeTable()

View File

@ -1,61 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
#
# Table: discs
# +--------+---------+--------+----------+
# | discid | albumid | number | subtitle |
# +--------+---------+--------+----------+
from gi.repository import GObject
from . import playlist
from . import sql
class Disc(playlist.Playlist):
def __init__(self, row):
playlist.Playlist.__init__(self, row, "media-optical")
self._number = row["number"]
self._subtitle = row["subtitle"]
def delete(self): Table.delete(self)
@GObject.Property
def name(self):
if self._subtitle:
return f"{self._number}: {self._subtitle}"
return f"Disc {self._number}"
@GObject.Property
def number(self): return self._number
@GObject.Property
def subtitle(self):
return self._subtitle if self._subtitle else ""
class DiscTable(playlist.ChildModel):
def __init__(self):
playlist.ChildModel.__init__(self, "discs", "albumid", "number")
def do_create(self):
sql.execute("CREATE TABLE IF NOT EXISTS discs "
"(discid INTEGER PRIMARY KEY, "
" albumid INTEGER, "
" plstateid INTEGER NOT NULL, "
" number INTEGER, "
" subtitle TEXT, "
" FOREIGN KEY(albumid) REFERENCES albums(albumid), "
" FOREIGN KEY(plstateid) REFERENCES playlist_states(plstateid), "
" UNIQUE(albumid, number))")
def do_factory(self, row):
return Disc(row)
def do_insert(self, plstate, album, number, subtitle):
return sql.execute("INSERT INTO discs (albumid, plstateid, number, subtitle) "
"VALUES (?, ?, ?, ?)",
[ album.rowid, plstate.rowid, number, subtitle ])
def do_lookup(self, album, number):
return sql.execute("SELECT * FROM discs WHERE (albumid=? AND number=?)",
[ album.rowid, number ])
Table = DiscTable()

View File

@ -1,64 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
#
# Table: genres
# +---------+-----------+------+------+
# | genreid | plstateid | name | sort |
# +---------+-----------+------+------+
#
# Index: genre_index
# +-----------------+
# | name -> genreid |
# +-----------------+
from gi.repository import GObject
from . import playlist
from . import track
from . import sql
class Genre(playlist.MappedPlaylist):
def __init__(self, row):
playlist.MappedPlaylist.__init__(self, row, "emblem-generic", "genre_map")
self._name = row["name"]
def delete(self):
self.clear()
Table.delete(self)
@GObject.Property
def name(self): return self._name
class GenreTable(playlist.Model):
def __init__(self):
playlist.Model.__init__(self, "genres", "sort")
def do_create(self):
sql.execute("CREATE TABLE IF NOT EXISTS genres "
"(genreid INTEGER PRIMARY KEY, "
" plstateid INTEGER NOT NULL, "
" name TEXT UNIQUE, "
" sort TEXT, "
" FOREIGN KEY(plstateid) REFERENCES playlist_states(plstateid))")
sql.execute("CREATE TABLE IF NOT EXISTS genre_map "
"(genreid INTEGER, "
" trackid INTEGER, "
" FOREIGN KEY(genreid) REFERENCES genres(genreid), "
" FOREIGN KEY(trackid) REFERENCES tracks(trackid), "
" UNIQUE(genreid, trackid))")
def do_drop(self):
sql.execute("DROP TABLE genres")
sql.execute("DROP TABLE genre_map")
def do_factory(self, row):
return Genre(row)
def do_insert(self, plstate, name):
return sql.execute("INSERT INTO genres (plstateid, name, sort) "
"VALUES (?, ?, ?)",
[ plstate.rowid, name, name.casefold() ])
def do_lookup(self, name):
return sql.execute("SELECT * FROM genres WHERE name=?", [ name ])
Table = GenreTable()

View File

@ -1,69 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
#
# Table: libraries
# +-----------+-----------+---------+------+
# | libraryid | plstateid | enabled | path |
# +-----------+-----------+---------+------+
import pathlib
from gi.repository import GObject
from . import sql
from . import playlist
from . import track
from . import user
class Library(playlist.Playlist):
def __init__(self, row):
playlist.Playlist.__init__(self, row, "folder-music")
self._path = pathlib.Path(row["path"])
self._enabled = row["enabled"]
@GObject.Property
def name(self): return str(self._path)
@GObject.Property
def path(self): return self._path
@GObject.Property(type=bool,default=True)
def enabled(self): return self._enabled
@enabled.setter
def enabled(self, newval):
sql.execute("UPDATE libraries SET enabled=? WHERE libraryid=?",
[ newval, self.rowid ])
sql.commit()
self._enabled = newval
user.Table.find("Collection").refresh()
def delete(self): Table.delete(self)
def tracks(self):
cursor = sql.execute(f"SELECT trackid FROM tracks "
"WHERE libraryid=?", [ self.rowid ])
return [ track.Table.get(row["trackid"]) for row in cursor.fetchall() ]
class LibraryTable(playlist.Model):
def __init__(self):
playlist.Model.__init__(self, "libraries", "path")
def do_create(self):
sql.execute("CREATE TABLE IF NOT EXISTS libraries "
"(libraryid INTEGER PRIMARY KEY, "
" plstateid INTEGER NOT NULL, "
" enabled INTEGER DEFAULT 1, "
" path TEXT UNIQUE, "
" FOREIGN KEY (plstateid) REFERENCES playlist_states(plstateid))")
def do_factory(self, row):
return Library(row)
def do_insert(self, plstate, path):
return sql.execute("INSERT INTO libraries (plstateid, path) "
"VALUES (?, ?)", [ plstate.rowid, str(path) ])
def do_lookup(self, path):
return sql.execute("SELECT * FROM libraries WHERE path=?", [ str(path) ])
Table = LibraryTable()

View File

@ -1,270 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import random
from gi.repository import GObject
from . import sql
from . import state
from . import table
class Playlist(GObject.GObject):
def __init__(self, row, icon_name):
GObject.GObject.__init__(self)
self._rowkey = row.keys()[0]
self._rowid = row[self._rowkey]
self._plstate = state.Table.get(row["plstateid"])
self._icon_name = icon_name
def delete(self): raise NotImplementedError
def has_children(self): return False
def can_add_remove_tracks(self): return False
def get_n_tracks(self):
cur = sql.execute(f"SELECT COUNT(*) FROM tracks "
f"WHERE {self._rowkey}=?", [ self._rowid ])
return cur.fetchone()[0]
def get_track(self, n):
order = ', '.join(self.plist_state.sort)
row = sql.execute(f"SELECT * FROM tracks "
f"INNER JOIN artists USING(artistid) "
f"INNER JOIN albums USING(albumid) "
f"INNER JOIN discs USING(discid) "
f"WHERE tracks.{self._rowkey}=? "
f"ORDER BY {order} LIMIT 1 OFFSET ?",
[ self.rowid, n ]).fetchone()
return track.Table.factory(row)
def get_tracks(self):
order = ', '.join(self.plist_state.sort)
rows = sql.execute(f"SELECT * FROM tracks "
f"INNER JOIN artists USING(artistid) "
f"INNER JOIN albums USING(albumid) "
f"INNER JOIN discs USING(discid) "
f"WHERE tracks.{self._rowkey}=? "
f"ORDER BY {order}", [ self.rowid ]).fetchall()
return [ track.Table.factory(row) for row in rows ]
def get_current_track(self):
return self.get_track(self.current) if self.current > -1 else None
def get_track_index(self, track):
order = ', '.join(self.plist_state.sort)
cur = sql.execute(f"SELECT * FROM (SELECT trackid,ROW_NUMBER() "
f"OVER (ORDER BY {order}) "
f"FROM tracks "
f"INNER JOIN artists USING(artistid) "
f"INNER JOIN albums USING(albumid) "
f"INNER JOIN discs USING(discid) "
f"WHERE tracks.{self._rowkey}=?) "
f"WHERE trackid=?", [ self.rowid, track.rowid ])
return cur.fetchone()[1] - 1
def track_adjusts_current(self, track):
return self.current > -1 and self.get_track_index(track) <= self.current
def add_track(self, track):
self.emit("track-added", track)
def remove_track(self, track, adjust_current):
self.emit("track-removed", track, adjust_current)
def next_track(self):
n = self.get_n_tracks()
if self.random and n > 1:
self.current += random.randint(1, int((3*n)/4))
else:
self.current += 1
return self.get_current_track()
def refresh(self):
self.emit("refreshed")
@GObject.Property
def name(self): raise NotImplementedError
@GObject.Property
def icon_name(self): return self._icon_name
@GObject.Property
def plist_state(self): return self._plstate
@GObject.Property
def current(self): return self._plstate.get_property("current")
@current.setter
def current(self, newval):
n_tracks = self.get_n_tracks()
if newval >= n_tracks:
if n_tracks == 0: newval = -1
elif self.random: newval = newval % n_tracks
elif self.loop: newval = 0
else: newval = n_tracks
self._plstate.set_property("current", newval)
@GObject.Property(type=bool,default=False)
def loop(self): return self._plstate.get_property("loop")
@loop.setter
def loop(self, newval): self._plstate.set_property("loop", newval)
@GObject.Property(type=bool,default=False)
def random(self): return self._plstate.get_property("random")
@random.setter
def random(self, newval): self._plstate.set_property("random", newval)
@GObject.Property(type=GObject.TYPE_PYOBJECT)
def sort(self): return self._plstate.get_property("sort")
@sort.setter
def sort(self, newval):
track = self.get_current_track()
self._plstate.set_property("sort", newval)
if track:
self.current = self.get_track_index(track)
self.refresh()
@GObject.Property
def rowid(self): return self._rowid
@GObject.Property
def rowkey(self): return self._rowkey
@GObject.Property
def plstateid(self): return self._plstate.rowid
@GObject.Signal
def refreshed(self): pass
@GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT,))
def track_added(self, track):
if self.track_adjusts_current(track):
self.current += 1
@GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT,bool))
def track_removed(self, track, adjust_current):
if adjust_current:
self.current -= 1
class MappedPlaylist(Playlist):
def __init__(self, row, icon_name, map_table):
Playlist.__init__(self, row, icon_name)
self._map_table = map_table
@GObject.Property
def map_table(self): return self._map_table
def can_add_remove_tracks(self): return self._map_table == "playlist_map"
def add_track(self, track):
res = sql.execute(f"INSERT OR IGNORE INTO {self.map_table} "
f"({self.rowkey}, trackid) VALUES (?, ?)",
[ self.rowid, track.rowid ]).rowcount == 1
if res:
super().add_track(track)
return res
def clear(self):
sql.execute(f"DELETE FROM {self.map_table} "
f"WHERE {self.rowkey}=?", [ self.rowid ])
def get_n_tracks(self):
cur = sql.execute(f"SELECT COUNT(*) FROM {self.map_table} "
f"WHERE {self._rowkey}=?", [ self._rowid ])
return cur.fetchone()[0]
def get_track(self, n):
order = ', '.join(self.plist_state.sort)
row = sql.execute(f"SELECT * FROM tracks "
f"INNER JOIN {self.map_table} USING(trackid) "
f"INNER JOIN artists USING(artistid) "
f"INNER JOIN albums USING(albumid) "
f"INNER JOIN discs USING(discid) "
f"WHERE {self._rowkey}=? "
f"ORDER BY {order} LIMIT 1 OFFSET ?",
[ self._rowid, n ]).fetchone()
return track.Table.factory(row)
def get_tracks(self):
order = ', '.join(self.plist_state.sort)
rows = sql.execute(f"SELECT * FROM tracks "
f"INNER JOIN {self.map_table} USING(trackid) "
f"INNER JOIN artists USING(artistid) "
f"INNER JOIN albums USING(albumid) "
f"INNER JOIN discs USING(discid) "
f"WHERE {self._rowkey}=? ORDER BY {order}",
[ self._rowid ]).fetchall()
return [ track.Table.factory(row) for row in rows ]
def get_track_index(self, track):
order = ', '.join(self.plist_state.sort)
row = sql.execute(f"SELECT * FROM (SELECT trackid,{self._rowkey},ROW_NUMBER() "
f"OVER (ORDER BY {order}) "
f"FROM tracks "
f"INNER JOIN {self.map_table} USING (trackid) "
f"INNER JOIN artists USING(artistid) "
f"INNER JOIN albums USING(albumid) "
f"INNER JOIN discs USING(discid)) "
f"WHERE {self._rowkey}=? AND trackid=?",
[ self.rowid, track.rowid ]).fetchone()
return row[2] - 1 if row else None
def remove_track(self, track):
adjust_current = self.track_adjusts_current(track)
res = sql.execute(f"DELETE FROM {self.map_table} "
f"WHERE {self.rowkey}=? AND trackid=?",
[ self.rowid, track.rowid ]).rowcount == 1
if res:
super().remove_track(track, adjust_current)
return res
class ParentPlaylist(Playlist):
def has_children(self): return True
def get_child_table(self): raise NotImplementedError
def get_n_children(self):
return self.get_child_table().get_n_children(self)
def get_child(self, n):
return self.get_child_table().get_child(self, n)
def get_child_index(self, child):
return self.get_child_table().get_child_index(self, child)
def find_child(self, *args):
if (res := self.lookup_child(*args)) == None:
res = self.get_child_table().insert(self, *args)
self.emit("children-changed", self.get_child_index(res), 0, 1)
return res
def lookup_child(self, *args):
return self.get_child_table().lookup(self, *args)
@GObject.Signal(arg_types=(int,int,int))
def children_changed(self, pos, rm, add): pass
class Model(table.Model):
def insert(self, *args, **kwargs):
loop = kwargs.pop("loop", False)
sort = kwargs.pop("sort", state.DefaultSort)
return super().insert(state.Table.insert(loop=loop,sort=sort), *args)
def delete(self, plist):
state.Table.delete(plist.plist_state)
return super().delete(plist)
class ChildModel(table.Child):
def insert(self, *args, **kwargs):
return super().insert(state.Table.insert(), *args)
def delete(self, plist):
state.Table.delete(plist.plist_state)
return super().delete(plist)
from . import track

View File

@ -1,15 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import lib
import sqlite3
File = lib.data.emmental_data / "emmental.sqlite"
Connection = sqlite3.connect(File, detect_types=sqlite3.PARSE_DECLTYPES, check_same_thread=False)
Connection.row_factory = sqlite3.Row
commit = Connection.commit
execute = Connection.execute
def optimize():
Connection.execute("PRAGMA analysis_limit=1000")
Connection.execute("PRAGMA optimize")

View File

@ -1,80 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
#
# Table: playlist_states
# +-----------+--------+------+---------+------+
# | plstateid | random | loop | current | sort |
# +-----------+--------+------+---------+------+
from gi.repository import GObject
from . import sql
from . import table
DefaultSort = [ "artists.sort ASC", "albums.release ASC", "albums.sort ASC",
"discs.number ASC", "tracks.number ASC", "tracks.trackid ASC" ]
class PlaylistState(GObject.GObject):
def __init__(self, row):
GObject.GObject.__init__(self)
self._plstateid = row["plstateid"]
self._random = row["random"]
self._loop = row["loop"]
self._current = row["current"]
self._sort = row["sort"]
@GObject.Property
def rowid(self): return self._plstateid
@GObject.Property(type=bool,default=False)
def random(self): return self._random
@random.setter
def random(self, newval): self._random = self.update("random", newval)
@GObject.Property(type=bool,default=False)
def loop(self): return self._loop
@loop.setter
def loop(self, newval): self._loop = self.update("loop", newval)
@GObject.Property(type=int)
def current(self): return self._current
@current.setter
def current(self, newval): self._current = self.update("current", newval)
@GObject.Property(type=GObject.TYPE_PYOBJECT)
def sort(self): return self._sort.split(",")
@sort.setter
def sort(self, newval):
if "tracks.trackid ASC" not in newval:
newval.append("tracks.trackid ASC")
self._sort = self.update("sort", ",".join(newval))
def update(self, column, newval):
sql.execute(f"UPDATE playlist_states SET {column}=? WHERE plstateid=?",
[ newval, self.rowid ])
sql.commit()
return newval
class PlaylistStateTable(table.Table):
def __init__(self):
table.Table.__init__(self, "playlist_states")
def do_create(self):
sql.execute("CREATE TABLE IF NOT EXISTS playlist_states "
"(plstateid INTEGER PRIMARY KEY, "
" random INTEGER DEFAULT 0, "
" loop INTEGER DEFAULT 0, "
" current INTEGER DEFAULT -1, "
" sort TEXT)")
def do_factory(self, row):
return PlaylistState(row)
def do_insert(self, random=False, loop=False, sort=DefaultSort):
return sql.execute("INSERT INTO playlist_states (random, loop, sort) "
"VALUES (?, ?, ?)", (random, loop, ",".join(sort)))
Table = PlaylistStateTable()

View File

@ -1,115 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
from gi.repository import GObject
from gi.repository import Gio
from . import sql
class Table:
def __init__(self, table):
self.cache = dict()
self.table = table
self.do_create()
def do_create(self): raise NotImplementedError
def do_factory(self, row): raise NotImplementedError
def do_insert(self, *args): raise NotImplementedError
def do_lookup(self, *args): raise NotImplementedError
def do_drop(self):
sql.execute(f"DROP TABLE {self.table}")
def delete(self, obj):
del self.cache[obj.rowid]
sql.execute(f"DELETE FROM {self.table} WHERE rowid=?", [ obj.rowid ])
def factory(self, row):
if row:
return self.cache.setdefault(row[0], self.do_factory(row))
def find(self, *args):
return ret if (ret := self.lookup(*args)) else self.insert(*args)
def get(self, rowid):
if (row := self.cache.get(rowid)):
return row
cur = sql.execute(f"SELECT * FROM {self.table} WHERE rowid=?", [ rowid ])
return self.factory(cur.fetchone())
def insert(self, *args, **kwargs):
return self.get(self.do_insert(*args, **kwargs).lastrowid)
def lookup(self, *args):
return self.factory(self.do_lookup(*args).fetchone())
def reset(self):
self.do_drop()
self.cache.clear()
self.do_create()
class Model(GObject.GObject, Gio.ListModel, Table):
def __init__(self, table, order):
GObject.GObject.__init__(self)
self.order = order
Table.__init__(self, table)
def do_get_item_type(self):
return GObject.TYPE_PYOBJECT
def do_get_n_items(self):
return sql.execute(f"SELECT COUNT(*) FROM {self.table}").fetchone()[0]
def do_get_item(self, n):
cur = sql.execute(f"SELECT * FROM {self.table} ORDER BY {self.order} "
"LIMIT 1 OFFSET ?", [ n ])
return self.factory(cur.fetchone())
def get_item_index(self, item):
cur = sql.execute("SELECT * FROM (SELECT rowid,ROW_NUMBER() "
f"OVER (ORDER BY {self.order}) "
f"FROM {self.table}) "
"WHERE rowid=?", [ item.rowid ])
return cur.fetchone()[1] - 1
def delete(self, item):
pos = self.get_item_index(item)
super().delete(item)
self.emit("items-changed", pos, 1, 0)
def insert(self, *args):
row = super().insert(*args)
pos = self.get_item_index(row)
self.emit("items-changed", pos, 0, 1)
return row
def reset(self):
n = self.get_n_items()
super().reset()
self.emit("items-changed", 0, n, self.get_n_items())
class Child(Table):
def __init__(self, table, parent, order):
Table.__init__(self, table)
self.parent = parent
self.order = order
def get_n_children(self, parent):
cur = sql.execute(f"SELECT COUNT(*) FROM {self.table} "
f"WHERE {self.parent}=?", [ parent.rowid ])
return cur.fetchone()[0]
def get_child(self, parent, n):
cur = sql.execute(f"SELECT * FROM {self.table} WHERE {self.parent}=? "
f"ORDER BY {self.order} LIMIT 1 OFFSET ?",
[ parent.rowid, n ])
return self.factory(cur.fetchone())
def get_child_index(self, parent, child):
cur = sql.execute(f"SELECT * FROM (SELECT rowid,ROW_NUMBER() "
f"OVER (ORDER BY {self.order}) "
f"FROM {self.table} "
f"WHERE {self.parent}=?)"
f"WHERE rowid=?", [ parent.rowid, child.rowid ])
return cur.fetchone()[1] - 1
def find(self, *args): raise NotImplementedError

View File

@ -1,90 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import datetime
import db
import sqlite3
import unittest
from gi.repository import GObject
class TestAlbum(unittest.TestCase):
def track_added(self, album, added):
self.added = added
def track_removed(self, album, removed, adjusted_current):
self.removed = (removed, False)
def setUp(self):
db.reset()
def test_init(self):
artist = db.artist.Table.find("Test Artist", "Test Sort")
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
self.assertIsInstance(album, db.playlist.ParentPlaylist)
self.assertEqual(album.get_property("name"), "Test Album")
self.assertEqual(album.get_property("icon-name"), "media-optical-cd-audio")
self.assertEqual(album.get_property("release"), datetime.date(2021, 3, 18))
self.assertEqual(album.get_child_table(), db.disc.Table)
def test_delete(self):
artist = db.artist.Table.find("Test Artist", "Test Sort")
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
album.delete()
self.assertIsNone(db.album.Table.lookup(artist, "Test Album",
datetime.date(2021, 3, 18)))
def test_find_disc(self):
artist = db.artist.Table.find("Test Artist", "Test Sort")
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
disc = album.find_disc(1, None)
self.assertIsInstance(disc, db.disc.Disc)
def test_tracks(self):
artist = db.artist.Table.find("Test Artist", "Test Sort")
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
album.connect("track-added", self.track_added)
self.assertEqual(album.get_n_tracks(), 0)
self.assertEqual(album.get_tracks(), [ ])
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
self.assertEqual(album.get_n_tracks(), 1)
self.assertEqual(album.get_track(0), track)
self.assertEqual(album.get_tracks(), [ track ])
self.assertEqual(album.get_track_index(track), 0)
self.assertEqual(self.added, track)
album.connect("track-removed", self.track_removed)
db.track.Table.delete(track)
self.assertEqual(album.get_tracks(), [ ])
self.assertEqual(self.removed, (track, False))
class TestAlbumTable(unittest.TestCase):
def setUp(self):
db.reset()
def test_init(self):
table = db.album.AlbumTable()
self.assertIsInstance(table, db.playlist.ChildModel)
self.assertEqual(table.table, "albums")
self.assertEqual(table.parent, "artistid")
self.assertEqual(table.order, "sort")
self.assertIsInstance(db.album.Table, db.album.AlbumTable)
db.sql.execute("SELECT albumid,artistid,plstateid,release,name,sort FROM albums")
def test_insert(self):
artist = db.artist.Table.insert("Test Artist", "Test Sort")
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
self.assertIsInstance(album, db.album.Album)
self.assertEqual(album._name, "Test Album")
self.assertEqual(album._rowkey, "albumid")
with self.assertRaises(sqlite3.IntegrityError):
db.album.Table.insert(artist, "Test Album",
datetime.date(2021, 3, 18))
def test_lookup(self):
artist = db.artist.Table.insert("Test Artist", "Test Sort")
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
self.assertEqual(db.album.Table.lookup(artist, "Test Album", datetime.date(2021, 3, 18)), album)
self.assertIsNone(db.album.Table.lookup(artist, "none", datetime.date(1, 1, 1)))

View File

@ -1,82 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import datetime
import db
import sqlite3
import unittest
from gi.repository import GObject
class TestArtist(unittest.TestCase):
def track_added(self, artist, added):
self.added = added
def track_removed(self, artist, removed, adjusted_current):
self.removed = (removed, adjusted_current)
def setUp(self):
db.reset()
def test_init(self):
artist = db.artist.Table.find("Test Artist", "Test Sort")
self.assertIsInstance(artist, db.playlist.ParentPlaylist)
self.assertEqual(artist.get_property("name"), "Test Artist")
self.assertEqual(artist.get_property("icon-name"), "avatar-default-symbolic")
self.assertEqual(artist.get_child_table(), db.album.Table)
def test_delete(self):
artist = db.artist.Table.find("Test Artist", "Test Sort")
artist.delete()
self.assertIsNone(db.artist.Table.lookup("Test Artist"))
def test_find_album(self):
artist = db.artist.Table.find("Test Artist", "Test Sort")
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
self.assertIsInstance(album, db.album.Album)
def test_tracks(self):
artist = db.artist.Table.find("Test Artist", "Test Sort")
artist.connect("track-added", self.track_added)
self.assertEqual(artist.get_n_tracks(), 0)
self.assertEqual(artist.get_tracks(), [ ])
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
self.assertEqual(artist.get_n_tracks(), 1)
self.assertEqual(artist.get_track(0), track)
self.assertEqual(artist.get_tracks(), [ track ])
self.assertEqual(artist.get_track_index(track), 0)
self.assertEqual(self.added, track)
artist.connect("track-removed", self.track_removed)
db.track.Table.delete(track)
self.assertEqual(artist.get_tracks(), [ ])
self.assertEqual(self.removed, (track, False))
class TestArtistTable(unittest.TestCase):
def setUp(self):
db.reset()
def test_init(self):
table = db.artist.ArtistTable()
self.assertIsInstance(table, db.playlist.Model)
self.assertEqual(table.table, "artists")
self.assertEqual(table.order, "sort")
self.assertIsInstance(db.artist.Table, db.artist.ArtistTable)
db.sql.execute("SELECT artistid,plstateid,name,sort FROM artists")
def test_insert(self):
table = db.artist.ArtistTable()
artist = table.insert("Test Artist", "Test Sort")
self.assertIsInstance(artist, db.artist.Artist)
self.assertEqual(artist._name, "Test Artist")
self.assertEqual(artist._rowkey, "artistid")
with self.assertRaises(sqlite3.IntegrityError):
db.artist.Table.insert("Test Artist", "Test Sort")
def test_lookup(self):
table = db.artist.ArtistTable()
artist = table.insert("Test Artist", "Test Sort")
self.assertEqual(table.lookup("Test Artist"), artist)
self.assertIsNone(table.lookup("none"))

View File

@ -1,20 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import db
import unittest
class TestDB(unittest.TestCase):
def test_new_db(self):
db.reset()
self.assertTrue(db.new_db())
db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
self.assertFalse(db.new_db())
def test_find_playlist(self):
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
collection = db.user.Table.find("Collection")
genre = db.genre.Table.find("Test Genre")
self.assertIsNone(db.find_playlist(123456))
for plist in [ collection, track.artist, track.album, track.disc,
genre, track.decade, track.year, track.library ]:
self.assertEqual(db.find_playlist(plist.plist_state.rowid), plist)

View File

@ -1,93 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import db
import sqlite3
import unittest
from gi.repository import GObject
class TestDecade(unittest.TestCase):
def children_changed(self, decade, pos, rm, add):
self.changed = (pos, rm, add)
def track_added(self, decade, added):
self.added = added
def track_removed(self, decade, removed, adjusted_current):
self.removed = (removed, adjusted_current)
def setUp(self):
db.reset()
def test_init(self):
decade = db.decade.Table.insert(2020)
self.assertIsInstance(decade, db.playlist.ParentPlaylist)
self.assertEqual(decade.get_property("name"), "2020s")
self.assertEqual(decade.get_property("icon-name"), "x-office-calendar")
self.assertEqual(decade.get_child_table(), db.year.Table)
def test_decade(self):
decade = db.decade.Table.insert(2020)
self.assertEqual(decade._decade, 2020)
self.assertEqual(decade.get_property("decade"), 2020)
def test_delete(self):
decade = db.decade.Table.find(2020)
decade.delete()
self.assertIsNone(db.decade.Table.lookup(2020))
def test_find_year(self):
decade = db.decade.Table.insert(2020)
decade.connect("children-changed", self.children_changed)
year = decade.find_year(2021)
self.assertIsInstance(year, db.year.Year)
self.assertEqual(self.changed, (0, 0, 1))
def test_tracks(self):
decade = db.decade.Table.insert(2020)
decade.connect("track-added", self.track_added)
self.assertEqual(decade.get_n_tracks(), 0)
self.assertEqual(decade.get_tracks(), [ ])
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
self.assertEqual(decade.get_n_tracks(), 1)
self.assertEqual(decade.get_track(0), track)
self.assertEqual(decade.get_tracks(), [ track ])
self.assertEqual(decade.get_track_index(track), 0)
self.assertEqual(self.added, track)
decade.connect("track-removed", self.track_removed)
db.track.Table.delete(track)
self.assertEqual(decade.get_tracks(), [ ])
self.assertEqual(self.removed, (track, False))
class TestDecadeTable(unittest.TestCase):
def setUp(self):
db.reset()
def test_init(self):
table = db.decade.DecadeTable()
self.assertIsInstance(table, db.playlist.Model)
self.assertEqual(table.table, "decades")
self.assertEqual(table.order, "decade")
self.assertIsInstance(db.decade.Table, db.decade.DecadeTable)
db.sql.execute("SELECT decadeid,plstateid,decade FROM decades")
def test_insert(self):
table = db.decade.DecadeTable()
decade = table.insert(2020)
self.assertIsInstance(decade, db.decade.Decade)
self.assertEqual(decade._decade, 2020)
self.assertEqual(decade._rowkey, "decadeid")
with self.assertRaises(sqlite3.IntegrityError):
db.decade.Table.insert(2020)
def test_lookup(self):
table = db.decade.DecadeTable()
decade = table.insert(2020)
self.assertEqual(table.lookup(2020), decade)
self.assertIsNone(table.lookup(2021))

View File

@ -1,105 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import datetime
import db
import sqlite3
import unittest
from gi.repository import GObject
class TestDisc(unittest.TestCase):
def track_added(self, disc, added):
self.added = added
def track_removed(self, disc, removed, adjusted_current):
self. removed = (removed, adjusted_current)
def setUp(self):
db.reset()
def make_disc(self, artist, album, discno, subtitle):
artist = db.artist.Table.find(artist, artist)
album = artist.find_album(album, datetime.date(2021, 3, 18))
return album.find_disc(discno, subtitle)
def test_init(self):
disc = self.make_disc("Test Artist", "Test Album", 1, "")
self.assertIsInstance(disc, db.playlist.Playlist)
self.assertEqual(disc.get_property("icon-name"), "media-optical")
self.assertEqual(disc.get_property("number"), 1)
def test_delete(self):
artist = db.artist.Table.find("Test Artist", "Test Artist")
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
disc = album.find_disc(1, "")
disc.delete()
self.assertIsNone(db.disc.Table.lookup(album, 1))
def test_subtitle(self):
disc = self.make_disc("Test Artist", "Test Album", 1, "Test Subtitle")
self.assertEqual(disc._subtitle, "Test Subtitle")
self.assertEqual(disc.get_property("subtitle"), "Test Subtitle")
self.assertEqual(disc.get_property("name"), "1: Test Subtitle")
def test_subtitle_none(self):
disc = self.make_disc("Test Artist", "Test Album", 1, None)
self.assertEqual(disc._subtitle, None)
self.assertEqual(disc.get_property("subtitle"), "")
self.assertEqual(disc.get_property("name"), "Disc 1")
def test_subtitle_len_0(self):
disc = self.make_disc("Test Artist", "Test Album", 1, "")
self.assertEqual(disc._subtitle, "")
self.assertEqual(disc.get_property("subtitle"), "")
self.assertEqual(disc.get_property("name"), "Disc 1")
def test_tracks(self):
disc = self.make_disc("Test Artist", "Test Album", 1, "Test Subtitle")
disc.connect("track-added", self.track_added)
self.assertEqual(disc.get_n_tracks(), 0)
self.assertEqual(disc.get_tracks(), [ ])
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
self.assertEqual(disc.get_n_tracks(), 1)
self.assertEqual(disc.get_track(0), track)
self.assertEqual(disc.get_tracks(), [ track ])
self.assertEqual(disc.get_track_index(track), 0)
self.assertEqual(self.added, track)
disc.connect("track-removed", self.track_removed)
db.track.Table.delete(track)
self.assertEqual(disc.get_tracks(), [ ])
self.assertEqual(self.removed, (track, False))
class TestDiscTable(unittest.TestCase):
def setUp(self):
db.reset()
def test_init(self):
table = db.disc.DiscTable()
self.assertIsInstance(table, db.playlist.ChildModel)
self.assertEqual(table.table, "discs")
self.assertEqual(table.parent, "albumid")
self.assertEqual(table.order, "number")
self.assertIsInstance(db.disc.Table, db.disc.DiscTable)
db.sql.execute("SELECT discid,albumid,plstateid,number,subtitle FROM discs")
def test_insert(self):
artist = db.artist.Table.insert("Test Artist", "Test Sort")
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
disc = db.disc.Table.insert(album, 1, "subtitle")
self.assertIsInstance(disc, db.disc.Disc)
self.assertEqual(disc._number, 1)
self.assertEqual(disc._subtitle, "subtitle")
self.assertEqual(disc._rowkey, "discid")
with self.assertRaises(sqlite3.IntegrityError):
db.disc.Table.insert(album, 1, "subtitle")
def test_lookup(self):
artist = db.artist.Table.insert("Test Artist", "Test Sort")
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
disc = album.find_disc(1, None)
self.assertEqual(db.disc.Table.lookup(album, 1), disc)
self.assertIsNone(db.disc.Table.lookup(album, "none"))

View File

@ -1,85 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import db
import sqlite3
import unittest
from gi.repository import GObject
from . import sql
class TestGenre(unittest.TestCase):
def track_added(self, genre, added):
self.added = added
def track_removed(self, genre, removed, adjusted_current):
self.removed = (removed, adjusted_current)
def setUp(self):
db.reset()
def test_init(self):
genre = db.genre.Table.find("Test Genre")
self.assertIsInstance(genre, db.playlist.MappedPlaylist)
self.assertEqual(genre._name, "Test Genre")
self.assertEqual(genre.get_property("name"), "Test Genre")
self.assertEqual(genre.get_property("icon-name"), "emblem-generic")
self.assertEqual(genre.get_property("map-table"), "genre_map")
def test_delete(self):
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
genre = db.genre.Table.find("Test Genre")
genre.add_track(track)
genre.delete()
self.assertEqual(genre.get_n_tracks(), 0)
self.assertIsNone(db.genre.Table.lookup("Test Genre"))
def test_add_remove_track(self):
genre = db.genre.Table.find("Test Genre")
genre.connect("track-added", self.track_added)
self.assertEqual(genre.get_n_tracks(), 0)
self.assertEqual(genre.get_tracks(), [ ])
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
self.assertTrue(genre.add_track(track))
self.assertEqual(genre.get_n_tracks(), 1)
self.assertEqual(genre.get_track(0), track)
self.assertEqual(genre.get_tracks(), [ track ])
self.assertEqual(genre.get_track_index(track), 0)
self.assertEqual(self.added, track)
genre.connect("track-removed", self.track_removed)
self.assertTrue(genre.remove_track(track))
self.assertFalse(genre.remove_track(track))
self.assertEqual(genre.get_n_tracks(), 0)
self.assertEqual(genre.get_tracks(), [ ])
self.assertEqual(self.removed, (track, False))
class TestGenreTable(unittest.TestCase):
def setUp(self):
db.reset()
def test_init(self):
table = db.genre.GenreTable()
self.assertIsInstance(table, db.playlist.Model)
self.assertEqual(table.table, "genres")
self.assertEqual(table.order, "sort")
self.assertIsInstance(db.genre.Table, db.genre.GenreTable)
db.sql.execute("SELECT genreid,plstateid,name,sort FROM genres")
db.sql.execute("SELECT genreid,trackid FROM genre_map")
def test_insert(self):
table = db.genre.GenreTable()
genre = table.insert("Test Genre")
self.assertIsInstance(genre, db.genre.Genre)
self.assertEqual(genre._name, "Test Genre")
self.assertEqual(genre._rowkey, "genreid")
with self.assertRaises(sqlite3.IntegrityError):
db.genre.Table.insert("Test Genre")
def test_lookup(self):
genre = db.genre.Table.insert("Test Genre")
self.assertEqual(db.genre.Table.lookup("Test Genre"), genre)
self.assertIsNone(db.genre.Table.lookup("none"))

View File

@ -1,103 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import db
import pathlib
import sqlite3
import unittest
from gi.repository import GObject
class TestLibrary(unittest.TestCase):
def track_added(self, library, added):
self.added = added
def track_removed(self, library, removed, adjusted_current):
self. removed = (removed, adjusted_current)
def setUp(self):
db.reset()
def test_init(self):
library = db.library.Table.insert(pathlib.Path("/a/b/c"))
self.assertIsInstance(library, db.playlist.Playlist)
self.assertEqual(library.get_property("name"), "/a/b/c")
self.assertEqual(library.get_property("icon-name"), "folder-music")
def test_delete(self):
library = db.library.Table.find(pathlib.Path("/a/b/c"))
library.delete()
self.assertIsNone(db.library.Table.lookup(pathlib.Path("/a/b/c")))
def test_path(self):
library = db.library.Table.insert(pathlib.Path("/a/b/c"))
self.assertIsInstance(library._path, pathlib.Path)
self.assertEqual(library.get_property("path"), library._path)
def test_enabled(self):
library = db.library.Table.insert(pathlib.Path("/a/b/c"))
self.assertTrue(library._enabled)
self.assertTrue(library.get_property("enabled"))
library.enabled = False
self.assertFalse(library._enabled)
def test_tracks(self):
library = db.library.Table.insert(pathlib.Path("/a/b/c"))
library.connect("track-added", self.track_added)
self.assertEqual(library.get_n_tracks(), 0)
self.assertEqual(library.get_tracks(), [ ])
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
self.assertEqual(library.get_n_tracks(), 1)
self.assertEqual(library.get_track(0), track)
self.assertEqual(library.get_tracks(), [ track ])
self.assertEqual(library.get_track_index(track), 0)
self.assertEqual(self.added, track)
library.connect("track-removed", self.track_removed)
db.track.Table.delete(track)
self.assertEqual(library.get_tracks(), [ ])
self.assertEqual(self.removed, (track, False))
class TestLibraryTable(unittest.TestCase):
def setUp(self):
db.reset()
def test_init(self):
table = db.library.LibraryTable()
self.assertIsInstance(table, db.playlist.Model)
self.assertIsInstance(table, db.table.Model)
self.assertEqual(table.table, "libraries")
self.assertEqual(table.order, "path")
self.assertIsInstance(db.library.Table, db.library.LibraryTable)
db.sql.execute("SELECT libraryid,plstateid,enabled,path FROM libraries")
def test_insert(self):
table = db.library.LibraryTable()
library = table.insert(pathlib.Path("/a/b/c"))
self.assertIsInstance(library, db.library.Library)
self.assertIsInstance(library._plstate, db.state.PlaylistState)
self.assertEqual(library._rowkey, "libraryid")
self.assertEqual(library._path, pathlib.Path("/a/b/c"))
self.assertTrue(library._enabled)
with self.assertRaises(sqlite3.IntegrityError):
db.library.Table.insert(pathlib.Path("/a/b/c"))
def test_delete(self):
table = db.library.LibraryTable()
library = table.insert(pathlib.Path("/a/b/c"))
state = library.plist_state
table.delete(library)
self.assertIsNone(db.library.Table.lookup(pathlib.Path("/a/b/c")))
self.assertIsNone(db.state.Table.get(state.rowid))
def test_lookup(self):
table = db.library.LibraryTable()
library = table.insert(pathlib.Path("/a/b/c"))
self.assertEqual(table.lookup(pathlib.Path("/a/b/c")), library)
self.assertIsNone(table.lookup(pathlib.Path("/a/b/d")))

View File

@ -1,245 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import db
import random
import unittest
from gi.repository import GObject
from . import playlist
class TestRow:
def __init__(self): pass
def keys(self): return [ "testid", "plstateid" ]
def __getitem__(self, key):
if key == "testid" or key == 0: return 1
return 10
class TestPlaylist(unittest.TestCase):
def setUp(self): db.reset()
def on_refresh(self, plist): self.refreshed = True
def test_init(self):
db.reset()
plist = playlist.Playlist(TestRow(), "missing-icon")
self.assertIsInstance(plist, GObject.GObject)
self.assertFalse(plist.has_children())
self.assertFalse(plist.can_add_remove_tracks())
self.assertEqual(plist._rowid, 1)
self.assertEqual(plist._rowkey, "testid")
self.assertEqual(plist._icon_name, "missing-icon")
self.assertEqual(plist.get_property("rowid"), 1)
self.assertEqual(plist.get_property("rowkey"), "testid")
self.assertEqual(plist.get_property("icon-name"), "missing-icon")
self.assertIsNone(plist._plstate)
self.assertIsNone(plist.get_property("plist_state"))
with self.assertRaises(NotImplementedError):
plist.get_property("name")
with self.assertRaises(NotImplementedError):
plist.delete()
def test_add_track(self):
plist = db.user.Table.find("Test Playlist")
track1 = db.make_fake_track(1, 1, "Track 1", "/a/b/c/1.ogg")
track2 = db.make_fake_track(2, 2, "Track 2", "/a/b/c/2.ogg")
track3 = db.make_fake_track(3, 3, "Track 3", "/a/b/c/3.ogg")
plist.sort = [ "tracks.number ASC" ]
plist.add_track(track2)
self.assertEqual(plist.get_track(0), track2)
self.assertEqual(plist.current, -1)
self.assertFalse(plist.track_adjusts_current(track2))
plist.current = 0
self.assertTrue(plist.track_adjusts_current(track2))
plist.add_track(track1)
self.assertEqual(plist.get_track(0), track1)
self.assertEqual(plist.get_track(1), track2)
self.assertEqual(plist.current, 1)
plist.add_track(track3)
self.assertEqual(plist.get_track(0), track1)
self.assertEqual(plist.get_track(1), track2)
self.assertEqual(plist.get_track(2), track3)
self.assertEqual(plist.current, 1)
def test_remove_track(self):
plist = db.user.Table.find("Test Playlist")
track1 = db.make_fake_track(1, 1, "Track 1", "/a/b/c/1.ogg")
track2 = db.make_fake_track(2, 2, "Track 2", "/a/b/c/2.ogg")
track3 = db.make_fake_track(3, 3, "Track 3", "/a/b/c/3.ogg")
plist.sort = [ "tracks.number ASC" ]
plist.add_track(track1)
plist.add_track(track2)
plist.add_track(track3)
plist.current = 1
self.assertTrue(plist.track_adjusts_current(track1))
self.assertTrue(plist.track_adjusts_current(track2))
self.assertFalse(plist.track_adjusts_current(track3))
plist.remove_track(track3)
self.assertEqual(plist.current, 1)
plist.remove_track(track1)
self.assertEqual(plist.current, 0)
plist.remove_track(track2)
self.assertEqual(plist.current, -1)
def test_current(self):
plist = db.user.Table.find("Test Playlist")
self.assertEqual(plist.get_property("current"), -1)
self.assertIsNone(plist.get_current_track())
plist.set_property("current", 1)
self.assertEqual(plist.get_property("current"), -1)
plist.add_track(db.make_fake_track(1, 1, "Track 1", "/a/b/c/1.ogg"))
plist.add_track(db.make_fake_track(2, 2, "Track 2", "/a/b/c/2.ogg"))
plist.set_property("current", 0)
self.assertEqual(plist.get_property("current"), 0)
self.assertEqual(plist.get_current_track(), plist.get_track(0))
plist.set_property("current", 1)
self.assertEqual(plist.get_property("current"), 1)
self.assertEqual(plist.get_current_track(), plist.get_track(1))
plist.set_property("current", 2)
self.assertEqual(plist.get_property("current"), 2)
self.assertIsNone(plist.get_current_track())
plist.set_property("current", 3)
self.assertEqual(plist.get_property("current"), 2)
self.assertIsNone(plist.get_current_track())
def test_loop(self):
plist = db.user.Table.find("Test Playlist")
self.assertFalse(plist.get_property("loop"))
plist.set_property("loop", True)
self.assertTrue(plist.get_property("loop"))
self.assertTrue(plist.plist_state.get_property("loop"))
plist.set_property("current", 0)
self.assertEqual(plist.get_property("current"), -1)
plist.add_track(db.make_fake_track(1, 1, "Track 1", "/a/b/c/1.ogg"))
plist.add_track(db.make_fake_track(2, 2, "Track 2", "/a/b/c/2.ogg"))
plist.set_property("current", 0)
self.assertEqual(plist.get_current_track(), plist.get_track(0))
plist.set_property("current", 1)
self.assertEqual(plist.get_current_track(), plist.get_track(1))
plist.set_property("current", 2)
self.assertEqual(plist.get_property("current"), 0)
self.assertEqual(plist.get_current_track(), plist.get_track(0))
def test_random(self):
plist = db.user.Table.find("Test Playlist")
self.assertFalse(plist.get_property("random"))
plist.set_property("random", True)
self.assertTrue(plist.get_property("random"))
self.assertTrue(plist.plist_state.get_property("random"))
plist.set_property("current", 0)
self.assertEqual(plist.get_property("current"), -1)
plist.add_track(db.make_fake_track(1, 1, "Track 1", "/a/b/c/1.ogg"))
plist.add_track(db.make_fake_track(2, 2, "Track 2", "/a/b/c/2.ogg"))
plist.set_property("current", 0)
self.assertEqual(plist.get_current_track(), plist.get_track(0))
plist.set_property("current", 5)
self.assertEqual(plist.get_property("current"), 1)
self.assertEqual(plist.get_current_track(), plist.get_track(1))
def test_sort(self):
plist = db.user.Table.find("Test Playlist")
plist.connect("refreshed", self.on_refresh)
self.assertEqual(plist.get_property("sort"), plist.plist_state.sort)
plist.set_property("sort", [ "tracks.number ASC" ])
self.assertEqual(plist.get_property("sort"),
[ "tracks.number ASC", "tracks.trackid ASC" ])
self.assertEqual(plist.plist_state.get_property("sort"),
[ "tracks.number ASC", "tracks.trackid ASC" ])
self.assertEqual(plist.get_property("current"), -1)
self.assertTrue(self.refreshed)
plist.add_track(db.make_fake_track(1, 1, "Track 1", "/a/b/c/1.ogg"))
plist.add_track(db.make_fake_track(2, 2, "Track 2", "/a/b/c/2.ogg"))
plist.set_property("sort", [ "tracks.number DESC" ])
self.assertEqual(plist.get_property("sort"),
[ "tracks.number DESC", "tracks.trackid ASC" ])
self.assertEqual(plist.get_property("current"), -1)
plist.set_property("current", 0)
self.assertEqual(plist.get_current_track().number, 2)
plist.set_property("sort", [ "tracks.number ASC" ])
self.assertEqual(plist.get_property("current"), 1)
def test_passthrough_plstateid(self):
plist = db.user.Table.find("Test Playlist")
self.assertEqual(plist.plstateid, plist.plist_state.rowid)
def test_next_track(self):
plist = db.user.Table.find("Test Playlist")
self.assertIsNone(plist.next_track())
self.assertEqual(plist.get_property("current"), -1)
plist.add_track(db.make_fake_track(1, 1, "Track 1", "/a/b/c/1.ogg"))
plist.add_track(db.make_fake_track(2, 2, "Track 2", "/a/b/c/2.ogg"))
self.assertEqual(plist.next_track(), plist.get_track(0))
self.assertEqual(plist.next_track(), plist.get_track(1))
self.assertIsNone(plist.next_track())
self.assertIsNone(plist.next_track())
def test_random_next_track(self):
plist = db.user.Table.find("Test Playlist")
plist.random = True
self.assertIsNone(plist.next_track())
self.assertEqual(plist.get_property("current"), -1)
plist.add_track(db.make_fake_track(1, 1, "Track 1", "/a/b/c/1.ogg"))
self.assertEqual(plist.next_track(), plist.get_track(0))
self.assertEqual(plist.get_property("current"), 0)
plist.add_track(db.make_fake_track(2, 2, "Track 2", "/a/b/c/2.ogg"))
plist.add_track(db.make_fake_track(3, 3, "Track 3", "/a/b/c/3.ogg"))
random.seed(20210318)
plist.current = -1
self.assertEqual(plist.next_track(), plist.get_track(1))
self.assertEqual(plist.next_track(), plist.get_track(2))
self.assertEqual(plist.next_track(), plist.get_track(0))
self.assertEqual(plist.next_track(), plist.get_track(2))
class TestMappedPlaylist(unittest.TestCase):
def test_init(self):
mapped = playlist.MappedPlaylist(TestRow(), "missing-icon", "test_map")
self.assertIsInstance(mapped, playlist.Playlist)
self.assertEqual(mapped._map_table, "test_map")
self.assertEqual(mapped.get_property("map-table"), "test_map")
class TestParentPlaylist(unittest.TestCase):
def test_init(self):
parent = playlist.ParentPlaylist(TestRow(), "missing-icon")
self.assertIsInstance(parent, playlist.Playlist)
self.assertTrue(parent.has_children())
with self.assertRaises(NotImplementedError):
parent.get_child_table()
with self.assertRaises(NotImplementedError):
parent.get_n_children()
with self.assertRaises(NotImplementedError):
parent.get_child(0)
with self.assertRaises(NotImplementedError):
parent.get_child_index(0)

View File

@ -1,14 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import lib
import sqlite3
import unittest
from . import sql
class TestSQL(unittest.TestCase):
def test_init(self):
self.assertEqual(sql.File, lib.data.emmental_data / "emmental.sqlite")
self.assertIsInstance(sql.Connection, sqlite3.Connection)
self.assertEqual(sql.Connection.row_factory, sqlite3.Row)
self.assertEqual(sql.commit, sql.Connection.commit)
self.assertEqual(sql.execute, sql.Connection.execute)

View File

@ -1,82 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import db
import sqlite3
import unittest
from gi.repository import GObject
class TestPlaylistState(unittest.TestCase):
def test_init(self):
state = db.state.Table.insert()
self.assertIsInstance(state, GObject.GObject)
self.assertIsNotNone(state._plstateid)
self.assertEqual(state.rowid, state._plstateid)
def test_random(self):
state = db.state.Table.insert()
self.assertFalse(state._random)
self.assertFalse(state.get_property("random"))
state.random = True
self.assertTrue(state._random)
def test_loop(self):
state = db.state.Table.insert()
self.assertFalse(state._loop)
self.assertFalse(state.get_property("loop"))
state.loop = True
self.assertTrue(state._loop)
def test_current(self):
state = db.state.Table.insert()
self.assertEqual(state._current, -1)
self.assertEqual(state.current, -1)
state.current = 3
self.assertEqual(state._current, 3)
def test_sort(self):
state = db.state.Table.insert()
self.assertEqual(state._sort, ",".join(db.state.DefaultSort))
self.assertEqual(state.sort, db.state.DefaultSort)
state.sort = [ "test", "sort" ]
self.assertEqual(state._sort, "test,sort,tracks.trackid ASC" )
state.sort = [ ]
self.assertEqual(state._sort, "tracks.trackid ASC" )
class TestPlaylistStateTable(unittest.TestCase):
def setUp(self):
db.reset()
def test_init(self):
table = db.state.PlaylistStateTable()
self.assertIsInstance(table, db.table.Table)
self.assertEqual(table.table, "playlist_states")
self.assertIsInstance(db.state.Table, db.state.PlaylistStateTable)
db.sql.execute("SELECT plstateid,random,loop,current,sort "
"FROM playlist_states")
def test_default_sort(self):
self.assertEqual(db.state.DefaultSort[0], "artists.sort ASC")
self.assertEqual(db.state.DefaultSort[1], "albums.release ASC")
self.assertEqual(db.state.DefaultSort[2], "albums.sort ASC")
self.assertEqual(db.state.DefaultSort[3], "discs.number ASC")
self.assertEqual(db.state.DefaultSort[4], "tracks.number ASC")
def test_insert(self):
table = db.state.PlaylistStateTable()
state = table.insert()
self.assertFalse(state.random)
self.assertFalse(state.loop)
self.assertEqual(state.current, -1)
self.assertEqual(state.sort, db.state.DefaultSort)
def test_lookup(self):
with self.assertRaises(NotImplementedError):
db.state.Table.lookup()

View File

@ -1,189 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import unittest
from gi.repository import GObject
from gi.repository import Gio
from . import sql
from . import table
class FakeRow(GObject.GObject):
def __init__(self, data):
GObject.GObject.__init__(self)
self.rowid = data["fakeid"]
self.name = data["name"]
class FakeTable(table.Table):
def __init__(self):
table.Table.__init__(self, "fake_table")
self.reset()
def do_create(self):
sql.execute("CREATE TABLE IF NOT EXISTS fake_table "
"(fakeid INTEGER PRIMARY KEY, name TEXT UNIQUE)")
def do_factory(self, row):
return FakeRow(row)
def do_insert(self, name):
return sql.execute("INSERT INTO fake_table (name) VALUES (?)", [ name ])
def do_lookup(self, name):
return sql.execute("SELECT * FROM fake_table WHERE name=?", [ name ])
class FakeModel(table.Model, FakeTable):
def __init__(self):
table.Model.__init__(self, "fake_table", "lower(name)")
self.reset()
class FakeChild(table.Child):
def __init__(self):
table.Child.__init__(self, "fake_child", "parentid", "lower(name)")
self.reset()
def do_create(self):
sql.execute("CREATE TABLE IF NOT EXISTS fake_child "
"(fakeid INTEGER PRIMARY KEY, "
"parentid INTEGER, "
"name TEXT UNIQUE, "
"FOREIGN KEY(parentid) REFERENCES fake_table(fakeid))")
def do_factory(self, row):
return FakeRow(row)
def do_insert(self, parent, name):
return sql.execute("INSERT INTO fake_child (parentid, name) "
"VALUES (?,?)", [ parent.rowid, name ])
class TestTable(unittest.TestCase):
def test_init(self):
fake = FakeTable()
self.assertIsInstance(fake.cache, dict)
self.assertEqual(fake.table, "fake_table")
def test_interface(self):
with self.assertRaises(NotImplementedError):
table.Table.do_create(None)
with self.assertRaises(NotImplementedError):
table.Table.do_factory(None, None)
with self.assertRaises(NotImplementedError):
table.Table.do_insert(None, "Text")
with self.assertRaises(NotImplementedError):
table.Table.do_lookup(None, "Text")
def test_insert_delete(self):
fake = FakeTable()
row = fake.insert("Test Name")
self.assertIsInstance(row, FakeRow)
self.assertEqual(fake.cache[1], row)
self.assertEqual(fake.get(1), row)
fake.delete(row)
self.assertEqual(fake.cache, { })
def test_find(self):
fake = FakeTable()
row = fake.find("Test Name")
self.assertIsInstance(row, FakeRow)
self.assertEqual(fake.cache[1], row)
self.assertEqual(fake.find("Test Name"), row)
def test_lookup(self):
fake = FakeTable()
row = fake.insert("Test Name")
fake.cache.clear()
row = fake.lookup("Test Name")
self.assertEqual(row.name, "Test Name")
self.assertEqual(fake.cache, { 1 : row })
self.assertEqual(fake.lookup("Test Name"), row)
def test_reset(self):
fake = FakeTable()
sql.execute("SELECT fakeid,name FROM fake_table")
fake.insert("Test Name")
fake.reset()
self.assertEqual(fake.cache, { })
class TestModel(unittest.TestCase):
def items_changed(self, table, pos, rm, add):
self.changed = (pos, rm, add)
def setUp(self):
self.changed = None
def test_init(self):
fake = FakeModel()
self.assertIsInstance(fake, GObject.GObject)
self.assertIsInstance(fake, Gio.ListModel)
self.assertIsInstance(fake, table.Table)
self.assertEqual(fake.order, "lower(name)")
def test_insert_delete(self):
fake = FakeModel()
fake.connect("items-changed", self.items_changed)
row = fake.insert("Test Row")
self.assertEqual(self.changed, (0, 0, 1))
fake.delete(row)
self.assertEqual(self.changed, (0, 1, 0))
def test_model(self):
fake = FakeModel()
self.assertEqual(fake.get_item_type(), GObject.TYPE_PYOBJECT)
self.assertEqual(fake.get_n_items(), 0)
c = fake.insert("C")
self.assertEqual(fake.get_n_items(), 1)
self.assertEqual(fake.get_item(0), c)
b = fake.insert("B")
self.assertEqual(fake.get_n_items(), 2)
self.assertEqual(fake.get_item(0), b)
self.assertEqual(fake.get_item(1), c)
a = fake.insert("A")
self.assertEqual(fake.get_n_items(), 3)
self.assertEqual(fake.get_item(0), a)
self.assertEqual(fake.get_item(1), b)
self.assertEqual(fake.get_item(2), c)
def test_reset(self):
fake = FakeModel()
fake.insert("Test Row")
fake.insert("Test Row 2")
fake.connect("items-changed", self.items_changed)
fake.reset()
self.assertEqual(self.changed, (0, 2, 0))
class TestChild(unittest.TestCase):
def test_init(self):
child = FakeChild()
self.assertIsInstance(child, table.Table)
self.assertEqual(child.parent, "parentid")
self.assertEqual(child.order, "lower(name)")
def test_children(self):
model = FakeModel()
child = FakeChild()
parent = model.insert("Fake Parent")
self.assertEqual(child.get_n_children(parent), 0)
c = child.insert(parent, "C")
b = child.insert(parent, "B")
a = child.insert(parent, "A")
self.assertEqual(child.get_n_children(parent), 3)
self.assertEqual(child.get_child(parent, 0), a)
self.assertEqual(child.get_child(parent, 1), b)
self.assertEqual(child.get_child(parent, 2), c)
self.assertEqual(child.get_child_index(parent, a), 0)
self.assertEqual(child.get_child_index(parent, b), 1)
self.assertEqual(child.get_child_index(parent, c), 2)

View File

@ -1,95 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import datetime
import db
import pathlib
import sqlite3
import unittest
from gi.repository import GObject
class TestTrack(unittest.TestCase):
def setUp(self):
db.reset()
def test_init(self):
track = db.make_fake_track(1, 1.234, "Test Title", "/a/b/c/1.ogg")
self.assertIsInstance(track.library, db.library.Library)
self.assertIsInstance(track.artist, db.artist.Artist)
self.assertIsInstance(track.album, db.album.Album)
self.assertIsInstance(track.disc, db.disc.Disc)
self.assertIsInstance(track.decade, db.decade.Decade)
self.assertIsInstance(track.year, db.year.Year)
self.assertEqual(track.number, 1)
self.assertEqual(track.playcount, 0)
self.assertIsNone(track.lastplayed, None)
self.assertEqual(track.length, 1.234)
self.assertEqual(track.title, "Test Title")
def test_genres(self):
track = db.make_fake_track(1, 1.234, "Test Title", "/a/b/c/1.ogg")
self.assertEqual(track.genres(), [ ])
genre = db.genre.Table.find("Test Genre")
genre.add_track(track)
self.assertEqual(track.genres(), [ genre ])
db.track.Table.delete(track)
self.assertEqual(genre.get_n_tracks(), 0)
def test_playlists(self):
new = db.user.Table.lookup("New Tracks")
favorites = db.user.Table.lookup("Favorites")
track = db.make_fake_track(1, 1.234, "Test Title", "/a/b/c/1.ogg")
self.assertEqual(track.playlists(), [ new ])
favorites.add_track(track)
self.assertEqual(track.playlists(), [ favorites, new ])
db.track.Table.delete(track)
self.assertEqual(new.get_n_tracks(), 0)
self.assertEqual(favorites.get_n_tracks(), 0)
def test_played(self):
track = db.make_fake_track(1, 1.234, "Test Title", "/a/b/c/1.ogg")
track.played()
self.assertEqual(track.playcount, 1)
self.assertEqual(track.lastplayed.date(), datetime.date.today())
class TestTrackTable(unittest.TestCase):
def setUp(self):
db.reset()
def test_init(self):
table = db.track.TrackTable()
self.assertIsInstance(table, db.table.Table)
self.assertEqual(table.table, "tracks")
self.assertIsInstance(db.track.Table, db.track.TrackTable)
db.sql.execute("SELECT trackid,libraryid,artistid,albumid,discid,decadeid,yearid FROM tracks")
db.sql.execute("SELECT number,playcount,lastplayed,length,title,path FROM tracks")
def test_insert(self):
library = db.library.Table.find(pathlib.Path("/a/b/c"))
artist = db.artist.Table.find("Test Artist", "test artist")
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
disc = album.find_disc(1, None)
decade = db.decade.Table.find(2020)
year = decade.find_year(2021)
track = db.track.Table.insert(library, artist, album, disc, decade,
year, 1, 1.234, "Test Title",
pathlib.Path("/a/b/c/d.efg"))
self.assertIsInstance(track, db.track.Track)
with self.assertRaises(sqlite3.IntegrityError):
db.track.Table.insert(library, artist, album, disc, decade, year,
1, 1.234, "Test Title", pathlib.Path("/a/b/c/d.efg"))
def test_lookup(self):
track = db.make_fake_track(1, 1.234, "Test Title", "/a/b/c/d.efg")
self.assertEqual(db.track.Table.lookup(pathlib.Path("/a/b/c/d.efg")), track)
self.assertIsNone(db.library.Table.lookup(pathlib.Path("/a/b/d/h.ijk")))
def test_find(self):
with self.assertRaises(NotImplementedError):
db.track.Table.find(pathlib.Path("/a/b/c/d.efg"))

View File

@ -1,386 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import db
import sqlite3
import unittest
from gi.repository import GObject
from . import sql
class TestCollection(unittest.TestCase):
def track_added(self, plist, added):
self.added = added
def track_removed(self, plist, removed, adjusted_current):
self.removed = (removed, adjusted_current)
def refreshed(self, plist):
self.refreshed = True
def setUp(self): db.reset()
def test_init(self):
collection = db.user.Table.find("Collection")
self.assertIsInstance(collection, db.playlist.Playlist)
self.assertIsInstance(collection, db.user.Collection)
self.assertEqual(collection.name, "Collection")
self.assertEqual(collection.icon_name, "media-playback-start")
self.assertTrue(collection.plist_state.loop)
def test_tracks(self):
collection = db.user.Table.find("Collection")
collection.connect("track-added", self.track_added)
self.assertEqual(collection.get_n_tracks(), 0)
self.assertEqual(collection.get_tracks(), [ ])
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
self.assertEqual(collection.get_n_tracks(), 1)
self.assertEqual(collection.get_track(0), track)
self.assertEqual(collection.get_tracks(), [ track ])
self.assertEqual(collection.get_track_index(track), 0)
self.assertEqual(self.added, track)
collection.connect("track-removed", self.track_removed)
db.track.Table.delete(track)
self.assertEqual(collection.get_tracks(), [ ])
self.assertEqual(self.removed, (track, False))
def test_library_enabled(self):
collection = db.user.Table.find("Collection")
track1 = db.make_fake_track(1, 1, "Test Track 1", "/a/b/c/1.ogg")
track2 = db.make_fake_track(2, 2, "Test Track 2", "/a/b/c/2.ogg")
self.assertEqual(collection.get_n_tracks(), 2)
self.assertEqual(collection.get_tracks(), [ track1, track2 ])
collection.connect("refreshed", self.refreshed)
track1.library.enabled = False
self.assertEqual(collection.get_n_tracks(), 0)
self.assertEqual(collection.get_tracks(), [ ])
self.assertTrue(self.refreshed)
self.refreshed = None
track1.library.enabled = True
self.assertEqual(collection.get_n_tracks(), 2)
self.assertEqual(collection.get_tracks(), [ track1, track2 ])
self.assertTrue(self.refreshed)
class TestFavorites(unittest.TestCase):
def track_added(self, plist, added):
self.added = added
def track_removed(self, plist, removed, adjusted_current):
self.removed = (removed, False)
def setUp(self): db.reset()
def test_init(self):
favorites = db.user.Table.find("Favorites")
self.assertIsInstance(favorites, db.playlist.MappedPlaylist)
self.assertIsInstance(favorites, db.user.UserPlaylist)
self.assertEqual(favorites.name, "Favorites")
self.assertEqual(favorites.icon_name, "emmental-favorites")
self.assertEqual(favorites.map_table, "playlist_map")
self.assertFalse(favorites.plist_state.loop)
self.assertTrue(favorites.can_add_remove_tracks())
def test_add_remove_track(self):
favorites = db.user.Table.find("Favorites")
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
favorites.connect("track-added", self.track_added)
self.assertEqual(favorites.get_n_tracks(), 0)
self.assertEqual(favorites.get_tracks(), [ ])
self.assertTrue(favorites.add_track(track))
self.assertFalse(favorites.add_track(track))
self.assertEqual(favorites.get_n_tracks(), 1)
self.assertEqual(favorites.get_track(0), track)
self.assertEqual(favorites.get_tracks(), [ track ])
self.assertEqual(favorites.get_track_index(track), 0)
self.assertEqual(self.added, track)
favorites.connect("track-removed", self.track_removed)
self.assertTrue(favorites.remove_track(track))
self.assertFalse(favorites.remove_track(track))
self.assertEqual(favorites.get_n_tracks(), 0)
self.assertEqual(favorites.get_tracks(), [ ])
self.assertEqual(self.removed, (track, False))
class TestNewTracks(unittest.TestCase):
def track_added(self, plist, added):
self.added = added
def track_removed(self, plist, removed, adjusted_current):
self.removed = (removed, adjusted_current)
def setUp(self): db.reset()
def test_init(self):
new = db.user.Table.find("New Tracks")
self.assertIsInstance(new, db.playlist.MappedPlaylist)
self.assertIsInstance(new, db.user.UserPlaylist)
self.assertEqual(new.name, "New Tracks")
self.assertEqual(new.icon_name, "starred")
self.assertEqual(new.map_table, "temp_playlist_map")
self.assertFalse(new.plist_state.loop)
self.assertFalse(new.can_add_remove_tracks())
def test_add_remove_track(self):
new = db.user.Table.find("New Tracks")
new.connect("track-added", self.track_added)
self.assertEqual(new.get_n_tracks(), 0)
self.assertEqual(new.get_tracks(), [ ])
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
self.assertEqual(new.get_n_tracks(), 1)
self.assertEqual(new.get_track(0), track)
self.assertEqual(new.get_tracks(), [ track ])
self.assertEqual(new.get_track_index(track), 0)
self.added = track
new.connect("track-removed", self.track_removed)
self.assertTrue(new.remove_track(track))
self.assertFalse(new.remove_track(track))
self.assertEqual(new.get_n_tracks(), 0)
self.assertEqual(new.get_tracks(), [ ])
self.assertEqual(self.removed, (track, False))
class TestPrevious(unittest.TestCase):
def track_added(self, plist, added):
self.added = added
def track_removed(self, plist, removed, adjusted_current):
self.removed = (removed, adjusted_current)
def setUp(self): db.reset()
def test_init(self):
previous = db.user.Table.find("Previous")
self.assertIsInstance(previous, db.user.UserPlaylist)
self.assertIsInstance(previous, db.user.Previous)
self.assertEqual(previous.name, "Previous")
self.assertEqual(previous.icon_name, "media-skip-backward")
self.assertEqual(previous.map_table, "temp_playlist_map")
self.assertEqual(previous.plist_state.sort, [ "temp_playlist_map.rowid DESC" ])
self.assertFalse(previous.plist_state.loop)
self.assertFalse(previous.can_add_remove_tracks())
def test_add_remove_track(self):
previous = db.user.Table.find("Previous")
track1 = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
track2 = db.make_fake_track(2, 2, "Test Track 2", "/a/b/c/2.ogg")
previous.connect("track-added", self.track_added)
self.assertEqual(previous.get_n_tracks(), 0)
self.assertEqual(previous.get_tracks(), [ ])
self.assertTrue(previous.add_track(track1))
self.assertEqual(previous.get_n_tracks(), 1)
self.assertEqual(previous.get_track(0), track1)
self.assertEqual(previous.get_tracks(), [ track1 ])
self.assertEqual(previous.get_track_index(track1), 0)
self.assertEqual(self.added, track1)
self.assertTrue(previous.add_track(track2))
self.assertEqual(previous.get_n_tracks(), 2)
self.assertEqual(previous.get_track(0), track2)
self.assertEqual(previous.get_track(1), track1)
self.assertEqual(previous.get_tracks(), [ track2, track1 ])
self.assertEqual(previous.get_track_index(track2), 0)
self.assertEqual(previous.get_track_index(track1), 1)
self.assertEqual(self.added, track2)
previous.connect("track-removed", self.track_removed)
self.assertTrue(previous.add_track(track1))
self.assertEqual(previous.get_n_tracks(), 2)
self.assertEqual(previous.get_track(0), track1)
self.assertEqual(previous.get_track(1), track2)
self.assertEqual(previous.get_tracks(), [ track1, track2 ])
self.assertEqual(previous.get_track_index(track1), 0)
self.assertEqual(previous.get_track_index(track2), 1)
self.assertEqual(self.removed, (track1, False))
self.assertEqual(self.added, track1)
self.assertTrue(previous.remove_track(track1))
self.assertFalse(previous.remove_track(track1))
self.assertEqual(previous.get_n_tracks(), 1)
self.assertEqual(previous.get_tracks(), [ track2 ])
self.assertEqual(previous.get_track_index(track2), 0)
self.assertEqual(self.removed, (track1, True))
def test_previous_track(self):
previous = db.user.Table.find("Previous")
self.assertEqual(previous.get_property("current"), -1)
previous.add_track(db.make_fake_track(1, 1, "Track 1", "/a/b/c/1.ogg"))
self.assertEqual(previous.get_property("current"), 0)
previous.add_track(db.make_fake_track(2, 2, "Track 2", "/a/b/c/2.ogg"))
self.assertEqual(previous.get_property("current"), 0)
previous.add_track(db.make_fake_track(3, 3, "Track 3", "/a/b/c/3.ogg"))
self.assertEqual(previous.get_property("current"), 0)
self.assertEqual(previous.previous_track(), previous.get_track(1))
self.assertEqual(previous.previous_track(), previous.get_track(2))
self.assertIsNone(previous.previous_track())
self.assertIsNone(previous.previous_track())
def test_next_track(self):
previous = db.user.Table.find("Previous")
previous.add_track(db.make_fake_track(1, 1, "Track 1", "/a/b/c/1.ogg"))
previous.add_track(db.make_fake_track(2, 2, "Track 2", "/a/b/c/2.ogg"))
previous.add_track(db.make_fake_track(3, 3, "Track 3", "/a/b/c/3.ogg"))
previous.current = 2
self.assertEqual(previous.next_track(), previous.get_track(1))
self.assertEqual(previous.current, 1)
self.assertEqual(previous.next_track(), previous.get_track(0))
self.assertEqual(previous.current, 0)
self.assertIsNone(previous.next_track())
self.assertEqual(previous.current, -1)
self.assertIsNone(previous.next_track())
self.assertEqual(previous.current, -1)
class TestQueuedTracks(unittest.TestCase):
def track_added(self, plist, added):
self.added = added
def track_removed(self, plist, removed, adjusted_current):
self.removed = (removed, adjusted_current)
def setUp(self): db.reset()
def test_init(self):
queued = db.user.Table.find("Queued Tracks")
self.assertIsInstance(queued, db.user.UserPlaylist)
self.assertIsInstance(queued, db.user.QueuedTracks)
self.assertEqual(queued.name, "Queued Tracks")
self.assertEqual(queued.icon_name, "media-skip-forward")
self.assertEqual(queued.map_table, "playlist_map")
self.assertEqual(queued.plist_state.sort, [ "playlist_map.rowid ASC" ])
self.assertFalse(queued.plist_state.loop)
self.assertTrue(queued.can_add_remove_tracks())
def test_add_remove_track(self):
queued = db.user.Table.find("Queued Tracks")
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
queued.connect("track-added", self.track_added)
self.assertEqual(queued.get_n_tracks(), 0)
self.assertEqual(queued.get_tracks(), [ ])
self.assertTrue(queued.add_track(track))
self.assertFalse(queued.add_track(track))
self.assertEqual(queued.get_n_tracks(), 1)
self.assertEqual(queued.get_track(0), track)
self.assertEqual(queued.get_tracks(), [ track ])
self.assertEqual(queued.get_track_index(track), 0)
self.assertEqual(self.added, track)
queued.connect("track-removed", self.track_removed)
self.assertTrue(queued.remove_track(track))
self.assertFalse(queued.remove_track(track))
self.assertEqual(queued.get_n_tracks(), 0)
self.assertEqual(queued.get_tracks(), [ ])
self.assertEqual(self.removed, (track, False))
def test_next_track(self):
queued = db.user.Table.find("Queued Tracks")
queued.add_track(db.make_fake_track(1, 1, "Test 1", "/a/b/c/1.ogg"))
queued.add_track(db.make_fake_track(2, 2, "Test 2", "/a/b/c/2.ogg"))
queued.add_track(db.make_fake_track(3, 3, "Test 3", "/a/b/c/3.ogg"))
self.assertEqual(queued.next_track(), db.track.Table.lookup("/a/b/c/1.ogg"))
self.assertEqual(queued.get_n_tracks(), 2)
self.assertEqual(queued.next_track(), db.track.Table.lookup("/a/b/c/2.ogg"))
self.assertEqual(queued.get_n_tracks(), 1)
self.assertEqual(queued.next_track(), db.track.Table.lookup("/a/b/c/3.ogg"))
self.assertEqual(queued.get_n_tracks(), 0)
self.assertIsNone(queued.next_track())
class TestUserPlaylist(unittest.TestCase):
def track_added(self, plist, added):
self.added = added
def track_removed(self, plist, removed, adjusted_current):
self.removed = (removed, adjusted_current)
def setUp(self): db.reset()
def test_init(self):
plist = db.user.Table.find("Test Playlist")
self.assertIsInstance(plist, db.playlist.MappedPlaylist)
self.assertIsInstance(plist, db.user.UserPlaylist)
self.assertEqual(plist.name, "Test Playlist")
self.assertEqual(plist.icon_name, "audio-x-generic")
self.assertEqual(plist.map_table, "playlist_map")
self.assertFalse(plist.plist_state.loop)
self.assertTrue(plist.can_add_remove_tracks())
def test_delete(self):
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
plist = db.user.Table.find("Test Playlist")
plist.add_track(track)
plist.delete()
self.assertEqual(plist.get_n_tracks(), 0)
self.assertIsNone(db.user.Table.lookup("Test Playlist"))
def test_add_remove_track(self):
plist = db.user.Table.find("Test Playlist")
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
plist.connect("track-added", self.track_added)
self.assertEqual(plist.get_n_tracks(), 0)
self.assertEqual(plist.get_tracks(), [ ])
self.assertTrue(plist.add_track(track))
self.assertFalse(plist.add_track(track))
self.assertEqual(plist.get_n_tracks(), 1)
self.assertEqual(plist.get_track(0), track)
self.assertEqual(plist.get_tracks(), [ track ])
self.assertEqual(plist.get_track_index(track), 0)
self.assertEqual(self.added, track)
plist.connect("track-removed", self.track_removed)
self.assertTrue(plist.remove_track(track))
self.assertFalse(plist.remove_track(track))
self.assertEqual(plist.get_n_tracks(), 0)
self.assertEqual(plist.get_tracks(), [ ])
self.assertEqual(self.removed, (track, False))
class TestUserTable(unittest.TestCase):
def setUp(self):
db.reset()
def test_init(self):
table = db.user.UserTable()
self.assertIsInstance(table, db.playlist.Model)
self.assertEqual(table.table, "playlists")
self.assertEqual(table.order, "sort")
self.assertIsInstance(db.user.Table, db.user.UserTable)
db.sql.execute("SELECT playlistid,plstateid,name,sort FROM playlists")
def test_insert(self):
table = db.user.UserTable()
playlist = table.find("Test Playlist")
self.assertIsInstance(playlist, db.user.UserPlaylist)
self.assertEqual(playlist._name, "Test Playlist")
self.assertEqual(playlist._rowkey, "playlistid")
with self.assertRaises(sqlite3.IntegrityError):
db.user.Table.insert("Test Playlist")
def test_lookup(self):
playlist = db.user.Table.insert("Test Playlist")
self.assertEqual(db.user.Table.lookup("Test Playlist"), playlist)
self.assertIsNone(db.user.Table.lookup("none"))
def test_default_playlists(self):
table = db.user.UserTable()
self.assertEqual(table.get_n_items(), 5)
self.assertEqual(table.get_item(0).name, "Collection")
self.assertEqual(table.get_item(1).name, "Favorites")
self.assertEqual(table.get_item(2).name, "New Tracks")
self.assertEqual(table.get_item(3).name, "Previous")
self.assertEqual(table.get_item(4).name, "Queued Tracks")

View File

@ -1,79 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
import db
import sqlite3
import unittest
from gi.repository import GObject
class TestYear(unittest.TestCase):
def track_added(self, plist, added):
self.added = added
def track_removed(self, plist, removed, adjusted_current):
self.removed = (removed, adjusted_current)
def setUp(self):
db.reset()
def test_init(self):
decade = db.decade.Table.find(2020)
year = decade.find_year(2021)
self.assertIsInstance(year, db.playlist.Playlist)
self.assertEqual(year.get_property("name"), "2021")
self.assertEqual(year.get_property("year"), 2021)
self.assertEqual(year.get_property("icon-name"), "x-office-calendar")
def test_delete(self):
decade = db.decade.Table.find(2020)
year = decade.find_year(2021)
year.delete()
self.assertIsNone(db.year.Table.lookup(2021))
def test_tracks(self):
decade = db.decade.Table.find(2020)
year = decade.find_year(2021)
year.connect("track-added", self.track_added)
self.assertEqual(year.get_n_tracks(), 0)
self.assertEqual(year.get_tracks(), [ ])
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
self.assertEqual(year.get_n_tracks(), 1)
self.assertEqual(year.get_track(0), track)
self.assertEqual(year.get_tracks(), [ track ])
self.assertEqual(year.get_track_index(track), 0)
self.assertEqual(self.added, track)
year.connect("track-removed", self.track_removed)
db.track.Table.delete(track)
self.assertEqual(year.get_tracks(), [ ])
self.assertEqual(self.removed, (track, False))
class TestYearTable(unittest.TestCase):
def setUp(self):
db.reset()
def test_init(self):
table = db.year.YearTable()
self.assertIsInstance(table, db.playlist.ChildModel)
self.assertEqual(table.table, "years")
self.assertEqual(table.order, "year")
self.assertIsInstance(db.year.Table, db.year.YearTable)
db.sql.execute("SELECT yearid,decadeid,plstateid,year FROM years")
def test_insert(self):
decade = db.decade.Table.insert(2020)
year = decade.find_year(2021)
self.assertIsInstance(year, db.year.Year)
self.assertEqual(year._year, 2021)
self.assertEqual(year._rowkey, "yearid")
with self.assertRaises(sqlite3.IntegrityError):
db.year.Table.insert(decade, 2021)
def test_lookup(self):
decade = db.decade.Table.find(2020)
year = decade.find_year(2021)
self.assertEqual(db.year.Table.lookup(2021), year)
self.assertIsNone(db.year.Table.lookup(2022))

View File

@ -1,178 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
#
# Table: tracks
# +---------+-----------+------------+---------+--------+--------+
# | trackid | libraryid | artistid | albumid | discid | yearid |
# +---------+-----------+------------+---------+--------+--------+
# | number | playcount | lastplayed | length | title | path |
# +---------+-----------+------------+---------+--------+--------|
import datetime
import pathlib
from gi.repository import GObject
from . import artist
from . import album
from . import decade
from . import disc
from . import genre
from . import library
from . import sql
from . import table
from . import user
from . import year
class Track(GObject.GObject):
def __init__(self, row):
GObject.GObject.__init__(self)
self._trackid = row["trackid"]
self._library = library.Table.get(row["libraryid"])
self._artist = artist.Table.get(row["artistid"])
self._album = album.Table.get(row["albumid"])
self._disc = disc.Table.get(row["discid"])
self._decade = decade.Table.get(row["decadeid"])
self._year = year.Table.get(row["yearid"])
self._number = row["number"]
self._playcount = row["playcount"]
self._lastplayed = row["lastplayed"]
self._length = row["length"]
self._title = row["title"]
self._path = pathlib.Path(row["path"])
@GObject.Property
def rowid(self): return self._trackid
@GObject.Property
def library(self): return self._library
@GObject.Property
def artist(self): return self._artist
@GObject.Property
def album(self): return self._album
@GObject.Property
def disc(self): return self._disc
@GObject.Property
def decade(self): return self._decade
@GObject.Property
def year(self): return self._year
@GObject.Property
def number(self): return self._number
@GObject.Property
def playcount(self): return self._playcount
@playcount.setter
def playcount(self, newval):
self._playcount = self.update("playcount", newval)
@GObject.Property
def lastplayed(self): return self._lastplayed
@lastplayed.setter
def lastplayed(self, newval):
self._lastplayed = self.update("lastplayed", newval)
@GObject.Property
def length(self): return self._length
@GObject.Property
def title(self): return self._title
@GObject.Property
def path(self): return self._path
def genres(self):
rows = sql.execute(f"SELECT genreid FROM genre_map WHERE trackid=?",
[ self.rowid ]).fetchall()
return [ genre.Table.get(row[0]) for row in rows ]
def playlists(self):
rows = sql.execute(f"SELECT playlistid FROM playlist_map UNION "
f"SELECT playlistid FROM temp_playlist_map "
f"WHERE trackid=?", [ self.rowid ]).fetchall()
return [ user.Table.get(row[0]) for row in rows ]
def update(self, column, newval):
sql.execute(f"UPDATE tracks SET {column}=? WHERE trackid=?",
[ newval, self.rowid ])
return newval
def played(self):
self.playcount += 1
self.lastplayed = datetime.datetime.now()
sql.commit()
class TrackTable(table.Table):
def __init__(self):
table.Table.__init__(self, "tracks")
def do_create(self):
sql.execute("CREATE TABLE IF NOT EXISTS tracks "
"(trackid INTEGER PRIMARY KEY, "
" libraryid INTEGER, "
" artistid INTEGER, "
" albumid INTEGER, "
" discid INTEGER, "
" decadeid INTEGER, "
" yearid INTEGER, "
" number INTEGER, "
" playcount INTEGER DEFAULT 0,"
" lastplayed TIMESTAMP DEFAULT NULL, "
" length REAL, "
" title TEXT, "
" path TEXT UNIQUE, "
" FOREIGN KEY(libraryid) REFERENCES libraries(libraryid), "
" FOREIGN KEY(artistid) REFERENCES artists(artistid), "
" FOREIGN KEY(albumid) REFERENCES albums(albumid), "
" FOREIGN KEY(discid) REFERENCES discs(discid), "
" FOREIGN KEY(yearid) REFERENCES years(yearid))")
def do_factory(self, row):
return Track(row)
def do_insert(self, library, artist, album, disc, decade,
year, number, length, title, path):
return sql.execute("INSERT INTO tracks (libraryid, artistid, albumid, "
"discid, decadeid, yearid, "
"number, length, title, path) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
[ library.rowid, artist.rowid, album.rowid, disc.rowid,
decade.rowid, year.rowid, number, length, title, str(path) ])
def do_lookup(self, path):
return sql.execute("SELECT * FROM tracks WHERE path=?", [ str(path) ])
def delete(self, track):
for plist in track.genres() + track.playlists():
plist.remove_track(track)
plists = [ track.artist, track.album, track.disc,
track.decade, track.year, track.library,
user.Table.find("Collection") ]
adjust = [ p.track_adjusts_current(track) for p in plists ]
super().delete(track)
for (plist, adjust) in zip(plists, adjust):
plist.remove_track(track, adjust)
def find(self, *args):
raise NotImplementedError
def insert(self, library, artist, album, disc, decade,
year, number, length, title, path):
track = super().insert(library, artist, album, disc, decade,
year, number, length, title, path)
user.Table.find("New Tracks").add_track(track)
for plist in [ artist, album, disc, decade, year, library,
user.Table.find("Collection") ]:
plist.add_track(track)
return track
Table = TrackTable()

View File

@ -1,168 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
#
# Table: playlists
# +------ ---+-----------+------+------+
# | playlistid | plstateid | name | sort |
# +-------- -+-----------+------+------+
from gi.repository import GObject
from . import playlist
from . import sql
from . import state
from . import track
class Collection(playlist.Playlist):
def __init__(self, row):
playlist.Playlist.__init__(self, row, "media-playback-start")
self._name = row["name"]
def get_n_tracks(self):
cur = sql.execute("SELECT COUNT(*) FROM tracks "
"JOIN libraries USING(libraryid) WHERE enabled=1")
return cur.fetchone()[0]
def get_track(self, n):
order = ', '.join(self.plist_state.sort)
row = sql.execute(f"SELECT * FROM tracks "
f"INNER JOIN artists USING(artistid) "
f"INNER JOIN albums USING(albumid) "
f"INNER JOIN discs USING(discid) "
f"INNER JOIN libraries USING(libraryid) "
f"WHERE libraries.enabled=1 "
f"ORDER BY {order} LIMIT 1 OFFSET ?",
[ n ]).fetchone()
return track.Table.factory(row)
def get_tracks(self):
order = ', '.join(self.plist_state.sort)
rows = sql.execute(f"SELECT * FROM tracks "
f"INNER JOIN artists USING(artistid) "
f"INNER JOIN albums USING(albumid) "
f"INNER JOIN discs USING(discid) "
f"INNER JOIN libraries USING(libraryid) "
f"WHERE libraries.enabled=1 "
f"ORDER BY {order}").fetchall()
return [ track.Table.factory(row) for row in rows ]
def get_track_index(self, track):
order = ', '.join(self.plist_state.sort)
cur = sql.execute(f"SELECT * FROM (SELECT trackid,ROW_NUMBER() "
f"OVER (ORDER BY {order}) "
f"FROM tracks "
f"INNER JOIN artists USING(artistid) "
f"INNER JOIN albums USING(albumid) "
f"INNER JOIN discs USING(discid) "
f"INNER JOIN libraries USING(libraryid) "
f"WHERE libraries.enabled=1) "
f"WHERE trackid=?", [ track.rowid ])
return cur.fetchone()[1] - 1
@GObject.Property
def name(self): return self._name
class UserPlaylist(playlist.MappedPlaylist):
def __init__(self, row, icon_name, map_table):
playlist.MappedPlaylist.__init__(self, row, icon_name, map_table)
self._name = row["name"]
def delete(self):
self.clear()
Table.delete(self)
sql.commit()
@GObject.Property
def name(self): return self._name
class Previous(UserPlaylist):
def __init__(self, row):
UserPlaylist.__init__(self, row, "media-skip-backward", "temp_playlist_map")
def add_track(self, track):
if self.get_track_index(track):
self.remove_track(track)
super().add_track(track)
self.current = 0
return True
def next_track(self):
self.current = max(-1, self.current - 1)
return self.get_current_track()
def previous_track(self):
return super().next_track()
class QueuedTracks(UserPlaylist):
def __init__(self, row):
UserPlaylist.__init__(self, row, "media-skip-forward", "playlist_map")
def next_track(self):
if track := super().next_track():
self.remove_track(track)
return track
class UserTable(playlist.Model):
def __init__(self):
playlist.Model.__init__(self, "playlists", "sort")
def do_create(self):
sql.execute("CREATE TABLE IF NOT EXISTS playlists "
"(playlistid INTEGER PRIMARY KEY, "
" plstateid INTEGER NOT NULL, "
" name TEXT UNIQUE, "
" sort TEXT, "
" FOREIGN KEY (plstateid) REFERENCES playlist_states(plstateid))")
sql.execute(f"CREATE TABLE IF NOT EXISTS playlist_map "
"(playlistid INTEGER, "
" trackid INTEGER, "
" FOREIGN KEY(playlistid) REFERENCES playlists(playlistid), "
" FOREIGN KEY(trackid) REFERENCES tracks(trackid), "
" UNIQUE(playlistid, trackid))")
sql.execute(f"CREATE TEMPORARY TABLE IF NOT EXISTS temp_playlist_map "
"(playlistid INTEGER, "
" trackid INTEGER, "
" FOREIGN KEY(playlistid) REFERENCES playlists(playlistid), "
" FOREIGN KEY(trackid) REFERENCES tracks(trackid), "
" UNIQUE(playlistid, trackid))")
self.find("Collection", loop=True)
self.find("Favorites")
self.find("New Tracks")
self.find("Previous", sort=["temp_playlist_map.rowid DESC"])
self.find("Queued Tracks", sort=["playlist_map.rowid ASC"])
def do_drop(self):
sql.execute("DROP TABLE playlists")
sql.execute("DROP TABLE playlist_map")
sql.execute("DROP TABLE temp_playlist_map")
def do_factory(self, row):
if row["name"] == "Collection":
return Collection(row)
elif row["name"] == "Favorites":
return UserPlaylist(row, "emmental-favorites", "playlist_map")
elif row["name"] == "New Tracks":
return UserPlaylist(row, "starred", "temp_playlist_map")
elif row["name"] == "Previous":
return Previous(row)
elif row["name"] == "Queued Tracks":
return QueuedTracks(row)
return UserPlaylist(row, "audio-x-generic", "playlist_map")
def do_insert(self, plstate, name):
return sql.execute("INSERT INTO playlists (plstateid, name, sort) "
"VALUES (?, ?, ?)", [ plstate.rowid, name, name.casefold() ])
def do_lookup(self, name):
return sql.execute("SELECT * FROM playlists WHERE name=?", [ name ])
def find(self, name, loop=False, sort=state.DefaultSort):
if (res := self.lookup(name)) == None:
res = self.insert(name, loop=loop, sort=sort)
return res
Table = UserTable()

View File

@ -1,49 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
#
# Table: years
# +--------+----------+-----------+------+
# | yearid | decadeid | plstateid | year |
# +--------+----------+-----------+------+
from gi.repository import GObject
from . import playlist
from . import sql
class Year(playlist.Playlist):
def __init__(self, row):
playlist.Playlist.__init__(self, row, "x-office-calendar")
self._year = row["year"]
def delete(self): Table.delete(self)
@GObject.Property
def name(self): return str(self._year)
@GObject.Property
def year(self): return self._year
class YearTable(playlist.ChildModel):
def __init__(self):
playlist.ChildModel.__init__(self, "years", "decadeid", "year")
def do_create(self):
sql.execute("CREATE TABLE IF NOT EXISTS years "
"(yearid INTEGER PRIMARY KEY, "
" decadeid INTEGER, "
" plstateid INTEGER NOT NULL, "
" year INTEGER UNIQUE, "
" FOREIGN KEY(decadeid) REFERENCES decades(decadeid), "
" FOREIGN KEY(plstateid) REFERENCES playlist_states(plstateid))")
def do_insert(self, plstate, dec, year):
return sql.execute("INSERT INTO years (decadeid, plstateid, year) "
"VALUES (?, ?, ?)", [ dec.rowid, plstate.rowid, year ])
def do_factory(self, row):
return Year(row)
def do_lookup(self, year):
return sql.execute("SELECT * FROM years WHERE year=?", [ year ])
Table = YearTable()

View File

@ -1,9 +1,8 @@
#!/usr/bin/python
# Copyright 2021 (c) Anna Schumaker.
import lib
import tagdb
lib.settings.load()
tagdb.load()
# Copyright 2022 (c) Anna Schumaker.
"""The main Emmental application."""
import sys
import emmental
import ui
ui.Application.run()
if __name__ == "__main__":
emmental.Application().run(sys.argv)

343
emmental/__init__.py Normal file
View File

@ -0,0 +1,343 @@
# Copyright 2022 (c) Anna Schumaker.
"""Set up our Application."""
import musicbrainzngs
import pathlib
from . import gsetup
from . import action
from . import audio
from . import db
from . import header
from . import listenbrainz
from . import mpris2
from . import nowplaying
from . import options
from . import playlist
from . import sidebar
from . import tracklist
from . import window
from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gio
from gi.repository import Adw
MAJOR_VERSION = 3
MINOR_VERSION = 2
MICRO_VERSION = 0
VERSION_NUMBER = f"{MAJOR_VERSION}.{MINOR_VERSION}.{MICRO_VERSION}"
VERSION_STRING = f"Emmental {VERSION_NUMBER}{gsetup.DEBUG_STR}"
class Application(Adw.Application):
"""Our custom Adw.Application."""
db = GObject.Property(type=db.Connection)
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)
def __init__(self):
"""Initialize an Application."""
super().__init__(application_id=gsetup.APPLICATION_ID,
resource_base_path=gsetup.RESOURCE_PATH,
flags=Gio.ApplicationFlags.HANDLES_OPEN)
self.add_main_option_entries([options.Version])
def __add_accelerators(self, accels: list[action.ActionEntry]) -> None:
for entry in accels:
self.add_action(entry.action)
self.set_accels_for_action(f"app.{entry.name}", entry.accels)
def __load_file(self, file: pathlib.Path,
*, gapless: bool = False) -> None:
self.__stop_current_track()
if gapless:
self.player.file = file
else:
self.player.stop()
self.player.file = file
self.player.play()
def __load_path(self, src: GObject.GObject, path: pathlib.Path) -> None:
if (track := self.db.tracks.lookup(path=path)) is not None:
self.__load_track(track)
self.__load_file(path)
def __load_track(self, track: GObject.GObject, *, gapless: bool = False,
rg_auto: str = "track", restart: bool = False) -> None:
self.__load_file(track.path, gapless=gapless)
if restart:
track.restart()
else:
track.start()
self.__set_replaygain(rg_auto=rg_auto)
self.__on_jump()
def __pick_next_track(self, *, user: bool, gapless: bool = False) -> None:
(track, rg_auto, restart) = self.factory.next_track(user=user)
self.__load_track(track, gapless=gapless,
rg_auto=rg_auto, restart=restart)
def __on_jump(self, nowplay: nowplaying.Card | None = None) -> None:
"""Handle a jump event."""
self.win.tracklist.scroll_to_track(self.db.tracks.current_track)
def __on_seek(self, nowplay: nowplaying.Card, newpos: float) -> None:
"""Handle a seek event."""
self.player.seek(newpos)
self.mpris.player.seeked(newpos)
def __seek(self, player: mpris2.player.Player, offset: float) -> None:
self.player.seek(self.player.position + offset)
def __set_position(self, player: mpris2.player.Player,
trackid: str, position: float) -> None:
self.player.seek(position)
def __set_replaygain(self, *args, rg_auto="track") -> None:
enabled = self.db.settings["audio.replaygain.enabled"]
mode = self.db.settings["audio.replaygain.mode"]
mode = rg_auto if mode == "auto" else mode
self.player.set_replaygain(enabled, mode)
def __stop_current_track(self) -> None:
if self.db.tracks.current_track is not None:
self.db.tracks.current_track.stop(self.player.playtime)
def __system_next(self, player: audio.Player, gapless: bool) -> None:
self.player.pause_on_load = self.autopause == 0
if self.autopause >= 0:
self.autopause -= 1
self.__pick_next_track(user=False, gapless=gapless)
def __user_next(self, *args) -> None:
self.__pick_next_track(user=True)
def __user_previous(self, *args) -> None:
self.__load_track(self.factory.previous_track(),
rg_auto="track", restart=True)
def __track_requested(self, factory: playlist.Factory, track,
rg_auto: str, restarted: bool) -> None:
self.__load_track(track, rg_auto=rg_auto, restart=restarted)
def __tracks_table_loaded(self, track_table, param) -> None:
if track_table.current_track is not None:
self.player.file = track_table.current_track.path
self.player.pause()
track_table.current_track.start()
self.__on_jump()
def build_header(self) -> header.Header:
"""Build a new header instance."""
hdr = header.Header(sql=self.db, title=VERSION_STRING)
for prop in ["bg-enabled", "bg-volume", "volume"]:
hdr.bind_property(prop, self.player, prop)
hdr.bind_property("listenbrainz-token", self.lbrainz, "user-token")
for (setting, property) in [("audio.volume", "volume"),
("audio.background.enabled", "bg-enabled"),
("audio.background.volume", "bg-volume"),
("audio.replaygain.enabled", "rg-enabled"),
("audio.replaygain.mode", "rg-mode"),
("listenbrainz.token",
"listenbrainz_token")]:
self.db.settings.bind_setting(setting, hdr, property)
self.__add_accelerators(hdr.accelerators)
hdr.connect("notify::rg-enabled", self.__set_replaygain)
hdr.connect("notify::rg-mode", self.__set_replaygain)
hdr.connect("track-requested", self.__load_path)
self.__set_replaygain()
return hdr
def build_now_playing(self) -> nowplaying.Card:
"""Build a new now playing card."""
playing = nowplaying.Card()
playing.bind_property("autopause", self, "autopause",
GObject.BindingFlags.BIDIRECTIONAL)
for prop in ["title", "album", "artist", "album-artist", "playing",
"position", "duration", "artwork", "have-track"]:
self.player.bind_property(prop, playing, prop)
self.db.tracks.bind_property("have-current-track",
playing, "have-db-track")
self.db.tracks.bind_property("current-favorite", playing, "favorite",
GObject.BindingFlags.BIDIRECTIONAL)
self.factory.bind_property("can-go-next", playing, "have-next")
self.factory.bind_property("can-go-previous", playing, "have-previous")
self.db.settings.bind_setting("now-playing.prefer-artist",
playing, "prefer-artist")
self.__add_accelerators(playing.accelerators)
playing.connect("jump", self.__on_jump)
playing.connect("play", self.player.play)
playing.connect("pause", self.player.pause)
playing.connect("seek", self.__on_seek)
playing.connect("next", self.__user_next)
playing.connect("previous", self.__user_previous)
return playing
def build_sidebar(self) -> sidebar.Card:
"""Build a new sidebar card."""
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:
"""Build a new tracklist card."""
track_list = tracklist.Card(sql=self.db)
for column in track_list.columns:
name = column.get_title().lower().replace(" ", "-")
self.db.settings.bind_setting(f"tracklist.{name}.size",
column, "fixed-width")
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:
"""Build a new window instance."""
win = window.Window(VERSION_STRING,
header=self.build_header(),
now_playing=self.build_now_playing(),
sidebar=self.build_sidebar(),
tracklist=self.build_tracklist())
win.bind_property("show-sidebar", win.header, "show-sidebar",
GObject.BindingFlags.BIDIRECTIONAL)
win.bind_property("user-editing", win.now_playing, "editing")
for (setting, property) in [("window.width", "default-width"),
("window.height", "default-height"),
("now-playing.size", "now-playing-size"),
("sidebar.show", "show-sidebar")]:
self.db.settings.bind_setting(setting, win, property)
self.__add_accelerators(win.accelerators)
return win
def connect_mpris2(self) -> None:
"""Connect the mpris2 properties and functions."""
self.mpris.app.link_property("Fullscreen", self.win, "fullscreened")
self.mpris.app.connect("Raise", self.win.present)
self.mpris.app.connect("Quit", self.win.close)
for tag in ["artist", "album", "album-artist", "album-disc-number",
"title", "track-number", "duration", "file", "artwork"]:
self.player.bind_property(tag, self.mpris.player, tag)
for (prop, mpris_prop) in [("have-track", "CanPlay"),
("have-track", "CanPause"),
("have-track", "CanSeek"),
("status", "PlaybackStatus"),
("position", "Position")]:
self.player.bind_property(prop, self.mpris.player, mpris_prop)
for (prop, mpris_prop) in [("active-shuffle", "Shuffle"),
("active-loop", "LoopStatus")]:
self.factory.bind_property(prop, self.mpris.player, mpris_prop,
GObject.BindingFlags.BIDIRECTIONAL)
for (prop, mpris_prop) in [("can-go-next", "CanGoNext"),
("can-go-previous", "CanGoPrevious")]:
self.factory.bind_property(prop, self.mpris.player, mpris_prop)
self.mpris.player.link_property("Volume", self.win.header, "volume")
self.mpris.player.connect("OpenPath", self.__load_path)
self.mpris.player.connect("Next", self.__user_next)
self.mpris.player.connect("Previous", self.__user_previous)
self.mpris.player.connect("Pause", self.player.pause)
self.mpris.player.connect("Play", self.player.play)
self.mpris.player.connect("PlayPause", self.player.play_pause)
self.mpris.player.connect("Seek", self.__seek)
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",
self.factory, "db-previous")
self.win.sidebar.bind_property("selected-playlist",
self.factory, "db-visible")
self.factory.connect("track-requested", self.__track_requested)
def connect_player(self) -> None:
"""Connect the audio.Player."""
self.player.connect("about-to-finish", self.__system_next, True)
self.player.connect("eos", self.__system_next, False)
def do_handle_local_options(self, opts: GLib.VariantDict) -> int:
"""Handle any command line options."""
if opts.contains("version"):
print(VERSION_STRING)
gsetup.print_env()
return 0
return -1
def do_startup(self) -> None:
"""Handle the Adw.Application::startup signal."""
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()
gsetup.add_style()
musicbrainzngs.set_useragent(f"emmental{gsetup.DEBUG_STR}",
VERSION_NUMBER)
self.db.tracks.connect("notify::loaded", self.__tracks_table_loaded)
self.db.load()
self.win = self.build_window()
self.add_window(self.win)
self.connect_mpris2()
self.connect_listenbrainz()
self.connect_playlist_factory()
self.connect_player()
def do_activate(self) -> None:
"""Handle the Adw.Application::activate signal."""
Adw.Application.do_activate(self)
self.win.present()
def do_open(self, files: list, n_files: int, hint: str) -> None:
"""Play an audio file passed from the command line."""
if n_files > 0:
path = pathlib.Path(files[0].get_path())
self.db.tracks.mark_path_active(path)
self.__load_path(None, path)
self.activate()
def do_shutdown(self) -> None:
"""Handle the Adw.Application::shutdown signal."""
Adw.Application.do_shutdown(self)
if self.player is not None:
self.player.shutdown()
self.player = None
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
if self.db is not None:
self.db.close()
self.db = None

39
emmental/action.py Normal file
View File

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

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]

238
emmental/audio/__init__.py Normal file
View File

@ -0,0 +1,238 @@
# Copyright 2022 (c) Anna Schumaker.
"""A custom GObject managing a GStreamer playbin."""
import pathlib
from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gst
from . import filter
from .. import path
from .. import tmpdir
UPDATE_INTERVAL = 100
SEEK_FLAGS = Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT
class Player(GObject.GObject):
"""Wraps a GStreamer Playbin with an interface for our application."""
artist = GObject.Property(type=str)
album = GObject.Property(type=str)
album_artist = GObject.Property(type=str)
album_disc_number = GObject.Property(type=int)
title = GObject.Property(type=str)
track_number = GObject.Property(type=int)
position = GObject.Property(type=float, default=0)
duration = GObject.Property(type=float, default=0)
volume = GObject.Property(type=float, default=1.0)
artwork = GObject.Property(type=GObject.TYPE_PYOBJECT)
file = GObject.Property(type=GObject.TYPE_PYOBJECT)
playing = GObject.Property(type=bool, default=False)
status = GObject.Property(type=str, default="Stopped")
have_track = GObject.Property(type=bool, default=False)
almost_done = GObject.Property(type=bool, default=False)
playtime = GObject.Property(type=float)
savedtime = GObject.Property(type=float)
bg_enabled = GObject.Property(type=bool, default=False)
bg_volume = GObject.Property(type=float, default=0.5)
pause_on_load = GObject.Property(type=bool, default=False)
def __init__(self):
"""Initialize the audio Player."""
super().__init__()
self._filter = filter.Filter()
self._timeout = None
self._playbin = Gst.ElementFactory.make("playbin")
self._playbin.set_property("audio-filter", self._filter)
self._playbin.set_property("video-sink",
Gst.ElementFactory.make("fakesink"))
self._playbin.set_state(Gst.State.READY)
bus = self._playbin.get_bus()
bus.add_signal_watch()
bus.connect("message::async-done", self.__msg_async_done)
bus.connect("message::eos", self.__msg_eos)
bus.connect("message::state-changed", self.__msg_state_changed)
bus.connect("message::stream-start", self.__msg_stream_start)
bus.connect("message::tag", self.__msg_tags)
self.bind_property("volume", self._playbin, "volume")
self.bind_property("bg-enabled", self._filter, "bg-enabled")
self.bind_property("bg-volume", self._filter, "bg-volume")
self.connect("notify::file", self.__notify_file)
def __check_last_second(self) -> None:
if self.duration - self.position <= 2 * (Gst.SECOND / Gst.USECOND):
if not self.almost_done:
self.emit("about-to-finish")
def __get_current_playtime(self) -> float:
if not self._playbin.clock:
return 0.0
time = self._playbin.clock.get_time() - self._playbin.base_time
return time / Gst.SECOND
def __msg_async_done(self, bus: Gst.Bus, message: Gst.Message) -> None:
self.__update_position()
def __msg_eos(self, bus: Gst.Bus, message: Gst.Message) -> None:
self.emit("eos")
def __msg_state_changed(self, bus: Gst.Bus, message: Gst.Message) -> None:
if message.src == self._playbin:
(old, new, pending) = message.parse_state_changed()
match (self.status, new, pending):
case ("Playing", Gst.State.PLAYING, _) | \
("Paused", Gst.State.PAUSED, _) | \
("Stopped", Gst.State.READY, _) | \
("Stopped", Gst.State.NULL, _):
pass
case (_, Gst.State.PLAYING, Gst.State.VOID_PENDING):
print("audio: state changed to 'playing'")
self.status = "Playing"
self.playing = True
case (_, Gst.State.PAUSED, Gst.State.VOID_PENDING):
print("audio: state changed to 'paused'")
self.status = "Paused"
self.playing = False
case (_, Gst.State.READY, Gst.State.VOID_PENDING) | \
(_, Gst.State.NULL, Gst.State.VOID_PENDING):
print("audio: state changed to 'stopped'")
self.status = "Stopped"
self.playing = False
self.__update_timeout()
def __msg_stream_start(self, bus: Gst.Bus, message: Gst.Message) -> None:
self.emit("file-loaded",
path.from_uri(self._playbin.get_property("current-uri")))
def __msg_tags(self, bus: Gst.Bus, message: Gst.Message) -> None:
taglist = message.parse_tag()
for tag in ["artist", "album", "album-artist", "album-disc-number",
"title", "track-number", "artwork"]:
match tag:
case "artwork":
(res, sample) = taglist.get_sample("image")
if res:
buffer = sample.get_buffer()
(res, map) = buffer.map(Gst.MapFlags.READ)
if res:
value = tmpdir.cover_jpg(map.data)
buffer.unmap(map)
case "track-number" | "album-disc-number":
(res, value) = taglist.get_uint(tag)
case _:
(res, value) = taglist.get_string(tag)
if res and self.get_property(tag) != value:
self.set_property(tag, value)
def __notify_file(self, player: GObject.GObject, param) -> None:
if self.file:
uri = self.file.as_uri()
print(f"audio: loading {uri}")
self._playbin.set_property("uri", uri)
def __reset_properties(self, *, duration: float = 0.0,
artwork: pathlib.Path | None = None) -> None:
for tag in ["artist", "album-artist", "album", "title"]:
self.set_property(tag, "")
for tag in ["album-disc-number", "track-number",
"position", "playtime", "savedtime"]:
self.set_property(tag, 0)
self.almost_done = False
self.pause_on_load = False
self.artwork = artwork
self.duration = duration
def __update_position(self) -> bool:
(res, pos) = self._playbin.query_position(Gst.Format.TIME)
self.position = pos / Gst.USECOND if res else 0
self.playtime = self.__get_current_playtime() + self.savedtime
self.__check_last_second()
return GLib.SOURCE_CONTINUE
def __update_timeout(self) -> None:
if self.playing and self._timeout is None:
self._timeout = GLib.timeout_add(UPDATE_INTERVAL,
self.__update_position)
elif self.playing is False and self._timeout is not None:
GLib.source_remove(self._timeout)
self._timeout = None
def get_replaygain(self) -> tuple[bool, str | None]:
"""Get the current ReplayGain mode."""
mode = self._filter.rg_mode
return (False, None) if mode == "disabled" else (True, mode)
def get_state(self) -> Gst.State:
"""Get the current state of the Player."""
return self._playbin.get_state(Gst.CLOCK_TIME_NONE).state
def pause(self, *args) -> None:
"""Pause playback."""
self.set_state_sync(Gst.State.PAUSED)
def play(self, *args) -> None:
"""Start playback."""
self.set_state_sync(Gst.State.PLAYING)
def play_pause(self, *args) -> None:
"""Start or Pause playback."""
state = Gst.State.PAUSED if self.playing else Gst.State.PLAYING
self.set_state_sync(state)
def seek(self, newpos: float, *args) -> None:
"""Seek to a different point in the stream."""
self.savedtime += self.__get_current_playtime()
self._playbin.seek_simple(Gst.Format.TIME, SEEK_FLAGS,
newpos * Gst.USECOND)
def set_replaygain(self, enabled: bool, mode: str) -> None:
"""Set the ReplayGain mode."""
self._filter.rg_mode = mode if enabled else "disabled"
def set_state_sync(self, state: Gst.State) -> None:
"""Set the state of the playbin, and wait for it to change."""
if self._playbin.set_state(state) == Gst.StateChangeReturn.ASYNC:
self.get_state()
def shutdown(self) -> None:
"""Shut down the player."""
self._playbin.set_state(Gst.State.NULL)
def stop(self, *args) -> None:
"""Stop playback."""
self.set_state_sync(Gst.State.READY)
@GObject.Signal
def about_to_finish(self) -> None:
"""Signal that playback is almost done."""
print("audio: about to finish")
self.almost_done = True
@GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT,))
def file_loaded(self, file: pathlib.Path) -> None:
"""Signal that a new URI has started."""
print("audio: file loaded")
if self.pause_on_load:
self._playbin.set_state(Gst.State.PAUSED)
(res, dur) = self._playbin.query_duration(Gst.Format.TIME)
cover = self.file.parent / "cover.jpg"
self.__reset_properties(duration=(dur / Gst.USECOND if res else 0),
artwork=(cover if cover.is_file() else None))
self.have_track = True
@GObject.Signal
def eos(self) -> None:
"""Signal that the current track has ended."""
print("audio: end of stream")
self.set_state_sync(Gst.State.READY)
self.__reset_properties()
self.have_track = False
self.file = None

45
emmental/audio/filter.py Normal file
View File

@ -0,0 +1,45 @@
# Copyright 2023 (c) Anna Schumaker.
"""A custom Gst.Bin with our audio filter effects."""
from gi.repository import GObject
from gi.repository import Gst
from . import replaygain
class Filter(Gst.Bin):
"""The audio filter element."""
bg_enabled = GObject.Property(type=bool, default=False)
bg_volume = GObject.Property(type=float, default=0.5)
rg_mode = GObject.Property(type=str, default="disabled")
def __init__(self):
"""Initialize the audio filter."""
super().__init__()
self._replaygain = replaygain.Filter()
self._volume = Gst.ElementFactory.make("volume")
self.add(self._replaygain)
self.add(self._volume)
rg_pad = self._replaygain.get_static_pad("src")
rg_pad.link(self._volume.get_static_pad("sink"))
self.__add_ghost_pad("sink", self._replaygain)
self.__add_ghost_pad("src", self._volume)
self.connect("notify", self.__notify)
def __add_ghost_pad(self, pad: str, elm: Gst.Element) -> None:
self.add_pad(Gst.GhostPad.new(pad, elm.get_static_pad(pad)))
def __notify(self, filter: Gst.Bin, param: GObject.ParamSpec) -> None:
match param.name:
case "bg-enabled" | "bg-volume":
vol = self.bg_volume if self.bg_enabled else 1.0
if vol != self._volume.get_property("volume"):
vs = f"{round(vol * 100)}%" if self.bg_enabled else "off"
print(f"audio: setting background listening to {vs}")
self._volume.set_property("volume", vol)
case "rg-mode":
if self.rg_mode != self._replaygain.mode:
self._replaygain.mode = self.rg_mode

View File

@ -0,0 +1,62 @@
# Copyright 2022 (c) Anna Schumaker.
"""A custom Gst.Bin for selecting ReplayGain mode."""
import collections
from gi.repository import GObject
from gi.repository import Gst
RequestPads = collections.namedtuple("RequestPads", ["src", "sink"])
class Filter(Gst.Bin):
"""The ReplayGain filter element."""
mode = GObject.Property(type=str, default="disabled")
def __init__(self):
"""Initialize the ReplayGain element."""
super().__init__()
self._src = Gst.ElementFactory.make("output-selector")
self._sink = Gst.ElementFactory.make("input-selector")
self._rgalbum = Gst.ElementFactory.make("rgvolume")
self._rgtrack = Gst.ElementFactory.make("rgvolume")
self._rglimit = Gst.ElementFactory.make("rglimiter")
for elm in [self._src, self._rgalbum, self._rgtrack,
self._rglimit, self._sink]:
self.add(elm)
self._disabled = self.__request_pads(self._rglimit)
self._album_mode = self.__request_pads(self._rgalbum)
self._track_mode = self.__request_pads(self._rgtrack)
self._rgalbum.set_property("pre-amp", 6.0)
self._rgtrack.set_property("pre-amp", 6.0)
self._rgtrack.set_property("album-mode", False)
self._src.set_property("pad-negotiation-mode", 2)
self._src.set_property("active-pad", self._disabled.src)
self._sink.set_property("active-pad", self._disabled.sink)
self.__add_ghost_pad("sink", self._src)
self.__add_ghost_pad("src", self._sink)
self.connect("notify::mode", self.__notify_mode)
def __add_ghost_pad(self, pad: str, elm: Gst.Element) -> None:
self.add_pad(Gst.GhostPad.new(pad, elm.get_static_pad(pad)))
def __request_pads(self, elm: Gst.Element) -> RequestPads:
pads = RequestPads(src=self._src.request_pad_simple("src_%u"),
sink=self._sink.request_pad_simple("sink_%u"))
pads.src.link(elm.get_static_pad("sink"))
elm.get_static_pad("src").link(pads.sink)
return pads
def __notify_mode(self, filter: Gst.Bin, param) -> None:
match self.mode:
case "album": pads = self._album_mode
case "track": pads = self._track_mode
case _: pads = self._disabled
print(f"audio: setting ReplayGain mode to '{self.mode}'")
self._src.set_property("active-pad", pads.src)
self._sink.set_property("active-pad", pads.sink)

168
emmental/audio/tagger.py Normal file
View File

@ -0,0 +1,168 @@
# Copyright 2023 (c) Anna Schumaker
"""Extract tags from an audio file."""
import dataclasses
import mutagen
import pathlib
import re
@dataclasses.dataclass
class _Artist:
"""Class for holding Artist-related tags."""
name: str
mbid: str
def __lt__(self, rhs) -> bool:
lhs = (self.name is not None, self.name, self.mbid)
return lhs < (rhs.name is not None, rhs.name, rhs.mbid)
@dataclasses.dataclass
class _Album:
"""Class for holding Album-related tags."""
name: str
mbid: str
artist: str
release: str
cover: pathlib.Path
artists: list[_Artist]
@dataclasses.dataclass
class _Medium:
"""Class for holding Medium-related tags."""
number: int
name: str
type: str
@dataclasses.dataclass
class _Track:
"""Class for holding Track-related tags."""
artist: str
length: int
mbid: int
mtime: float
number: int
title: str
class _Tags:
"""Extract tags found in the Mutagen tag dictionary."""
def __init__(self, file: pathlib.Path, tags: dict,
length: int = 0, mtime: float = 0.0):
"""Initialize the Tagger."""
self.file = file
self.tags = tags
self.artists = sorted(self.list_artists())
self.album = _Album(tags.get("album", [""])[0],
tags.get("musicbrainz_releasegroupid", [""])[0],
self.get_album_artist(),
self.get_release(),
file.parent / "cover.jpg",
sorted(self.list_album_artists()))
self.medium = _Medium(int(tags.get("discnumber", [1])[0]),
tags.get("discsubtitle", [""])[0],
tags.get("media", [""])[0])
self.track = _Track(tags.get("artist", [""])[0],
length,
tags.get("musicbrainz_releasetrackid", [""])[0],
mtime,
int(tags.get("tracknumber", [0])[0]),
tags.get("title", [""])[0])
self.genres = sorted(self.list_genres())
self.year = self.get_year()
def get_album_artist(self) -> str:
"""Find the album artist of the file."""
if (res := self.tags.get("albumartist")) is None:
res = self.tags.get("artist", [""])
return res[0]
def list_album_artists(self) -> list[_Artist]:
"""Find the list of album artists for the track."""
artists = self.tags.get("albumartist", [])
mbids = self.tags.get("musicbrainz_albumartistid", len(artists) * [""])
if len(artists) != len(mbids):
artists = [None] * len(mbids)
map = {a.mbid: a for a in self.artists}
map.update({(a.name, a.mbid): a for a in self.artists})
return [map.get(m, map.get((a, m))) for (a, m) in zip(artists, mbids)]
def list_artists(self) -> list[_Artist]:
"""Find the list of artists for the track."""
artists = self.tags.get("artists", [])
mbids = self.tags.get("musicbrainz_artistid", len(artists) * [""])
found = set()
need = set()
if len(artists) == 0 and len(mbids) == 0:
res = {(a, "") for a in self.tags.get("artist", [])}
elif len(artists) == len(mbids):
res = {(a, m) for (a, m) in zip(artists, mbids)}
found.update({m for m in mbids if len(m)})
else:
res = {(None, mbid) for mbid in mbids}
need.update({mbid for mbid in mbids if len(mbid)})
albumartists = self.tags.get("albumartist", [])
mbids = self.tags.get("musicbrainz_albumartistid",
len(albumartists) * [""])
if len(albumartists) == len(mbids):
res.update({(a, m) for (a, m) in zip(albumartists, mbids)})
found.update({m for m in mbids if len(m)})
else:
res.update({(None, mbid) for mbid in mbids})
need.update({mbid for mbid in mbids if len(mbid)})
res.difference_update({(None, mbid) for mbid in found & need})
return [_Artist(a, m) for (a, m) in list(res)]
def list_genres(self) -> list[str]:
"""Find the genres of the file."""
res = []
for genre in self.tags.get("genre", []):
res.extend([g.strip() for g in re.split("[,;/]", genre)])
for reltype in self.tags.get("releasetype", []):
match reltype:
case "album" | "compilation": continue
case "ep": res.append("EP")
case _: res.append(reltype.title())
return res
def get_release(self) -> str:
"""Find the release date of the file."""
if (res := self.tags.get("originaldate")) is None:
if (res := self.tags.get("originalyear")) is None:
if (res := self.tags.get("date")) is None:
res = self.tags.get("year", [""])
return res[0]
def get_year(self) -> int | None:
"""Find the year in the release string."""
if len(self.album.release):
return int(re.match(r"\d+", self.album.release).group(0))
def tag_file(file: pathlib.Path, mtime: float | None) -> _Tags | None:
"""Tag the requested file."""
if file.is_file():
file_mtime = file.stat().st_mtime
if mtime is None or file_mtime > mtime:
if (tags := mutagen.File(file)) is not None:
return _Tags(file, tags, tags.info.length, file_mtime)

152
emmental/buttons.py Normal file
View File

@ -0,0 +1,152 @@
# Copyright 2022 (c) Anna Schumaker.
"""Helper classes for Buttons."""
from gi.repository import GObject
from gi.repository import Gtk
class Button(Gtk.Button):
"""A Gtk.Button with extra properties and default large size."""
icon_name = GObject.Property(type=str)
icon_opacity = GObject.Property(type=float, default=1.0,
minimum=0.0, maximum=1.0)
def __init__(self, large_icon: bool = False, **kwargs):
"""Initialize a Button."""
super().__init__(focusable=False, **kwargs)
icon_size = Gtk.IconSize.LARGE if large_icon else Gtk.IconSize.NORMAL
self._image = Gtk.Image(icon_name=self.icon_name, icon_size=icon_size,
opacity=self.icon_opacity)
self.bind_property("icon-name", self._image, "icon-name")
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."""
popover_child = GObject.Property(type=Gtk.Widget)
def __init__(self, **kwargs):
"""Initialize a popover.Button."""
super().__init__(popover=Gtk.Popover(), **kwargs)
self.bind_property("popover-child", self.get_popover(), "child")
self.get_popover().set_child(self.popover_child)
def popdown(self):
"""Close the popover."""
self.get_popover().popdown()
class SplitButton(Gtk.Box):
"""A Button and secondary widget packed together."""
icon_name = GObject.Property(type=str)
large_icon = GObject.Property(type=bool, default=False)
def __init__(self, secondary: Gtk.Button, **kwargs):
"""Initialize a Split Button."""
super().__init__(**kwargs)
self._primary = Button(hexpand=True, icon_name=self.icon_name,
large_icon=self.large_icon)
self._separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL,
margin_top=12, margin_bottom=12)
self._secondary = secondary
self.bind_property("icon-name", self._primary, "icon-name")
self.bind_property("large-icon", self._primary, "large-icon")
self._primary.connect("activate", self.__activate)
self._primary.connect("clicked", self.__clicked)
self.append(self._primary)
self.append(self._separator)
self.append(secondary)
self.add_css_class("emmental-splitbutton")
def __activate(self, button: Button) -> None:
self.emit("activate-primary")
def __clicked(self, button: Button) -> None:
self.emit("clicked")
def activate(self, *args) -> None:
"""Activate the primary button."""
self._primary.activate()
@GObject.Property(type=Gtk.Button, flags=GObject.ParamFlags.READABLE)
def secondary(self) -> Gtk.Button:
"""Get the secondary button attached to the SplitButton."""
return self._secondary
@GObject.Signal
def activate_primary(self) -> None:
"""Signal that the primary button has been activated."""
@GObject.Signal
def clicked(self) -> None:
"""Signal that the primary button has been clicked."""
class ImageToggle(Button):
"""Inspired by a ToggleButton, but changes image based on state."""
active_icon_name = GObject.Property(type=str)
active_tooltip_text = GObject.Property(type=str)
inactive_icon_name = GObject.Property(type=str)
inactive_tooltip_text = GObject.Property(type=str)
def __init__(self, active_icon_name: str, inactive_icon_name: str,
active_tooltip_text: str | None = None,
inactive_tooltip_text: str | None = None,
*, active: bool = False, **kwargs) -> None:
"""Initialize an ImageToggle button."""
super().__init__(active_icon_name=active_icon_name,
inactive_icon_name=inactive_icon_name,
icon_name=inactive_icon_name,
active_tooltip_text=active_tooltip_text,
inactive_tooltip_text=inactive_tooltip_text,
tooltip_text=inactive_tooltip_text,
active=active, **kwargs)
self.connect("notify", self.__notify)
def __notify(self, toggle: Button, param: GObject.ParamSpec) -> None:
match (param.name, self.active):
case ("active-tooltip-text", True) | \
("inactive-tooltip-text", False):
self.set_tooltip_text(self.get_property(param.name))
def do_clicked(self) -> None:
"""Handle a click event."""
self.active = not self.active
@GObject.Property(type=bool, default=False)
def active(self) -> bool:
"""Get the active state."""
return self.icon_name == self.active_icon_name
@active.setter
def active(self, newval: bool) -> None:
if newval != self.active:
if newval:
self.icon_name = self.active_icon_name
self.props.tooltip_text = self.active_tooltip_text
else:
self.icon_name = self.inactive_icon_name
self.props.tooltip_text = self.inactive_tooltip_text
self.emit("toggled")
@GObject.Signal
def toggled(self) -> None:
"""Active state has been toggled."""

114
emmental/db/__init__.py Normal file
View File

@ -0,0 +1,114 @@
# Copyright 2022 (c) Anna Schumaker
"""Easily work with our underlying sqlite3 database."""
import pathlib
from gi.repository import GObject
from typing import Generator
from . import albums
from . import artists
from . import connection
from . import decades
from . import genres
from . import libraries
from . import playlist
from . import media
from . import playlists
from . import settings
from . import table
from . import tracks
from . import years
SQL_V1_SCRIPT = pathlib.Path(__file__).parent / "emmental.sql"
SQL_V2_SCRIPT = pathlib.Path(__file__).parent / "upgrade-v2.sql"
SQL_V3_SCRIPT = pathlib.Path(__file__).parent / "upgrade-v3.sql"
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."""
super().__init__()
self.__check_version()
self.settings = settings.Table(self)
self.playlists = playlists.Table(self)
self.artists = artists.Table(self)
self.albums = albums.Table(self, queue=self.artists.queue)
self.media = media.Table(self, queue=self.artists.queue)
self.genres = genres.Table(self)
self.decades = decades.Table(self)
self.years = years.Table(self, queue=self.decades.queue)
self.libraries = libraries.Table(self)
self.tracks = tracks.Table(self)
def __check_loaded(self) -> None:
for tbl in list(self.playlist_tables()) + [self.tracks]:
if tbl.loaded is False:
return
self.loaded = True
def __check_version(self) -> None:
user_version = self("PRAGMA user_version").fetchone()["user_version"]
match user_version:
case 0:
self.executescript(SQL_V1_SCRIPT)
self.executescript(SQL_V2_SCRIPT)
self.executescript(SQL_V3_SCRIPT)
case 1:
self.executescript(SQL_V2_SCRIPT)
self.executescript(SQL_V3_SCRIPT)
case 2:
self.executescript(SQL_V3_SCRIPT)
case 3: pass
case _:
raise Exception(f"Unsupported data version: {user_version}")
def close(self) -> None:
"""Close the database connection."""
self.settings.stop()
for tbl in self.playlist_tables():
tbl.stop()
self.tracks.stop()
super().close()
def filter(self, glob: str) -> None:
"""Filter the playlist tables."""
for tbl in self.playlist_tables():
tbl.filter(glob)
def load(self) -> None:
"""Load the database tables."""
self.settings.load()
for tbl in self.playlist_tables():
tbl.load()
self.tracks.load()
def playlist_tables(self) -> Generator[playlist.Table, None, None]:
"""Iterate over each playlist table."""
for tbl in [self.playlists, self.artists, self.albums, self.media,
self.genres, self.decades, self.years, self.libraries]:
yield tbl
def set_active_playlist(self, plist: playlist.Playlist) -> None:
"""Set the currently active playlist."""
if self.active_playlist == plist:
return
if self.active_playlist is not None:
self.active_playlist.active = False
self.active_playlist = plist
if plist is not None:
plist.active = True
@GObject.Signal(arg_types=(table.Table,))
def table_loaded(self, tbl: table.Table) -> None:
"""Signal that a table has been loaded."""
tbl.loaded = True
self.__check_loaded()

157
emmental/db/albums.py Normal file
View File

@ -0,0 +1,157 @@
# Copyright 2022 (c) Anna Schumaker
"""A custom Gio.ListModel for working with albums."""
import pathlib
import sqlite3
from gi.repository import GObject
from .media import Medium
from .. import format
from . import playlist
from . import tracks
class Album(playlist.Playlist):
"""Our custom Album with a ListModel representing mediums."""
albumid = GObject.Property(type=int)
artist = GObject.Property(type=str)
release = GObject.Property(type=str)
mbid = GObject.Property(type=str)
cover = GObject.Property(type=GObject.TYPE_PYOBJECT)
def __init__(self, **kwargs):
"""Initialize an Album object."""
super().__init__(**kwargs)
self.add_children(self.table.sql.media,
self.table.get_mediumids(self))
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."""
return self.table.get_artists(self)
def get_media(self) -> list[Medium]:
"""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."""
return self.albumid
@GObject.Property(type=playlist.Playlist)
def parent(self) -> playlist.Playlist | None:
"""Get the parent playlist of this Album."""
artists = self.get_artists()
return artists[0] if len(artists) else None
class Table(playlist.Table):
"""Our Album Table."""
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
"""Initialize the Album Table."""
super().__init__(sql=sql, autodelete=True,
system_tracks=False, **kwargs)
def do_add_track(self, album: Album, track: tracks.Track) -> bool:
"""Verify adding a Track to the Album playlist."""
return track.get_medium().get_album() == album
def do_construct(self, **kwargs) -> Album:
"""Construct a new album."""
return Album(**kwargs)
def do_get_sort_key(self, album: Album) -> tuple[tuple, bool,
str, tuple, str]:
"""Get a sort key for the requested Artist."""
return (format.sort_key(album.name),
len(album.mbid) == 0, album.mbid.casefold(),
format.sort_key(album.artist),
album.release)
def do_remove_track(self, album: Album, track: tracks.Track) -> bool:
"""Verify removing a Track from the Album playlist."""
return True
def do_sql_delete(self, album: Album) -> sqlite3.Cursor:
"""Delete an album."""
for artist in album.get_artists():
artist.remove_album(album)
for medium in album.get_media():
medium.delete()
return self.sql("DELETE FROM albums WHERE albumid=?", album.albumid)
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
"""Search for albums matching the search text."""
return self.sql("""SELECT albumid FROM album_artist_view
WHERE CASEFOLD(album) GLOB :glob
OR CASEFOLD(medium) GLOB :glob""", glob=glob)
def do_sql_insert(self, name: str, artist: str,
release: str, *, mbid: str = "",
cover: pathlib.Path = None) -> sqlite3.Cursor | None:
"""Create a new album."""
if cur := self.sql("""INSERT INTO albums
(name, artist, release, mbid, cover)
VALUES (?, ?, ?, ?, ?)""",
name, artist, release, mbid, cover):
return self.sql("SELECT * FROM albums_view WHERE albumid=?",
cur.lastrowid)
def do_sql_select_all(self) -> sqlite3.Cursor:
"""Load albums from the database."""
return self.sql("SELECT * FROM albums_view")
def do_sql_select_one(self, name: str = None,
artist: str = None, release: str = None,
*, mbid: str = "") -> sqlite3.Cursor:
"""Look up an albums by name, mbid, artist, and release."""
where = ["mbid=?"]
args = [mbid.lower()]
if None not in (name, artist, release):
where.extend(["CASEFOLD(name)=?",
"CASEFOLD(artist)=?", "release=?"])
args.extend([name.casefold(), artist.casefold(), release])
return self.sql(f"""SELECT albumid FROM albums
WHERE {" AND ".join(where)}""", *args)
def do_sql_select_trackids(self, album: Album) -> sqlite3.Cursor:
"""Load an Album's Tracks from the database."""
return self.sql("""SELECT trackid FROM album_tracks_view
WHERE albumid=?""", album.albumid)
def do_sql_update(self, album: Album, column: str, newval) -> bool:
"""Rename an album."""
return self.sql(f"UPDATE albums SET {column}=? WHERE albumid=?",
newval, album.albumid)
def get_artists(self, album: Album) -> list[playlist.Playlist]:
"""Get the list of artists for this album."""
rows = self.sql("""SELECT artistid FROM album_artist_link
WHERE albumid=?""", album.albumid).fetchall()
artists = [self.sql.artists.rows.get(row["artistid"]) for row in rows]
return list(filter(None, artists))
def get_media(self, album: Album) -> list[Medium]:
"""Get the list of media for this album."""
return [self.sql.media.rows.get(id)
for id in self.get_mediumids(album)]
def get_mediumids(self, album: Album) -> set[int]:
"""Get the set of mediumids for this album."""
rows = self.sql("SELECT mediumid FROM media WHERE albumid=?",
album.albumid)
return {row["mediumid"] for row in rows.fetchall()}

147
emmental/db/artists.py Normal file
View File

@ -0,0 +1,147 @@
# Copyright 2022 (c) Anna Schumaker
"""A custom Gio.ListModel for working with artists."""
import sqlite3
from gi.repository import GObject
from gi.repository import Gtk
from .albums import Album
from .. import format
from . import playlist
from . import table
class Artist(playlist.Playlist):
"""Our custom Artist object."""
artistid = GObject.Property(type=int)
mbid = GObject.Property(type=str)
def __init__(self, **kwargs):
"""Initialize an Artist object."""
super().__init__(**kwargs)
self.add_children(self.table.sql.albums,
self.table.get_albumids(self))
def add_album(self, album: Album) -> None:
"""Add an Album to this Artist."""
if self.table.add_album(self, album):
self.add_child(album)
def has_album(self, album: Album) -> bool:
"""Check if the Artist has this Album."""
return self.has_child(album)
def remove_album(self, album: Album) -> None:
"""Remove an album from this Artist."""
self.table.remove_album(self, album)
self.remove_child(album)
@property
def primary_key(self) -> int:
"""Get the Artist primary key."""
return self.artistid
class Filter(table.KeySet):
"""Custom filter to hide artists without albums."""
show_all = GObject.Property(type=bool, default=False)
def __init__(self, show_all: bool = False):
"""Initialize the Artist filter."""
super().__init__(show_all=show_all)
self.connect("notify::show-all", self.__notify_show_all)
def __notify_show_all(self, filter: table.KeySet, param) -> None:
self.changed(Gtk.FilterChange.LESS_STRICT if self.show_all else
Gtk.FilterChange.MORE_STRICT)
def do_get_strictness(self) -> Gtk.FilterMatch:
"""Get the strictness of the filter."""
res = super().do_get_strictness()
if not self.show_all and res == Gtk.FilterMatch.ALL:
return Gtk.FilterMatch.SOME
return res
def do_match(self, artist: Artist) -> bool:
"""Check if the artist matches the filter."""
res = super().do_match(artist)
if not self.show_all and res:
return artist.child_set.keyset.n_keys > 0
return res
class Table(playlist.Table):
"""Our Artist Table."""
show_all = GObject.Property(type=bool, default=False)
def __init__(self, sql: GObject.TYPE_PYOBJECT,
show_all: bool = False, **kwargs):
"""Initialize an Artist model."""
super().__init__(sql=sql, show_all=show_all, autodelete=True,
filter=Filter(show_all=show_all), **kwargs)
self.bind_property("show-all", self.get_filter(), "show-all")
def do_construct(self, **kwargs) -> Artist:
"""Construct a new artist."""
return Artist(**kwargs)
def do_get_sort_key(self, artist: Artist) -> tuple[tuple, bool, str]:
"""Get a sort key for the requested Playlist."""
return (format.sort_key(artist.name),
len(artist.mbid) == 0,
artist.mbid.casefold())
def do_sql_delete(self, artist: Artist) -> bool:
"""Delete an artist."""
return self.sql("DELETE FROM artists WHERE artistid=?",
artist.artistid)
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
"""Search for artists matching the search text."""
return self.sql("""SELECT artistid FROM album_artist_view
WHERE CASEFOLD(artist) GLOB :glob
OR CASEFOLD(album) GLOB :glob
OR CASEFOLD(medium) GLOB :glob""", glob=glob)
def do_sql_insert(self, name: str,
mbid: str = "") -> sqlite3.Cursor | None:
"""Create a new artist."""
if cur := self.sql("INSERT INTO artists (name, mbid) VALUES (?, ?)",
name, mbid):
return self.sql("SELECT * FROM artists_view WHERE artistid=?",
cur.lastrowid)
def do_sql_select_all(self) -> sqlite3.Cursor:
"""Load artists from the database."""
return self.sql("SELECT * FROM artists_view")
def do_sql_select_one(self, name: str | None = None,
*, mbid: str = "") -> sqlite3.Cursor:
"""Look up an artist by name and mbid."""
where = "mbid=? AND CASEFOLD(name)=?" if name else "mbid=?"
args = [mbid.lower(), name.casefold()] if name else [mbid.lower()]
return self.sql(f"SELECT artistid FROM artists WHERE {where}", *args)
def do_sql_update(self, artist: Artist,
column: str, newval) -> sqlite3.Cursor:
"""Update an artist."""
return self.sql(f"UPDATE artists SET {column}=? WHERE artistid=?",
newval, artist.artistid)
def add_album(self, artist: Artist, album: Album) -> bool:
"""Add an album to this artist."""
return self.sql("INSERT INTO album_artist_link VALUES (?, ?)",
artist.artistid, album.albumid) is not None
def get_albumids(self, artist: Artist) -> set[int]:
"""Get an Artist's associated albumids from the database."""
cur = self.sql("""SELECT albumid FROM album_artist_link
WHERE artistid=?""", artist.artistid)
return {row["albumid"] for row in cur.fetchall()}
def remove_album(self, artist: Artist, album: Album) -> bool:
"""Remove an album from this artist."""
return self.sql("""DELETE FROM album_artist_link
WHERE artistid=? AND albumid=?""",
artist.artistid, album.albumid).rowcount == 1

95
emmental/db/connection.py Normal file
View File

@ -0,0 +1,95 @@
# Copyright 2022 (c) Anna Schumaker
"""Easily work with our underlying sqlite3 database."""
import pathlib
import sqlite3
import sys
from gi.repository import GObject
from .. import gsetup
DATA_FILE = gsetup.DATA_DIR / f"emmental{gsetup.DEBUG_STR}.sqlite3"
DATABASE = ":memory:" if "unittest" in sys.modules else DATA_FILE
def adapt_path(path: pathlib.Path) -> str:
"""Adapt a pathlib.Path into a sqlite3 string."""
return str(path)
def convert_path(path: bytes) -> pathlib.Path:
"""Convert a path string into a pathlib.Path object."""
return pathlib.Path(path.decode())
sqlite3.register_adapter(pathlib.PosixPath, adapt_path)
sqlite3.register_converter("path", convert_path)
class Connection(GObject.GObject):
"""Connect to the database."""
connected = GObject.Property(type=bool, default=True)
def __init__(self):
"""Initialize a sqlite connection."""
super().__init__()
self._sql = sqlite3.connect(DATABASE,
detect_types=sqlite3.PARSE_DECLTYPES)
self._sql.create_function("CASEFOLD", 1,
lambda s: s.casefold() if s else None,
deterministic=True)
self._sql.row_factory = sqlite3.Row
self._sql("PRAGMA foreign_keys = ON")
def __call__(self, statement: str,
*args, **kwargs) -> sqlite3.Cursor | None:
"""Execute a SQL statement."""
try:
return self._sql.execute(statement, args if len(args) else kwargs)
except sqlite3.IntegrityError:
return None
def __del__(self) -> None:
"""Clean up before exiting."""
self.close()
def __enter__(self) -> None:
"""Begin a transaction."""
if not self._sql.in_transaction:
self._sql.commit()
self._sql.execute("BEGIN")
def __exit__(self, exp_type, exp_value, traceback) -> bool:
"""Either commit or rollback an active transaction."""
if exp_type is None:
self._sql.commit()
else:
self._sql.rollback()
return exp_type is None
def close(self) -> None:
"""Close the database connection."""
if self.connected:
self._sql.commit()
self._sql.execute("PRAGMA optimize")
self._sql.close()
self.connected = False
def commit(self) -> None:
"""Commit pending changes."""
self._sql.commit()
def executemany(self, statement: str, *args) -> sqlite3.Cursor | None:
"""Execute several similar SQL statements at once."""
try:
return self._sql.executemany(statement, args)
except sqlite3.InternalError:
return None
def executescript(self, script: pathlib.Path) -> sqlite3.Cursor | None:
"""Execute a SQL script."""
if script.is_file():
with open(script) as f:
cur = self._sql.executescript(f.read())
self.commit()
return cur

109
emmental/db/decades.py Normal file
View File

@ -0,0 +1,109 @@
# Copyright 2022 (c) Anna Schumaker
"""A custom Gio.ListModel for working with decades."""
import sqlite3
from gi.repository import GObject
from .years import Year
from . import playlist
from . import tracks
class Decade(playlist.Playlist):
"""Our custom Decade object."""
decade = GObject.Property(type=int)
def __init__(self, **kwargs):
"""Initialize a Decade object."""
super().__init__(**kwargs)
self.add_children(self.table.sql.years,
self.table.get_yearids(self))
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."""
return self.decade
class Table(playlist.Table):
"""Our Decade Table."""
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
"""Initialize the Decade table."""
super().__init__(sql=sql, autodelete=True,
system_tracks=False, **kwargs)
def do_add_track(self, decade: Decade, track: tracks.Track) -> bool:
"""Verify adding a Track to the Decade playlist."""
return (track.year // 10 * 10) == decade.decade
def do_construct(self, **kwargs) -> Decade:
"""Construct a new Decade playlist."""
return Decade(**kwargs)
def do_get_sort_key(self, decade: Decade) -> int:
"""Get the sort key for the requested decade."""
return decade.decade
def do_remove_track(self, decade: Decade, track: tracks.Track) -> bool:
"""Verify removing a Track from the Decade playlist."""
return True
def do_sql_delete(self, decade: Decade) -> sqlite3.Cursor:
"""Delete a decade."""
for year in decade.get_years():
year.delete()
return self.sql("DELETE FROM decades WHERE decade=?", decade.decade)
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
"""Search for decades matching the search text."""
return self.sql("""SELECT decade FROM decades_view
WHERE CASEFOLD(name) GLOB :glob
UNION SELECT (year / 10 * 10) AS decade
FROM years WHERE year GLOB :glob""", glob=glob)
def do_sql_insert(self, year: int) -> sqlite3.Cursor | None:
"""Create a new Decade playlist."""
decade = year // 10 * 10
if self.sql("INSERT INTO decades (decade) VALUES (?)", decade):
return self.sql("SELECT * FROM decades_view WHERE decade=?",
decade)
def do_sql_select_all(self) -> sqlite3.Cursor:
"""Load Decades from the database."""
return self.sql("SELECT * FROM decades_view")
def do_sql_select_one(self, year: int) -> sqlite3.Cursor:
"""Look up an decade by year."""
return self.sql("SELECT decade FROM decades WHERE decade=?",
year // 10 * 10)
def do_sql_select_trackids(self, decade: Decade) -> sqlite3.Cursor:
"""Load a Decade's Tracks from the database."""
return self.sql("""SELECT trackid FROM decade_tracks_view
WHERE decade=?""", decade.decade)
def get_yearids(self, decade: Decade) -> set[int]:
"""Get the set of years for this decade."""
rows = self.sql("SELECT year FROM years WHERE (year / 10 * 10)=?",
decade.decade)
return {row["year"] for row in rows}
def get_years(self, decade: Decade) -> list[Year]:
"""Get the list of years for this decade."""
return [self.sql.years.rows.get(yr) for yr in self.get_yearids(decade)]

633
emmental/db/emmental.sql Normal file
View File

@ -0,0 +1,633 @@
/* Copyright 2022 (c) Anna Schumaker */
PRAGMA user_version = 1;
/**************************************
* *
* Application Settings *
* *
**************************************/
CREATE TABLE settings (
key TEXT PRIMARY KEY,
type TEXT NOT NULL,
value TEXT NOT NULL,
CHECK (type IN ("gint", "gdouble", "gboolean", "gchararray"))
);
/*************************************
* *
* Playlist Properties *
* *
*************************************/
CREATE TABLE playlist_properties (
propertyid INTEGER PRIMARY KEY,
active BOOLEAN NOT NULL DEFAULT FALSE,
loop STRING NOT NULL DEFAULT "None",
shuffle BOOLEAN NOT NULL DEFAULT FALSE,
sort_order STRING NOT NULL DEFAULT "",
current_trackid INTEGER DEFAULT NULL REFERENCES tracks (trackid)
ON DELETE SET NULL
ON UPDATE CASCADE,
CHECK (loop IN ("None", "Track", "Playlist"))
);
CREATE TRIGGER playlists_active_trigger
AFTER UPDATE OF active ON playlist_properties
FOR EACH ROW BEGIN
UPDATE playlist_properties
SET active = FALSE
WHERE propertyid != NEW.propertyid AND active == TRUE;
END;
/*******************************************
* *
* User and System Playlists *
* *
*******************************************/
CREATE TABLE playlists (
playlistid INTEGER PRIMARY KEY,
propertyid INTEGER REFERENCES playlist_properties(propertyid)
ON DELETE CASCADE
ON UPDATE CASCADE,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
image PATH
);
CREATE VIEW playlists_view AS
SELECT playlistid, propertyid, name, image,
active, loop, shuffle, sort_order, current_trackid
FROM playlists
JOIN playlist_properties USING (propertyid);
CREATE TRIGGER playlists_insert_trigger AFTER INSERT ON playlists
BEGIN
INSERT INTO playlist_properties (active, loop, sort_order)
VALUES (NEW.name == "Collection",
IIF(NEW.name == "Collection", "Playlist", "None"),
CASE
WHEN NEW.name == "Most Played Tracks"
THEN "playcount DESC, albumartist, album, mediumno, number"
WHEN NEW.name == "Previous Tracks"
THEN "laststarted DESC"
ELSE "albumartist, album, mediumno, number"
END);
UPDATE playlists SET propertyid = last_insert_rowid()
WHERE playlistid = NEW.playlistid;
END;
CREATE TRIGGER playlists_delete_trigger AFTER DELETE ON playlists
BEGIN
DELETE FROM playlist_properties WHERE propertyid = OLD.propertyid;
END;
CREATE TRIGGER collection_loop_trigger
BEFORE UPDATE OF loop ON playlist_properties
WHEN NEW.loop == "None" AND NEW.propertyid == (SELECT propertyid
FROM playlists
WHERE name='Collection')
BEGIN
SELECT RAISE(ABORT, "Collection playlist cannot disable loop");
END;
CREATE TRIGGER previous_loop_trigger
BEFORE UPDATE OF loop ON playlist_properties
WHEN NEW.loop != "None" AND NEW.propertyid == (SELECT propertyid
FROM playlists
WHERE name='Previous Tracks')
BEGIN
SELECT RAISE(ABORT, "Previous Tracks cannot be looped");
END;
CREATE TRIGGER previous_shuffle_trigger
BEFORE UPDATE OF shuffle ON playlist_properties
WHEN NEW.shuffle = TRUE AND NEW.propertyid == (SELECT propertyid
FROM playlists
WHERE name='Previous Tracks')
BEGIN
SELECT RAISE(ABORT, "Previous Tracks cannot be shuffled");
END;
CREATE TRIGGER previous_sort_order_trigger
BEFORE UPDATE OF sort_order ON playlist_properties
WHEN NEW.sort_order != "laststarted DESC" AND NEW.propertyid == (SELECT propertyid
FROM playlists
WHERE name='Previous Tracks')
BEGIN
SELECT RAISE(ABORT, "Previous Tracks cannot be sorted");
END;
/*************************
* *
* Artists *
* *
*************************/
CREATE TABLE artists (
artistid INTEGER PRIMARY KEY,
propertyid INTEGER REFERENCES playlist_properties (propertyid)
ON DELETE CASCADE
ON UPDATE CASCADE,
name TEXT NOT NULL COLLATE NOCASE,
mbid TEXT NOT NULL DEFAULT "" COLLATE NOCASE,
UNIQUE (name, mbid)
);
CREATE VIEW artists_view AS
SELECT artistid, propertyid, name, mbid,
active, loop, shuffle, sort_order, current_trackid
FROM artists
JOIN playlist_properties USING (propertyid);
CREATE TRIGGER artists_insert_trigger AFTER INSERT ON artists
BEGIN
INSERT INTO playlist_properties (active, sort_order)
VALUES (False, "release, album, mediumno, number");
UPDATE artists SET propertyid = last_insert_rowid(),
mbid = LOWER(NEW.mbid)
WHERE artistid = NEW.artistid;
END;
CREATE TRIGGER artists_delete_trigger AFTER DELETE ON artists
BEGIN
DELETE FROM playlist_properties WHERE propertyid = OLD.propertyid;
END;
/************************
* *
* Albums *
* *
************************/
CREATE TABLE albums (
albumid INTEGER PRIMARY KEY,
propertyid INTEGER REFERENCES playlist_properties (propertyid)
ON DELETE CASCADE
ON UPDATE CASCADE,
name TEXT NOT NULL COLLATE NOCASE,
artist TEXT NOT NULL COLLATE NOCASE,
release TEXT NOT NULL,
mbid TEXT NOT NULL DEFAULT "" COLLATE NOCASE,
cover PATH,
UNIQUE (name, mbid, artist, release)
);
CREATE VIEW albums_view AS
SELECT albumid, propertyid, name, mbid, artist, release, cover,
active, loop, shuffle, sort_order, current_trackid
FROM albums
JOIN playlist_properties USING (propertyid);
CREATE TRIGGER albums_insert_trigger AFTER INSERT ON albums
BEGIN
INSERT INTO playlist_properties (active, sort_order)
VALUES (False, "mediumno, number");
UPDATE albums SET propertyid = last_insert_rowid(),
mbid = LOWER(NEW.mbid)
WHERE albumid = NEW.albumid;
END;
CREATE TRIGGER albums_delete_trigger AFTER DELETE ON albums
BEGIN
DELETE FROM playlist_properties WHERE propertyid = OLD.propertyid;
END;
/*************************
* *
* Mediums *
* *
*************************/
CREATE TABLE media (
mediumid INTEGER PRIMARY KEY,
propertyid INTEGER REFERENCES playlist_properties (propertyid)
ON DELETE CASCADE
ON UPDATE CASCADE,
albumid INTEGER NOT NULL REFERENCES albums (albumid)
ON DELETE CASCADE
ON UPDATE CASCADE,
number INTEGER NOT NULL,
name TEXT NOT NULL DEFAULT "" COLLATE NOCASE,
type TEXT NOT NULL DEFAULT "" COLLATE NOCASE,
UNIQUE (albumid, number, type)
);
CREATE VIEW media_view AS
SELECT mediumid, propertyid, albumid, number, name, type,
active, loop, shuffle, sort_order, current_trackid
FROM media
JOIN playlist_properties USING (propertyid);
CREATE TRIGGER media_insert_trigger AFTER INSERT ON media
BEGIN
INSERT INTO playlist_properties (active, sort_order)
VALUES (False, "mediumno, number");
UPDATE media SET propertyid = last_insert_rowid()
WHERE mediumid = NEW.mediumid;
END;
CREATE TRIGGER media_delete_trigger AFTER DELETE ON media
BEGIN
DELETE FROM playlist_properties WHERE propertyid = OLD.propertyid;
END;
/*******************************************************
* *
* Artist <--> Album <--> Medium Linking *
* *
*******************************************************/
CREATE TABLE album_artist_link (
artistid INTEGER NOT NULL REFERENCES artists (artistid)
ON DELETE CASCADE
ON UPDATE CASCADE,
albumid INTEGER NOT NULL REFERENCES albums (albumid)
ON DELETE CASCADE
ON UPDATE CASCADE,
UNIQUE (artistid, albumid)
);
CREATE VIEW album_artist_view AS
SELECT artistid, artists.name as artist,
albumid, COALESCE(albums.name, "") as album,
media.mediumid, COALESCE(media.name, "") as medium
FROM artists
LEFT JOIN album_artist_link USING (artistid)
LEFT JOIN albums USING (albumid)
LEFT JOIN media USING (albumid);
/************************
* *
* Genres *
* *
************************/
CREATE TABLE genres (
genreid INTEGER PRIMARY KEY,
propertyid INTEGER REFERENCES playlist_properties (propertyid)
ON DELETE CASCADE
ON UPDATE CASCADE,
name TEXT NOT NULL UNIQUE COLLATE NOCASE
);
CREATE VIEW genres_view AS
SELECT genreid, propertyid, name,
active, loop, shuffle, sort_order, current_trackid
FROM genres
JOIN playlist_properties USING (propertyid);
CREATE TRIGGER genres_insert_trigger AFTER INSERT ON genres
BEGIN
INSERT INTO playlist_properties (active, sort_order)
VALUES (False, "albumartist, album, mediumno, number");
UPDATE genres SET propertyid = last_insert_rowid()
WHERE genreid = NEW.genreid;
END;
CREATE TRIGGER genres_delete_trigger AFTER DELETE ON genres
BEGIN
DELETE FROM playlist_properties WHERE propertyid = OLD.propertyid;
END;
/*************************
* *
* Decades *
* *
*************************/
CREATE TABLE decades (
decade INTEGER PRIMARY KEY,
propertyid INTEGER REFERENCES playlist_properties (propertyid)
ON DELETE CASCADE
ON UPDATE CASCADE
CHECK (decade % 10 = 0)
);
CREATE VIEW decades_view AS
SELECT decade, propertyid, FORMAT("The %ds", decade) as name,
active, loop, shuffle, sort_order, current_trackid
FROM decades
JOIN playlist_properties USING (propertyid);
CREATE TRIGGER decades_insert_trigger AFTER INSERT ON decades
BEGIN
INSERT INTO playlist_properties (active, sort_order)
VALUES (False, "release, albumartist, album, mediumno, number");
UPDATE decades SET propertyid = last_insert_rowid()
WHERE decade = NEW.decade;
END;
CREATE TRIGGER decades_delete_trigger AFTER DELETE ON decades
BEGIN
DELETE FROM playlist_properties WHERE propertyid = OLD.propertyid;
END;
/***********************
* *
* Years *
* *
***********************/
CREATE TABLE years (
year INTEGER PRIMARY KEY,
propertyid INTEGER REFERENCES playlist_properties (propertyid)
ON DELETE CASCADE
ON UPDATE CASCADE
);
CREATE VIEW years_view AS
SELECT year, propertyid, FORMAT("%s", year) as name,
active, loop, shuffle, sort_order, current_trackid
FROM years
JOIN playlist_properties USING (propertyid);
CREATE TRIGGER years_insert_trigger AFTER INSERT ON years
BEGIN
INSERT INTO playlist_properties (active, sort_order)
VALUES (False, "release, albumartist, album, mediumno, number");
UPDATE years SET propertyid = last_insert_rowid()
WHERE year = NEW.year;
END;
CREATE TRIGGER years_delete_trigger AFTER DELETE ON years
BEGIN
DELETE FROM playlist_properties WHERE propertyid = OLD.propertyid;
END;
/*******************************
* *
* Library Paths *
* *
*******************************/
CREATE TABLE libraries (
libraryid INTEGER PRIMARY KEY,
propertyid INTEGER REFERENCES playlist_properties (propertyid)
ON DELETE CASCADE
ON UPDATE CASCADE,
path PATH UNIQUE,
enabled BOOLEAN DEFAULT TRUE,
deleting BOOLEAN DEFAULT FALSE
);
CREATE VIEW libraries_view AS
SELECT libraryid, propertyid, path, path as name, enabled,
active, loop, shuffle, sort_order, current_trackid
FROM libraries
JOIN playlist_properties USING (propertyid);
CREATE TRIGGER libraries_insert_trigger AFTER INSERT ON libraries
BEGIN
INSERT INTO playlist_properties (active, sort_order)
VALUES (False, "filepath");
UPDATE libraries SET propertyid = last_insert_rowid()
WHERE libraryid = NEW.libraryid;
END;
CREATE TRIGGER libraries_delete_trigger AFTER DELETE ON libraries
BEGIN
DELETE FROM playlist_properties WHERE propertyid = OLD.propertyid;
END;
/************************
* *
* Tracks *
* *
************************/
CREATE TABLE tracks (
trackid INTEGER PRIMARY KEY,
libraryid INTEGER REFERENCES libraries (libraryid)
ON DELETE CASCADE
ON UPDATE CASCADE,
mediumid INTEGER REFERENCES media (mediumid)
ON DELETE CASCADE
ON UPDATE CASCADE,
year INTEGER REFERENCES years (year)
ON DELETE CASCADE
ON UPDATE CASCADE,
path PATH NOT NULL,
mbid TEXT NOT NULL DEFAULT "" COLLATE NOCASE,
title TEXT NOT NULL,
number INTEGER NOT NULL,
length REAL NOT NULL,
artist TEXT NOT NULL,
mtime REAL NOT NULL,
active BOOLEAN NOT NULL DEFAULT FALSE,
favorite BOOLEAN NOT NULL DEFAULT FALSE,
playcount INTEGER NOT NULL DEFAULT 0,
added DATE DEFAULT CURRENT_DATE,
laststarted TIMESTAMP,
lastplayed TIMESTAMP,
UNIQUE (libraryid, path)
);
CREATE VIEW track_info_view AS
SELECT trackid, tracks.mediumid, tracks.number, length, playcount,
laststarted, lastplayed, title, tracks.artist,
tracks.path as filepath,
media.number as mediumno, COALESCE(media.name, "") as medium,
albums.albumid, COALESCE(albums.name, "") as album,
COALESCE(albums.release, "") as release,
COALESCE(albums.artist, "") as albumartist,
libraries.deleting
FROM tracks
LEFT JOIN media USING (mediumid)
LEFT JOIN albums USING (albumid)
LEFT JOIN libraries USING (libraryid);
CREATE TRIGGER tracks_active_trigger
AFTER UPDATE OF active ON tracks
FOR EACH ROW BEGIN
UPDATE tracks
SET active = FALSE
WHERE trackid != NEW.trackid and active == TRUE;
END;
/*********************************************
* *
* Track <--> Playlist Linking *
* *
*********************************************/
CREATE TABLE system_tracks (
trackid INTEGER REFERENCES tracks (trackid)
ON DELETE CASCADE
ON UPDATE CASCADE,
propertyid INTEGER REFERENCES playlist_properties (propertyid)
ON DELETE CASCADE
ON UPDATE CASCADE,
UNIQUE(trackid, propertyid)
);
CREATE TABLE user_tracks (
trackid INTEGER REFERENCES tracks (trackid)
ON DELETE CASCADE
ON DELETE CASCADE,
propertyid INTEGER REFERENCES playlist_properties (propertyid)
ON DELETE CASCADE
ON UPDATE CASCADE,
position INTEGER,
UNIQUE(trackid, propertyid)
);
CREATE VIEW system_tracks_view AS
SELECT trackid, system_tracks.propertyid
FROM system_tracks
JOIN tracks USING (trackid)
JOIN libraries USING (libraryid)
WHERE libraries.deleting = FALSE;
CREATE VIEW user_tracks_view AS
SELECT trackid, user_tracks.propertyid, user_tracks.position
FROM user_tracks
JOIN tracks USING (trackid)
JOIN libraries USING (libraryid)
WHERE libraries.deleting = FALSE;
CREATE VIEW collection_view AS
SELECT tracks.trackid FROM tracks
JOIN libraries USING (libraryid)
WHERE libraries.enabled = TRUE AND libraries.deleting = FALSE;
CREATE VIEW favorite_view AS
SELECT tracks.trackid FROM tracks
JOIN libraries USING (libraryid)
WHERE tracks.favorite = TRUE AND libraries.deleting = FALSE;
CREATE VIEW most_played_view AS
SELECT tracks.trackid FROM tracks
JOIN libraries USING (libraryid)
WHERE tracks.playcount > (SELECT CEIL(AVG(playcount))
FROM tracks WHERE playcount>0)
AND libraries.deleting = FALSE;
CREATE VIEW new_tracks_view AS
SELECT tracks.trackid FROM tracks
JOIN libraries USING (libraryid)
WHERE tracks.added > DATE('now', 'localtime', '-7 days')
AND libraries.deleting = FALSE;
CREATE VIEW unplayed_tracks_view AS
SELECT tracks.trackid FROM tracks
JOIN libraries USING (libraryid)
WHERE tracks.playcount == 0 AND libraries.deleting = FALSE;
CREATE VIEW artist_tracks_view AS
SELECT tracks.trackid, artists.artistid
FROM tracks
JOIN system_tracks USING (trackid)
JOIN artists USING (propertyid)
JOIN libraries USING (libraryid)
WHERE libraries.deleting = False;
CREATE VIEW album_tracks_view AS
SELECT tracks.trackid, albums.albumid
FROM tracks
JOIN media USING (mediumid)
JOIN albums USING (albumid)
JOIN libraries USING (libraryid)
WHERE libraries.deleting = False;
CREATE VIEW medium_tracks_view AS
SELECT tracks.trackid, media.mediumid
FROM tracks
JOIN media USING (mediumid)
JOIN libraries USING (libraryid)
WHERE libraries.deleting = False;
CREATE VIEW genre_tracks_view AS
SELECT tracks.trackid, genres.genreid
FROM tracks
JOIN system_tracks USING (trackid)
JOIN genres USING (propertyid)
JOIN libraries USING (libraryid)
WHERE libraries.deleting = False;
CREATE VIEW decade_tracks_view AS
SELECT tracks.trackid, decades.decade
FROM tracks
JOIN decades ON (tracks.year / 10 * 10) = decades.decade
JOIN libraries USING (libraryid)
WHERE libraries.deleting = False;
CREATE VIEW year_tracks_view AS
SELECT tracks.trackid, years.year
FROM tracks
JOIN years USING (year)
JOIN libraries USING (libraryid)
WHERE libraries.deleting = False;
CREATE VIEW library_tracks_view AS
SELECT tracks.trackid, libraries.libraryid
FROM tracks
JOIN libraries USING (libraryid)
WHERE libraries.deleting = False;
/****************************************************
* *
* Data saved when Tracks are deleted *
* *
****************************************************/
CREATE TABLE saved_track_data (
mbid TEXT PRIMARY KEY,
favorite BOOLEAN NOT NULL DEFAULT FALSE,
playcount INTEGER NOT NULL DEFAULT 0,
lastplayed TIMESTAMP DEFAULT NULL,
laststarted TIMESTAMP DEFAULT NULL
);
CREATE TRIGGER tracks_delete_save BEFORE DELETE ON tracks
WHEN OLD.mbid != "" BEGIN
INSERT INTO saved_track_data
(mbid, favorite, playcount, lastplayed, laststarted)
VALUES (OLD.mbid, OLD.favorite, OLD.playcount,
OLD.lastplayed, OLD.laststarted);
END;
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
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;
/******************************************
* *
* Create Default Playlists *
* *
******************************************/
INSERT INTO playlists (name) VALUES
("Collection"),
("Favorite Tracks"),
("Most Played Tracks"),
("New Tracks"),
("Previous Tracks"),
("Queued Tracks"),
("Unplayed Tracks");

63
emmental/db/genres.py Normal file
View File

@ -0,0 +1,63 @@
# Copyright 2022 (c) Anna Schumaker
"""A custom Gio.ListModel for genres."""
import sqlite3
from gi.repository import GObject
from .. import format
from . import playlist
class Genre(playlist.Playlist):
"""Our custom Genre object representing a single genre."""
genreid = GObject.Property(type=int)
@property
def primary_key(self) -> int:
"""Get this Gener's primary key."""
return self.genreid
class Table(playlist.Table):
"""Our Genre Table."""
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
"""Initialize the Genres Table."""
super().__init__(sql=sql, autodelete=True, **kwargs)
def do_construct(self, **kwargs) -> Genre:
"""Construct a new Genre."""
return Genre(**kwargs)
def do_get_sort_key(self, genre: Genre) -> tuple[tuple[str], int]:
"""Get a sort key for the Genre."""
return (format.sort_key(genre.name), genre.genreid)
def do_sql_delete(self, genre: Genre) -> sqlite3.Cursor:
"""Delete a genre."""
return self.sql("DELETE FROM genres WHERE genreid=?", genre.genreid)
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
"""Search for genres matching the search text."""
return self.sql("""SELECT genreid FROM genres
WHERE CASEFOLD(name) GLOB ?""", glob)
def do_sql_insert(self, name: str) -> sqlite3.Cursor | None:
"""Create a new genre."""
if cur := self.sql("INSERT INTO genres (name) VALUES (?)", name):
return self.sql("SELECT * FROM genres_view WHERE genreid=?",
cur.lastrowid)
def do_sql_select_all(self) -> sqlite3.Cursor:
"""Load genres from the database."""
return self.sql("SELECT * FROM genres_view")
def do_sql_select_one(self, name: str) -> sqlite3.Cursor:
"""Look up a genre by name."""
return self.sql("SELECT genreid FROM genres WHERE CASEFOLD(name)=?",
name.casefold())
def do_sql_update(self, genre: playlist.Playlist,
column: str, newval) -> sqlite3.Cursor:
"""Update a genre."""
return self.sql(f"UPDATE genres SET {column}=? WHERE genreid=?",
newval, genre.genreid)

94
emmental/db/idle.py Normal file
View File

@ -0,0 +1,94 @@
# Copyright 2022 (c) Anna Schumaker
"""Idle queues to assid with large database operations."""
import typing
from gi.repository import GObject
from gi.repository import GLib
class Queue(GObject.GObject):
"""A base class Idle Queue."""
total = GObject.Property(type=int)
progress = GObject.Property(type=float)
running = GObject.Property(type=bool, default=False)
enabled = GObject.Property(type=bool, default=True)
def __init__(self, **kwargs):
"""Initialize an Idle Queue."""
super().__init__(**kwargs)
self._tasks = []
self._idle_id = None
def __getitem__(self, n: int) -> tuple:
"""Get the n-th task in the queue."""
return self._tasks[n] if n < len(self._tasks) else None
def __run_next_task(self) -> None:
task = self._tasks[0]
if task[0](*task[1:]):
self._tasks.pop(0)
def __start(self) -> None:
if not self.running:
self.running = True
self._idle_id = GLib.idle_add(self.run_task)
self.__update_counters()
def __update_counters(self) -> bool:
if (pending := len(self._tasks)) == 0:
self.cancel()
return GLib.SOURCE_REMOVE
self.progress = 1 - (pending / self.total)
return GLib.SOURCE_CONTINUE
def cancel(self) -> None:
"""Cancel all pending tasks."""
if self._idle_id is not None:
GLib.source_remove(self._idle_id)
self._tasks.clear()
self.progress = 0.0
self.total = 0
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:
while len(self._tasks) > 0:
self.__run_next_task()
self.cancel()
def push(self, func: typing.Callable, *args,
now: bool = False, first: bool = False) -> bool | None:
"""Add a task to the Idle Queue."""
if not self.enabled or now:
return func(*args)
pos = 0 if first else len(self._tasks)
self._tasks.insert(pos, (func, *args))
self.total += 1
self.__start()
def push_many(self, func: typing.Callable, args: list[tuple[any]],
now: bool = False) -> None:
"""Add several tasks to the Idle Queue."""
if not self.enabled or now:
for arg in args:
func(*arg)
else:
self._tasks.extend([(func, *arg) for arg in args])
self.total += len(args)
self.__start()
def run_task(self) -> bool:
"""Manually run the next task."""
if len(self._tasks) > 0:
self.__run_next_task()
return self.__update_counters()
return GLib.SOURCE_REMOVE

192
emmental/db/libraries.py Normal file
View File

@ -0,0 +1,192 @@
# Copyright 2022 (c) Anna Schumaker
"""A custom Gio.ListModel for working with libraries."""
import pathlib
import sqlite3
from gi.repository import GObject
from .. import path
from . import idle
from . import playlist
from . import tagger
from . import tracks
class Library(playlist.Playlist):
"""Our custom Library with path and enabled properties."""
libraryid = GObject.Property(type=int)
path = GObject.Property(type=GObject.TYPE_PYOBJECT)
enabled = GObject.Property(type=bool, default=True)
deleting = GObject.Property(type=bool, default=False)
queue = GObject.Property(type=idle.Queue)
readdir = GObject.Property(type=GObject.TYPE_PYOBJECT)
tagger = GObject.Property(type=GObject.TYPE_PYOBJECT)
online = GObject.Property(type=bool, default=False)
def __init__(self, **kwargs):
"""Initialize our Library object."""
super().__init__(queue=idle.Queue(), **kwargs)
self.scan()
def __check_trackid(self, trackid: int) -> bool:
track = self.table.sql.tracks.rows.get(trackid)
if track is not None and not track.path.exists():
tagger.untag_track(self.table.sql, track)
track.delete()
return True
def __queue_delete(self) -> bool:
self.table.delete(self)
self.table.sql.tracks.load()
return True
def __queue_tracks(self) -> bool:
if (files := self.readdir.poll_result()) is None:
self.__stop_thread("readdir")
self.queue.push(self.__stop_thread, "tagger")
return True
self.queue.push_many(self.__tag_track, [(f,) for f in files])
return False
def __reload_playlist_tracks(self, playlist: playlist.Playlist) -> bool:
playlist.reload_tracks(idle=False)
return True
def __tag_track(self, path: pathlib.Path) -> bool:
if self.tagger.ready.is_set():
result = self.tagger.get_result(db=self.table.sql, library=self)
if result is None:
track = self.table.sql.tracks.lookup(self, path=path)
mtime = track.mtime if track else None
self.tagger.tag_file(path, mtime=mtime)
else:
return True
return False
def __scan_library(self) -> bool:
self.readdir = path.readdir_async(self.path)
if self.readdir is not None:
self.online = True
self.load_tracks()
self.queue.push_many(self.__check_trackid,
[(tid,) for tid in self.tracks.trackids])
self.queue.push(self.__queue_tracks)
self.tagger = tagger.Thread()
return True
def __stop_thread(self, thread_name: str) -> bool:
if (thread := self.get_property(thread_name)) is not None:
thread.stop()
self.set_property(thread_name, None)
return True
def do_update(self, column: str) -> bool:
"""Update a Library playlist."""
match column:
case "readdir" | "tagger": pass
case "online": self.table.notify_online(self)
case _: return super().do_update(column)
return True
def delete(self) -> bool:
"""Delete this Library."""
if self.deleting is False:
self.stop()
self.deleting = True
self.table.sql.tracks.clear()
for tbl in self.table.sql.playlist_tables():
if tbl is not self:
self.queue.push_many(self.__reload_playlist_tracks,
[(plist,) for plist in tbl.store])
self.queue.push(self.__queue_delete)
return True
return False
def scan(self) -> None:
"""Scan the Library."""
if not self.queue.running:
self.queue.push(self.__scan_library)
def stop(self) -> None:
"""Stop this Library's background work."""
self.__stop_thread("readdir")
self.__stop_thread("tagger")
self.queue.cancel()
@property
def primary_key(self) -> int:
"""Get this library's primary key."""
return self.libraryid
class Table(playlist.Table):
"""Our Library ListModel."""
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
"""Initialize the Libraries Table."""
super().__init__(sql=sql, system_tracks=False, **kwargs)
def do_add_track(self, library: Library, track: tracks.Track) -> bool:
"""Verify adding a Track to a Library playlist."""
return track.get_library() == library
def do_construct(self, **kwargs) -> Library:
"""Construct a new library."""
return Library(**kwargs)
def do_remove_track(self, library: Library, track: tracks.Track) -> bool:
"""Verify removing a Track from a Library playlist."""
return True
def do_sql_delete(self, library: Library) -> sqlite3.Cursor:
"""Delete a library."""
return self.sql("DELETE FROM libraries WHERE libraryid=?",
library.libraryid)
def do_sql_insert(self, path: pathlib.Path) -> sqlite3.Cursor:
"""Create a new library."""
if cur := self.sql("INSERT INTO libraries (path) VALUES (?)", path):
return self.sql("SELECT * FROM libraries_view WHERE libraryid=?",
cur.lastrowid)
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
"""Search for libraries matching the search text."""
return self.sql("""SELECT libraryid FROM libraries_view
WHERE name GLOB ?""", glob)
def do_sql_select_all(self) -> sqlite3.Cursor:
"""Load libraries from the database."""
return self.sql("SELECT * FROM libraries_view")
def do_sql_select_one(self, path: pathlib.Path) -> sqlite3.Cursor:
"""Look up a library by path."""
return self.sql("SELECT libraryid FROM libraries WHERE path=?", path)
def do_sql_select_trackids(self, library: Library) -> sqlite3.Cursor:
"""Load a Library's Tracks from the database."""
return self.sql("""SELECT trackid FROM library_tracks_view
WHERE libraryid=?""", library.libraryid)
def do_sql_update(self, library: Library, column: str, newval) -> bool:
"""Update a Library playlist."""
if column == "enabled" and self.sql.playlists.collection:
self.sql.playlists.collection.reload_tracks(idle=True)
return self.sql(f"UPDATE libraries SET {column}=? WHERE rowid=?",
newval, library.libraryid)
def notify_online(self, library: Library) -> None:
"""Notify that a library's online status has changed."""
if not library.online or self.loaded:
self.emit("library-online", library)
def stop(self) -> None:
"""Stop any background work."""
for library in self.store:
library.stop()
super().stop()
@GObject.Signal(arg_types=(Library,))
def library_online(self, library: Library) -> None:
"""Signal that a library online status has changed."""

135
emmental/db/media.py Normal file
View File

@ -0,0 +1,135 @@
# Copyright 2022 (c) Anna Schumaker.
"""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
class Medium(playlist.Playlist):
"""Our custom Medium object representing a single disc in an album."""
mediumid = GObject.Property(type=int)
albumid = GObject.Property(type=int)
number = GObject.Property(type=int, default=1)
type = GObject.Property(type=str)
def get_album(self) -> playlist.Playlist:
"""Get this Medium's Album."""
return self.table.sql.albums.rows.get(self.albumid)
def rename(self, new_name: str) -> bool:
"""Rename this medium."""
return self.table.rename(self, new_name)
@property
def primary_key(self) -> int:
"""Get this Medium's primary key."""
return self.mediumid
@GObject.Property(type=playlist.Playlist)
def parent(self) -> playlist.Playlist | None:
"""Get this Medium's parent playlist."""
return self.get_album()
class Filter(table.KeySet):
"""Custom filter to hide media with empty names."""
def do_get_strictness(self) -> Gtk.FilterMatch:
"""Get the strictness of the filter."""
if (res := super().do_get_strictness()) == Gtk.FilterMatch.ALL:
res = Gtk.FilterMatch.SOME
return res
def do_match(self, medium: Medium) -> bool:
"""Check if the Medium matches the filter."""
return len(medium.name) > 0 if super().do_match(medium) else False
class Table(playlist.Table):
"""Our Media Table."""
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
"""Initialize the Media Table."""
super().__init__(sql=sql, filter=Filter(), autodelete=True,
system_tracks=False, **kwargs)
def do_construct(self, **kwargs) -> Medium:
"""Construct a new medium."""
return Medium(**kwargs)
def do_add_track(self, medium: Medium, track: tracks.Track) -> bool:
"""Verify adding a Track to the Medium playlist."""
return track.get_medium() == medium
def do_get_sort_key(self, medium: Medium) -> tuple[int, int, tuple, str]:
"""Get the sort key for a medium."""
return (medium.albumid, medium.number,
format.sort_key(medium.name), medium.type)
def do_remove_track(self, medium: Medium, track: tracks.Track) -> bool:
"""Verify removing a Track from the Medium playlist."""
return True
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)
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
"""Search for media names matching the search text."""
return self.sql("""SELECT mediumid FROM media
WHERE CASEFOLD(name) GLOB ?""", glob)
def do_sql_insert(self, album: playlist.Playlist, name: str,
*, number: int, type: str = "") -> sqlite3.Cursor | None:
"""Create a new medium."""
if cur := self.sql("""INSERT INTO media (albumid, number, name, type)
VALUES (?, ?, ?, ?)""",
album.albumid, number, name, type):
return self.sql("SELECT * FROM media_view WHERE mediumid=?",
cur.lastrowid)
def do_sql_select_all(self) -> sqlite3.Cursor:
"""Load media from the database."""
return self.sql("SELECT * FROM media_view")
def do_sql_select_one(self, album: playlist.Playlist,
*, number: int, type: str = "") -> sqlite3.Cursor:
"""Look up a medium by album, number, and type."""
return self.sql("""SELECT mediumid FROM media
WHERE albumid=? AND number=? AND type=?""",
album.albumid, number, type)
def do_sql_select_trackids(self, medium: Medium) -> sqlite3.Cursor:
"""Load a Medium's Tracks from the database."""
return self.sql("""SELECT trackid FROM medium_tracks_view
WHERE mediumid=?""", medium.mediumid)
def do_sql_update(self, medium: Medium,
column: str, newval) -> sqlite3.Cursor:
"""Update a medium."""
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:
if self.update(medium, "name", new_name):
self.store.remove(medium)
medium.name = new_name
self.store.append(medium)
return True
return False

309
emmental/db/playlist.py Normal file
View File

@ -0,0 +1,309 @@
# 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
from .tracks import Track, TrackidSet
from .. import format
from . import table
class Playlist(table.Row):
"""Our shared Playlist Row object."""
propertyid = GObject.Property(type=int)
name = GObject.Property(type=str)
active = GObject.Property(type=bool, default=False)
loop = GObject.Property(type=str, default="None")
shuffle = GObject.Property(type=bool, default=False)
sort_order = GObject.Property(type=str)
tracks = GObject.Property(type=TrackidSet)
n_tracks = GObject.Property(type=int)
user_tracks = GObject.Property(type=bool, default=False)
tracks_loaded = GObject.Property(type=bool, default=False)
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,
name: str, current_trackid: int | None = 0, **kwargs):
"""Initialize a Playlist object."""
current_trackid = 0 if current_trackid is None else current_trackid
super().__init__(table=table, propertyid=propertyid, name=name,
current_trackid=current_trackid,
tracks=TrackidSet(), **kwargs)
self.tracks.bind_property("n-trackids", self, "n-tracks")
def __add_track(self, track: Track) -> bool:
self.tracks.add_track(track)
return True
def __remove_track(self, track: Track) -> bool:
self.tracks.remove_track(track)
self.table.remove_track(self, track)
return True
def add_children(self, child_table: table.Table, child_keys: set) -> None:
"""Create a FilterListModel for this playlist's children."""
self.child_set = table.TableSubset(child_table, keys=child_keys)
self.children = Gtk.FilterListModel.new(self.child_set,
child_table.get_filter())
def do_update(self, column: str) -> bool:
"""Update a Playlist object."""
match column:
case "propertyid" | "name" | "n-tracks" | "child-set" | \
"children" | "user-tracks" | "tracks-loaded" | \
"tracks-movable": pass
case _: 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):
self.table.queue.push(self.__add_track, track, now=not idle)
def get_track_order(self) -> dict[int, int]:
"""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
def load_tracks(self) -> bool:
"""Load this Playlist's Tracks (if they haven't been loaded yet)."""
if not self.tracks_loaded:
self.tracks.trackids = self.table.get_trackids(self)
self.tracks_loaded = True
return True
def move_track_down(self, track: Track) -> bool:
"""Move a track down in the sort order."""
return self.table.move_track_down(self, track)
def move_track_up(self, track: Track) -> bool:
"""Move a track up in the sort order."""
return self.table.move_track_up(self, track)
def reload_tracks(self, *, idle: bool = False) -> None:
"""Load this Playlist's Tracks."""
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)
def rename(self, new_name: str) -> bool:
"""Rename this playlist."""
return self.table.rename(self, new_name)
@GObject.Property(type=table.Row)
def parent(self) -> table.Row | None:
"""Get this playlist's parent playlist."""
return None
class Table(table.Table):
"""A table.Table with extra functionality for Playlists."""
active_playlist = GObject.Property(type=Playlist)
treemodel = GObject.Property(type=Gtk.TreeListModel)
autodelete = GObject.Property(type=bool, default=False)
system_tracks = GObject.Property(type=bool, default=True)
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
"""Initialize a Playlist Table."""
super().__init__(sql=sql, **kwargs)
self.treemodel = Gtk.TreeListModel.new(root=self,
passthrough=False,
autoexpand=False,
create_func=self.__create_tree)
def __do_autodelete(self, plist: Playlist) -> bool:
if plist.n_tracks == 0:
self.delete(plist)
return True
def __autodelete(self, plist: Playlist):
if self.autodelete:
self.queue.push(self.__do_autodelete, plist)
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
def do_get_sort_key(self, playlist: Playlist) -> tuple[str]:
"""Get a sort key for the requested Playlist."""
return format.sort_key(playlist.name)
def do_get_user_track_order(self, playlist: Playlist) -> dict[int, int]:
"""Get a mapping of sort keys for the tracks in this Playlist."""
raise NotImplementedError
def do_move_track_down(self, playlist: Playlist, track: Track) -> bool:
"""Move a track down in the sort order."""
raise NotImplementedError
def do_move_track_up(self, playlist: Playlist, track: Track) -> bool:
"""Move a track up in the sort order."""
raise NotImplementedError
def do_remove_track(self, playlist: Playlist, track: Track) -> bool:
"""Remove a Track from the Playlist."""
raise NotImplementedError
def do_sql_select_trackids(self, playlist: Playlist) -> sqlite3.Cursor:
"""Select the trackids that are in this Playlist."""
raise NotImplementedError
def add_system_track(self, playlist: Playlist, track: Track) -> bool:
"""Add a Track to a system Playlist."""
cur = self.sql("""INSERT INTO system_tracks (propertyid, trackid)
VALUES (?, ?)""", playlist.propertyid, track.trackid)
return cur and cur.rowcount == 1
def add_track(self, playlist: Playlist, track: Track) -> bool:
"""Add a Track to a Playlist."""
if track is None or track.get_library().deleting:
return False
if self.system_tracks:
return self.add_system_track(playlist, track)
return self.do_add_track(playlist, track)
def clear(self) -> None:
"""Clear the Table."""
self.active_playlist = None
super().clear()
def construct(self, propertyid: int, name: str, **kwargs) -> Playlist:
"""Construct a new Playlist object."""
res = super().construct(propertyid=propertyid, name=name, **kwargs)
if res.active:
self.sql.set_active_playlist(res)
res.reload_tracks(idle=True)
return res
def delete(self, playlist: Playlist) -> bool:
"""Delete a playlist from the database."""
if playlist.active:
self.sql.set_active_playlist(None)
return super().delete(playlist)
def get_sql_system_trackids(self, playlist: Playlist) -> sqlite3.Cursor:
"""Load a System Playlist's Tracks from the database."""
return self.sql("""SELECT trackid FROM system_tracks_view
WHERE propertyid=?""", playlist.propertyid)
def get_trackids(self, playlist: Playlist) -> set[int]:
"""Load a Playlist's Tracks from the database."""
if self.system_tracks:
cur = self.get_sql_system_trackids(playlist)
else:
cur = self.do_sql_select_trackids(playlist)
res = {row["trackid"] for row in cur.fetchall()}
self.__autodelete(playlist)
return res
def get_track_order(self, playlist: Playlist) -> dict[int, int]:
"""Get the track sort order for a playlist."""
if playlist.tracks_movable and playlist.sort_order == "user":
return self.do_get_user_track_order(playlist)
return self.sql.tracks.map_sort_order(playlist.sort_order)
def move_track_down(self, playlist: Playlist, track: Track) -> bool:
"""Move a track down in the playlist."""
if not playlist.tracks_movable:
return False
if res := self.do_move_track_down(playlist, track):
if playlist.sort_order != "user":
playlist.sort_order = "user"
return res
def move_track_up(self, playlist: Playlist, track: Track) -> bool:
"""Move a track up in the playlist."""
if not playlist.tracks_movable:
return False
if res := self.do_move_track_up(playlist, track):
if playlist.sort_order != "user":
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
WHERE propertyid=? AND trackid=?""",
playlist.propertyid, track.trackid).rowcount == 1
def remove_track(self, playlist: Playlist, track: Track) -> bool:
"""Remove a Track from a Playlist."""
if self.system_tracks:
res = self.remove_system_track(playlist, track)
else:
res = self.do_remove_track(playlist, track)
self.__autodelete(playlist)
return res
def update(self, playlist: Playlist, column: str, newval) -> bool:
"""Update a Playlist in the Database."""
match column:
case "active" | "loop" | "shuffle" | \
"sort-order" | "current-trackid":
return self.update_playlist_property(playlist, column, newval)
case _:
return super().update(playlist, column, newval)
def update_playlist_property(self, playlist: Playlist,
column: str, newval) -> bool:
"""Update the playlists_common table."""
match column:
case "active":
self.active_playlist = playlist if playlist.active else None
case "current-trackid":
column = "current_trackid"
newval = None if newval == 0 else newval
case "sort-order":
column = "sort_order"
return self.sql(f"""UPDATE playlist_properties
SET {column}=? WHERE propertyid=?""",
newval, playlist.propertyid) is not None

237
emmental/db/playlists.py Normal file
View File

@ -0,0 +1,237 @@
# 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
class Playlist(playlist.Playlist):
"""Our custom Playlist with an image filepath."""
playlistid = GObject.Property(type=int)
image = GObject.Property(type=GObject.TYPE_PYOBJECT)
def do_update(self, column: str) -> None:
"""Update a playlist object."""
match (self.name, column, self.get_property(column)):
case ("Collection", "loop", "None"):
self.loop = "Playlist"
case ("Collection", "n-tracks", 0):
self.table.have_collection_tracks = False
case ("Collection", "n-tracks", _):
self.table.have_collection_tracks = True
case ("Previous Tracks", "loop", "Playlist") | \
("Previous Tracks", "loop", "Track"):
self.loop = "None"
case ("Previous Tracks", "shuffle", True):
self.shuffle = False
case ("Previous Tracks", "sort-order", _):
if self.sort_order != "laststarted DESC":
self.sort_order = "laststarted DESC"
case (_, _, _): super().do_update(column)
def rename(self, new_name: str) -> bool:
"""Rename this playlist."""
return self.table.rename(self, new_name)
@property
def primary_key(self) -> int:
"""Get the playlist primary key."""
return self.playlistid
class Table(playlist.Table):
"""Our Playlist Table."""
collection = GObject.Property(type=Playlist)
favorites = GObject.Property(type=Playlist)
most_played = GObject.Property(type=Playlist)
new_tracks = GObject.Property(type=Playlist)
previous = GObject.Property(type=Playlist)
queued = GObject.Property(type=Playlist)
unplayed = GObject.Property(type=Playlist)
have_collection_tracks = GObject.Property(type=bool, default=False)
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:
order = self.get_track_order(playlist)
tracks = sorted(playlist.tracks.trackids, key=order.get)
start = tracks.index(trackid)
new = start + offset
if not (0 <= new < len(tracks)):
return False
tracks[start] = tracks[new]
tracks[new] = trackid
# Note: We write out all trackids so we don't have to update during
# do_add_track() and do_remove_track()
args = [(i, playlist.propertyid, t) for (i, t) in enumerate(tracks)]
self.sql.executemany("""UPDATE user_tracks SET position=?
WHERE propertyid=? AND trackid=?""", *args)
return True
def do_construct(self, **kwargs) -> Playlist:
"""Construct a new playlist."""
match (plist := Playlist(**kwargs)).name:
case "Collection": self.collection = plist
case "Favorite Tracks":
self.favorites = plist
self.favorites.user_tracks = True
case "Most Played Tracks": self.most_played = plist
case "New Tracks": self.new_tracks = plist
case "Previous Tracks":
self.previous = plist
self.sql("DELETE FROM system_tracks WHERE propertyid=?",
self.previous.propertyid)
case "Queued Tracks":
self.queued = plist
self.queued.user_tracks = True
self.queued.tracks_movable = True
case "Unplayed Tracks": self.unplayed = plist
case _:
plist.user_tracks = True
plist.tracks_movable = True
return plist
def do_add_track(self, playlist: Playlist, track: tracks.Track) -> bool:
"""Add a Track to the requested Playlist."""
match playlist:
case self.collection: return track.get_library().enabled
case self.most_played: view = "most_played_view"
case self.new_tracks: view = "new_tracks_view"
case self.favorites:
track.update_properties(favorite=True)
return True
case self.previous:
self.add_system_track(playlist, track)
return True
case self.queued:
self.sql.set_active_playlist(playlist)
return self.add_user_track(playlist, track)
case self.unplayed: return track.playcount == 0
case _: return self.add_user_track(playlist, track)
return self.sql(f"SELECT ? IN {view}", track.trackid).fetchone()[0]
def do_get_user_track_order(self, playlist: Playlist) -> dict[int, int]:
"""Get the user-configured sort order for a playlist."""
cur = self.sql("""SELECT trackid FROM user_tracks WHERE propertyid=?
ORDER BY position NULLS LAST, rowid""",
playlist.propertyid)
return {row["trackid"]: i for (i, row) in enumerate(cur.fetchall())}
def do_move_track_down(self, playlist: Playlist,
track: tracks.Track) -> bool:
"""Move a track down in the user sort order."""
return self.__move_user_trackid(playlist, track.trackid, offset=1)
def do_move_track_up(self, playlist: Playlist,
track: tracks.Track) -> bool:
"""Move a track up in the user sort order."""
return self.__move_user_trackid(playlist, track.trackid, offset=-1)
def do_remove_track(self, playlist: Playlist, track: tracks.Track) -> bool:
"""Remove a Track from the requested Playlist."""
match playlist:
case self.collection: return True
case self.most_played: return True
case self.new_tracks: return True
case self.unplayed: return True
case self.favorites:
track.update_properties(favorite=False)
return True
case self.previous:
return self.remove_system_track(playlist, track)
case _: return self.remove_user_track(playlist, track)
def do_sql_delete(self, playlist: Playlist) -> sqlite3.Cursor:
"""Delete a playlist."""
return self.sql("DELETE FROM playlists WHERE playlistid=?",
playlist.playlistid)
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
"""Search for playlists matching the search text."""
return self.sql("""SELECT playlistid FROM playlists
WHERE CASEFOLD(name) GLOB ?""", glob)
def do_sql_insert(self, name: str, **kwargs) -> sqlite3.Cursor | None:
"""Insert a new playlist into the database."""
if (cur := self.sql("INSERT INTO playlists (name) VALUES (?)", name)):
return self.sql("SELECT * FROM playlists_view WHERE playlistid=?",
cur.lastrowid)
def do_sql_select_all(self) -> sqlite3.Cursor:
"""Load playlists from the database."""
return self.sql("SELECT * FROM playlists_view")
def do_sql_select_trackids(self, playlist: Playlist) -> sqlite3.Cursor:
"""Load Tracks from the database."""
match playlist:
case self.collection: view = "collection_view"
case self.favorites: view = "favorite_view"
case self.most_played: view = "most_played_view"
case self.new_tracks: view = "new_tracks_view"
case self.unplayed: view = "unplayed_tracks_view"
case self.previous: return self.get_sql_system_trackids(playlist)
case _: return self.get_sql_user_trackids(playlist)
return self.sql(f"SELECT trackid FROM {view}")
def do_sql_select_one(self, name: str) -> sqlite3.Cursor:
"""Look up a playlist by name."""
return self.sql("SELECT playlistid FROM playlists WHERE name=?", name)
def do_sql_update(self, playlist: Playlist,
column: str, newval) -> sqlite3.Cursor:
"""Update a playlist."""
return self.sql(f"UPDATE playlists SET {column}=? WHERE playlistid=?",
newval, playlist.playlistid)
def add_user_track(self, playlist: Playlist, track: tracks.Track) -> bool:
"""Add a Track to the User Tracks table."""
cur = self.sql("""INSERT INTO user_tracks (propertyid, trackid)
VALUES (?, ?)""", playlist.propertyid, track.trackid)
return cur and cur.rowcount == 1
def get_sql_user_trackids(self, playlist: Playlist) -> sqlite3.Cursor:
"""Load user Tracks from the database."""
return self.sql("""SELECT trackid FROM user_tracks_view
WHERE propertyid=?""", playlist.propertyid)
def create(self, name: str) -> Playlist:
"""Create a new Playlist."""
if len(name := name.strip()) > 0:
return super().create(name)
def remove_user_track(self, playlist: Playlist,
track: tracks.Track) -> bool:
"""Remove a track from the User Tracks table."""
return self.sql("""DELETE FROM user_tracks
WHERE propertyid=? AND trackid=?""",
playlist.propertyid, track.trackid).rowcount == 1
def rename(self, playlist: Playlist, new_name: str) -> bool:
"""Rename a Playlist."""
if len(new_name := new_name.strip()) > 0:
if playlist.name != new_name:
if self.update(playlist, "name", new_name):
self.store.remove(playlist)
playlist.name = new_name
self.store.append(playlist)
return True
return False

112
emmental/db/settings.py Normal file
View File

@ -0,0 +1,112 @@
# Copyright 2022 (c) Anna Schumaker.
"""Easy access to the settings table in our database."""
import sqlite3
from gi.repository import GObject
from . import idle
from . import table
class Setting(table.Row):
"""Base class for settings."""
key = GObject.Property(type=str)
@property
def primary_key(self) -> str:
"""Get the primary key for this setting."""
return self.key
class IntSetting(Setting):
"""An integer setting."""
value = GObject.Property(type=int)
class FloatSetting(Setting):
"""A float setting."""
value = GObject.Property(type=float)
class BoolSetting(Setting):
"""A boolean setting."""
value = GObject.Property(type=bool, default=False)
class StringSetting(Setting):
"""A string setting."""
value = GObject.Property(type=str)
class Table(table.Table):
"""Creates and manages our settings properties."""
def __init__(self, sql: GObject.TYPE_PYOBJECT):
"""Initialize the settings table."""
super().__init__(sql, queue=idle.Queue(enabled=False))
def __getitem__(self, key: str) -> int | float | str | bool | None:
"""Get the value for a specific settings key."""
if (setting := self.lookup(key)) is not None:
return setting.value
def do_construct(self, type: str, value: any, **kwargs) -> table.Row:
"""Construct a new settings row."""
match type:
case "gint":
return IntSetting(value=int(value), **kwargs)
case "gdouble":
return FloatSetting(value=float(value), **kwargs)
case "gboolean":
value = str(value) == "True"
return BoolSetting(value=value, **kwargs)
case "gchararray":
return StringSetting(value=value, **kwargs)
def do_get_sort_key(self, setting: table.Row) -> list[str]:
"""Get the sort key for a specific setting."""
return setting.key.casefold().split(".")
def do_sql_delete(self, setting: table.Row) -> sqlite3.Cursor:
"""Delete a setting."""
return self.sql("DELETE FROM settings WHERE key=?", setting.key)
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
"""Filter the settings table."""
return self.sql("""SELECT key FROM settings
WHERE CASEFOLD(key) GLOB ?""", glob)
def do_sql_insert(self, key: str, type: str, value) -> sqlite3.Cursor:
"""Create a new settings row."""
return self.sql("""INSERT INTO settings (key, type, value)
VALUES (?, ?, ?) RETURNING *""",
key, type, str(value))
def do_sql_select_all(self) -> sqlite3.Cursor:
"""Load settings from the database."""
return self.sql("SELECT * FROM settings ORDER BY CASEFOLD(key)")
def do_sql_select_one(self, key: str) -> int | None:
"""Look up a setting by key."""
return self.sql("SELECT key FROM settings WHERE key=?", key)
def do_sql_update(self, setting: table.Row, column: str,
newval: any) -> sqlite3.Cursor:
"""Update a Setting."""
return self.sql(f"UPDATE settings SET {column}=? WHERE key=?",
str(newval), setting.key)
def bind_setting(self, key: str, target: GObject.GObject,
property: str) -> None:
"""Bind a setting to a target property."""
if (setting := self.lookup(key=key)) is None:
param = target.find_property(property)
setting = self.create(key=key, type=param.value_type.name,
value=target.get_property(property))
else:
target.set_property(property, setting.value)
setting.bind_property("value", target, property,
GObject.BindingFlags.BIDIRECTIONAL)

347
emmental/db/table.py Normal file
View File

@ -0,0 +1,347 @@
# Copyright 2022 (c) Anna Schumaker
"""Base classes for database objects."""
import bisect
import sqlite3
from gi.repository import GObject
from gi.repository import Gio
from gi.repository import Gtk
from .idle import Queue
from .. import store
class Row(GObject.GObject):
"""A single row in a database table."""
table = GObject.Property(type=Gio.ListModel)
def __init__(self, table: Gio.ListModel, **kwargs):
"""Initialize a database Row."""
super().__init__(table=table, **kwargs)
self.connect("notify", self.__notify)
def __notify(self, row: GObject.GObject, param: GObject.ParamSpec) -> None:
match param.name:
case "table": pass
case _: self.do_update(param.name)
def do_update(self, column: str) -> bool:
"""Update a Row in the database."""
return self.table.update(self, column, self.get_property(column))
def delete(self) -> bool:
"""Delete this Row."""
return self.table.delete(self)
@property
def primary_key(self) -> None:
"""Get the primary key for this row."""
raise NotImplementedError
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 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 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_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 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 Gtk.Filter."""
if self._keys is None:
return Gtk.FilterMatch.ALL
if len(self._keys) == 0:
return Gtk.FilterMatch.NONE
return Gtk.FilterMatch.SOME
def do_match(self, row: Row) -> bool:
"""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 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 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
def keys(self) -> set[any]:
"""Return the set of matching primary keys."""
return self._keys
@keys.setter
def keys(self, keys: set[any] | None) -> None:
"""Set the matching primary keys."""
(removed, added, change) = self.__find_difference(keys)
if change is not None:
self._keys = keys
self.emit("keys-changed", removed, added)
self.changed(change)
@GObject.Signal(arg_types=(int,))
def key_added(self, key: int) -> None:
"""Signal that a Row has been added to the KeySet."""
@GObject.Signal(arg_types=(int,))
def key_removed(self, key: int) -> None:
"""Signal that a Row has been removed from the KeySet."""
@GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT, GObject.TYPE_PYOBJECT))
def keys_changed(self, removed: set | None, added: set | None) -> None:
"""Signal that the KeySet has been directly modified."""
class Table(Gtk.FilterListModel):
"""An object that represents a database Table."""
sql = GObject.Property(type=GObject.TYPE_PYOBJECT)
queue = GObject.Property(type=Queue)
store = GObject.Property(type=Gio.ListModel)
rows = GObject.Property(type=GObject.TYPE_PYOBJECT)
loaded = GObject.Property(type=bool, default=False)
def __init__(self, sql: GObject.TYPE_PYOBJECT,
filter: KeySet | None = None,
queue: Queue | None = None, **kwargs):
"""Set up our Table object."""
super().__init__(sql=sql, rows=dict(),
store=store.SortedList(self.get_sort_key),
filter=(filter if filter else KeySet()),
queue=(queue if queue else Queue()), **kwargs)
self.set_model(self.store)
def __clear_rows(self) -> None:
self.rows.clear()
self.store.clear()
self.loaded = False
def __contains__(self, row: Row) -> bool:
"""Check if the row is in the _rowid_map for this Table."""
return self.index(row) is not None
def do_construct(self, *args, **kwargs) -> Row:
"""Construct a new Row instance."""
raise NotImplementedError
def do_get_sort_key(self, row: Row) -> any:
"""Get a sort key for the requested row."""
return None
def do_sql_delete(self, row: Row) -> bool:
"""Delete a Row."""
raise NotImplementedError
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
"""Select matching rowids using GLOB."""
raise NotImplementedError
def do_sql_insert(self, *args, **kwargs) -> sqlite3.Cursor:
"""Create a new Row."""
raise NotImplementedError
def do_sql_select_all(self) -> sqlite3.Cursor:
"""Return all rows from the table."""
raise NotImplementedError
def do_sql_select_one(self, *args, **kwargs) -> sqlite3.Cursor:
"""Look up a single row."""
raise NotImplementedError
def do_sql_update(self, row: Row, column: str, newval) -> sqlite3.Cursor:
"""Update a row."""
raise NotImplementedError
def clear(self) -> None:
"""Clear the table."""
self.stop()
self.__clear_rows()
def construct(self, *args, **kwargs) -> Row:
"""Construct a new Row instance."""
return self.do_construct(table=self, *args, **kwargs)
def create(self, *args, **kwargs) -> Row | None:
"""Create a new Row in the Table."""
if cur := self.do_sql_insert(*args, **kwargs):
return self.insert(self.construct(**cur.fetchone()))
def delete(self, row: Row) -> bool:
"""Delete a Row from the Table."""
if row in self and self.do_sql_delete(row).rowcount == 1:
self.sql.commit()
self.store.remove(row)
del self.rows[row.primary_key]
return True
return False
def _filter_idle(self, glob: str) -> bool:
rows = self.do_sql_glob(glob).fetchall()
self.get_filter().keys = {row[0] for row in rows}
return True
def filter(self, glob: str | None, *, now: bool = False) -> None:
"""Filter the displayed Rows."""
if glob is not None:
self.queue.cancel_task(self._filter_idle)
self.queue.push(self._filter_idle, glob, now=now, first=True)
else:
self.get_filter().keys = None
def get_sort_key(self, row: Row) -> tuple:
"""Get a sort key for the requested row."""
res = self.do_get_sort_key(row)
return res if res is not None else row.primary_key
def index(self, row: Row) -> int | None:
"""Find the index of a specific Row."""
if row.table is self:
return self.store.index(row)
def insert(self, row: Row) -> Row | None:
"""Insert a Row in sorted position."""
if row and row not in self:
self.store.append(row)
return self.rows.setdefault(row.primary_key, row)
def _load_idle(self) -> bool:
self.__clear_rows()
cur = self.do_sql_select_all()
rows = [self.construct(**row) for row in cur.fetchall()]
self.store.extend(rows)
self.rows = {row.primary_key: row for row in rows}
self.sql.emit("table-loaded", self)
return True
def load(self, *, now: bool = False) -> None:
"""Load the Table from the database."""
self.queue.push(self._load_idle, now=now)
def lookup(self, *args, **kwargs) -> Row | None:
"""Look up a Row in the database."""
row = self.do_sql_select_one(*args, **kwargs).fetchone()
return self.rows.get(row[0]) if row else None
def stop(self) -> None:
"""Stop any background work."""
self.queue.cancel()
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)

242
emmental/db/tagger.py Normal file
View File

@ -0,0 +1,242 @@
# Copyright 2022 (c) Anna Schumaker
"""A wrapper around Mutagen to help us read tags."""
import emmental.audio.tagger
import musicbrainzngs
import pathlib
from gi.repository import GObject
from .. import audio
from .. import thread
from . import albums
from . import artists
from . import connection
from . import decades
from . import media
from . import genres
from . import playlist
from . import tracks
from . import years
class Tags:
"""Translate the audio.tagger._Tags object into Playlists."""
def __init__(self, db: GObject.TYPE_PYOBJECT,
raw_tags: audio.tagger._Tags,
library: playlist.Playlist):
"""Initialize the Tags object."""
self.db = db
with self.db:
self.album = self.get_album(raw_tags.album)
self.album_artists = [self.get_artist(artist)
for artist in raw_tags.album.artists]
self.artists = [self.get_artist(artist)
for artist in raw_tags.artists]
self.decade = self.get_decade(raw_tags.year)
self.genres = list(filter(None, [self.get_genre(genre)
for genre in raw_tags.genres]))
self.medium = self.get_medium(raw_tags.medium)
self.year = self.get_year(raw_tags.year)
self.track = self.get_track(library, raw_tags.file, raw_tags.track)
self.__update_album_artists()
def __update_album_artists(self) -> None:
if self.album is not None:
old = set(self.album.get_artists())
new = set(self.album_artists)
for artist in old - new:
artist.remove_album(self.album)
for artist in new - old:
artist.add_album(self.album)
def __update_track(self, track: tracks.Track,
raw_track: audio.tagger._Track) -> None:
orig_year = track.get_year()
orig_decade = orig_year.parent
orig_genres = set(track.get_genres())
orig_medium = track.get_medium()
orig_album = orig_medium.get_album()
orig_artists = set(track.get_artists())
track.update_properties(mediumid=self.medium.mediumid,
year=self.year.year,
title=raw_track.title,
number=raw_track.number,
length=raw_track.length,
artist=raw_track.artist,
mbid=raw_track.mbid,
mtime=raw_track.mtime)
self.__update_track_playlist_set(track, orig_artists,
set(self.artists))
self.__update_track_playlist_set(track, orig_genres, set(self.genres))
self.__update_track_playlist(track, orig_album, self.album)
self.__update_track_playlist(track, orig_medium, self.medium)
self.__update_track_playlist(track, orig_decade, self.decade)
self.__update_track_playlist(track, orig_year, self.year)
def __update_track_playlist(self, track: tracks.Track,
orig: playlist.Playlist,
new: playlist.Playlist):
if orig != new:
orig.remove_track(track, idle=True)
new.add_track(track, idle=True)
def __update_track_playlist_set(self, track: tracks.Track,
orig: set[playlist.Playlist],
new: set[playlist.Playlist]):
for plist in orig - new:
plist.remove_track(track, idle=True)
for plist in new - orig:
plist.add_track(track, idle=True)
def get_album(self, raw_album: audio.tagger._Album) -> albums.Album | None:
"""Convert the raw album into an Album object."""
if raw_album.name == "":
return None
cover = raw_album.cover if raw_album.cover.is_file() else None
album = self.db.albums.lookup(raw_album.name, raw_album.artist,
raw_album.release, mbid=raw_album.mbid)
if album is not None:
if album.cover != cover:
album.cover = cover
return album
return self.db.albums.create(raw_album.name, raw_album.artist,
raw_album.release, mbid=raw_album.mbid,
cover=cover)
def get_artist(self, raw_artist: audio.tagger._Artist) \
-> artists.Artist | None:
"""Convert the raw artist into an Artist object."""
artist = self.db.artists.lookup(raw_artist.name, mbid=raw_artist.mbid)
if artist is not None:
return artist
return self.db.artists.create(raw_artist.name, mbid=raw_artist.mbid)
def get_decade(self, raw_year: int | None) -> decades.Decade | None:
"""Convert the raw year into a Decade object."""
if raw_year:
decade = self.db.decades.lookup(raw_year)
return decade if decade else self.db.decades.create(raw_year)
def get_genre(self, raw_genre: str) -> genres.Genre:
"""Convert the raw genre names into Genre objects."""
genre = self.db.genres.lookup(raw_genre)
return genre if genre else self.db.genres.create(raw_genre)
def get_medium(self, raw_medium: audio.tagger._Medium) \
-> media.Medium | None:
"""Convert the raw medium into a Medium object."""
if self.album is None:
return None
medium = self.db.media.lookup(self.album, number=raw_medium.number,
type=raw_medium.type)
if medium is not None:
medium.rename(raw_medium.name)
return medium
return self.db.media.create(self.album, raw_medium.name,
number=raw_medium.number,
type=raw_medium.type)
def get_track(self, library: playlist.Playlist, filepath: pathlib.Path,
raw_track: audio.tagger._Track) -> tracks.Track | None:
"""Convert the raw track into a Track object."""
if self.medium is None or self.year is None:
return None
track = self.db.tracks.lookup(library, path=filepath)
if track is not None:
self.__update_track(track, raw_track)
return track
track = self.db.tracks.create(library, filepath, self.medium,
self.year, title=raw_track.title,
number=raw_track.number,
length=raw_track.length,
artist=raw_track.artist,
mbid=raw_track.mbid,
mtime=raw_track.mtime)
for plist in [self.db.playlists.collection,
self.db.playlists.new_tracks,
self.db.playlists.unplayed,
self.album, *self.artists, self.medium,
*self.genres, self.decade, self.year, library]:
plist.add_track(track, idle=True)
return track
def get_year(self, raw_year: int | None) -> years.Year | None:
"""Convert the raw year into a Year object."""
if raw_year:
year = self.db.years.lookup(raw_year)
return year if year else self.db.years.create(raw_year)
class Thread(thread.Thread):
"""A thread for tagging files without blocking the UI."""
def __init__(self):
"""Initialize the Tagger Thread."""
super().__init__()
self._connection = None
def __get_connection(self) -> connection.Connection:
if not self._connection:
self._connection = connection.Connection()
return self._connection
def __check_artist(self, artist: audio.tagger._Artist) -> None:
if artist.name is None and len(artist.mbid) > 0:
sql = self.__get_connection()
cur = sql("SELECT name FROM artists WHERE mbid=?", artist.mbid)
if row := cur.fetchone():
artist.name = row["name"]
else:
mb_res = musicbrainzngs.get_artist_by_id(artist.mbid)
artist.name = mb_res["artist"]["name"]
def do_get_result(self, result: thread.Data, db: GObject.TYPE_PYOBJECT,
library: playlist.Playlist) -> tuple:
"""Return the resulting Tags structure."""
tags = None if result.tags is None else Tags(db, result.tags, library)
return (result.path, tags)
def do_run_task(self, task: thread.Data) -> None:
"""Tag a file."""
tags = emmental.audio.tagger.tag_file(task.path, task.mtime)
if tags is not None:
for artist in tags.artists:
self.__check_artist(artist)
self.set_result(path=task.path, tags=tags)
def do_stop(self) -> None:
"""Close the connection before stopping."""
if self._connection:
self._connection.close()
self._connection = None
def tag_file(self, path: pathlib.Path,
*, mtime: float | None = None) -> None:
"""Tag a file."""
self.set_task(path=path, mtime=mtime)
def untag_track(db: GObject.TYPE_PYOBJECT, track: tracks.Track) -> None:
"""Untag a Track."""
medium = track.get_medium()
year = track.get_year()
playlists = [plist for plist in db.playlists.store]
playlists.extend([medium, medium.get_album()])
playlists.extend(track.get_artists())
playlists.extend([year, year.parent, track.get_library()])
for plist in playlists:
plist.remove_track(track)

374
emmental/db/tracks.py Normal file
View File

@ -0,0 +1,374 @@
# Copyright 2022 (c) Anna Schumaker.
"""A custom Gio.ListModel for working with tracks."""
import datetime
import pathlib
import random
import sqlite3
from typing import Iterable
from gi.repository import GObject
from gi.repository import Gtk
from . import table
PLAYED_THRESHOLD = 2 / 3
class Track(table.Row):
"""Our custom Track object."""
trackid = GObject.Property(type=int)
libraryid = GObject.Property(type=int)
mediumid = GObject.Property(type=int)
year = GObject.Property(type=int)
active = GObject.Property(type=bool, default=False)
favorite = GObject.Property(type=bool, default=False)
path = GObject.Property(type=GObject.TYPE_PYOBJECT)
mbid = GObject.Property(type=str)
title = GObject.Property(type=str)
artist = GObject.Property(type=str)
number = GObject.Property(type=int)
length = GObject.Property(type=float)
mtime = GObject.Property(type=float)
playcount = GObject.Property(type=int)
added = GObject.Property(type=GObject.TYPE_PYOBJECT)
laststarted = GObject.Property(type=GObject.TYPE_PYOBJECT)
lastplayed = GObject.Property(type=GObject.TYPE_PYOBJECT)
restarted = GObject.Property(type=GObject.TYPE_PYOBJECT)
def do_update(self, column: str) -> bool:
"""Update a Track object."""
match column:
case "trackid" | "libraryid" | "active" | "path" | "playcount" | \
"laststarted" | "lastplayed" | "restarted": pass
case _: return super().do_update(column)
return True
def get_artists(self) -> list[table.Row]:
"""Get a list of Artists for this Track."""
return self.table.get_artists(self)
def get_genres(self) -> list[table.Row]:
"""Get a list of Genres for this Track."""
return self.table.get_genres(self)
def get_library(self) -> table.Row | None:
"""Get the Library associated with this Track."""
return self.table.sql.libraries.rows.get(self.libraryid)
def get_medium(self) -> table.Row | None:
"""Get the Medium associated with this Track."""
return self.table.sql.media.rows.get(self.mediumid)
def get_year(self) -> table.Row | None:
"""Get the Year associated with this Track."""
return self.table.sql.years.rows.get(self.year)
def restart(self) -> None:
"""Mark that a previously started track has been started again."""
self.table.restart_track(self)
def start(self) -> None:
"""Mark that this track has started playback."""
self.table.start_track(self)
def stop(self, play_time: float) -> None:
"""Mark that this track has stopped playback."""
self.table.stop_track(self, play_time / self.length > PLAYED_THRESHOLD)
def update_properties(self, **kwargs) -> None:
"""Update one or more of this Track's properties."""
for (property, newval) in kwargs.items():
if self.get_property(property) != newval:
self.set_property(property, newval)
@property
def primary_key(self) -> int:
"""Get the primary key for this Track."""
return self.trackid
class Filter(table.KeySet):
"""A customized Filter that never sets strictness to FilterMatch.All."""
def do_get_strictness(self) -> Gtk.FilterMatch:
"""Get the strictness of the filter."""
if self.n_keys == 0:
return Gtk.FilterMatch.NONE
return Gtk.FilterMatch.SOME
class Table(table.Table):
"""A ListStore tailored for storing Track objects."""
have_current_track = GObject.Property(type=bool, default=False)
current_track = GObject.Property(type=Track)
current_favorite = GObject.Property(type=bool, default=False)
def __init__(self, sql: GObject.TYPE_PYOBJECT):
"""Initialize a Track Table."""
super().__init__(sql, filter=Filter())
self.set_model(None)
self.connect("notify::current-track", self.__notify_current_track)
self.connect("notify::current-favorite",
self.__notify_current_favorite)
def __notify_current_track(self, table: table.Table, param) -> None:
if self.current_track is not None:
self.have_current_track = True
self.current_favorite = self.current_track.favorite
self.sql.playlists.previous.add_track(self.current_track)
else:
self.have_current_track = False
self.current_favorite = False
def __notify_current_favorite(self, table: table.Table, param) -> None:
if self.current_track is not None:
self.current_track.update_properties(
favorite=self.current_favorite)
elif self.current_favorite is True:
self.current_favorite = False
def do_construct(self, **kwargs) -> Track:
"""Construct a new Track instance."""
if (track := Track(**kwargs)).active:
self.current_track = track
return track
def do_sql_delete(self, track: Track) -> sqlite3.Cursor:
"""Delete a Track."""
return self.sql("DELETE FROM tracks WHERE trackid=?", track.trackid)
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
"""Filter the Track table."""
return self.sql("""SELECT trackid FROM track_info_view WHERE
CASEFOLD(title) GLOB :glob
OR CASEFOLD(artist) GLOB :glob
OR CASEFOLD(album) GLOB :glob
OR CASEFOLD(albumartist) GLOB :glob
OR CASEFOLD(medium) GLOB :glob
OR release GLOB :glob""", glob=glob)
def do_sql_insert(self, library: table.Row, path: pathlib.Path,
medium: table.Row, year: table.Row, *, title: str = "",
number: int = 0, length: float = 0.0, artist: str = "",
mbid: str = "", mtime: float = 0.0) -> sqlite3.Cursor:
"""Insert a new Track into the database."""
if cur := self.sql("""INSERT INTO tracks
(libraryid, mediumid, path, year, title,
number, length, artist, mbid, mtime)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *""",
library.libraryid, medium.mediumid, path, year.year,
title, number, length, artist, mbid, mtime):
return self.sql("SELECT * FROM tracks WHERE trackid=?",
cur.lastrowid)
def do_sql_select_all(self) -> sqlite3.Cursor:
"""Load Tracks from the database."""
return self.sql("SELECT * FROM tracks")
def do_sql_select_one(self, library: table.Row = None,
*, path: pathlib.Path = None,
mbid: str = None) -> sqlite3.Cursor:
"""Look up a Track in the database."""
if path is None and mbid is None:
raise KeyError("Either 'path' or 'mbid' are required")
args = [("libraryid=?", library.libraryid if library else None),
("path=?", path), ("mbid=?", mbid)]
(where, args) = tuple(zip(*[arg for arg in args if None not in arg]))
sql_where = " AND ".join(where)
return self.sql(f"SELECT trackid FROM tracks WHERE {sql_where}", *args)
def do_sql_update(self, track: Track, column: str,
newval: any) -> sqlite3.Cursor:
"""Update a Track."""
match (column, newval):
case ("favorite", True):
self.sql.playlists.favorites.add_track(track)
if track == self.current_track:
self.current_favorite = True
case ("favorite", False):
self.sql.playlists.favorites.remove_track(track)
if track == self.current_track:
self.current_favorite = False
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
WHERE trackid=?""", track.trackid).fetchall()
return [self.sql.artists.rows.get(row["artistid"]) for row in rows]
def get_genres(self, track: Track) -> list[int]:
"""Get the list of Genres for a specific Track."""
rows = self.sql("""SELECT genreid FROM genre_tracks_view
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"
cur = self.sql(f"""SELECT trackid FROM track_info_view
ORDER BY {ordering}""")
return {row["trackid"]: i for (i, row) in enumerate(cur.fetchall())}
def mark_path_active(self, path: pathlib.Path) -> None:
"""Mark a specific track as active in the database.."""
if self.sql("UPDATE tracks SET active=TRUE WHERE path=?",
path).rowcount == 0:
self.sql("UPDATE tracks SET active=FALSE WHERE active=TRUE")
def restart_track(self, track: Track) -> None:
"""Mark that a Track has been restarted."""
track.active = True
track.restarted = datetime.datetime.utcnow()
self.current_track = track
def start_track(self, track: Track) -> None:
"""Mark that a Track has been started."""
self.sql.playlists.previous.remove_track(track)
cur = self.sql("""UPDATE tracks SET active=TRUE, laststarted=?
WHERE trackid=? RETURNING laststarted""",
datetime.datetime.utcnow(), track.trackid)
track.active = True
track.laststarted = cur.fetchone()["laststarted"]
self.current_track = track
self.sql.commit()
def stop_track(self, track: Track, played: bool) -> None:
"""Mark that a Track has been stopped."""
args = [("active=?", False)]
if played:
if track.restarted is not None:
track.laststarted = track.restarted
args.append(("laststarted=?", track.restarted))
args.append(("lastplayed=?", track.laststarted))
args.append(("playcount=?", track.playcount + 1))
(fields, vals) = tuple(zip(*args))
update = ", ".join(fields)
row = self.sql(f"""UPDATE tracks SET {update} WHERE trackid=?
RETURNING lastplayed, playcount""",
*vals, track.trackid).fetchone()
track.active = False
track.playcount = row["playcount"]
track.lastplayed = row["lastplayed"]
track.restarted = None
self.current_track = None
if played:
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."""
n_trackids = GObject.Property(type=int)
def __init__(self, trackids: Iterable[int] = []):
"""Initialize a TrackidSet."""
super().__init__()
self.__trackids = set(trackids)
self.n_trackids = len(self.__trackids)
def __contains__(self, track: Track) -> bool:
"""Check if a Track is in the set."""
return track.trackid in self.__trackids
def __len__(self) -> int:
"""Find the number of Tracks in the set."""
return len(self.__trackids)
def __sub__(self, rhs):
"""Subtract two TrackidSets."""
return TrackidSet(self.__trackids - rhs.trackids)
def add_track(self, track: Track) -> None:
"""Add a Track to the set."""
if track.trackid not in self.__trackids:
self.__trackids.add(track.trackid)
self.n_trackids = len(self)
self.emit("trackid-added", track.trackid)
def random_trackid(self) -> int | None:
"""Get a random trackid from the set."""
if len(self.__trackids) > 0:
return random.choice(list(self.__trackids))
def remove_track(self, track: Track) -> None:
"""Remove a Track from the set."""
if track.trackid in self.__trackids:
self.__trackids.discard(track.trackid)
self.n_trackids = len(self)
self.emit("trackid-removed", track.trackid)
@property
def trackids(self) -> set:
"""Get the set of trackids."""
return self.__trackids
@trackids.setter
def trackids(self, trackids: Iterable[int]) -> None:
"""Add several trackids to the set at one time."""
new_trackids = set(trackids)
if self.__trackids.isdisjoint(new_trackids):
self.__trackids = new_trackids
self.n_trackids = len(self)
self.emit("trackids-reset")
else:
removed = self.__trackids - new_trackids
added = new_trackids - self.__trackids
self.__trackids = new_trackids
self.n_trackids = len(self)
for id in removed:
self.emit("trackid-removed", id)
for id in added:
self.emit("trackid-added", id)
@GObject.Signal(arg_types=(int,))
def trackid_added(self, trackid: int) -> None:
"""Signal that a Track has been added to the set."""
@GObject.Signal(arg_types=(int,))
def trackid_removed(self, trackid: int) -> None:
"""Signal that a Track has been removed from the set."""
@GObject.Signal
def trackids_reset(self) -> None:
"""Signal that the Tracks in the set have been reset."""

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;

82
emmental/db/years.py Normal file
View File

@ -0,0 +1,82 @@
# Copyright 2022 (c) Anna Schumaker.
"""A custom Gio.ListModel for managing individual years."""
import sqlite3
from gi.repository import GObject
from . import playlist
from . import tracks
class Year(playlist.Playlist):
"""Our custom Year object."""
year = GObject.Property(type=int)
@property
def primary_key(self) -> int:
"""Get this year's primary key."""
return self.year
@GObject.Property(type=playlist.Playlist)
def parent(self) -> playlist.Playlist | None:
"""Get this Year's parent playlist."""
return self.table.sql.decades.lookup(self.year)
class Table(playlist.Table):
"""Our Year Table."""
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
"""Initialize the Years table."""
super().__init__(sql=sql, autodelete=True,
system_tracks=False, **kwargs)
def do_add_track(self, year: Year, track: tracks.Track) -> bool:
"""Verify adding a Track to the Year playlist."""
return track.year == year.year
def do_construct(self, **kwargs) -> Year:
"""Construct a new Year playlist."""
return Year(**kwargs)
def do_get_sort_key(self, year: Year) -> tuple:
"""Get the sort key for a specific year."""
return year.year
def do_remove_track(self, year: Year, track: tracks.Track) -> bool:
"""Verify removing a Track from the Year playlist."""
return True
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:
"""Search for years matching the search text."""
return self.sql("SELECT year FROM years_view WHERE name GLOB ?", glob)
def do_sql_insert(self, year: int) -> sqlite3.Cursor | None:
"""Create a new Year playlist."""
if self.sql("INSERT INTO years (year) VALUES (?)", year):
return self.sql("SELECT * FROM years_view WHERE year=?", year)
def do_sql_select_all(self) -> sqlite3.Cursor:
"""Load Years from the database."""
return self.sql("SELECT * FROM years_view")
def do_sql_select_one(self, year: int) -> sqlite3.Cursor:
"""Look up a year."""
return self.sql("SELECT year FROM years WHERE year=?", year)
def do_sql_select_trackids(self, year: Year) -> sqlite3.Cursor:
"""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

99
emmental/emmental.css Normal file
View File

@ -0,0 +1,99 @@
/* Copyright 2022 (c) Anna Schumaker. */
/* Make the Gtk.Paned separator transparent with extra padding */
paned.emmental-pane>separator {
opacity: 0;
padding: 8px;
}
*.emmental-padding {
padding: 8px;
}
box.emmental-splitbutton>button {
border-radius: 0%;
margin-left: -1px;
}
box.emmental-splitbutton>menubutton>button {
border-radius: 0%;
margin-left: -1px;
padding: 6px;
}
row.emmental-active-row {
font-weight: bold;
background-color: alpha(@accent_color, 0.15);
}
row.emmental-active-row:hover {
background-color: alpha(@accent_color, 0.22);
}
row.emmental-active-row:active {
background-color: alpha(@accent_color, 0.31);
}
row.emmental-active-row:selected {
background-color: alpha(@accent_color, 0.25);
}
row.emmental-active-row:selected:hover {
background-color: alpha(@accent_color, 0.28);
}
row.emmental-active-row:selected:active {
background-color: alpha(@accent_color, 0.34);
}
image.emmental-sidebar-arrow {
transition: 250ms;
transform: rotate(0deg);
}
image.emmental-sidebar-arrow:checked {
transition: 250ms;
transform: rotate(-180deg);
color: @accent_color;
}
box.emmental-sidebar-section>button {
border-radius: 0%;
margin-top: -1px;
}
button.emmental-delete>image {
color: @destructive_color;
}
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;
}
columnview.emmental-track-list > listview > row > cell > label {
padding: 0px 4px;
}
columnview.emmental-track-list > listview > row > cell > picture {
padding: 4px 0px;
min-height: 36px;
min-width: 36px;
border-radius: 15%;
}
box.emmental-move-buttons > button > image {
color: @accent_color;
}

68
emmental/entry.py Normal file
View File

@ -0,0 +1,68 @@
# Copyright 2022 (c) Anna Schumaker.
"""Customized Gtk.Entries for easier development."""
from gi.repository import Gtk
from gi.repository import GObject
from . import format
class Filter(Gtk.SearchEntry):
"""A Gtk.Entry that returns a filter query."""
def __init__(self, what: str, **kwargs):
"""Set up the FilterEntry."""
super().__init__(placeholder_text=f"type to filter {what}", **kwargs)
def get_placeholder_text(self) -> str:
"""Get the entry's placeholder-text."""
return self.get_property("placeholder-text")
def get_query(self) -> str | None:
"""Get the query string for the entered text."""
return format.search(self.get_text())
class ValueBase(Gtk.Entry):
"""Base class for value entries."""
def __init__(self, input_purpose: Gtk.InputPurpose, value, **kwargs):
"""Initialize a ValueBase Entry."""
super().__init__(input_purpose=input_purpose,
value=value, text=str(value), **kwargs)
self.connect("notify::value", self.__notify_value)
def __notify_value(self, entry: Gtk.Entry, param) -> None:
self.set_text(str(self.value))
def do_activate(self) -> None:
"""Handle the activate signal."""
self.value = type(self.value)(self.get_text())
class Integer(ValueBase):
"""Entry for Integers."""
value = GObject.Property(type=int)
def __init__(self, value: int = 0, **kwargs):
"""Initialize an Integer Entry."""
super().__init__(Gtk.InputPurpose.DIGITS, value, **kwargs)
class Float(ValueBase):
"""Entry for Floats."""
value = GObject.Property(type=float)
def __init__(self, value: float = 0.0, **kwargs):
"""Initialize a Float Entry."""
super().__init__(Gtk.InputPurpose.NUMBER, value, **kwargs)
class String(ValueBase):
"""Entry for Strings."""
value = GObject.Property(type=str)
def __init__(self, value: str = "", **kwargs):
"""Initialize a String Entry."""
super().__init__(Gtk.InputPurpose.FREE_FORM, value, **kwargs)

201
emmental/factory.py Normal file
View File

@ -0,0 +1,201 @@
# Copyright 2022 (c) Anna Schumaker.
"""A customized Gtk.SignalListItemFactory for easier use."""
import typing
from gi.repository import GObject
from gi.repository import Gio
from gi.repository import Gtk
class ListRow(GObject.GObject):
"""Extra state that we attach to the Gtk.ListItem."""
listitem = GObject.Property(type=Gtk.ListItem)
def __init__(self, listitem: Gtk.ListItem, **kwargs):
"""Initialize a ListRow object."""
GObject.GObject.__init__(self, listitem=listitem, **kwargs)
self.bindings = []
def do_bind(self) -> None:
"""Bind the list item to the child widget."""
def do_unbind(self) -> None:
"""Unbind the list item from the child widget."""
def bind_active(self, item_prop: str) -> None:
"""Bind a property to the Row's active property."""
self.bind_and_set(self.item, item_prop, self, "active")
def bind_and_set(self, src: GObject.GObject, src_prop: str,
dst: GObject.GObject, dst_prop: str,
bidirectional: bool = False,
invert_boolean: bool = False) -> None:
"""Bind and set a property from the src object to the dst object."""
f_bidir = GObject.BindingFlags.BIDIRECTIONAL if bidirectional else 0
f_invrt = GObject.BindingFlags.INVERT_BOOLEAN if invert_boolean else 0
src_value = src.get_property(src_prop)
value = not src_value if invert_boolean else src_value
dst.set_property(dst_prop, value)
self.bindings.append(src.bind_property(src_prop, dst, dst_prop,
f_bidir | f_invrt))
def bind_and_set_property(self, item_prop: str, child_prop: str,
bidirectional: bool = False,
invert_boolean: bool = False) -> None:
"""Bind and set a list item property."""
self.bind_and_set(self.item, item_prop, self.child, child_prop,
bidirectional, invert_boolean)
def bind(self) -> None:
"""Bind the list item to the child widget."""
self.do_bind()
def unbind(self) -> None:
"""Unbind the list item from the child widget."""
for binding in self.bindings:
binding.unbind()
self.bindings.clear()
self.do_unbind()
@GObject.Property(type=bool, default=False)
def active(self) -> bool:
"""Get the active state of this Row."""
if self.listrow is not None:
return self.listrow.has_css_class("emmental-active-row")
return False
@active.setter
def active(self, newval: bool) -> None:
if self.listrow is not None:
if newval:
self.listrow.add_css_class("emmental-active-row")
else:
self.listrow.remove_css_class("emmental-active-row")
@GObject.Property(type=Gtk.Widget)
def child(self) -> Gtk.Widget | None:
"""Get the child widget displayed by this Row."""
return self.listitem.get_child()
@child.setter
def child(self, newval: Gtk.Widget) -> None:
self.listitem.set_child(newval)
@GObject.Property(type=GObject.TYPE_PYOBJECT)
def item(self) -> GObject.TYPE_PYOBJECT:
"""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."""
item_property = GObject.Property(type=str)
def __init__(self, listitem: Gtk.ListItem, item_property: str,
xalign: float = 0.0, numeric: bool = False) -> None:
"""Create a new Gtk.Label."""
super().__init__(listitem, item_property=item_property)
self.child = Gtk.Inscription(xalign=xalign)
if numeric:
self.child.add_css_class("numeric")
def do_bind(self) -> None:
"""Bind a ListItem to the Label."""
self.bind_and_set_property(self.item_property, "text")
class TreeRow(ListRow):
"""A ListRow for displaying child widgets in a Tree."""
n_children = GObject.Property(type=int)
have_children = GObject.Property(type=bool, default=False)
indented = GObject.Property(type=bool, default=True)
def __init__(self, listitem: Gtk.ListItem, **kwargs) -> None:
"""Create a new TreeRow."""
super().__init__(listitem, **kwargs)
listitem.set_child(Gtk.TreeExpander(hide_expander=True,
indent_for_icon=self.indented))
self.bind_property("n-children", self, "have-children")
self.bind_property("have-children", listitem.get_child(),
"hide-expander",
GObject.BindingFlags.INVERT_BOOLEAN)
self.bind_property("indented", listitem.get_child(), "indent-for-icon")
def bind(self) -> None:
"""Bind a TreeRow to the TreeExpander."""
self.listitem.get_child().set_list_row(self.listitem.get_item())
super().bind()
def bind_n_children(self, children: Gio.ListModel | None) -> None:
"""Bind to the n-items property of the child listmodel."""
if children is not None:
self.bind_and_set(children, "n-items", self, "n-children")
def unbind(self) -> None:
"""Unbind a TreeRow from the TreeExpander."""
self.listitem.get_child().set_list_row(None)
super().unbind()
@GObject.Property(type=Gtk.Widget)
def child(self) -> Gtk.Widget | None:
"""Get the child widget displayed by this Row."""
return self.listitem.get_child().get_child()
@child.setter
def child(self, newval=Gtk.Widget) -> None:
self.listitem.get_child().set_child(newval)
@GObject.Property(type=GObject.TYPE_PYOBJECT)
def item(self) -> GObject.TYPE_PYOBJECT:
"""Get the list item for this Row."""
return self.listitem.get_item().get_item()
class Factory(Gtk.SignalListItemFactory):
"""A customized Factory for making list row widgets."""
def __init__(self, row_type: typing.Type[ListRow], **kwargs):
"""Initialize a ListFactory."""
super().__init__()
self.row_type = row_type
self.connect("setup", self.__setup, kwargs)
self.connect("bind", self.__bind)
self.connect("unbind", self.__unbind)
self.connect("teardown", self.__teardown)
def __setup(self, factory: Gtk.SignalListItemFactory,
listitem: Gtk.ListItem, kwargs: dict) -> None:
listitem.listrow = self.row_type(listitem, **kwargs)
def __bind(self, factory: Gtk.SignalListItemFactory,
listitem: Gtk.ListItem) -> None:
listitem.listrow.bind()
def __unbind(self, factory: Gtk.SignalListItemFactory,
listitem: Gtk.ListItem) -> None:
listitem.listrow.unbind()
def __teardown(self, factory: Gtk.SignalListItemFactory,
listitem: Gtk.ListItem) -> None:
listitem.set_child(None)
listitem.listrow = None
class InscriptionFactory(Factory):
"""A Factory that creates InscriptionRows."""
def __init__(self, item_property: str,
script_type: typing.Type[InscriptionRow] = InscriptionRow,
xalign: float = 0.0, numeric: bool = False):
"""Initialize a LabelFactory."""
super().__init__(row_type=script_type, item_property=item_property,
xalign=xalign, numeric=numeric)

35
emmental/format.py Normal file
View File

@ -0,0 +1,35 @@
# Copyright 2022 (c) Anna Schumaker
"""Helper functions for formatting strings."""
import re
IGNORE_WORDS = set(["a", "an", "the", ""])
def search(input: str) -> str | None:
"""Translate the input string into a sqlite3 GLOB statement."""
input = input.strip().casefold()
if len(input) == 0:
return None
if input[0] == "^":
input = input[1:] if len(input) > 1 else "*"
elif input[0] != "*":
input = "*" + input
if input[-1] == "$":
input = input[:-1]
elif input[-1] != "*":
input += "*"
return input
def sort_key(input: str) -> tuple:
"""Translate the input string into a sort key."""
if len(input) == 0:
return ()
input = re.sub(r"[\"\'’“”]", "", input.casefold())
res = re.split(r"[ /_-]", input)
if len(res) > 1 and res[0] in IGNORE_WORDS:
res = res[1:]
return tuple(res)

80
emmental/gsetup.py Normal file
View File

@ -0,0 +1,80 @@
# Copyright 2022 (c) Anna Schumaker.
"""Set up GObject Introspection, and custom styling, and icons."""
import pathlib
import sys
import sqlite3
import gi
import xdg.BaseDirectory
gi.require_version("Pango", "1.0")
gi.require_version("Gdk", "4.0")
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
gi.require_version("Gst", "1.0")
gi.importlib.import_module("gi.repository.Gio")
gi.importlib.import_module("gi.repository.Gtk")
gi.importlib.import_module("gi.repository.Gst").init(sys.argv)
DEBUG_STR = "-debug" if __debug__ else ""
APPLICATION_ID = f"com.nowheycreamery.emmental{DEBUG_STR}"
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"
RESOURCE_ICONS = f"{RESOURCE_PATH}/icons/scalable/apps"
RESOURCE_FILE = pathlib.Path(__file__).parent.parent / "emmental.gresource"
RESOURCE = gi.repository.Gio.Resource.load(str(RESOURCE_FILE))
gi.repository.Gio.resources_register(RESOURCE)
def add_style():
"""Add our stylesheet to the default display."""
style = gi.repository.Gtk.StyleContext
style.add_provider_for_display(gi.repository.Gdk.Display.get_default(),
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}"
def env_string() -> str:
"""Return a string with the version numbers of our dependencies."""
gst = gi.repository.Gst.version()
strs = [__version_string("Python", sys.version_info.major,
sys.version_info.minor, sys.version_info.micro),
__version_string("Gtk", gi.repository.Gtk.MAJOR_VERSION,
gi.repository.Gtk.MINOR_VERSION,
gi.repository.Gtk.MICRO_VERSION),
__version_string("Libadwaita", gi.repository.Adw.MAJOR_VERSION,
gi.repository.Adw.MINOR_VERSION,
gi.repository.Adw.MICRO_VERSION),
__version_string("GStreamer", gst.major, gst.minor, gst.micro),
__version_string("Pango", gi.repository.Pango.VERSION_MAJOR,
gi.repository.Pango.VERSION_MINOR,
gi.repository.Pango.VERSION_MICRO),
__version_string("SQLite", sqlite3.sqlite_version_info[0],
sqlite3.sqlite_version_info[1],
sqlite3.sqlite_version_info[2])]
return "\n".join(strs)
def print_env() -> None:
"""Print the environment versions to stdout."""
print(env_string())

185
emmental/header/__init__.py Normal file
View File

@ -0,0 +1,185 @@
# Copyright 2022 (c) Anna Schumaker.
"""A custom Gtk.HeaderBar configured for our application."""
import pathlib
import typing
from gi.repository import GObject
from gi.repository import Gtk
from gi.repository import Adw
from ..action import ActionEntry
from .. import db
from .. import buttons
from .. import gsetup
from . import listenbrainz
from . import open
from . import replaygain
from . import volume
if __debug__:
from . import settings
SUBTITLE = "The Cheesy Music Player"
def _volume_icon(vol: float) -> str:
if vol == 0.0:
return "audio-volume-muted-symbolic"
if vol <= 1/3:
return "audio-volume-low-symbolic"
if vol <= 2/3:
return "audio-volume-medium-symbolic"
return "audio-volume-high-symbolic"
class Header(Gtk.HeaderBar):
"""Our custom Gtk.HeaderBar containing window title and volume controls."""
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)
rg_mode = GObject.Property(type=str, default="auto")
volume = GObject.Property(type=float, default=1.0)
def __init__(self, sql: db.Connection, title: str):
"""Initialize the HeaderBar."""
super().__init__(title=title, subtitle=SUBTITLE, sql=sql)
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()
self._background_icon = Gtk.Image(icon_name="sound-wave")
self._replaygain = replaygain.ReplayGainRow()
self._icons = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 6)
self._icons.append(self._volume_icon)
self._icons.append(self._background_icon)
self._vol_box = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
self._vol_box.add_css_class("boxed-list")
self._vol_box.append(self._volume)
self._vol_box.append(self._background)
self._vol_box.append(self._replaygain)
self._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",
GObject.BindingFlags.BIDIRECTIONAL)
self.bind_property("rg-enabled", self._replaygain, "enabled",
GObject.BindingFlags.BIDIRECTIONAL)
self.bind_property("rg-mode", self._replaygain, "mode",
GObject.BindingFlags.BIDIRECTIONAL)
self.bind_property("volume", self._volume, "volume",
GObject.BindingFlags.BIDIRECTIONAL)
self.pack_start(self._show_sidebar)
self.pack_start(self._menu_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:
if __debug__:
self._window.present()
def __notify(self, header: typing.Self, param: GObject.ParamSpec) -> None:
match param.name:
case "bg-enabled":
icon = "sound-wave-alt" if self.bg_enabled else "sound-wave"
self._background_icon.set_from_icon_name(icon)
case "volume":
self._volume_icon.set_from_icon_name(_volume_icon(self.volume))
bg_status = "off"
if self.bg_enabled:
bg_status = f"{round(self.bg_volume * 100)}%"
rg_status = f"{self.rg_mode} mode" if self.rg_enabled else "off"
status = (f"volume: {round(self.volume * 100)}%\n"
f"background listening: {bg_status}\n"
f"normalizing: {rg_status}")
self._vol_button.set_tooltip_text(status)
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,
"<Shift><Control>Down"),
ActionEntry("increase-volume", self._volume.increment,
"<Shift><Control>Up"),
ActionEntry("toggle-bg-mode", self._background.activate,
"<Shift><Control>b"),
ActionEntry("toggle-sidebar", self._show_sidebar.activate,
"<Control>bracketright")]
if __debug__:
res.append(ActionEntry("edit-settings", self._settings.activate,
"<Shift><Control>s"))
return res
@GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT,))
def track_requested(self, path: pathlib.Path) -> None:
"""Signal that a track has been requested."""

View File

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

46
emmental/header/open.py Normal file
View File

@ -0,0 +1,46 @@
# Copyright 2023 (c) Anna Schumaker.
"""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 OpenRow(Adw.ActionRow):
"""Our pre-configured open Adw.ActionRow."""
def __init__(self):
"""Initialize our open ActionRow."""
super().__init__(activatable=True, title="Open File",
subtitle="Select a file for playback")
self._prefix = Gtk.Image(icon_name="document-open-symbolic")
self._filters = Gio.ListStore()
self._filter = Gtk.FileFilter(name="Audio Files",
mime_types=["inode/directory",
"audio/*"])
self._dialog = Gtk.FileDialog(filters=self._filters,
title="Pick a Track")
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)
self.emit("track-requested", pathlib.Path(file.get_path()))
except GLib.Error:
pass
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)
@GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT,))
def track_requested(self, file: pathlib.Path) -> None:
"""Signal that a track has been requested."""

View File

@ -0,0 +1,78 @@
# Copyright 2022 (c) Anna Schumaker.
"""A widget for selecting ReplayGain mode."""
from gi.repository import GObject
from gi.repository import Gtk
from gi.repository import Adw
class CheckRow(Adw.ActionRow):
"""A custom Adw.ActionRow displaying a Check Button."""
active = GObject.Property(type=bool, default=False)
group = GObject.Property(type=Adw.ActionRow)
mode = GObject.Property(type=str)
def __init__(self, mode: str, active: bool = False,
group: Adw.ActionRow | None = None, **kwargs):
"""Initialize the Check Row."""
super().__init__(mode=mode, active=active, group=group, **kwargs)
self._prefix = Gtk.CheckButton(active=active,
group=group._prefix if group else None)
self.bind_property("active", self._prefix, "active",
GObject.BindingFlags.BIDIRECTIONAL)
self.set_activatable_widget(self._prefix)
self.add_prefix(self._prefix)
def set_active(self, newval: bool) -> None:
"""Set the active property."""
if self.active != newval:
self.active = newval
class ReplayGainRow(Adw.ExpanderRow):
"""Build up a widget for configuring ReplayGain settings."""
enabled = GObject.Property(type=bool, default=False)
mode = GObject.Property(type=str, default="auto")
def __init__(self):
"""Initialize the ReplayGain selector."""
super().__init__(title="Volume Normalization",
subtitle="Configure ReplayGain normalizing")
self._switch = Gtk.Switch(valign=Gtk.Align.CENTER)
self._automatic = CheckRow(title="Automatic Mode",
subtitle="Emmental decides automatically",
mode="auto", active=True)
self._album = CheckRow(title="Album Mode",
subtitle="Albums have the same volume",
mode="album", group=self._automatic)
self._track = CheckRow(title="Track Mode",
subtitle="Tracks have the same volume",
mode="track", group=self._automatic)
self.add_prefix(self._switch)
self.add_row(self._automatic)
self.add_row(self._album)
self.add_row(self._track)
self.connect("notify::mode", self.__notify_mode)
self._automatic.connect("notify::active", self.__row_activated)
self._album.connect("notify::active", self.__row_activated)
self._track.connect("notify::active", self.__row_activated)
self._switch.bind_property("active", self, "expanded",
GObject.BindingFlags.BIDIRECTIONAL)
self.bind_property("enabled", self._switch, "active",
GObject.BindingFlags.BIDIRECTIONAL)
def __notify_mode(self, row: Adw.ExpanderRow, param) -> None:
match self.mode:
case "album": self._album.set_active(True)
case "track": self._track.set_active(True)
case _: self._automatic.set_active(True)
def __row_activated(self, row: CheckRow, param: GObject.ParamSpec) -> None:
if row.active:
self.mode = row.mode

View File

@ -0,0 +1,84 @@
# Copyright 2022 (c) Anna Schumaker.
"""A custom Gtk.Dialog for showing Settings."""
from gi.repository import Gtk
from gi.repository import Adw
from .. import db
from .. import entry
from .. import factory
class ValueRow(factory.ListRow):
"""A Row for displaying settings values."""
def do_bind(self) -> None:
"""Bind a db.Setting to this Row."""
if isinstance(self.item.value, bool):
self.child = Gtk.Switch(halign=Gtk.Align.START)
self.bind_and_set_property("value", "active", bidirectional=True)
elif isinstance(self.item.value, str):
self.child = entry.String(has_frame=False)
self.bind_and_set_property("value", "value", bidirectional=True)
elif isinstance(self.item.value, int):
self.child = entry.Integer(has_frame=False)
self.bind_and_set_property("value", "value", bidirectional=True)
elif isinstance(self.item.value, float):
self.child = entry.Float(has_frame=False)
self.bind_and_set_property("value", "value", bidirectional=True)
class Window(Adw.Window):
"""A custom window that displays the current settings."""
def __init__(self, sql: db.Connection):
"""Initialize the Settings window."""
super().__init__(default_width=500, default_height=500,
title="Emmental Settings", icon_name="settings",
hide_on_close=True,
content=Gtk.Box.new(Gtk.Orientation.VERTICAL, 0))
self._search = entry.Filter(what="settings")
self._header = Gtk.HeaderBar(title_widget=self._search)
self._selection = Gtk.NoSelection(model=sql.settings)
self._view = Gtk.ColumnView(model=self._selection,
show_row_separators=True)
self._scroll = Gtk.ScrolledWindow(child=self._view, vexpand=True)
self._scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
self.__append_column(factory.InscriptionFactory("key"),
"Key", width=400)
self.__append_column(factory.Factory(row_type=ValueRow),
"Value", width=100)
self.get_content().append(self._header)
self.get_content().append(self._scroll)
if __debug__:
self.add_css_class("devel")
self._search.connect("search-changed", self.__filter)
def __append_column(self, factory: factory.Factory,
title: str, *, width: int) -> None:
self._view.append_column(Gtk.ColumnViewColumn(factory=factory,
title=title,
fixed_width=width))
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()

84
emmental/header/volume.py Normal file
View File

@ -0,0 +1,84 @@
# Copyright 2022 (c) Anna Schumaker.
"""A custom Gtk.Box with controls for adjusting the volume."""
from gi.repository import GObject
from gi.repository import Gtk
from gi.repository import Adw
STEP_SIZE = 0.05
def format_value_func(scale, value: float) -> str:
"""Format the volume value to a percentage."""
return f"{round(value*100)} %"
class VolumeRow(Gtk.ListBoxRow):
"""A Gtk.Box containing widgets for adjusting the volume."""
volume = GObject.Property(type=float, default=1.0)
def __init__(self, volume: float = 1.0):
"""Initialize our volume controls."""
super().__init__(volume=volume)
self._box = Gtk.Box()
self._decrement = Gtk.Button(icon_name="list-remove-symbolic",
tooltip_text="reduce the volume",
valign=Gtk.Align.END, has_frame=False,
margin_bottom=5)
self._adjustment = Gtk.Adjustment.new(volume, 0.0, 1.0,
STEP_SIZE, 0, 0)
self._scale = Gtk.Scale(adjustment=self._adjustment, draw_value=True,
valign=Gtk.Align.END, hexpand=True)
self._increment = Gtk.Button(icon_name="list-add-symbolic",
tooltip_text="increase the volume",
valign=Gtk.Align.END, has_frame=False,
margin_bottom=5)
self._scale.set_format_value_func(format_value_func)
self._box.append(self._decrement)
self._box.append(self._scale)
self._box.append(self._increment)
self.set_child(self._box)
self._decrement.connect("clicked", self.decrement)
self._scale.connect("value-changed", self.__value_changed)
self._increment.connect("clicked", self.increment)
self.bind_property("volume", self._adjustment, "value",
GObject.BindingFlags.BIDIRECTIONAL)
def decrement(self, button: Gtk.Button | None = None) -> None:
"""Decrease the volume by STEP_SIZE."""
self._scale.set_value(self._scale.get_value() - STEP_SIZE)
def increment(self, button: Gtk.Button | None = None) -> None:
"""Increase the volume by STEP_SIZE."""
self._scale.set_value(self._scale.get_value() + STEP_SIZE)
def __value_changed(self, range: Gtk.Range) -> None:
self.volume = range.get_value()
class BackgroundRow(Adw.ExpanderRow):
"""A VolumeRow for setting Background Listening volume."""
enabled = GObject.Property(type=bool, default=False)
volume = GObject.Property(type=float, default=0.5)
def __init__(self):
"""Initialize the BackgroundRow."""
super().__init__(title="Background Listening",
subtitle="Decrease the volume to help focus")
self._switch = Gtk.Switch(valign=Gtk.Align.CENTER)
self._volume = VolumeRow(volume=self.volume)
self.add_prefix(self._switch)
self.add_row(self._volume)
self._switch.bind_property("active", self, "expanded",
GObject.BindingFlags.BIDIRECTIONAL)
self.bind_property("enabled", self._switch, "active",
GObject.BindingFlags.BIDIRECTIONAL)
self.bind_property("volume", self._volume, "volume",
GObject.BindingFlags.BIDIRECTIONAL)

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

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