Compare commits
136 Commits
emmental-3
...
main
Author | SHA1 | Date |
---|---|---|
Anna Schumaker | c3818a2b18 | |
Anna Schumaker | 19c47be056 | |
Anna Schumaker | dbc60e1c5f | |
Anna Schumaker | 6779535cf1 | |
Anna Schumaker | 2ae5fd0969 | |
Anna Schumaker | b1490fd447 | |
Anna Schumaker | 4c5d3c78c0 | |
Anna Schumaker | 84a832389f | |
Anna Schumaker | 924f65fddd | |
Anna Schumaker | 14c153733d | |
Anna Schumaker | efe2611422 | |
Anna Schumaker | c49a23b046 | |
Anna Schumaker | a944af7f3e | |
Anna Schumaker | c5867badae | |
Anna Schumaker | e85bdcc7f4 | |
Anna Schumaker | c7dca6164e | |
Anna Schumaker | eada937b7a | |
Anna Schumaker | d373c33283 | |
Anna Schumaker | 1db187dba5 | |
Anna Schumaker | 0d100ec752 | |
Anna Schumaker | c4e827bc5a | |
Anna Schumaker | a4e0968ef4 | |
Anna Schumaker | 58a1df1d1d | |
Anna Schumaker | 3c25dc2c7f | |
Anna Schumaker | c37ae94a5d | |
Anna Schumaker | e6a219017d | |
Anna Schumaker | 3c15515faf | |
Anna Schumaker | 6c6ebf3676 | |
Anna Schumaker | ad8fd70f9a | |
Anna Schumaker | 8c316d0126 | |
Anna Schumaker | 3f153e1423 | |
Anna Schumaker | a08273535c | |
Anna Schumaker | ae1c611959 | |
Anna Schumaker | e73b6c09e7 | |
Anna Schumaker | b02fd609f7 | |
Anna Schumaker | 3241830c8e | |
Anna Schumaker | 97659f212d | |
Anna Schumaker | d22a9b23a1 | |
Anna Schumaker | 29693dcf84 | |
Anna Schumaker | bee48deac6 | |
Anna Schumaker | 5e096fa704 | |
Anna Schumaker | 6ebf29a632 | |
Anna Schumaker | a4f30d87e6 | |
Anna Schumaker | 51b290e1f0 | |
Anna Schumaker | fa203a72dd | |
Anna Schumaker | 3b8fb8531e | |
Anna Schumaker | 3e73ce0650 | |
Anna Schumaker | 17e4d85f1b | |
Anna Schumaker | 24675bf202 | |
Anna Schumaker | 072264a77c | |
Anna Schumaker | e7526f595f | |
Anna Schumaker | 7d2ec00da7 | |
Anna Schumaker | 70d7f5fa70 | |
Anna Schumaker | 2504f4b91d | |
Anna Schumaker | 7358183fef | |
Anna Schumaker | c195e68216 | |
Anna Schumaker | 1397e6e9e3 | |
Anna Schumaker | 717fdf39cd | |
Anna Schumaker | 9cf980d967 | |
Anna Schumaker | 87d8a2ae3a | |
Anna Schumaker | ddfd37130b | |
Anna Schumaker | 5011db344e | |
Anna Schumaker | 9f240bbc8b | |
Anna Schumaker | f6481f0182 | |
Anna Schumaker | 3d6350d7bd | |
Anna Schumaker | eb6b4d8ef4 | |
Anna Schumaker | f7349cd864 | |
Anna Schumaker | 30bcd30328 | |
Anna Schumaker | ef99951f74 | |
Anna Schumaker | 0fd391a9fd | |
Anna Schumaker | bc92e72265 | |
Anna Schumaker | 8dae0ed7bd | |
Anna Schumaker | 1707f87e45 | |
Anna Schumaker | 7e99fd1ba0 | |
Anna Schumaker | a8e7078308 | |
Anna Schumaker | 1d0813f217 | |
Anna Schumaker | 725619faf5 | |
Anna Schumaker | 6607e5b0ad | |
Anna Schumaker | 73019d8eb4 | |
Anna Schumaker | 6032e549a5 | |
Anna Schumaker | ba4907ec34 | |
Anna Schumaker | 06771ecab6 | |
Anna Schumaker | 17b2a82e20 | |
Anna Schumaker | c0c516fb70 | |
Anna Schumaker | 3cddde0986 | |
Anna Schumaker | 4f15bde850 | |
Anna Schumaker | 5ee86a9b5e | |
Anna Schumaker | 85c18fb5fe | |
Anna Schumaker | 67b508384c | |
Anna Schumaker | 929beb2a97 | |
Anna Schumaker | f400366210 | |
Anna Schumaker | 0c66b13209 | |
Anna Schumaker | e846c957a5 | |
Anna Schumaker | 37f81825b1 | |
Anna Schumaker | c375d2366a | |
Anna Schumaker | d807f8bd36 | |
Anna Schumaker | a13e481754 | |
Anna Schumaker | afb0ba5d68 | |
Anna Schumaker | 0307fae362 | |
Anna Schumaker | a6cd453c63 | |
Anna Schumaker | 7079076857 | |
Anna Schumaker | 8d72e1375f | |
Anna Schumaker | 87b92ffc90 | |
Anna Schumaker | bb4ca1e9c4 | |
Anna Schumaker | 087c378e59 | |
Anna Schumaker | 1f434358de | |
Anna Schumaker | 41cb325ad0 | |
Anna Schumaker | 0c1e5fcace | |
Anna Schumaker | 9cb927aabb | |
Anna Schumaker | 397c693aef | |
Anna Schumaker | eb162154b5 | |
Anna Schumaker | 2b5cdaa197 | |
Anna Schumaker | c5f9608c49 | |
Anna Schumaker | cae93cae11 | |
Anna Schumaker | 01a37dbbc1 | |
Anna Schumaker | 14c487c295 | |
Anna Schumaker | 57dd2c280e | |
Anna Schumaker | 59fb7d12f3 | |
Anna Schumaker | 93cdd9137a | |
Anna Schumaker | 45e95cc8c1 | |
Anna Schumaker | fa5cd55fce | |
Anna Schumaker | f307c92edb | |
Anna Schumaker | 8afd1a6240 | |
Anna Schumaker | 84fbd94aa1 | |
Anna Schumaker | 7155fa9db5 | |
Anna Schumaker | 0e40e6a4e8 | |
Anna Schumaker | deea9caa37 | |
Anna Schumaker | a626a1f3c4 | |
Anna Schumaker | dae588bfaf | |
Anna Schumaker | 03e5b9ad1b | |
Anna Schumaker | 5b0a0f54e4 | |
Anna Schumaker | dd9d6268ff | |
Anna Schumaker | a86ce6165d | |
Anna Schumaker | e624566919 | |
Anna Schumaker | b9a25ce5af | |
Anna Schumaker | bb9ecdbb5d |
|
@ -4,6 +4,7 @@
|
|||
*.ui~
|
||||
*.txt
|
||||
*.patch
|
||||
*.tar.gz
|
||||
PKGBUILD
|
||||
emmental.gresource*
|
||||
emmental/mpris2/*.xml
|
||||
|
|
|
@ -3,4 +3,4 @@
|
|||
url = ssh://aur@aur.archlinux.org/emmental.git
|
||||
[submodule "mpris-spec"]
|
||||
path = mpris-spec
|
||||
url = https://github.com/freedesktop/mpris-spec.git
|
||||
url = https://gitlab.freedesktop.org/mpris/mpris-spec.git
|
||||
|
|
28
Makefile
28
Makefile
|
@ -55,7 +55,7 @@ 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
|
||||
--dir=$(EMMENTAL_SHARE)/applications com.nowheycreamery.emmental.desktop
|
||||
|
||||
.PHONY: install
|
||||
install: emmental.gresource mpris2 install.app install.icons install.desktop
|
||||
|
@ -65,19 +65,25 @@ install: emmental.gresource mpris2 install.app install.icons install.desktop
|
|||
|
||||
.PHONY: uninstall
|
||||
uninstall:
|
||||
rm -f ${PREFIX}/share/applications/com.nowheycreamery.emmental.desktop
|
||||
rm -f ${PREFIX}/bin/emmental
|
||||
rm -rf ${PREFIX}/lib/emmental/
|
||||
rm -f ${EMMENTAL_SHARE}/applications/com.nowheycreamery.emmental.desktop
|
||||
rm -f ${EMMENTAL_BIN}/emmental
|
||||
rm -rf ${EMMENTAL_LIB}/emmental/
|
||||
|
||||
.PHONY: pkgbuild
|
||||
pkgbuild:
|
||||
.PHONY: pkgbuild.pkgver
|
||||
pkgbuild.pkgver:
|
||||
$(eval MAJOR := $(shell grep \^MAJOR_VERSION emmental/__init__.py | awk -F= '{ gsub(/ /,""); print $$2}'))
|
||||
$(eval MINOR := $(shell grep \^MINOR_VERSION emmental/__init__.py | awk -F= '{ gsub(/ /,""); print $$2}'))
|
||||
$(eval 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}|${MAJOR}.${MINOR}|' aur/PKGBUILD
|
||||
sed -i 's|{SHA256SUM}|$(CSUM)|' aur/PKGBUILD
|
||||
$(eval MICRO := $(shell grep \^MICRO_VERSION emmental/__init__.py | awk -F= '{ gsub(/ /,""); print $$2}'))
|
||||
sed -i 's/^pkgver=.*/pkgver=${MAJOR}.${MINOR}.${MICRO}/' aur/PKGBUILD
|
||||
|
||||
.PHONY: pkgbuild.sha256sum
|
||||
pkgbuild.sha256sum:
|
||||
$(eval TAG := $(shell git describe --abbrev=0))
|
||||
$(eval CHECKSUM := $(shell git archive --prefix=emmental/ --format tar.gz $(TAG) | sha256sum | awk '{print $$1}'))
|
||||
sed -i 's/^sha256sums=.*/sha256sums=(${CHECKSUM})/' aur/PKGBUILD
|
||||
|
||||
.PHONY: pkgbuild
|
||||
pkgbuild: pkgbuild.pkgver pkgbuild.sha256sum
|
||||
cd aur && makepkg --printsrcinfo > .SRCINFO
|
||||
|
||||
.PHONY: pytest
|
||||
|
|
|
@ -8,18 +8,19 @@ other playlists run out of tracks.
|
|||
* MPRIS2
|
||||
* ReplayGain
|
||||
* Gapless playback
|
||||
* Background listening mode
|
||||
* Automatically pause after a user-configured number of tracks
|
||||
* Playlist creation and management
|
||||
* Automatic playlists based on Artists, Albums, Genres, Decades, and Years
|
||||
* Multiple library path support
|
||||
* Plays all audio formats supported by GStreamer
|
||||
* Renamed track detection (using MusicBrainzIDs)
|
||||
* Updated tag detection
|
||||
* Renamed and updated tracks detection (using MusicBrainzIDs)
|
||||
|
||||
## Dependencies
|
||||
* Python3
|
||||
* dateutil
|
||||
* gobject
|
||||
* liblistenbrainz
|
||||
* musicbrainzngs
|
||||
* mutagen
|
||||
* pyxdg
|
||||
|
|
2
aur
2
aur
|
@ -1 +1 @@
|
|||
Subproject commit 405e47e0215ddb9b02b0fdc2ac913583341ba2f4
|
||||
Subproject commit 543220f35990f5b5a69eac5a6c06e69eae6ffb82
|
|
@ -9,3 +9,4 @@ Terminal=false
|
|||
MimeType=application/musepack;application/ogg;application/x-ape;application/x-flac;application/x-id3;application/x-musepack;application/x-ogg;application/x-ogm-audio;audio/aac;audio/ape;audio/flac;audio/mp;audio/mp3;audio/mp4;audio/mpc;audio/mpeg;audio/mpeg3;audio/mpegurl;audio/musepack;audio/ogg;audio/vnd.rn-realaudio;audio/vorbis;audio/x-ape;audio/x-flac;audio/x-it;audio/x-m4a;audio/x-mod;audio/x-mp;audio/x-mp3;audio/x-mpc;audio/x-mpeg;audio/x-mpeg-3;audio/x-mpegurl;audio/x-ms-wma;audio/x-musepack;audio/x-ogg;audio/x-oggflac;audio/x-pn-realaudio;audio/x-s3m;audio/x-scpls;audio/x-speex;audio/x-stm;audio/x-vorbis;audio/x-vorbis+ogg;audio/x-wav;audio/x-xm;
|
||||
Categories=AudioVideo;Audio;Music;Player;GTK;GNOME;
|
||||
SingleMainWindow=true
|
||||
StartupWMClass=emmental.py
|
|
@ -1,19 +0,0 @@
|
|||
# Maintainer: Anna Schumaker <anna@nowheycreamery.com>
|
||||
pkgname=emmental
|
||||
pkgver={MAJOR}.{MINOR}
|
||||
pkgrel=1
|
||||
pkgdesc='The cheesy music player'
|
||||
url='https://www.git.nowheycreamery.com/anna/emmental'
|
||||
arch=('any')
|
||||
license=('GPL3')
|
||||
depends=('python' 'python-gobject' 'python-mutagen' 'python-pyxdg' 'gtk4' 'gstreamer' 'gst-plugins-base')
|
||||
optdepends=('gst-plugins-good' 'gst-plugins-bad' 'gst-plugins-ugly')
|
||||
source=("https://git.nowheycreamery.com/anna/emmental/archive/emmental-$pkgver.tar.gz")
|
||||
sha256sums=({SHA256SUM})
|
||||
|
||||
package() {
|
||||
cd "$pkgname"
|
||||
make PREFIX="$pkgdir/usr" install
|
||||
sed -i "s|$pkgdir||" $pkgdir/usr/bin/emmental
|
||||
sed -i "s|$pkgdir||" $pkgdir/usr/share/applications/emmental.desktop
|
||||
}
|
|
@ -3,9 +3,11 @@
|
|||
import musicbrainzngs
|
||||
import pathlib
|
||||
from . import gsetup
|
||||
from . import action
|
||||
from . import audio
|
||||
from . import db
|
||||
from . import header
|
||||
from . import listenbrainz
|
||||
from . import mpris2
|
||||
from . import nowplaying
|
||||
from . import options
|
||||
|
@ -19,8 +21,11 @@ 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}"
|
||||
MINOR_VERSION = 2
|
||||
MICRO_VERSION = 0
|
||||
|
||||
VERSION_NUMBER = f"{MAJOR_VERSION}.{MINOR_VERSION}.{MICRO_VERSION}"
|
||||
VERSION_STRING = f"Emmental {VERSION_NUMBER}{gsetup.DEBUG_STR}"
|
||||
|
||||
|
||||
class Application(Adw.Application):
|
||||
|
@ -30,6 +35,7 @@ class Application(Adw.Application):
|
|||
factory = GObject.Property(type=playlist.Factory)
|
||||
mpris = GObject.Property(type=mpris2.Connection)
|
||||
player = GObject.Property(type=audio.Player)
|
||||
lbrainz = GObject.Property(type=listenbrainz.ListenBrainz)
|
||||
win = GObject.Property(type=window.Window)
|
||||
|
||||
autopause = GObject.Property(type=int, default=-1, minimum=-1, maximum=99)
|
||||
|
@ -41,6 +47,11 @@ class Application(Adw.Application):
|
|||
flags=Gio.ApplicationFlags.HANDLES_OPEN)
|
||||
self.add_main_option_entries([options.Version])
|
||||
|
||||
def __add_accelerators(self, accels: list[action.ActionEntry]) -> None:
|
||||
for entry in accels:
|
||||
self.add_action(entry.action)
|
||||
self.set_accels_for_action(f"app.{entry.name}", entry.accels)
|
||||
|
||||
def __load_file(self, file: pathlib.Path,
|
||||
*, gapless: bool = False) -> None:
|
||||
self.__stop_current_track()
|
||||
|
@ -124,12 +135,20 @@ class Application(Adw.Application):
|
|||
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 prop in ["bg-enabled", "bg-volume", "volume"]:
|
||||
hdr.bind_property(prop, self.player, prop)
|
||||
hdr.bind_property("listenbrainz-token", self.lbrainz, "user-token")
|
||||
for (setting, property) in [("audio.volume", "volume"),
|
||||
("audio.background.enabled", "bg-enabled"),
|
||||
("audio.background.volume", "bg-volume"),
|
||||
("audio.replaygain.enabled", "rg-enabled"),
|
||||
("audio.replaygain.mode", "rg-mode")]:
|
||||
("audio.replaygain.mode", "rg-mode"),
|
||||
("listenbrainz.token",
|
||||
"listenbrainz_token")]:
|
||||
self.db.settings.bind_setting(setting, hdr, property)
|
||||
|
||||
self.__add_accelerators(hdr.accelerators)
|
||||
|
||||
hdr.connect("notify::rg-enabled", self.__set_replaygain)
|
||||
hdr.connect("notify::rg-mode", self.__set_replaygain)
|
||||
hdr.connect("track-requested", self.__load_path)
|
||||
|
@ -153,6 +172,8 @@ class Application(Adw.Application):
|
|||
self.db.settings.bind_setting("now-playing.prefer-artist",
|
||||
playing, "prefer-artist")
|
||||
|
||||
self.__add_accelerators(playing.accelerators)
|
||||
|
||||
playing.connect("jump", self.__on_jump)
|
||||
playing.connect("play", self.player.play)
|
||||
playing.connect("pause", self.player.pause)
|
||||
|
@ -166,6 +187,7 @@ class Application(Adw.Application):
|
|||
side_bar = sidebar.Card(sql=self.db)
|
||||
self.db.settings.bind_setting("sidebar.artists.show-all", side_bar,
|
||||
"show-all-artists")
|
||||
self.__add_accelerators(side_bar.accelerators)
|
||||
return side_bar
|
||||
|
||||
def build_tracklist(self) -> tracklist.Card:
|
||||
|
@ -178,6 +200,8 @@ class Application(Adw.Application):
|
|||
self.db.settings.bind_setting(f"tracklist.{name}.visible",
|
||||
column, "visible")
|
||||
self.factory.bind_property("visible-playlist", track_list, "playlist")
|
||||
|
||||
self.__add_accelerators(track_list.accelerators)
|
||||
return track_list
|
||||
|
||||
def build_window(self) -> window.Window:
|
||||
|
@ -187,13 +211,17 @@ class Application(Adw.Application):
|
|||
now_playing=self.build_now_playing(),
|
||||
sidebar=self.build_sidebar(),
|
||||
tracklist=self.build_tracklist())
|
||||
win.bind_property("show-sidebar", win.header, "show-sidebar",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
win.bind_property("user-editing", win.now_playing, "editing")
|
||||
|
||||
for (setting, property) in [("window.width", "default-width"),
|
||||
("window.height", "default-height"),
|
||||
("now-playing.size", "now-playing-size"),
|
||||
("sidebar.size", "sidebar-size")]:
|
||||
("sidebar.show", "show-sidebar")]:
|
||||
self.db.settings.bind_setting(setting, win, property)
|
||||
|
||||
self.__add_accelerators(win.accelerators)
|
||||
return win
|
||||
|
||||
def connect_mpris2(self) -> None:
|
||||
|
@ -230,6 +258,15 @@ class Application(Adw.Application):
|
|||
self.mpris.player.connect("SetPosition", self.__set_position)
|
||||
self.mpris.player.connect("Stop", self.player.stop)
|
||||
|
||||
def connect_listenbrainz(self) -> None:
|
||||
"""Connect the listenbrainz client."""
|
||||
self.db.tracks.bind_property("current-track",
|
||||
self.lbrainz, "now-playing")
|
||||
self.lbrainz.bind_property("valid-token", self.win.header,
|
||||
"listenbrainz-token-valid")
|
||||
|
||||
self.db.tracks.connect("track-played", self.lbrainz.submit_listens)
|
||||
|
||||
def connect_playlist_factory(self) -> None:
|
||||
"""Connect the playlist factory properties."""
|
||||
self.db.playlists.bind_property("previous",
|
||||
|
@ -247,7 +284,7 @@ class Application(Adw.Application):
|
|||
"""Handle any command line options."""
|
||||
if opts.contains("version"):
|
||||
print(VERSION_STRING)
|
||||
gsetup.print_versions()
|
||||
gsetup.print_env()
|
||||
return 0
|
||||
return -1
|
||||
|
||||
|
@ -256,18 +293,20 @@ class Application(Adw.Application):
|
|||
Adw.Application.do_startup(self)
|
||||
self.db = db.Connection()
|
||||
self.mpris = mpris2.Connection()
|
||||
self.lbrainz = listenbrainz.ListenBrainz(self.db)
|
||||
self.factory = playlist.Factory(self.db)
|
||||
self.player = audio.Player()
|
||||
|
||||
gsetup.add_style()
|
||||
musicbrainzngs.set_useragent(f"emmental{gsetup.DEBUG_STR}",
|
||||
f"{MAJOR_VERSION}.{MINOR_VERSION}")
|
||||
VERSION_NUMBER)
|
||||
self.db.tracks.connect("notify::loaded", self.__tracks_table_loaded)
|
||||
self.db.load()
|
||||
|
||||
self.win = self.build_window()
|
||||
self.add_window(self.win)
|
||||
self.connect_mpris2()
|
||||
self.connect_listenbrainz()
|
||||
self.connect_playlist_factory()
|
||||
self.connect_player()
|
||||
|
||||
|
@ -293,6 +332,9 @@ class Application(Adw.Application):
|
|||
if self.win is not None:
|
||||
self.win.close()
|
||||
self.win = None
|
||||
if self.lbrainz is not None:
|
||||
self.lbrainz.stop()
|
||||
self.lbrainz = None
|
||||
if self.mpris is not None:
|
||||
self.mpris.disconnect()
|
||||
self.mpris = None
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
# Copyright 2023 (c) Anna Schumaker.
|
||||
"""A custom ActionEntry that works in Python."""
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gio
|
||||
from gi.repository import Gtk
|
||||
|
||||
|
||||
class ActionEntry(GObject.GObject):
|
||||
"""Our own AcitionEntry class to make accelerators easier."""
|
||||
|
||||
enabled = GObject.Property(type=bool, default=True)
|
||||
|
||||
def __init__(self, name: str, func: callable, *accels: tuple[str],
|
||||
enabled: tuple[GObject.GObject, str] | None = None):
|
||||
"""Initialize an ActionEntry."""
|
||||
super().__init__()
|
||||
|
||||
for accel in accels:
|
||||
if not Gtk.accelerator_parse(accel)[0]:
|
||||
raise ValueError
|
||||
|
||||
self.accels = list(accels)
|
||||
self.func = func
|
||||
|
||||
if enabled is not None:
|
||||
self.enabled = enabled[0].get_property(enabled[1])
|
||||
enabled[0].bind_property(enabled[1], self, "enabled")
|
||||
|
||||
self.action = Gio.SimpleAction(name=name, enabled=self.enabled)
|
||||
self.action.connect("activate", self.__activate)
|
||||
self.bind_property("enabled", self.action, "enabled")
|
||||
|
||||
def __activate(self, action: Gio.SimpleAction, param) -> None:
|
||||
self.func()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get then name of this ActionEntry."""
|
||||
return self.action.get_name()
|
|
@ -0,0 +1,48 @@
|
|||
# Copyright 2023 (c) Anna Schumaker.
|
||||
"""Functions for configuring a callback at a specific time."""
|
||||
import datetime
|
||||
import math
|
||||
from gi.repository import GLib
|
||||
|
||||
_GSOURCE_MAPPING = dict()
|
||||
_NEXT_ALARM_ID = 1
|
||||
|
||||
|
||||
def _calc_seconds(time: datetime.time) -> int:
|
||||
"""Calculate the number of seconds until the given time."""
|
||||
now = datetime.datetime.now()
|
||||
then = datetime.datetime.combine(now.date(), time)
|
||||
|
||||
if now >= then:
|
||||
then += datetime.timedelta(days=1)
|
||||
|
||||
return math.ceil((then - now).total_seconds())
|
||||
|
||||
|
||||
def __set_alarm(time: datetime.time, func: callable, alarm_id: int) -> None:
|
||||
gsrcid = GLib.timeout_add_seconds(_calc_seconds(time), _do_alarm,
|
||||
time, func, alarm_id)
|
||||
_GSOURCE_MAPPING[alarm_id] = gsrcid
|
||||
return alarm_id
|
||||
|
||||
|
||||
def _do_alarm(time: datetime.time, func: callable, alarm_id: int) -> bool:
|
||||
"""Run an alarm callback."""
|
||||
func()
|
||||
__set_alarm(time, func, alarm_id)
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
|
||||
def set_alarm(time: datetime.time, func: callable) -> int:
|
||||
"""Register a callback to be called at a specific time."""
|
||||
global _NEXT_ALARM_ID
|
||||
|
||||
res = __set_alarm(time, func, _NEXT_ALARM_ID)
|
||||
_NEXT_ALARM_ID += 1
|
||||
return res
|
||||
|
||||
|
||||
def cancel_alarm(alarm_id: int) -> None:
|
||||
"""Cancel an alarm."""
|
||||
GLib.source_remove(_GSOURCE_MAPPING[alarm_id])
|
||||
del _GSOURCE_MAPPING[alarm_id]
|
|
@ -4,7 +4,7 @@ import pathlib
|
|||
from gi.repository import GObject
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Gst
|
||||
from . import replaygain
|
||||
from . import filter
|
||||
from .. import path
|
||||
from .. import tmpdir
|
||||
|
||||
|
@ -35,16 +35,18 @@ class Player(GObject.GObject):
|
|||
playtime = GObject.Property(type=float)
|
||||
savedtime = GObject.Property(type=float)
|
||||
|
||||
bg_enabled = GObject.Property(type=bool, default=False)
|
||||
bg_volume = GObject.Property(type=float, default=0.5)
|
||||
pause_on_load = GObject.Property(type=bool, default=False)
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the audio Player."""
|
||||
super().__init__()
|
||||
self._replaygain = replaygain.Filter()
|
||||
self._filter = filter.Filter()
|
||||
self._timeout = None
|
||||
|
||||
self._playbin = Gst.ElementFactory.make("playbin")
|
||||
self._playbin.set_property("audio-filter", self._replaygain)
|
||||
self._playbin.set_property("audio-filter", self._filter)
|
||||
self._playbin.set_property("video-sink",
|
||||
Gst.ElementFactory.make("fakesink"))
|
||||
self._playbin.set_state(Gst.State.READY)
|
||||
|
@ -58,6 +60,8 @@ class Player(GObject.GObject):
|
|||
bus.connect("message::tag", self.__msg_tags)
|
||||
|
||||
self.bind_property("volume", self._playbin, "volume")
|
||||
self.bind_property("bg-enabled", self._filter, "bg-enabled")
|
||||
self.bind_property("bg-volume", self._filter, "bg-volume")
|
||||
|
||||
self.connect("notify::file", self.__notify_file)
|
||||
|
||||
|
@ -163,7 +167,7 @@ class Player(GObject.GObject):
|
|||
|
||||
def get_replaygain(self) -> tuple[bool, str | None]:
|
||||
"""Get the current ReplayGain mode."""
|
||||
mode = self._replaygain.mode
|
||||
mode = self._filter.rg_mode
|
||||
return (False, None) if mode == "disabled" else (True, mode)
|
||||
|
||||
def get_state(self) -> Gst.State:
|
||||
|
@ -191,7 +195,7 @@ class Player(GObject.GObject):
|
|||
|
||||
def set_replaygain(self, enabled: bool, mode: str) -> None:
|
||||
"""Set the ReplayGain mode."""
|
||||
self._replaygain.mode = mode if enabled else "disabled"
|
||||
self._filter.rg_mode = mode if enabled else "disabled"
|
||||
|
||||
def set_state_sync(self, state: Gst.State) -> None:
|
||||
"""Set the state of the playbin, and wait for it to change."""
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
# Copyright 2023 (c) Anna Schumaker.
|
||||
"""A custom Gst.Bin with our audio filter effects."""
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gst
|
||||
from . import replaygain
|
||||
|
||||
|
||||
class Filter(Gst.Bin):
|
||||
"""The audio filter element."""
|
||||
|
||||
bg_enabled = GObject.Property(type=bool, default=False)
|
||||
bg_volume = GObject.Property(type=float, default=0.5)
|
||||
rg_mode = GObject.Property(type=str, default="disabled")
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the audio filter."""
|
||||
super().__init__()
|
||||
self._replaygain = replaygain.Filter()
|
||||
self._volume = Gst.ElementFactory.make("volume")
|
||||
|
||||
self.add(self._replaygain)
|
||||
self.add(self._volume)
|
||||
|
||||
rg_pad = self._replaygain.get_static_pad("src")
|
||||
rg_pad.link(self._volume.get_static_pad("sink"))
|
||||
|
||||
self.__add_ghost_pad("sink", self._replaygain)
|
||||
self.__add_ghost_pad("src", self._volume)
|
||||
|
||||
self.connect("notify", self.__notify)
|
||||
|
||||
def __add_ghost_pad(self, pad: str, elm: Gst.Element) -> None:
|
||||
self.add_pad(Gst.GhostPad.new(pad, elm.get_static_pad(pad)))
|
||||
|
||||
def __notify(self, filter: Gst.Bin, param: GObject.ParamSpec) -> None:
|
||||
match param.name:
|
||||
case "bg-enabled" | "bg-volume":
|
||||
vol = self.bg_volume if self.bg_enabled else 1.0
|
||||
if vol != self._volume.get_property("volume"):
|
||||
vs = f"{round(vol * 100)}%" if self.bg_enabled else "off"
|
||||
print(f"audio: setting background listening to {vs}")
|
||||
self._volume.set_property("volume", vol)
|
||||
case "rg-mode":
|
||||
if self.rg_mode != self._replaygain.mode:
|
||||
self._replaygain.mode = self.rg_mode
|
|
@ -8,22 +8,29 @@ class Button(Gtk.Button):
|
|||
"""A Gtk.Button with extra properties and default large size."""
|
||||
|
||||
icon_name = GObject.Property(type=str)
|
||||
icon_size = GObject.Property(type=Gtk.IconSize,
|
||||
default=Gtk.IconSize.NORMAL)
|
||||
icon_opacity = GObject.Property(type=float, default=1.0,
|
||||
minimum=0.0, maximum=1.0)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
def __init__(self, large_icon: bool = False, **kwargs):
|
||||
"""Initialize a Button."""
|
||||
super().__init__(focusable=False, **kwargs)
|
||||
self._image = Gtk.Image(icon_name=self.icon_name,
|
||||
icon_size=self.icon_size,
|
||||
icon_size = Gtk.IconSize.LARGE if large_icon else Gtk.IconSize.NORMAL
|
||||
self._image = Gtk.Image(icon_name=self.icon_name, icon_size=icon_size,
|
||||
opacity=self.icon_opacity)
|
||||
self.bind_property("icon-name", self._image, "icon-name")
|
||||
self.bind_property("icon-size", self._image, "icon-size")
|
||||
self.bind_property("icon-opacity", self._image, "opacity")
|
||||
self.set_child(self._image)
|
||||
|
||||
@GObject.Property(type=bool, default=False)
|
||||
def large_icon(self) -> bool:
|
||||
"""Get if this Button has a large icon."""
|
||||
return self._image.get_icon_size() == Gtk.IconSize.LARGE
|
||||
|
||||
@large_icon.setter
|
||||
def large_icon(self, newval: bool) -> None:
|
||||
size = Gtk.IconSize.LARGE if newval else Gtk.IconSize.NORMAL
|
||||
self._image.set_icon_size(size)
|
||||
|
||||
|
||||
class PopoverButton(Gtk.MenuButton):
|
||||
"""A MenuButton with a Gtk.Popover attached."""
|
||||
|
@ -45,20 +52,20 @@ class SplitButton(Gtk.Box):
|
|||
"""A Button and secondary widget packed together."""
|
||||
|
||||
icon_name = GObject.Property(type=str)
|
||||
icon_size = GObject.Property(type=Gtk.IconSize,
|
||||
default=Gtk.IconSize.NORMAL)
|
||||
large_icon = GObject.Property(type=bool, default=False)
|
||||
|
||||
def __init__(self, secondary: Gtk.Button, **kwargs):
|
||||
"""Initialize a Split Button."""
|
||||
super().__init__(**kwargs)
|
||||
self._primary = Button(hexpand=True, icon_name=self.icon_name,
|
||||
icon_size=self.icon_size)
|
||||
large_icon=self.large_icon)
|
||||
self._separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL,
|
||||
margin_top=12, margin_bottom=12)
|
||||
self._secondary = secondary
|
||||
|
||||
self.bind_property("icon-name", self._primary, "icon-name")
|
||||
self.bind_property("icon-size", self._primary, "icon-size")
|
||||
self.bind_property("large-icon", self._primary, "large-icon")
|
||||
self._primary.connect("activate", self.__activate)
|
||||
self._primary.connect("clicked", self.__clicked)
|
||||
|
||||
self.append(self._primary)
|
||||
|
@ -67,14 +74,25 @@ class SplitButton(Gtk.Box):
|
|||
|
||||
self.add_css_class("emmental-splitbutton")
|
||||
|
||||
def __activate(self, button: Button) -> None:
|
||||
self.emit("activate-primary")
|
||||
|
||||
def __clicked(self, button: Button) -> None:
|
||||
self.emit("clicked")
|
||||
|
||||
def activate(self, *args) -> None:
|
||||
"""Activate the primary button."""
|
||||
self._primary.activate()
|
||||
|
||||
@GObject.Property(type=Gtk.Button, flags=GObject.ParamFlags.READABLE)
|
||||
def secondary(self) -> Gtk.Button:
|
||||
"""Get the secondary button attached to the SplitButton."""
|
||||
return self._secondary
|
||||
|
||||
@GObject.Signal
|
||||
def activate_primary(self) -> None:
|
||||
"""Signal that the primary button has been activated."""
|
||||
|
||||
@GObject.Signal
|
||||
def clicked(self) -> None:
|
||||
"""Signal that the primary button has been clicked."""
|
||||
|
@ -84,14 +102,30 @@ class ImageToggle(Button):
|
|||
"""Inspired by a ToggleButton, but changes image based on state."""
|
||||
|
||||
active_icon_name = GObject.Property(type=str)
|
||||
active_tooltip_text = GObject.Property(type=str)
|
||||
|
||||
inactive_icon_name = GObject.Property(type=str)
|
||||
inactive_tooltip_text = GObject.Property(type=str)
|
||||
|
||||
def __init__(self, active_icon_name: str, inactive_icon_name: str,
|
||||
active: bool = False, **kwargs) -> None:
|
||||
active_tooltip_text: str | None = None,
|
||||
inactive_tooltip_text: str | None = None,
|
||||
*, active: bool = False, **kwargs) -> None:
|
||||
"""Initialize an ImageToggle button."""
|
||||
super().__init__(active_icon_name=active_icon_name,
|
||||
inactive_icon_name=inactive_icon_name,
|
||||
icon_name=inactive_icon_name, active=active, **kwargs)
|
||||
icon_name=inactive_icon_name,
|
||||
active_tooltip_text=active_tooltip_text,
|
||||
inactive_tooltip_text=inactive_tooltip_text,
|
||||
tooltip_text=inactive_tooltip_text,
|
||||
active=active, **kwargs)
|
||||
self.connect("notify", self.__notify)
|
||||
|
||||
def __notify(self, toggle: Button, param: GObject.ParamSpec) -> None:
|
||||
match (param.name, self.active):
|
||||
case ("active-tooltip-text", True) | \
|
||||
("inactive-tooltip-text", False):
|
||||
self.set_tooltip_text(self.get_property(param.name))
|
||||
|
||||
def do_clicked(self) -> None:
|
||||
"""Handle a click event."""
|
||||
|
@ -105,8 +139,12 @@ class ImageToggle(Button):
|
|||
@active.setter
|
||||
def active(self, newval: bool) -> None:
|
||||
if newval != self.active:
|
||||
icon = self.active_icon_name if newval else self.inactive_icon_name
|
||||
self.icon_name = icon
|
||||
if newval:
|
||||
self.icon_name = self.active_icon_name
|
||||
self.props.tooltip_text = self.active_tooltip_text
|
||||
else:
|
||||
self.icon_name = self.inactive_icon_name
|
||||
self.props.tooltip_text = self.inactive_tooltip_text
|
||||
self.emit("toggled")
|
||||
|
||||
@GObject.Signal
|
||||
|
|
|
@ -18,13 +18,16 @@ from . import tracks
|
|||
from . import years
|
||||
|
||||
|
||||
SQL_SCRIPT = pathlib.Path(__file__).parent / "emmental.sql"
|
||||
SQL_V1_SCRIPT = pathlib.Path(__file__).parent / "emmental.sql"
|
||||
SQL_V2_SCRIPT = pathlib.Path(__file__).parent / "upgrade-v2.sql"
|
||||
SQL_V3_SCRIPT = pathlib.Path(__file__).parent / "upgrade-v3.sql"
|
||||
|
||||
|
||||
class Connection(connection.Connection):
|
||||
"""Connect to the database."""
|
||||
|
||||
active_playlist = GObject.Property(type=playlist.Playlist)
|
||||
loaded = GObject.Property(type=bool, default=False)
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize a sqlite connection."""
|
||||
|
@ -43,13 +46,25 @@ class Connection(connection.Connection):
|
|||
|
||||
self.tracks = tracks.Table(self)
|
||||
|
||||
def __check_loaded(self) -> None:
|
||||
for tbl in list(self.playlist_tables()) + [self.tracks]:
|
||||
if tbl.loaded is False:
|
||||
return
|
||||
self.loaded = True
|
||||
|
||||
def __check_version(self) -> None:
|
||||
user_version = self("PRAGMA user_version").fetchone()["user_version"]
|
||||
match user_version:
|
||||
case 0:
|
||||
with open(SQL_SCRIPT) as f:
|
||||
self._sql.executescript(f.read())
|
||||
case 1: pass
|
||||
self.executescript(SQL_V1_SCRIPT)
|
||||
self.executescript(SQL_V2_SCRIPT)
|
||||
self.executescript(SQL_V3_SCRIPT)
|
||||
case 1:
|
||||
self.executescript(SQL_V2_SCRIPT)
|
||||
self.executescript(SQL_V3_SCRIPT)
|
||||
case 2:
|
||||
self.executescript(SQL_V3_SCRIPT)
|
||||
case 3: pass
|
||||
case _:
|
||||
raise Exception(f"Unsupported data version: {user_version}")
|
||||
|
||||
|
@ -82,6 +97,8 @@ class Connection(connection.Connection):
|
|||
|
||||
def set_active_playlist(self, plist: playlist.Playlist) -> None:
|
||||
"""Set the currently active playlist."""
|
||||
if self.active_playlist == plist:
|
||||
return
|
||||
if self.active_playlist is not None:
|
||||
self.active_playlist.active = False
|
||||
|
||||
|
@ -94,3 +111,4 @@ class Connection(connection.Connection):
|
|||
def table_loaded(self, tbl: table.Table) -> None:
|
||||
"""Signal that a table has been loaded."""
|
||||
tbl.loaded = True
|
||||
self.__check_loaded()
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
import pathlib
|
||||
import sqlite3
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
from .media import Medium
|
||||
from .. import format
|
||||
from . import playlist
|
||||
|
@ -23,10 +22,11 @@ class Album(playlist.Playlist):
|
|||
"""Initialize an Album object."""
|
||||
super().__init__(**kwargs)
|
||||
self.add_children(self.table.sql.media,
|
||||
Gtk.CustomFilter.new(self.__match_medium))
|
||||
self.table.get_mediumids(self))
|
||||
|
||||
def __match_medium(self, medium: Medium) -> bool:
|
||||
return medium.albumid == self.albumid and len(medium.name) > 0
|
||||
def add_medium(self, medium: Medium) -> None:
|
||||
"""Add a Medium to this Album."""
|
||||
self.add_child(medium)
|
||||
|
||||
def get_artists(self) -> list[playlist.Playlist]:
|
||||
"""Get a list of artists for this album."""
|
||||
|
@ -36,6 +36,14 @@ class Album(playlist.Playlist):
|
|||
"""Get a list of media for this album."""
|
||||
return self.table.get_media(self)
|
||||
|
||||
def has_medium(self, medium: Medium) -> bool:
|
||||
"""Check if a Medium is from this Album."""
|
||||
return self.has_child(medium)
|
||||
|
||||
def remove_medium(self, medium: Medium) -> None:
|
||||
"""Remove a Medium from this Album."""
|
||||
return self.remove_child(medium)
|
||||
|
||||
@property
|
||||
def primary_key(self) -> int:
|
||||
"""Get the Album primary key."""
|
||||
|
@ -139,6 +147,11 @@ class Table(playlist.Table):
|
|||
|
||||
def get_media(self, album: Album) -> list[Medium]:
|
||||
"""Get the list of media for this album."""
|
||||
return [self.sql.media.rows.get(id)
|
||||
for id in self.get_mediumids(album)]
|
||||
|
||||
def get_mediumids(self, album: Album) -> set[int]:
|
||||
"""Get the set of mediumids for this album."""
|
||||
rows = self.sql("SELECT mediumid FROM media WHERE albumid=?",
|
||||
album.albumid)
|
||||
return [self.sql.media.rows.get(row["mediumid"]) for row in rows]
|
||||
return {row["mediumid"] for row in rows.fetchall()}
|
||||
|
|
|
@ -19,21 +19,21 @@ class Artist(playlist.Playlist):
|
|||
"""Initialize an Artist object."""
|
||||
super().__init__(**kwargs)
|
||||
self.add_children(self.table.sql.albums,
|
||||
table.Filter(self.table.get_albumids(self)))
|
||||
self.table.get_albumids(self))
|
||||
|
||||
def add_album(self, album: Album) -> None:
|
||||
"""Add an Album to this Artist."""
|
||||
if self.table.add_album(self, album):
|
||||
self.children.get_filter().add_row(album)
|
||||
self.add_child(album)
|
||||
|
||||
def has_album(self, album: Album) -> bool:
|
||||
"""Check if the Artist has this Album."""
|
||||
return self.children.get_filter().match(album)
|
||||
return self.has_child(album)
|
||||
|
||||
def remove_album(self, album: Album) -> None:
|
||||
"""Remove an album from this Artist."""
|
||||
self.children.get_filter().remove_row(album)
|
||||
self.table.remove_album(self, album)
|
||||
self.remove_child(album)
|
||||
|
||||
@property
|
||||
def primary_key(self) -> int:
|
||||
|
@ -41,7 +41,7 @@ class Artist(playlist.Playlist):
|
|||
return self.artistid
|
||||
|
||||
|
||||
class Filter(table.Filter):
|
||||
class Filter(table.KeySet):
|
||||
"""Custom filter to hide artists without albums."""
|
||||
|
||||
show_all = GObject.Property(type=bool, default=False)
|
||||
|
@ -51,7 +51,7 @@ class Filter(table.Filter):
|
|||
super().__init__(show_all=show_all)
|
||||
self.connect("notify::show-all", self.__notify_show_all)
|
||||
|
||||
def __notify_show_all(self, filter: table.Filter, param) -> None:
|
||||
def __notify_show_all(self, filter: table.KeySet, param) -> None:
|
||||
self.changed(Gtk.FilterChange.LESS_STRICT if self.show_all else
|
||||
Gtk.FilterChange.MORE_STRICT)
|
||||
|
||||
|
@ -66,7 +66,7 @@ class Filter(table.Filter):
|
|||
"""Check if the artist matches the filter."""
|
||||
res = super().do_match(artist)
|
||||
if not self.show_all and res:
|
||||
return artist.children.get_filter().n_keys > 0
|
||||
return artist.child_set.keyset.n_keys > 0
|
||||
return res
|
||||
|
||||
|
||||
|
|
|
@ -75,9 +75,21 @@ class Connection(GObject.GObject):
|
|||
self._sql.close()
|
||||
self.connected = False
|
||||
|
||||
def commit(self) -> None:
|
||||
"""Commit pending changes."""
|
||||
self._sql.commit()
|
||||
|
||||
def executemany(self, statement: str, *args) -> sqlite3.Cursor | None:
|
||||
"""Execute several similar SQL statements at once."""
|
||||
try:
|
||||
return self._sql.executemany(statement, args)
|
||||
except sqlite3.InternalError:
|
||||
return None
|
||||
|
||||
def executescript(self, script: pathlib.Path) -> sqlite3.Cursor | None:
|
||||
"""Execute a SQL script."""
|
||||
if script.is_file():
|
||||
with open(script) as f:
|
||||
cur = self._sql.executescript(f.read())
|
||||
self.commit()
|
||||
return cur
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
"""A custom Gio.ListModel for working with decades."""
|
||||
import sqlite3
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
from .years import Year
|
||||
from . import playlist
|
||||
from . import tracks
|
||||
|
@ -17,15 +16,24 @@ class Decade(playlist.Playlist):
|
|||
"""Initialize a Decade object."""
|
||||
super().__init__(**kwargs)
|
||||
self.add_children(self.table.sql.years,
|
||||
Gtk.CustomFilter.new(self.__match_year))
|
||||
self.table.get_yearids(self))
|
||||
|
||||
def __match_year(self, year: Year) -> bool:
|
||||
return self.decade == year.year // 10 * 10
|
||||
def add_year(self, year: Year) -> None:
|
||||
"""Add a year to this decade."""
|
||||
self.add_child(year)
|
||||
|
||||
def get_years(self) -> list[Year]:
|
||||
"""Get a list of years for this decade."""
|
||||
return self.table.get_years(self)
|
||||
|
||||
def has_year(self, year: Year) -> bool:
|
||||
"""Check if the year is in this decade."""
|
||||
return self.has_child(year)
|
||||
|
||||
def remove_year(self, year: Year) -> None:
|
||||
"""Remove a year from this decade."""
|
||||
self.remove_child(year)
|
||||
|
||||
@property
|
||||
def primary_key(self) -> int:
|
||||
"""Get the primary key of this Decade."""
|
||||
|
@ -90,8 +98,12 @@ class Table(playlist.Table):
|
|||
return self.sql("""SELECT trackid FROM decade_tracks_view
|
||||
WHERE decade=?""", decade.decade)
|
||||
|
||||
def get_years(self, decade: Decade) -> list[Year]:
|
||||
"""Get the list of years for this decade."""
|
||||
def get_yearids(self, decade: Decade) -> set[int]:
|
||||
"""Get the set of years for this decade."""
|
||||
rows = self.sql("SELECT year FROM years WHERE (year / 10 * 10)=?",
|
||||
decade.decade)
|
||||
return [self.sql.years.rows.get(row["year"]) for row in rows]
|
||||
return {row["year"] for row in rows}
|
||||
|
||||
def get_years(self, decade: Decade) -> list[Year]:
|
||||
"""Get the list of years for this decade."""
|
||||
return [self.sql.years.rows.get(yr) for yr in self.get_yearids(decade)]
|
||||
|
|
|
@ -52,6 +52,11 @@ class Queue(GObject.GObject):
|
|||
self.running = False
|
||||
self._idle_id = None
|
||||
|
||||
def cancel_task(self, func: typing.Callable) -> None:
|
||||
"""Remove all instances of a specific task from the Idle Queue."""
|
||||
self._tasks = [t for t in self._tasks if t[0] != func]
|
||||
self.__update_counters()
|
||||
|
||||
def complete(self) -> None:
|
||||
"""Complete all pending tasks."""
|
||||
if self.running:
|
||||
|
@ -60,12 +65,13 @@ class Queue(GObject.GObject):
|
|||
self.cancel()
|
||||
|
||||
def push(self, func: typing.Callable, *args,
|
||||
now: bool = False) -> bool | None:
|
||||
now: bool = False, first: bool = False) -> bool | None:
|
||||
"""Add a task to the Idle Queue."""
|
||||
if not self.enabled or now:
|
||||
return func(*args)
|
||||
|
||||
self._tasks.append((func, *args))
|
||||
pos = 0 if first else len(self._tasks)
|
||||
self._tasks.insert(pos, (func, *args))
|
||||
self.total += 1
|
||||
self.__start()
|
||||
|
||||
|
|
|
@ -55,10 +55,11 @@ class Library(playlist.Playlist):
|
|||
|
||||
def __tag_track(self, path: pathlib.Path) -> bool:
|
||||
if self.tagger.ready.is_set():
|
||||
(file, tags) = self.tagger.get_result(self.table.sql, self)
|
||||
if file is None:
|
||||
result = self.tagger.get_result(db=self.table.sql, library=self)
|
||||
if result is None:
|
||||
track = self.table.sql.tracks.lookup(self, path=path)
|
||||
self.tagger.tag_file(path, track.mtime if track else None)
|
||||
mtime = track.mtime if track else None
|
||||
self.tagger.tag_file(path, mtime=mtime)
|
||||
else:
|
||||
return True
|
||||
return False
|
||||
|
|
|
@ -2,8 +2,10 @@
|
|||
"""A custom Gio.ListModel for managing individual media in an album."""
|
||||
import sqlite3
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
from .. import format
|
||||
from . import playlist
|
||||
from . import table
|
||||
from . import tracks
|
||||
|
||||
|
||||
|
@ -34,12 +36,26 @@ class Medium(playlist.Playlist):
|
|||
return self.get_album()
|
||||
|
||||
|
||||
class Filter(table.KeySet):
|
||||
"""Custom filter to hide media with empty names."""
|
||||
|
||||
def do_get_strictness(self) -> Gtk.FilterMatch:
|
||||
"""Get the strictness of the filter."""
|
||||
if (res := super().do_get_strictness()) == Gtk.FilterMatch.ALL:
|
||||
res = Gtk.FilterMatch.SOME
|
||||
return res
|
||||
|
||||
def do_match(self, medium: Medium) -> bool:
|
||||
"""Check if the Medium matches the filter."""
|
||||
return len(medium.name) > 0 if super().do_match(medium) else False
|
||||
|
||||
|
||||
class Table(playlist.Table):
|
||||
"""Our Media Table."""
|
||||
|
||||
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
|
||||
"""Initialize the Media Table."""
|
||||
super().__init__(sql=sql, autodelete=True,
|
||||
super().__init__(sql=sql, filter=Filter(), autodelete=True,
|
||||
system_tracks=False, **kwargs)
|
||||
|
||||
def do_construct(self, **kwargs) -> Medium:
|
||||
|
@ -61,6 +77,7 @@ class Table(playlist.Table):
|
|||
|
||||
def do_sql_delete(self, medium: Medium) -> sqlite3.Cursor:
|
||||
"""Delete a medium."""
|
||||
medium.get_album().remove_medium(medium)
|
||||
return self.sql("DELETE FROM media WHERE mediumid=?",
|
||||
medium.mediumid)
|
||||
|
||||
|
@ -100,6 +117,13 @@ class Table(playlist.Table):
|
|||
return self.sql(f"UPDATE media SET {column}=? WHERE mediumid=?",
|
||||
newval, medium.mediumid)
|
||||
|
||||
def create(self, album: playlist.Playlist,
|
||||
*args, **kwargs) -> Medium | None:
|
||||
"""Create a new Medium playlist."""
|
||||
if (medium := super().create(album, *args, **kwargs)) is not None:
|
||||
album.add_medium(medium)
|
||||
return medium
|
||||
|
||||
def rename(self, medium: Medium, new_name: str) -> bool:
|
||||
"""Rename a medium."""
|
||||
if (new_name := new_name.strip()) != medium.name:
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
# Copyright 2022 (c) Anna Schumaker
|
||||
"""A customized Gio.ListStore for tracking Playlist GObjects."""
|
||||
import sqlite3
|
||||
import typing
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gio
|
||||
from gi.repository import Gtk
|
||||
|
@ -28,6 +29,7 @@ class Playlist(table.Row):
|
|||
tracks_movable = GObject.Property(type=bool, default=False)
|
||||
current_trackid = GObject.Property(type=int)
|
||||
|
||||
child_set = GObject.Property(type=table.TableSubset)
|
||||
children = GObject.Property(type=Gtk.FilterListModel)
|
||||
|
||||
def __init__(self, table: Gio.ListModel, propertyid: int,
|
||||
|
@ -48,20 +50,27 @@ class Playlist(table.Row):
|
|||
self.table.remove_track(self, track)
|
||||
return True
|
||||
|
||||
def add_children(self, child_table: table.Table,
|
||||
child_filter: Gtk.Filter) -> None:
|
||||
def add_children(self, child_table: table.Table, child_keys: set) -> None:
|
||||
"""Create a FilterListModel for this playlist's children."""
|
||||
self.children = Gtk.FilterListModel.new(child_table, child_filter)
|
||||
self.children.set_incremental(True)
|
||||
self.child_set = table.TableSubset(child_table, keys=child_keys)
|
||||
self.children = Gtk.FilterListModel.new(self.child_set,
|
||||
child_table.get_filter())
|
||||
|
||||
def do_update(self, column: str) -> bool:
|
||||
"""Update a Playlist object."""
|
||||
match column:
|
||||
case "propertyid" | "name" | "n-tracks" | "children" | \
|
||||
"user-tracks" | "tracks-loaded" | "tracks-movable": pass
|
||||
case "propertyid" | "name" | "n-tracks" | "child-set" | \
|
||||
"children" | "user-tracks" | "tracks-loaded" | \
|
||||
"tracks-movable": pass
|
||||
case _: return super().do_update(column)
|
||||
return True
|
||||
|
||||
def add_child(self, child: typing.Self) -> None:
|
||||
"""Add a child Playlist to this Playlist."""
|
||||
self.child_set.add_row(child)
|
||||
if self.child_set.keyset.n_keys == 1:
|
||||
self.table.refilter(Gtk.FilterChange.LESS_STRICT)
|
||||
|
||||
def add_track(self, track: Track, *, idle: bool = False) -> None:
|
||||
"""Add a Track to this Playlist."""
|
||||
if self.table.add_track(self, track):
|
||||
|
@ -71,6 +80,10 @@ class Playlist(table.Row):
|
|||
"""Get a dictionary mapping for trackid -> sorted position."""
|
||||
return self.table.get_track_order(self)
|
||||
|
||||
def has_child(self, child: typing.Self) -> bool:
|
||||
"""Check if this Playlist has a specific child Playlist."""
|
||||
return child in self.child_set
|
||||
|
||||
def has_track(self, track: Track) -> bool:
|
||||
"""Check if a Track is on this Playlist."""
|
||||
return track in self.tracks
|
||||
|
@ -95,6 +108,12 @@ class Playlist(table.Row):
|
|||
self.tracks_loaded = False
|
||||
self.table.queue.push(self.load_tracks, now=not idle)
|
||||
|
||||
def remove_child(self, child: typing.Self) -> None:
|
||||
"""Remove a child Playlist from this Playlist."""
|
||||
self.child_set.remove_row(child)
|
||||
if self.child_set.keyset.n_keys == 0:
|
||||
self.table.refilter(Gtk.FilterChange.MORE_STRICT)
|
||||
|
||||
def remove_track(self, track: table.Row, *, idle: bool = False) -> None:
|
||||
"""Remove a Track from this Playlist."""
|
||||
self.table.queue.push(self.__remove_track, track, now=not idle)
|
||||
|
@ -138,6 +157,10 @@ class Table(table.Table):
|
|||
def __create_tree(self, plist: Playlist) -> Gtk.FilterListModel | None:
|
||||
return plist.children
|
||||
|
||||
def __refilter(self, change_how: Gtk.FilterChange) -> bool:
|
||||
self.get_filter().changed(change_how)
|
||||
return True
|
||||
|
||||
def do_add_track(self, playlist: Playlist, track: Track) -> bool:
|
||||
"""Add a Track to the Playlist."""
|
||||
raise NotImplementedError
|
||||
|
@ -239,6 +262,11 @@ class Table(table.Table):
|
|||
playlist.sort_order = "user"
|
||||
return res
|
||||
|
||||
def refilter(self, change_how: Gtk.FilterChange) -> None:
|
||||
"""Schedule refiltering the Table."""
|
||||
self.queue.cancel_task(self.__refilter)
|
||||
self.queue.push(self.__refilter, change_how, first=True)
|
||||
|
||||
def remove_system_track(self, playlist: Playlist, track: Track) -> bool:
|
||||
"""Remove a Track from a system Playlist."""
|
||||
return self.sql("""DELETE FROM system_tracks
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
# Copyright 2022 (c) Anna Schumaker
|
||||
"""A custom Gio.ListModel for working with playlists."""
|
||||
import datetime
|
||||
import sqlite3
|
||||
from gi.repository import GObject
|
||||
from .. import alarm
|
||||
from . import playlist
|
||||
from . import tracks
|
||||
|
||||
|
@ -57,6 +59,11 @@ class Table(playlist.Table):
|
|||
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
|
||||
"""Initialize the Playlists Table."""
|
||||
super().__init__(sql=sql, system_tracks=False, **kwargs)
|
||||
alarm.set_alarm(datetime.time(hour=0, minute=0, second=5),
|
||||
self.__at_midnight)
|
||||
|
||||
def __at_midnight(self) -> None:
|
||||
self.new_tracks.reload_tracks()
|
||||
|
||||
def __move_user_trackid(self, playlist: Playlist, trackid: int,
|
||||
*, offset: int) -> bool:
|
||||
|
@ -113,6 +120,9 @@ class Table(playlist.Table):
|
|||
case self.previous:
|
||||
self.add_system_track(playlist, track)
|
||||
return True
|
||||
case self.queued:
|
||||
self.sql.set_active_playlist(playlist)
|
||||
return self.add_user_track(playlist, track)
|
||||
case self.unplayed: return track.playcount == 0
|
||||
case _: return self.add_user_track(playlist, track)
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
# Copyright 2022 (c) Anna Schumaker
|
||||
"""Base classes for database objects."""
|
||||
import bisect
|
||||
import sqlite3
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gio
|
||||
|
@ -37,44 +38,52 @@ class Row(GObject.GObject):
|
|||
raise NotImplementedError
|
||||
|
||||
|
||||
class Filter(Gtk.Filter):
|
||||
"""A Filter that can be used to search playlists."""
|
||||
class KeySet(Gtk.Filter):
|
||||
"""A Gtk.Filter that also acts as a Python Set."""
|
||||
|
||||
n_keys = GObject.Property(type=int)
|
||||
|
||||
def __init__(self, keys: set | None = None, **kwargs):
|
||||
"""Set up our Filter."""
|
||||
"""Set up our KeySet."""
|
||||
super().__init__(**kwargs)
|
||||
self._keys = keys
|
||||
self.n_keys = len(keys) if keys is not None else -1
|
||||
|
||||
def __contains__(self, row: Row) -> bool:
|
||||
"""Check if a Row is in the KeySet."""
|
||||
return self._keys is None or row.primary_key in self._keys
|
||||
|
||||
def __sub__(self, rhs: Gtk.Filter) -> set[int]:
|
||||
"""Subtract two Filters and return the result."""
|
||||
"""Subtract two KeySets and return the result."""
|
||||
match (self._keys, rhs._keys):
|
||||
case (None, _): return None
|
||||
case (_, None): return self._keys
|
||||
case (_, _): return self._keys - rhs._keys
|
||||
|
||||
def __find_change(self, keys: set[any] | None) -> Gtk.FilterChange | None:
|
||||
if keys == self._keys:
|
||||
return None
|
||||
elif keys is None:
|
||||
return Gtk.FilterChange.LESS_STRICT
|
||||
elif self._keys is None:
|
||||
return Gtk.FilterChange.MORE_STRICT
|
||||
elif keys.issuperset(self._keys):
|
||||
return Gtk.FilterChange.LESS_STRICT
|
||||
elif keys.issubset(self._keys):
|
||||
return Gtk.FilterChange.MORE_STRICT
|
||||
return Gtk.FilterChange.DIFFERENT
|
||||
def __find_difference(self, new: set[any] | None) \
|
||||
-> tuple[set, set, Gtk.FilterChange | None]:
|
||||
if self._keys is None:
|
||||
if new is None:
|
||||
return (set(), set(), None)
|
||||
return (set(), new, Gtk.FilterChange.MORE_STRICT)
|
||||
elif new is None:
|
||||
return (self._keys, set(), Gtk.FilterChange.LESS_STRICT)
|
||||
|
||||
removed = self._keys - new
|
||||
added = new - self._keys
|
||||
match len(removed), len(added):
|
||||
case 0, 0: return (removed, added, None)
|
||||
case _, 0: return (removed, added, Gtk.FilterChange.MORE_STRICT)
|
||||
case 0, _: return (removed, added, Gtk.FilterChange.LESS_STRICT)
|
||||
case _, _: return (removed, added, Gtk.FilterChange.DIFFERENT)
|
||||
|
||||
def changed(self, how: Gtk.FilterChange) -> None:
|
||||
"""Notify that the filter has changed."""
|
||||
"""Notify that the KeySet has changed."""
|
||||
self.n_keys = len(self._keys) if self._keys is not None else -1
|
||||
super().changed(how)
|
||||
|
||||
def do_get_strictness(self) -> Gtk.FilterMatch:
|
||||
"""Get the strictness of the filter."""
|
||||
"""Get the strictness of the Gtk.Filter."""
|
||||
if self._keys is None:
|
||||
return Gtk.FilterMatch.ALL
|
||||
if len(self._keys) == 0:
|
||||
|
@ -82,19 +91,21 @@ class Filter(Gtk.Filter):
|
|||
return Gtk.FilterMatch.SOME
|
||||
|
||||
def do_match(self, row: Row) -> bool:
|
||||
"""Check if the Row matches the filter."""
|
||||
"""Check if the Row is in the KeySet."""
|
||||
return self._keys is None or row.primary_key in self._keys
|
||||
|
||||
def add_row(self, row: Row) -> None:
|
||||
"""Add a Row to the Filter."""
|
||||
if self._keys is not None:
|
||||
"""Add a Row to the KeySet."""
|
||||
if row not in self:
|
||||
self._keys.add(row.primary_key)
|
||||
self.emit("key-added", row.primary_key)
|
||||
self.changed(Gtk.FilterChange.LESS_STRICT)
|
||||
|
||||
def remove_row(self, row: Row) -> None:
|
||||
"""Remove a Row from the Filter."""
|
||||
if self._keys is not None:
|
||||
"""Remove a Row from the KeySet."""
|
||||
if self._keys is not None and row in self:
|
||||
self._keys.discard(row.primary_key)
|
||||
self.emit("key-removed", row.primary_key)
|
||||
self.changed(Gtk.FilterChange.MORE_STRICT)
|
||||
|
||||
@property
|
||||
|
@ -105,9 +116,23 @@ class Filter(Gtk.Filter):
|
|||
@keys.setter
|
||||
def keys(self, keys: set[any] | None) -> None:
|
||||
"""Set the matching primary keys."""
|
||||
if (how := self.__find_change(keys)) is not None:
|
||||
(removed, added, change) = self.__find_difference(keys)
|
||||
if change is not None:
|
||||
self._keys = keys
|
||||
self.changed(how)
|
||||
self.emit("keys-changed", removed, added)
|
||||
self.changed(change)
|
||||
|
||||
@GObject.Signal(arg_types=(int,))
|
||||
def key_added(self, key: int) -> None:
|
||||
"""Signal that a Row has been added to the KeySet."""
|
||||
|
||||
@GObject.Signal(arg_types=(int,))
|
||||
def key_removed(self, key: int) -> None:
|
||||
"""Signal that a Row has been removed from the KeySet."""
|
||||
|
||||
@GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT, GObject.TYPE_PYOBJECT))
|
||||
def keys_changed(self, removed: set | None, added: set | None) -> None:
|
||||
"""Signal that the KeySet has been directly modified."""
|
||||
|
||||
|
||||
class Table(Gtk.FilterListModel):
|
||||
|
@ -121,12 +146,12 @@ class Table(Gtk.FilterListModel):
|
|||
loaded = GObject.Property(type=bool, default=False)
|
||||
|
||||
def __init__(self, sql: GObject.TYPE_PYOBJECT,
|
||||
filter: Filter | None = None,
|
||||
filter: KeySet | None = None,
|
||||
queue: Queue | None = None, **kwargs):
|
||||
"""Set up our Table object."""
|
||||
super().__init__(sql=sql, rows=dict(), incremental=True,
|
||||
super().__init__(sql=sql, rows=dict(),
|
||||
store=store.SortedList(self.get_sort_key),
|
||||
filter=(filter if filter else Filter()),
|
||||
filter=(filter if filter else KeySet()),
|
||||
queue=(queue if queue else Queue()), **kwargs)
|
||||
self.set_model(self.store)
|
||||
|
||||
|
@ -188,6 +213,7 @@ class Table(Gtk.FilterListModel):
|
|||
def delete(self, row: Row) -> bool:
|
||||
"""Delete a Row from the Table."""
|
||||
if row in self and self.do_sql_delete(row).rowcount == 1:
|
||||
self.sql.commit()
|
||||
self.store.remove(row)
|
||||
del self.rows[row.primary_key]
|
||||
return True
|
||||
|
@ -201,7 +227,8 @@ class Table(Gtk.FilterListModel):
|
|||
def filter(self, glob: str | None, *, now: bool = False) -> None:
|
||||
"""Filter the displayed Rows."""
|
||||
if glob is not None:
|
||||
self.queue.push(self._filter_idle, glob, now=now)
|
||||
self.queue.cancel_task(self._filter_idle)
|
||||
self.queue.push(self._filter_idle, glob, now=now, first=True)
|
||||
else:
|
||||
self.get_filter().keys = None
|
||||
|
||||
|
@ -246,3 +273,75 @@ class Table(Gtk.FilterListModel):
|
|||
def update(self, row: Row, column: str, newval) -> bool:
|
||||
"""Update a Row."""
|
||||
return self.do_sql_update(row, column, newval) is not None
|
||||
|
||||
|
||||
class TableSubset(GObject.GObject, Gio.ListModel):
|
||||
"""A list model containing a subset of the rows in the source Table."""
|
||||
|
||||
keyset = GObject.Property(type=KeySet)
|
||||
table = GObject.Property(type=Table)
|
||||
n_rows = GObject.Property(type=int)
|
||||
|
||||
def __init__(self, table: Table, *, keys: set[any] | None = None):
|
||||
"""Initialize a KeySetModel."""
|
||||
super().__init__(keyset=KeySet(set() if keys is None else keys),
|
||||
table=table)
|
||||
self._items = []
|
||||
|
||||
self.keyset.connect("key-added", self.__on_key_added)
|
||||
self.keyset.connect("key-removed", self.__on_key_removed)
|
||||
self.table.connect("notify::loaded", self.__notify_table_loaded)
|
||||
|
||||
def __contains__(self, row: Row) -> bool:
|
||||
"""Check if the Row is in the internal KeySet."""
|
||||
return row in self.keyset
|
||||
|
||||
def __bisect(self, key: any) -> int | None:
|
||||
if self.table.loaded:
|
||||
sort_key = self.table.get_sort_key(self.table.rows[key])
|
||||
return bisect.bisect_left(self._items, sort_key,
|
||||
key=self.table.get_sort_key)
|
||||
return None
|
||||
|
||||
def __items_changed(self, position: int, removed: int, added: int) -> None:
|
||||
self.n_rows = len(self._items)
|
||||
self.items_changed(position, removed, added)
|
||||
|
||||
def __notify_table_loaded(self, table: Table, param) -> None:
|
||||
if table.loaded and self.keyset.n_keys > 0:
|
||||
self._items = sorted([table.rows[k] for k in self.keyset.keys],
|
||||
key=self.table.get_sort_key)
|
||||
self.__items_changed(0, 0, self.keyset.n_keys)
|
||||
elif not table.loaded and self.n_rows > 0:
|
||||
self._items = []
|
||||
self.__items_changed(0, self.n_rows, 0)
|
||||
|
||||
def __on_key_added(self, keyset: KeySet, key: any) -> None:
|
||||
if (pos := self.__bisect(key)) is not None:
|
||||
self._items.insert(pos, self.table.rows[key])
|
||||
self.__items_changed(pos, 0, 1)
|
||||
|
||||
def __on_key_removed(self, keyset: KeySet, key: any) -> None:
|
||||
if (pos := self.__bisect(key)) is not None:
|
||||
del self._items[pos]
|
||||
self.__items_changed(pos, 1, 0)
|
||||
|
||||
def do_get_item_type(self) -> GObject.GType:
|
||||
"""Get the Gio.ListModel item type."""
|
||||
return Row.__gtype__
|
||||
|
||||
def do_get_n_items(self) -> int:
|
||||
"""Get the number of Rows in the TableSubset."""
|
||||
return self.n_rows
|
||||
|
||||
def do_get_item(self, n: int) -> int:
|
||||
"""Get the nth item in the TableSubset."""
|
||||
return self._items[n] if n < len(self._items) else None
|
||||
|
||||
def add_row(self, row: Row) -> None:
|
||||
"""Add a row to the TableSubset."""
|
||||
self.keyset.add_row(row)
|
||||
|
||||
def remove_row(self, row: Row) -> None:
|
||||
"""Remove a row from the TableSubset."""
|
||||
self.keyset.remove_row(row)
|
||||
|
|
|
@ -3,9 +3,9 @@
|
|||
import emmental.audio.tagger
|
||||
import musicbrainzngs
|
||||
import pathlib
|
||||
import threading
|
||||
from gi.repository import GObject
|
||||
from .. import audio
|
||||
from .. import thread
|
||||
from . import albums
|
||||
from . import artists
|
||||
from . import connection
|
||||
|
@ -178,24 +178,12 @@ class Tags:
|
|||
return year if year else self.db.years.create(raw_year)
|
||||
|
||||
|
||||
class Thread(threading.Thread):
|
||||
class Thread(thread.Thread):
|
||||
"""A thread for tagging files without blocking the UI."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the Tagger Thread."""
|
||||
super().__init__()
|
||||
self.ready = threading.Event()
|
||||
|
||||
self._connection = None
|
||||
self._condition = threading.Condition()
|
||||
self._file = None
|
||||
self._mtime = None
|
||||
self._tags = None
|
||||
self.start()
|
||||
|
||||
def __close_connection(self) -> None:
|
||||
if self._connection:
|
||||
self._connection.close()
|
||||
self._connection = None
|
||||
|
||||
def __get_connection(self) -> connection.Connection:
|
||||
|
@ -213,55 +201,31 @@ class Thread(threading.Thread):
|
|||
mb_res = musicbrainzngs.get_artist_by_id(artist.mbid)
|
||||
artist.name = mb_res["artist"]["name"]
|
||||
|
||||
def get_result(self, db: GObject.TYPE_PYOBJECT,
|
||||
library: playlist.Playlist) \
|
||||
-> tuple[pathlib.Path | None, Tags | None]:
|
||||
def do_get_result(self, result: thread.Data, db: GObject.TYPE_PYOBJECT,
|
||||
library: playlist.Playlist) -> tuple:
|
||||
"""Return the resulting Tags structure."""
|
||||
with self._condition:
|
||||
if not self.ready.is_set():
|
||||
return (None, None)
|
||||
tags = None if result.tags is None else Tags(db, result.tags, library)
|
||||
return (result.path, tags)
|
||||
|
||||
tags = Tags(db, self._tags, library) if self._tags else None
|
||||
res = (self._file, tags)
|
||||
self._file = None
|
||||
self._tags = None
|
||||
return res
|
||||
|
||||
def run(self) -> None:
|
||||
"""Sleep until we have work to do."""
|
||||
with self._condition:
|
||||
self.ready.set()
|
||||
|
||||
while self._condition.wait():
|
||||
if self._file is None:
|
||||
break
|
||||
|
||||
tags = emmental.audio.tagger.tag_file(self._file, self._mtime)
|
||||
def do_run_task(self, task: thread.Data) -> None:
|
||||
"""Tag a file."""
|
||||
tags = emmental.audio.tagger.tag_file(task.path, task.mtime)
|
||||
if tags is not None:
|
||||
for artist in tags.artists:
|
||||
self.__check_artist(artist)
|
||||
|
||||
self._tags = tags
|
||||
self.ready.set()
|
||||
self.set_result(path=task.path, tags=tags)
|
||||
|
||||
self.__close_connection()
|
||||
def do_stop(self) -> None:
|
||||
"""Close the connection before stopping."""
|
||||
if self._connection:
|
||||
self._connection.close()
|
||||
self._connection = None
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the thread."""
|
||||
with self._condition:
|
||||
self._file = None
|
||||
self._mtime = None
|
||||
self._condition.notify()
|
||||
self.join()
|
||||
|
||||
def tag_file(self, file: pathlib.Path, mtime: float | None) -> None:
|
||||
def tag_file(self, path: pathlib.Path,
|
||||
*, mtime: float | None = None) -> None:
|
||||
"""Tag a file."""
|
||||
with self._condition:
|
||||
self.ready.clear()
|
||||
self._file = file
|
||||
self._mtime = mtime
|
||||
self._tags = None
|
||||
self._condition.notify()
|
||||
self.set_task(path=path, mtime=mtime)
|
||||
|
||||
|
||||
def untag_track(db: GObject.TYPE_PYOBJECT, track: tracks.Track) -> None:
|
||||
|
|
|
@ -90,7 +90,7 @@ class Track(table.Row):
|
|||
return self.trackid
|
||||
|
||||
|
||||
class Filter(table.Filter):
|
||||
class Filter(table.KeySet):
|
||||
"""A customized Filter that never sets strictness to FilterMatch.All."""
|
||||
|
||||
def do_get_strictness(self) -> Gtk.FilterMatch:
|
||||
|
@ -200,6 +200,12 @@ class Table(table.Table):
|
|||
return self.sql(f"UPDATE tracks SET {column}=? WHERE trackid=?",
|
||||
newval, track.trackid)
|
||||
|
||||
def delete_listens(self, listenids: list[int]) -> None:
|
||||
"""Delete the listens indicated by the provided listenids."""
|
||||
self.sql.executemany("""DELETE FROM listenbrainz_queue
|
||||
WHERE listenid=?""",
|
||||
*[(id,) for id in listenids])
|
||||
|
||||
def get_artists(self, track: Track) -> list[table.Row]:
|
||||
"""Get the set of Artists for a specific Track."""
|
||||
rows = self.sql("""SELECT artistid FROM artist_tracks_view
|
||||
|
@ -212,6 +218,14 @@ class Table(table.Table):
|
|||
WHERE trackid=?""", track.trackid).fetchall()
|
||||
return [self.sql.genres.rows.get(row["genreid"]) for row in rows]
|
||||
|
||||
def get_n_listens(self, n: int) -> list[tuple]:
|
||||
"""Get the n most recent listens from the listenbrainz queue."""
|
||||
cur = self.sql("""SELECT listenid, trackid, timestamp
|
||||
FROM listenbrainz_queue ORDER BY timestamp DESC
|
||||
LIMIT ?""", n)
|
||||
return [(row["listenid"], self.rows[row["trackid"]], row["timestamp"])
|
||||
for row in cur.fetchall()]
|
||||
|
||||
def map_sort_order(self, ordering: str) -> dict[int, int]:
|
||||
"""Get a lookup table for Track sort keys."""
|
||||
ordering = ordering if len(ordering) > 0 else "trackid"
|
||||
|
@ -241,6 +255,7 @@ class Table(table.Table):
|
|||
track.active = True
|
||||
track.laststarted = cur.fetchone()["laststarted"]
|
||||
self.current_track = track
|
||||
self.sql.commit()
|
||||
|
||||
def stop_track(self, track: Track, played: bool) -> None:
|
||||
"""Mark that a Track has been stopped."""
|
||||
|
@ -269,6 +284,16 @@ class Table(table.Table):
|
|||
self.sql.playlists.most_played.reload_tracks(idle=True)
|
||||
self.sql.playlists.queued.remove_track(track)
|
||||
self.sql.playlists.unplayed.remove_track(track)
|
||||
self.emit("track-played", track)
|
||||
|
||||
self.sql.commit()
|
||||
|
||||
@GObject.Signal(arg_types=(Track,))
|
||||
def track_played(self, track: Track) -> None:
|
||||
"""Signal that a Track was played."""
|
||||
if track is not None:
|
||||
self.sql("""INSERT INTO listenbrainz_queue (trackid, timestamp)
|
||||
VALUES (?, ?)""", track.trackid, track.lastplayed)
|
||||
|
||||
|
||||
class TrackidSet(GObject.GObject):
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
/* Copyright 2023 (c) Anna Schumaker */
|
||||
|
||||
PRAGMA user_version = 2;
|
||||
|
||||
/*
|
||||
* The `saved_track_data` table is missing the date added field, which
|
||||
* causes restored tracks to show up in the "New Tracks" playlist again.
|
||||
* We can fix this by storing the date that the track was initially added
|
||||
* to the database, and restoring it later.
|
||||
*/
|
||||
|
||||
ALTER TABLE saved_track_data
|
||||
ADD COLUMN added DATE DEFAULT NULL;
|
||||
|
||||
UPDATE saved_track_data SET added = CURRENT_DATE;
|
||||
|
||||
DROP TRIGGER tracks_delete_save;
|
||||
CREATE TRIGGER tracks_delete_save BEFORE DELETE ON tracks
|
||||
WHEN OLD.mbid != "" BEGIN
|
||||
INSERT INTO saved_track_data
|
||||
(mbid, favorite, playcount, lastplayed, laststarted, added)
|
||||
VALUES (OLD.mbid, OLD.favorite, OLD.playcount,
|
||||
OLD.lastplayed, OLD.laststarted, OLD.added);
|
||||
END;
|
||||
|
||||
DROP TRIGGER tracks_insert_restore;
|
||||
CREATE TRIGGER tracks_insert_restore AFTER INSERT ON tracks
|
||||
WHEN NEW.mbid != "" BEGIN
|
||||
UPDATE tracks SET favorite = saved_track_data.favorite,
|
||||
playcount = saved_track_data.playcount,
|
||||
lastplayed = saved_track_data.lastplayed,
|
||||
laststarted = saved_track_data.laststarted,
|
||||
added = saved_track_data.added
|
||||
FROM saved_track_data
|
||||
WHERE tracks.mbid = saved_track_data.mbid AND
|
||||
tracks.mbid = NEW.mbid;
|
||||
DELETE FROM saved_track_data WHERE mbid = NEW.mbid;
|
||||
END;
|
|
@ -0,0 +1,25 @@
|
|||
/* Copyright 2024 (c) Anna Schumaker */
|
||||
|
||||
PRAGMA user_version = 3;
|
||||
|
||||
/*
|
||||
* The `listenbrainz_queue` table is used to store recently played tracks
|
||||
* before submitting them to ListenBrainz. This gives us some form of offline
|
||||
* recovery, since anything in this table needs to be submitted the next time
|
||||
* we can successfully connect. As a bonus, I prepopulate this table using
|
||||
* the last played data from tracks that have already been played when this
|
||||
* table is created.
|
||||
*/
|
||||
|
||||
CREATE TABLE listenbrainz_queue (
|
||||
listenid INTEGER PRIMARY KEY,
|
||||
trackid INTEGER REFERENCES tracks (trackid)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
INSERT INTO listenbrainz_queue (trackid, timestamp)
|
||||
SELECT trackid, lastplayed FROM tracks
|
||||
WHERE lastplayed IS NOT NULL;
|
|
@ -48,6 +48,8 @@ class Table(playlist.Table):
|
|||
|
||||
def do_sql_delete(self, year: Year) -> sqlite3.Cursor:
|
||||
"""Delete a year."""
|
||||
if year.parent is not None:
|
||||
year.parent.remove_year(year)
|
||||
return self.sql("DELETE FROM years WHERE year=?", year.year)
|
||||
|
||||
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
|
||||
|
@ -71,3 +73,10 @@ class Table(playlist.Table):
|
|||
"""Load a Year's Tracks from the database."""
|
||||
return self.sql("""SELECT trackid FROM year_tracks_view
|
||||
WHERE year=?""", year.year)
|
||||
|
||||
def create(self, *args, **kwargs) -> Year | None:
|
||||
"""Create a new Year playlist."""
|
||||
if (year := super().create(*args, **kwargs)) is not None:
|
||||
if year.parent is not None:
|
||||
year.parent.add_year(year)
|
||||
return year
|
||||
|
|
|
@ -21,29 +21,29 @@ box.emmental-splitbutton>menubutton>button {
|
|||
padding: 6px;
|
||||
}
|
||||
|
||||
listview > row:checked {
|
||||
row.emmental-active-row {
|
||||
font-weight: bold;
|
||||
background-color: alpha(@accent_color, 0.2);
|
||||
background-color: alpha(@accent_color, 0.15);
|
||||
}
|
||||
|
||||
listview > row:checked:hover {
|
||||
background-color: alpha(@accent_color, 0.27);
|
||||
row.emmental-active-row:hover {
|
||||
background-color: alpha(@accent_color, 0.22);
|
||||
}
|
||||
|
||||
listview > row:checked:active {
|
||||
background-color: alpha(@accent_color, 0.36);
|
||||
row.emmental-active-row:active {
|
||||
background-color: alpha(@accent_color, 0.31);
|
||||
}
|
||||
|
||||
listview > row:checked:selected {
|
||||
background-color: alpha(@accent_color, 0.3);
|
||||
row.emmental-active-row:selected {
|
||||
background-color: alpha(@accent_color, 0.25);
|
||||
}
|
||||
|
||||
listview > row:checked:selected:hover {
|
||||
background-color: alpha(@accent_color, 0.33);
|
||||
row.emmental-active-row:selected:hover {
|
||||
background-color: alpha(@accent_color, 0.28);
|
||||
}
|
||||
|
||||
listview > row:checked:selected:active {
|
||||
background-color: alpha(@accent_color, 0.39);
|
||||
row.emmental-active-row:selected:active {
|
||||
background-color: alpha(@accent_color, 0.34);
|
||||
}
|
||||
|
||||
image.emmental-sidebar-arrow {
|
||||
|
@ -70,6 +70,14 @@ button.emmental-stop>image {
|
|||
color: @red_3;
|
||||
}
|
||||
|
||||
columnview.emmental-track-list > header {
|
||||
background-color: @card_bg_color;
|
||||
}
|
||||
|
||||
columnview.emmental-track-list > listview {
|
||||
background-color: @card_bg_color;
|
||||
}
|
||||
|
||||
columnview.emmental-track-list > listview > row > cell {
|
||||
padding: 0px 2px;
|
||||
min-height: 40px;
|
||||
|
|
|
@ -61,17 +61,17 @@ class ListRow(GObject.GObject):
|
|||
@GObject.Property(type=bool, default=False)
|
||||
def active(self) -> bool:
|
||||
"""Get the active state of this Row."""
|
||||
if parent := self.listitem.get_child().get_parent():
|
||||
return parent.get_state_flags() & Gtk.StateFlags.CHECKED
|
||||
if self.listrow is not None:
|
||||
return self.listrow.has_css_class("emmental-active-row")
|
||||
return False
|
||||
|
||||
@active.setter
|
||||
def active(self, newval: bool) -> None:
|
||||
if parent := self.listitem.get_child().get_parent():
|
||||
if self.listrow is not None:
|
||||
if newval:
|
||||
parent.set_state_flags(Gtk.StateFlags.CHECKED, False)
|
||||
self.listrow.add_css_class("emmental-active-row")
|
||||
else:
|
||||
parent.unset_state_flags(Gtk.StateFlags.CHECKED)
|
||||
self.listrow.remove_css_class("emmental-active-row")
|
||||
|
||||
@GObject.Property(type=Gtk.Widget)
|
||||
def child(self) -> Gtk.Widget | None:
|
||||
|
@ -87,6 +87,11 @@ class ListRow(GObject.GObject):
|
|||
"""Get the list item for this Row."""
|
||||
return self.listitem.get_item()
|
||||
|
||||
@GObject.Property(type=Gtk.Widget)
|
||||
def listrow(self) -> Gtk.Widget:
|
||||
"""Get the listrow widget that our child widget is contained in."""
|
||||
return self.listitem.props.child.props.parent
|
||||
|
||||
|
||||
class InscriptionRow(ListRow):
|
||||
"""A ListRow for displaying Gtk.Inscription widgets."""
|
||||
|
|
|
@ -17,13 +17,16 @@ gi.importlib.import_module("gi.repository.Gtk")
|
|||
gi.importlib.import_module("gi.repository.Gst").init(sys.argv)
|
||||
|
||||
DEBUG_STR = "-debug" if __debug__ else ""
|
||||
APPLICATION_ID = f"com.nowheycreamery.emmental{'-debug' if __debug__ else ''}"
|
||||
APPLICATION_ID = f"com.nowheycreamery.emmental{DEBUG_STR}"
|
||||
|
||||
CSS_FILE = pathlib.Path(__file__).parent / "emmental.css"
|
||||
CSS_PRIORITY = gi.repository.Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
||||
CSS_PROVIDER = gi.repository.Gtk.CssProvider()
|
||||
CSS_PROVIDER.load_from_path(str(CSS_FILE))
|
||||
|
||||
CACHE_DIR = pathlib.Path(xdg.BaseDirectory.save_cache_path("emmental"))
|
||||
CACHE_DIR = CACHE_DIR / DEBUG_STR.lstrip("-")
|
||||
|
||||
DATA_DIR = pathlib.Path(xdg.BaseDirectory.save_data_path("emmental"))
|
||||
|
||||
RESOURCE_PATH = "/com/nowheycreamery/emmental"
|
||||
|
@ -40,26 +43,38 @@ def add_style():
|
|||
CSS_PROVIDER, CSS_PRIORITY)
|
||||
|
||||
|
||||
def __print_version(subsystem, major, minor, micro):
|
||||
print(f" ⋅ {subsystem} {major}.{minor}.{micro}")
|
||||
def has_icon(icon_name: str):
|
||||
"""Check if the icon theme has a specific icon."""
|
||||
display = gi.repository.Gdk.Display.get_default()
|
||||
theme = gi.repository.Gtk.IconTheme.get_for_display(display)
|
||||
return theme.has_icon(icon_name)
|
||||
|
||||
|
||||
def 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,
|
||||
def __version_string(subsystem, major, minor, micro):
|
||||
return f" ⋅ {subsystem} {major}.{minor}.{micro}"
|
||||
|
||||
|
||||
def env_string() -> str:
|
||||
"""Return a string with the version numbers of our dependencies."""
|
||||
gst = gi.repository.Gst.version()
|
||||
strs = [__version_string("Python", sys.version_info.major,
|
||||
sys.version_info.minor, sys.version_info.micro),
|
||||
__version_string("Gtk", gi.repository.Gtk.MAJOR_VERSION,
|
||||
gi.repository.Gtk.MINOR_VERSION,
|
||||
gi.repository.Gtk.MICRO_VERSION)
|
||||
__print_version("Libadwaita", gi.repository.Adw.MAJOR_VERSION,
|
||||
gi.repository.Gtk.MICRO_VERSION),
|
||||
__version_string("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.Adw.MICRO_VERSION),
|
||||
__version_string("GStreamer", gst.major, gst.minor, gst.micro),
|
||||
__version_string("Pango", gi.repository.Pango.VERSION_MAJOR,
|
||||
gi.repository.Pango.VERSION_MINOR,
|
||||
gi.repository.Pango.VERSION_MICRO)
|
||||
__print_version("SQLite", sqlite3.sqlite_version_info[0],
|
||||
gi.repository.Pango.VERSION_MICRO),
|
||||
__version_string("SQLite", sqlite3.sqlite_version_info[0],
|
||||
sqlite3.sqlite_version_info[1],
|
||||
sqlite3.sqlite_version_info[2])
|
||||
sqlite3.sqlite_version_info[2])]
|
||||
return "\n".join(strs)
|
||||
|
||||
|
||||
def print_env() -> None:
|
||||
"""Print the environment versions to stdout."""
|
||||
print(env_string())
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""A custom Gtk.HeaderBar configured for our application."""
|
||||
import pathlib
|
||||
import typing
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
from ..action import ActionEntry
|
||||
from .. import db
|
||||
from .. import buttons
|
||||
from .. import gsetup
|
||||
from . import listenbrainz
|
||||
from . import open
|
||||
from . import replaygain
|
||||
from . import volume
|
||||
|
@ -31,6 +35,10 @@ class Header(Gtk.HeaderBar):
|
|||
sql = GObject.Property(type=db.Connection)
|
||||
title = GObject.Property(type=str)
|
||||
subtitle = GObject.Property(type=str)
|
||||
listenbrainz_token = GObject.Property(type=str)
|
||||
show_sidebar = GObject.Property(type=bool, default=False)
|
||||
bg_enabled = GObject.Property(type=bool, default=False)
|
||||
bg_volume = GObject.Property(type=float, default=0.5)
|
||||
rg_enabled = GObject.Property(type=bool, default=False)
|
||||
rg_mode = GObject.Property(type=str, default="auto")
|
||||
volume = GObject.Property(type=float, default=1.0)
|
||||
|
@ -38,22 +46,56 @@ class Header(Gtk.HeaderBar):
|
|||
def __init__(self, sql: db.Connection, title: str):
|
||||
"""Initialize the HeaderBar."""
|
||||
super().__init__(title=title, subtitle=SUBTITLE, sql=sql)
|
||||
self._open = open.Button()
|
||||
self._title = Adw.WindowTitle(title=self.title, subtitle=self.subtitle)
|
||||
self._volume = volume.Controls()
|
||||
self._replaygain = replaygain.Selector()
|
||||
self._title = Adw.WindowTitle(title=self.title, subtitle=self.subtitle,
|
||||
tooltip_text=gsetup.env_string())
|
||||
|
||||
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 = "sidebar-show-symbolic"
|
||||
self._show_sidebar = Gtk.ToggleButton(icon_name=icon, has_frame=False)
|
||||
self._open = open.OpenRow()
|
||||
self._listenbrainz = listenbrainz.ListenBrainzRow()
|
||||
|
||||
icon = _volume_icon(self.volume)
|
||||
self._button = buttons.PopoverButton(popover_child=self._box,
|
||||
self._menu_box = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
|
||||
self._menu_box.add_css_class("boxed-list")
|
||||
self._menu_box.append(self._open)
|
||||
self._menu_box.append(self._listenbrainz)
|
||||
|
||||
if __debug__:
|
||||
self._settings = settings.Row(sql)
|
||||
self._menu_box.append(self._settings)
|
||||
|
||||
icon = "open-menu-symbolic"
|
||||
self._menu_button = buttons.PopoverButton(popover_child=self._menu_box,
|
||||
icon_name=icon)
|
||||
|
||||
self._volume = volume.VolumeRow()
|
||||
self._volume_icon = Gtk.Image(icon_name=_volume_icon(self.volume))
|
||||
self._background = volume.BackgroundRow()
|
||||
self._background_icon = Gtk.Image(icon_name="sound-wave")
|
||||
self._replaygain = replaygain.ReplayGainRow()
|
||||
|
||||
self._icons = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 6)
|
||||
self._icons.append(self._volume_icon)
|
||||
self._icons.append(self._background_icon)
|
||||
|
||||
self._vol_box = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
|
||||
self._vol_box.add_css_class("boxed-list")
|
||||
self._vol_box.append(self._volume)
|
||||
self._vol_box.append(self._background)
|
||||
self._vol_box.append(self._replaygain)
|
||||
|
||||
self._vol_button = buttons.PopoverButton(popover_child=self._vol_box,
|
||||
child=self._icons,
|
||||
has_frame=False, margin_end=6)
|
||||
|
||||
self.bind_property("title", self._title, "title")
|
||||
self.bind_property("subtitle", self._title, "subtitle")
|
||||
self.bind_property("listenbrainz-token", self._listenbrainz, "text")
|
||||
self.bind_property("show-sidebar", self._show_sidebar, "active",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.bind_property("bg-enabled", self._background, "enabled",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.bind_property("bg-volume", self._background, "volume",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.bind_property("rg-enabled", self._replaygain, "enabled",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.bind_property("rg-mode", self._replaygain, "mode",
|
||||
|
@ -61,30 +103,83 @@ class Header(Gtk.HeaderBar):
|
|||
self.bind_property("volume", self._volume, "volume",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
|
||||
self.pack_start(self._open)
|
||||
if __debug__:
|
||||
self._window = settings.Window(sql)
|
||||
self._settings = Gtk.Button.new_from_icon_name("settings-symbolic")
|
||||
self._settings.connect("clicked", self.__run_settings)
|
||||
self.pack_start(self._settings)
|
||||
self.pack_start(self._show_sidebar)
|
||||
self.pack_start(self._menu_button)
|
||||
|
||||
self.pack_end(self._button)
|
||||
self.pack_end(self._vol_button)
|
||||
self.set_title_widget(self._title)
|
||||
|
||||
self._menu_button.props.popover.connect("closed", self.__menu_closed)
|
||||
self._open.connect("track-requested", self.__track_requested)
|
||||
self.connect("notify::volume", self.__notify_volume)
|
||||
self._listenbrainz.connect("apply", self.__listenbrainz_apply)
|
||||
self.connect("notify", self.__notify)
|
||||
|
||||
def __run_settings(self, button: Gtk.Button) -> None:
|
||||
if __debug__:
|
||||
self._window.present()
|
||||
|
||||
def __notify_volume(self, header, param) -> None:
|
||||
self._button.set_icon_name(_volume_icon(self.volume))
|
||||
def __notify(self, header: typing.Self, param: GObject.ParamSpec) -> None:
|
||||
match param.name:
|
||||
case "bg-enabled":
|
||||
icon = "sound-wave-alt" if self.bg_enabled else "sound-wave"
|
||||
self._background_icon.set_from_icon_name(icon)
|
||||
case "volume":
|
||||
self._volume_icon.set_from_icon_name(_volume_icon(self.volume))
|
||||
|
||||
def __track_requested(self, button: open.Button,
|
||||
bg_status = "off"
|
||||
if self.bg_enabled:
|
||||
bg_status = f"{round(self.bg_volume * 100)}%"
|
||||
|
||||
rg_status = f"{self.rg_mode} mode" if self.rg_enabled else "off"
|
||||
status = (f"volume: {round(self.volume * 100)}%\n"
|
||||
f"background listening: {bg_status}\n"
|
||||
f"normalizing: {rg_status}")
|
||||
self._vol_button.set_tooltip_text(status)
|
||||
|
||||
def __listenbrainz_apply(self, entry: Adw.PasswordEntryRow) -> None:
|
||||
self.listenbrainz_token = entry.get_text()
|
||||
self._menu_button.popdown()
|
||||
|
||||
def __menu_closed(self, popover: Gtk.Popover) -> None:
|
||||
self._listenbrainz.props.text = self.listenbrainz_token
|
||||
|
||||
def __track_requested(self, button: open.OpenRow,
|
||||
path: pathlib.Path) -> None:
|
||||
self.emit("track-requested", path)
|
||||
|
||||
@GObject.Property(type=bool, default=True)
|
||||
def listenbrainz_token_valid(self) -> bool:
|
||||
"""Check if we think the listenbrainz token is valid."""
|
||||
return not self._listenbrainz.has_css_class("warning")
|
||||
|
||||
@listenbrainz_token_valid.setter
|
||||
def listenbrainz_token_valid(self, valid: bool) -> None:
|
||||
if valid:
|
||||
self._menu_button.remove_css_class("warning")
|
||||
self._listenbrainz.remove_css_class("warning")
|
||||
else:
|
||||
win = self.get_ancestor(Gtk.Window)
|
||||
win.post_toast("listenbrainz: user token is invalid")
|
||||
self._menu_button.add_css_class("warning")
|
||||
self._listenbrainz.add_css_class("warning")
|
||||
|
||||
@property
|
||||
def accelerators(self) -> list[ActionEntry]:
|
||||
"""Get a list of accelerators for the Header."""
|
||||
res = [ActionEntry("open-file", self._open.activate, "<Control>o"),
|
||||
ActionEntry("decrease-volume", self._volume.decrement,
|
||||
"<Shift><Control>Down"),
|
||||
ActionEntry("increase-volume", self._volume.increment,
|
||||
"<Shift><Control>Up"),
|
||||
ActionEntry("toggle-bg-mode", self._background.activate,
|
||||
"<Shift><Control>b"),
|
||||
ActionEntry("toggle-sidebar", self._show_sidebar.activate,
|
||||
"<Control>bracketright")]
|
||||
if __debug__:
|
||||
res.append(ActionEntry("edit-settings", self._settings.activate,
|
||||
"<Shift><Control>s"))
|
||||
return res
|
||||
|
||||
@GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT,))
|
||||
def track_requested(self, path: pathlib.Path) -> None:
|
||||
"""Signal that a track has been requested."""
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
# Copyright 2024 (c) Anna Schumaker.
|
||||
"""A custom Adw.PasswordEntryRow to set the user token."""
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
|
||||
|
||||
def ListenBrainzRow() -> Adw.PasswordEntryRow:
|
||||
"""Create a new PasswordEntryRow for entering the user token."""
|
||||
row = Adw.PasswordEntryRow(title="ListenBrainz User Token",
|
||||
show_apply_button=True)
|
||||
row.prefix = Gtk.Image(icon_name="listenbrainz-logo-symbolic")
|
||||
|
||||
row.add_prefix(row.prefix)
|
||||
return row
|
|
@ -1,18 +1,21 @@
|
|||
# Copyright 2023 (c) Anna Schumaker.
|
||||
"""A custom Button that opens a FileDialog to select a file for playback."""
|
||||
"""A custom Adw.ActionRow to select a file for playback."""
|
||||
import pathlib
|
||||
from gi.repository import GObject
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Gio
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
|
||||
|
||||
class Button(Gtk.Button):
|
||||
"""Our pre-configured open button."""
|
||||
class OpenRow(Adw.ActionRow):
|
||||
"""Our pre-configured open Adw.ActionRow."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize our open button."""
|
||||
super().__init__(icon_name="document-open-symbolic")
|
||||
"""Initialize our open ActionRow."""
|
||||
super().__init__(activatable=True, title="Open File",
|
||||
subtitle="Select a file for playback")
|
||||
self._prefix = Gtk.Image(icon_name="document-open-symbolic")
|
||||
self._filters = Gio.ListStore()
|
||||
self._filter = Gtk.FileFilter(name="Audio Files",
|
||||
mime_types=["inode/directory",
|
||||
|
@ -22,6 +25,9 @@ class Button(Gtk.Button):
|
|||
|
||||
self._filters.append(self._filter)
|
||||
|
||||
self.connect("activated", self.__on_activated)
|
||||
self.add_prefix(self._prefix)
|
||||
|
||||
def __async_ready(self, dialog: Gtk.FileDialog, task: Gio.Task) -> None:
|
||||
try:
|
||||
file = dialog.open_finish(task)
|
||||
|
@ -29,8 +35,9 @@ class Button(Gtk.Button):
|
|||
except GLib.Error:
|
||||
pass
|
||||
|
||||
def do_clicked(self) -> None:
|
||||
"""Handle a click event."""
|
||||
def __on_activated(self, row: Adw.ActionRow) -> None:
|
||||
"""Handle activating an OpenRow."""
|
||||
self.get_ancestor(Gtk.Popover).popdown()
|
||||
self._dialog.open(self.get_ancestor(Gtk.Window), None,
|
||||
self.__async_ready)
|
||||
|
||||
|
|
|
@ -2,9 +2,36 @@
|
|||
"""A widget for selecting ReplayGain mode."""
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
|
||||
|
||||
class Selector(Gtk.Grid):
|
||||
class CheckRow(Adw.ActionRow):
|
||||
"""A custom Adw.ActionRow displaying a Check Button."""
|
||||
|
||||
active = GObject.Property(type=bool, default=False)
|
||||
group = GObject.Property(type=Adw.ActionRow)
|
||||
mode = GObject.Property(type=str)
|
||||
|
||||
def __init__(self, mode: str, active: bool = False,
|
||||
group: Adw.ActionRow | None = None, **kwargs):
|
||||
"""Initialize the Check Row."""
|
||||
super().__init__(mode=mode, active=active, group=group, **kwargs)
|
||||
self._prefix = Gtk.CheckButton(active=active,
|
||||
group=group._prefix if group else None)
|
||||
|
||||
self.bind_property("active", self._prefix, "active",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
|
||||
self.set_activatable_widget(self._prefix)
|
||||
self.add_prefix(self._prefix)
|
||||
|
||||
def set_active(self, newval: bool) -> None:
|
||||
"""Set the active property."""
|
||||
if self.active != newval:
|
||||
self.active = newval
|
||||
|
||||
|
||||
class ReplayGainRow(Adw.ExpanderRow):
|
||||
"""Build up a widget for configuring ReplayGain settings."""
|
||||
|
||||
enabled = GObject.Property(type=bool, default=False)
|
||||
|
@ -12,42 +39,40 @@ class Selector(Gtk.Grid):
|
|||
|
||||
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)
|
||||
super().__init__(title="Volume Normalization",
|
||||
subtitle="Configure ReplayGain normalizing")
|
||||
self._switch = Gtk.Switch(valign=Gtk.Align.CENTER)
|
||||
self._automatic = CheckRow(title="Automatic Mode",
|
||||
subtitle="Emmental decides automatically",
|
||||
mode="auto", active=True)
|
||||
self._album = CheckRow(title="Album Mode",
|
||||
subtitle="Albums have the same volume",
|
||||
mode="album", group=self._automatic)
|
||||
self._track = CheckRow(title="Track Mode",
|
||||
subtitle="Tracks have the same volume",
|
||||
mode="track", group=self._automatic)
|
||||
|
||||
self.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.add_prefix(self._switch)
|
||||
self.add_row(self._automatic)
|
||||
self.add_row(self._album)
|
||||
self.add_row(self._track)
|
||||
|
||||
self.connect("notify::mode", self.__notify_mode)
|
||||
self._auto.connect("toggled", self.__mode_toggled, "auto")
|
||||
self._album.connect("toggled", self.__mode_toggled, "album")
|
||||
self._track.connect("toggled", self.__mode_toggled, "track")
|
||||
self._automatic.connect("notify::active", self.__row_activated)
|
||||
self._album.connect("notify::active", self.__row_activated)
|
||||
self._track.connect("notify::active", self.__row_activated)
|
||||
|
||||
self._switch.bind_property("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",
|
||||
self._switch.bind_property("active", self, "expanded",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.bind_property("enabled", self._switch, "active",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
|
||||
self._title.add_css_class("title-4")
|
||||
|
||||
def __notify_mode(self, selector: Gtk.Grid, param) -> None:
|
||||
match selector.get_property("mode"):
|
||||
def __notify_mode(self, row: Adw.ExpanderRow, param) -> None:
|
||||
match self.mode:
|
||||
case "album": self._album.set_active(True)
|
||||
case "track": self._track.set_active(True)
|
||||
case _: self._auto.set_active(True)
|
||||
case _: self._automatic.set_active(True)
|
||||
|
||||
def __mode_toggled(self, check: Gtk.CheckButton, new_mode: str) -> None:
|
||||
if check.get_active():
|
||||
self.mode = new_mode
|
||||
def __row_activated(self, row: CheckRow, param: GObject.ParamSpec) -> None:
|
||||
if row.active:
|
||||
self.mode = row.mode
|
||||
|
|
|
@ -64,3 +64,21 @@ class Window(Adw.Window):
|
|||
|
||||
def __filter(self, entry: entry.Filter) -> None:
|
||||
self._selection.get_model().filter(entry.get_query())
|
||||
|
||||
|
||||
class Row(Adw.ActionRow):
|
||||
"""An Adw.ActionRow for opening the Settings Window."""
|
||||
|
||||
def __init__(self, sql: db.Connection):
|
||||
"""Initialize our settings ActionRow."""
|
||||
super().__init__(activatable=True, title="Edit Settings",
|
||||
subtitle="Open the settings editor (debug only)")
|
||||
self._prefix = Gtk.Image(icon_name="settings-symbolic")
|
||||
self._window = Window(sql)
|
||||
|
||||
self.connect("activated", self.__on_activated)
|
||||
self.add_prefix(self._prefix)
|
||||
|
||||
def __on_activated(self, row: Adw.ActionRow) -> None:
|
||||
self.get_ancestor(Gtk.Popover).popdown()
|
||||
self._window.present()
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
"""A custom Gtk.Box with controls for adjusting the volume."""
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
|
||||
STEP_SIZE = 0.05
|
||||
|
||||
|
@ -11,42 +12,73 @@ def format_value_func(scale, value: float) -> str:
|
|||
return f"{round(value*100)} %"
|
||||
|
||||
|
||||
class Controls(Gtk.Box):
|
||||
class VolumeRow(Gtk.ListBoxRow):
|
||||
"""A Gtk.Box containing widgets for adjusting the volume."""
|
||||
|
||||
volume = GObject.Property(type=float, default=1.0)
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, volume: float = 1.0):
|
||||
"""Initialize our volume controls."""
|
||||
super().__init__()
|
||||
super().__init__(volume=volume)
|
||||
self._box = Gtk.Box()
|
||||
self._decrement = Gtk.Button(icon_name="list-remove-symbolic",
|
||||
tooltip_text="reduce the volume",
|
||||
valign=Gtk.Align.END, has_frame=False,
|
||||
margin_bottom=6)
|
||||
self._adjustment = Gtk.Adjustment.new(1.0, 0.0, 1.0, STEP_SIZE, 0, 0)
|
||||
margin_bottom=5)
|
||||
self._adjustment = Gtk.Adjustment.new(volume, 0.0, 1.0,
|
||||
STEP_SIZE, 0, 0)
|
||||
self._scale = Gtk.Scale(adjustment=self._adjustment, draw_value=True,
|
||||
valign=Gtk.Align.END, hexpand=True)
|
||||
self._increment = Gtk.Button(icon_name="list-add-symbolic",
|
||||
tooltip_text="increase the volume",
|
||||
valign=Gtk.Align.END, has_frame=False,
|
||||
margin_bottom=6)
|
||||
margin_bottom=5)
|
||||
|
||||
self._scale.set_format_value_func(format_value_func)
|
||||
|
||||
self.append(self._decrement)
|
||||
self.append(self._scale)
|
||||
self.append(self._increment)
|
||||
self._box.append(self._decrement)
|
||||
self._box.append(self._scale)
|
||||
self._box.append(self._increment)
|
||||
self.set_child(self._box)
|
||||
|
||||
self._decrement.connect("clicked", self.__decrement)
|
||||
self._decrement.connect("clicked", self.decrement)
|
||||
self._scale.connect("value-changed", self.__value_changed)
|
||||
self._increment.connect("clicked", self.__increment)
|
||||
self._increment.connect("clicked", self.increment)
|
||||
|
||||
self.bind_property("volume", self._adjustment, "value",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
|
||||
def __decrement(self, button: Gtk.Button) -> None:
|
||||
def decrement(self, button: Gtk.Button | None = None) -> None:
|
||||
"""Decrease the volume by STEP_SIZE."""
|
||||
self._scale.set_value(self._scale.get_value() - STEP_SIZE)
|
||||
|
||||
def __increment(self, button: Gtk.Button) -> None:
|
||||
def increment(self, button: Gtk.Button | None = None) -> None:
|
||||
"""Increase the volume by STEP_SIZE."""
|
||||
self._scale.set_value(self._scale.get_value() + STEP_SIZE)
|
||||
|
||||
def __value_changed(self, range: Gtk.Range) -> None:
|
||||
self.volume = range.get_value()
|
||||
|
||||
|
||||
class BackgroundRow(Adw.ExpanderRow):
|
||||
"""A VolumeRow for setting Background Listening volume."""
|
||||
|
||||
enabled = GObject.Property(type=bool, default=False)
|
||||
volume = GObject.Property(type=float, default=0.5)
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the BackgroundRow."""
|
||||
super().__init__(title="Background Listening",
|
||||
subtitle="Decrease the volume to help focus")
|
||||
self._switch = Gtk.Switch(valign=Gtk.Align.CENTER)
|
||||
self._volume = VolumeRow(volume=self.volume)
|
||||
|
||||
self.add_prefix(self._switch)
|
||||
self.add_row(self._volume)
|
||||
|
||||
self._switch.bind_property("active", self, "expanded",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.bind_property("enabled", self._switch, "active",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.bind_property("volume", self._volume, "volume",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
# Copyright 2023 (c) Anna Schumaker.
|
||||
"""Our adaptable layout that can rearrange widgets as the window is resized."""
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
|
||||
|
||||
MIN_WIDTH = Adw.BreakpointConditionLengthType.MIN_WIDTH
|
||||
|
||||
|
||||
class Layout(Adw.Bin):
|
||||
"""A widget that can rearrange based on window dimensions."""
|
||||
|
||||
show_sidebar = GObject.Property(type=bool, default=False)
|
||||
|
||||
wide_view = GObject.Property(type=bool, default=False)
|
||||
|
||||
def __init__(self, *, content: Gtk.Widget = None,
|
||||
sidebar: Gtk.Widget = None):
|
||||
"""Initialize our Layout widget."""
|
||||
super().__init__()
|
||||
self._split_view = Adw.OverlaySplitView(content=content,
|
||||
sidebar=sidebar,
|
||||
collapsed=not self.wide_view)
|
||||
self.props.child = self._split_view
|
||||
|
||||
self.bind_property("show-sidebar", self._split_view, "show-sidebar",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.bind_property("wide-view", self._split_view, "collapsed",
|
||||
GObject.BindingFlags.INVERT_BOOLEAN)
|
||||
|
||||
def __define_breakpoint(self, property: str, value: bool,
|
||||
length: int) -> Adw.Breakpoint:
|
||||
condition = Adw.BreakpointCondition.new_length(MIN_WIDTH, length,
|
||||
Adw.LengthUnit.SP)
|
||||
breakpoint = Adw.Breakpoint.new(condition)
|
||||
breakpoint.add_setter(self, property, GObject.Value(bool, value))
|
||||
return breakpoint
|
||||
|
||||
@GObject.Property(type=Gtk.Widget)
|
||||
def content(self) -> Gtk.Widget:
|
||||
"""Get the content widget for the Layout."""
|
||||
return self._split_view.props.content
|
||||
|
||||
@content.setter
|
||||
def content(self, widget: Gtk.Widget) -> None:
|
||||
self._split_view.props.content = widget
|
||||
|
||||
@GObject.Property(type=Gtk.Widget)
|
||||
def sidebar(self) -> Gtk.Widget:
|
||||
"""Get the sidebar widget for the Layout."""
|
||||
return self._split_view.props.sidebar
|
||||
|
||||
@sidebar.setter
|
||||
def sidebar(self, widget: Gtk.Widget) -> None:
|
||||
self._split_view.props.sidebar = widget
|
||||
|
||||
@property
|
||||
def breakpoints(self) -> list[Adw.Breakpoint]:
|
||||
"""Get a list of breakpoints supported by the layout."""
|
||||
return [self.__define_breakpoint("wide-view", True, 1000)]
|
|
@ -0,0 +1,114 @@
|
|||
# Copyright 2024 (c) Anna Schumaker.
|
||||
"""Our ListenBrainz custom GObject."""
|
||||
from gi.repository import GObject
|
||||
from gi.repository import GLib
|
||||
from .. import db
|
||||
from . import listen
|
||||
from . import thread
|
||||
from . import task
|
||||
|
||||
|
||||
class ListenBrainz(GObject.GObject):
|
||||
"""Our main ListenBrainz GObject."""
|
||||
|
||||
sql = GObject.Property(type=db.Connection)
|
||||
offline = GObject.Property(type=bool, default=True)
|
||||
user_token = GObject.Property(type=str)
|
||||
valid_token = GObject.Property(type=bool, default=True)
|
||||
now_playing = GObject.Property(type=db.tracks.Track)
|
||||
|
||||
def __init__(self, sql: db.Connection):
|
||||
"""Initialize the ListenBrainz GObject."""
|
||||
super().__init__(sql=sql)
|
||||
self._queue = task.Queue()
|
||||
self._thread = thread.Thread()
|
||||
|
||||
self._idle_id = None
|
||||
self._timeout_id = None
|
||||
|
||||
self.connect("notify::offline", self.__notify_offline)
|
||||
self.connect("notify::user-token", self.__notify_user_token)
|
||||
self.connect("notify::now-playing", self.__notify_now_playing)
|
||||
|
||||
def __check_connected(self) -> bool:
|
||||
return len(self.user_token) and self.valid_token and not self.offline
|
||||
|
||||
def __check_online(self) -> None:
|
||||
self.notify("user-token")
|
||||
|
||||
def __check_result(self) -> None:
|
||||
if (res := self._thread.get_result()) is not None:
|
||||
self.valid_token = res.valid
|
||||
self.offline = res.offline
|
||||
if res.op == "submit-listens" and self.valid_token \
|
||||
and not self.offline:
|
||||
listens = [lsn.listenid for lsn in res.listens]
|
||||
self.sql.tracks.delete_listens(listens)
|
||||
|
||||
def __parse_task(self, op: str, *args) -> bool:
|
||||
match op:
|
||||
case "clear-token":
|
||||
self._thread.clear_user_token()
|
||||
case "now-playing":
|
||||
self._thread.submit_now_playing(listen.Listen(*args))
|
||||
case "set-token":
|
||||
self._thread.set_user_token(*args)
|
||||
case "submit-listens":
|
||||
listens = self.sql.tracks.get_n_listens(50)
|
||||
if len(listens) == 0:
|
||||
self._idle_id = None
|
||||
return GLib.SOURCE_REMOVE
|
||||
self._thread.submit_listens([listen.Listen(trk, listenid=id,
|
||||
listened_at=ts)
|
||||
for (id, trk, ts) in listens])
|
||||
return GLib.SOURCE_CONTINUE
|
||||
|
||||
def __idle_work(self) -> bool:
|
||||
if self.sql.loaded and self._thread.ready.is_set():
|
||||
self.__check_result()
|
||||
return self.__parse_task(*self._queue.pop())
|
||||
return GLib.SOURCE_CONTINUE
|
||||
|
||||
def __idle_start(self) -> None:
|
||||
if self._idle_id is None:
|
||||
self._idle_id = GLib.idle_add(self.__idle_work)
|
||||
|
||||
def __notify_offline(self, listenbrainz: GObject.GObject,
|
||||
param: GObject.ParamSpec) -> None:
|
||||
if self.offline and self._timeout_id is None:
|
||||
self._timeout_id = GLib.timeout_add_seconds(300,
|
||||
self.__check_online)
|
||||
elif not self.offline and self._timeout_id is not None:
|
||||
self.__source_stop("_timeout_id")
|
||||
|
||||
def __notify_user_token(self, listenbrainz: GObject.GObject,
|
||||
param: GObject.ParamSpec) -> None:
|
||||
match self.user_token:
|
||||
case "": self._queue.push("clear-token")
|
||||
case _: self._queue.push("set-token", self.user_token)
|
||||
self.__idle_start()
|
||||
|
||||
def __notify_now_playing(self, listenbrainz: GObject.GObject,
|
||||
param: GObject.ParamSpec) -> None:
|
||||
if self.now_playing is not None:
|
||||
self._queue.push("now-playing", self.now_playing)
|
||||
if self.__check_connected():
|
||||
self.__idle_start()
|
||||
else:
|
||||
self._queue.clear("now-playing")
|
||||
|
||||
def __source_stop(self, srcid: str) -> None:
|
||||
if (id := getattr(self, srcid)) is not None:
|
||||
GLib.source_remove(id)
|
||||
setattr(self, srcid, None)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the ListenBrainz thread."""
|
||||
self.__source_stop("_idle_id")
|
||||
self.__source_stop("_timeout_id")
|
||||
self._thread.stop()
|
||||
|
||||
def submit_listens(self, *args) -> None:
|
||||
"""Submit recent listens to ListenBrainz."""
|
||||
if self.__check_connected():
|
||||
self.__idle_start()
|
|
@ -0,0 +1,28 @@
|
|||
# Copyright 2024 (c) Anna Schumaker.
|
||||
"""Convert a db.track.Track to a liblistenbrainz.Listen."""
|
||||
import datetime
|
||||
import dateutil.tz
|
||||
import liblistenbrainz
|
||||
from .. import db
|
||||
from .. import gsetup
|
||||
|
||||
|
||||
class Listen(liblistenbrainz.Listen):
|
||||
"""A single ListenBrainz Listen."""
|
||||
|
||||
def __init__(self, track: db.tracks.Track, *, listenid: int = None,
|
||||
listened_at: datetime.datetime = None):
|
||||
"""Initialize our Listen class."""
|
||||
album = track.get_medium().get_album()
|
||||
artists = [a.mbid for a in track.get_artists() if len(a.mbid) > 0]
|
||||
album_mbid = album.mbid if len(album.mbid) > 0 else None
|
||||
super().__init__(track.title, track.artist, release_name=album.name,
|
||||
artist_mbids=artists, release_group_mbid=album_mbid,
|
||||
tracknumber=track.number,
|
||||
additional_info={"media_player":
|
||||
f"emmental{gsetup.DEBUG_STR}"})
|
||||
self.listenid = listenid
|
||||
|
||||
if listened_at is not None:
|
||||
when = listened_at.replace(tzinfo=dateutil.tz.tzutc())
|
||||
self.listened_at = when.astimezone().timestamp()
|
|
@ -0,0 +1,31 @@
|
|||
# Copyright 2024 (c) Anna Schumaker.
|
||||
"""Our ListenBrainz operation priority queue."""
|
||||
|
||||
|
||||
class Queue:
|
||||
"""A queue for prioritizing ListenBrainz operations."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the task Queue."""
|
||||
self._set_token = None
|
||||
self._now_playing = None
|
||||
|
||||
def clear(self, op: str) -> None:
|
||||
"""Clear a pending operation."""
|
||||
match op:
|
||||
case "clear-token" | "set-token": self._set_token = None
|
||||
case "now-playing": self._now_playing = None
|
||||
|
||||
def push(self, op: str, *args) -> None:
|
||||
"""Push an operation onto the queue."""
|
||||
match op:
|
||||
case "clear-token" | "set-token": self._set_token = (op, *args)
|
||||
case "now-playing": self._now_playing = (op, *args)
|
||||
|
||||
def pop(self) -> tuple:
|
||||
"""Pop an operation off the queue."""
|
||||
if (res := self._set_token) is not None:
|
||||
self._set_token = None
|
||||
elif (res := self._now_playing) is not None:
|
||||
self._now_playing = None
|
||||
return res if res is not None else ("submit-listens",)
|
|
@ -0,0 +1,96 @@
|
|||
# Copyright 2024 (c) Anna Schumaker.
|
||||
"""Our ListenBrainz client thread."""
|
||||
import liblistenbrainz
|
||||
import requests
|
||||
from .. import thread
|
||||
|
||||
|
||||
class Thread(thread.Thread):
|
||||
"""Thread for submitting listens to ListenBrainz."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the ListenBrainz Thread object."""
|
||||
super().__init__()
|
||||
self._client = liblistenbrainz.client.ListenBrainz()
|
||||
|
||||
def __print(self, text: str) -> None:
|
||||
print(f"listenbrainz: {text}")
|
||||
|
||||
def __set_user_token(self, token: str) -> None:
|
||||
try:
|
||||
self._client.set_auth_token(token)
|
||||
self.set_result("set-token", token=token)
|
||||
except liblistenbrainz.errors.InvalidAuthTokenException:
|
||||
self.set_result("set-token", token=token, valid=False)
|
||||
except requests.exceptions.ConnectionError:
|
||||
self.set_result("set-token", token=token, offline=True)
|
||||
|
||||
def __submit_now_playing(self, listen: liblistenbrainz.Listen) -> None:
|
||||
try:
|
||||
self._client.submit_playing_now(listen)
|
||||
self.set_result("now-playing")
|
||||
except liblistenbrainz.errors.ListenBrainzAPIException:
|
||||
self.set_result("now-playing", valid=False)
|
||||
except requests.exceptions.ConnectionError:
|
||||
self.set_result("now-playing", offline=True)
|
||||
|
||||
def __submit_listens(self, listens: list[liblistenbrainz.Listen]) -> None:
|
||||
try:
|
||||
if len(listens) == 1:
|
||||
self._client.submit_single_listen(listens[0])
|
||||
else:
|
||||
self._client.submit_multiple_listens(listens)
|
||||
self.set_result("submit-listens", listens=listens)
|
||||
except liblistenbrainz.errors.ListenBrainzAPIException:
|
||||
self.set_result("submit-listens", listens=listens, valid=False)
|
||||
except requests.exceptions.ConnectionError:
|
||||
self.set_result("submit-listens", listens=listens, offline=True)
|
||||
|
||||
def do_run_task(self, task: thread.Data) -> None:
|
||||
"""Call a specific listenbrainz operation."""
|
||||
match task.op:
|
||||
case "clear-token":
|
||||
self._client.set_auth_token(None, check_validity=False)
|
||||
self.set_result("clear-token")
|
||||
case "now-playing":
|
||||
self.__submit_now_playing(task.listen)
|
||||
case "set-token":
|
||||
self.__set_user_token(task.token)
|
||||
case "submit-listens":
|
||||
self.__submit_listens(task.listens)
|
||||
|
||||
def clear_user_token(self) -> None:
|
||||
"""Schedule clearing the user token."""
|
||||
self.__print("clearing user token")
|
||||
self.set_task(op="clear-token")
|
||||
|
||||
def get_result(self, **kwargs) -> thread.Data:
|
||||
"""Get the result of a listenbrainz task."""
|
||||
if (res := super().get_result(**kwargs)) is not None:
|
||||
if not res.valid:
|
||||
self.__print("user token is invalid")
|
||||
if res.offline:
|
||||
self.__print("offline")
|
||||
return res
|
||||
|
||||
def set_result(self, op: str, *, valid: bool = True,
|
||||
offline: bool = False, **kwargs) -> None:
|
||||
"""Set the Thread result with a standard format for all ops."""
|
||||
super().set_result(op=op, valid=valid, offline=offline, **kwargs)
|
||||
|
||||
def set_user_token(self, token: str) -> None:
|
||||
"""Schedule setting the user token."""
|
||||
self.__print("setting user token")
|
||||
self.set_task(op="set-token", token=token)
|
||||
|
||||
def submit_now_playing(self, listen: liblistenbrainz.Listen) -> None:
|
||||
"""Schedule setting the now-playing track."""
|
||||
self.__print(f"now playing '{listen.track_name}' " +
|
||||
f"by '{listen.artist_name}'")
|
||||
self.set_task(op="now-playing", listen=listen)
|
||||
|
||||
def submit_listens(self, listens: list[liblistenbrainz.Listen]) -> None:
|
||||
"""Submit listens to listenbrainz."""
|
||||
num = len(listens)
|
||||
self.__print(f"submitting {num} listen{'s' if num != 1 else ''}")
|
||||
self.set_task(op="submit-listens", listens=listens)
|
|
@ -2,10 +2,11 @@
|
|||
"""Implement the MPRIS2 Specification."""
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gio
|
||||
from .. import gsetup
|
||||
from . import application
|
||||
from . import player
|
||||
|
||||
MPRIS2_ID = f"org.mpris.MediaPlayer2.emmental{'-debug' if __debug__ else ''}"
|
||||
MPRIS2_ID = f"org.mpris.MediaPlayer2.emmental{gsetup.DEBUG_STR}"
|
||||
|
||||
|
||||
class Connection(GObject.GObject):
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
"""A card for displaying information about the currently playing track."""
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
from ..action import ActionEntry
|
||||
from .. import buttons
|
||||
from . import artwork
|
||||
from . import controls
|
||||
|
@ -28,6 +29,7 @@ class Card(Gtk.Box):
|
|||
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)
|
||||
editing = GObject.Property(type=bool, default=False)
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize a Now Playing Card."""
|
||||
|
@ -39,12 +41,15 @@ class Card(Gtk.Box):
|
|||
self._bottom_box = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 0)
|
||||
self._favorite = buttons.ImageToggle("heart-filled",
|
||||
"heart-outline-thick-symbolic",
|
||||
icon_size=Gtk.IconSize.LARGE,
|
||||
"remove from 'Favorite Tracks'",
|
||||
"add to 'Favorite Tracks'",
|
||||
large_icon=True,
|
||||
has_frame=False, sensitive=False,
|
||||
valign=Gtk.Align.CENTER)
|
||||
self._jump = buttons.Button(icon_name="go-jump", has_frame=False,
|
||||
icon_size=Gtk.IconSize.LARGE,
|
||||
valign=Gtk.Align.CENTER, sensitive=False)
|
||||
self._jump = buttons.Button(icon_name="arrow4-down-symbolic",
|
||||
tooltip_text="scroll to current track",
|
||||
large_icon=True, sensitive=False,
|
||||
has_frame=False, valign=Gtk.Align.CENTER)
|
||||
self._seeker = seeker.Scale(sensitive=False)
|
||||
|
||||
self.bind_property("artwork", self._artwork, "filepath")
|
||||
|
@ -52,7 +57,8 @@ class Card(Gtk.Box):
|
|||
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"]:
|
||||
for prop in ["playing", "editing", "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")
|
||||
|
@ -90,6 +96,29 @@ class Card(Gtk.Box):
|
|||
value: float) -> None:
|
||||
self.emit("seek", value)
|
||||
|
||||
@property
|
||||
def accelerators(self) -> list[ActionEntry]:
|
||||
"""Get a list of accelerators for the Now Playing card."""
|
||||
return [ActionEntry("toggle-favorite", self._favorite.activate,
|
||||
"<Control>f", enabled=(self, "have-db-track")),
|
||||
ActionEntry("goto-current-track", self._jump.activate,
|
||||
"<Control>g", enabled=(self, "have-db-track")),
|
||||
ActionEntry("next-track", self._controls.activate_next,
|
||||
"Return", enabled=(self._controls,
|
||||
"can-activate-next")),
|
||||
ActionEntry("previous-track", self._controls.activate_previous,
|
||||
"BackSpace", enabled=(self._controls,
|
||||
"can-activate-prev")),
|
||||
ActionEntry("play-pause", self._controls.activate_play_pause,
|
||||
"space", enabled=(self._controls,
|
||||
"can-activate-play-pause")),
|
||||
ActionEntry("inc-autopause", self._controls.increase_autopause,
|
||||
"<Control>plus", "<Control>KP_Add",
|
||||
enabled=(self, "playing")),
|
||||
ActionEntry("dec-autopause", self._controls.decrease_autopause,
|
||||
"<Control>minus", "<Control>KP_Subtract",
|
||||
enabled=(self, "playing"))]
|
||||
|
||||
@GObject.Signal
|
||||
def jump(self) -> None:
|
||||
"""Signal that the Tracklist should be scrolled."""
|
||||
|
|
|
@ -8,39 +8,40 @@ 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."""
|
||||
class Artwork(Gtk.Picture):
|
||||
"""Our custom Album Art widget takes a pathlib.Path."""
|
||||
|
||||
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)
|
||||
super().__init__(content_fit=Gtk.ContentFit.CONTAIN,
|
||||
margin_top=6, margin_bottom=6,
|
||||
margin_start=6, margin_end=6,
|
||||
halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER)
|
||||
self._fullsize = Gtk.Picture(content_fit=Gtk.ContentFit.FILL)
|
||||
self._popover = Gtk.Popover(child=self._fullsize)
|
||||
self._clicked = Gtk.GestureClick()
|
||||
|
||||
self._popover = Gtk.Popover(child=self._fullsize)
|
||||
self._popover.set_parent(self)
|
||||
|
||||
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.add_css_class("card")
|
||||
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()
|
||||
name = self.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.set_filename(str(path))
|
||||
self._fullsize.set_filename(str(path))
|
||||
else:
|
||||
self._picture.set_resource(FALLBACK_RESOURCE)
|
||||
self.set_resource(FALLBACK_RESOURCE)
|
||||
self._fullsize.set_resource(FALLBACK_RESOURCE)
|
||||
|
||||
def clicked(self, gesture: Gtk.GestureClick, n_press: int,
|
||||
|
|
|
@ -25,6 +25,8 @@ class Entry(Gtk.Entry):
|
|||
self.connect("icon_release", self.__icon_release)
|
||||
self.connect("notify::value", self.__update_text)
|
||||
|
||||
self.add_css_class("card")
|
||||
|
||||
def __set_value(self, newval: int) -> bool:
|
||||
if -1 <= newval <= 99:
|
||||
self.value = newval
|
||||
|
@ -89,6 +91,16 @@ class Entry(Gtk.Entry):
|
|||
self.set_icon_sensitive(Gtk.EntryIconPosition.SECONDARY,
|
||||
self.value < 99)
|
||||
|
||||
def decrement(self) -> None:
|
||||
"""Decrease the autopause count by 1."""
|
||||
if self.value > -1:
|
||||
self.value -= 1
|
||||
|
||||
def increment(self) -> None:
|
||||
"""Increase the autopause count by 1."""
|
||||
if self.value < 99:
|
||||
self.value += 1
|
||||
|
||||
|
||||
class Button(buttons.PopoverButton):
|
||||
"""A PopoverButton that displays Autopause count."""
|
||||
|
@ -114,3 +126,11 @@ class Button(buttons.PopoverButton):
|
|||
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>")
|
||||
|
||||
def decrement(self) -> None:
|
||||
"""Decrease the autopause value."""
|
||||
self.popover_child.decrement()
|
||||
|
||||
def increment(self) -> None:
|
||||
"""Increase the autopause value."""
|
||||
self.popover_child.increment()
|
||||
|
|
|
@ -14,7 +14,7 @@ class PillButton(buttons.Button):
|
|||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize a Pill Button."""
|
||||
super().__init__(icon_size=Gtk.IconSize.LARGE, **kwargs)
|
||||
super().__init__(large_icon=True, **kwargs)
|
||||
self.add_css_class("pill")
|
||||
|
||||
|
||||
|
@ -28,6 +28,11 @@ class Controls(Gtk.Box):
|
|||
have_previous = GObject.Property(type=bool, default=False)
|
||||
have_track = GObject.Property(type=bool, default=False)
|
||||
|
||||
editing = GObject.Property(type=bool, default=False)
|
||||
can_activate_next = GObject.Property(type=bool, default=False)
|
||||
can_activate_prev = GObject.Property(type=bool, default=False)
|
||||
can_activate_play_pause = GObject.Property(type=bool, default=False)
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the Controls."""
|
||||
super().__init__(valign=Gtk.Align.START, homogeneous=True,
|
||||
|
@ -37,14 +42,16 @@ class Controls(Gtk.Box):
|
|||
|
||||
self._autopause = autopause.Button()
|
||||
self._prev = PillButton(icon_name="media-skip-backward",
|
||||
tooltip_text="previous track", sensitive=False)
|
||||
self._play = PillButton(icon_name="play-large", tooltip_text="play",
|
||||
sensitive=False)
|
||||
self._play = PillButton(icon_name="play-large", sensitive=False)
|
||||
self._pause = buttons.SplitButton(icon_name="pause-large",
|
||||
icon_size=Gtk.IconSize.LARGE,
|
||||
large_icon=True,
|
||||
tooltip_text="pause",
|
||||
secondary=self._autopause,
|
||||
visible=False, sensitive=False)
|
||||
self._next = PillButton(icon_name="media-skip-forward",
|
||||
sensitive=False)
|
||||
tooltip_text="next track", sensitive=False)
|
||||
|
||||
for button in [self._prev, self._play, self._pause, self._next]:
|
||||
self.append(button)
|
||||
|
@ -63,19 +70,59 @@ class Controls(Gtk.Box):
|
|||
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.connect("notify", self.__notify)
|
||||
|
||||
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:
|
||||
def __notify_playing(self, controls: Gtk.Box, 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
|
||||
|
||||
def __notify(self, controls: Gtk.Box, param) -> None:
|
||||
match param.name:
|
||||
case "editing":
|
||||
allowed = not self.editing
|
||||
self.can_activate_next = self.have_next and allowed
|
||||
self.can_activate_prev = self.have_previous and allowed
|
||||
self.can_activate_play_pause = self.have_track and allowed
|
||||
case "playing": self.__notify_playing(controls, param)
|
||||
case "have-next":
|
||||
self.can_activate_next = self.have_next and not self.editing
|
||||
case "have-previous":
|
||||
can_activate = self.have_previous and not self.editing
|
||||
self.can_activate_prev = can_activate
|
||||
case "have-track":
|
||||
can_activate = self.have_track and not self.editing
|
||||
self.can_activate_play_pause = can_activate
|
||||
|
||||
def activate_next(self) -> None:
|
||||
"""Activate the Next button."""
|
||||
self._next.activate()
|
||||
|
||||
def activate_previous(self) -> None:
|
||||
"""Activate the Previous button."""
|
||||
self._prev.activate()
|
||||
|
||||
def activate_play_pause(self) -> None:
|
||||
"""Activate the Play or Pause button."""
|
||||
if self.playing:
|
||||
self._pause.activate()
|
||||
else:
|
||||
self._play.activate()
|
||||
|
||||
def decrease_autopause(self) -> None:
|
||||
"""Decrease the autopause count."""
|
||||
self._autopause.decrement()
|
||||
|
||||
def increase_autopause(self) -> None:
|
||||
"""Increase the autopause count."""
|
||||
self._autopause.increment()
|
||||
|
||||
@GObject.Signal
|
||||
def previous(self) -> None:
|
||||
"""Signals that the Previous button has been clicked."""
|
||||
|
|
|
@ -6,7 +6,7 @@ Version = GLib.OptionEntry()
|
|||
Version.long_name = "version"
|
||||
Version.short_name = ord("v")
|
||||
Version.flags = GLib.OptionFlags.NONE
|
||||
Version.arg = GLib.OptionArg.NONE
|
||||
# Version.arg = GLib.OptionArg.NONE
|
||||
Version.arg_data = None
|
||||
Version.description = "Print version information and exit"
|
||||
Version.arg_description = None
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""A card for displaying the list of playlists."""
|
||||
from gi.repository import GObject
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Gtk
|
||||
from . import artist
|
||||
from . import decade
|
||||
|
@ -8,6 +9,7 @@ from . import genre
|
|||
from . import library
|
||||
from . import playlist
|
||||
from . import section
|
||||
from ..action import ActionEntry
|
||||
from .. import db
|
||||
from .. import entry
|
||||
|
||||
|
@ -23,58 +25,93 @@ class Card(Gtk.Box):
|
|||
"""Set up the Sidebar widget."""
|
||||
super().__init__(sql=sql, orientation=Gtk.Orientation.VERTICAL,
|
||||
sensitive=False, **kwargs)
|
||||
self._filter = entry.Filter("playlists")
|
||||
self._header = Gtk.CenterBox()
|
||||
self._filter = entry.Filter("playlists", hexpand=True)
|
||||
self._jump = Gtk.Button(icon_name="arrow4-down-symbolic",
|
||||
tooltip_text="scroll to current playlist")
|
||||
self._playlists = playlist.Section(self.sql.playlists)
|
||||
self._artists = artist.Section(self.sql.artists, self.sql.albums)
|
||||
self._genres = genre.Section(self.sql.genres)
|
||||
self._decades = decade.Section(self.sql.decades, self.sql.years)
|
||||
self._libraries = library.Section(self.sql.libraries)
|
||||
self._group = section.Group(sql)
|
||||
self._view = section.View(sql)
|
||||
|
||||
self.append(self._filter)
|
||||
self._header.set_center_widget(self._filter)
|
||||
self._header.set_end_widget(self._jump)
|
||||
self.append(self._header)
|
||||
|
||||
for sect in [self._playlists, self._artists, self._genres,
|
||||
self._decades, self._libraries]:
|
||||
self.append(sect)
|
||||
self._group.add(sect)
|
||||
self._view.add(sect)
|
||||
self.append(self._view)
|
||||
|
||||
self._group.bind_property("selected-playlist",
|
||||
self._view.bind_property("selected-playlist",
|
||||
self, "selected-playlist")
|
||||
self.bind_property("show-all-artists", self._artists, "show-all",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
|
||||
self._filter.connect("search-changed", self.__search_changed)
|
||||
self.sql.connect("table-loaded", self.__table_loaded)
|
||||
self._jump.connect("clicked", self.__jump_to_playlist)
|
||||
self.sql.connect("notify::loaded", self.__database_loaded)
|
||||
|
||||
self.add_css_class("background")
|
||||
self.add_css_class("linked")
|
||||
self._header.add_css_class("toolbar")
|
||||
self.add_css_class("card")
|
||||
|
||||
def __jump_to_playlist(self, jump: Gtk.Button) -> None:
|
||||
self.select_playlist(self.sql.active_playlist)
|
||||
|
||||
def __search_changed(self, entry: entry.Filter) -> None:
|
||||
self.sql.filter(entry.get_query())
|
||||
|
||||
def __table_loaded(self, sql: db.Connection, table: db.table.Table):
|
||||
if self.get_sensitive() is False:
|
||||
if False not in {tbl.loaded for tbl in sql.playlist_tables()}:
|
||||
self.set_sensitive(True)
|
||||
self.select_playlist(sql.active_playlist)
|
||||
def __database_loaded(self, sql: db.Connection, param: GObject.ParamSpec):
|
||||
self.set_sensitive(sql.loaded)
|
||||
if sql.loaded is True:
|
||||
self.select_playlist(sql.active_playlist, 150)
|
||||
if len(sql.libraries) == 0:
|
||||
self._libraries.extra_widget.emit("clicked")
|
||||
|
||||
def select_playlist(self, playlist: db.playlist.Playlist) -> None:
|
||||
"""Set the current active playlist."""
|
||||
def __select_playlist(self, playlist: db.playlist.Playlist) -> bool:
|
||||
if playlist is not None:
|
||||
match playlist.table:
|
||||
case self.sql.playlists:
|
||||
section = self._playlists
|
||||
case self.sql.artists | self.sql.albums | self.sql.media:
|
||||
section = self._artists
|
||||
case self.sql.genres:
|
||||
section = self._genres
|
||||
case self.sql.decades | self.sql.years:
|
||||
section = self._decades
|
||||
case self.sql.libraries:
|
||||
section = self._libraries
|
||||
|
||||
section = self.table_section(playlist.table)
|
||||
if not section.active:
|
||||
section.active = True
|
||||
return GLib.SOURCE_CONTINUE
|
||||
section.select_playlist(playlist)
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
def select_playlist(self, playlist: db.playlist.Playlist,
|
||||
timeout: int = 0) -> None:
|
||||
"""Set the current active playlist."""
|
||||
GLib.timeout_add(timeout, self.__select_playlist, playlist)
|
||||
|
||||
def table_section(self, table: db.playlist.Table) -> section.Section:
|
||||
"""Get the Section associated with a specific Playlist Table."""
|
||||
match table:
|
||||
case self.sql.playlists:
|
||||
return self._playlists
|
||||
case self.sql.artists | self.sql.albums | self.sql.media:
|
||||
return self._artists
|
||||
case self.sql.genres:
|
||||
return self._genres
|
||||
case self.sql.decades | self.sql.years:
|
||||
return self._decades
|
||||
case self.sql.libraries:
|
||||
return self._libraries
|
||||
|
||||
@property
|
||||
def accelerators(self) -> list[ActionEntry]:
|
||||
"""Get a list of accelerators for the Sidebar."""
|
||||
return [ActionEntry("focus-search-playlist", self._filter.grab_focus,
|
||||
"<Control>question", enabled=(self, "sensitive")),
|
||||
ActionEntry("goto-active-playlist", self._jump.activate,
|
||||
"<Control><Alt>g", enabled=(self, "sensitive")),
|
||||
ActionEntry("goto-playlists", self._playlists.activate,
|
||||
"<Shift><Control>p", enabled=(self, "sensitive")),
|
||||
ActionEntry("goto-artists", self._artists.activate,
|
||||
"<Shift><Control>a", enabled=(self, "sensitive")),
|
||||
ActionEntry("goto-genres", self._genres.activate,
|
||||
"<Shift><Control>g", enabled=(self, "sensitive")),
|
||||
ActionEntry("goto-decades", self._decades.activate,
|
||||
"<Shift><Control>d", enabled=(self, "sensitive")),
|
||||
ActionEntry("goto-libraries", self._libraries.activate,
|
||||
"<Shift><Control>l", enabled=(self, "sensitive"))]
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""Displays our artist and album tree."""
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
from ..buttons import ImageToggle
|
||||
from .. import db
|
||||
from . import row
|
||||
|
@ -36,8 +35,9 @@ class Section(section.Section):
|
|||
subtitle="0 artists, 0 albums",
|
||||
icon_name="library-artists", album_table=album_table)
|
||||
self.extra_widget = ImageToggle("music-artist", "music-artist2",
|
||||
icon_size=Gtk.IconSize.NORMAL,
|
||||
has_frame=False)
|
||||
"show album artists",
|
||||
"show all artists",
|
||||
large_icon=False, has_frame=False)
|
||||
self.album_table.connect("items-changed", self.__update_subtitle)
|
||||
self.bind_property("show-all", self.extra_widget, "active",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
|
|
|
@ -55,7 +55,7 @@ class Header(Gtk.Box):
|
|||
self.bind_property("reveal-widget", self._revealer, "child")
|
||||
self.bind_property("animation", self._revealer, "transition-type")
|
||||
|
||||
self._clicked.connect("released", self.__clicked)
|
||||
self._clicked.connect("released", self.activate)
|
||||
self.connect("notify::active", self.__notify_active)
|
||||
|
||||
self._box.append(self._icon)
|
||||
|
@ -70,12 +70,12 @@ class Header(Gtk.Box):
|
|||
self.append(self._overlay)
|
||||
self.append(self._revealer)
|
||||
|
||||
def __clicked(self, gesture: Gtk.GestureClick, n_press: int,
|
||||
x: int, y: int) -> None:
|
||||
self.active = True
|
||||
|
||||
def __notify_active(self, header, param) -> None:
|
||||
if self.active:
|
||||
self._arrow.set_state_flags(Gtk.StateFlags.CHECKED, False)
|
||||
else:
|
||||
self._arrow.unset_state_flags(Gtk.StateFlags.CHECKED)
|
||||
|
||||
def activate(self, *args) -> None:
|
||||
"""Activate the Header."""
|
||||
self.active = True
|
||||
|
|
|
@ -4,9 +4,9 @@ import pathlib
|
|||
from gi.repository import GObject
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Gio
|
||||
from gi.repository import Gdk
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
from .. import texture
|
||||
|
||||
|
||||
IMAGE_FILTERS = Gio.ListStore()
|
||||
|
@ -37,11 +37,7 @@ class Icon(Adw.Bin):
|
|||
self.set_child(self._icon)
|
||||
|
||||
def __notify_filepath(self, icon: Adw.Bin, param) -> None:
|
||||
if self.filepath is None:
|
||||
texture = None
|
||||
else:
|
||||
texture = Gdk.Texture.new_from_filename(str(self.filepath))
|
||||
self._icon.set_custom_image(texture)
|
||||
self._icon.set_custom_image(texture.CACHE[self.filepath])
|
||||
|
||||
|
||||
class Settable(Icon):
|
||||
|
@ -61,7 +57,9 @@ class Settable(Icon):
|
|||
def __async_ready(self, dialog: Gtk.FileDialog, task: Gio.Task) -> None:
|
||||
try:
|
||||
file = dialog.open_finish(task)
|
||||
self.filepath = pathlib.Path(file.get_path())
|
||||
path = pathlib.Path(file.get_path())
|
||||
texture.CACHE.drop(path)
|
||||
self.filepath = path
|
||||
except GLib.Error:
|
||||
self.filepath = None
|
||||
|
||||
|
|
|
@ -39,9 +39,10 @@ class Section(section.Section):
|
|||
|
||||
def __init__(self, table=db.libraries.Table):
|
||||
"""Initialize our library path section."""
|
||||
super().__init__(table, LibraryRow, icon_name="library-music",
|
||||
super().__init__(table, LibraryRow, icon_name="library-music-symbolic",
|
||||
title="Library Paths", subtitle="0 library paths")
|
||||
self.extra_widget = Gtk.Button(icon_name="folder-new", has_frame=False)
|
||||
self.extra_widget = Gtk.Button(icon_name="folder-new", has_frame=False,
|
||||
tooltip_text="add new library path")
|
||||
self._dialog = Gtk.FileDialog(title="Pick a Directory",
|
||||
filters=DIRECTORY_FILTERS)
|
||||
self._toast = None
|
||||
|
|
|
@ -50,15 +50,19 @@ class Section(section.Section):
|
|||
self._entry = Gtk.Entry(placeholder_text="add new playlist",
|
||||
primary_icon_name="list-add")
|
||||
self.extra_widget = buttons.PopoverButton(icon_name="document-new",
|
||||
tooltip_text=("add new "
|
||||
"playlist"),
|
||||
has_frame=False,
|
||||
popover_child=self._entry)
|
||||
|
||||
self._entry.connect("activate", self.__add_new_playlist)
|
||||
self._entry.connect("changed", self.__entry_changed)
|
||||
self._entry.connect("icon-release", self.__entry_icon_release)
|
||||
self._entry.add_css_class("card")
|
||||
|
||||
def __add_new_playlist(self, entry: Gtk.Entry) -> None:
|
||||
if self.table.create(entry.get_text()) is not None:
|
||||
self.table.sql.commit()
|
||||
self.extra_widget.popdown()
|
||||
|
||||
def __entry_changed(self, entry: Gtk.Entry) -> None:
|
||||
|
|
|
@ -63,6 +63,7 @@ class PlaylistRow(BaseRow):
|
|||
self._icon = Settable()
|
||||
self._title = EditableTitle(margin_start=12, margin_end=12)
|
||||
self._delete = Gtk.Button(icon_name="big-x-symbolic",
|
||||
tooltip_text="delete playlist",
|
||||
valign=Gtk.Align.CENTER,
|
||||
has_frame=False, visible=False)
|
||||
|
||||
|
@ -109,13 +110,17 @@ class LibraryRow(BaseRow):
|
|||
super().__init__(**kwargs)
|
||||
self._box = Gtk.Box()
|
||||
self._overlay = Gtk.Overlay(child=self._box)
|
||||
self._switch = Gtk.Switch(active=self.enabled, valign=Gtk.Align.CENTER)
|
||||
self._switch = Gtk.Switch(active=self.enabled, valign=Gtk.Align.CENTER,
|
||||
tooltip_text="disable library path")
|
||||
self._title = PlaylistTitle(margin_start=12, margin_end=12)
|
||||
self._scan = Gtk.Button(icon_name="update", has_frame=False,
|
||||
tooltip_text="update library path",
|
||||
valign=Gtk.Align.CENTER)
|
||||
self._stop = Gtk.Button(icon_name="stop-sign-large", has_frame=False,
|
||||
tooltip_text="cancel update",
|
||||
valign=Gtk.Align.CENTER, visible=False)
|
||||
self._delete = Gtk.Button(icon_name="big-x-symbolic",
|
||||
tooltip_text="delete library path",
|
||||
valign=Gtk.Align.CENTER, has_frame=False)
|
||||
self._progress = Gtk.ProgressBar(valign=Gtk.Align.END, visible=False)
|
||||
|
||||
|
@ -137,6 +142,7 @@ class LibraryRow(BaseRow):
|
|||
self._delete.connect("clicked", self.__on_button_press, "delete")
|
||||
self._scan.connect("clicked", self.__on_button_press, "scan")
|
||||
self._stop.connect("clicked", self.__on_button_press, "stop")
|
||||
self._switch.connect("notify::active", self.__on_switch_activated)
|
||||
|
||||
self._delete.add_css_class("emmental-delete")
|
||||
self._stop.add_css_class("emmental-stop")
|
||||
|
@ -157,6 +163,10 @@ class LibraryRow(BaseRow):
|
|||
case "scan": self.playlist.scan()
|
||||
case "stop": self.playlist.stop()
|
||||
|
||||
def __on_switch_activated(self, switch: Gtk.Switch, param) -> None:
|
||||
state = "disable" if self.enabled else "enable"
|
||||
self._switch.set_tooltip_text(f"{state} library path")
|
||||
|
||||
|
||||
class TreeRow(factory.TreeRow):
|
||||
"""A factory Row used for displaying individual playlists."""
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
"""A sidebar Header attached to a hidden ListView for selecting playlists."""
|
||||
import typing
|
||||
from gi.repository import GObject
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Gtk
|
||||
from .. import db
|
||||
from .. import factory
|
||||
|
@ -86,9 +85,7 @@ class Section(header.Header):
|
|||
def select_playlist(self, playlist: db.playlist.Playlist) -> None:
|
||||
"""Select the requested playlist."""
|
||||
if (index := self.playlist_index(playlist)) is not None:
|
||||
self._selection.select_item(index, True)
|
||||
self._listview.activate_action("list.scroll-to-item",
|
||||
GLib.Variant.new_uint32(index))
|
||||
self._listview.scroll_to(index, Gtk.ListScrollFlags.SELECT)
|
||||
|
||||
@GObject.Signal(arg_types=(db.playlist.Playlist,))
|
||||
def playlist_activated(self, playlist: db.playlist.Playlist):
|
||||
|
@ -99,8 +96,8 @@ class Section(header.Header):
|
|||
"""Signal that the selected playlist has changed."""
|
||||
|
||||
|
||||
class Group(GObject.GObject):
|
||||
"""A group of sections."""
|
||||
class View(Gtk.Box):
|
||||
"""A widget for displaying a group of sections."""
|
||||
|
||||
sql = GObject.Property(type=db.Connection)
|
||||
current = GObject.Property(type=Section)
|
||||
|
@ -108,8 +105,8 @@ class Group(GObject.GObject):
|
|||
selected_playlist = GObject.Property(type=db.playlist.Playlist)
|
||||
|
||||
def __init__(self, sql: db.Connection):
|
||||
"""Initialize a Section Group."""
|
||||
super().__init__(sql=sql)
|
||||
"""Initialize a Section View."""
|
||||
super().__init__(sql=sql, orientation=Gtk.Orientation.VERTICAL)
|
||||
self._sections = []
|
||||
|
||||
def __on_active(self, section: Section, param: GObject.ParamSpec) -> None:
|
||||
|
@ -145,6 +142,7 @@ class Group(GObject.GObject):
|
|||
def add(self, section: Section) -> None:
|
||||
"""Add a section to the group."""
|
||||
self._sections.append(section)
|
||||
self.append(section)
|
||||
section.connect("notify::active", self.__on_active)
|
||||
section.connect("playlist-activated", self.__playlist_activated)
|
||||
section.connect("playlist-selected", self.__playlist_selected)
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
# Copyright 2023 (c) Anna Schumaker.
|
||||
"""A cache to hold Gdk.Textures used by cover art."""
|
||||
import pathlib
|
||||
import sys
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Gdk
|
||||
|
||||
|
||||
if "unittest" in sys.modules:
|
||||
import tempfile
|
||||
TEMP_DIR = tempfile.TemporaryDirectory(prefix="emmental-")
|
||||
CACHE_PATH = pathlib.Path(TEMP_DIR.name)
|
||||
else:
|
||||
from . import gsetup
|
||||
CACHE_PATH = gsetup.CACHE_DIR
|
||||
|
||||
CACHE_PATH.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
class _TextureCache(dict):
|
||||
"""A custom dictionary for storing texture files."""
|
||||
|
||||
def __check_update_cache(self, path: pathlib.Path) -> Gdk.Texture | None:
|
||||
if path.is_file() \
|
||||
and (cache_path := self.__get_cache_path(path)).exists() \
|
||||
and cache_path.stat().st_mtime < path.stat().st_mtime:
|
||||
self.__drop(path, cache_path)
|
||||
return self.__load_new_item(path, cache_path)
|
||||
|
||||
def __drop(self, path: pathlib.Path, cache_path: pathlib.Path) -> None:
|
||||
self.pop(path, None)
|
||||
cache_path.unlink(missing_ok=True)
|
||||
|
||||
def __get_cache_path(self, path: pathlib.Path) -> pathlib.Path:
|
||||
return CACHE_PATH / path.absolute().relative_to("/")
|
||||
|
||||
def __load_cached_item(self, path: pathlib.Path,
|
||||
cache_path: pathlib.Path) -> Gdk.Texture:
|
||||
texture = Gdk.Texture.new_from_filename(str(cache_path))
|
||||
self.__setitem__(path, texture)
|
||||
return texture
|
||||
|
||||
def __load_new_item(self, path: pathlib.Path,
|
||||
cache_path: pathlib.Path) -> Gdk.Texture:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("rb") as f_path:
|
||||
bytes = f_path.read()
|
||||
with cache_path.open("wb") as f_cache:
|
||||
f_cache.write(bytes)
|
||||
texture = Gdk.Texture.new_from_bytes(GLib.Bytes.new(bytes))
|
||||
self.__setitem__(path, texture)
|
||||
return texture
|
||||
|
||||
def __get_missing_item(self, path: pathlib.Path,
|
||||
cache_path: pathlib.Path) -> Gdk.Texture:
|
||||
if cache_path.is_file():
|
||||
return self.__load_cached_item(path, cache_path)
|
||||
elif path.is_file():
|
||||
return self.__load_new_item(path, cache_path)
|
||||
|
||||
def __missing__(self, path: pathlib.Path | None) -> Gdk.Texture:
|
||||
"""Load a cache item from disk or add a new item entirely."""
|
||||
return self.__get_missing_item(path, self.__get_cache_path(path))
|
||||
|
||||
def __getitem__(self, path: pathlib.Path | None) -> Gdk.Texture | None:
|
||||
"""Get a Gdk.Texture cache item from the cache."""
|
||||
if path is not None:
|
||||
texture = self.__check_update_cache(path)
|
||||
return super().__getitem__(path) if texture is None else texture
|
||||
|
||||
def drop(self, path: pathlib.Path | None) -> None:
|
||||
"""Drop a single cache item from the cache."""
|
||||
self.__drop(path, self.__get_cache_path(path))
|
||||
|
||||
|
||||
CACHE = _TextureCache()
|
|
@ -0,0 +1,94 @@
|
|||
# Copyright 2024 (c) Anna Schumaker.
|
||||
"""A Thread class designed to easily sync up with the main thread."""
|
||||
import threading
|
||||
|
||||
|
||||
class Data:
|
||||
"""A class for holding generic fields inspired by SimpleNamespace."""
|
||||
|
||||
def __init__(self, values_dict: dict = {}, **kwargs):
|
||||
"""Initialize our Data class."""
|
||||
self.__dict__.update(values_dict | kwargs)
|
||||
|
||||
def __eq__(self, rhs: any) -> bool:
|
||||
"""Compare two Data classes."""
|
||||
if isinstance(rhs, Data):
|
||||
return self.__dict__ == rhs.__dict__
|
||||
elif isinstance(rhs, dict):
|
||||
return self.__dict__ == rhs
|
||||
return False
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Get a string representation of the Data."""
|
||||
items = (f"{k}={v!r}" for k, v in self.__dict__.items())
|
||||
return f"{type(self).__name__}({', '.join(items)})"
|
||||
|
||||
|
||||
class Thread(threading.Thread):
|
||||
"""A worker Thread class that is easy to sync up with the main thread."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize our worker Thread object."""
|
||||
super().__init__()
|
||||
self.ready = threading.Event()
|
||||
|
||||
self._condition = threading.Condition()
|
||||
self._task = None
|
||||
self._result = None
|
||||
|
||||
self.start()
|
||||
|
||||
def do_get_result(self, result: Data, **kwargs) -> Data:
|
||||
"""Get the result of the task."""
|
||||
return self._result
|
||||
|
||||
def do_run_task(self, task: Data) -> None:
|
||||
"""Run the task."""
|
||||
self.set_result()
|
||||
|
||||
def do_stop(self) -> None:
|
||||
"""Extra work when stopping the thread."""
|
||||
|
||||
def get_result(self, **kwargs) -> Data:
|
||||
"""Get the result of the current task."""
|
||||
with self._condition:
|
||||
if not self.ready.is_set() or self._result is None:
|
||||
return None
|
||||
|
||||
res = self.do_get_result(self._result, **kwargs)
|
||||
self._result = None
|
||||
return res
|
||||
|
||||
def run(self) -> None:
|
||||
"""Wait for a task to run."""
|
||||
with self._condition:
|
||||
self.ready.set()
|
||||
|
||||
while self._condition.wait():
|
||||
if self._task is None:
|
||||
self.do_stop()
|
||||
break
|
||||
|
||||
self.do_run_task(self._task)
|
||||
|
||||
def set_result(self, **kwargs: dict) -> None:
|
||||
"""Set the result of the task."""
|
||||
self._result = Data(kwargs)
|
||||
self.ready.set()
|
||||
|
||||
def __set_task(self, task: Data | None) -> None:
|
||||
"""Set the task to be run by the thread."""
|
||||
with self._condition:
|
||||
self.ready.clear()
|
||||
self._task = task
|
||||
self._result = None
|
||||
self._condition.notify()
|
||||
|
||||
def set_task(self, **kwargs: dict) -> None:
|
||||
"""Set the task to be run by the thread."""
|
||||
self.__set_task(Data(kwargs))
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the thread."""
|
||||
self.__set_task(None)
|
||||
self.join()
|
|
@ -4,12 +4,14 @@ from gi.repository import GObject
|
|||
from gi.repository import GLib
|
||||
from gi.repository import Gio
|
||||
from gi.repository import Gtk
|
||||
from ..action import ActionEntry
|
||||
from ..playlist.playlist import Playlist
|
||||
from ..playlist.previous import Previous
|
||||
from .. import db
|
||||
from .. import entry
|
||||
from . import buttons
|
||||
from . import footer
|
||||
from . import selection
|
||||
from . import trackview
|
||||
|
||||
|
||||
|
@ -21,23 +23,24 @@ class Card(Gtk.Box):
|
|||
def __init__(self, sql: db.Connection, **kwargs):
|
||||
"""Set up the Tracklist widget."""
|
||||
super().__init__(sql=sql, orientation=Gtk.Orientation.VERTICAL,
|
||||
spacing=6, **kwargs)
|
||||
**kwargs)
|
||||
self._top_left = Gtk.Box()
|
||||
self._top_right = Gtk.Box(sensitive=False)
|
||||
self._top_box = Gtk.CenterBox(margin_top=6, margin_start=6,
|
||||
margin_end=6)
|
||||
self._top_box = Gtk.CenterBox(margin_start=6, margin_end=6)
|
||||
self._filter = entry.Filter("tracks", hexpand=True,
|
||||
margin_start=100, margin_end=100)
|
||||
self._trackview = trackview.TrackView(sql, margin_start=6,
|
||||
margin_end=6)
|
||||
self._trackview = trackview.TrackView(sql)
|
||||
self._osd = selection.OSD(sql, self._trackview.selection_model,
|
||||
child=self._trackview)
|
||||
self._visible_cols = buttons.VisibleColumns(self._trackview.columns)
|
||||
self._unselect = Gtk.Button(icon_name="edit-select-none-symbolic",
|
||||
tooltip_text="unselect all tracks",
|
||||
has_frame=False, sensitive=False)
|
||||
self._loop = buttons.LoopButton()
|
||||
self._shuffle = buttons.ShuffleButton()
|
||||
self._sort = buttons.SortButton()
|
||||
self._footer = footer.Footer(margin_start=6, margin_end=6,
|
||||
margin_bottom=6)
|
||||
margin_top=6, margin_bottom=6)
|
||||
|
||||
self._top_left.append(self._visible_cols)
|
||||
self._top_left.append(self._unselect)
|
||||
|
@ -51,9 +54,14 @@ class Card(Gtk.Box):
|
|||
self._top_box.set_end_widget(self._top_right)
|
||||
|
||||
self.append(self._top_box)
|
||||
self.append(self._trackview)
|
||||
self.append(Gtk.Separator())
|
||||
self.append(self._osd)
|
||||
self.append(Gtk.Separator())
|
||||
self.append(self._footer)
|
||||
|
||||
self._osd.bind_property("have-selected", self._trackview,
|
||||
"have-selected")
|
||||
self._osd.bind_property("n-selected", self._trackview, "n-selected")
|
||||
self._trackview.bind_property("n-tracks", self._footer, "count")
|
||||
self._trackview.bind_property("n-selected", self._footer, "selected")
|
||||
self._trackview.bind_property("runtime", self._footer, "runtime")
|
||||
|
@ -62,16 +70,14 @@ class Card(Gtk.Box):
|
|||
"sensitive")
|
||||
|
||||
self._filter.connect("search-changed", self.__search_changed)
|
||||
self._unselect.connect("clicked", self.__clear_selection)
|
||||
self._unselect.connect("clicked", self._osd.clear_selection)
|
||||
self._loop.connect("notify::state", self.__update_loop_state)
|
||||
self._shuffle.connect("notify::active", self.__update_shuffle_state)
|
||||
self._sort.connect("notify::sort-order", self.__update_sort_order)
|
||||
|
||||
self._top_box.add_css_class("toolbar")
|
||||
self.add_css_class("card")
|
||||
|
||||
def __clear_selection(self, unselect: Gtk.Button) -> None:
|
||||
self._trackview.clear_selected_tracks()
|
||||
|
||||
def __playlist_notify(self, playlist: Playlist, param) -> None:
|
||||
match param.name:
|
||||
case "loop":
|
||||
|
@ -89,7 +95,7 @@ class Card(Gtk.Box):
|
|||
self._loop.state = self.playlist.loop
|
||||
self._shuffle.active = self.playlist.shuffle
|
||||
self._sort.set_sort_order(self.playlist.sort_order)
|
||||
self._trackview.reset_osd()
|
||||
self._osd.reset()
|
||||
|
||||
def __update_loop_state(self, loop: buttons.LoopButton, param) -> None:
|
||||
if self.playlist.loop != loop.state:
|
||||
|
@ -132,7 +138,24 @@ class Card(Gtk.Box):
|
|||
self._trackview.playlist.disconnect_by_func(self.__playlist_notify)
|
||||
|
||||
self._trackview.playlist = newval
|
||||
self._osd.playlist = newval
|
||||
|
||||
if newval is not None:
|
||||
self._top_right.set_sensitive(not isinstance(newval, Previous))
|
||||
self.__set_button_state()
|
||||
newval.connect("notify", self.__playlist_notify)
|
||||
|
||||
@property
|
||||
def accelerators(self) -> list[ActionEntry]:
|
||||
"""Get a list of accelerators for the Tracklist."""
|
||||
return [ActionEntry("focus-search-track", self._filter.grab_focus,
|
||||
"<Control>slash"),
|
||||
ActionEntry("clear-selected-tracks", self._unselect.activate,
|
||||
"Escape", enabled=(self._unselect, "sensitive")),
|
||||
ActionEntry("cycle-loop", self._loop.activate,
|
||||
"<Control>l", enabled=(self._top_right,
|
||||
"sensitive")),
|
||||
ActionEntry("toggle-shuffle", self._shuffle.activate,
|
||||
"<Control>s", enabled=(self._top_right,
|
||||
"sensitive"))] + \
|
||||
self._osd.accelerators
|
||||
|
|
|
@ -5,19 +5,29 @@ from gi.repository import Gio
|
|||
from gi.repository import Gtk
|
||||
from . import sorter
|
||||
from .. import buttons
|
||||
from .. import factory
|
||||
from .. import gsetup
|
||||
|
||||
|
||||
class VisibleSwitch(factory.ListRow):
|
||||
"""A list row containing a Gtk.Switch."""
|
||||
class VisibleRow(Gtk.ListBoxRow):
|
||||
"""A ListBoxRow containing a Gtk.Switch and a title Label."""
|
||||
|
||||
def __init__(self, listitem: Gtk.ListItem):
|
||||
"""Initialize a VisibleSwitch ListRow."""
|
||||
super().__init__(listitem=listitem, child=Gtk.Switch())
|
||||
active = GObject.Property(type=bool, default=True)
|
||||
title = GObject.Property(type=str)
|
||||
|
||||
def do_bind(self) -> None:
|
||||
"""Bind the visible property to the switch active property."""
|
||||
self.bind_and_set_property("visible", "active", bidirectional=True)
|
||||
def __init__(self, title: str, active: bool):
|
||||
"""Initialize a VisibleRow ListBoxRow."""
|
||||
super().__init__(title=title, active=active,
|
||||
child=Gtk.Box(margin_start=6, margin_end=6,
|
||||
margin_top=6, margin_bottom=6,
|
||||
spacing=6))
|
||||
self._switch = Gtk.Switch(active=active)
|
||||
self._label = Gtk.Label.new(title)
|
||||
|
||||
self.bind_property("active", self._switch, "active",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
|
||||
self.props.child.append(self._switch)
|
||||
self.props.child.append(self._label)
|
||||
|
||||
|
||||
class VisibleColumns(buttons.PopoverButton):
|
||||
|
@ -28,21 +38,21 @@ class VisibleColumns(buttons.PopoverButton):
|
|||
def __init__(self, columns: Gio.ListModel, **kwargs):
|
||||
"""Initialize the VisibleColumns button."""
|
||||
super().__init__(columns=columns, icon_name="columns-symbolic",
|
||||
tooltip_text="configure visible columns",
|
||||
has_frame=False, **kwargs)
|
||||
self._selection = Gtk.NoSelection(model=self.columns)
|
||||
self.popover_child = Gtk.ColumnView(model=self._selection,
|
||||
show_row_separators=True)
|
||||
self.__append_column(factory.InscriptionFactory("title"),
|
||||
"Column", width=125)
|
||||
self.__append_column(factory.Factory(row_type=VisibleSwitch),
|
||||
"Visible")
|
||||
self.popover_child.add_css_class("data-table")
|
||||
self.popover_child = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
|
||||
self.popover_child.bind_model(columns, self.__create_func)
|
||||
self.popover_child.connect("row-activated", self.__row_activated)
|
||||
self.popover_child.add_css_class("boxed-list")
|
||||
|
||||
def __append_column(self, factory: factory.Factory,
|
||||
title: str, *, width: int = -1) -> None:
|
||||
column = Gtk.ColumnViewColumn(factory=factory, title=title,
|
||||
fixed_width=width)
|
||||
self.popover_child.append_column(column)
|
||||
def __create_func(self, column: Gtk.ColumnViewColumn) -> VisibleRow:
|
||||
row = VisibleRow(column.get_title(), column.get_visible())
|
||||
row.bind_property("active", column, "visible",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
return row
|
||||
|
||||
def __row_activated(self, box: Gtk.ListBox, row: Gtk.ListBoxRow) -> None:
|
||||
row.active = not row.active
|
||||
|
||||
|
||||
class LoopButton(buttons.ImageToggle):
|
||||
|
@ -53,8 +63,10 @@ class LoopButton(buttons.ImageToggle):
|
|||
def __init__(self, **kwargs):
|
||||
"""Initialize a Loop Button."""
|
||||
super().__init__(active_icon_name="media-playlist-repeat-song",
|
||||
active_tooltip_text="loop: track",
|
||||
inactive_icon_name="media-playlist-repeat",
|
||||
icon_size=Gtk.IconSize.NORMAL, state="None",
|
||||
inactive_tooltip_text="loop: disabled",
|
||||
large_icon=False, state="None",
|
||||
has_frame=False, **kwargs)
|
||||
|
||||
def do_clicked(self):
|
||||
|
@ -79,9 +91,11 @@ class LoopButton(buttons.ImageToggle):
|
|||
case ("None", True):
|
||||
self.active = False
|
||||
self.icon_opacity = 0.5
|
||||
self.inactive_tooltip_text = "loop: disabled"
|
||||
case ("Playlist", _):
|
||||
self.active = False
|
||||
self.icon_opacity = 1.0
|
||||
self.inactive_tooltip_text = "loop: playlist"
|
||||
case ("Track", _):
|
||||
self.active = True
|
||||
self.icon_opacity = 1.0
|
||||
|
@ -93,91 +107,75 @@ class ShuffleButton(buttons.ImageToggle):
|
|||
def __init__(self, **kwargs):
|
||||
"""Initialize a Shuffle Button."""
|
||||
super().__init__(active_icon_name="media-playlist-shuffle",
|
||||
inactive_icon_name="media-playlist-consecutive",
|
||||
icon_size=Gtk.IconSize.NORMAL, icon_opacity=0.5,
|
||||
active_tooltip_text="shuffle: enabled",
|
||||
inactive_icon_name=self.get_inactive_icon(),
|
||||
inactive_tooltip_text="shuffle: disabled",
|
||||
large_icon=False, icon_opacity=0.5,
|
||||
has_frame=False, **kwargs)
|
||||
|
||||
def do_toggled(self):
|
||||
"""Adjust opacity when active state toggles."""
|
||||
self.icon_opacity = 1.0 if self.active else 0.5
|
||||
self.inactive_icon_name = self.get_inactive_icon()
|
||||
|
||||
def get_inactive_icon(self) -> str:
|
||||
"""Return the inactive icon name."""
|
||||
if gsetup.has_icon("media-playlist-normal"):
|
||||
return "media-playlist-normal"
|
||||
return "media-playlist-consecutive"
|
||||
|
||||
|
||||
class SortFieldWidget(Gtk.Box):
|
||||
"""A Widget to display in the Sort Order button popover."""
|
||||
class SortRow(Gtk.ListBoxRow):
|
||||
"""A ListBoxRow for managing Sort Order."""
|
||||
|
||||
active = GObject.Property(type=bool, default=False)
|
||||
sort_field = GObject.Property(type=sorter.SortField)
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize a SortField Widget."""
|
||||
super().__init__(spacing=6)
|
||||
self._enabled = Gtk.Switch(valign=Gtk.Align.CENTER)
|
||||
self._name = Gtk.Label(hexpand=True, sensitive=False)
|
||||
def __init__(self, sort_field: sorter.SortField):
|
||||
"""Initialize a Sort Row."""
|
||||
super().__init__(sort_field=sort_field, active=sort_field.enabled,
|
||||
child=Gtk.Box(margin_start=6, margin_end=6,
|
||||
margin_top=6, margin_bottom=6,
|
||||
spacing=6))
|
||||
self._switch = Gtk.Switch(active=self.active, valign=Gtk.Align.CENTER)
|
||||
self._label = Gtk.Label(label=sort_field.name, hexpand=True,
|
||||
sensitive=self.active, xalign=0.0)
|
||||
self._reverse = buttons.ImageToggle("arrow1-up", "arrow1-down",
|
||||
icon_size=Gtk.IconSize.NORMAL,
|
||||
sensitive=False)
|
||||
self._box = Gtk.Box(sensitive=False)
|
||||
active=sort_field.reversed,
|
||||
sensitive=self.active,
|
||||
has_frame=False)
|
||||
self._move_box = Gtk.Box(sensitive=self.active)
|
||||
self._move_up = Gtk.Button(icon_name="go-up-symbolic")
|
||||
self._move_down = Gtk.Button(icon_name="go-down-symbolic")
|
||||
|
||||
self._enabled.bind_property("active", self._name, "sensitive")
|
||||
self._enabled.bind_property("active", self._reverse, "sensitive")
|
||||
self._enabled.bind_property("active", self._box, "sensitive")
|
||||
self._switch.connect("notify::active", self.__toggle_enabled)
|
||||
self._reverse.connect("toggled", self.__reverse)
|
||||
self._move_up.connect("clicked", self.__move_up)
|
||||
self._move_down.connect("clicked", self.__move_down)
|
||||
|
||||
self._enabled.connect("notify::active", self.__notify_enabled)
|
||||
self._reverse.connect("clicked", self.__reverse)
|
||||
self._move_up.connect("clicked", self.__move_item_up)
|
||||
self._move_down.connect("clicked", self.__move_item_down)
|
||||
self.props.child.append(self._switch)
|
||||
self.props.child.append(self._label)
|
||||
self.props.child.append(self._reverse)
|
||||
self.props.child.append(self._move_box)
|
||||
|
||||
self.append(self._enabled)
|
||||
self.append(self._name)
|
||||
self.append(self._reverse)
|
||||
self.append(self._box)
|
||||
self._move_box.append(self._move_up)
|
||||
self._move_box.append(self._move_down)
|
||||
self._move_box.add_css_class("linked")
|
||||
|
||||
self._box.append(self._move_up)
|
||||
self._box.append(self._move_down)
|
||||
self._box.add_css_class("linked")
|
||||
|
||||
def __move_item_down(self, button: Gtk.Button) -> None:
|
||||
if self.sort_field is not None:
|
||||
self.sort_field.move_down()
|
||||
|
||||
def __move_item_up(self, button: Gtk.Button) -> None:
|
||||
if self.sort_field is not None:
|
||||
self.sort_field.move_up()
|
||||
|
||||
def __notify_enabled(self, switch: Gtk.Switch, param) -> None:
|
||||
if self.sort_field is not None:
|
||||
if switch.get_active():
|
||||
def __toggle_enabled(self, switch: Gtk.Switch, param) -> None:
|
||||
if switch.props.active:
|
||||
self.sort_field.enable()
|
||||
else:
|
||||
self.sort_field.disable()
|
||||
|
||||
def __reverse(self, button: buttons.ImageToggle) -> None:
|
||||
if self.sort_field is not None:
|
||||
self.sort_field.reverse()
|
||||
|
||||
def set_sort_field(self, field: sorter.SortField | None) -> None:
|
||||
"""Set the Sort Field displayed by this Widget."""
|
||||
self.sort_field = field
|
||||
self._name.set_text(field.name if field is not None else "")
|
||||
self._enabled.set_active(field is not None and field.enabled)
|
||||
self._reverse.active = field is not None and field.reversed
|
||||
def __move_up(self, button: Gtk.Button) -> None:
|
||||
self.sort_field.move_up()
|
||||
|
||||
|
||||
class SortRow(factory.ListRow):
|
||||
"""A row for managing Sort Order."""
|
||||
|
||||
def __init__(self, listitem: Gtk.ListItem):
|
||||
"""Initialize a Sort Row."""
|
||||
super().__init__(listitem=listitem, child=SortFieldWidget())
|
||||
|
||||
def do_bind(self) -> None:
|
||||
"""Bind Sort Field properties to the Widget."""
|
||||
self.child.set_sort_field(self.item)
|
||||
|
||||
def do_unbind(self) -> None:
|
||||
"""Unbind properties from the widget."""
|
||||
self.child.set_sort_field(None)
|
||||
def __move_down(self, button: Gtk.Button) -> None:
|
||||
self.sort_field.move_down()
|
||||
|
||||
|
||||
class SortButton(buttons.PopoverButton):
|
||||
|
@ -189,14 +187,24 @@ class SortButton(buttons.PopoverButton):
|
|||
def __init__(self, **kwargs):
|
||||
"""Initialize the Sort button."""
|
||||
super().__init__(has_frame=False, model=sorter.SortOrderModel(),
|
||||
icon_name="view-list-ordered-symbolic", **kwargs)
|
||||
self._selection = Gtk.NoSelection(model=self.model)
|
||||
self._factory = factory.Factory(row_type=SortRow)
|
||||
self.popover_child = Gtk.ListView(model=self._selection,
|
||||
factory=self._factory,
|
||||
show_separators=True)
|
||||
tooltip_text="configure playlist sort order",
|
||||
icon_name="list-compact-symbolic", **kwargs)
|
||||
self.popover_child = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
|
||||
self.popover_child.bind_model(self.model, self.__create_func)
|
||||
self.popover_child.connect("row-activated", self.__row_activated)
|
||||
self.popover_child.add_css_class("boxed-list")
|
||||
|
||||
self.model.bind_property("sort-order", self, "sort-order")
|
||||
|
||||
def __create_func(self, sort_field: sorter.SortField) -> SortRow:
|
||||
return SortRow(sort_field)
|
||||
|
||||
def __row_activated(self, box: Gtk.ListBox, row: SortRow) -> None:
|
||||
if row.active:
|
||||
row._reverse.active = not row.sort_field.reversed
|
||||
else:
|
||||
row.sort_field.enable()
|
||||
|
||||
def set_sort_order(self, newval: str) -> None:
|
||||
"""Directly set the sort order."""
|
||||
self.model.set_sort_order(newval)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""A Footer widget to display below the TrackView."""
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Pango
|
||||
from gi.repository import Gtk
|
||||
|
||||
|
||||
|
@ -14,9 +15,11 @@ class Footer(Gtk.CenterBox):
|
|||
def __init__(self, **kwargs):
|
||||
"""Initialize a Footer widget."""
|
||||
super().__init__(**kwargs)
|
||||
self._count = Gtk.Label(label="Showing 0 tracks", xalign=0.0)
|
||||
self._selected = Gtk.Label()
|
||||
self._runtime = Gtk.Label(label="0 seconds", xalign=1.0)
|
||||
self._count = Gtk.Label(label="Showing 0 tracks", xalign=0.0,
|
||||
ellipsize=Pango.EllipsizeMode.START)
|
||||
self._selected = Gtk.Label(ellipsize=Pango.EllipsizeMode.MIDDLE)
|
||||
self._runtime = Gtk.Label(label="0 seconds", xalign=1.0,
|
||||
ellipsize=Pango.EllipsizeMode.END)
|
||||
|
||||
self.set_start_widget(self._count)
|
||||
self.set_center_widget(self._selected)
|
||||
|
|
|
@ -4,10 +4,10 @@ import datetime
|
|||
import dateutil.tz
|
||||
import pathlib
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gdk
|
||||
from gi.repository import Gtk
|
||||
from .. import buttons
|
||||
from .. import factory
|
||||
from .. import texture
|
||||
|
||||
|
||||
class TrackRow(factory.ListRow):
|
||||
|
@ -63,23 +63,6 @@ class TrackRow(factory.ListRow):
|
|||
else:
|
||||
self.bind_album(child_prop)
|
||||
|
||||
@GObject.Property(type=bool, default=False)
|
||||
def active(self) -> bool:
|
||||
"""Get the active state of this Row."""
|
||||
if parent := self.listitem.get_child().get_parent():
|
||||
if parent := parent.get_parent():
|
||||
return parent.get_state_flags() & Gtk.StateFlags.CHECKED
|
||||
return False
|
||||
|
||||
@active.setter
|
||||
def active(self, newval: bool) -> None:
|
||||
if parent := self.listitem.get_child().get_parent():
|
||||
if parent := parent.get_parent():
|
||||
if newval:
|
||||
parent.set_state_flags(Gtk.StateFlags.CHECKED, False)
|
||||
else:
|
||||
parent.unset_state_flags(Gtk.StateFlags.CHECKED)
|
||||
|
||||
@GObject.Property(type=bool, default=True)
|
||||
def online(self) -> bool:
|
||||
"""Get the online state of this Row."""
|
||||
|
@ -90,6 +73,14 @@ class TrackRow(factory.ListRow):
|
|||
self.listitem.set_activatable(newval)
|
||||
self.child.set_sensitive(newval)
|
||||
|
||||
@GObject.Property(type=Gtk.Widget)
|
||||
def listrow(self) -> Gtk.Widget:
|
||||
"""Test property for active track styling."""
|
||||
if child := self.listitem.props.child:
|
||||
if cell := child.props.parent:
|
||||
return cell.props.parent
|
||||
return None
|
||||
|
||||
|
||||
class InscriptionRow(TrackRow):
|
||||
"""Base class for Track Rows displaying a Gtk.Inscription."""
|
||||
|
@ -287,8 +278,6 @@ class MediumString(InscriptionRow):
|
|||
class AlbumCover(TrackRow):
|
||||
"""A Track Row to display Album art."""
|
||||
|
||||
Cache = dict()
|
||||
|
||||
filepath = GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||
|
||||
def __init__(self, listitem: Gtk.ListItem, property: str):
|
||||
|
@ -302,19 +291,14 @@ class AlbumCover(TrackRow):
|
|||
match param.name:
|
||||
case "mediumid": self.rebind_album("filepath", to_self=True)
|
||||
case "filepath":
|
||||
if self.filepath is None:
|
||||
texture = None
|
||||
elif (texture := AlbumCover.Cache.get(self.filepath)) is None:
|
||||
texture = Gdk.Texture.new_from_filename(str(self.filepath))
|
||||
AlbumCover.Cache[self.filepath] = texture
|
||||
|
||||
self.child.set_paintable(texture)
|
||||
self.child.set_has_tooltip(texture is not None)
|
||||
tex = texture.CACHE[self.filepath]
|
||||
self.child.set_paintable(tex)
|
||||
self.child.set_has_tooltip(tex is not None)
|
||||
|
||||
def __query_tooltip(self, child: Gtk.Picture, x: int, y: int,
|
||||
keyboard_mode: bool, tooltip: Gtk.Tooltip) -> bool:
|
||||
texture = AlbumCover.Cache.get(self.filepath)
|
||||
tooltip.set_custom(Gtk.Picture.new_for_paintable(texture))
|
||||
tex = texture.CACHE[self.filepath]
|
||||
tooltip.set_custom(Gtk.Picture.new_for_paintable(tex))
|
||||
return True
|
||||
|
||||
def do_bind(self) -> None:
|
||||
|
@ -330,9 +314,8 @@ class FavoriteButton(TrackRow):
|
|||
"""Initialize a Favorite Button."""
|
||||
super().__init__(listitem, property=property)
|
||||
self.child = buttons.ImageToggle("heart-filled", "heart-outline-thick",
|
||||
icon_size=Gtk.IconSize.NORMAL,
|
||||
valign=Gtk.Align.CENTER,
|
||||
has_frame=False)
|
||||
large_icon=False, has_frame=False,
|
||||
valign=Gtk.Align.CENTER)
|
||||
|
||||
def do_bind(self):
|
||||
"""Bind a track property to the Toggle Button."""
|
||||
|
|
|
@ -4,40 +4,39 @@ from gi.repository import GObject
|
|||
from gi.repository import Gdk
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
from ..action import ActionEntry
|
||||
from ..buttons import PopoverButton
|
||||
from .. import db
|
||||
from .. import factory
|
||||
from .. import playlist
|
||||
|
||||
|
||||
class PlaylistRowWidget(Gtk.Box):
|
||||
"""A row widget for Playlists."""
|
||||
class PlaylistRow(Gtk.ListBoxRow):
|
||||
"""A ListBoxRow widget for Playlists."""
|
||||
|
||||
name = GObject.Property(type=str)
|
||||
image = GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize a PlaylistRowWidget."""
|
||||
super().__init__()
|
||||
self._icon = Adw.Avatar(size=32)
|
||||
self._label = Gtk.Label(xalign=0.0)
|
||||
def __init__(self, name: str, image: GObject.TYPE_PYOBJECT):
|
||||
"""Initialize a PlaylistRow."""
|
||||
super().__init__(child=Gtk.Box(margin_start=6, margin_end=6,
|
||||
margin_top=6, margin_bottom=6,
|
||||
spacing=6), name=name)
|
||||
match name:
|
||||
case "Favorite Tracks": icon_name = "heart-filled-symbolic"
|
||||
case "Queued Tracks": icon_name = "music-queue-symbolic"
|
||||
case _: icon_name = "playlist2-symbolic"
|
||||
|
||||
self._icon = Adw.Avatar(size=32, text=name, icon_name=icon_name)
|
||||
self._label = Gtk.Label.new(name)
|
||||
|
||||
self.bind_property("name", self._label, "label")
|
||||
self.bind_property("name", self._icon, "text")
|
||||
self.connect("notify::name", self.__name_changed)
|
||||
self.connect("notify::image", self.__image_changed)
|
||||
self.image = image
|
||||
|
||||
self.append(self._icon)
|
||||
self.append(self._label)
|
||||
self.props.child.append(self._icon)
|
||||
self.props.child.append(self._label)
|
||||
|
||||
def __name_changed(self, row: Gtk.Box, param) -> None:
|
||||
match self.name:
|
||||
case "Favorite Tracks": icon = "heart-filled-symbolic"
|
||||
case "Queued Tracks": icon = "music-queue-symbolic"
|
||||
case _: icon = "playlist2-symbolic"
|
||||
self._icon.set_icon_name(icon)
|
||||
|
||||
def __image_changed(self, row: Gtk.Box, param) -> None:
|
||||
def __image_changed(self, row: Gtk.ListBoxRow,
|
||||
param: GObject.ParamSpec) -> None:
|
||||
if self.image is not None and self.image.is_file():
|
||||
texture = Gdk.Texture.new_from_filename(str(self.image))
|
||||
else:
|
||||
|
@ -45,20 +44,6 @@ class PlaylistRowWidget(Gtk.Box):
|
|||
self._icon.set_custom_image(texture)
|
||||
|
||||
|
||||
class PlaylistRow(factory.ListRow):
|
||||
"""A list row for displaying Playlists."""
|
||||
|
||||
def __init__(self, listitem: Gtk.ListItem):
|
||||
"""Initialize a PlaylistRow."""
|
||||
super().__init__(listitem)
|
||||
self.child = PlaylistRowWidget()
|
||||
|
||||
def do_bind(self):
|
||||
"""Bind a Playlist to this Row."""
|
||||
self.bind_and_set_property("name", "name")
|
||||
self.bind_and_set_property("image", "image")
|
||||
|
||||
|
||||
class UserTracksFilter(Gtk.Filter):
|
||||
"""Filters for tracks with user-tracks set to True."""
|
||||
|
||||
|
@ -77,28 +62,28 @@ class UserTracksFilter(Gtk.Filter):
|
|||
return playlist.user_tracks and playlist != self.playlist
|
||||
|
||||
|
||||
class PlaylistView(Gtk.ListView):
|
||||
class PlaylistView(Gtk.ListBox):
|
||||
"""A ListView for selecting Playlists."""
|
||||
|
||||
playlist = GObject.Property(type=db.playlist.Playlist)
|
||||
|
||||
def __init__(self, sql: db.Connection):
|
||||
"""Initialize the PlaylistView."""
|
||||
super().__init__(show_separators=True, single_click_activate=True)
|
||||
super().__init__(selection_mode=Gtk.SelectionMode.NONE)
|
||||
self._filtered = Gtk.FilterListModel(model=sql.playlists,
|
||||
filter=UserTracksFilter())
|
||||
self._selection = Gtk.NoSelection(model=self._filtered)
|
||||
self._factory = factory.Factory(PlaylistRow)
|
||||
|
||||
self.connect("activate", self.__playlist_activated)
|
||||
self.bind_property("playlist", self._filtered.get_filter(), "playlist")
|
||||
self.add_css_class("rich-list")
|
||||
self.bind_model(self._filtered, self.__create_func)
|
||||
self.connect("row-activated", self.__row_activated)
|
||||
self.add_css_class("boxed-list")
|
||||
|
||||
self.set_model(self._selection)
|
||||
self.set_factory(self._factory)
|
||||
def __row_activated(self, box: Gtk.ListBox, row: PlaylistRow) -> None:
|
||||
self.emit("playlist-selected", self._filtered[row.get_index()])
|
||||
|
||||
def __playlist_activated(self, view: Gtk.ListView, position: int) -> None:
|
||||
self.emit("playlist-selected", self._selection[position])
|
||||
def __create_func(self, playlist: db.playlist.Playlist) -> PlaylistRow:
|
||||
row = PlaylistRow(playlist.name, playlist.image)
|
||||
playlist.bind_property("image", row, "image")
|
||||
return row
|
||||
|
||||
@GObject.Signal(arg_types=(db.playlists.Playlist,))
|
||||
def playlist_selected(self, playlist: db.playlists.Playlist) -> None:
|
||||
|
@ -115,8 +100,10 @@ class MoveButtons(Gtk.Box):
|
|||
"""Initialize the Move Buttons."""
|
||||
super().__init__(**kwargs)
|
||||
self._down = Gtk.Button(icon_name="go-down-symbolic",
|
||||
tooltip_text="move selected track down",
|
||||
hexpand=True, sensitive=False)
|
||||
self._up = Gtk.Button(icon_name="go-up-symbolic",
|
||||
tooltip_text="move selected track up",
|
||||
hexpand=True, sensitive=False)
|
||||
|
||||
self.bind_property("can-move-down", self._down, "sensitive")
|
||||
|
@ -152,6 +139,7 @@ class MoveButtons(Gtk.Box):
|
|||
class OSD(Gtk.Overlay):
|
||||
"""An Overlay with extra controls for the Tracklist."""
|
||||
|
||||
sql = GObject.Property(type=db.Connection)
|
||||
playlist = GObject.Property(type=playlist.playlist.Playlist)
|
||||
selection = GObject.Property(type=Gtk.SelectionModel)
|
||||
|
||||
|
@ -161,15 +149,18 @@ class OSD(Gtk.Overlay):
|
|||
def __init__(self, sql: db.Connection,
|
||||
selection: Gtk.SelectionModel, **kwargs):
|
||||
"""Initialize an OSD."""
|
||||
super().__init__(selection=selection, **kwargs)
|
||||
super().__init__(sql=sql, selection=selection, **kwargs)
|
||||
self._add = PopoverButton(child=Adw.ButtonContent(label="Add",
|
||||
icon_name="list-add-symbolic"),
|
||||
tooltip_text="add selected tracks "
|
||||
"to a playlist",
|
||||
halign=Gtk.Align.START, valign=Gtk.Align.END,
|
||||
margin_start=16, margin_bottom=16,
|
||||
direction=Gtk.ArrowType.UP, visible=False,
|
||||
popover_child=PlaylistView(sql))
|
||||
self._remove = Gtk.Button(child=Adw.ButtonContent(label="Remove",
|
||||
icon_name="list-remove-symbolic"),
|
||||
tooltip_text="remove selected tracks",
|
||||
halign=Gtk.Align.END, valign=Gtk.Align.END,
|
||||
margin_end=16, margin_bottom=16,
|
||||
visible=False)
|
||||
|
@ -206,12 +197,14 @@ class OSD(Gtk.Overlay):
|
|||
playlist: db.playlists.Playlist) -> None:
|
||||
for track in self.__get_selected_tracks():
|
||||
playlist.add_track(track)
|
||||
self.sql.commit()
|
||||
self.clear_selection()
|
||||
|
||||
def __remove_clicked(self, button: Gtk.Button) -> None:
|
||||
if self.playlist is not None:
|
||||
for track in self.__get_selected_tracks():
|
||||
self.playlist.remove_track(track)
|
||||
self.sql.commit()
|
||||
self.clear_selection()
|
||||
|
||||
def __move_track_down(self, move: MoveButtons) -> None:
|
||||
|
@ -219,6 +212,7 @@ class OSD(Gtk.Overlay):
|
|||
index = self.selection.get_selection().get_nth(0)
|
||||
self.selection.get_model().set_incremental(False)
|
||||
self.playlist.move_track_down(self.selection[index])
|
||||
self.sql.commit()
|
||||
self.selection.get_model().set_incremental(True)
|
||||
self.__update_visibility()
|
||||
|
||||
|
@ -227,6 +221,7 @@ class OSD(Gtk.Overlay):
|
|||
index = self.selection.get_selection().get_nth(0)
|
||||
self.selection.get_model().set_incremental(False)
|
||||
self.playlist.move_track_up(self.selection[index])
|
||||
self.sql.commit()
|
||||
self.selection.get_model().set_incremental(True)
|
||||
self.__update_visibility()
|
||||
|
||||
|
@ -263,3 +258,15 @@ class OSD(Gtk.Overlay):
|
|||
self.__selection_changed(self.selection, 0, 0)
|
||||
if self.playlist is not None:
|
||||
self._add.popover_child.playlist = self.playlist.playlist
|
||||
|
||||
@property
|
||||
def accelerators(self) -> list[ActionEntry]:
|
||||
"""Get a list of accelerators for the OSD."""
|
||||
return [ActionEntry("remove-selected-tracks", self._remove.activate,
|
||||
"Delete", enabled=(self._remove, "visible")),
|
||||
ActionEntry("move-track-up", self._move._up.activate,
|
||||
"<Control>Up",
|
||||
enabled=(self._move, "can-move-up")),
|
||||
ActionEntry("move-track-down", self._move._down.activate,
|
||||
"<Control>Down",
|
||||
enabled=(self._move, "can-move-down"))]
|
||||
|
|
|
@ -7,10 +7,9 @@ from .. import db
|
|||
from .. import factory
|
||||
from .. import playlist
|
||||
from . import row
|
||||
from . import selection
|
||||
|
||||
|
||||
class TrackView(Gtk.Frame):
|
||||
class TrackView(Gtk.ScrolledWindow):
|
||||
"""A Gtk.ColumnView that has been configured to show Tracks."""
|
||||
|
||||
playlist = GObject.Property(type=playlist.playlist.Playlist)
|
||||
|
@ -30,8 +29,6 @@ class TrackView(Gtk.Frame):
|
|||
show_row_separators=True,
|
||||
enable_rubberband=True,
|
||||
model=self._selection)
|
||||
self._scrollwin = Gtk.ScrolledWindow(child=self._columnview)
|
||||
self._osd = selection.OSD(sql, self._selection, child=self._scrollwin)
|
||||
|
||||
self.__append_column("Art", "cover", row.AlbumCover, resizable=False)
|
||||
self.__append_column("Fav", "favorite", row.FavoriteButton,
|
||||
|
@ -57,16 +54,13 @@ class TrackView(Gtk.Frame):
|
|||
self.__append_column("Filepath", "path", row.PathString, visible=False)
|
||||
|
||||
self.bind_property("playlist", self._filtermodel, "model")
|
||||
self.bind_property("playlist", self._osd, "playlist")
|
||||
self._osd.bind_property("have-selected", self, "have-selected")
|
||||
self._osd.bind_property("n-selected", self, "n-selected")
|
||||
self._selection.bind_property("n-items", self, "n-tracks")
|
||||
|
||||
self._selection.connect("items-changed", self.__runtime_changed)
|
||||
self._columnview.connect("activate", self.__track_activated)
|
||||
self._columnview.add_css_class("emmental-track-list")
|
||||
|
||||
self.set_child(self._osd)
|
||||
self.set_child(self._columnview)
|
||||
|
||||
def __append_column(self, title: str, property: str, row_type: type,
|
||||
*, width: int = -1, visible: bool = True,
|
||||
|
@ -87,23 +81,16 @@ class TrackView(Gtk.Frame):
|
|||
|
||||
def scroll_to_track(self, track: db.tracks.Track) -> None:
|
||||
"""Scroll to the requested Track."""
|
||||
# This is a workaround until the ColumnView has better scrolling
|
||||
# support, which seems to be targeted for Gtk 4.10.
|
||||
adjustment = self._scrollwin.get_vadjustment()
|
||||
for (i, t) in enumerate(self._selection):
|
||||
if t == track:
|
||||
pos = max(i - 3, 0) * adjustment.get_upper()
|
||||
adjustment.set_value(pos / self._selection.get_n_items())
|
||||
|
||||
def clear_selected_tracks(self) -> None:
|
||||
"""Clear the currently selected tracks."""
|
||||
self._osd.clear_selection()
|
||||
|
||||
def reset_osd(self) -> None:
|
||||
"""Reset the OSD."""
|
||||
self._osd.reset()
|
||||
for i in range(self._selection.props.n_items):
|
||||
if self._selection[i] == track:
|
||||
self._columnview.scroll_to(i, None, Gtk.ListScrollFlags.NONE)
|
||||
|
||||
@GObject.Property(type=Gio.ListModel)
|
||||
def columns(self) -> Gio.ListModel:
|
||||
"""Get the ListModel for the columns."""
|
||||
return self._columnview.get_columns()
|
||||
|
||||
@GObject.Property(type=Gio.ListModel)
|
||||
def selection_model(self) -> Gio.ListModel:
|
||||
"""Get the SelectionModel for the ColumnView."""
|
||||
return self._columnview.get_model()
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
from .action import ActionEntry
|
||||
from . import layout
|
||||
|
||||
|
||||
def _make_pane(orientation: Gtk.Orientation, position: int = 0,
|
||||
|
@ -11,7 +13,7 @@ def _make_pane(orientation: Gtk.Orientation, position: int = 0,
|
|||
pane = Gtk.Paned(orientation=orientation, hexpand=True, vexpand=True,
|
||||
shrink_start_child=False, resize_start_child=False,
|
||||
start_child=start_child, end_child=end_child,
|
||||
position=position)
|
||||
position=position, margin_start=8)
|
||||
pane.add_css_class("emmental-pane")
|
||||
return pane
|
||||
|
||||
|
@ -28,44 +30,53 @@ class Window(Adw.Window):
|
|||
|
||||
header = GObject.Property(type=Gtk.Widget)
|
||||
sidebar = GObject.Property(type=Gtk.Widget)
|
||||
sidebar_size = GObject.Property(type=int, default=300)
|
||||
show_sidebar = GObject.Property(type=bool, default=False)
|
||||
now_playing = GObject.Property(type=Gtk.Widget)
|
||||
now_playing_size = GObject.Property(type=int, default=250)
|
||||
tracklist = GObject.Property(type=Gtk.Widget)
|
||||
user_editing = GObject.Property(type=bool, default=False)
|
||||
|
||||
def __init__(self, version: str, **kwargs):
|
||||
"""Initialize our Window."""
|
||||
super().__init__(icon_name="emmental", title=version,
|
||||
default_width=1600, default_height=900, **kwargs)
|
||||
default_width=1600, default_height=900,
|
||||
width_request=525, height_request=500, **kwargs)
|
||||
self._box = Gtk.Box.new(Gtk.Orientation.VERTICAL, 0)
|
||||
self._header = Adw.Bin(child=self.header)
|
||||
self._inner_pane = _make_pane(Gtk.Orientation.VERTICAL,
|
||||
position=self.now_playing_size,
|
||||
start_child=self.now_playing,
|
||||
end_child=self.tracklist)
|
||||
self._outer_pane = _make_pane(Gtk.Orientation.HORIZONTAL,
|
||||
position=self.sidebar_size,
|
||||
start_child=self.sidebar,
|
||||
end_child=self._inner_pane)
|
||||
self._toast = Adw.ToastOverlay(child=self._outer_pane)
|
||||
self._layout = layout.Layout(content=self._inner_pane,
|
||||
sidebar=self.sidebar)
|
||||
self._toast = Adw.ToastOverlay(child=self._layout)
|
||||
|
||||
self._outer_pane.add_css_class("emmental-padding")
|
||||
self._layout.add_css_class("emmental-padding")
|
||||
if __debug__:
|
||||
self.add_css_class("devel")
|
||||
|
||||
self.bind_property("header", self._header, "child")
|
||||
self.bind_property("sidebar", self._outer_pane, "start-child")
|
||||
self.bind_property("sidebar-size", self._outer_pane, "position",
|
||||
self.bind_property("sidebar", self._layout, "sidebar")
|
||||
self.bind_property("show-sidebar", self._layout, "show-sidebar",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.bind_property("now-playing", self._inner_pane, "start-child")
|
||||
self.bind_property("now-playing-size", self._inner_pane, "position",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.bind_property("tracklist", self._inner_pane, "end-child")
|
||||
|
||||
self.connect("notify::focus-widget", self.__notify_focus_widget)
|
||||
|
||||
for breakpoint in self._layout.breakpoints:
|
||||
self.add_breakpoint(breakpoint)
|
||||
|
||||
self._box.append(self._header)
|
||||
self._box.append(self._toast)
|
||||
self.set_content(self._box)
|
||||
|
||||
def __notify_focus_widget(self, win: Gtk.Window, param) -> None:
|
||||
self.user_editing = isinstance(win.get_property("focus-widget"),
|
||||
Gtk.Editable)
|
||||
|
||||
def close(self, *args) -> None:
|
||||
"""Close the window."""
|
||||
super().close()
|
||||
|
@ -79,3 +90,8 @@ class Window(Adw.Window):
|
|||
def present(self, *args) -> None:
|
||||
"""Present the window."""
|
||||
super().present()
|
||||
|
||||
@property
|
||||
def accelerators(self) -> list[ActionEntry]:
|
||||
"""Get a list of accelerators for the Window."""
|
||||
return [ActionEntry("reset-focus", self.set_focus, "Escape")]
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16px" viewBox="0 0 16 16" width="16px"><filter id="a" height="100%" width="100%" x="0%" y="0%"><feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/></filter><mask id="b"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.5"/></g></mask><clipPath id="c"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="d"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.7"/></g></mask><clipPath id="e"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="f"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.35"/></g></mask><clipPath id="g"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><g mask="url(#b)"><g clip-path="url(#c)" transform="matrix(1 0 0 1 -920 -120)"><path d="m 550 182 c -0.351562 0.003906 -0.695312 0.101562 -1 0.28125 v 3.4375 c 0.304688 0.179688 0.648438 0.277344 1 0.28125 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 c -0.339844 0 -0.679688 0.058594 -1 0.175781 v 6.824219 h 4 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#d)"><g clip-path="url(#e)" transform="matrix(1 0 0 1 -920 -120)"><path d="m 569 182 v 4 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 v 7 h 3 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#f)"><g clip-path="url(#g)" transform="matrix(1 0 0 1 -920 -120)"><path d="m 573 182.269531 v 3.449219 c 0.613281 -0.355469 0.996094 -1.007812 1 -1.71875 c 0 -0.714844 -0.382812 -1.375 -1 -1.730469 z m 0 4.90625 v 6.824219 h 2 v -4 c 0 -1.269531 -0.800781 -2.402344 -2 -2.824219 z m 0 0"/></g></g><path d="m 7.984375 1 c -0.550781 0 -1 0.449219 -1 1 v 8.585938 l -2.292969 -2.292969 c -0.1875 -0.1875 -0.441406 -0.292969 -0.707031 -0.292969 s -0.519531 0.105469 -0.707031 0.292969 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 l 4 4 c 0.390625 0.390625 1.023437 0.390625 1.414062 0 l 4 -4 c 0.390625 -0.390625 0.390625 -1.023437 0 -1.414062 s -1.023437 -0.390625 -1.414062 0 l -2.292969 2.292969 v -8.585938 c 0 -0.550781 -0.445313 -1 -1 -1 z m 0 0"/></svg>
|
After Width: | Height: | Size: 2.2 KiB |
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16px" viewBox="0 0 16 16" width="16px"><filter id="a" height="100%" width="100%" x="0%" y="0%"><feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/></filter><mask id="b"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.5"/></g></mask><clipPath id="c"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="d"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.7"/></g></mask><clipPath id="e"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="f"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.35"/></g></mask><clipPath id="g"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><path d="m 2.386719 3 c -0.214844 0 -0.386719 0.167969 -0.386719 0.378906 v 1.242188 c 0 0.210937 0.171875 0.378906 0.386719 0.378906 h 1.230469 c 0.210937 0 0.382812 -0.167969 0.382812 -0.378906 v -1.242188 c 0 -0.210937 -0.171875 -0.378906 -0.382812 -0.378906 z m 3.613281 0 v 2 h 8 v -2 z m -3.613281 4 c -0.214844 0 -0.386719 0.167969 -0.386719 0.378906 v 1.242188 c 0 0.210937 0.171875 0.378906 0.386719 0.378906 h 1.230469 c 0.210937 0 0.382812 -0.167969 0.382812 -0.378906 v -1.242188 c 0 -0.210937 -0.171875 -0.378906 -0.382812 -0.378906 z m 3.613281 0 v 2 h 8 v -2 z m -3.613281 4 c -0.214844 0 -0.386719 0.167969 -0.386719 0.378906 v 1.242188 c 0 0.210937 0.171875 0.378906 0.386719 0.378906 h 1.230469 c 0.210937 0 0.382812 -0.167969 0.382812 -0.378906 v -1.242188 c 0 -0.210937 -0.171875 -0.378906 -0.382812 -0.378906 z m 3.613281 0 v 2 h 8 v -2 z m 0 0" fill="#222222"/><g mask="url(#b)"><g clip-path="url(#c)" transform="matrix(1 0 0 1 -660 -864)"><path d="m 550 182 c -0.351562 0.003906 -0.695312 0.101562 -1 0.28125 v 3.4375 c 0.304688 0.179688 0.648438 0.277344 1 0.28125 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 c -0.339844 0 -0.679688 0.058594 -1 0.175781 v 6.824219 h 4 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#d)"><g clip-path="url(#e)" transform="matrix(1 0 0 1 -660 -864)"><path d="m 569 182 v 4 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 v 7 h 3 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#f)"><g clip-path="url(#g)" transform="matrix(1 0 0 1 -660 -864)"><path d="m 573 182.269531 v 3.449219 c 0.613281 -0.355469 0.996094 -1.007812 1 -1.71875 c 0 -0.714844 -0.382812 -1.375 -1 -1.730469 z m 0 4.90625 v 6.824219 h 2 v -4 c 0 -1.269531 -0.800781 -2.402344 -2 -2.824219 z m 0 0"/></g></g></svg>
|
After Width: | Height: | Size: 2.6 KiB |
|
@ -0,0 +1,49 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
id="a"
|
||||
viewBox="0 0 16 16"
|
||||
version="1.1"
|
||||
sodipodi:docname="listenbrainz-logo-symbolic.svg"
|
||||
width="16"
|
||||
height="16"
|
||||
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview2"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="52.917468"
|
||||
inkscape:cx="4.2330068"
|
||||
inkscape:cy="7.9652337"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1016"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="a" />
|
||||
<defs
|
||||
id="defs1">
|
||||
<style
|
||||
id="style1">.b{fill:#353070;}.c{fill:#eb743b;}</style>
|
||||
</defs>
|
||||
<polygon
|
||||
class="b"
|
||||
points="13,29 13,1 1,8 1,22 "
|
||||
id="polygon1"
|
||||
transform="matrix(0.5,0,0,0.5,1,0.5)"
|
||||
style="fill:#222222;fill-opacity:1" />
|
||||
<polygon
|
||||
class="c"
|
||||
points="14,29 14,1 26,8 26,22 "
|
||||
id="polygon2"
|
||||
transform="matrix(0.399792,0,0,0.42127119,3.5057644,1.6847072)"
|
||||
style="fill:none;stroke:#222222;stroke-width:3.01583;stroke-dasharray:none;stroke-opacity:1" />
|
||||
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
|
@ -0,0 +1,47 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
id="a"
|
||||
viewBox="0 0 16 16"
|
||||
version="1.1"
|
||||
sodipodi:docname="listenbrainz-logo.svg"
|
||||
width="16"
|
||||
height="16"
|
||||
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview2"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="45.119402"
|
||||
inkscape:cx="3.9007609"
|
||||
inkscape:cy="8.3445255"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1016"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="a" />
|
||||
<defs
|
||||
id="defs1">
|
||||
<style
|
||||
id="style1">.b{fill:#353070;}.c{fill:#eb743b;}</style>
|
||||
</defs>
|
||||
<polygon
|
||||
class="b"
|
||||
points="1,22 13,29 13,1 1,8 "
|
||||
id="polygon1"
|
||||
transform="matrix(0.5,0,0,0.5,1.25,0.5)" />
|
||||
<polygon
|
||||
class="c"
|
||||
points="26,8 26,22 14,29 14,1 "
|
||||
id="polygon2"
|
||||
transform="matrix(0.5,0,0,0.5,1.25,0.5)" />
|
||||
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 5.5 4 c -0.277344 0 -0.5 0.222656 -0.5 0.5 v 8 c 0 0.277344 0.222656 0.5 0.5 0.5 s 0.5 -0.222656 0.5 -0.5 v -8 c 0 -0.277344 -0.222656 -0.5 -0.5 -0.5 z m 4 1 c -0.277344 0 -0.5 0.222656 -0.5 0.5 v 6 c 0 0.277344 0.222656 0.5 0.5 0.5 s 0.5 -0.222656 0.5 -0.5 v -6 c 0 -0.277344 -0.222656 -0.5 -0.5 -0.5 z m -2 1 c -0.277344 0 -0.5 0.222656 -0.5 0.5 v 4 c 0 0.277344 0.222656 0.5 0.5 0.5 s 0.5 -0.222656 0.5 -0.5 v -4 c 0 -0.277344 -0.222656 -0.5 -0.5 -0.5 z m 4 0 c -0.277344 0 -0.5 0.222656 -0.5 0.5 v 4 c 0 0.277344 0.222656 0.5 0.5 0.5 s 0.5 -0.222656 0.5 -0.5 v -4 c 0 -0.277344 -0.222656 -0.5 -0.5 -0.5 z m -8 1 c -0.277344 0 -0.5 0.222656 -0.5 0.5 v 3 c 0 0.277344 0.222656 0.5 0.5 0.5 s 0.5 -0.222656 0.5 -0.5 v -3 c 0 -0.277344 -0.222656 -0.5 -0.5 -0.5 z m -2.027344 1 c -0.261718 0.011719 -0.472656 0.230469 -0.472656 0.5 v 1 c 0 0.277344 0.222656 0.5 0.5 0.5 s 0.5 -0.222656 0.5 -0.5 v -1 c 0 -0.277344 -0.222656 -0.5 -0.5 -0.5 c -0.007812 0 -0.015625 0 -0.027344 0 z m 12 0 c -0.261718 0.011719 -0.472656 0.230469 -0.472656 0.5 v 1 c 0 0.277344 0.222656 0.5 0.5 0.5 s 0.5 -0.222656 0.5 -0.5 v -1 c 0 -0.277344 -0.222656 -0.5 -0.5 -0.5 c -0.007812 0 -0.015625 0 -0.027344 0 z m 0 0"/></svg>
|
After Width: | Height: | Size: 1.3 KiB |
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 8 2 c -0.554688 0 -1 0.445312 -1 1 v 10 c 0 0.554688 0.445312 1 1 1 s 1 -0.445312 1 -1 v -10 c 0 -0.554688 -0.445312 -1 -1 -1 z m 6 2 c -0.554688 0 -1 0.445312 -1 1 v 6 c 0 0.554688 0.445312 1 1 1 s 1 -0.445312 1 -1 v -6 c 0 -0.554688 -0.445312 -1 -1 -1 z m -9 1 c -0.554688 0 -1 0.445312 -1 1 v 4 c 0 0.554688 0.445312 1 1 1 s 1 -0.445312 1 -1 v -4 c 0 -0.554688 -0.445312 -1 -1 -1 z m 6 1 c -0.554688 0 -1 0.445312 -1 1 v 2 c 0 0.554688 0.445312 1 1 1 s 1 -0.445312 1 -1 v -2 c 0 -0.554688 -0.445312 -1 -1 -1 z m -9 1 c -0.554688 0 -1 0.445312 -1 1 s 0.445312 1 1 1 s 1 -0.445312 1 -1 s -0.445312 -1 -1 -1 z m 0 0"/></svg>
|
After Width: | Height: | Size: 762 B |
|
@ -53,6 +53,27 @@ class TestAudio(unittest.TestCase):
|
|||
self.assertRegex(self.player._playbin.get_property("video-sink").name,
|
||||
r"fakesink\d+")
|
||||
|
||||
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
||||
def test_filter(self, mock_stdout: io.StringIO):
|
||||
"""Test the filter element added to the playbin."""
|
||||
self.assertIsInstance(self.player._filter,
|
||||
emmental.audio.filter.Filter)
|
||||
self.assertEqual(self.player._playbin.get_property("audio-filter"),
|
||||
self.player._filter)
|
||||
|
||||
self.assertFalse(self.player.bg_enabled)
|
||||
self.assertEqual(self.player.bg_volume, 0.5)
|
||||
|
||||
self.player.bg_enabled = True
|
||||
self.player.bg_volume = 0.75
|
||||
self.assertTrue(self.player._filter.bg_enabled)
|
||||
self.assertEqual(self.player._filter.bg_volume, 0.75)
|
||||
|
||||
self.player.bg_enabled = False
|
||||
self.player.bg_volume = 0.5
|
||||
self.assertFalse(self.player._filter.bg_enabled)
|
||||
self.assertEqual(self.player.bg_volume, 0.5)
|
||||
|
||||
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
||||
def test_eos(self, mock_stdout: io.StringIO):
|
||||
"""Test handling an EOS message."""
|
||||
|
@ -297,22 +318,17 @@ class TestAudio(unittest.TestCase):
|
|||
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
||||
def test_replaygain(self, mock_stdout: io.StringIO):
|
||||
"""Test that ReplayGain functions work as expected."""
|
||||
self.assertIsInstance(self.player._replaygain,
|
||||
emmental.audio.replaygain.Filter)
|
||||
self.assertEqual(self.player._playbin.get_property("audio-filter"),
|
||||
self.player._replaygain)
|
||||
|
||||
self.assertEqual(self.player._replaygain.mode, "disabled")
|
||||
self.assertEqual(self.player._filter.rg_mode, "disabled")
|
||||
self.assertEqual(self.player.get_replaygain(), (False, None))
|
||||
|
||||
self.player.set_replaygain(True, "album")
|
||||
self.assertEqual(self.player._replaygain.mode, "album")
|
||||
self.assertEqual(self.player._filter.rg_mode, "album")
|
||||
self.assertEqual(self.player.get_replaygain(), (True, "album"))
|
||||
self.assertRegex(mock_stdout.getvalue(),
|
||||
r"audio: setting ReplayGain mode to 'album'")
|
||||
|
||||
self.player.set_replaygain(False, "track")
|
||||
self.assertEqual(self.player._replaygain.mode, "disabled")
|
||||
self.assertEqual(self.player._filter.rg_mode, "disabled")
|
||||
self.assertEqual(self.player.get_replaygain(), (False, None))
|
||||
self.assertRegex(mock_stdout.getvalue(),
|
||||
r"audio: setting ReplayGain mode to 'disabled'")
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
# Copyright 2023 (c) Anna Schumaker.
|
||||
"""Tests our combined Filter element."""
|
||||
import io
|
||||
import unittest
|
||||
import emmental.audio.filter
|
||||
from gi.repository import Gst
|
||||
|
||||
|
||||
class TestFilter(unittest.TestCase):
|
||||
"""Tests our custom Filter element."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
self.filter = emmental.audio.filter.Filter()
|
||||
|
||||
def test_init(self):
|
||||
"""Test that the filter is set up correctly."""
|
||||
self.assertIsInstance(self.filter, Gst.Bin)
|
||||
self.assertIsInstance(self.filter.get_static_pad("src"), Gst.GhostPad)
|
||||
self.assertIsInstance(self.filter.get_static_pad("sink"), Gst.GhostPad)
|
||||
|
||||
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
||||
def test_replaygain(self, mock_stdout: io.StringIO):
|
||||
"""Test the ReplayGain element in the filter."""
|
||||
self.assertIsInstance(self.filter._replaygain,
|
||||
emmental.audio.replaygain.Filter)
|
||||
|
||||
self.assertEqual(self.filter.rg_mode, "disabled")
|
||||
self.filter.rg_mode = "track"
|
||||
self.assertEqual(self.filter._replaygain.mode, "track")
|
||||
self.filter.rg_mode = "track"
|
||||
self.assertEqual(mock_stdout.getvalue(),
|
||||
"audio: setting ReplayGain mode to 'track'\n")
|
||||
|
||||
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
||||
def test_volume(self, mock_stdout: io.StringIO):
|
||||
"""Test the Volume element in the filter."""
|
||||
self.assertIsInstance(self.filter._volume, Gst.Element)
|
||||
self.assertRegex(self.filter._volume.name, r"volume\d+")
|
||||
self.assertEqual(self.filter._volume.get_property("volume"), 1.0)
|
||||
|
||||
self.assertFalse(self.filter.bg_enabled)
|
||||
self.assertEqual(self.filter.bg_volume, 0.5)
|
||||
|
||||
self.filter.bg_enabled = True
|
||||
self.assertEqual(self.filter._volume.get_property("volume"), 0.5)
|
||||
self.assertEqual(mock_stdout.getvalue(),
|
||||
"audio: setting background listening to 50%\n")
|
||||
|
||||
self.filter.bg_volume = 0.50
|
||||
self.assertEqual(mock_stdout.getvalue(),
|
||||
"audio: setting background listening to 50%\n")
|
||||
|
||||
self.filter.bg_volume = 0.75
|
||||
self.assertEqual(self.filter._volume.get_property("volume"), 0.75)
|
||||
self.assertEqual(mock_stdout.getvalue(),
|
||||
"audio: setting background listening to 50%\n"
|
||||
"audio: setting background listening to 75%\n")
|
||||
|
||||
self.filter.bg_enabled = False
|
||||
self.assertEqual(self.filter._volume.get_property("volume"), 1.0)
|
||||
self.assertEqual(mock_stdout.getvalue(),
|
||||
"audio: setting background listening to 50%\n"
|
||||
"audio: setting background listening to 75%\n"
|
||||
"audio: setting background listening to off\n")
|
||||
|
||||
self.filter.bg_volume = 0.5
|
||||
self.assertEqual(self.filter._volume.get_property("volume"), 1.0)
|
||||
self.assertEqual(mock_stdout.getvalue(),
|
||||
"audio: setting background listening to 50%\n"
|
||||
"audio: setting background listening to 75%\n"
|
||||
"audio: setting background listening to off\n")
|
|
@ -0,0 +1,7 @@
|
|||
/* Copyright 2023 (c) Anna Schumaker */
|
||||
CREATE TABLE test (a INT, b INT);
|
||||
INSERT INTO test VALUES (1, 2);
|
||||
INSERT INTO test VALUES (3, 4);
|
||||
INSERT INTO test VALUES (5, 6);
|
||||
INSERT INTO test VALUES (7, 8);
|
||||
INSERT INTO test VALUES (9, 0);
|
|
@ -41,6 +41,22 @@ class TestAlbumObject(tests.util.TestCase):
|
|||
self.assertEqual(album2.mbid, "ab-cd-ef")
|
||||
self.assertEqual(album2.cover, cover)
|
||||
|
||||
def test_add_remove_medium(self):
|
||||
"""Test adding and removing a medium from the Album."""
|
||||
album = self.table.create("Test Album", "Album Artist", "2023-03")
|
||||
medium = self.sql.media.create(album, "Test Medium", number=1)
|
||||
|
||||
self.assertFalse(medium in self.album.child_set)
|
||||
self.assertFalse(self.album.has_medium(medium))
|
||||
|
||||
self.album.add_medium(medium)
|
||||
self.assertTrue(medium in self.album.child_set)
|
||||
self.assertTrue(self.album.has_medium(medium))
|
||||
|
||||
self.album.remove_medium(medium)
|
||||
self.assertFalse(medium in self.album.child_set)
|
||||
self.assertFalse(self.album.has_medium(medium))
|
||||
|
||||
def test_get_artists(self):
|
||||
"""Test getting the list of artists for this album."""
|
||||
with unittest.mock.patch.object(self.table, "get_artists",
|
||||
|
@ -56,22 +72,14 @@ class TestAlbumObject(tests.util.TestCase):
|
|||
self.assertListEqual(self.album.get_media(), [1, 2, 3])
|
||||
mock.assert_called_with(self.album)
|
||||
|
||||
def test_media_model(self):
|
||||
"""Test getting a Gio.ListModel representing this Album's media."""
|
||||
def test_children(self):
|
||||
"""Test the Album's 'children' model is set up properly."""
|
||||
self.assertIsInstance(self.album.child_set,
|
||||
emmental.db.table.TableSubset)
|
||||
self.assertIsInstance(self.album.children, Gtk.FilterListModel)
|
||||
self.assertIsInstance(self.album.children.get_filter(),
|
||||
Gtk.CustomFilter)
|
||||
self.assertEqual(self.album.children.get_model(), self.sql.media)
|
||||
|
||||
album = self.table.create("Test Album", "Album Artist", "2023-03")
|
||||
medium = self.sql.media.create(album, "Test Medium", number=1)
|
||||
self.assertTrue(album.children.get_filter().match(medium))
|
||||
|
||||
medium.albumid = album.albumid + 1
|
||||
self.assertFalse(album.children.get_filter().match(medium))
|
||||
|
||||
medium = self.sql.media.create(album, "", number=2)
|
||||
self.assertFalse(album.children.get_filter().match(medium))
|
||||
self.assertEqual(self.album.children.get_filter(),
|
||||
self.sql.media.get_filter())
|
||||
self.assertEqual(self.album.child_set.table, self.sql.media)
|
||||
|
||||
|
||||
class TestAlbumTable(tests.util.TestCase):
|
||||
|
@ -228,9 +236,11 @@ class TestAlbumTable(tests.util.TestCase):
|
|||
|
||||
def test_load(self):
|
||||
"""Test loading the album table."""
|
||||
self.table.create("Album 1", "Album Artist", "2023-03")
|
||||
album = self.table.create("Album 1", "Album Artist", "2023-03")
|
||||
self.table.create("Album 2", "Album Artist", "2023-03",
|
||||
mbid="ab-cd-ef", cover=tests.util.COVER_JPG)
|
||||
medium = self.sql.media.create(album, "Test Medium", number=1)
|
||||
album.add_medium(medium)
|
||||
|
||||
albums2 = emmental.db.albums.Table(self.sql)
|
||||
self.assertEqual(len(albums2), 0)
|
||||
|
@ -243,6 +253,7 @@ class TestAlbumTable(tests.util.TestCase):
|
|||
self.assertEqual(albums2.get_item(0).release, "2023-03")
|
||||
self.assertEqual(albums2.get_item(0).mbid, "")
|
||||
self.assertIsNone(albums2.get_item(0).cover)
|
||||
self.assertSetEqual(albums2.get_item(0).child_set.keyset.keys, {1})
|
||||
|
||||
self.assertEqual(albums2.get_item(1).name, "Album 2")
|
||||
self.assertEqual(albums2.get_item(1).artist, "Album Artist")
|
||||
|
@ -250,6 +261,7 @@ class TestAlbumTable(tests.util.TestCase):
|
|||
self.assertEqual(albums2.get_item(1).mbid, "ab-cd-ef")
|
||||
self.assertEqual(albums2.get_item(1).cover,
|
||||
tests.util.COVER_JPG)
|
||||
self.assertSetEqual(albums2.get_item(1).child_set.keyset.keys, set())
|
||||
|
||||
def test_lookup(self):
|
||||
"""Test looking up album playlists."""
|
||||
|
@ -320,4 +332,6 @@ class TestAlbumTable(tests.util.TestCase):
|
|||
medium1 = self.sql.media.create(album, "", number=1)
|
||||
medium2 = self.sql.media.create(album, "", number=2)
|
||||
|
||||
self.assertSetEqual(self.table.get_mediumids(album),
|
||||
{medium1.mediumid, medium2.mediumid})
|
||||
self.assertListEqual(self.table.get_media(album), [medium1, medium2])
|
||||
|
|
|
@ -20,7 +20,6 @@ class TestArtistObject(tests.util.TestCase):
|
|||
def test_init(self):
|
||||
"""Test that the Artist is set up properly."""
|
||||
self.assertIsInstance(self.artist, emmental.db.playlist.Playlist)
|
||||
self.assertSetEqual(self.artist.children.get_filter().keys, set())
|
||||
self.assertEqual(self.artist.table, self.table)
|
||||
self.assertEqual(self.artist.propertyid, 456)
|
||||
self.assertEqual(self.artist.artistid, 123)
|
||||
|
@ -37,8 +36,7 @@ class TestArtistObject(tests.util.TestCase):
|
|||
self.artist.add_album(album)
|
||||
|
||||
mock_add.assert_called_with(self.artist, album)
|
||||
self.assertSetEqual(self.artist.children.get_filter().keys,
|
||||
{album.albumid})
|
||||
self.assertIn(album, self.artist.child_set)
|
||||
self.assertTrue(self.artist.has_album(album))
|
||||
|
||||
with unittest.mock.patch.object(self.table, "remove_album",
|
||||
|
@ -46,15 +44,17 @@ class TestArtistObject(tests.util.TestCase):
|
|||
self.artist.remove_album(album)
|
||||
|
||||
mock_remove.assert_called_with(self.artist, album)
|
||||
self.assertSetEqual(self.artist.children.get_filter().keys, set())
|
||||
self.assertNotIn(album, self.artist.child_set)
|
||||
self.assertFalse(self.artist.has_album(album))
|
||||
|
||||
def test_children(self):
|
||||
"""Test that Albums have been added as Artist playlist children."""
|
||||
self.assertIsInstance(self.artist.child_set,
|
||||
emmental.db.table.TableSubset)
|
||||
self.assertIsInstance(self.artist.children, Gtk.FilterListModel)
|
||||
self.assertIsInstance(self.artist.children.get_filter(),
|
||||
emmental.db.table.Filter)
|
||||
self.assertEqual(self.artist.children.get_model(), self.sql.albums)
|
||||
self.assertEqual(self.artist.children.get_filter(),
|
||||
self.sql.albums.get_filter())
|
||||
self.assertEqual(self.artist.child_set.table, self.sql.albums)
|
||||
|
||||
|
||||
class TestFilter(tests.util.TestCase):
|
||||
|
@ -68,7 +68,7 @@ class TestFilter(tests.util.TestCase):
|
|||
|
||||
def test_init(self):
|
||||
"""Test that the filter is initialized properly."""
|
||||
self.assertIsInstance(self.filter, emmental.db.table.Filter)
|
||||
self.assertIsInstance(self.filter, emmental.db.table.KeySet)
|
||||
self.assertFalse(self.filter.show_all)
|
||||
|
||||
filter2 = emmental.db.artists.Filter(show_all=True)
|
||||
|
@ -219,13 +219,11 @@ class TestArtistTable(tests.util.TestCase):
|
|||
|
||||
self.assertEqual(artists2.get_item(0).name, "Artist 1")
|
||||
self.assertEqual(artists2.get_item(0).mbid, "")
|
||||
self.assertSetEqual(artists2.get_item(0).children.get_filter().keys,
|
||||
{1})
|
||||
self.assertSetEqual(artists2.get_item(0).child_set.keyset.keys, {1})
|
||||
|
||||
self.assertEqual(artists2.get_item(1).name, "Artist 2")
|
||||
self.assertEqual(artists2.get_item(1).mbid, "ab-cd-ef")
|
||||
self.assertSetEqual(artists2.get_item(1).children.get_filter().keys,
|
||||
set())
|
||||
self.assertSetEqual(artists2.get_item(1).child_set.keyset.keys, set())
|
||||
|
||||
def test_lookup(self):
|
||||
"""Test looking up artist playlists."""
|
||||
|
|
|
@ -79,6 +79,20 @@ class TestConnection(unittest.TestCase):
|
|||
self.assertEqual(tuple(rows[3]), (4, "d"))
|
||||
self.assertEqual(tuple(rows[4]), (5, "e"))
|
||||
|
||||
@unittest.mock.patch("emmental.db.connection.Connection.commit")
|
||||
def test_executescript(self, mock_commit: unittest.mock.Mock):
|
||||
"""Test the executescript function."""
|
||||
script = pathlib.Path(__file__).parent / "test-script.sql"
|
||||
cur = self.sql.executescript(script)
|
||||
self.assertIsInstance(cur, sqlite3.Cursor)
|
||||
mock_commit.assert_called()
|
||||
|
||||
rows = self.sql("SELECT * FROM test").fetchall()
|
||||
self.assertListEqual([(row["a"], row["b"]) for row in rows],
|
||||
[(1, 2), (3, 4), (5, 6), (7, 8), (9, 0)])
|
||||
|
||||
self.assertIsNone(self.sql.executescript(script.parent / "no-script"))
|
||||
|
||||
def test_path_column(self):
|
||||
"""Test that the PATH column type has been set up."""
|
||||
self.sql("CREATE TABLE test (path PATH)")
|
||||
|
|
|
@ -11,8 +11,10 @@ class TestConnection(tests.util.TestCase):
|
|||
|
||||
def test_paths(self):
|
||||
"""Check that path constants are pointing to the right places."""
|
||||
script = pathlib.Path(emmental.db.__file__).parent / "emmental.sql"
|
||||
self.assertEqual(emmental.db.SQL_SCRIPT, script)
|
||||
dir = pathlib.Path(emmental.db.__file__).parent
|
||||
self.assertEqual(emmental.db.SQL_V1_SCRIPT, dir / "emmental.sql")
|
||||
self.assertEqual(emmental.db.SQL_V2_SCRIPT, dir / "upgrade-v2.sql")
|
||||
self.assertEqual(emmental.db.SQL_V3_SCRIPT, dir / "upgrade-v3.sql")
|
||||
|
||||
def test_connection(self):
|
||||
"""Check that the connection manager is initialized properly."""
|
||||
|
@ -21,16 +23,16 @@ class TestConnection(tests.util.TestCase):
|
|||
def test_version(self):
|
||||
"""Test checking the database schema version."""
|
||||
cur = self.sql("PRAGMA user_version")
|
||||
self.assertEqual(cur.fetchone()["user_version"], 1)
|
||||
self.assertEqual(cur.fetchone()["user_version"], 3)
|
||||
|
||||
def test_version_too_new(self):
|
||||
"""Test failing when the database version is too new."""
|
||||
self.sql._Connection__check_version()
|
||||
|
||||
self.sql("PRAGMA user_version = 2")
|
||||
self.sql("PRAGMA user_version = 4")
|
||||
with self.assertRaises(Exception) as e:
|
||||
self.sql._Connection__check_version()
|
||||
self.assertEqual(str(e.exception), "Unsupported data version: 2")
|
||||
self.assertEqual(str(e.exception), "Unsupported data version: 4")
|
||||
|
||||
def test_close(self):
|
||||
"""Check closing the connection."""
|
||||
|
@ -71,22 +73,34 @@ class TestConnection(tests.util.TestCase):
|
|||
|
||||
def test_load(self):
|
||||
"""Check that calling load() loads the tables."""
|
||||
idle_tables = [tbl for tbl in self.sql.playlist_tables()] + \
|
||||
[self.sql.tracks]
|
||||
plist_tables = list(self.sql.playlist_tables())
|
||||
all_tables = [self.sql.settings] + plist_tables + [self.sql.tracks]
|
||||
|
||||
table_loaded = unittest.mock.Mock()
|
||||
self.sql.connect("table-loaded", table_loaded)
|
||||
|
||||
self.assertFalse(self.sql.loaded)
|
||||
notify_loaded = unittest.mock.Mock()
|
||||
self.sql.connect("notify::loaded", notify_loaded)
|
||||
|
||||
self.sql.load()
|
||||
self.assertTrue(self.sql.settings.loaded)
|
||||
for tbl in idle_tables:
|
||||
notify_loaded.assert_not_called()
|
||||
|
||||
for tbl in all_tables[1:]:
|
||||
self.assertFalse(tbl.loaded)
|
||||
for tbl in idle_tables:
|
||||
for tbl in plist_tables:
|
||||
tbl.queue.complete()
|
||||
self.assertTrue(tbl.loaded)
|
||||
self.assertFalse(self.sql.loaded)
|
||||
notify_loaded.assert_not_called()
|
||||
|
||||
calls = [unittest.mock.call(self.sql, tbl)
|
||||
for tbl in [self.sql.settings] + idle_tables]
|
||||
self.sql.tracks.queue.complete()
|
||||
self.assertTrue(self.sql.tracks.loaded)
|
||||
self.assertTrue(self.sql.loaded)
|
||||
notify_loaded.assert_called()
|
||||
|
||||
calls = [unittest.mock.call(self.sql, tbl) for tbl in all_tables]
|
||||
table_loaded.assert_has_calls(calls)
|
||||
|
||||
def test_filter(self):
|
||||
|
@ -114,6 +128,11 @@ class TestConnection(tests.util.TestCase):
|
|||
self.assertFalse(plist1.active)
|
||||
self.assertTrue(plist2.active)
|
||||
|
||||
notify = unittest.mock.Mock()
|
||||
self.sql.connect("notify::active-playlist", notify)
|
||||
self.sql.set_active_playlist(plist2)
|
||||
notify.assert_not_called()
|
||||
|
||||
self.sql.set_active_playlist(None)
|
||||
self.assertIsNone(self.sql.active_playlist)
|
||||
self.assertFalse(plist2.active)
|
||||
|
|
|
@ -28,6 +28,21 @@ class TestDecadeObject(tests.util.TestCase):
|
|||
self.assertEqual(self.decade.name, "The 2020s")
|
||||
self.assertIsNone(self.decade.parent)
|
||||
|
||||
def test_add_remove_year(self):
|
||||
"""Test adding and removing a year from the decade."""
|
||||
year = self.sql.years.create(1988)
|
||||
|
||||
self.assertFalse(year in self.decade.child_set)
|
||||
self.assertFalse(self.decade.has_year(year))
|
||||
|
||||
self.decade.add_year(year)
|
||||
self.assertTrue(year in self.decade.child_set)
|
||||
self.assertTrue(self.decade.has_year(year))
|
||||
|
||||
self.decade.remove_year(year)
|
||||
self.assertFalse(year in self.decade.child_set)
|
||||
self.assertFalse(self.decade.has_year(year))
|
||||
|
||||
def test_get_years(self):
|
||||
"""Test getting the list of years for this decade."""
|
||||
with unittest.mock.patch.object(self.table, "get_years",
|
||||
|
@ -37,16 +52,12 @@ class TestDecadeObject(tests.util.TestCase):
|
|||
|
||||
def test_years_model(self):
|
||||
"""Test getting a Gio.ListModel representing a Decade's years."""
|
||||
self.assertIsInstance(self.decade.child_set,
|
||||
emmental.db.table.TableSubset)
|
||||
self.assertIsInstance(self.decade.children, Gtk.FilterListModel)
|
||||
self.assertIsInstance(self.decade.children.get_filter(),
|
||||
Gtk.CustomFilter)
|
||||
self.assertEqual(self.decade.children.get_model(), self.sql.years)
|
||||
|
||||
year = self.sql.years.create(2023)
|
||||
self.assertTrue(self.decade.children.get_filter().match(year))
|
||||
|
||||
year = self.sql.years.create(1988)
|
||||
self.assertFalse(self.decade.children.get_filter().match(year))
|
||||
self.assertEqual(self.decade.children.get_filter(),
|
||||
self.sql.years.get_filter())
|
||||
self.assertEqual(self.decade.child_set.table, self.sql.years)
|
||||
|
||||
|
||||
class TestDecadeTable(tests.util.TestCase):
|
||||
|
@ -164,8 +175,10 @@ class TestDecadeTable(tests.util.TestCase):
|
|||
|
||||
def test_load(self):
|
||||
"""Load the decade table from the database."""
|
||||
self.table.create(1980)
|
||||
decade = self.table.create(1980)
|
||||
self.table.create(1990)
|
||||
year = self.sql.years.create(1988)
|
||||
decade.add_year(year)
|
||||
|
||||
decades2 = emmental.db.decades.Table(self.sql)
|
||||
self.assertEqual(len(decades2), 0)
|
||||
|
@ -175,9 +188,11 @@ class TestDecadeTable(tests.util.TestCase):
|
|||
|
||||
self.assertEqual(decades2.get_item(0).decade, 1980)
|
||||
self.assertEqual(decades2.get_item(0).name, "The 1980s")
|
||||
self.assertSetEqual(decades2.get_item(0).child_set.keyset.keys, {1988})
|
||||
|
||||
self.assertEqual(decades2.get_item(1).decade, 1990)
|
||||
self.assertEqual(decades2.get_item(1).name, "The 1990s")
|
||||
self.assertSetEqual(decades2.get_item(1).child_set.keyset.keys, set())
|
||||
|
||||
def test_lookup(self):
|
||||
"""Test looking up decade playlists."""
|
||||
|
@ -214,4 +229,5 @@ class TestDecadeTable(tests.util.TestCase):
|
|||
y1985 = self.sql.years.create(1985)
|
||||
y1988 = self.sql.years.create(1988)
|
||||
|
||||
self.assertSetEqual(self.table.get_yearids(decade), {1985, 1988})
|
||||
self.assertListEqual(self.table.get_years(decade), [y1985, y1988])
|
||||
|
|
|
@ -51,6 +51,26 @@ class TestIdleQueue(unittest.TestCase):
|
|||
self.assertEqual(self.queue.total, 0)
|
||||
self.assertEqual(self.queue.progress, 0.0)
|
||||
|
||||
def test_cancel_task(self, mock_idle_add: unittest.mock.Mock,
|
||||
mock_source_removed: unittest.mock.Mock):
|
||||
"""Test canceling a specific task."""
|
||||
self.queue.push(1)
|
||||
self.queue.push(2)
|
||||
self.queue.push(1)
|
||||
|
||||
self.queue.cancel_task(1)
|
||||
self.assertListEqual(self.queue._tasks, [(2,)])
|
||||
self.assertEqual(self.queue.total, 3)
|
||||
self.assertAlmostEqual(self.queue.progress, 2 / 3)
|
||||
mock_source_removed.assert_not_called()
|
||||
|
||||
self.queue.cancel_task(2)
|
||||
self.assertListEqual(self.queue._tasks, [])
|
||||
self.assertIsNone(self.queue._idle_id)
|
||||
self.assertEqual(self.queue.total, 0)
|
||||
self.assertEqual(self.queue.progress, 0.0)
|
||||
mock_source_removed.assert_called_with(42)
|
||||
|
||||
def test_complete(self, mock_idle_add: unittest.mock.Mock,
|
||||
mock_source_removed: unittest.mock.Mock):
|
||||
"""Test completing queued tasks."""
|
||||
|
@ -119,6 +139,17 @@ class TestIdleQueue(unittest.TestCase):
|
|||
mock_idle_add.assert_not_called()
|
||||
func.assert_called_with(1)
|
||||
|
||||
def test_push_first(self, mock_idle_add: unittest.mock.Mock,
|
||||
mock_source_removed: unittest.mock.Mock):
|
||||
"""Test pushing an idle task with first=True."""
|
||||
self.queue.push(1)
|
||||
self.queue.push(0, first=True)
|
||||
self.assertListEqual(self.queue._tasks, [(0,), (1,)])
|
||||
self.queue.push(2, first=False)
|
||||
self.assertListEqual(self.queue._tasks, [(0,), (1,), (2,)])
|
||||
self.queue.push(3)
|
||||
self.assertListEqual(self.queue._tasks, [(0,), (1,), (2,), (3,)])
|
||||
|
||||
def test_push_many_enabled(self, mock_idle_add: unittest.mock.Mock,
|
||||
mock_source_removed: unittest.mock.Mock):
|
||||
"""Test adding several calls to one function at one time."""
|
||||
|
|
|
@ -182,23 +182,23 @@ class TestLibraryObject(tests.util.TestCase):
|
|||
tagger.tag_file.assert_not_called()
|
||||
|
||||
tagger.ready.is_set.return_value = True
|
||||
tagger.get_result.return_value = (None, None)
|
||||
tagger.get_result.return_value = None
|
||||
self.assertFalse(self.library._Library__tag_track(track))
|
||||
tagger.get_result.assert_called_with(self.sql, self.library)
|
||||
tagger.tag_file.assert_called_with(track, None)
|
||||
tagger.get_result.assert_called_with(db=self.sql, library=self.library)
|
||||
tagger.tag_file.assert_called_with(track, mtime=None)
|
||||
|
||||
self.sql.tracks.lookup = unittest.mock.Mock()
|
||||
self.sql.tracks.lookup.return_value.mtime = 12345
|
||||
self.assertFalse(self.library._Library__tag_track(track))
|
||||
tagger.get_result.assert_called_with(self.sql, self.library)
|
||||
tagger.tag_file.assert_called_with(track, 12345)
|
||||
tagger.get_result.assert_called_with(db=self.sql, library=self.library)
|
||||
tagger.tag_file.assert_called_with(track, mtime=12345)
|
||||
|
||||
tagger.reset_mock()
|
||||
tagger.ready.is_set.return_value = True
|
||||
tagger.get_result.return_value = (track, tags)
|
||||
tagger.get_result.return_value = {"path": track, "tags": tags}
|
||||
self.assertTrue(self.library._Library__tag_track(track))
|
||||
tagger.tag_file.assert_not_called()
|
||||
tagger.get_result.assert_called_with(self.sql, self.library)
|
||||
tagger.get_result.assert_called_with(db=self.sql, library=self.library)
|
||||
|
||||
@unittest.mock.patch("emmental.db.tagger.untag_track")
|
||||
def test_scan_check_trackid(self, mock_untag: unittest.mock.Mock()):
|
||||
|
|
|
@ -4,6 +4,7 @@ import pathlib
|
|||
import unittest.mock
|
||||
import emmental.db
|
||||
import tests.util
|
||||
from gi.repository import Gtk
|
||||
|
||||
|
||||
class TestMediumObject(tests.util.TestCase):
|
||||
|
@ -46,6 +47,36 @@ class TestMediumObject(tests.util.TestCase):
|
|||
mock_rename.assert_called_with(self.medium, "New Name")
|
||||
|
||||
|
||||
class TestFilter(tests.util.TestCase):
|
||||
"""Test the medium filter."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
super().setUp()
|
||||
self.filter = emmental.db.media.Filter()
|
||||
|
||||
def test_init(self):
|
||||
"""Test that the filter is initialized properly."""
|
||||
self.assertIsInstance(self.filter, emmental.db.table.KeySet)
|
||||
|
||||
def test_strictness(self):
|
||||
"""Test checking strictness."""
|
||||
self.filter.keys = None
|
||||
self.assertEqual(self.filter.get_strictness(), Gtk.FilterMatch.SOME)
|
||||
self.filter.keys = set()
|
||||
self.assertEqual(self.filter.get_strictness(), Gtk.FilterMatch.NONE)
|
||||
self.filter.keys = {1, 2, 3}
|
||||
self.assertEqual(self.filter.get_strictness(), Gtk.FilterMatch.SOME)
|
||||
|
||||
def test_match(self):
|
||||
"""Test matching a Medium."""
|
||||
album = self.sql.albums.create("Test Album", "Test Artist", "123")
|
||||
medium = self.sql.media.create(album, "", number=1)
|
||||
self.assertFalse(self.filter.match(medium))
|
||||
medium.name = "abcde"
|
||||
self.assertTrue(self.filter.match(medium))
|
||||
|
||||
|
||||
class TestMediumsTable(tests.util.TestCase):
|
||||
"""Tests our mediums table."""
|
||||
|
||||
|
@ -61,6 +92,8 @@ class TestMediumsTable(tests.util.TestCase):
|
|||
def test_init(self):
|
||||
"""Test that the medium model is configured corretly."""
|
||||
self.assertIsInstance(self.table, emmental.db.playlist.Table)
|
||||
self.assertIsInstance(self.table.get_filter(),
|
||||
emmental.db.media.Filter)
|
||||
self.assertEqual(len(self.table), 0)
|
||||
self.assertTrue(self.table.autodelete)
|
||||
self.assertFalse(self.table.system_tracks)
|
||||
|
@ -99,6 +132,7 @@ class TestMediumsTable(tests.util.TestCase):
|
|||
self.assertEqual(medium1.number, 1)
|
||||
self.assertEqual(medium1.type, "")
|
||||
self.assertEqual(medium1.sort_order, "mediumno, number")
|
||||
self.assertTrue(self.album.has_medium(medium1))
|
||||
|
||||
cur = self.sql("SELECT COUNT(name) FROM media")
|
||||
self.assertEqual(cur.fetchone()["COUNT(name)"], 1)
|
||||
|
@ -123,6 +157,7 @@ class TestMediumsTable(tests.util.TestCase):
|
|||
medium = self.table.create(self.album, "Medium 1", number=1)
|
||||
self.assertTrue(medium.delete())
|
||||
self.assertIsNone(self.table.index(medium))
|
||||
self.assertFalse(self.album.has_medium(medium))
|
||||
|
||||
cur = self.sql("SELECT COUNT(name) FROM media")
|
||||
self.assertEqual(cur.fetchone()["COUNT(name)"], 0)
|
||||
|
@ -172,17 +207,19 @@ class TestMediumsTable(tests.util.TestCase):
|
|||
self.assertEqual(len(mediums2), 0)
|
||||
|
||||
mediums2.load(now=True)
|
||||
self.assertEqual(len(mediums2), 2)
|
||||
self.assertEqual(len(mediums2.store), 2)
|
||||
|
||||
self.assertEqual(mediums2.get_item(0).albumid, self.album.albumid)
|
||||
self.assertEqual(mediums2.get_item(0).name, "")
|
||||
self.assertEqual(mediums2.get_item(0).number, 1)
|
||||
self.assertEqual(mediums2.get_item(0).type, "")
|
||||
self.assertEqual(mediums2.store.get_item(0).albumid,
|
||||
self.album.albumid)
|
||||
self.assertEqual(mediums2.store.get_item(0).name, "")
|
||||
self.assertEqual(mediums2.store.get_item(0).number, 1)
|
||||
self.assertEqual(mediums2.store.get_item(0).type, "")
|
||||
|
||||
self.assertEqual(mediums2.get_item(1).albumid, self.album.albumid)
|
||||
self.assertEqual(mediums2.get_item(1).name, "Medium 2")
|
||||
self.assertEqual(mediums2.get_item(1).number, 2)
|
||||
self.assertEqual(mediums2.get_item(1).type, "Digital Media")
|
||||
self.assertEqual(mediums2.store.get_item(1).albumid,
|
||||
self.album.albumid)
|
||||
self.assertEqual(mediums2.store.get_item(1).name, "Medium 2")
|
||||
self.assertEqual(mediums2.store.get_item(1).number, 2)
|
||||
self.assertEqual(mediums2.store.get_item(1).type, "Digital Media")
|
||||
|
||||
def test_lookup(self):
|
||||
"""Test looking up medium playlists."""
|
||||
|
|
|
@ -21,6 +21,7 @@ class TestPlaylistRow(unittest.TestCase):
|
|||
self.table.move_track_up = unittest.mock.Mock(return_value=True)
|
||||
self.table.get_trackids = unittest.mock.Mock(return_value={1, 2, 3})
|
||||
self.table.get_track_order = unittest.mock.Mock()
|
||||
self.table.refilter = unittest.mock.Mock()
|
||||
self.table.queue = emmental.db.idle.Queue()
|
||||
self.table.update = unittest.mock.Mock(return_value=True)
|
||||
|
||||
|
@ -65,14 +66,28 @@ class TestPlaylistRow(unittest.TestCase):
|
|||
|
||||
def test_children(self):
|
||||
"""Test the child playlist properties."""
|
||||
self.assertIsNone(self.playlist.child_set)
|
||||
self.assertIsNone(self.playlist.children)
|
||||
|
||||
filter = Gtk.Filter()
|
||||
self.playlist.add_children(self.table, filter)
|
||||
table = emmental.db.table.Table(None)
|
||||
self.playlist.add_children(table, set())
|
||||
|
||||
self.assertIsInstance(self.playlist.child_set,
|
||||
emmental.db.table.TableSubset)
|
||||
self.assertEqual(self.playlist.child_set.table, table)
|
||||
self.assertSetEqual(self.playlist.child_set.keyset.keys, set())
|
||||
|
||||
self.assertIsInstance(self.playlist.children, Gtk.FilterListModel)
|
||||
self.assertEqual(self.playlist.children.get_filter(), filter)
|
||||
self.assertEqual(self.playlist.children.get_model(), self.table)
|
||||
self.assertTrue(self.playlist.children.get_incremental())
|
||||
self.assertEqual(self.playlist.children.get_filter(),
|
||||
table.get_filter())
|
||||
self.assertEqual(self.playlist.children.get_model(),
|
||||
self.playlist.child_set)
|
||||
self.assertFalse(self.playlist.children.get_incremental())
|
||||
|
||||
playlist2 = emmental.db.playlist.Playlist(table=self.table,
|
||||
propertyid=2, name="Plist2")
|
||||
playlist2.add_children(table, {1, 2, 3})
|
||||
self.assertSetEqual(playlist2.child_set.keyset.keys, {1, 2, 3})
|
||||
|
||||
def test_parent(self):
|
||||
"""Test the parent playlist property."""
|
||||
|
@ -97,6 +112,20 @@ class TestPlaylistRow(unittest.TestCase):
|
|||
self.table.update.assert_called_with(self.playlist,
|
||||
prop, value)
|
||||
|
||||
def test_add_child(self):
|
||||
"""Test adding a child playlist to the playlist."""
|
||||
table = emmental.db.table.Table(None)
|
||||
child1 = tests.util.table.MockRow(table=table, number=1)
|
||||
child2 = tests.util.table.MockRow(table=table, number=2)
|
||||
self.playlist.add_children(table, set())
|
||||
|
||||
self.playlist.add_child(child1)
|
||||
self.assertIn(child1, self.playlist.child_set)
|
||||
self.table.refilter.assert_called_with(Gtk.FilterChange.LESS_STRICT)
|
||||
|
||||
self.playlist.add_child(child2)
|
||||
self.table.refilter.assert_called_once()
|
||||
|
||||
def test_add_track(self):
|
||||
"""Test adding a track to the playlist."""
|
||||
self.playlist.add_track(self.track, idle=True)
|
||||
|
@ -122,6 +151,18 @@ class TestPlaylistRow(unittest.TestCase):
|
|||
{1: 3, 2: 2, 3: 1})
|
||||
self.table.get_track_order.assert_called_with(self.playlist)
|
||||
|
||||
def test_has_child(self):
|
||||
"""Test the playlist has_child() function."""
|
||||
table = emmental.db.table.Table(None)
|
||||
child = tests.util.table.MockRow(table=table, number=1)
|
||||
self.playlist.add_children(table, set())
|
||||
|
||||
self.assertFalse(self.playlist.has_child(child))
|
||||
self.playlist.add_child(child)
|
||||
self.assertTrue(self.playlist.has_child(child))
|
||||
self.playlist.remove_child(child)
|
||||
self.assertFalse(self.playlist.has_child(child))
|
||||
|
||||
def test_has_track(self):
|
||||
"""Test the playlist has_track() function."""
|
||||
self.assertFalse(self.playlist.has_track(self.track))
|
||||
|
@ -141,6 +182,23 @@ class TestPlaylistRow(unittest.TestCase):
|
|||
self.assertTrue(self.playlist.move_track_up(self.track))
|
||||
self.table.move_track_up.assert_called_with(self.playlist, self.track)
|
||||
|
||||
def test_remove_child(self):
|
||||
"""Test removing a child playlist from the playlist."""
|
||||
table = emmental.db.table.Table(None)
|
||||
child1 = tests.util.table.MockRow(table=table, number=1)
|
||||
child2 = tests.util.table.MockRow(table=table, number=2)
|
||||
self.playlist.add_children(table, set())
|
||||
self.playlist.add_child(child1)
|
||||
self.playlist.add_child(child2)
|
||||
self.table.refilter.reset_mock()
|
||||
|
||||
self.playlist.remove_child(child1)
|
||||
self.assertFalse(child1 in self.playlist.child_set)
|
||||
self.table.refilter.assert_not_called()
|
||||
|
||||
self.playlist.remove_child(child2)
|
||||
self.table.refilter.assert_called_with(Gtk.FilterChange.MORE_STRICT)
|
||||
|
||||
def test_remove_track(self):
|
||||
"""Test removing a track from the playlist."""
|
||||
self.playlist.tracks.trackids.add(self.track.trackid)
|
||||
|
@ -358,6 +416,27 @@ class TestPlaylistTable(tests.util.TestCase):
|
|||
self.table.move_track_up(plist, self.track)
|
||||
self.assertEqual(plist.sort_order, "user")
|
||||
|
||||
def test_refilter(self):
|
||||
"""Test refiltering the playlist table."""
|
||||
self.table.queue.push(unittest.mock.Mock())
|
||||
|
||||
with unittest.mock.patch.object(self.table.get_filter(),
|
||||
"changed") as mock_changed:
|
||||
self.table.refilter(Gtk.FilterChange.MORE_STRICT)
|
||||
self.assertEqual(self.table.queue[0],
|
||||
(self.table._Table__refilter,
|
||||
Gtk.FilterChange.MORE_STRICT))
|
||||
mock_changed.assert_not_called()
|
||||
|
||||
self.table.refilter(Gtk.FilterChange.LESS_STRICT)
|
||||
self.assertEqual(self.table.queue[0],
|
||||
(self.table._Table__refilter,
|
||||
Gtk.FilterChange.LESS_STRICT))
|
||||
mock_changed.assert_not_called()
|
||||
|
||||
self.table.queue.complete()
|
||||
mock_changed.assert_called_with(Gtk.FilterChange.LESS_STRICT)
|
||||
|
||||
def test_remove_track(self):
|
||||
"""Test adding a track to a playlist."""
|
||||
self.assertTrue(self.table.system_tracks)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
# Copyright 2022 (c) Anna Schumaker
|
||||
"""Tests our playlist Gio.ListModel."""
|
||||
import datetime
|
||||
import pathlib
|
||||
import unittest.mock
|
||||
import emmental.db
|
||||
|
@ -326,6 +327,18 @@ class TestSystemPlaylists(tests.util.TestCase):
|
|||
pathlib.Path("/a/b/1.ogg"),
|
||||
self.medium, self.year)
|
||||
|
||||
def test_midnight_alarm(self):
|
||||
"""Test playlist maintenance run every night at midnight."""
|
||||
with unittest.mock.patch.object(self.table.new_tracks,
|
||||
"reload_tracks") as mock_reload:
|
||||
self.table._Table__at_midnight()
|
||||
mock_reload.assert_called()
|
||||
|
||||
with unittest.mock.patch("emmental.alarm.set_alarm") as mock_set:
|
||||
table2 = emmental.db.playlists.Table(self.sql)
|
||||
mock_set.assert_called_with(datetime.time(second=5),
|
||||
table2._Table__at_midnight)
|
||||
|
||||
def test_collection(self):
|
||||
"""Test the Collection playlist."""
|
||||
self.assertIsInstance(self.table.collection,
|
||||
|
@ -615,6 +628,11 @@ class TestSystemPlaylists(tests.util.TestCase):
|
|||
self.table.queued.propertyid).fetchall()
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["trackid"], self.track.trackid)
|
||||
self.assertEqual(self.sql.active_playlist, self.table.queued)
|
||||
|
||||
self.sql.set_active_playlist(self.table.collection)
|
||||
self.table.queued.add_track(self.track)
|
||||
self.assertEqual(self.sql.active_playlist, self.table.queued)
|
||||
|
||||
self.library.deleting = True
|
||||
self.table.queued.reload_tracks()
|
||||
|
|
|
@ -50,119 +50,166 @@ class TestRow(unittest.TestCase):
|
|||
|
||||
|
||||
@unittest.mock.patch("gi.repository.Gtk.Filter.changed")
|
||||
class TestFilter(unittest.TestCase):
|
||||
"""Tests our database row Filter."""
|
||||
class TestKeySet(unittest.TestCase):
|
||||
"""Tests our KeySet for holding database Rows."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
self.filter = emmental.db.table.Filter()
|
||||
self.keyset = emmental.db.table.KeySet()
|
||||
self.table = Gio.ListStore()
|
||||
self.row1 = tests.util.table.MockRow(number=1, table=self.table)
|
||||
self.row2 = tests.util.table.MockRow(number=2, table=self.table)
|
||||
|
||||
def test_init(self, mock_changed: unittest.mock.Mock):
|
||||
"""Test that the filter is created correctly."""
|
||||
self.assertIsInstance(self.filter, Gtk.Filter)
|
||||
self.assertIsNone(self.filter._keys, None)
|
||||
self.assertEqual(self.filter.n_keys, -1)
|
||||
"""Test that the KeySet is created correctly."""
|
||||
self.assertIsInstance(self.keyset, Gtk.Filter)
|
||||
self.assertIsNone(self.keyset._keys, None)
|
||||
self.assertEqual(self.keyset.n_keys, -1)
|
||||
|
||||
filter2 = emmental.db.table.Filter(keys={1, 2, 3})
|
||||
self.assertSetEqual(filter2._keys, {1, 2, 3})
|
||||
self.assertEqual(filter2.n_keys, 3)
|
||||
keyset2 = emmental.db.table.KeySet(keys={1, 2, 3})
|
||||
self.assertSetEqual(keyset2._keys, {1, 2, 3})
|
||||
self.assertEqual(keyset2.n_keys, 3)
|
||||
|
||||
def test_subtract(self, mock_changed: unittest.mock.Mock):
|
||||
"""Test subtracting two filters."""
|
||||
filter2 = emmental.db.table.Filter(keys={2, 3})
|
||||
self.assertIsNone(self.filter - self.filter)
|
||||
self.assertIsNone(self.filter - filter2)
|
||||
self.assertSetEqual(filter2 - self.filter, {2, 3})
|
||||
"""Test subtracting two KeySets."""
|
||||
keyset2 = emmental.db.table.KeySet(keys={2, 3})
|
||||
self.assertIsNone(self.keyset - self.keyset)
|
||||
self.assertIsNone(self.keyset - keyset2)
|
||||
self.assertSetEqual(keyset2 - self.keyset, {2, 3})
|
||||
|
||||
self.filter.keys = {1, 2, 3, 4, 5}
|
||||
self.assertSetEqual(self.filter - filter2, {1, 4, 5})
|
||||
self.assertSetEqual(filter2 - self.filter, set())
|
||||
self.keyset.keys = {1, 2, 3, 4, 5}
|
||||
self.assertSetEqual(self.keyset - keyset2, {1, 4, 5})
|
||||
self.assertSetEqual(keyset2 - self.keyset, set())
|
||||
|
||||
def test_strictness(self, mock_changed: unittest.mock.Mock):
|
||||
"""Test checking strictness."""
|
||||
self.assertEqual(self.filter.get_strictness(), Gtk.FilterMatch.ALL)
|
||||
self.filter._keys = set()
|
||||
self.assertEqual(self.filter.get_strictness(), Gtk.FilterMatch.NONE)
|
||||
self.filter._keys = {1, 2, 3}
|
||||
self.assertEqual(self.filter.get_strictness(), Gtk.FilterMatch.SOME)
|
||||
self.assertEqual(self.keyset.get_strictness(), Gtk.FilterMatch.ALL)
|
||||
self.keyset._keys = set()
|
||||
self.assertEqual(self.keyset.get_strictness(), Gtk.FilterMatch.NONE)
|
||||
self.keyset._keys = {1, 2, 3}
|
||||
self.assertEqual(self.keyset.get_strictness(), Gtk.FilterMatch.SOME)
|
||||
|
||||
def test_add_row(self, mock_changed: unittest.mock.Mock):
|
||||
"""Test adding Rows to the filter."""
|
||||
self.filter.add_row(self.row1)
|
||||
self.assertIsNone(self.filter.keys)
|
||||
"""Test adding Rows to the KeySet."""
|
||||
mock_added = unittest.mock.Mock()
|
||||
self.keyset.connect("key-added", mock_added)
|
||||
|
||||
self.filter.keys = set()
|
||||
self.filter.add_row(self.row1)
|
||||
self.assertSetEqual(self.filter.keys, {1})
|
||||
mock_changed.assert_called_with(Gtk.FilterChange.LESS_STRICT)
|
||||
self.assertEqual(self.filter.n_keys, 1)
|
||||
self.keyset.add_row(self.row1)
|
||||
self.assertIsNone(self.keyset.keys)
|
||||
mock_added.assert_not_called()
|
||||
|
||||
self.filter.add_row(self.row2)
|
||||
self.assertSetEqual(self.filter.keys, {1, 2})
|
||||
self.keyset.keys = set()
|
||||
self.keyset.add_row(self.row1)
|
||||
self.assertSetEqual(self.keyset.keys, {1})
|
||||
self.assertEqual(self.keyset.n_keys, 1)
|
||||
mock_changed.assert_called_with(Gtk.FilterChange.LESS_STRICT)
|
||||
self.assertEqual(self.filter.n_keys, 2)
|
||||
mock_added.assert_called_with(self.keyset, 1)
|
||||
|
||||
self.keyset.add_row(self.row2)
|
||||
self.assertSetEqual(self.keyset.keys, {1, 2})
|
||||
self.assertEqual(self.keyset.n_keys, 2)
|
||||
mock_changed.assert_called_with(Gtk.FilterChange.LESS_STRICT)
|
||||
mock_added.assert_called_with(self.keyset, 2)
|
||||
|
||||
mock_changed.reset_mock()
|
||||
mock_added.reset_mock()
|
||||
self.keyset.add_row(self.row2)
|
||||
self.assertSetEqual(self.keyset.keys, {1, 2})
|
||||
mock_changed.assert_not_called()
|
||||
mock_added.assert_not_called()
|
||||
|
||||
def test_remove_row(self, mock_changed: unittest.mock.Mock):
|
||||
"""Test removing Rows from the filter."""
|
||||
self.filter.remove_row(self.row1)
|
||||
mock_changed.assert_not_called()
|
||||
"""Test removing Rows from the KeySet."""
|
||||
mock_removed = unittest.mock.Mock()
|
||||
self.keyset.connect("key-removed", mock_removed)
|
||||
|
||||
self.filter.keys = {1, 2}
|
||||
self.filter.remove_row(self.row1)
|
||||
self.assertSetEqual(self.filter._keys, {2})
|
||||
self.keyset.remove_row(self.row1)
|
||||
mock_changed.assert_not_called()
|
||||
mock_removed.assert_not_called()
|
||||
|
||||
self.keyset.keys = {1, 2}
|
||||
self.keyset.remove_row(self.row1)
|
||||
self.assertSetEqual(self.keyset._keys, {2})
|
||||
self.assertEqual(self.keyset.n_keys, 1)
|
||||
mock_changed.assert_called_with(Gtk.FilterChange.MORE_STRICT)
|
||||
self.assertEqual(self.filter.n_keys, 1)
|
||||
mock_removed.assert_called_with(self.keyset, 1)
|
||||
|
||||
mock_changed.reset_mock()
|
||||
self.filter.remove_row(self.row2)
|
||||
self.assertSetEqual(self.filter._keys, set())
|
||||
mock_removed.reset_mock()
|
||||
self.keyset.remove_row(self.row1)
|
||||
self.assertSetEqual(self.keyset.keys, {2})
|
||||
self.assertEqual(self.keyset.n_keys, 1)
|
||||
mock_changed.assert_not_called()
|
||||
mock_removed.assert_not_called()
|
||||
|
||||
self.keyset.remove_row(self.row2)
|
||||
self.assertSetEqual(self.keyset._keys, set())
|
||||
self.assertEqual(self.keyset.n_keys, 0)
|
||||
mock_changed.assert_called_with(Gtk.FilterChange.MORE_STRICT)
|
||||
self.assertEqual(self.filter.n_keys, 0)
|
||||
mock_removed.assert_called_with(self.keyset, 2)
|
||||
|
||||
def test_keys(self, mock_changed: unittest.mock.Mock):
|
||||
"""Test setting and getting the filter keys property."""
|
||||
self.assertIsNone(self.filter.keys)
|
||||
"""Test getting and setting the KeySet.keys property."""
|
||||
mock_keys_changed = unittest.mock.Mock()
|
||||
self.keyset.connect("keys-changed", mock_keys_changed)
|
||||
|
||||
self.filter.keys = {1, 2, 3}
|
||||
self.assertSetEqual(self.filter._keys, {1, 2, 3})
|
||||
mock_changed.assert_called_with(Gtk.FilterChange.MORE_STRICT)
|
||||
self.assertEqual(self.filter.n_keys, 3)
|
||||
|
||||
mock_changed.reset_mock()
|
||||
self.filter.keys = {1, 2}
|
||||
self.assertSetEqual(self.filter.keys, {1, 2})
|
||||
mock_changed.assert_called_with(Gtk.FilterChange.MORE_STRICT)
|
||||
self.assertEqual(self.filter.n_keys, 2)
|
||||
|
||||
mock_changed.reset_mock()
|
||||
self.filter.keys = {1, 2}
|
||||
self.assertIsNone(self.keyset.keys)
|
||||
self.keyset.keys = None
|
||||
self.assertIsNone(self.keyset.keys)
|
||||
mock_changed.assert_not_called()
|
||||
mock_keys_changed.assert_not_called()
|
||||
|
||||
self.filter.keys = {1, 2, 3}
|
||||
self.assertSetEqual(self.filter.keys, {1, 2, 3})
|
||||
self.keyset.keys = {1, 2, 3}
|
||||
self.assertSetEqual(self.keyset._keys, {1, 2, 3})
|
||||
self.assertEqual(self.keyset.n_keys, 3)
|
||||
mock_changed.assert_called_with(Gtk.FilterChange.MORE_STRICT)
|
||||
mock_keys_changed.assert_called_with(self.keyset, set(), {1, 2, 3})
|
||||
|
||||
mock_changed.reset_mock()
|
||||
self.keyset.keys = {1, 2}
|
||||
self.assertSetEqual(self.keyset.keys, {1, 2})
|
||||
self.assertEqual(self.keyset.n_keys, 2)
|
||||
mock_changed.assert_called_with(Gtk.FilterChange.MORE_STRICT)
|
||||
mock_keys_changed.assert_called_with(self.keyset, {3}, set())
|
||||
|
||||
mock_changed.reset_mock()
|
||||
mock_keys_changed.reset_mock()
|
||||
self.keyset.keys = {1, 2}
|
||||
mock_changed.assert_not_called()
|
||||
mock_keys_changed.assert_not_called()
|
||||
|
||||
self.keyset.keys = {1, 2, 3}
|
||||
self.assertSetEqual(self.keyset.keys, {1, 2, 3})
|
||||
mock_changed.assert_called_with(Gtk.FilterChange.LESS_STRICT)
|
||||
mock_keys_changed.assert_called_with(self.keyset, set(), {3})
|
||||
|
||||
self.filter.keys = {4, 5, 6}
|
||||
self.assertSetEqual(self.filter._keys, {4, 5, 6})
|
||||
self.keyset.keys = {4, 5, 6}
|
||||
self.assertSetEqual(self.keyset._keys, {4, 5, 6})
|
||||
mock_changed.assert_called_with(Gtk.FilterChange.DIFFERENT)
|
||||
mock_keys_changed.assert_called_with(self.keyset, {1, 2, 3}, {4, 5, 6})
|
||||
|
||||
self.filter.keys = None
|
||||
self.assertIsNone(self.filter._keys)
|
||||
self.keyset.keys = None
|
||||
self.assertIsNone(self.keyset._keys)
|
||||
self.assertEqual(self.keyset.n_keys, -1)
|
||||
mock_changed.assert_called_with(Gtk.FilterChange.LESS_STRICT)
|
||||
self.assertEqual(self.filter.n_keys, -1)
|
||||
mock_keys_changed.assert_called_with(self.keyset, {4, 5, 6}, set())
|
||||
|
||||
def test_match(self, mock_changed: unittest.mock.Mock):
|
||||
"""Test matching playlists."""
|
||||
self.assertTrue(self.filter.match(self.row1))
|
||||
self.filter.keys = {1, 2, 3}
|
||||
self.assertTrue(self.filter.match(self.row1))
|
||||
self.filter.keys = {4, 5, 6}
|
||||
self.assertFalse(self.filter.match(self.row1))
|
||||
self.filter.keys = set()
|
||||
self.assertFalse(self.filter.match(self.row1))
|
||||
def test_match_contains(self, mock_changed: unittest.mock.Mock):
|
||||
"""Test matching Rows and the __contains__() magic method."""
|
||||
self.assertTrue(self.keyset.match(self.row1))
|
||||
self.assertTrue(self.row1 in self.keyset)
|
||||
|
||||
self.keyset.keys = {1, 2, 3}
|
||||
self.assertTrue(self.keyset.match(self.row1))
|
||||
self.assertTrue(self.row1 in self.keyset)
|
||||
|
||||
self.keyset.keys = {4, 5, 6}
|
||||
self.assertFalse(self.keyset.match(self.row1))
|
||||
self.assertFalse(self.row1 in self.keyset)
|
||||
|
||||
self.keyset.keys = set()
|
||||
self.assertFalse(self.keyset.match(self.row1))
|
||||
self.assertFalse(self.row1 in self.keyset)
|
||||
|
||||
|
||||
class TestTable(tests.util.TestCase):
|
||||
|
@ -178,7 +225,7 @@ class TestTable(tests.util.TestCase):
|
|||
self.assertIsInstance(self.table, Gtk.FilterListModel)
|
||||
self.assertIsInstance(self.table.queue, emmental.db.idle.Queue)
|
||||
self.assertIsInstance(self.table.get_filter(),
|
||||
emmental.db.table.Filter)
|
||||
emmental.db.table.KeySet)
|
||||
self.assertIsInstance(self.table.store, emmental.store.SortedList)
|
||||
self.assertIsInstance(self.table.rows, dict)
|
||||
|
||||
|
@ -186,9 +233,9 @@ class TestTable(tests.util.TestCase):
|
|||
self.assertEqual(self.table.get_model(), self.table.store)
|
||||
self.assertEqual(self.table.store.key_func, self.table.get_sort_key)
|
||||
self.assertDictEqual(self.table.rows, {})
|
||||
self.assertTrue(self.table.get_incremental())
|
||||
self.assertFalse(self.table.get_incremental())
|
||||
|
||||
filter2 = emmental.db.table.Filter()
|
||||
filter2 = emmental.db.table.KeySet()
|
||||
queue2 = emmental.db.idle.Queue()
|
||||
table2 = emmental.db.table.Table(self.sql, filter=filter2,
|
||||
queue=queue2)
|
||||
|
@ -308,9 +355,12 @@ class TestTableFunctions(tests.util.TestCase):
|
|||
def test_delete(self):
|
||||
"""Test deleting rows."""
|
||||
row = self.table.create(number=1)
|
||||
|
||||
with unittest.mock.patch.object(self.sql, "commit") as mock_commit:
|
||||
self.assertTrue(row.delete())
|
||||
self.assertEqual(len(self.table), 0)
|
||||
self.assertDictEqual(self.table.rows, dict())
|
||||
mock_commit.assert_called()
|
||||
|
||||
self.assertFalse(row.delete())
|
||||
|
||||
|
@ -318,9 +368,13 @@ class TestTableFunctions(tests.util.TestCase):
|
|||
"""Test filtering Rows in the table."""
|
||||
for n in [1, 121, 212, 333]:
|
||||
self.table.create(number=n)
|
||||
self.table.queue.push(unittest.mock.Mock())
|
||||
|
||||
self.table.filter("*3*")
|
||||
self.assertIsNone(self.table.get_filter().keys)
|
||||
self.assertEqual(self.table.queue[0], (self.table._filter_idle, "*3*"))
|
||||
|
||||
self.table.filter("*2*")
|
||||
self.assertIsNone(self.table.get_filter().keys)
|
||||
self.assertEqual(self.table.queue[0], (self.table._filter_idle, "*2*"))
|
||||
|
||||
self.table.queue.complete()
|
||||
|
@ -390,3 +444,147 @@ class TestTableFunctions(tests.util.TestCase):
|
|||
|
||||
self.table.create(number=3)
|
||||
self.assertFalse(self.table.update(row, "number", 3))
|
||||
|
||||
|
||||
class TestTableSubset(tests.util.TestCase):
|
||||
"""Tests the TableSubset."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
super().setUp()
|
||||
self.table = tests.util.table.MockTable(self.sql)
|
||||
self.subset = emmental.db.table.TableSubset(self.table)
|
||||
self.rows = [self.table.create(number=i) for i in range(5)]
|
||||
|
||||
def test_init(self):
|
||||
"""Test that the TableSubset was set up properly."""
|
||||
self.assertIsInstance(self.subset, Gio.ListModel)
|
||||
self.assertIsInstance(self.subset, GObject.GObject)
|
||||
self.assertIsInstance(self.subset.keyset, emmental.db.table.KeySet)
|
||||
self.assertSetEqual(self.subset.keyset.keys, set())
|
||||
self.assertEqual(self.subset.table, self.table)
|
||||
|
||||
subset2 = emmental.db.table.TableSubset(self.table, keys={1, 2, 3})
|
||||
self.assertSetEqual(subset2.keyset.keys, {1, 2, 3})
|
||||
|
||||
def test_get_item_type(self):
|
||||
"""Test the Gio.ListModel.get_item_type() function."""
|
||||
self.assertEqual(self.subset.get_item_type(),
|
||||
emmental.db.table.Row.__gtype__)
|
||||
|
||||
def test_get_n_items(self):
|
||||
"""Test the Gio.ListModel.get_n_items() function."""
|
||||
self.assertEqual(self.subset.get_n_items(), 0)
|
||||
self.assertEqual(self.subset.n_rows, 0)
|
||||
|
||||
self.subset.add_row(self.rows[0])
|
||||
self.assertEqual(self.subset.get_n_items(), 0)
|
||||
self.assertEqual(self.subset.n_rows, 0)
|
||||
|
||||
self.table.loaded = True
|
||||
self.assertEqual(self.subset.get_n_items(), 1)
|
||||
self.assertEqual(self.subset.n_rows, 1)
|
||||
|
||||
self.table.loaded = False
|
||||
self.assertEqual(self.subset.get_n_items(), 0)
|
||||
self.assertEqual(self.subset.n_rows, 0)
|
||||
|
||||
def test_get_item(self):
|
||||
"""Test the Gio.ListModel.get_item() function."""
|
||||
for row in self.rows:
|
||||
self.subset.add_row(row)
|
||||
|
||||
self.assertListEqual(self.subset._items, [])
|
||||
|
||||
for i, row in enumerate(self.rows):
|
||||
with self.subTest(i=i, row=row.number):
|
||||
self.assertIsNone(self.subset.get_item(i))
|
||||
|
||||
self.table.loaded = True
|
||||
self.assertEqual(self.subset.get_item(i), row)
|
||||
self.assertEqual(self.subset._items[i], row)
|
||||
|
||||
self.table.loaded = False
|
||||
self.assertIsNone(self.subset.get_item(i))
|
||||
|
||||
def test_add_row(self):
|
||||
"""Test adding a row to the TableSubset."""
|
||||
expected = set()
|
||||
self.table.loaded = True
|
||||
self.assertListEqual(self.subset._items, [])
|
||||
|
||||
changed = unittest.mock.Mock()
|
||||
self.subset.connect("items-changed", changed)
|
||||
|
||||
for n, i in enumerate([2, 0, 4, 1, 3], start=1):
|
||||
row = self.rows[i]
|
||||
with self.subTest(i=i, row=row.number):
|
||||
expected.add(i)
|
||||
self.subset.add_row(row)
|
||||
self.assertSetEqual(self.subset.keyset.keys, expected)
|
||||
self.assertEqual(self.subset.n_rows, n)
|
||||
changed.assert_called_with(self.subset,
|
||||
sorted(expected).index(i), 0, 1)
|
||||
|
||||
self.assertListEqual(self.subset._items, self.rows)
|
||||
self.assertListEqual(list(self.subset), self.rows)
|
||||
|
||||
def test_remove_row(self):
|
||||
"""Test removing a row from the TableSubset."""
|
||||
self.table.loaded = True
|
||||
[self.subset.add_row(row) for row in self.rows]
|
||||
expected = {row.number for row in self.rows}
|
||||
|
||||
changed = unittest.mock.Mock()
|
||||
self.subset.connect("items-changed", changed)
|
||||
|
||||
for n, i in enumerate([2, 0, 4, 1, 3], start=1):
|
||||
row = self.rows[i]
|
||||
rm = sorted(expected).index(i)
|
||||
with self.subTest(i=i, row=row.number):
|
||||
expected.discard(i)
|
||||
self.subset.remove_row(row)
|
||||
self.assertSetEqual(self.subset.keyset.keys, expected)
|
||||
self.assertEqual(self.subset.n_rows, 5 - n)
|
||||
changed.assert_called_with(self.subset, rm, 1, 0)
|
||||
|
||||
self.assertEqual(self.subset.n_rows, 0)
|
||||
|
||||
def test_contains(self):
|
||||
"""Test the __contains__() magic method."""
|
||||
self.table.loaded = True
|
||||
self.assertFalse(self.rows[0] in self.subset)
|
||||
self.subset.add_row(self.rows[0])
|
||||
self.assertTrue(self.rows[0] in self.subset)
|
||||
|
||||
def test_table_not_loaded(self):
|
||||
"""Test operations when the table hasn't been loaded."""
|
||||
self.subset.add_row(self.rows[0])
|
||||
self.assertListEqual(self.subset._items, [])
|
||||
self.assertEqual(self.subset.n_rows, 0)
|
||||
self.assertIsNone(self.subset.get_item(0))
|
||||
|
||||
self.subset.remove_row(self.rows[0])
|
||||
self.assertListEqual(self.subset._items, [])
|
||||
self.assertEqual(self.subset.n_rows, 0)
|
||||
|
||||
def test_table_loaded(self):
|
||||
"""Test changing the value of Table.loaded."""
|
||||
changed = unittest.mock.Mock()
|
||||
self.subset.connect("items-changed", changed)
|
||||
|
||||
self.table.loaded = True
|
||||
changed.assert_not_called()
|
||||
self.table.loaded = False
|
||||
changed.assert_not_called()
|
||||
|
||||
self.subset.add_row(self.rows[0])
|
||||
self.subset.add_row(self.rows[1])
|
||||
|
||||
self.table.loaded = True
|
||||
self.assertEqual(self.subset.n_rows, 2)
|
||||
changed.assert_called_with(self.subset, 0, 0, 2)
|
||||
|
||||
self.table.loaded = False
|
||||
self.assertEqual(self.subset.n_rows, 0)
|
||||
changed.assert_called_with(self.subset, 0, 2, 0)
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
# Copyright 2022 (c) Anna Schumaker
|
||||
"""Tests our Mutagen wrapper."""
|
||||
import pathlib
|
||||
import threading
|
||||
import unittest.mock
|
||||
import emmental.db.tagger
|
||||
import emmental.thread
|
||||
import tests.util
|
||||
|
||||
|
||||
|
@ -276,8 +276,8 @@ class TestTaggerThread(tests.util.TestCase):
|
|||
|
||||
def test_init(self, mock_file: unittest.mock.Mock):
|
||||
"""Test that the tagger thread was initialized properly."""
|
||||
self.assertIsInstance(self.tagger, threading.Thread)
|
||||
self.assertIsInstance(self.tagger._condition, threading.Condition)
|
||||
self.assertIsInstance(self.tagger, emmental.thread.Thread)
|
||||
self.assertIsNone(self.tagger._connection)
|
||||
self.assertTrue(self.tagger.is_alive())
|
||||
|
||||
def test_stop(self, mock_file: unittest.mock.Mock):
|
||||
|
@ -285,74 +285,49 @@ class TestTaggerThread(tests.util.TestCase):
|
|||
mock_connection = unittest.mock.Mock()
|
||||
mock_connection.close = unittest.mock.Mock()
|
||||
|
||||
self.tagger._file = "abcde"
|
||||
self.tagger._mtime = 12345
|
||||
self.tagger._connection = mock_connection
|
||||
|
||||
with unittest.mock.patch.object(self.tagger._condition, "notify",
|
||||
wraps=self.tagger._condition.notify) \
|
||||
as mock_notify:
|
||||
self.tagger.stop()
|
||||
self.assertIsNone(self.tagger._file)
|
||||
self.assertIsNone(self.tagger._mtime)
|
||||
mock_notify.assert_called()
|
||||
|
||||
self.assertFalse(self.tagger.is_alive())
|
||||
self.assertIsNone(self.tagger._connection)
|
||||
mock_connection.close.assert_called()
|
||||
|
||||
def test_tag_file(self, mock_file: unittest.mock.Mock):
|
||||
"""Test asking the thread to tag a file."""
|
||||
path = pathlib.Path("/a/b/c.ogg")
|
||||
|
||||
self.assertIsInstance(self.tagger.ready, threading.Event)
|
||||
self.assertIsNone(self.tagger._file)
|
||||
self.assertIsNone(self.tagger._tags)
|
||||
self.assertIsNone(self.tagger._mtime)
|
||||
self.assertTrue(self.tagger.ready.is_set())
|
||||
|
||||
mock_file.return_value = None
|
||||
|
||||
self.tagger.ready.set()
|
||||
self.tagger._tags = 12345
|
||||
self.tagger.tag_file(path, None)
|
||||
self.assertFalse(self.tagger.ready.is_set())
|
||||
self.assertEqual(self.tagger._file, path)
|
||||
self.assertIsNone(self.tagger._mtime)
|
||||
self.assertIsNone(self.tagger._tags)
|
||||
self.tagger.tag_file(path, mtime=None)
|
||||
self.assertEqual(self.tagger._task, {"path": path, "mtime": None})
|
||||
|
||||
self.tagger.ready.wait()
|
||||
self.assertIsNone(self.tagger._tags)
|
||||
mock_file.assert_called_with(pathlib.Path("/a/b/c.ogg"), None)
|
||||
|
||||
mock_file.return_value = self.make_tags(dict())
|
||||
self.tagger.tag_file(path, 12345)
|
||||
self.assertEqual(self.tagger._mtime, 12345)
|
||||
self.tagger.tag_file(path, mtime=12345)
|
||||
self.assertEqual(self.tagger._task, {"path": path, "mtime": 12345})
|
||||
|
||||
self.tagger.ready.wait()
|
||||
self.assertIsNotNone(self.tagger._tags)
|
||||
mock_file.assert_called_with(self.tagger._file, 12345)
|
||||
mock_file.assert_called_with(path, 12345)
|
||||
|
||||
def test_get_result(self, mock_file: unittest.mock.Mock):
|
||||
"""Test creating a Tags structure after tagging."""
|
||||
mock_file.return_value = None
|
||||
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), None)
|
||||
self.assertTupleEqual(self.tagger.get_result(self.sql, self.library),
|
||||
(None, None))
|
||||
self.assertIsNone(self.tagger.get_result(db=self.sql,
|
||||
library=self.library))
|
||||
|
||||
track_path = pathlib.Path("/a/b/c.ogg")
|
||||
self.tagger.tag_file(track_path, mtime=None)
|
||||
self.tagger.ready.wait()
|
||||
self.assertTupleEqual(self.tagger.get_result(self.sql, self.library),
|
||||
(pathlib.Path("/a/b/c.ogg"), None))
|
||||
self.assertIsNone(self.tagger._file)
|
||||
self.assertTupleEqual(self.tagger.get_result(db=self.sql,
|
||||
library=self.library),
|
||||
(track_path, None))
|
||||
|
||||
mock_file.return_value = self.make_tags(dict())
|
||||
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), None)
|
||||
self.tagger.tag_file(track_path, mtime=None)
|
||||
self.tagger.ready.wait()
|
||||
(file, tags) = self.tagger.get_result(self.sql, self.library)
|
||||
self.assertEqual(file, pathlib.Path("/a/b/c.ogg"))
|
||||
self.assertIsInstance(tags, emmental.db.tagger.Tags)
|
||||
self.assertIsNone(self.tagger._file)
|
||||
self.assertIsNone(self.tagger._mtime)
|
||||
self.assertIsNone(self.tagger._tags)
|
||||
res = self.tagger.get_result(db=self.sql, library=self.library)
|
||||
self.assertTupleEqual(res, (track_path, res[1]))
|
||||
|
||||
@unittest.mock.patch("emmental.db.connection.Connection.__call__")
|
||||
@unittest.mock.patch("musicbrainzngs.get_artist_by_id")
|
||||
|
@ -370,7 +345,7 @@ class TestTaggerThread(tests.util.TestCase):
|
|||
mock_cursor.fetchone = unittest.mock.Mock(return_value=None)
|
||||
mock_connection.return_value = mock_cursor
|
||||
|
||||
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), None)
|
||||
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), mtime=None)
|
||||
self.tagger.ready.wait()
|
||||
self.assertEqual(audio_tags.artists[0].name, "Some Artist")
|
||||
self.assertEqual(audio_tags.artists[1].name, "Some Artist")
|
||||
|
@ -394,7 +369,7 @@ class TestTaggerThread(tests.util.TestCase):
|
|||
|
||||
self.assertIsNone(self.tagger._connection)
|
||||
|
||||
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), None)
|
||||
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), mtime=None)
|
||||
self.tagger.ready.wait()
|
||||
self.assertIsInstance(self.tagger._connection,
|
||||
emmental.db.connection.Connection)
|
||||
|
|
|
@ -247,10 +247,14 @@ class TestTrackTable(tests.util.TestCase):
|
|||
|
||||
def test_create_restore(self):
|
||||
"""Test restoring saved track data."""
|
||||
now = datetime.datetime.now()
|
||||
now = datetime.datetime.utcnow()
|
||||
today = now.date()
|
||||
yesterday = today - datetime.timedelta(days=1)
|
||||
self.sql("""INSERT INTO saved_track_data
|
||||
(mbid, favorite, playcount, lastplayed, laststarted)
|
||||
VALUES (?, ?, ?, ? , ?)""", "ab-cd-ef", True, 42, now, now)
|
||||
(mbid, favorite, playcount,
|
||||
lastplayed, laststarted, added)
|
||||
VALUES (?, ?, ?, ? , ?, ?)""",
|
||||
"ab-cd-ef", True, 42, now, now, yesterday)
|
||||
|
||||
track1 = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
||||
self.medium, self.year)
|
||||
|
@ -258,6 +262,7 @@ class TestTrackTable(tests.util.TestCase):
|
|||
self.assertEqual(track1.playcount, 0)
|
||||
self.assertIsNone(track1.lastplayed)
|
||||
self.assertIsNone(track1.laststarted)
|
||||
self.assertEqual(track1.added, today)
|
||||
|
||||
row = self.sql("SELECT COUNT(*) FROM saved_track_data").fetchone()
|
||||
self.assertEqual(row["COUNT(*)"], 1)
|
||||
|
@ -268,6 +273,7 @@ class TestTrackTable(tests.util.TestCase):
|
|||
self.assertEqual(track2.playcount, 42)
|
||||
self.assertEqual(track2.lastplayed, now)
|
||||
self.assertEqual(track2.laststarted, now)
|
||||
self.assertEqual(track2.added, yesterday)
|
||||
|
||||
row = self.sql("SELECT COUNT(*) FROM saved_track_data").fetchone()
|
||||
self.assertEqual(row["COUNT(*)"], 0)
|
||||
|
@ -286,6 +292,20 @@ class TestTrackTable(tests.util.TestCase):
|
|||
|
||||
self.assertFalse(track.delete())
|
||||
|
||||
def test_delete_listens(self):
|
||||
"""Test deleting listens from the listenbrainz_queue."""
|
||||
track1 = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
||||
self.medium, self.year, length=10)
|
||||
track2 = self.tracks.create(self.library, pathlib.Path("/a/b/2.ogg"),
|
||||
self.medium, self.year, length=10)
|
||||
|
||||
for track in [track1, track2]:
|
||||
track.start()
|
||||
track.stop(9)
|
||||
|
||||
self.tracks.delete_listens([1, 2])
|
||||
self.assertListEqual(self.tracks.get_n_listens(5), [])
|
||||
|
||||
def test_delete_save(self):
|
||||
"""Test saving track data when a track is deleted."""
|
||||
now = datetime.datetime.now()
|
||||
|
@ -308,6 +328,7 @@ class TestTrackTable(tests.util.TestCase):
|
|||
self.assertEqual(rows[0]["laststarted"], now)
|
||||
self.assertEqual(rows[0]["lastplayed"], now)
|
||||
self.assertEqual(rows[0]["playcount"], 42)
|
||||
self.assertEqual(rows[0]["added"], datetime.datetime.utcnow().date())
|
||||
|
||||
def test_filter(self):
|
||||
"""Test filtering the Track table."""
|
||||
|
@ -478,6 +499,40 @@ class TestTrackTable(tests.util.TestCase):
|
|||
self.assertListEqual(self.tracks.get_genres(track),
|
||||
[genre1, genre2])
|
||||
|
||||
def test_get_n_listens(self):
|
||||
"""Test the get_n_listens() function."""
|
||||
track1 = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
||||
self.medium, self.year, length=10)
|
||||
track2 = self.tracks.create(self.library, pathlib.Path("/a/b/2.ogg"),
|
||||
self.medium, self.year, length=12)
|
||||
|
||||
self.assertListEqual(self.tracks.get_n_listens(2), [])
|
||||
|
||||
track1.start()
|
||||
track1.stop(8)
|
||||
ts1 = track1.lastplayed
|
||||
self.assertListEqual(self.tracks.get_n_listens(2),
|
||||
[(1, track1, ts1)])
|
||||
|
||||
track2.start()
|
||||
track2.stop(11)
|
||||
ts2 = track2.lastplayed
|
||||
self.assertListEqual(self.tracks.get_n_listens(2),
|
||||
[(2, track2, ts2),
|
||||
(1, track1, ts1)])
|
||||
|
||||
track1.start()
|
||||
track1.stop(9)
|
||||
ts3 = track1.lastplayed
|
||||
self.assertListEqual(self.tracks.get_n_listens(2),
|
||||
[(3, track1, ts3),
|
||||
(2, track2, ts2)])
|
||||
|
||||
self.assertListEqual(self.tracks.get_n_listens(4),
|
||||
[(3, track1, ts3),
|
||||
(2, track2, ts2),
|
||||
(1, track1, ts1)])
|
||||
|
||||
def test_mark_path_active(self):
|
||||
"""Test marking a path as active."""
|
||||
self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
||||
|
@ -508,7 +563,11 @@ class TestTrackTable(tests.util.TestCase):
|
|||
track = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
||||
self.medium, self.year)
|
||||
|
||||
with unittest.mock.patch.object(self.sql, "commit",
|
||||
wraps=self.sql.commit) as mock_commit:
|
||||
track.start()
|
||||
mock_commit.assert_called()
|
||||
|
||||
row = self.sql("SELECT laststarted FROM tracks WHERE trackid=?",
|
||||
track.trackid).fetchone()
|
||||
self.assertTrue(track.active)
|
||||
|
@ -523,9 +582,15 @@ class TestTrackTable(tests.util.TestCase):
|
|||
"""Test marking that a Track has stopped playback."""
|
||||
track = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
||||
self.medium, self.year, length=10)
|
||||
track_played = unittest.mock.Mock()
|
||||
self.tracks.connect("track-played", track_played)
|
||||
|
||||
track.start()
|
||||
with unittest.mock.patch.object(self.sql, "commit",
|
||||
wraps=self.sql.commit) as mock_commit:
|
||||
track.stop(3)
|
||||
mock_commit.assert_called()
|
||||
|
||||
row = self.sql("SELECT lastplayed FROM tracks WHERE trackid=?",
|
||||
track.trackid).fetchone()
|
||||
self.assertFalse(track.active)
|
||||
|
@ -534,12 +599,20 @@ class TestTrackTable(tests.util.TestCase):
|
|||
self.assertIsNone(track.lastplayed)
|
||||
self.assertIsNone(self.tracks.current_track)
|
||||
|
||||
cur = self.sql("SELECT trackid, timestamp FROM listenbrainz_queue")
|
||||
self.assertListEqual(cur.fetchall(), [])
|
||||
|
||||
self.playlists.most_played.reload_tracks.assert_not_called()
|
||||
self.playlists.queued.remove_track.assert_not_called()
|
||||
self.playlists.unplayed.remove_track.assert_not_called()
|
||||
track_played.assert_not_called()
|
||||
|
||||
track.start()
|
||||
with unittest.mock.patch.object(self.sql, "commit",
|
||||
wraps=self.sql.commit) as mock_commit:
|
||||
track.stop(8)
|
||||
mock_commit.assert_called()
|
||||
|
||||
row = self.sql("""SELECT lastplayed, playcount FROM tracks
|
||||
WHERE trackid=?""", track.trackid).fetchone()
|
||||
self.assertEqual(row["playcount"], 1)
|
||||
|
@ -547,14 +620,22 @@ class TestTrackTable(tests.util.TestCase):
|
|||
self.assertEqual(row["lastplayed"], track.laststarted)
|
||||
self.assertEqual(track.lastplayed, track.laststarted)
|
||||
|
||||
cur = self.sql("SELECT trackid, timestamp FROM listenbrainz_queue")
|
||||
row = cur.fetchall()[0]
|
||||
self.assertEqual(row["trackid"], track.trackid)
|
||||
self.assertEqual(row["timestamp"], track.lastplayed)
|
||||
|
||||
self.playlists.most_played.reload_tracks.assert_called()
|
||||
self.playlists.queued.remove_track.assert_called_with(track)
|
||||
self.playlists.unplayed.remove_track.assert_called_with(track)
|
||||
track_played.assert_called_with(self.tracks, track)
|
||||
|
||||
def test_stop_restarted_track(self):
|
||||
"""Test marking that a restarted Track has stopped playback."""
|
||||
track = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
||||
self.medium, self.year, length=10)
|
||||
track_played = unittest.mock.Mock()
|
||||
self.tracks.connect("track-played", track_played)
|
||||
|
||||
track.restart()
|
||||
track.stop(3)
|
||||
|
@ -569,9 +650,13 @@ class TestTrackTable(tests.util.TestCase):
|
|||
self.assertIsNone(track.restarted)
|
||||
self.assertIsNone(self.tracks.current_track)
|
||||
|
||||
cur = self.sql("SELECT trackid, timestamp FROM listenbrainz_queue")
|
||||
self.assertListEqual(cur.fetchall(), [])
|
||||
|
||||
self.playlists.most_played.reload_tracks.assert_not_called()
|
||||
self.playlists.queued.remove_track.assert_not_called()
|
||||
self.playlists.unplayed.remove_track.assert_not_called()
|
||||
track_played.assert_not_called()
|
||||
|
||||
track.restart()
|
||||
restarted = track.restarted
|
||||
|
@ -585,9 +670,15 @@ class TestTrackTable(tests.util.TestCase):
|
|||
self.assertEqual(row["laststarted"], restarted)
|
||||
self.assertEqual(track.laststarted, restarted)
|
||||
|
||||
cur = self.sql("SELECT trackid, timestamp FROM listenbrainz_queue")
|
||||
row = cur.fetchall()[0]
|
||||
self.assertEqual(row["trackid"], track.trackid)
|
||||
self.assertEqual(row["timestamp"], track.lastplayed)
|
||||
|
||||
self.playlists.most_played.reload_tracks.assert_called_with(idle=True)
|
||||
self.playlists.queued.remove_track.assert_called_with(track)
|
||||
self.playlists.unplayed.remove_track.assert_called_with(track)
|
||||
track_played.assert_called_with(self.tracks, track)
|
||||
|
||||
def test_current_track(self):
|
||||
"""Test the current-track and have-current-track properties."""
|
||||
|
|
|
@ -75,12 +75,14 @@ class TestYearTable(tests.util.TestCase):
|
|||
|
||||
def test_create(self):
|
||||
"""Test creating a year playlist."""
|
||||
decade = self.sql.decades.create(1980)
|
||||
year = self.table.create(1988)
|
||||
self.assertIsInstance(year, emmental.db.years.Year)
|
||||
self.assertEqual(year.year, 1988)
|
||||
self.assertEqual(year.name, "1988")
|
||||
self.assertEqual(year.sort_order,
|
||||
"release, albumartist, album, mediumno, number")
|
||||
self.assertTrue(year in decade.child_set)
|
||||
|
||||
cur = self.sql("SELECT COUNT(year) FROM years")
|
||||
self.assertEqual(cur.fetchone()["COUNT(year)"], 1)
|
||||
|
@ -93,8 +95,10 @@ class TestYearTable(tests.util.TestCase):
|
|||
|
||||
def test_delete(self):
|
||||
"""Test deleting a year playlist."""
|
||||
decade = self.sql.decades.create(1980)
|
||||
year = self.table.create(1988)
|
||||
self.assertTrue(year.delete())
|
||||
self.assertFalse(year in decade.child_set)
|
||||
|
||||
cur = self.sql("SELECT COUNT(year) FROM years")
|
||||
self.assertEqual(cur.fetchone()["COUNT(year)"], 0)
|
||||
|
|
|
@ -33,34 +33,124 @@ class TestHeader(tests.util.TestCase):
|
|||
self.assertEqual(self.header._title.get_subtitle(),
|
||||
emmental.header.SUBTITLE)
|
||||
|
||||
self.assertEqual(self.header._title.get_tooltip_text(),
|
||||
emmental.gsetup.env_string())
|
||||
|
||||
def test_show_sidebar(self):
|
||||
"""Check that the show sidebar button works as expected."""
|
||||
self.assertIsInstance(self.header._show_sidebar, Gtk.ToggleButton)
|
||||
self.assertEqual(self.header._show_sidebar.props.icon_name,
|
||||
"sidebar-show-symbolic")
|
||||
self.assertFalse(self.header._show_sidebar.props.has_frame)
|
||||
self.assertFalse(self.header._show_sidebar.props.active)
|
||||
|
||||
self.assertFalse(self.header.show_sidebar)
|
||||
self.header.show_sidebar = True
|
||||
self.assertTrue(self.header._show_sidebar.props.active)
|
||||
|
||||
self.header._show_sidebar.props.active = False
|
||||
self.assertFalse(self.header.show_sidebar)
|
||||
|
||||
def test_open(self):
|
||||
"""Check that the Open button works as expected."""
|
||||
self.assertIsInstance(self.header._open, emmental.header.open.Button)
|
||||
"""Check that the Open ActionRow works as expected."""
|
||||
self.assertIsInstance(self.header._open, emmental.header.open.OpenRow)
|
||||
self.assertEqual(self.header._menu_box.get_row_at_index(0),
|
||||
self.header._open)
|
||||
|
||||
signal = unittest.mock.Mock()
|
||||
self.header.connect("track-requested", signal)
|
||||
self.header._open.emit("track-requested", pathlib.Path("/a/b/c/1.ogg"))
|
||||
signal.assert_called_with(self.header, pathlib.Path("/a/b/c/1.ogg"))
|
||||
|
||||
def test_listenbrainz(self):
|
||||
"""Check that the ListenBrainzRow is set up correctly."""
|
||||
self.assertIsInstance(self.header._listenbrainz, Adw.PasswordEntryRow)
|
||||
self.assertEqual(self.header._menu_box.get_row_at_index(1),
|
||||
self.header._listenbrainz)
|
||||
|
||||
self.assertEqual(self.header.listenbrainz_token, "")
|
||||
self.assertEqual(self.header._listenbrainz.props.text, "")
|
||||
|
||||
self.header.listenbrainz_token = "abcde"
|
||||
self.assertEqual(self.header._listenbrainz.props.text, "abcde")
|
||||
|
||||
with unittest.mock.patch.object(self.header._menu_button,
|
||||
"popdown") as mock_popdown:
|
||||
self.header._listenbrainz.props.text = "fghij"
|
||||
self.header._listenbrainz.emit("apply")
|
||||
self.assertEqual(self.header.listenbrainz_token, "fghij")
|
||||
mock_popdown.assert_called()
|
||||
|
||||
self.header._listenbrainz.props.text = "abcde"
|
||||
self.header._menu_button.get_popover().emit("closed")
|
||||
self.assertEqual(self.header._listenbrainz.props.text, "fghij")
|
||||
|
||||
def test_listenbrainz_token_valid(self):
|
||||
"""Test the listenbrainz-token-valid property."""
|
||||
win = Gtk.Window(titlebar=self.header)
|
||||
win.post_toast = unittest.mock.Mock()
|
||||
|
||||
self.assertTrue(self.header.listenbrainz_token_valid)
|
||||
|
||||
self.header.listenbrainz_token_valid = False
|
||||
self.assertTrue(self.header._menu_button.has_css_class("warning"))
|
||||
self.assertTrue(self.header._listenbrainz.has_css_class("warning"))
|
||||
self.assertFalse(self.header.listenbrainz_token_valid)
|
||||
win.post_toast.assert_called_with(
|
||||
"listenbrainz: user token is invalid")
|
||||
|
||||
win.post_toast.reset_mock()
|
||||
self.header.listenbrainz_token_valid = True
|
||||
self.assertFalse(self.header._menu_button.has_css_class("warning"))
|
||||
self.assertFalse(self.header._listenbrainz.has_css_class("warning"))
|
||||
self.assertTrue(self.header.listenbrainz_token_valid)
|
||||
win.post_toast.assert_not_called()
|
||||
|
||||
def test_settings(self):
|
||||
"""Check that the Settings window is set up correctly."""
|
||||
self.assertIsInstance(self.header._settings, Gtk.Button)
|
||||
self.assertIsInstance(self.header._window,
|
||||
emmental.header.settings.Window)
|
||||
"""Check that the SettingsRow is set up correctly."""
|
||||
self.assertIsInstance(self.header._settings,
|
||||
emmental.header.settings.Row)
|
||||
self.assertEqual(self.header._menu_box.get_row_at_index(2),
|
||||
self.header._settings)
|
||||
|
||||
self.assertEqual(self.header.sql, self.sql)
|
||||
self.assertEqual(self.header._settings.get_icon_name(),
|
||||
"settings-symbolic")
|
||||
def test_menu_button(self):
|
||||
"""Check that the menu popover button is set up properly."""
|
||||
self.assertIsInstance(self.header._menu_button,
|
||||
emmental.buttons.PopoverButton)
|
||||
self.assertIsNotNone(self.header._menu_button.props.parent)
|
||||
|
||||
with unittest.mock.patch.object(self.header._window,
|
||||
"present") as mock_present:
|
||||
self.header._settings.emit("clicked")
|
||||
mock_present.assert_called()
|
||||
self.assertEqual(self.header._menu_button.props.icon_name,
|
||||
"open-menu-symbolic")
|
||||
self.assertEqual(self.header._menu_button.popover_child,
|
||||
self.header._menu_box)
|
||||
|
||||
def test_menu_popover_child(self):
|
||||
"""Check that the menu popover button child was set up correctly."""
|
||||
self.assertIsInstance(self.header._menu_box, Gtk.ListBox)
|
||||
self.assertEqual(self.header._menu_box.get_selection_mode(),
|
||||
Gtk.SelectionMode.NONE)
|
||||
self.assertTrue(self.header._menu_box.has_css_class("boxed-list"))
|
||||
|
||||
self.assertEqual(self.header._menu_box.get_row_at_index(0),
|
||||
self.header._open)
|
||||
|
||||
def test_volume_icons(self):
|
||||
"""Check that the volume icons box is set up properly."""
|
||||
self.assertIsInstance(self.header._icons, Gtk.Box)
|
||||
self.assertIsInstance(self.header._volume_icon, Gtk.Image)
|
||||
self.assertIsInstance(self.header._background_icon, Gtk.Image)
|
||||
|
||||
self.assertEqual(self.header._icons.get_spacing(), 6)
|
||||
|
||||
self.assertEqual(self.header._icons.get_first_child(),
|
||||
self.header._volume_icon)
|
||||
self.assertEqual(self.header._volume_icon.get_next_sibling(),
|
||||
self.header._background_icon)
|
||||
|
||||
def test_volume(self):
|
||||
"""Check that volume widgets work as expected."""
|
||||
self.assertIsInstance(self.header._volume,
|
||||
emmental.header.volume.Controls)
|
||||
emmental.header.volume.VolumeRow)
|
||||
self.assertEqual(self.header.volume, 1.0)
|
||||
|
||||
for i, vol in [(x, x/10) for x in range(11)]:
|
||||
|
@ -75,13 +165,51 @@ class TestHeader(tests.util.TestCase):
|
|||
widget.volume = vol
|
||||
self.assertEqual(self.header.volume, vol)
|
||||
self.assertEqual(self.header._volume.volume, vol)
|
||||
self.assertEqual(self.header._button.get_icon_name(),
|
||||
self.assertEqual(self.header._volume_icon.get_icon_name(),
|
||||
f"audio-volume-{icon}-symbolic")
|
||||
self.assertEqual(self.header._vol_button.get_tooltip_text(),
|
||||
f"volume: {i*10}%\n"
|
||||
"background listening: off\nnormalizing: off")
|
||||
|
||||
def test_background_listening(self):
|
||||
"""Test the background listening mode."""
|
||||
self.assertIsInstance(self.header._background,
|
||||
emmental.header.volume.BackgroundRow)
|
||||
self.assertEqual(self.header._background_icon.get_icon_name(),
|
||||
"sound-wave")
|
||||
|
||||
self.assertFalse(self.header.bg_enabled)
|
||||
self.assertEqual(self.header.bg_volume, 0.5)
|
||||
|
||||
self.header.bg_enabled = True
|
||||
self.assertTrue(self.header._background.enabled)
|
||||
self.assertEqual(self.header._background_icon.get_icon_name(),
|
||||
"sound-wave-alt")
|
||||
self.assertEqual(self.header._vol_button.get_tooltip_text(),
|
||||
"volume: 100%\nbackground listening: 50%\n"
|
||||
"normalizing: off")
|
||||
|
||||
self.header.bg_volume = 0.75
|
||||
self.assertEqual(self.header._background.volume, 0.75)
|
||||
self.assertEqual(self.header._vol_button.get_tooltip_text(),
|
||||
"volume: 100%\nbackground listening: 75%\n"
|
||||
"normalizing: off")
|
||||
|
||||
self.header._background.volume = 0.25
|
||||
self.assertEqual(self.header.bg_volume, 0.25)
|
||||
self.assertEqual(self.header._vol_button.get_tooltip_text(),
|
||||
"volume: 100%\nbackground listening: 25%\n"
|
||||
"normalizing: off")
|
||||
|
||||
self.header._background.enabled = False
|
||||
self.assertFalse(self.header.bg_enabled)
|
||||
self.assertEqual(self.header._background_icon.get_icon_name(),
|
||||
"sound-wave")
|
||||
|
||||
def test_replaygain(self):
|
||||
"""Test that we can configure ReplayGain as expected."""
|
||||
self.assertIsInstance(self.header._replaygain,
|
||||
emmental.header.replaygain.Selector)
|
||||
emmental.header.replaygain.ReplayGainRow)
|
||||
self.assertFalse(self.header.rg_enabled)
|
||||
self.assertEqual(self.header.rg_mode, "auto")
|
||||
|
||||
|
@ -89,29 +217,66 @@ class TestHeader(tests.util.TestCase):
|
|||
self.header.rg_mode = "track"
|
||||
self.assertTrue(self.header._replaygain.enabled)
|
||||
self.assertEqual(self.header._replaygain.mode, "track")
|
||||
self.assertEqual(self.header._vol_button.get_tooltip_text(),
|
||||
"volume: 100%\nbackground listening: off\n"
|
||||
"normalizing: track mode")
|
||||
|
||||
self.header._replaygain.enabled = False
|
||||
self.header._replaygain.mode = "album"
|
||||
self.assertFalse(self.header.rg_enabled)
|
||||
self.assertEqual(self.header.rg_mode, "album")
|
||||
self.assertEqual(self.header._vol_button.get_tooltip_text(),
|
||||
"volume: 100%\nbackground listening: off\n"
|
||||
"normalizing: off")
|
||||
|
||||
def test_popover(self):
|
||||
"""Check that the menu popover was set up correctly."""
|
||||
self.assertIsInstance(self.header._button,
|
||||
def test_volume_popover_button(self):
|
||||
"""Check that the volume popover button was set up correctly."""
|
||||
self.assertIsInstance(self.header._vol_button,
|
||||
emmental.buttons.PopoverButton)
|
||||
self.assertIsInstance(self.header._box, Gtk.Box)
|
||||
self.assertEqual(self.header._vol_button.popover_child,
|
||||
self.header._vol_box)
|
||||
|
||||
self.assertEqual(self.header._box.get_orientation(),
|
||||
Gtk.Orientation.VERTICAL)
|
||||
self.assertEqual(self.header._box.get_spacing(), 0)
|
||||
self.assertEqual(self.header._vol_button.get_child(),
|
||||
self.header._icons)
|
||||
self.assertEqual(self.header._vol_button.get_margin_end(), 6)
|
||||
self.assertFalse(self.header._vol_button.get_has_frame())
|
||||
|
||||
self.assertEqual(self.header._button.get_icon_name(),
|
||||
"audio-volume-high-symbolic")
|
||||
self.assertEqual(self.header._button.popover_child, self.header._box)
|
||||
self.assertEqual(self.header._box.get_first_child(),
|
||||
def test_volume_popover_child(self):
|
||||
"""Check that the volume popover button child was set up correctly."""
|
||||
self.assertIsInstance(self.header._vol_box, Gtk.ListBox)
|
||||
self.assertEqual(self.header._vol_box.get_selection_mode(),
|
||||
Gtk.SelectionMode.NONE)
|
||||
self.assertTrue(self.header._vol_box.has_css_class("boxed-list"))
|
||||
|
||||
self.assertEqual(self.header._vol_box.get_row_at_index(0),
|
||||
self.header._volume)
|
||||
self.assertEqual(self.header._vol_box.get_row_at_index(1),
|
||||
self.header._background)
|
||||
self.assertEqual(self.header._vol_box.get_row_at_index(2),
|
||||
self.header._replaygain)
|
||||
|
||||
sep = self.header._volume.get_next_sibling()
|
||||
self.assertIsInstance(sep, Gtk.Separator)
|
||||
self.assertEqual(sep.get_orientation(), Gtk.Orientation.HORIZONTAL)
|
||||
self.assertEqual(sep.get_next_sibling(), self.header._replaygain)
|
||||
def test_accelerators(self):
|
||||
"""Check that the accelerators list is set up properly."""
|
||||
entries = [("open-file", self.header._open.activate, "<Control>o"),
|
||||
("decrease-volume", self.header._volume.decrement,
|
||||
"<Shift><Control>Down"),
|
||||
("increase-volume", self.header._volume.increment,
|
||||
"<Shift><Control>Up"),
|
||||
("toggle-bg-mode", self.header._background.activate,
|
||||
"<Shift><Control>b"),
|
||||
("toggle-sidebar", self.header._show_sidebar.activate,
|
||||
"<Control>bracketright"),
|
||||
("edit-settings", self.header._settings.activate,
|
||||
"<Shift><Control>s")]
|
||||
|
||||
accels = self.header.accelerators
|
||||
self.assertIsInstance(accels, list)
|
||||
|
||||
for i, (name, func, accel) in enumerate(entries):
|
||||
with self.subTest(name=name):
|
||||
self.assertIsInstance(accels[i], emmental.action.ActionEntry)
|
||||
self.assertEqual(accels[i].name, name)
|
||||
self.assertEqual(accels[i].func, func)
|
||||
self.assertListEqual(accels[i].accels, [accel])
|
||||
|
||||
self.assertEqual(len(accels), i + 1)
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
# Copyright 2024 (c) Anna Schumaker.
|
||||
"""Tests our Listenbrainz User Token entry."""
|
||||
import emmental.header.listenbrainz
|
||||
import unittest
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
|
||||
|
||||
class TestListenbrainzRow(unittest.TestCase):
|
||||
"""Test the ListenBrainzRow."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
self.row = emmental.header.listenbrainz.ListenBrainzRow()
|
||||
|
||||
def test_init(self):
|
||||
"""Test that the ListenBrainzRow was set up properly."""
|
||||
self.assertIsInstance(self.row, Adw.PasswordEntryRow)
|
||||
self.assertIsInstance(self.row.prefix, Gtk.Image)
|
||||
|
||||
self.assertEqual(self.row.props.title, "ListenBrainz User Token")
|
||||
self.assertTrue(self.row.props.show_apply_button)
|
||||
|
||||
self.assertEqual(self.row.prefix.props.icon_name,
|
||||
"listenbrainz-logo-symbolic")
|
|
@ -1,56 +1,69 @@
|
|||
# Copyright 2023 (c) Anna Schumaker.
|
||||
"""Tests our Open button."""
|
||||
"""Tests our Open Adw.ActionRow."""
|
||||
import emmental.header.open
|
||||
import pathlib
|
||||
import unittest
|
||||
from gi.repository import Gio
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
|
||||
|
||||
class TestButton(unittest.TestCase):
|
||||
"""Test the Open button."""
|
||||
class TestOpenRow(unittest.TestCase):
|
||||
"""Test the Open row."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
self.button = emmental.header.open.Button()
|
||||
self.row = emmental.header.open.OpenRow()
|
||||
|
||||
def test_button(self):
|
||||
"""Check that the button was set up properly."""
|
||||
self.assertIsInstance(self.button, Gtk.Button)
|
||||
self.assertEqual(self.button.get_icon_name(), "document-open-symbolic")
|
||||
def test_action_row(self):
|
||||
"""Check that the action row was set up properly."""
|
||||
self.assertIsInstance(self.row, Adw.ActionRow)
|
||||
self.assertIsInstance(self.row._prefix, Gtk.Image)
|
||||
|
||||
self.assertEqual(self.row.props.title, "Open File")
|
||||
self.assertEqual(self.row.props.subtitle, "Select a file for playback")
|
||||
self.assertTrue(self.row.props.activatable)
|
||||
|
||||
self.assertEqual(self.row._prefix.props.icon_name,
|
||||
"document-open-symbolic")
|
||||
|
||||
def test_filter(self):
|
||||
"""Check that the file filter is set up properly."""
|
||||
self.assertIsInstance(self.button._filter, Gtk.FileFilter)
|
||||
self.assertIsInstance(self.button._filters, Gio.ListStore)
|
||||
self.assertIsInstance(self.row._filter, Gtk.FileFilter)
|
||||
self.assertIsInstance(self.row._filters, Gio.ListStore)
|
||||
|
||||
self.assertEqual(self.button._filter.get_name(), "Audio Files")
|
||||
self.assertEqual(self.button._filters[0], self.button._filter)
|
||||
self.assertEqual(self.row._filter.get_name(), "Audio Files")
|
||||
self.assertEqual(self.row._filters[0], self.row._filter)
|
||||
|
||||
def test_dialog(self):
|
||||
"""Check that the file dialog is set up properly."""
|
||||
self.assertIsInstance(self.button._dialog, Gtk.FileDialog)
|
||||
self.assertEqual(self.button._dialog.get_title(), "Pick a Track")
|
||||
self.assertEqual(self.button._dialog.get_filters(),
|
||||
self.button._filters)
|
||||
self.assertTrue(self.button._dialog.get_modal())
|
||||
self.assertIsInstance(self.row._dialog, Gtk.FileDialog)
|
||||
self.assertEqual(self.row._dialog.get_title(), "Pick a Track")
|
||||
self.assertEqual(self.row._dialog.get_filters(),
|
||||
self.row._filters)
|
||||
self.assertTrue(self.row._dialog.get_modal())
|
||||
|
||||
def test_clicked(self):
|
||||
"""Test clicking on the button."""
|
||||
with unittest.mock.patch.object(self.button._dialog,
|
||||
def test_activate(self):
|
||||
"""Test activating an OpenRow."""
|
||||
listbox = Gtk.ListBox()
|
||||
popover = Gtk.Popover(child=listbox)
|
||||
listbox.append(self.row)
|
||||
|
||||
with unittest.mock.patch.object(popover, "popdown") as mock_popdown:
|
||||
with unittest.mock.patch.object(self.row._dialog,
|
||||
"open") as mock_open:
|
||||
self.button.emit("clicked")
|
||||
self.row.emit("activated")
|
||||
mock_popdown.assert_called()
|
||||
mock_open.assert_called_with(None, None,
|
||||
self.button._Button__async_ready)
|
||||
self.row._OpenRow__async_ready)
|
||||
|
||||
with unittest.mock.patch.object(self.button._dialog,
|
||||
with unittest.mock.patch.object(self.row._dialog,
|
||||
"open_finish") as mock_finish:
|
||||
task = Gio.Task()
|
||||
signal = unittest.mock.Mock()
|
||||
mock_finish.return_value = Gio.File.new_for_path("/a/b/c/1.ogg")
|
||||
self.button.connect("track-requested", signal)
|
||||
self.row.connect("track-requested", signal)
|
||||
|
||||
self.button._Button__async_ready(self.button._dialog, task)
|
||||
self.row._OpenRow__async_ready(self.row._dialog, task)
|
||||
mock_finish.assert_called_with(task)
|
||||
signal.assert_called_with(self.button,
|
||||
pathlib.Path("/a/b/c/1.ogg"))
|
||||
signal.assert_called_with(self.row, pathlib.Path("/a/b/c/1.ogg"))
|
||||
|
|
|
@ -1,119 +1,159 @@
|
|||
# Copyright 2022 (c) Anna Schumaker
|
||||
"""Tests our ReplayGain selector."""
|
||||
import unittest
|
||||
import unittest.mock
|
||||
import emmental.header.replaygain
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
|
||||
|
||||
class TestSelector(unittest.TestCase):
|
||||
"""Test case for our custom ReplayGain Selector."""
|
||||
class TestCheckRow(unittest.TestCase):
|
||||
"""Test case for our custom CheckRow ListBox row."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
self.selector = emmental.header.replaygain.Selector()
|
||||
self.checkrow = emmental.header.replaygain.CheckRow("test mode",
|
||||
title="My Title")
|
||||
|
||||
def test_selector(self):
|
||||
"""Check that the Selector is set up properly."""
|
||||
self.assertIsInstance(self.selector, Gtk.Grid)
|
||||
self.assertEqual(self.selector.get_column_spacing(), 6)
|
||||
self.assertEqual(self.selector.get_margin_top(), 8)
|
||||
def test_init(self):
|
||||
"""Test that the CheckRow is set up properly."""
|
||||
self.assertIsInstance(self.checkrow, Adw.ActionRow)
|
||||
self.assertIsInstance(self.checkrow._prefix, Gtk.CheckButton)
|
||||
|
||||
self.assertEqual(self.selector.mode, "auto")
|
||||
self.assertFalse(self.selector.enabled)
|
||||
self.assertEqual(self.checkrow.mode, "test mode")
|
||||
self.assertEqual(self.checkrow.get_title(), "My Title")
|
||||
self.assertEqual(self.checkrow.get_activatable_widget(),
|
||||
self.checkrow._prefix)
|
||||
|
||||
self.selector.enabled = True
|
||||
self.assertTrue(self.selector._switch.get_state())
|
||||
self.selector.enabled = False
|
||||
self.assertFalse(self.selector._switch.get_state())
|
||||
def test_active(self):
|
||||
"""Test the CheckRow active property."""
|
||||
self.assertFalse(self.checkrow.active)
|
||||
|
||||
def test_title(self):
|
||||
"""Check that the Selector title label is set up properly."""
|
||||
self.assertIsInstance(self.selector._title, Gtk.Label)
|
||||
self.assertEqual(self.selector.get_child_at(0, 0),
|
||||
self.selector._title)
|
||||
self.checkrow.active = True
|
||||
self.assertTrue(self.checkrow._prefix.get_active())
|
||||
self.checkrow._prefix.set_active(False)
|
||||
self.assertFalse(self.checkrow.active)
|
||||
|
||||
self.assertEqual(self.selector._title.get_text(),
|
||||
"Volume Normalization")
|
||||
self.assertAlmostEqual(self.selector._title.get_yalign(), 0.8)
|
||||
self.assertTrue(self.selector._title.has_css_class("title-4"))
|
||||
self.assertTrue(self.selector._title.get_hexpand())
|
||||
self.assertTrue(self.selector._title.get_vexpand())
|
||||
checkrow2 = emmental.header.replaygain.CheckRow("other", active=True)
|
||||
self.assertTrue(checkrow2.active)
|
||||
self.assertTrue(checkrow2._prefix.get_active())
|
||||
|
||||
def test_group(self):
|
||||
"""Test the CheckRow group property."""
|
||||
self.assertIsNone(self.checkrow.group)
|
||||
checkrow2 = emmental.header.replaygain.CheckRow("other",
|
||||
group=self.checkrow)
|
||||
self.assertEqual(checkrow2.group, self.checkrow)
|
||||
|
||||
def test_set_active(self):
|
||||
"""Test the set_active() property."""
|
||||
notify = unittest.mock.Mock()
|
||||
self.checkrow.connect("notify::active", notify)
|
||||
|
||||
self.checkrow.set_active(True)
|
||||
self.assertTrue(self.checkrow.active)
|
||||
notify.assert_called()
|
||||
|
||||
notify.reset_mock()
|
||||
self.checkrow.set_active(True)
|
||||
notify.assert_not_called()
|
||||
|
||||
|
||||
class TestReplayGainRow(unittest.TestCase):
|
||||
"""Test case for our custom ReplayGain ListBox row."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
self.replaygain = emmental.header.replaygain.ReplayGainRow()
|
||||
|
||||
def test_init(self):
|
||||
"""Check that the ReplayGainRow is set up properly."""
|
||||
self.assertIsInstance(self.replaygain, Adw.ExpanderRow)
|
||||
self.assertEqual(self.replaygain.get_title(), "Volume Normalization")
|
||||
self.assertEqual(self.replaygain.get_subtitle(),
|
||||
"Configure ReplayGain normalizing")
|
||||
|
||||
self.assertEqual(self.replaygain.mode, "auto")
|
||||
self.assertFalse(self.replaygain.enabled)
|
||||
|
||||
self.replaygain.set_expanded(True)
|
||||
self.assertTrue(self.replaygain._switch.get_active())
|
||||
self.replaygain.set_expanded(False)
|
||||
self.assertFalse(self.replaygain._switch.get_active())
|
||||
|
||||
def test_switch(self):
|
||||
"""Check that the Selector switch works as intended."""
|
||||
self.assertIsInstance(self.selector._switch, Gtk.Switch)
|
||||
self.assertEqual(self.selector.get_child_at(1, 0),
|
||||
self.selector._switch)
|
||||
self.assertFalse(self.selector._switch.get_active())
|
||||
"""Check that the ReplayGainRow switch works as intended."""
|
||||
self.assertIsInstance(self.replaygain._switch, Gtk.Switch)
|
||||
self.assertEqual(self.replaygain._switch.get_valign(),
|
||||
Gtk.Align.CENTER)
|
||||
self.assertFalse(self.replaygain._switch.get_active())
|
||||
|
||||
self.selector._switch.set_active(True)
|
||||
self.assertTrue(self.selector.enabled)
|
||||
self.assertTrue(self.selector._auto.get_sensitive())
|
||||
self.assertTrue(self.selector._album.get_sensitive())
|
||||
self.assertTrue(self.selector._track.get_sensitive())
|
||||
|
||||
self.selector._switch.set_active(False)
|
||||
self.assertFalse(self.selector.enabled)
|
||||
self.assertFalse(self.selector._auto.get_sensitive())
|
||||
self.assertFalse(self.selector._album.get_sensitive())
|
||||
self.assertFalse(self.selector._track.get_sensitive())
|
||||
self.replaygain._switch.set_active(True)
|
||||
self.assertTrue(self.replaygain.get_expanded())
|
||||
self.replaygain._switch.set_active(False)
|
||||
self.assertFalse(self.replaygain.get_expanded())
|
||||
|
||||
def test_automatic_mode(self):
|
||||
"""Test the Selector automatic mode button."""
|
||||
self.assertIsInstance(self.selector._auto, Gtk.CheckButton)
|
||||
self.assertEqual(self.selector.get_child_at(0, 1), self.selector._auto)
|
||||
"""Test the ReplayGainRow automatic mode option."""
|
||||
self.assertIsInstance(self.replaygain._automatic,
|
||||
emmental.header.replaygain.CheckRow)
|
||||
self.assertEqual(self.replaygain._automatic.get_title(),
|
||||
"Automatic Mode")
|
||||
self.assertEqual(self.replaygain._automatic.get_subtitle(),
|
||||
"Emmental decides automatically")
|
||||
|
||||
self.assertEqual(self.selector._auto.get_label(),
|
||||
"Decide automatically")
|
||||
self.assertFalse(self.selector._auto.get_sensitive())
|
||||
self.assertTrue(self.selector._auto.get_active())
|
||||
self.assertEqual(self.replaygain._automatic.mode, "auto")
|
||||
self.assertTrue(self.replaygain._automatic.active)
|
||||
|
||||
self.selector._track.set_active(True)
|
||||
self.selector._auto.set_active(True)
|
||||
self.assertEqual(self.selector.mode, "auto")
|
||||
self.replaygain._track.active = True
|
||||
self.replaygain._automatic.active = True
|
||||
self.assertEqual(self.replaygain.mode, "auto")
|
||||
self.assertFalse(self.replaygain._track.active)
|
||||
|
||||
def test_album_mode(self):
|
||||
"""Test the Selector album mode button."""
|
||||
self.assertIsInstance(self.selector._album, Gtk.CheckButton)
|
||||
self.assertEqual(self.selector.get_child_at(0, 2),
|
||||
self.selector._album)
|
||||
|
||||
self.assertEqual(self.selector._album.get_label(),
|
||||
"""Test the ReplayGainRow album mode option."""
|
||||
self.assertIsInstance(self.replaygain._album,
|
||||
emmental.header.replaygain.CheckRow)
|
||||
self.assertEqual(self.replaygain._album.get_title(), "Album Mode")
|
||||
self.assertEqual(self.replaygain._album.get_subtitle(),
|
||||
"Albums have the same volume")
|
||||
self.assertFalse(self.selector._album.get_sensitive())
|
||||
self.assertFalse(self.selector._album.get_active())
|
||||
|
||||
self.selector._album.set_active(True)
|
||||
self.assertEqual(self.selector.mode, "album")
|
||||
self.assertEqual(self.replaygain._album.mode, "album")
|
||||
self.assertEqual(self.replaygain._album.group,
|
||||
self.replaygain._automatic)
|
||||
|
||||
self.replaygain._album.active = True
|
||||
self.assertEqual(self.replaygain.mode, "album")
|
||||
|
||||
def test_track_mode(self):
|
||||
"""Test the Selector album mode button."""
|
||||
self.assertIsInstance(self.selector._track, Gtk.CheckButton)
|
||||
self.assertEqual(self.selector.get_child_at(0, 3),
|
||||
self.selector._track)
|
||||
|
||||
self.assertEqual(self.selector._track.get_label(),
|
||||
"""Test the ReplayGainRow track mode option."""
|
||||
self.assertIsInstance(self.replaygain._track,
|
||||
emmental.header.replaygain.CheckRow)
|
||||
self.assertEqual(self.replaygain._track.get_title(), "Track Mode")
|
||||
self.assertEqual(self.replaygain._track.get_subtitle(),
|
||||
"Tracks have the same volume")
|
||||
self.assertFalse(self.selector._track.get_sensitive())
|
||||
self.assertFalse(self.selector._track.get_active())
|
||||
|
||||
self.selector._track.set_active(True)
|
||||
self.assertEqual(self.selector.mode, "track")
|
||||
self.assertEqual(self.replaygain._track.mode, "track")
|
||||
self.assertEqual(self.replaygain._track.group,
|
||||
self.replaygain._automatic)
|
||||
|
||||
self.replaygain._track.active = True
|
||||
self.assertEqual(self.replaygain.mode, "track")
|
||||
|
||||
def test_mode_property(self):
|
||||
"""Test that the mode property is set correctly."""
|
||||
self.selector.mode = "album"
|
||||
self.assertTrue(self.selector._album.get_active())
|
||||
self.assertFalse(self.selector._auto.get_active())
|
||||
self.assertFalse(self.selector._track.get_active())
|
||||
self.replaygain.mode = "album"
|
||||
self.assertTrue(self.replaygain._album.active)
|
||||
self.assertFalse(self.replaygain._automatic.active)
|
||||
self.assertFalse(self.replaygain._track.active)
|
||||
|
||||
self.selector.mode = "track"
|
||||
self.assertTrue(self.selector._track.get_active())
|
||||
self.assertFalse(self.selector._auto.get_active())
|
||||
self.assertFalse(self.selector._album.get_active())
|
||||
self.replaygain.mode = "track"
|
||||
self.assertTrue(self.replaygain._track.active)
|
||||
self.assertFalse(self.replaygain._automatic.active)
|
||||
self.assertFalse(self.replaygain._album.active)
|
||||
|
||||
self.selector.mode = "anything else"
|
||||
self.assertTrue(self.selector._auto.get_active())
|
||||
self.assertFalse(self.selector._album.get_active())
|
||||
self.assertFalse(self.selector._track.get_active())
|
||||
self.assertEqual(self.selector.mode, "auto")
|
||||
self.replaygain.mode = "anything else"
|
||||
self.assertTrue(self.replaygain._automatic.active)
|
||||
self.assertFalse(self.replaygain._album.active)
|
||||
self.assertFalse(self.replaygain._track.active)
|
||||
self.assertEqual(self.replaygain.mode, "auto")
|
||||
|
|
|
@ -141,3 +141,39 @@ class TestWindow(tests.util.TestCase):
|
|||
emmental.header.settings.ValueRow)
|
||||
self.assertEqual(columns[1].get_title(), "Value")
|
||||
self.assertEqual(columns[1].get_fixed_width(), 100)
|
||||
|
||||
|
||||
class TestSettingsRow(tests.util.TestCase):
|
||||
"""Test the SettingsRow."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
super().setUp()
|
||||
self.row = emmental.header.settings.Row(sql=self.sql)
|
||||
|
||||
def test_init(self):
|
||||
"""Test that the SettingsRow was set up properly."""
|
||||
self.assertIsInstance(self.row, Adw.ActionRow)
|
||||
self.assertIsInstance(self.row._prefix, Gtk.Image)
|
||||
self.assertIsInstance(self.row._window,
|
||||
emmental.header.settings.Window)
|
||||
|
||||
self.assertEqual(self.row.props.title, "Edit Settings")
|
||||
self.assertEqual(self.row.props.subtitle,
|
||||
"Open the settings editor (debug only)")
|
||||
self.assertTrue(self.row.props.activatable)
|
||||
|
||||
self.assertEqual(self.row._prefix.props.icon_name, "settings-symbolic")
|
||||
|
||||
def test_activate(self):
|
||||
"""Test activating a SettingsRow."""
|
||||
listbox = Gtk.ListBox()
|
||||
popover = Gtk.Popover(child=listbox)
|
||||
listbox.append(self.row)
|
||||
|
||||
with unittest.mock.patch.object(popover, "popdown") as mock_popdown:
|
||||
with unittest.mock.patch.object(self.row._window,
|
||||
"present") as mock_present:
|
||||
self.row.emit("activated")
|
||||
mock_popdown.assert_called()
|
||||
mock_present.assert_called()
|
||||
|
|
|
@ -4,14 +4,15 @@ import unittest
|
|||
import emmental.header.volume
|
||||
import tests.util
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
|
||||
|
||||
class TestControls(unittest.TestCase):
|
||||
class TestVolumeRow(unittest.TestCase):
|
||||
"""Test case for our custom volume controls."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
self.vol = emmental.header.volume.Controls()
|
||||
self.vol = emmental.header.volume.VolumeRow()
|
||||
self.value = tests.util.FloatObject(value=1.0)
|
||||
self.vol.bind_property("volume", self.value, "value")
|
||||
|
||||
|
@ -21,9 +22,12 @@ class TestControls(unittest.TestCase):
|
|||
|
||||
def test_volume(self):
|
||||
"""Check that the volume Controls are set up properly."""
|
||||
self.assertIsInstance(self.vol, Gtk.Box)
|
||||
self.assertEqual(self.vol.get_spacing(), 0)
|
||||
self.assertEqual(self.vol.get_orientation(),
|
||||
self.assertIsInstance(self.vol, Gtk.ListBoxRow)
|
||||
self.assertIsInstance(self.vol._box, Gtk.Box)
|
||||
|
||||
self.assertEqual(self.vol.get_child(), self.vol._box)
|
||||
self.assertEqual(self.vol._box.get_spacing(), 0)
|
||||
self.assertEqual(self.vol._box.get_orientation(),
|
||||
Gtk.Orientation.HORIZONTAL)
|
||||
self.assertEqual(self.vol.volume, 1.0)
|
||||
|
||||
|
@ -31,15 +35,21 @@ class TestControls(unittest.TestCase):
|
|||
self.assertAlmostEqual(self.vol._scale.get_value(), 0.85)
|
||||
self.assertAlmostEqual(self.value.value, 0.85)
|
||||
|
||||
vol2 = emmental.header.volume.VolumeRow(volume=0.5)
|
||||
self.assertEqual(vol2.volume, 0.5)
|
||||
self.assertEqual(vol2._adjustment.get_value(), 0.5)
|
||||
|
||||
def test_decrement_button(self):
|
||||
"""Test the decrement button."""
|
||||
self.assertIsInstance(self.vol._decrement, Gtk.Button)
|
||||
self.assertEqual(self.vol.get_first_child(), self.vol._decrement)
|
||||
self.assertEqual(self.vol._box.get_first_child(), self.vol._decrement)
|
||||
|
||||
self.assertEqual(self.vol._decrement.get_tooltip_text(),
|
||||
"reduce the volume")
|
||||
self.assertEqual(self.vol._decrement.get_icon_name(),
|
||||
"list-remove-symbolic")
|
||||
self.assertEqual(self.vol._decrement.get_valign(), Gtk.Align.END)
|
||||
self.assertEqual(self.vol._decrement.get_margin_bottom(), 6)
|
||||
self.assertEqual(self.vol._decrement.get_margin_bottom(), 5)
|
||||
self.assertFalse(self.vol._decrement.get_has_frame())
|
||||
|
||||
self.vol._decrement.emit("clicked")
|
||||
|
@ -47,6 +57,11 @@ class TestControls(unittest.TestCase):
|
|||
self.assertAlmostEqual(self.vol._scale.get_value(), 0.95)
|
||||
self.assertAlmostEqual(self.value.value, 0.95)
|
||||
|
||||
self.vol.decrement()
|
||||
self.assertAlmostEqual(self.vol.volume, 0.90)
|
||||
self.assertAlmostEqual(self.vol._scale.get_value(), 0.90)
|
||||
self.assertAlmostEqual(self.value.value, 0.90)
|
||||
|
||||
def test_scale(self):
|
||||
"""Check that the volume slider has been set up properly."""
|
||||
self.assertIsInstance(self.vol._adjustment, Gtk.Adjustment)
|
||||
|
@ -79,10 +94,12 @@ class TestControls(unittest.TestCase):
|
|||
self.assertEqual(self.vol._scale.get_next_sibling(),
|
||||
self.vol._increment)
|
||||
|
||||
self.assertEqual(self.vol._increment.get_tooltip_text(),
|
||||
"increase the volume")
|
||||
self.assertEqual(self.vol._increment.get_icon_name(),
|
||||
"list-add-symbolic")
|
||||
self.assertEqual(self.vol._increment.get_valign(), Gtk.Align.END)
|
||||
self.assertEqual(self.vol._increment.get_margin_bottom(), 6)
|
||||
self.assertEqual(self.vol._increment.get_margin_bottom(), 5)
|
||||
self.assertFalse(self.vol._increment.get_has_frame())
|
||||
|
||||
self.vol.volume = 0.9
|
||||
|
@ -91,6 +108,11 @@ class TestControls(unittest.TestCase):
|
|||
self.assertAlmostEqual(self.vol._scale.get_value(), 0.95)
|
||||
self.assertAlmostEqual(self.value.value, 0.95)
|
||||
|
||||
self.vol.increment()
|
||||
self.assertAlmostEqual(self.vol.volume, 1.0)
|
||||
self.assertAlmostEqual(self.vol._scale.get_value(), 1.0)
|
||||
self.assertAlmostEqual(self.value.value, 1.0)
|
||||
|
||||
def test_format_value(self):
|
||||
"""Check that the scale value is formatted correctly."""
|
||||
format_value = emmental.header.volume.format_value_func
|
||||
|
@ -98,3 +120,58 @@ class TestControls(unittest.TestCase):
|
|||
with self.subTest(value=value):
|
||||
self.assertEqual(format_value(self.vol._scale, value/100),
|
||||
f"{value} %")
|
||||
|
||||
|
||||
class TestBackgroundRow(unittest.TestCase):
|
||||
"""Test case for our Background Listening volume row."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
self.background = emmental.header.volume.BackgroundRow()
|
||||
|
||||
def test_init(self):
|
||||
"""Test that the Background Listening row was set up properly."""
|
||||
self.assertIsInstance(self.background, Adw.ExpanderRow)
|
||||
self.assertEqual(self.background.get_title(), "Background Listening")
|
||||
self.assertEqual(self.background.get_subtitle(),
|
||||
"Decrease the volume to help focus")
|
||||
|
||||
self.background.set_expanded(True)
|
||||
self.assertTrue(self.background._switch.get_active())
|
||||
self.background.set_expanded(False)
|
||||
self.assertFalse(self.background._switch.get_active())
|
||||
|
||||
def test_switch(self):
|
||||
"""Check that the BackgroundRow switch works as intended."""
|
||||
self.assertIsInstance(self.background._switch, Gtk.Switch)
|
||||
self.assertEqual(self.background._switch.get_valign(),
|
||||
Gtk.Align.CENTER)
|
||||
self.assertFalse(self.background._switch.get_active())
|
||||
|
||||
self.background._switch.set_active(True)
|
||||
self.assertTrue(self.background.get_expanded())
|
||||
self.background._switch.set_active(False)
|
||||
self.assertFalse(self.background.get_expanded())
|
||||
|
||||
def test_volume(self):
|
||||
"""Check the VolumeRow instance inside the Expander."""
|
||||
self.assertIsInstance(self.background._volume,
|
||||
emmental.header.volume.VolumeRow)
|
||||
self.assertEqual(self.background.volume, 0.5)
|
||||
self.assertEqual(self.background._volume.volume, 0.5)
|
||||
|
||||
self.background.volume = 0.75
|
||||
self.assertEqual(self.background._volume.volume, 0.75)
|
||||
|
||||
self.background._volume.volume = 0.25
|
||||
self.assertEqual(self.background.volume, 0.25)
|
||||
|
||||
def test_enabled(self):
|
||||
"""Check the BackgroundRow enabled property."""
|
||||
self.assertFalse(self.background.enabled)
|
||||
|
||||
self.background.enabled = True
|
||||
self.assertTrue(self.background._switch.get_active())
|
||||
|
||||
self.background._switch.set_active(False)
|
||||
self.assertFalse(self.background.enabled)
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
# Copyright 2024 (c) Anna Schumaker.
|
||||
"""Test creating a liblistenbrainz.Listen from a Track."""
|
||||
import datetime
|
||||
import dateutil.tz
|
||||
import emmental.listenbrainz.listen
|
||||
import liblistenbrainz
|
||||
import pathlib
|
||||
import tests.util
|
||||
|
||||
|
||||
class TestListen(tests.util.TestCase):
|
||||
"""ListenBrainz Listen test case."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
super().setUp()
|
||||
self.library = self.sql.libraries.create(pathlib.Path("/a/b"))
|
||||
self.artists = [self.sql.artists.create("Artist 1", mbid="mbid-ar1"),
|
||||
self.sql.artists.create("Artist 2"),
|
||||
self.sql.artists.create("Artist 3", mbid="mbid-ar3")]
|
||||
self.album = self.sql.albums.create("Test Album", "Test Artist",
|
||||
release="1988-06",
|
||||
mbid="mbid-release")
|
||||
self.medium = self.sql.media.create(self.album, "", number=1)
|
||||
self.year = self.sql.years.create(1988)
|
||||
self.track = self.sql.tracks.create(self.library,
|
||||
pathlib.Path("/a/b/c.ogg"),
|
||||
self.medium, self.year,
|
||||
title="Track 1", number=1,
|
||||
artist="Track Artist")
|
||||
|
||||
for artist in self.artists:
|
||||
artist.add_track(self.track)
|
||||
|
||||
self.listen = emmental.listenbrainz.listen.Listen(self.track)
|
||||
|
||||
def test_init(self):
|
||||
"""Test initializing our Listen instance."""
|
||||
self.assertIsInstance(self.listen, liblistenbrainz.Listen)
|
||||
self.assertEqual(self.listen.track_name, "Track 1")
|
||||
self.assertEqual(self.listen.artist_name, "Track Artist")
|
||||
self.assertEqual(self.listen.release_name, "Test Album")
|
||||
self.assertEqual(self.listen.release_group_mbid, "mbid-release")
|
||||
self.assertEqual(self.listen.tracknumber, 1)
|
||||
self.assertDictEqual(self.listen.additional_info,
|
||||
{"media_player": "emmental-debug"})
|
||||
self.assertListEqual(self.listen.artist_mbids,
|
||||
["mbid-ar1", "mbid-ar3"])
|
||||
self.assertIsNone(self.listen.listened_at)
|
||||
self.assertIsNone(self.listen.listenid)
|
||||
|
||||
utc_now = datetime.datetime.utcnow()
|
||||
local_now = utc_now.replace(tzinfo=dateutil.tz.tzutc()).astimezone()
|
||||
listen = emmental.listenbrainz.listen.Listen(self.track,
|
||||
listenid=1234,
|
||||
listened_at=utc_now)
|
||||
self.assertEqual(listen.listenid, 1234)
|
||||
self.assertEqual(listen.listened_at, local_now.timestamp())
|
|
@ -0,0 +1,315 @@
|
|||
# Copyright 2024 (c) Anna Schumaker.
|
||||
"""Tests our custom ListenBrainz GObject."""
|
||||
import datetime
|
||||
import emmental.listenbrainz
|
||||
import io
|
||||
import pathlib
|
||||
import tests.util
|
||||
import unittest
|
||||
from gi.repository import GObject
|
||||
from gi.repository import GLib
|
||||
|
||||
|
||||
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
||||
@unittest.mock.patch("gi.repository.GLib.source_remove")
|
||||
@unittest.mock.patch("gi.repository.GLib.idle_add", return_value=42)
|
||||
class TestListenBrainz(tests.util.TestCase):
|
||||
"""ListenBrainz GObject test case."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
super().setUp()
|
||||
self.listenbrainz = emmental.listenbrainz.ListenBrainz(self.sql)
|
||||
self.library = self.sql.libraries.create(pathlib.Path("/a/b"))
|
||||
self.album = self.sql.albums.create("Test Album", "Test Artist",
|
||||
release="1988-06",
|
||||
mbid="mbid-release")
|
||||
self.medium = self.sql.media.create(self.album, "", number=1)
|
||||
self.year = self.sql.years.create(1988)
|
||||
self.track = self.sql.tracks.create(self.library,
|
||||
pathlib.Path("/a/b/c.ogg"),
|
||||
self.medium, self.year,
|
||||
title="Track 1", number=1,
|
||||
artist="Track Artist", length=10)
|
||||
|
||||
@unittest.mock.patch("gi.repository.GLib.source_remove")
|
||||
def tearDown(self, mock_source_remove: unittest.mock.Mock):
|
||||
"""Clean up."""
|
||||
self.listenbrainz.stop()
|
||||
|
||||
def test_init(self, mock_idle_add: unittest.mock.Mock,
|
||||
mock_source_remove: unittest.mock.Mock,
|
||||
mock_stdout: io.StringIO):
|
||||
"""Test that the ListenBrainz GObject was set up properly."""
|
||||
self.assertIsInstance(self.listenbrainz, GObject.GObject)
|
||||
self.assertIsInstance(self.listenbrainz._queue,
|
||||
emmental.listenbrainz.task.Queue)
|
||||
self.assertIsInstance(self.listenbrainz._thread,
|
||||
emmental.listenbrainz.thread.Thread)
|
||||
self.assertIsNone(self.listenbrainz._idle_id)
|
||||
self.assertEqual(self.listenbrainz.sql, self.sql)
|
||||
self.assertIsNone(self.listenbrainz._timeout_id)
|
||||
|
||||
def test_early_idle_work(self, mock_idle_add: unittest.mock.Mock,
|
||||
mock_source_remove: unittest.mock.Mock,
|
||||
mock_stdout: io.StringIO):
|
||||
"""Test __idle_work() before the database has finished loading."""
|
||||
with unittest.mock.patch.object(self.listenbrainz._thread.ready,
|
||||
"is_set") as mock_is_set:
|
||||
self.assertEqual(self.listenbrainz._ListenBrainz__idle_work(),
|
||||
GLib.SOURCE_CONTINUE)
|
||||
mock_is_set.assert_not_called()
|
||||
|
||||
self.sql.loaded = True
|
||||
self.assertEqual(self.listenbrainz._ListenBrainz__idle_work(),
|
||||
GLib.SOURCE_REMOVE)
|
||||
mock_is_set.assert_called()
|
||||
|
||||
def test_stop(self, mock_idle_add: unittest.mock.Mock,
|
||||
mock_source_remove: unittest.mock.Mock,
|
||||
mock_stdout: io.StringIO):
|
||||
"""Test stopping the thread during shutdown."""
|
||||
self.listenbrainz._idle_id = 12345
|
||||
self.listenbrainz._timeout_id = 67890
|
||||
|
||||
self.listenbrainz.stop()
|
||||
self.assertFalse(self.listenbrainz._thread.is_alive())
|
||||
self.assertIsNone(self.listenbrainz._idle_id)
|
||||
self.assertIsNone(self.listenbrainz._timeout_id)
|
||||
mock_source_remove.assert_has_calls([unittest.mock.call(12345),
|
||||
unittest.mock.call(67890)])
|
||||
|
||||
def test_set_user_token(self, mock_idle_add: unittest.mock.Mock,
|
||||
mock_source_remove: unittest.mock.Mock,
|
||||
mock_stdout: io.StringIO):
|
||||
"""Test setting the user-token property."""
|
||||
self.assertEqual(self.listenbrainz.user_token, "")
|
||||
self.assertTrue(self.listenbrainz.valid_token)
|
||||
self.assertTrue(self.listenbrainz.offline)
|
||||
|
||||
self.sql.loaded = True
|
||||
idle_work = self.listenbrainz._ListenBrainz__idle_work
|
||||
with unittest.mock.patch.object(self.listenbrainz._thread,
|
||||
"set_user_token") as mock_set_token:
|
||||
self.listenbrainz.user_token = "abc"
|
||||
self.assertEqual(self.listenbrainz._queue._set_token,
|
||||
("set-token", "abc"))
|
||||
self.assertEqual(self.listenbrainz._idle_id, 42)
|
||||
mock_idle_add.assert_called_with(idle_work)
|
||||
|
||||
self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE)
|
||||
mock_set_token.assert_called_with("abc")
|
||||
|
||||
mock_idle_add.reset_mock()
|
||||
self.listenbrainz.user_token = "abcde"
|
||||
self.assertEqual(self.listenbrainz._queue._set_token,
|
||||
("set-token", "abcde"))
|
||||
self.assertEqual(self.listenbrainz._idle_id, 42)
|
||||
mock_idle_add.assert_not_called()
|
||||
|
||||
self.listenbrainz._thread.set_result(op="set-token", token="abc",
|
||||
valid=True)
|
||||
self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE)
|
||||
mock_set_token.assert_called_with("abcde")
|
||||
|
||||
self.listenbrainz._thread.ready.clear()
|
||||
self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE)
|
||||
|
||||
for valid, offline in [(False, False), (False, True),
|
||||
(True, False), (True, True)]:
|
||||
with self.subTest(valid=valid, offline=offline):
|
||||
self.listenbrainz._thread.set_result(op="set-token",
|
||||
token="abcde",
|
||||
valid=valid,
|
||||
offline=offline)
|
||||
self.assertEqual(idle_work(), GLib.SOURCE_REMOVE)
|
||||
self.assertEqual(self.listenbrainz.valid_token, valid)
|
||||
self.assertEqual(self.listenbrainz.offline, offline)
|
||||
self.assertIsNone(self.listenbrainz._idle_id)
|
||||
|
||||
def test_clear_user_token(self, mock_idle_add: unittest.mock.Mock,
|
||||
mock_source_remove: unittest.mock.Mock,
|
||||
mock_stdout: io.StringIO):
|
||||
"""Test clearing the user-token property."""
|
||||
self.sql.loaded = True
|
||||
idle_work = self.listenbrainz._ListenBrainz__idle_work
|
||||
with unittest.mock.patch.object(self.listenbrainz._thread,
|
||||
"clear_user_token") as mock_clear:
|
||||
self.listenbrainz.valid_token = False
|
||||
self.listenbrainz.user_token = ""
|
||||
self.assertEqual(self.listenbrainz._queue._set_token,
|
||||
("clear-token",))
|
||||
self.assertEqual(self.listenbrainz._idle_id, 42)
|
||||
mock_idle_add.assert_called_with(idle_work)
|
||||
|
||||
self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE)
|
||||
mock_clear.assert_called()
|
||||
|
||||
self.listenbrainz._thread.set_result(op="clear-token", valid=True)
|
||||
self.assertEqual(idle_work(), GLib.SOURCE_REMOVE)
|
||||
self.assertTrue(self.listenbrainz.valid_token)
|
||||
self.assertIsNone(self.listenbrainz._idle_id)
|
||||
|
||||
def test_submit_now_playing(self, mock_idle_add: unittest.mock.Mock,
|
||||
mock_source_remove: unittest.mock.Mock,
|
||||
mock_stdout: io.StringIO):
|
||||
"""Test setting the now-playing property."""
|
||||
self.assertIsNone(self.listenbrainz.now_playing)
|
||||
|
||||
self.sql.loaded = True
|
||||
self.listenbrainz.user_token = "abcde"
|
||||
self.listenbrainz.valid_token = True
|
||||
self.listenbrainz.offline = False
|
||||
self.listenbrainz._queue.pop()
|
||||
self.listenbrainz._ListenBrainz__source_stop("_idle_id")
|
||||
|
||||
self.listenbrainz.now_playing = self.track
|
||||
self.assertTupleEqual(self.listenbrainz._queue._now_playing,
|
||||
("now-playing", self.track))
|
||||
self.assertEqual(self.listenbrainz._idle_id, 42)
|
||||
|
||||
idle_work = self.listenbrainz._ListenBrainz__idle_work
|
||||
with unittest.mock.patch.object(self.listenbrainz._thread,
|
||||
"submit_now_playing") as mock_playing:
|
||||
self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE)
|
||||
mock_playing.assert_called()
|
||||
self.assertIsInstance(mock_playing.call_args.args[0],
|
||||
emmental.listenbrainz.listen.Listen)
|
||||
|
||||
for valid, offline in [(False, False), (False, True),
|
||||
(True, False), (True, True)]:
|
||||
with self.subTest(valid=valid, offline=offline):
|
||||
self.listenbrainz._thread.set_result(op="now-playing",
|
||||
valid=valid,
|
||||
offline=offline)
|
||||
self.assertEqual(idle_work(), GLib.SOURCE_REMOVE)
|
||||
self.assertEqual(self.listenbrainz.valid_token, valid)
|
||||
self.assertEqual(self.listenbrainz.offline, offline)
|
||||
|
||||
def test_submit_now_playing_later(self, mock_idle_add: unittest.mock.Mock,
|
||||
mock_source_remove: unittest.mock.Mock,
|
||||
mock_stdout: io.StringIO):
|
||||
"""Test the now-playing property when ListenBrainz is disconnected."""
|
||||
self.assertIsNone(self.listenbrainz.now_playing)
|
||||
|
||||
self.listenbrainz.now_playing = self.track
|
||||
self.assertTupleEqual(self.listenbrainz._queue._now_playing,
|
||||
("now-playing", self.track))
|
||||
self.assertIsNone(self.listenbrainz._idle_id)
|
||||
|
||||
self.listenbrainz.user_token = "abcde"
|
||||
self.listenbrainz.valid_token = False
|
||||
self.listenbrainz._queue.pop()
|
||||
self.listenbrainz._ListenBrainz__source_stop("_idle_id")
|
||||
self.listenbrainz.now_playing = self.track
|
||||
self.assertTupleEqual(self.listenbrainz._queue._now_playing,
|
||||
("now-playing", self.track))
|
||||
self.assertIsNone(self.listenbrainz._idle_id)
|
||||
|
||||
self.listenbrainz.valid_token = True
|
||||
self.listenbrainz._queue._now_playing = "abcde"
|
||||
self.listenbrainz.now_playing = None
|
||||
self.assertIsNone(self.listenbrainz._queue._now_playing)
|
||||
self.assertIsNone(self.listenbrainz._idle_id)
|
||||
|
||||
self.listenbrainz.offline = True
|
||||
self.listenbrainz.now_playing = self.track
|
||||
self.assertTupleEqual(self.listenbrainz._queue._now_playing,
|
||||
("now-playing", self.track))
|
||||
self.assertIsNone(self.listenbrainz._idle_id)
|
||||
|
||||
def test_submit_listens(self, mock_idle_add: unittest.mock.Mock,
|
||||
mock_source_remove: unittest.mock.Mock,
|
||||
mock_stdout: io.StringIO):
|
||||
"""Test submitting recently listened tracks."""
|
||||
ts1 = datetime.datetime.utcnow()
|
||||
ts2 = datetime.datetime.utcnow()
|
||||
idle_work = self.listenbrainz._ListenBrainz__idle_work
|
||||
listens = [emmental.listenbrainz.listen.Listen(self.track, listenid=1,
|
||||
listened_at=ts1),
|
||||
emmental.listenbrainz.listen.Listen(self.track, listenid=2,
|
||||
listened_at=ts2)]
|
||||
|
||||
self.sql.loaded = True
|
||||
self.listenbrainz.user_token = "abcde"
|
||||
self.listenbrainz.valid_token = True
|
||||
self.listenbrainz._queue.pop()
|
||||
self.listenbrainz._ListenBrainz__source_stop("_idle_id")
|
||||
self.listenbrainz.offline = False
|
||||
self.listenbrainz.submit_listens("ignored", "args")
|
||||
self.assertIsNotNone(self.listenbrainz._idle_id)
|
||||
|
||||
with unittest.mock.patch.object(self.sql.tracks,
|
||||
"get_n_listens") as mock_get_listens:
|
||||
mock_get_listens.return_value = [(1, self.track, ts1),
|
||||
(2, self.track, ts2)]
|
||||
|
||||
with unittest.mock.patch.object(self.listenbrainz._thread,
|
||||
"submit_listens") as mock_submit:
|
||||
self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE)
|
||||
mock_get_listens.assert_called_with(50)
|
||||
mock_submit.assert_called()
|
||||
|
||||
with unittest.mock.patch.object(self.sql.tracks,
|
||||
"delete_listens") as mock_delete:
|
||||
for valid, offline in [(False, False), (False, True),
|
||||
(True, False), (True, True)]:
|
||||
mock_delete.reset_mock()
|
||||
with self.subTest(valid=valid, offline=offline):
|
||||
self.listenbrainz._thread.set_result(op="submit-listens",
|
||||
listens=listens,
|
||||
valid=valid,
|
||||
offline=offline)
|
||||
self.assertEqual(idle_work(), GLib.SOURCE_REMOVE)
|
||||
self.assertEqual(self.listenbrainz.valid_token, valid)
|
||||
self.assertEqual(self.listenbrainz.offline, offline)
|
||||
if valid is True and offline is False:
|
||||
mock_delete.assert_called_with([1, 2])
|
||||
else:
|
||||
mock_delete.assert_not_called()
|
||||
|
||||
def test_submit_listens_later(self, mock_idle_add: unittest.mock.Mock,
|
||||
mock_source_remove: unittest.mock.Mock,
|
||||
mock_stdout: io.StringIO):
|
||||
"""Test submitting listens when ListenBrainz is disconnected."""
|
||||
self.listenbrainz.submit_listens("ignored", "args")
|
||||
self.assertIsNone(self.listenbrainz._idle_id)
|
||||
|
||||
self.listenbrainz.user_token = "abcde"
|
||||
self.listenbrainz.valid_token = False
|
||||
self.listenbrainz._queue.pop()
|
||||
self.listenbrainz._idle_id = None
|
||||
self.listenbrainz.submit_listens("ignored", "args")
|
||||
self.assertIsNone(self.listenbrainz._idle_id)
|
||||
|
||||
self.listenbrainz.valid_token = True
|
||||
self.listenbrainz.offline = True
|
||||
self.listenbrainz.submit_listens("ignored", "args")
|
||||
self.assertIsNone(self.listenbrainz._idle_id)
|
||||
|
||||
@unittest.mock.patch("gi.repository.GLib.timeout_add_seconds")
|
||||
def test_offline_recovery(self, mock_timeout_add: unittest.mock.Mock,
|
||||
mock_idle_add: unittest.mock.Mock,
|
||||
mock_source_remove: unittest.mock.Mock,
|
||||
mock_stdout: io.StringIO):
|
||||
"""Test handling an offline response."""
|
||||
self.assertTrue(self.listenbrainz.offline)
|
||||
|
||||
check_func = self.listenbrainz._ListenBrainz__check_online
|
||||
mock_timeout_add.return_value = 67890
|
||||
self.listenbrainz.offline = True
|
||||
self.assertEqual(self.listenbrainz._timeout_id, 67890)
|
||||
mock_timeout_add.assert_called_with(300, check_func)
|
||||
|
||||
mock_timeout_add.reset_mock()
|
||||
mock_timeout_add.return_value = 99999
|
||||
self.listenbrainz.offline = True
|
||||
self.assertEqual(self.listenbrainz._timeout_id, 67890)
|
||||
mock_timeout_add.assert_not_called()
|
||||
|
||||
self.listenbrainz.offline = False
|
||||
mock_source_remove.assert_called_with(67890)
|
||||
|
||||
mock_source_remove.reset_mock()
|
||||
self.listenbrainz.offline = False
|
||||
mock_source_remove.assert_not_called()
|
|
@ -0,0 +1,68 @@
|
|||
# Copyright 2024 (c) Anna Schumaker.
|
||||
"""Tests our ListenBrainz priority queue."""
|
||||
import emmental.listenbrainz.task
|
||||
import liblistenbrainz
|
||||
import unittest
|
||||
|
||||
|
||||
class TestTaskQueue(unittest.TestCase):
|
||||
"""Test the ListenBrainz queue."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
self.queue = emmental.listenbrainz.task.Queue()
|
||||
|
||||
def test_init(self):
|
||||
"""Test that the queue was set up properly."""
|
||||
self.assertIsNotNone(self.queue)
|
||||
self.assertTupleEqual(self.queue.pop(), ("submit-listens",))
|
||||
|
||||
def test_push_set_token(self):
|
||||
"""Test calling push() with the 'set-token' operation."""
|
||||
self.assertIsNone(self.queue._set_token)
|
||||
|
||||
self.queue.push("set-token", "abcde")
|
||||
self.assertTupleEqual(self.queue._set_token, ("set-token", "abcde"))
|
||||
self.queue.push("set-token", "fghij")
|
||||
self.assertTupleEqual(self.queue._set_token, ("set-token", "fghij"))
|
||||
|
||||
self.assertTupleEqual(self.queue.pop(), ("set-token", "fghij"))
|
||||
self.assertIsNone(self.queue._set_token)
|
||||
|
||||
self.queue.push("set-token", "abcde")
|
||||
self.queue.clear("set-token")
|
||||
self.assertIsNone(self.queue._set_token)
|
||||
|
||||
self.assertTupleEqual(self.queue.pop(), ("submit-listens",))
|
||||
|
||||
def test_push_clear_token(self):
|
||||
"""Test calling push() with the 'clear-token' operation."""
|
||||
self.queue.push("clear-token")
|
||||
self.assertTupleEqual(self.queue._set_token, ("clear-token",))
|
||||
self.assertTupleEqual(self.queue.pop(), ("clear-token",))
|
||||
self.assertIsNone(self.queue._set_token)
|
||||
|
||||
self.queue.push("clear-token")
|
||||
self.queue.clear("clear-token")
|
||||
self.assertIsNone(self.queue._set_token)
|
||||
|
||||
self.assertTupleEqual(self.queue.pop(), ("submit-listens",))
|
||||
|
||||
def test_push_now_playing(self):
|
||||
"""Test the push_now_playing() function."""
|
||||
self.assertIsNone(self.queue._now_playing)
|
||||
|
||||
listen = liblistenbrainz.Listen("Track Name", "Artist Name")
|
||||
self.queue.push("now-playing", listen)
|
||||
self.assertTupleEqual(self.queue._now_playing, ("now-playing", listen))
|
||||
|
||||
self.queue.push("set-token", "abcde")
|
||||
self.assertTupleEqual(self.queue.pop(), ("set-token", "abcde"))
|
||||
self.assertTupleEqual(self.queue.pop(), ("now-playing", listen))
|
||||
self.assertIsNone(self.queue._now_playing)
|
||||
|
||||
self.queue.push("now-playing", listen)
|
||||
self.queue.clear("now-playing")
|
||||
self.assertIsNone(self.queue._now_playing)
|
||||
|
||||
self.assertTupleEqual(self.queue.pop(), ("submit-listens",))
|
|
@ -0,0 +1,189 @@
|
|||
# Copyright 2024 (c) Anna Schumaker.
|
||||
"""Tests our ListenBrainz client thread."""
|
||||
import emmental.listenbrainz.thread
|
||||
import io
|
||||
import liblistenbrainz
|
||||
import requests
|
||||
import unittest
|
||||
|
||||
|
||||
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
||||
class TestThread(unittest.TestCase):
|
||||
"""ListenBrainz Thread test case."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
self.thread = emmental.listenbrainz.thread.Thread()
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up."""
|
||||
self.thread.stop()
|
||||
|
||||
def test_init(self, mock_stdout: io.StringIO):
|
||||
"""Test that the ListenBrainz thread was initialized properly."""
|
||||
self.assertIsInstance(self.thread, emmental.thread.Thread)
|
||||
self.assertIsInstance(self.thread._client,
|
||||
liblistenbrainz.client.ListenBrainz)
|
||||
|
||||
def test_clear_user_token(self, mock_stdout: io.StringIO):
|
||||
"""Test clearing the user token."""
|
||||
with unittest.mock.patch.object(self.thread._client,
|
||||
"set_auth_token") as mock_set_auth:
|
||||
self.thread.clear_user_token()
|
||||
self.assertFalse(self.thread.ready.is_set())
|
||||
self.assertEqual(self.thread._task, {"op": "clear-token"})
|
||||
self.assertEqual(mock_stdout.getvalue(),
|
||||
"listenbrainz: clearing user token\n")
|
||||
|
||||
self.thread.ready.wait()
|
||||
mock_set_auth.assert_called_with(None, check_validity=False)
|
||||
self.assertEqual(self.thread.get_result(),
|
||||
{"op": "clear-token", "valid": True,
|
||||
"offline": False})
|
||||
|
||||
def test_set_user_token(self, mock_stdout: io.StringIO):
|
||||
"""Test setting the user auth token."""
|
||||
with unittest.mock.patch.object(self.thread._client,
|
||||
"set_auth_token") as mock_set_auth:
|
||||
self.thread.set_user_token("abcde")
|
||||
self.assertFalse(self.thread.ready.is_set())
|
||||
self.assertEqual(self.thread._task,
|
||||
{"op": "set-token", "token": "abcde"})
|
||||
self.assertEqual(mock_stdout.getvalue(),
|
||||
"listenbrainz: setting user token\n")
|
||||
|
||||
self.thread.ready.wait()
|
||||
mock_set_auth.assert_called_with("abcde")
|
||||
self.assertEqual(self.thread.get_result(),
|
||||
{"op": "set-token", "token": "abcde",
|
||||
"valid": True, "offline": False})
|
||||
|
||||
def test_set_user_token_exceptions(self, mock_stdout: io.StringIO):
|
||||
"""Test exception handling when setting the user auth token."""
|
||||
with unittest.mock.patch.object(self.thread._client,
|
||||
"set_auth_token") as mock_set_auth:
|
||||
mock_set_auth.side_effect = \
|
||||
liblistenbrainz.errors.InvalidAuthTokenException()
|
||||
self.thread.set_user_token("abcde")
|
||||
self.thread.ready.wait()
|
||||
self.assertEqual(self.thread.get_result(),
|
||||
{"op": "set-token", "token": "abcde",
|
||||
"valid": False, "offline": False})
|
||||
self.assertEqual(mock_stdout.getvalue(),
|
||||
"listenbrainz: setting user token\n" +
|
||||
"listenbrainz: user token is invalid\n")
|
||||
|
||||
mock_set_auth.side_effect = requests.exceptions.ConnectionError()
|
||||
self.thread.set_user_token("abcde")
|
||||
self.thread.ready.wait()
|
||||
self.assertEqual(self.thread.get_result(),
|
||||
{"op": "set-token", "token": "abcde",
|
||||
"valid": True, "offline": True})
|
||||
self.assertRegex(mock_stdout.getvalue(), "listenbrainz: offline")
|
||||
|
||||
def test_submit_now_playing(self, mock_stdout: io.StringIO):
|
||||
"""Test submitting the now playing track."""
|
||||
listen = liblistenbrainz.Listen("Track Name", "Artist Name")
|
||||
with unittest.mock.patch.object(self.thread._client,
|
||||
"submit_playing_now") as mock_submit:
|
||||
self.thread.submit_now_playing(listen)
|
||||
self.assertFalse(self.thread.ready.is_set())
|
||||
self.assertEqual(self.thread._task, {"op": "now-playing",
|
||||
"listen": listen})
|
||||
self.assertEqual(mock_stdout.getvalue(),
|
||||
"listenbrainz: now playing 'Track Name' " +
|
||||
"by 'Artist Name'\n")
|
||||
|
||||
self.thread.ready.wait()
|
||||
mock_submit.assert_called_with(listen)
|
||||
self.assertEqual(self.thread.get_result(),
|
||||
{"op": "now-playing", "valid": True,
|
||||
"offline": False})
|
||||
|
||||
def test_submit_now_playing_exceptions(self, mock_stdout: io.StringIO):
|
||||
"""Test exception handling when submitting the now playing track."""
|
||||
listen = liblistenbrainz.Listen("Track Name", "Artist Name")
|
||||
with unittest.mock.patch.object(self.thread._client,
|
||||
"submit_playing_now") as mock_submit:
|
||||
mock_submit.side_effect = \
|
||||
liblistenbrainz.errors.ListenBrainzAPIException(401)
|
||||
self.thread.submit_now_playing(listen)
|
||||
self.thread.ready.wait()
|
||||
self.assertEqual(self.thread.get_result(),
|
||||
{"op": "now-playing", "valid": False,
|
||||
"offline": False})
|
||||
self.assertEqual(mock_stdout.getvalue(),
|
||||
"listenbrainz: now playing 'Track Name' " +
|
||||
"by 'Artist Name'\n" +
|
||||
"listenbrainz: user token is invalid\n")
|
||||
|
||||
mock_submit.side_effect = requests.exceptions.ConnectionError()
|
||||
self.thread.submit_now_playing(listen)
|
||||
self.thread.ready.wait()
|
||||
self.assertEqual(self.thread.get_result(),
|
||||
{"op": "now-playing", "valid": True,
|
||||
"offline": True})
|
||||
self.assertRegex(mock_stdout.getvalue(), "listenbrainz: offline")
|
||||
|
||||
def test_submit_single_listen(self, mock_stdout: io.StringIO):
|
||||
"""Test submitting a single listen."""
|
||||
listens = [liblistenbrainz.Listen("Track Name", "Artist Name")]
|
||||
with unittest.mock.patch.object(self.thread._client,
|
||||
"submit_single_listen") as mock_submit:
|
||||
self.thread.submit_listens(listens)
|
||||
self.assertFalse(self.thread.ready.is_set())
|
||||
self.assertEqual(self.thread._task, {"op": "submit-listens",
|
||||
"listens": listens})
|
||||
self.assertEqual(mock_stdout.getvalue(),
|
||||
"listenbrainz: submitting 1 listen\n")
|
||||
|
||||
self.thread.ready.wait()
|
||||
mock_submit.assert_called_with(listens[0])
|
||||
self.assertEqual(self.thread.get_result(),
|
||||
{"op": "submit-listens", "listens": listens,
|
||||
"valid": True, "offline": False})
|
||||
|
||||
def test_submit_multiple_listens(self, mock_stdout: io.StringIO):
|
||||
"""Test submitting multiple listens."""
|
||||
listens = [liblistenbrainz.Listen("Track 1", "Artist"),
|
||||
liblistenbrainz.Listen("Track 2", "Artist"),
|
||||
liblistenbrainz.Listen("Track 3", "Artist")]
|
||||
with unittest.mock.patch.object(self.thread._client,
|
||||
"submit_multiple_listens") \
|
||||
as mock_submit:
|
||||
self.thread.submit_listens(listens)
|
||||
self.assertFalse(self.thread.ready.is_set())
|
||||
self.assertEqual(self.thread._task, {"op": "submit-listens",
|
||||
"listens": listens})
|
||||
self.assertEqual(mock_stdout.getvalue(),
|
||||
"listenbrainz: submitting 3 listens\n")
|
||||
|
||||
self.thread.ready.wait()
|
||||
mock_submit.assert_called_with(listens)
|
||||
self.assertEqual(self.thread.get_result(),
|
||||
{"op": "submit-listens", "listens": listens,
|
||||
"valid": True, "offline": False})
|
||||
|
||||
def test_submit_listens_exceptions(self, mock_stdout: io.StringIO):
|
||||
"""Test exception handling when submitting listens."""
|
||||
listens = [liblistenbrainz.Listen("Track Name", "Artist Name")]
|
||||
with unittest.mock.patch.object(self.thread._client,
|
||||
"submit_single_listen") as mock_submit:
|
||||
mock_submit.side_effect = \
|
||||
liblistenbrainz.errors.ListenBrainzAPIException(401)
|
||||
self.thread.submit_listens(listens)
|
||||
self.thread.ready.wait()
|
||||
self.assertEqual(self.thread.get_result(),
|
||||
{"op": "submit-listens", "listens": listens,
|
||||
"valid": False, "offline": False})
|
||||
self.assertEqual(mock_stdout.getvalue(),
|
||||
"listenbrainz: submitting 1 listen\n" +
|
||||
"listenbrainz: user token is invalid\n")
|
||||
|
||||
mock_submit.side_effect = requests.exceptions.ConnectionError()
|
||||
self.thread.submit_listens(listens)
|
||||
self.thread.ready.wait()
|
||||
self.assertEqual(self.thread.get_result(),
|
||||
{"op": "submit-listens", "listens": listens,
|
||||
"valid": True, "offline": True})
|
||||
self.assertRegex(mock_stdout.getvalue(), "listenbrainz: offline")
|
|
@ -21,7 +21,9 @@ class TestArtwork(unittest.TestCase):
|
|||
|
||||
def test_init(self):
|
||||
"""Test that the artwork widget is configured correctly."""
|
||||
self.assertIsInstance(self.artwork, Gtk.Frame)
|
||||
self.assertIsInstance(self.artwork, Gtk.Picture)
|
||||
self.assertEqual(self.artwork.get_content_fit(),
|
||||
Gtk.ContentFit.CONTAIN)
|
||||
|
||||
self.assertEqual(self.artwork.get_halign(), Gtk.Align.CENTER)
|
||||
self.assertEqual(self.artwork.get_valign(), Gtk.Align.CENTER)
|
||||
|
@ -31,27 +33,24 @@ class TestArtwork(unittest.TestCase):
|
|||
self.assertEqual(self.artwork.get_margin_start(), 6)
|
||||
self.assertEqual(self.artwork.get_margin_end(), 6)
|
||||
|
||||
def test_picture(self):
|
||||
"""Test that the artwork picture is configured correctly."""
|
||||
self.assertIsInstance(self.artwork._picture, Gtk.Picture)
|
||||
self.assertEqual(self.artwork._picture.get_content_fit(),
|
||||
Gtk.ContentFit.CONTAIN)
|
||||
self.assertEqual(self.artwork.get_child(), self.artwork._picture)
|
||||
self.assertTrue(self.artwork.has_css_class("card"))
|
||||
|
||||
def test_filepath(self):
|
||||
"""Test that the filepath property works as expected."""
|
||||
self.assertIsNone(self.artwork.filepath)
|
||||
self.assertIsNotNone(self.artwork._picture.get_paintable())
|
||||
self.assertEqual(self.artwork._picture.get_file().get_parse_name(),
|
||||
self.assertIsNotNone(self.artwork.get_paintable())
|
||||
self.assertEqual(self.artwork.get_file().get_parse_name(),
|
||||
f"resource://{self.fallback}")
|
||||
|
||||
self.artwork.filepath = tests.util.COVER_JPG
|
||||
self.assertIsNotNone(self.artwork._picture.get_paintable())
|
||||
self.assertEqual(self.artwork._picture.get_file().get_parse_name(),
|
||||
self.assertIsNotNone(self.artwork.get_paintable())
|
||||
self.assertEqual(self.artwork.get_file().get_parse_name(),
|
||||
str(tests.util.COVER_JPG))
|
||||
self.assertEqual(self.artwork.filepath, tests.util.COVER_JPG)
|
||||
|
||||
self.artwork.filepath = None
|
||||
self.assertIsNotNone(self.artwork._picture.get_paintable())
|
||||
self.assertEqual(self.artwork._picture.get_file().get_parse_name(),
|
||||
self.assertIsNotNone(self.artwork.get_paintable())
|
||||
self.assertEqual(self.artwork.get_file().get_parse_name(),
|
||||
f"resource://{self.fallback}")
|
||||
|
||||
def test_fullsize(self):
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue