Compare commits

...

181 Commits

Author SHA1 Message Date
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
288 changed files with 23374 additions and 9866 deletions

3
.gitignore vendored
View File

@ -3,4 +3,7 @@
*.coverage
*.ui~
*.txt
*.patch
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://github.com/freedesktop/mpris-spec.git

View File

@ -5,36 +5,84 @@ 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 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 ${PREFIX}/share/applications/com.nowheycreamery.emmental.desktop
rm -f ${PREFIX}/bin/emmental
rm -rf ${PREFIX}/lib/emmental/
.PHONY: pkgbuild
pkgbuild:
$(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 TAG := $(shell git describe --tags --abbrev=0))
$(eval CSUM := $(shell git archive --format tar.gz $(TAG) | sha256sum | awk '{print $$1}'))
cp data/PKGBUILD aur/
sed -i 's|{MAJOR}.{MINOR}|${EMMENTAL_MAJOR}.${EMMENTAL_MINOR}|' aur/PKGBUILD
sed -i 's|{SHA256SUM}|${EMMENTAL_CSUM}|' aur/PKGBUILD
sed -i 's|{MAJOR}.{MINOR}|${MAJOR}.${MINOR}|' aur/PKGBUILD
sed -i 's|{SHA256SUM}|$(CSUM)|' aur/PKGBUILD
cd aur && makepkg --printsrcinfo > .SRCINFO
.PHONY: pytest
pytest: emmental.gresource mpris2
pytest
.PHONY: tests
tests:
python tools/generate_tracks.py
python -m unittest discover -v
tests: pytest flake8

View File

@ -1,3 +1,65 @@
# 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
* 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 track detection (using MusicBrainzIDs)
* Updated tag detection
## Dependencies
* Python3
* dateutil
* gobject
* 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,25 +0,0 @@
# Copyright 2021 (c) Anna Schumaker.
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):
match int(value):
case -1: return "Keep Playing"
case 0: return "This Track"
case 1: return "Next Track"
case _: return f"{int(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,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,3 +0,0 @@
#!/bin/bash
python -O {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,273 +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):
if self.current > -1:
if (index := self.get_track_index(track)) != None:
return index <= self.current
return False
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,170 +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):
match row["name"]:
case "Collection":
return Collection(row)
case "Favorites":
return UserPlaylist(row, "emmental-favorites", "playlist_map")
case "New Tracks":
return UserPlaylist(row, "starred", "temp_playlist_map")
case "Previous":
return Previous(row)
case "Queued Tracks":
return QueuedTracks(row)
case _:
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()

11
emmental.desktop Normal file
View File

@ -0,0 +1,11 @@
[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

View File

@ -1,32 +1,8 @@
#!/usr/bin/python
# Copyright 2021 (c) Anna Schumaker.
import lib
lib.settings.load()
import db
import scanner
import ui
from gi.repository import Gtk
class Application(Gtk.Application):
def __init__(self, *args, **kwargs):
app_id = f"org.gtk.emmental{'-debug' if __debug__ else ''}"
Gtk.Application.__init__(self, *args, application_id=app_id, **kwargs)
def do_startup(self):
Gtk.Application.do_startup(self)
self.add_window(ui.window.Window())
for i in range(db.library.Table.get_n_items()):
scanner.update_library(db.library.Table.get_item(i))
def do_activate(self):
for window in self.get_windows():
window.present()
def do_shutdown(self):
Gtk.Application.do_shutdown(self)
scanner.Queue.clear()
db.sql.optimize()
# Copyright 2022 (c) Anna Schumaker.
"""The main Emmental application."""
import sys
import emmental
if __name__ == "__main__":
Application().run()
emmental.Application().run(sys.argv)

301
emmental/__init__.py Normal file
View File

@ -0,0 +1,301 @@
# Copyright 2022 (c) Anna Schumaker.
"""Set up our Application."""
import musicbrainzngs
import pathlib
from . import gsetup
from . import audio
from . import db
from . import header
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 = 0
VERSION_STRING = f"Emmental {MAJOR_VERSION}.{MINOR_VERSION}{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)
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 __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)
hdr.bind_property("volume", self.player, "volume")
for (setting, property) in [("audio.volume", "volume"),
("audio.replaygain.enabled", "rg-enabled"),
("audio.replaygain.mode", "rg-mode")]:
self.db.settings.bind_setting(setting, hdr, property)
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")
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")
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")
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())
for (setting, property) in [("window.width", "default-width"),
("window.height", "default-height"),
("now-playing.size", "now-playing-size"),
("sidebar.size", "sidebar-size")]:
self.db.settings.bind_setting(setting, win, property)
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_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_versions()
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.factory = playlist.Factory(self.db)
self.player = audio.Player()
gsetup.add_style()
musicbrainzngs.set_useragent(f"emmental{gsetup.DEBUG_STR}",
f"{MAJOR_VERSION}.{MINOR_VERSION}")
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_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.mpris is not None:
self.mpris.disconnect()
self.mpris = None
if self.db is not None:
self.db.close()
self.db = None

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

@ -0,0 +1,234 @@
# 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 replaygain
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)
pause_on_load = GObject.Property(type=bool, default=False)
def __init__(self):
"""Initialize the audio Player."""
super().__init__()
self._replaygain = replaygain.Filter()
self._timeout = None
self._playbin = Gst.ElementFactory.make("playbin")
self._playbin.set_property("audio-filter", self._replaygain)
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.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._replaygain.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._replaygain.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

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)

114
emmental/buttons.py Normal file
View File

@ -0,0 +1,114 @@
# 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_size = GObject.Property(type=Gtk.IconSize,
default=Gtk.IconSize.NORMAL)
icon_opacity = GObject.Property(type=float, default=1.0,
minimum=0.0, maximum=1.0)
def __init__(self, **kwargs):
"""Initialize a Button."""
super().__init__(focusable=False, **kwargs)
self._image = Gtk.Image(icon_name=self.icon_name,
icon_size=self.icon_size,
opacity=self.icon_opacity)
self.bind_property("icon-name", self._image, "icon-name")
self.bind_property("icon-size", self._image, "icon-size")
self.bind_property("icon-opacity", self._image, "opacity")
self.set_child(self._image)
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)
icon_size = GObject.Property(type=Gtk.IconSize,
default=Gtk.IconSize.NORMAL)
def __init__(self, secondary: Gtk.Button, **kwargs):
"""Initialize a Split Button."""
super().__init__(**kwargs)
self._primary = Button(hexpand=True, icon_name=self.icon_name,
icon_size=self.icon_size)
self._separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL,
margin_top=12, margin_bottom=12)
self._secondary = secondary
self.bind_property("icon-name", self._primary, "icon-name")
self.bind_property("icon-size", self._primary, "icon-size")
self._primary.connect("clicked", self.__clicked)
self.append(self._primary)
self.append(self._separator)
self.append(secondary)
self.add_css_class("emmental-splitbutton")
def __clicked(self, button: Button) -> None:
self.emit("clicked")
@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 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)
inactive_icon_name = GObject.Property(type=str)
def __init__(self, active_icon_name: str, inactive_icon_name: str,
active: bool = False, **kwargs) -> None:
"""Initialize an ImageToggle button."""
super().__init__(active_icon_name=active_icon_name,
inactive_icon_name=inactive_icon_name,
icon_name=inactive_icon_name, active=active, **kwargs)
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:
icon = self.active_icon_name if newval else self.inactive_icon_name
self.icon_name = icon
self.emit("toggled")
@GObject.Signal
def toggled(self) -> None:
"""Active state has been toggled."""

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

@ -0,0 +1,96 @@
# 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_SCRIPT = pathlib.Path(__file__).parent / "emmental.sql"
class Connection(connection.Connection):
"""Connect to the database."""
active_playlist = GObject.Property(type=playlist.Playlist)
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_version(self) -> None:
user_version = self("PRAGMA user_version").fetchone()["user_version"]
match user_version:
case 0:
with open(SQL_SCRIPT) as f:
self._sql.executescript(f.read())
case 1: pass
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 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

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

@ -0,0 +1,144 @@
# Copyright 2022 (c) Anna Schumaker
"""A custom Gio.ListModel for working with albums."""
import pathlib
import sqlite3
from gi.repository import GObject
from gi.repository import Gtk
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,
Gtk.CustomFilter.new(self.__match_medium))
def __match_medium(self, medium: Medium) -> bool:
return medium.albumid == self.albumid and len(medium.name) > 0
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)
@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."""
rows = self.sql("SELECT mediumid FROM media WHERE albumid=?",
album.albumid)
return [self.sql.media.rows.get(row["mediumid"]) for row in rows]

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,
table.Filter(self.table.get_albumids(self)))
def add_album(self, album: Album) -> None:
"""Add an Album to this Artist."""
if self.table.add_album(self, album):
self.children.get_filter().add_row(album)
def has_album(self, album: Album) -> bool:
"""Check if the Artist has this Album."""
return self.children.get_filter().match(album)
def remove_album(self, album: Album) -> None:
"""Remove an album from this Artist."""
self.children.get_filter().remove_row(album)
self.table.remove_album(self, album)
@property
def primary_key(self) -> int:
"""Get the Artist primary key."""
return self.artistid
class Filter(table.Filter):
"""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.Filter, 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.children.get_filter().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

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

@ -0,0 +1,83 @@
# 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 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

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

@ -0,0 +1,97 @@
# Copyright 2022 (c) Anna Schumaker
"""A custom Gio.ListModel for working with decades."""
import sqlite3
from gi.repository import GObject
from gi.repository import Gtk
from .years import Year
from . import playlist
from . import tracks
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,
Gtk.CustomFilter.new(self.__match_year))
def __match_year(self, year: Year) -> bool:
return self.decade == year.year // 10 * 10
def get_years(self) -> list[Year]:
"""Get a list of years for this decade."""
return self.table.get_years(self)
@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_years(self, decade: Decade) -> list[Year]:
"""Get the list of years for this decade."""
rows = self.sql("SELECT year FROM years WHERE (year / 10 * 10)=?",
decade.decade)
return [self.sql.years.rows.get(row["year"]) for row in rows]

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)

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

@ -0,0 +1,88 @@
# 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 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) -> bool | None:
"""Add a task to the Idle Queue."""
if not self.enabled or now:
return func(*args)
self._tasks.append((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

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

@ -0,0 +1,191 @@
# 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():
(file, tags) = self.tagger.get_result(self.table.sql, self)
if file is None:
track = self.table.sql.tracks.lookup(self, path=path)
self.tagger.tag_file(path, track.mtime if track else None)
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."""

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

@ -0,0 +1,111 @@
# Copyright 2022 (c) Anna Schumaker.
"""A custom Gio.ListModel for managing individual media in an album."""
import sqlite3
from gi.repository import GObject
from .. import format
from . import playlist
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 Table(playlist.Table):
"""Our Media Table."""
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
"""Initialize the Media Table."""
super().__init__(sql=sql, 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."""
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 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

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

@ -0,0 +1,281 @@
# Copyright 2022 (c) Anna Schumaker
"""A customized Gio.ListStore for tracking Playlist GObjects."""
import sqlite3
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)
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_filter: Gtk.Filter) -> None:
"""Create a FilterListModel for this playlist's children."""
self.children = Gtk.FilterListModel.new(child_table, child_filter)
self.children.set_incremental(True)
def do_update(self, column: str) -> bool:
"""Update a Playlist object."""
match column:
case "propertyid" | "name" | "n-tracks" | "children" | \
"user-tracks" | "tracks-loaded" | "tracks-movable": pass
case _: return super().do_update(column)
return True
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_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_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 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 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

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

@ -0,0 +1,227 @@
# Copyright 2022 (c) Anna Schumaker
"""A custom Gio.ListModel for working with playlists."""
import sqlite3
from gi.repository import GObject
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)
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.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)

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

@ -0,0 +1,248 @@
# Copyright 2022 (c) Anna Schumaker
"""Base classes for database objects."""
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 Filter(Gtk.Filter):
"""A Filter that can be used to search playlists."""
n_keys = GObject.Property(type=int)
def __init__(self, keys: set | None = None, **kwargs):
"""Set up our Filter."""
super().__init__(**kwargs)
self._keys = keys
self.n_keys = len(keys) if keys is not None else -1
def __sub__(self, rhs: Gtk.Filter) -> set[int]:
"""Subtract two Filters and return the result."""
match (self._keys, rhs._keys):
case (None, _): return None
case (_, None): return self._keys
case (_, _): return self._keys - rhs._keys
def __find_change(self, keys: set[any] | None) -> Gtk.FilterChange | None:
if keys == self._keys:
return None
elif keys is None:
return Gtk.FilterChange.LESS_STRICT
elif self._keys is None:
return Gtk.FilterChange.MORE_STRICT
elif keys.issuperset(self._keys):
return Gtk.FilterChange.LESS_STRICT
elif keys.issubset(self._keys):
return Gtk.FilterChange.MORE_STRICT
return Gtk.FilterChange.DIFFERENT
def changed(self, how: Gtk.FilterChange) -> None:
"""Notify that the filter has changed."""
self.n_keys = len(self._keys) if self._keys is not None else -1
super().changed(how)
def do_get_strictness(self) -> Gtk.FilterMatch:
"""Get the strictness of the filter."""
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 matches the filter."""
return self._keys is None or row.primary_key in self._keys
def add_row(self, row: Row) -> None:
"""Add a Row to the Filter."""
if self._keys is not None:
self._keys.add(row.primary_key)
self.changed(Gtk.FilterChange.LESS_STRICT)
def remove_row(self, row: Row) -> None:
"""Remove a Row from the Filter."""
if self._keys is not None:
self._keys.discard(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."""
if (how := self.__find_change(keys)) is not None:
self._keys = keys
self.changed(how)
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: Filter | None = None,
queue: Queue | None = None, **kwargs):
"""Set up our Table object."""
super().__init__(sql=sql, rows=dict(), incremental=True,
store=store.SortedList(self.get_sort_key),
filter=(filter if filter else Filter()),
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.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.push(self._filter_idle, glob, now=now)
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

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

@ -0,0 +1,278 @@
# Copyright 2022 (c) Anna Schumaker
"""A wrapper around Mutagen to help us read tags."""
import emmental.audio.tagger
import musicbrainzngs
import pathlib
import threading
from gi.repository import GObject
from .. import audio
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(threading.Thread):
"""A thread for tagging files without blocking the UI."""
def __init__(self):
"""Initialize the Tagger Thread."""
super().__init__()
self.ready = threading.Event()
self._connection = None
self._condition = threading.Condition()
self._file = None
self._mtime = None
self._tags = None
self.start()
def __close_connection(self) -> None:
if self._connection:
self._connection.close()
self._connection = None
def __get_connection(self) -> connection.Connection:
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 get_result(self, db: GObject.TYPE_PYOBJECT,
library: playlist.Playlist) \
-> tuple[pathlib.Path | None, Tags | None]:
"""Return the resulting Tags structure."""
with self._condition:
if not self.ready.is_set():
return (None, None)
tags = Tags(db, self._tags, library) if self._tags else None
res = (self._file, tags)
self._file = None
self._tags = None
return res
def run(self) -> None:
"""Sleep until we have work to do."""
with self._condition:
self.ready.set()
while self._condition.wait():
if self._file is None:
break
tags = emmental.audio.tagger.tag_file(self._file, self._mtime)
if tags is not None:
for artist in tags.artists:
self.__check_artist(artist)
self._tags = tags
self.ready.set()
self.__close_connection()
def stop(self) -> None:
"""Stop the thread."""
with self._condition:
self._file = None
self._mtime = None
self._condition.notify()
self.join()
def tag_file(self, file: pathlib.Path, mtime: float | None) -> None:
"""Tag a file."""
with self._condition:
self.ready.clear()
self._file = file
self._mtime = mtime
self._tags = None
self._condition.notify()
def untag_track(db: GObject.TYPE_PYOBJECT, track: tracks.Track) -> None:
"""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)

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

@ -0,0 +1,349 @@
# 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.Filter):
"""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 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 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
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)
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."""

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

@ -0,0 +1,73 @@
# 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."""
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)

91
emmental/emmental.css Normal file
View File

@ -0,0 +1,91 @@
/* 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;
}
listview > row:checked {
font-weight: bold;
background-color: alpha(@accent_color, 0.2);
}
listview > row:checked:hover {
background-color: alpha(@accent_color, 0.27);
}
listview > row:checked:active {
background-color: alpha(@accent_color, 0.36);
}
listview > row:checked:selected {
background-color: alpha(@accent_color, 0.3);
}
listview > row:checked:selected:hover {
background-color: alpha(@accent_color, 0.33);
}
listview > row:checked:selected:active {
background-color: alpha(@accent_color, 0.39);
}
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 > 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)

196
emmental/factory.py Normal file
View File

@ -0,0 +1,196 @@
# 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 parent := self.listitem.get_child().get_parent():
return parent.get_state_flags() & Gtk.StateFlags.CHECKED
return False
@active.setter
def active(self, newval: bool) -> None:
if parent := self.listitem.get_child().get_parent():
if newval:
parent.set_state_flags(Gtk.StateFlags.CHECKED, False)
else:
parent.unset_state_flags(Gtk.StateFlags.CHECKED)
@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()
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)

65
emmental/gsetup.py Normal file
View File

@ -0,0 +1,65 @@
# 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' if __debug__ else ''}"
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))
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 __print_version(subsystem, major, minor, micro):
print(f"{subsystem} {major}.{minor}.{micro}")
def print_versions():
"""Print version information for libraries we use."""
__print_version("Python", sys.version_info.major, sys.version_info.minor,
sys.version_info.micro)
__print_version("Gtk", gi.repository.Gtk.MAJOR_VERSION,
gi.repository.Gtk.MINOR_VERSION,
gi.repository.Gtk.MICRO_VERSION)
__print_version("Libadwaita", gi.repository.Adw.MAJOR_VERSION,
gi.repository.Adw.MINOR_VERSION,
gi.repository.Adw.MICRO_VERSION)
__print_version("GStreamer", gi.repository.Gst.version().major,
gi.repository.Gst.version().minor,
gi.repository.Gst.version().micro)
__print_version("Pango", gi.repository.Pango.VERSION_MAJOR,
gi.repository.Pango.VERSION_MINOR,
gi.repository.Pango.VERSION_MICRO)
__print_version("SQLite", sqlite3.sqlite_version_info[0],
sqlite3.sqlite_version_info[1],
sqlite3.sqlite_version_info[2])

View File

@ -0,0 +1,90 @@
# Copyright 2022 (c) Anna Schumaker.
"""A custom Gtk.HeaderBar configured for our application."""
import pathlib
from gi.repository import GObject
from gi.repository import Gtk
from gi.repository import Adw
from .. import db
from .. import buttons
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)
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._open = open.Button()
self._title = Adw.WindowTitle(title=self.title, subtitle=self.subtitle)
self._volume = volume.Controls()
self._replaygain = replaygain.Selector()
self._box = Gtk.Box.new(Gtk.Orientation.VERTICAL, 0)
self._box.append(self._volume)
self._box.append(Gtk.Separator())
self._box.append(self._replaygain)
icon = _volume_icon(self.volume)
self._button = buttons.PopoverButton(popover_child=self._box,
icon_name=icon)
self.bind_property("title", self._title, "title")
self.bind_property("subtitle", self._title, "subtitle")
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._open)
if __debug__:
self._window = settings.Window(sql)
self._settings = Gtk.Button.new_from_icon_name("settings-symbolic")
self._settings.connect("clicked", self.__run_settings)
self.pack_start(self._settings)
self.pack_end(self._button)
self.set_title_widget(self._title)
self._open.connect("track-requested", self.__track_requested)
self.connect("notify::volume", self.__notify_volume)
def __run_settings(self, button: Gtk.Button) -> None:
if __debug__:
self._window.present()
def __notify_volume(self, header, param) -> None:
self._button.set_icon_name(_volume_icon(self.volume))
def __track_requested(self, button: open.Button,
path: pathlib.Path) -> None:
self.emit("track-requested", path)
@GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT,))
def track_requested(self, path: pathlib.Path) -> None:
"""Signal that a track has been requested."""

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

@ -0,0 +1,39 @@
# Copyright 2023 (c) Anna Schumaker.
"""A custom Button that opens a FileDialog 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
class Button(Gtk.Button):
"""Our pre-configured open button."""
def __init__(self):
"""Initialize our open button."""
super().__init__(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)
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 do_clicked(self) -> None:
"""Handle a click event."""
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,53 @@
# Copyright 2022 (c) Anna Schumaker.
"""A widget for selecting ReplayGain mode."""
from gi.repository import GObject
from gi.repository import Gtk
class Selector(Gtk.Grid):
"""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__(column_spacing=6, margin_top=8)
self._title = Gtk.Label(label="Volume Normalization", yalign=0.8,
hexpand=True, vexpand=True)
self._switch = Gtk.Switch()
self._auto = Gtk.CheckButton(label="Decide automatically",
sensitive=False, active=True)
self._album = Gtk.CheckButton(label="Albums have the same volume",
sensitive=False, group=self._auto)
self._track = Gtk.CheckButton(label="Tracks have the same volume",
sensitive=False, group=self._auto)
self.attach(self._title, 0, 0, 1, 1)
self.attach(self._switch, 1, 0, 1, 1)
self.attach(self._auto, 0, 1, 2, 1)
self.attach(self._album, 0, 2, 2, 1)
self.attach(self._track, 0, 3, 2, 1)
self.connect("notify::mode", self.__notify_mode)
self._auto.connect("toggled", self.__mode_toggled, "auto")
self._album.connect("toggled", self.__mode_toggled, "album")
self._track.connect("toggled", self.__mode_toggled, "track")
self._switch.bind_property("state", self._auto, "sensitive")
self._switch.bind_property("state", self._album, "sensitive")
self._switch.bind_property("state", self._track, "sensitive")
self.bind_property("enabled", self._switch, "state",
GObject.BindingFlags.BIDIRECTIONAL)
self._title.add_css_class("title-4")
def __notify_mode(self, selector: Gtk.Grid, param) -> None:
match selector.get_property("mode"):
case "album": self._album.set_active(True)
case "track": self._track.set_active(True)
case _: self._auto.set_active(True)
def __mode_toggled(self, check: Gtk.CheckButton, new_mode: str) -> None:
if check.get_active():
self.mode = new_mode

View File

@ -0,0 +1,66 @@
# 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())

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

@ -0,0 +1,52 @@
# 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
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 Controls(Gtk.Box):
"""A Gtk.Box containing widgets for adjusting the volume."""
volume = GObject.Property(type=float, default=1.0)
def __init__(self):
"""Initialize our volume controls."""
super().__init__()
self._decrement = Gtk.Button(icon_name="list-remove-symbolic",
valign=Gtk.Align.END, has_frame=False,
margin_bottom=6)
self._adjustment = Gtk.Adjustment.new(1.0, 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",
valign=Gtk.Align.END, has_frame=False,
margin_bottom=6)
self._scale.set_format_value_func(format_value_func)
self.append(self._decrement)
self.append(self._scale)
self.append(self._increment)
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:
self._scale.set_value(self._scale.get_value() - STEP_SIZE)
def __increment(self, button: Gtk.Button) -> None:
self._scale.set_value(self._scale.get_value() + STEP_SIZE)
def __value_changed(self, range: Gtk.Range) -> None:
self.volume = range.get_value()

View File

@ -0,0 +1,52 @@
# Copyright 2022 (c) Anna Schumaker.
"""Implement the MPRIS2 Specification."""
from gi.repository import GObject
from gi.repository import Gio
from . import application
from . import player
MPRIS2_ID = f"org.mpris.MediaPlayer2.emmental{'-debug' if __debug__ else ''}"
class Connection(GObject.GObject):
"""Our Mpris2 Object."""
dbus = GObject.Property(type=Gio.DBusConnection)
def __init__(self):
"""Initialize Mpris2."""
super().__init__()
self.app = application.Application()
self.player = player.Player()
self.bind_property("dbus", self.app, "dbus")
self.bind_property("dbus", self.player, "dbus")
self._busid = Gio.bus_own_name(Gio.BusType.SESSION, MPRIS2_ID,
Gio.BusNameOwnerFlags.NONE,
self.__on_bus_acquired, None,
self.__on_name_lost)
def __del__(self):
"""Clean up."""
self.disconnect()
def __on_bus_acquired(self, dbus: Gio.DBusConnection, name: str) -> None:
self.dbus = dbus
self.app.register(dbus)
self.player.register(dbus)
def __on_name_lost(self, dbus: Gio.DBusConnection, name: str) -> None:
self.app.unregister(dbus)
self.player.unregister(dbus)
def disconnect(self):
"""Disconnect from dbus."""
if self.dbus:
self.app.unregister(self.dbus)
self.player.unregister(self.dbus)
self.dbus = None
if self._busid:
Gio.bus_unown_name(self._busid)
self._busid = None

View File

@ -0,0 +1,49 @@
# Copyright 2022 (c) Anna Schumaker.
"""Our Mpris2 Application dbus Object."""
import pathlib
from gi.repository import GObject
from gi.repository import GLib
from . import dbus
MPRIS2_XML = pathlib.Path(__file__).parent / "MediaPlayer2.xml"
class Application(dbus.Object):
"""The mpris2 Application dbus object."""
CanQuit = GObject.Property(type=bool, default=True)
Fullscreen = GObject.Property(type=bool, default=False)
CanSetFullscreen = GObject.Property(type=bool, default=True)
CanRaise = GObject.Property(type=bool, default=True)
HasTrackList = GObject.Property(type=bool, default=False)
Identity = GObject.Property(type=str, default="Emmental Music Player")
DesktopEntry = GObject.Property(type=str, default="emmental")
def __init__(self):
"""Initialize the mpris2 application object."""
super().__init__(xml=MPRIS2_XML)
def do_notify(self, property: str) -> None:
"""Notify DBus when the Fullscreen property changes."""
match property:
case "Fullscreen":
value = GLib.Variant("b", self.Fullscreen)
self.properties_changed({property: value})
@GObject.Property
def SupportedUriSchemes(self) -> list[str]:
"""URI schemes supported by Emmental."""
return ["file"]
@GObject.Property
def SupportedMimeTypes(self) -> list[str]:
"""Mime Types supported by Emmental."""
return ["audio"]
@GObject.Signal
def Raise(self) -> None:
"""Raise the window."""
@GObject.Signal
def Quit(self) -> None:
"""Quit Emmental."""

89
emmental/mpris2/dbus.py Normal file
View File

@ -0,0 +1,89 @@
# Copyright 2022 (c) Anna Schumaker.
"""Our generic dbus object."""
import pathlib
from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gio
OBJECT_PATH = "/org/mpris/MediaPlayer2"
class Object(GObject.GObject):
"""A generic dbus object."""
dbus = GObject.Property(type=Gio.DBusConnection)
nodeinfo = GObject.Property(type=Gio.DBusNodeInfo)
interface = GObject.Property(type=Gio.DBusInterfaceInfo)
registration = GObject.Property(type=int)
def __init__(self, xml: pathlib.Path = None):
"""Initialize a dbus Object."""
super().__init__()
if xml and xml.is_file():
with open(xml, 'r') as f:
self.nodeinfo = Gio.DBusNodeInfo.new_for_xml(f.read())
self.interface = self.nodeinfo.interfaces[0]
self.connect("notify", self.__on_notify)
def __on_notify(self, object: GObject.GObject, param) -> None:
self.do_notify(param.name)
def do_notify(self, property: str) -> None:
"""Handle a property value changing."""
def link_property(self, property: str, object: GObject.GObject,
object_property: str) -> None:
"""Link a dbus property to the object."""
self.bind_property(property, object, object_property,
GObject.BindingFlags.BIDIRECTIONAL)
def properties_changed(self, changed: dict) -> None:
"""Emit the org.freedesktop.DBus.PropertiesChanged signal."""
if self.dbus is None:
return
args = GLib.Variant.new_tuple(GLib.Variant("s", self.interface.name),
GLib.Variant("a{sv}", changed),
GLib.Variant("as", []))
self.dbus.emit_signal(None, OBJECT_PATH,
"org.freedesktop.DBus.Properties",
"PropertiesChanged", args)
def register(self, dbus: Gio.DBusConnection):
"""Register this dbus Object."""
self.registration = dbus.register_object(OBJECT_PATH, self.interface,
self.__method_call,
self.__get_property,
self.__set_property)
def unregister(self, dbus: Gio.DBusConnection):
"""Unregister this Object from the bus."""
dbus.unregister_object(self.registration)
self.registration = 0
def __method_call(self, dbus: Gio.DBusConnection, sender: str, object: str,
interface: str, method: str, parameters: GLib.Variant,
invocation: Gio.DBusMethodInvocation) -> None:
if object != OBJECT_PATH or interface != self.interface.name:
return None
self.emit(method, *parameters.unpack())
invocation.return_value(GLib.Variant.new_tuple())
def __get_property(self, dbus: Gio.DBusConnection, sender: str,
object: str, interface: str,
property: str) -> GLib.Variant | None:
if object != OBJECT_PATH or interface != self.interface.name:
return None
if property_info := self.interface.lookup_property(property):
return GLib.Variant(property_info.signature,
self.get_property(property))
def __set_property(self, dbus: Gio.DBusConnection, sender: str,
object: str, interface: str, property: str,
value: GLib.Variant) -> bool:
if object != OBJECT_PATH or interface != self.interface.name:
return None
self.set_property(property, value.unpack())
return True

136
emmental/mpris2/player.py Normal file
View File

@ -0,0 +1,136 @@
# Copyright 2022 (c) Anna Schumaker.
"""Our Mpris2 Player dbus Object."""
import pathlib
from gi.repository import GObject
from gi.repository import GLib
from .. import path
from . import dbus
PLAYER_XML = pathlib.Path(__file__).parent / "Player.xml"
OBJECT_PATH = "/com/nowheycreamery/emmental"
class Player(dbus.Object):
"""The mpris2 Player dbus object."""
PlaybackStatus = GObject.Property(type=str, default="Stopped")
LoopStatus = GObject.Property(type=str, default="None")
Rate = GObject.Property(type=float, default=1.0)
Shuffle = GObject.Property(type=bool, default=False)
Volume = GObject.Property(type=float, default=1.0)
Position = GObject.Property(type=float, default=0)
MinimumRate = GObject.Property(type=float, default=1.0)
MaximumRate = GObject.Property(type=float, default=1.0)
CanGoNext = GObject.Property(type=bool, default=False)
CanGoPrevious = GObject.Property(type=bool, default=False)
CanPlay = GObject.Property(type=bool, default=False)
CanPause = GObject.Property(type=bool, default=False)
CanSeek = GObject.Property(type=bool, default=False)
CanControl = GObject.Property(type=bool, default=True)
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)
duration = GObject.Property(type=float)
trackid = GObject.Property(type=int)
file = GObject.Property(type=GObject.TYPE_PYOBJECT)
artwork = GObject.Property(type=GObject.TYPE_PYOBJECT)
def __init__(self):
"""Initialize the mpris2 application object."""
super().__init__(xml=PLAYER_XML)
def do_notify(self, property: str) -> None:
"""Notify DBus when tags change."""
match property:
case "artist" | "album" | "album-artist" | "album-disc-number" | \
"title" | "track-number" | "duration" | "trackid" | \
"uri" | "artwork":
changed = GLib.Variant("a{sv}", self.Metadata)
self.properties_changed({"Metadata": changed})
case "PlaybackStatus":
changed = GLib.Variant("s", self.PlaybackStatus)
self.properties_changed({property: changed})
case "CanPlay" | "CanPause" | "CanSeek":
changed = GLib.Variant("b", self.get_property(property))
self.properties_changed({property: changed})
def seeked(self, newpos: float) -> None:
"""Notify that the track position has changed."""
args = GLib.Variant.new_tuple(GLib.Variant("x", newpos))
self.dbus.emit_signal(None, dbus.OBJECT_PATH, self.interface.name,
"Seeked", args)
@GObject.Property
def Metadata(self) -> dict:
"""Metadata for the current Track."""
res = dict()
if self.file:
trackid = f"{OBJECT_PATH}/{self.trackid}"
res["mpris:trackid"] = GLib.Variant("o", trackid)
res["mpris:length"] = GLib.Variant("x", self.duration)
res["xesam:url"] = GLib.Variant("s", self.file.as_uri())
if self.artwork is not None:
res["mpris:artUrl"] = GLib.Variant("s", self.artwork.as_uri())
if len(self.artist) > 0:
res["xesam:artist"] = GLib.Variant("as", [self.artist])
if len(self.album) > 0:
res["xesam:album"] = GLib.Variant("s", self.album)
if len(self.album_artist) > 0:
res["xesam:albumArtist"] = GLib.Variant("as",
[self.album_artist])
if self.album_disc_number > 0:
res["xesam:discNumber"] = GLib.Variant("u",
self.album_disc_number)
if len(self.title) > 0:
res["xesam:title"] = GLib.Variant("s", self.title)
if self.track_number > 0:
res["xesam:trackNumber"] = GLib.Variant("u", self.track_number)
return res
@GObject.Signal
def Next(self) -> None:
"""Skip to the next track."""
@GObject.Signal
def Previous(self) -> None:
"""Skip to the previous track."""
@GObject.Signal
def Pause(self) -> None:
"""Pause playback."""
@GObject.Signal
def PlayPause(self) -> None:
"""Toggle playback status."""
@GObject.Signal
def Stop(self) -> None:
"""Stop playback."""
@GObject.Signal
def Play(self) -> None:
"""Start or resume playback."""
@GObject.Signal(arg_types=(float,))
def Seek(self, offset: float) -> None:
"""Seek forward or backward by the given offset."""
@GObject.Signal(arg_types=(str, float))
def SetPosition(self, trackid: str, position: float) -> None:
"""Set the current track position in microseconds."""
@GObject.Signal(arg_types=(str,))
def OpenUri(self, uri: str) -> None:
"""Open the given uri."""
self.emit("OpenPath", path.from_uri(uri))
@GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT,))
def OpenPath(self, filepath: pathlib.Path) -> None:
"""Open the given path."""

View File

@ -0,0 +1,115 @@
# Copyright 2022 (c) Anna Schumaker.
"""A card for displaying information about the currently playing track."""
from gi.repository import GObject
from gi.repository import Gtk
from .. import buttons
from . import artwork
from . import controls
from . import seeker
from . import tags
class Card(Gtk.Box):
"""The Now Playing information card."""
artwork = GObject.Property(type=GObject.TYPE_PYOBJECT)
title = GObject.Property(type=str)
album = GObject.Property(type=str)
artist = GObject.Property(type=str)
album_artist = GObject.Property(type=str)
favorite = GObject.Property(type=bool, default=False)
duration = GObject.Property(type=float, default=1)
position = GObject.Property(type=float, default=0)
prefer_artist = GObject.Property(type=bool, default=True)
playing = GObject.Property(type=bool, default=False)
autopause = GObject.Property(type=int, default=-1, minimum=-1, maximum=99)
have_next = GObject.Property(type=bool, default=False)
have_previous = GObject.Property(type=bool, default=False)
have_track = GObject.Property(type=bool, default=False)
have_db_track = GObject.Property(type=bool, default=False)
def __init__(self):
"""Initialize a Now Playing Card."""
super().__init__()
self._grid = Gtk.Grid()
self._artwork = artwork.Artwork()
self._tags = tags.TagInfo()
self._controls = controls.Controls()
self._bottom_box = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 0)
self._favorite = buttons.ImageToggle("heart-filled",
"heart-outline-thick-symbolic",
icon_size=Gtk.IconSize.LARGE,
has_frame=False, sensitive=False,
valign=Gtk.Align.CENTER)
self._jump = buttons.Button(icon_name="go-jump", has_frame=False,
icon_size=Gtk.IconSize.LARGE,
valign=Gtk.Align.CENTER, sensitive=False)
self._seeker = seeker.Scale(sensitive=False)
self.bind_property("artwork", self._artwork, "filepath")
for prop in ["title", "album", "artist", "album-artist"]:
self.bind_property(prop, self._tags, prop)
self.bind_property("prefer-artist", self._tags, "prefer-artist",
GObject.BindingFlags.BIDIRECTIONAL)
for prop in ["playing", "have-next", "have-previous", "have-track"]:
self.bind_property(prop, self._controls, prop)
self.bind_property("have-db-track", self._jump, "sensitive")
self.bind_property("have-db-track", self._favorite, "sensitive")
self.bind_property("have-track", self._seeker, "sensitive")
self.bind_property("autopause", self._controls, "autopause",
GObject.BindingFlags.BIDIRECTIONAL)
self.bind_property("favorite", self._favorite, "active",
GObject.BindingFlags.BIDIRECTIONAL)
self.bind_property("duration", self._seeker, "duration")
self.bind_property("position", self._seeker, "position")
for sig in ["play", "pause", "previous", "next"]:
self._controls.connect(sig, self.__on_control, sig)
self._jump.connect("clicked", self.__on_jump)
self._seeker.connect("change-value", self.__on_seek)
self._bottom_box.append(self._favorite)
self._bottom_box.append(self._jump)
self._bottom_box.append(self._seeker)
self._grid.attach(self._tags, 0, 0, 1, 1)
self._grid.attach(self._controls, 1, 0, 1, 1)
self._grid.attach(self._bottom_box, 0, 1, 2, 1)
self.append(self._artwork)
self.append(self._grid)
self.add_css_class("card")
def __on_control(self, controls: controls.Controls, signal: str) -> None:
self.emit(signal)
def __on_jump(self, jump: Gtk.Button) -> None:
self.emit("jump")
def __on_seek(self, seek: seeker.Scale, scroll: Gtk.ScrollType,
value: float) -> None:
self.emit("seek", value)
@GObject.Signal
def jump(self) -> None:
"""Signal that the Tracklist should be scrolled."""
@GObject.Signal
def play(self) -> None:
"""Signal that the Play button has been clicked."""
@GObject.Signal
def pause(self) -> None:
"""Signal that the Pause button has been clicked."""
@GObject.Signal
def previous(self) -> None:
"""Signal that the Previous button has been clicked."""
@GObject.Signal
def next(self) -> None:
"""Signal that the Nause button has been clicked."""
@GObject.Signal(arg_types=(float,))
def seek(self, newpos: float) -> None:
"""Signal that the user wants us to seek."""

View File

@ -0,0 +1,49 @@
# Copyright 2022 (c) Anna Schumaker.
"""Our custom Album Art widget."""
import pathlib
from gi.repository import GObject
from gi.repository import Gtk
from .. import gsetup
FALLBACK_RESOURCE = f"{gsetup.RESOURCE_ICONS}/emmental.svg"
class Artwork(Gtk.Frame):
"""Our custom Album Art widget that draws a border around a picture."""
def __init__(self):
"""Initialize the Album Art widget."""
super().__init__(margin_top=6, margin_bottom=6, margin_start=6,
margin_end=6, halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER)
self._picture = Gtk.Picture(content_fit=Gtk.ContentFit.CONTAIN)
self._fullsize = Gtk.Picture(content_fit=Gtk.ContentFit.FILL)
self._popover = Gtk.Popover(child=self._fullsize)
self._clicked = Gtk.GestureClick()
self._clicked.connect("released", self.clicked)
self.add_controller(self._clicked)
self._popover.set_parent(self)
self.set_child(self._picture)
self.filepath = None
@GObject.Property(type=GObject.TYPE_PYOBJECT)
def filepath(self) -> pathlib.Path:
"""Get the current artwork path."""
name = self._picture.get_file().get_parse_name()
return None if name.startswith("resource:") else pathlib.Path(name)
@filepath.setter
def filepath(self, path: pathlib.Path) -> None:
if path is not None:
self._picture.set_filename(str(path))
self._fullsize.set_filename(str(path))
else:
self._picture.set_resource(FALLBACK_RESOURCE)
self._fullsize.set_resource(FALLBACK_RESOURCE)
def clicked(self, gesture: Gtk.GestureClick, n_press: int,
x: float, y: float) -> None:
"""Handle a click event."""
self._popover.popup()

View File

@ -0,0 +1,116 @@
# Copyright 2022 (c) Anna Schumaker.
"""Widgets for configuring autopause."""
import re
from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gtk
from .. import buttons
class Entry(Gtk.Entry):
"""A custom SpinButton so we can format output in Python."""
value = GObject.Property(type=int, default=-1, minimum=-1, maximum=99)
def __init__(self):
"""Initialize a Spin button."""
super().__init__(max_width_chars=20, placeholder_text="Keep playing",
primary_icon_sensitive=False,
primary_icon_name="list-remove-symbolic",
secondary_icon_name="list-add-symbolic")
self._timeout = (None, None)
#
self.connect("activate", self.__parse_text)
self.connect("icon_press", self.__icon_press)
self.connect("icon_release", self.__icon_release)
self.connect("notify::value", self.__update_text)
def __set_value(self, newval: int) -> bool:
if -1 <= newval <= 99:
self.value = newval
return True
return False
def __parse_text(self, entry: Gtk.Entry) -> None:
if parse := re.search(r"this|next|cancel|-?\d+",
entry.get_text(), re.I):
match parse.group().lower():
case "cancel": self.__set_value(-1)
case "this": self.__set_value(0)
case "next": self.__set_value(1)
case _: self.__set_value(int(parse.group()))
self.delete_text(0, -1)
def __change_value(self, change_how: str) -> bool:
match change_how:
case "increment": status = self.__set_value(self.value + 1)
case "decrement": status = self.__set_value(self.value - 1)
if not status:
self._timeout = (None, None)
elif self._timeout[1] == 150:
return GLib.SOURCE_CONTINUE
else:
timeout_id = GLib.timeout_add(150, self.__change_value, change_how)
self._timeout = (timeout_id, 150)
return GLib.SOURCE_REMOVE
def __icon_press(self, entry: Gtk.Entry,
icon_pos: Gtk.EntryIconPosition) -> None:
self.__icon_release(entry, icon_pos)
match icon_pos:
case Gtk.EntryIconPosition.SECONDARY:
change_how = "increment"
self.value += 1
case Gtk.EntryIconPosition.PRIMARY:
change_how = "decrement"
self.value -= 1
timeout_id = GLib.timeout_add(500, self.__change_value, change_how)
self._timeout = (timeout_id, 500)
def __icon_release(self, entry: Gtk.Entry,
icon_pos: Gtk.EntryIconPosition) -> None:
if self._timeout != (None, None):
GLib.source_remove(self._timeout[0])
self._timeout = (None, None)
def __update_text(self, spin, param) -> None:
match self.value:
case -1: text = "Keep playing"
case 0: text = "Pause after this track"
case 1: text = "Pause after the next track"
case _: text = f"Pause after {self.value} tracks"
self.set_placeholder_text(text)
self.set_icon_sensitive(Gtk.EntryIconPosition.PRIMARY,
self.value > -1)
self.set_icon_sensitive(Gtk.EntryIconPosition.SECONDARY,
self.value < 99)
class Button(buttons.PopoverButton):
"""A PopoverButton that displays Autopause count."""
value = GObject.Property(type=int, default=-1, minimum=-1, maximum=99)
def __init__(self, **kwargs):
"""Initialize an Autopause Button."""
super().__init__(popover_child=Entry(), **kwargs)
self._arrow = Gtk.Image(icon_name="pan-down-symbolic", margin_top=6)
self._count = Gtk.Label(yalign=0, halign=Gtk.Align.CENTER,
margin_top=3)
self._overlay = Gtk.Overlay(child=self._arrow)
self._overlay.add_overlay(self._count)
self.bind_property("value", self.popover_child, "value",
GObject.BindingFlags.BIDIRECTIONAL)
self.connect("notify::value", self.__notify_value)
self._count.set_markup("<small></small>")
self.set_child(self._overlay)
def __notify_value(self, button: buttons.PopoverButton, param) -> None:
text = str(self.value) if self.value > -1 else ""
self._count.set_markup(f"<small>{text}</small>")

View File

@ -0,0 +1,93 @@
# Copyright 2022 (c) Anna Schumaker.
"""Our playback control widgets."""
from gi.repository import GObject
from gi.repository import Gtk
from . import autopause
from .. import buttons
from .. import window
MARGIN = 24
class PillButton(buttons.Button):
"""A Button with the pill style class."""
def __init__(self, **kwargs):
"""Initialize a Pill Button."""
super().__init__(icon_size=Gtk.IconSize.LARGE, **kwargs)
self.add_css_class("pill")
class Controls(Gtk.Box):
"""Our playback control widgets."""
autopause = GObject.Property(type=int, default=-1, minimum=-1, maximum=99)
playing = GObject.Property(type=bool, default=False)
have_next = GObject.Property(type=bool, default=False)
have_previous = GObject.Property(type=bool, default=False)
have_track = GObject.Property(type=bool, default=False)
def __init__(self):
"""Initialize the Controls."""
super().__init__(valign=Gtk.Align.START, homogeneous=True,
halign=Gtk.Align.END, hexpand=False,
margin_start=MARGIN/2, margin_end=MARGIN,
margin_top=MARGIN, margin_bottom=MARGIN)
self._autopause = autopause.Button()
self._prev = PillButton(icon_name="media-skip-backward",
sensitive=False)
self._play = PillButton(icon_name="play-large", sensitive=False)
self._pause = buttons.SplitButton(icon_name="pause-large",
icon_size=Gtk.IconSize.LARGE,
secondary=self._autopause,
visible=False, sensitive=False)
self._next = PillButton(icon_name="media-skip-forward",
sensitive=False)
for button in [self._prev, self._play, self._pause, self._next]:
self.append(button)
self._prev.connect("clicked", self.__on_click, "previous")
self._play.connect("clicked", self.__on_click, "play")
self._pause.connect("clicked", self.__on_click, "pause")
self._next.connect("clicked", self.__on_click, "next")
self.bind_property("autopause", self._autopause, "value",
GObject.BindingFlags.BIDIRECTIONAL)
self.bind_property("playing", self._pause, "visible")
self.bind_property("playing", self._play, "visible",
GObject.BindingFlags.INVERT_BOOLEAN)
self.bind_property("have-next", self._next, "sensitive")
self.bind_property("have-previous", self._prev, "sensitive")
self.bind_property("have-track", self._play, "sensitive")
self.bind_property("have-track", self._pause, "sensitive")
self.connect("notify::playing", self.__notify_playing)
self.add_css_class("linked")
def __on_click(self, button: Gtk.Button, signal: str) -> None:
self.emit(signal)
def __notify_playing(self, controls, param) -> None:
if not self.playing and self.autopause != -1:
if win := self.get_ancestor(window.Window):
win.post_toast("Autopause Cancelled")
self.autopause = -1
@GObject.Signal
def previous(self) -> None:
"""Signals that the Previous button has been clicked."""
@GObject.Signal
def play(self) -> None:
"""Signals that the Play button has been clicked."""
@GObject.Signal
def pause(self) -> None:
"""Signals that the Pause button has been clicked."""
@GObject.Signal
def next(self) -> None:
"""Signals that the Next button has been clicked."""

View File

@ -0,0 +1,116 @@
# Copyright 2022 (c) Anna Schumaker.
"""A Gtk.Label set up for displaying nowplaying tags."""
from gi.repository import GObject
from gi.repository import Gdk
from gi.repository import Gtk
from gi.repository import Pango
class Label(Gtk.Label):
"""A Gtk.Label wrapped in a ScrolledWindow for displaying tags."""
prefix = GObject.Property(type=str)
size = GObject.Property(type=int)
def __init__(self, prefix="", **kwargs):
"""Initialize our Label."""
super().__init__(prefix=prefix, xalign=0, **kwargs)
self.connect("notify::size", self.__set_size)
@GObject.Property(type=str)
def text(self) -> str:
"""Get the text set to the label (without any prefix)."""
return self.get_text()[len(self.prefix):]
@text.setter
def text(self, text: str) -> None:
"""Set text to the label (adding any prefix)."""
text = f"{self.prefix}{text}" if text else ""
self.set_text(text)
def __set_size(self, label: Gtk.Label, param) -> None:
# Note that we have to create a new Pango.AttrList here because
# Gtk.Label.get_attributes() returns a COPY of the AttrList, so
# any modifications won't take effect.
attrlist = Pango.AttrList.new()
attrlist.insert(Pango.attr_size_new_absolute(self.size * Pango.SCALE))
self.set_attributes(attrlist)
class PreferArtistMenu(Gtk.Box):
"""Menu for selecting the prefer-artist property."""
prefer_artist = GObject.Property(type=bool, default=True)
def __init__(self):
"""Configure the Prefer Artist Menu."""
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self._artist = Gtk.CheckButton(active=True)
self._albumartist = Gtk.CheckButton()
self._artist.set_label("Prefer the artist tag.")
self._albumartist.set_label("Prefer the album artist tag.")
self._albumartist.set_group(self._artist)
self.bind_property("prefer-artist", self._artist, "active",
GObject.BindingFlags.BIDIRECTIONAL)
self.bind_property("prefer-artist", self._albumartist, "active",
GObject.BindingFlags.INVERT_BOOLEAN)
self.append(self._artist)
self.append(self._albumartist)
class ArtistLabel(Label):
"""A label that shows either Artist or Album Artist information."""
artist = GObject.Property(type=str)
album_artist = GObject.Property(type=str)
prefer_artist = GObject.Property(type=bool, default=True)
def __init__(self, **kwargs):
"""Initialize the Artist Label."""
super().__init__(**kwargs)
self._menu = PreferArtistMenu()
self._popover = Gtk.Popover(child=self._menu)
self._clicked = Gtk.GestureClick()
self.bind_property("prefer-artist", self._menu, "prefer-artist",
GObject.BindingFlags.BIDIRECTIONAL)
self.add_controller(self._clicked)
self._clicked.connect("released", self.__clicked)
self.connect("notify::artist", self.__notify)
self.connect("notify::album-artist", self.__notify)
self.connect("notify::prefer-artist", self.__notify)
self.connect("notify::size", self.__notify_size)
rect = Gdk.Rectangle()
rect.x, rect.y = (8, 8)
self._popover.set_pointing_to(rect)
self._popover.set_parent(self)
def __del__(self) -> None:
"""Clean up the Artist Label."""
self._popover.unparent()
def __clicked(self, gesture, n_press, x, y) -> None:
self._popover.popup()
def __notify_size(self, label, param) -> None:
rect = Gdk.Rectangle()
(rect.x, rect.y) = (self.size, self.size)
self._popover.set_pointing_to(rect)
def __notify(self, label, param) -> None:
if self.prefer_artist:
if len(self.artist) > 0:
self.text = self.artist
else:
self.text = self.album_artist
else:
if len(self.album_artist) > 0:
self.text = self.album_artist
else:
self.text = self.artist

View File

@ -0,0 +1,50 @@
# Copyright 2022 (c) Anna Schumaker.
"""A Gtk.Scale configured for position tracking seeking."""
from gi.repository import GObject
from gi.repository import Gtk
from gi.repository import Gst
class Scale(Gtk.Scale):
"""A Gtk.Scale configured for our application."""
def __init__(self, **kwargs):
"""Initialize our Scale."""
super().__init__(margin_start=45, margin_end=45, draw_value=True,
hexpand=True, **kwargs)
self._adjustment = Gtk.Adjustment.new(value=0, lower=0, upper=1,
step_increment=5*Gst.SECOND,
page_increment=30*Gst.SECOND,
page_size=0)
self.set_adjustment(self._adjustment)
self.set_format_value_func(self.format_value)
def format_value(self, scale: Gtk.Scale, value: float) -> str:
"""Format the position and duration values."""
duration = round(self.duration * Gst.USECOND / Gst.SECOND)
position = round(value * Gst.USECOND / Gst.SECOND)
remaining = duration - position
(p_m, p_s) = divmod(position, 60)
(r_m, r_s) = divmod(remaining, 60)
return f"{p_m:02}:{p_s:02} / {r_m:02}:{r_s:02}"
@GObject.Property(type=float)
def duration(self) -> float:
"""Get the duration of the current track."""
return self._adjustment.get_upper()
@duration.setter
def duration(self, newval: float) -> None:
"""Set the duration of the current track."""
self.set_range(0, max(newval, 1))
self.emit("value-changed")
@GObject.Property(type=float)
def position(self) -> float:
"""Get the position of the current track."""
return self.get_value()
@position.setter
def position(self, newval: float) -> None:
"""Set the position of the current track."""
self.set_value(newval)

View File

@ -0,0 +1,53 @@
# Copyright 2022 (c) Anna Schumaker.
"""Widgets for information about the current track."""
from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gtk
from . import label
class TagInfo(Gtk.ScrolledWindow):
"""A widget for displaying information about the current track."""
title = GObject.Property(type=str)
album = GObject.Property(type=str)
artist = GObject.Property(type=str)
album_artist = GObject.Property(type=str)
prefer_artist = GObject.Property(type=bool, default=True)
def __init__(self):
"""Initialize the TagInfo widget."""
super().__init__(hexpand=True, vexpand=True)
self._box = Gtk.Box.new(Gtk.Orientation.VERTICAL, 0)
self._title = label.Label()
self._album = label.Label(prefix="from ")
self._artist = label.ArtistLabel(prefix="by ")
self._saved_height = 0
self._idle_id = None
self.bind_property("title", self._title, "text")
self.bind_property("album", self._album, "text")
self.bind_property("artist", self._artist, "artist")
self.bind_property("album-artist", self._artist, "album-artist")
self.bind_property("prefer-artist", self._artist, "prefer-artist",
GObject.BindingFlags.BIDIRECTIONAL)
self._box.append(self._title)
self._box.append(self._album)
self._box.append(self._artist)
self.set_child(self._box)
def __set_label_size(self):
self._title.size = self._saved_height / 2.5
for tag in [self._album, self._artist]:
tag.size = self._saved_height / 5
self._idle_id = None
return GLib.SOURCE_REMOVE
def do_size_allocate(self, width, height, baseline) -> None:
"""Monitor for size changes."""
Gtk.ScrolledWindow.do_size_allocate(self, width, height, baseline)
if height != self._saved_height:
self._saved_height = height
if not self._idle_id:
self._idle_id = GLib.idle_add(self.__set_label_size)

12
emmental/options.py Normal file
View File

@ -0,0 +1,12 @@
# Copyright 2022 (c) Anna Schumaker.
"""Configure command line options passed to Emmental."""
from gi.repository import GLib
Version = GLib.OptionEntry()
Version.long_name = "version"
Version.short_name = ord("v")
Version.flags = GLib.OptionFlags.NONE
Version.arg = GLib.OptionArg.NONE
Version.arg_data = None
Version.description = "Print version information and exit"
Version.arg_description = None

60
emmental/path.py Normal file
View File

@ -0,0 +1,60 @@
# Copyright 2022 (c) Anna Schumaker.
"""Extra path handling for URIs."""
import pathlib
import threading
import urllib
class ReaddirThread(threading.Thread):
"""An object to manager asynchronous tree iteration."""
def __init__(self, directory: pathlib.Path):
"""Initialize an IterTreeResult."""
super().__init__()
self.root = directory
self._files = []
self._lock = threading.Lock()
self._stop_event = threading.Event()
def __read_directory(self, directory: pathlib.Path) -> None:
if self._stop_event.is_set():
return
for path in directory.iterdir():
if path.is_dir():
self.__read_directory(path)
else:
with self._lock:
self._files.append(path)
def poll_result(self) -> list[pathlib.Path] | None:
"""Poll for the result of the IterTreeThread."""
with self._lock:
if self.is_alive() or len(self._files):
files = self._files
self._files = []
return files
return None
def run(self) -> None:
"""Run the IterTreeThread."""
self.__read_directory(self.root)
def stop(self) -> None:
"""Signal the thread to stop."""
self._stop_event.set()
def readdir_async(directory: pathlib.Path) -> ReaddirThread:
"""Iterate through a directory tree asynchronously."""
if directory.is_dir():
res = ReaddirThread(directory)
res.start()
return res
def from_uri(uri: str) -> pathlib.Path:
"""Make a path from a uri."""
if parsed := urllib.parse.urlparse(uri):
return pathlib.Path(urllib.parse.unquote(parsed.path))
return pathlib.Path(uri)

View File

@ -0,0 +1,178 @@
# Copyright 2023 (c) Anna Schumaker.
"""An object for managing the visible and active playlists."""
from gi.repository import GObject
from . import playlist
from . import previous
from .. import db
class Factory(GObject.GObject):
"""Our playlist model factory."""
sql = GObject.Property(type=db.Connection)
db_active = GObject.Property(type=db.playlist.Playlist)
db_previous = GObject.Property(type=db.playlist.Playlist)
db_visible = GObject.Property(type=db.playlist.Playlist)
active_playlist = GObject.Property(type=playlist.Playlist)
previous_playlist = GObject.Property(type=previous.Previous)
visible_playlist = GObject.Property(type=playlist.Playlist)
can_go_next = GObject.Property(type=bool, default=False)
can_go_previous = GObject.Property(type=bool, default=False)
def __init__(self, sql: db.Connection):
"""Initialize the Playlist Factory."""
super().__init__(sql=sql)
self.sql.bind_property("active-playlist", self, "db-active")
self.connect("notify", self.__notify_db_playlists)
self.sql.playlists.connect("notify::have-collection-tracks",
self.__update_can_go_next)
def __get_playlists(self) -> list[playlist.Playlist]:
plists = [self.active_playlist, self.previous_playlist,
self.visible_playlist]
return [p for p in plists if p is not None]
def __search_playlists(self, db_plist: db.playlist.Playlist) \
-> playlist.Playlist:
for plist in self.__get_playlists():
if plist.playlist == db_plist:
return plist
def __update_can_go(self, which: str, newval: bool) -> None:
if self.get_property(f"can-go-{which}") != newval:
self.set_property(f"can-go-{which}", newval)
def __update_can_go_next(self, *args) -> None:
prev = self.previous_playlist
active = self.active_playlist
self.__update_can_go("next",
self.sql.playlists.have_collection_tracks or
(active is not None and active.can_go_next) or
(prev is not None and prev.can_go_forward))
def __update_can_go_prev(self, *args) -> None:
self.__update_can_go("previous", self.previous_playlist.can_go_next)
def __playlist_track_requested(self, playlist: playlist.Playlist,
track: db.tracks.Track) -> None:
album = isinstance(playlist.playlist, db.albums.Album) or \
isinstance(playlist.playlist, db.media.Medium)
self.emit("track-requested", track,
"album" if album else "track",
playlist.playlist == self.sql.playlists.previous)
def __make_playlist(self,
db_plist: db.playlist.Playlist) -> playlist.Playlist:
if db_plist == self.sql.playlists.previous:
res = previous.Previous(self.sql, db_plist)
res.connect("notify::can-go-next", self.__update_can_go_prev)
res.connect("notify::can-go-forward", self.__update_can_go_next)
else:
res = playlist.Playlist(self.sql, db_plist)
res.connect("notify::can-go-next", self.__update_can_go_next)
res.connect("track-requested", self.__playlist_track_requested)
return res
def __free_playlist(self, plist: playlist.Playlist) -> None:
plist.playlist = None
if isinstance(plist, previous.Previous):
plist.disconnect_by_func(self.__update_can_go_prev)
plist.disconnect_by_func(self.__update_can_go_next)
plist.disconnect_by_func(self.__playlist_track_requested)
def __run_factory(self, label: str) -> None:
db_plist = self.get_property(f"db-{label}")
plist = self.get_property(f"{label}-playlist")
print(f"factory: {label} playlist is:",
"<None>" if db_plist is None else db_plist.name)
if db_plist is None:
if self.__get_playlists().count(plist) == 1:
self.__free_playlist(plist)
new = None
elif plist is None or self.__get_playlists().count(plist) > 1:
if (new := self.__search_playlists(db_plist)) is None:
new = self.__make_playlist(db_plist)
elif (new := self.__search_playlists(db_plist)) is None:
plist.playlist = db_plist
return
else:
plist.playlist = None
self.set_property(f"{label}-playlist", new)
def __notify_db_playlists(self, factory: GObject.GObject, param) -> None:
match param.name:
case "db-active":
self.__run_factory("active")
self.notify("active-loop")
self.notify("active-shuffle")
case "db-previous":
self.__run_factory("previous")
case "db-visible":
self.__run_factory("visible")
def next_track(self, *, user: bool = False) \
-> tuple[db.tracks.Track | None, str, bool]:
"""Get the next track."""
track = None
restart = False
if user is True:
track = self.previous_playlist.previous_track()
restart = track is not None
if track is None and self.active_playlist is not None:
track = self.active_playlist.next_track()
if track is None:
self.sql.set_active_playlist(self.sql.playlists.collection)
track = self.active_playlist.next_track()
album = isinstance(self.db_active, db.albums.Album) or \
isinstance(self.db_active, db.media.Medium)
rg_auto = "album" if album else "track"
return (track, rg_auto, restart)
def previous_track(self) -> tuple[db.tracks.Track | None, bool]:
"""Get the previous Track."""
if self.previous_playlist is None:
return None
return self.previous_playlist.next_track()
@GObject.Property(type=str, flags=playlist.FLAGS)
def active_loop(self) -> str:
"""Get the loop state of the active playlist."""
if self.active_playlist is not None:
return self.active_playlist.loop
return "None"
@active_loop.setter
def active_loop(self, newval: str) -> None:
"""Set the loop state of the active playlist."""
if self.active_playlist is not None:
if self.active_playlist.loop != newval:
self.active_playlist.loop = newval
self.notify("active-loop")
@GObject.Property(type=bool, default=False, flags=playlist.FLAGS)
def active_shuffle(self) -> bool:
"""Get the shuffle state of the active playlist."""
if self.active_playlist is not None:
return self.active_playlist.shuffle
return False
@active_shuffle.setter
def active_shuffle(self, newval: bool) -> None:
"""Set the shuffle state of the active playlist."""
if self.active_playlist is not None:
if self.active_playlist.shuffle != newval:
self.active_playlist.shuffle = newval
self.notify("active-shuffle")
@GObject.Signal(arg_types=(db.tracks.Track, str, bool))
def track_requested(self, track: db.tracks.Track,
rg_auto: str, restarted: bool) -> None:
"""Signal that a track has been requested by the user."""

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