Compare commits
56 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 |
|
@ -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
|
||||
|
|
|
@ -20,6 +20,7 @@ other playlists run out of tracks.
|
|||
* Python3
|
||||
* dateutil
|
||||
* gobject
|
||||
* liblistenbrainz
|
||||
* musicbrainzngs
|
||||
* mutagen
|
||||
* pyxdg
|
||||
|
|
2
aur
2
aur
|
@ -1 +1 @@
|
|||
Subproject commit 562b8d043c5f78ed3b2f62677414117f0af509a6
|
||||
Subproject commit 543220f35990f5b5a69eac5a6c06e69eae6ffb82
|
|
@ -7,6 +7,7 @@ from . import action
|
|||
from . import audio
|
||||
from . import db
|
||||
from . import header
|
||||
from . import listenbrainz
|
||||
from . import mpris2
|
||||
from . import nowplaying
|
||||
from . import options
|
||||
|
@ -20,8 +21,8 @@ from gi.repository import Gio
|
|||
from gi.repository import Adw
|
||||
|
||||
MAJOR_VERSION = 3
|
||||
MINOR_VERSION = 0
|
||||
MICRO_VERSION = 6
|
||||
MINOR_VERSION = 2
|
||||
MICRO_VERSION = 0
|
||||
|
||||
VERSION_NUMBER = f"{MAJOR_VERSION}.{MINOR_VERSION}.{MICRO_VERSION}"
|
||||
VERSION_STRING = f"Emmental {VERSION_NUMBER}{gsetup.DEBUG_STR}"
|
||||
|
@ -34,6 +35,7 @@ class Application(Adw.Application):
|
|||
factory = GObject.Property(type=playlist.Factory)
|
||||
mpris = GObject.Property(type=mpris2.Connection)
|
||||
player = GObject.Property(type=audio.Player)
|
||||
lbrainz = GObject.Property(type=listenbrainz.ListenBrainz)
|
||||
win = GObject.Property(type=window.Window)
|
||||
|
||||
autopause = GObject.Property(type=int, default=-1, minimum=-1, maximum=99)
|
||||
|
@ -135,11 +137,14 @@ class Application(Adw.Application):
|
|||
hdr = header.Header(sql=self.db, title=VERSION_STRING)
|
||||
for prop in ["bg-enabled", "bg-volume", "volume"]:
|
||||
hdr.bind_property(prop, self.player, prop)
|
||||
hdr.bind_property("listenbrainz-token", self.lbrainz, "user-token")
|
||||
for (setting, property) in [("audio.volume", "volume"),
|
||||
("audio.background.enabled", "bg-enabled"),
|
||||
("audio.background.volume", "bg-volume"),
|
||||
("audio.replaygain.enabled", "rg-enabled"),
|
||||
("audio.replaygain.mode", "rg-mode")]:
|
||||
("audio.replaygain.mode", "rg-mode"),
|
||||
("listenbrainz.token",
|
||||
"listenbrainz_token")]:
|
||||
self.db.settings.bind_setting(setting, hdr, property)
|
||||
|
||||
self.__add_accelerators(hdr.accelerators)
|
||||
|
@ -206,12 +211,14 @@ class Application(Adw.Application):
|
|||
now_playing=self.build_now_playing(),
|
||||
sidebar=self.build_sidebar(),
|
||||
tracklist=self.build_tracklist())
|
||||
win.bind_property("show-sidebar", win.header, "show-sidebar",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
win.bind_property("user-editing", win.now_playing, "editing")
|
||||
|
||||
for (setting, property) in [("window.width", "default-width"),
|
||||
("window.height", "default-height"),
|
||||
("now-playing.size", "now-playing-size"),
|
||||
("sidebar.size", "sidebar-size")]:
|
||||
("sidebar.show", "show-sidebar")]:
|
||||
self.db.settings.bind_setting(setting, win, property)
|
||||
|
||||
self.__add_accelerators(win.accelerators)
|
||||
|
@ -251,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",
|
||||
|
@ -277,6 +293,7 @@ class Application(Adw.Application):
|
|||
Adw.Application.do_startup(self)
|
||||
self.db = db.Connection()
|
||||
self.mpris = mpris2.Connection()
|
||||
self.lbrainz = listenbrainz.ListenBrainz(self.db)
|
||||
self.factory = playlist.Factory(self.db)
|
||||
self.player = audio.Player()
|
||||
|
||||
|
@ -289,6 +306,7 @@ class Application(Adw.Application):
|
|||
self.win = self.build_window()
|
||||
self.add_window(self.win)
|
||||
self.connect_mpris2()
|
||||
self.connect_listenbrainz()
|
||||
self.connect_playlist_factory()
|
||||
self.connect_player()
|
||||
|
||||
|
@ -314,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,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]
|
|
@ -18,13 +18,16 @@ from . import tracks
|
|||
from . import years
|
||||
|
||||
|
||||
SQL_SCRIPT = pathlib.Path(__file__).parent / "emmental.sql"
|
||||
SQL_V1_SCRIPT = pathlib.Path(__file__).parent / "emmental.sql"
|
||||
SQL_V2_SCRIPT = pathlib.Path(__file__).parent / "upgrade-v2.sql"
|
||||
SQL_V3_SCRIPT = pathlib.Path(__file__).parent / "upgrade-v3.sql"
|
||||
|
||||
|
||||
class Connection(connection.Connection):
|
||||
"""Connect to the database."""
|
||||
|
||||
active_playlist = GObject.Property(type=playlist.Playlist)
|
||||
loaded = GObject.Property(type=bool, default=False)
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize a sqlite connection."""
|
||||
|
@ -43,13 +46,25 @@ class Connection(connection.Connection):
|
|||
|
||||
self.tracks = tracks.Table(self)
|
||||
|
||||
def __check_loaded(self) -> None:
|
||||
for tbl in list(self.playlist_tables()) + [self.tracks]:
|
||||
if tbl.loaded is False:
|
||||
return
|
||||
self.loaded = True
|
||||
|
||||
def __check_version(self) -> None:
|
||||
user_version = self("PRAGMA user_version").fetchone()["user_version"]
|
||||
match user_version:
|
||||
case 0:
|
||||
with open(SQL_SCRIPT) as f:
|
||||
self._sql.executescript(f.read())
|
||||
case 1: pass
|
||||
self.executescript(SQL_V1_SCRIPT)
|
||||
self.executescript(SQL_V2_SCRIPT)
|
||||
self.executescript(SQL_V3_SCRIPT)
|
||||
case 1:
|
||||
self.executescript(SQL_V2_SCRIPT)
|
||||
self.executescript(SQL_V3_SCRIPT)
|
||||
case 2:
|
||||
self.executescript(SQL_V3_SCRIPT)
|
||||
case 3: pass
|
||||
case _:
|
||||
raise Exception(f"Unsupported data version: {user_version}")
|
||||
|
||||
|
@ -96,3 +111,4 @@ class Connection(connection.Connection):
|
|||
def table_loaded(self, tbl: table.Table) -> None:
|
||||
"""Signal that a table has been loaded."""
|
||||
tbl.loaded = True
|
||||
self.__check_loaded()
|
||||
|
|
|
@ -85,3 +85,11 @@ class Connection(GObject.GObject):
|
|||
return self._sql.executemany(statement, args)
|
||||
except sqlite3.InternalError:
|
||||
return None
|
||||
|
||||
def executescript(self, script: pathlib.Path) -> sqlite3.Cursor | None:
|
||||
"""Execute a SQL script."""
|
||||
if script.is_file():
|
||||
with open(script) as f:
|
||||
cur = self._sql.executescript(f.read())
|
||||
self.commit()
|
||||
return cur
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -3,9 +3,9 @@
|
|||
import emmental.audio.tagger
|
||||
import musicbrainzngs
|
||||
import pathlib
|
||||
import threading
|
||||
from gi.repository import GObject
|
||||
from .. import audio
|
||||
from .. import thread
|
||||
from . import albums
|
||||
from . import artists
|
||||
from . import connection
|
||||
|
@ -178,24 +178,12 @@ class Tags:
|
|||
return year if year else self.db.years.create(raw_year)
|
||||
|
||||
|
||||
class Thread(threading.Thread):
|
||||
class Thread(thread.Thread):
|
||||
"""A thread for tagging files without blocking the UI."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the Tagger Thread."""
|
||||
super().__init__()
|
||||
self.ready = threading.Event()
|
||||
|
||||
self._connection = None
|
||||
self._condition = threading.Condition()
|
||||
self._file = None
|
||||
self._mtime = None
|
||||
self._tags = None
|
||||
self.start()
|
||||
|
||||
def __close_connection(self) -> None:
|
||||
if self._connection:
|
||||
self._connection.close()
|
||||
self._connection = None
|
||||
|
||||
def __get_connection(self) -> connection.Connection:
|
||||
|
@ -213,55 +201,31 @@ class Thread(threading.Thread):
|
|||
mb_res = musicbrainzngs.get_artist_by_id(artist.mbid)
|
||||
artist.name = mb_res["artist"]["name"]
|
||||
|
||||
def get_result(self, db: GObject.TYPE_PYOBJECT,
|
||||
library: playlist.Playlist) \
|
||||
-> tuple[pathlib.Path | None, Tags | None]:
|
||||
def do_get_result(self, result: thread.Data, db: GObject.TYPE_PYOBJECT,
|
||||
library: playlist.Playlist) -> tuple:
|
||||
"""Return the resulting Tags structure."""
|
||||
with self._condition:
|
||||
if not self.ready.is_set():
|
||||
return (None, None)
|
||||
tags = None if result.tags is None else Tags(db, result.tags, library)
|
||||
return (result.path, tags)
|
||||
|
||||
tags = Tags(db, self._tags, library) if self._tags else None
|
||||
res = (self._file, tags)
|
||||
self._file = None
|
||||
self._tags = None
|
||||
return res
|
||||
|
||||
def run(self) -> None:
|
||||
"""Sleep until we have work to do."""
|
||||
with self._condition:
|
||||
self.ready.set()
|
||||
|
||||
while self._condition.wait():
|
||||
if self._file is None:
|
||||
break
|
||||
|
||||
tags = emmental.audio.tagger.tag_file(self._file, self._mtime)
|
||||
if tags is not None:
|
||||
for artist in tags.artists:
|
||||
self.__check_artist(artist)
|
||||
|
||||
self._tags = tags
|
||||
self.ready.set()
|
||||
|
||||
self.__close_connection()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the thread."""
|
||||
with self._condition:
|
||||
self._file = None
|
||||
self._mtime = None
|
||||
self._condition.notify()
|
||||
self.join()
|
||||
|
||||
def tag_file(self, file: pathlib.Path, mtime: float | None) -> None:
|
||||
def do_run_task(self, task: thread.Data) -> None:
|
||||
"""Tag a file."""
|
||||
with self._condition:
|
||||
self.ready.clear()
|
||||
self._file = file
|
||||
self._mtime = mtime
|
||||
self._tags = None
|
||||
self._condition.notify()
|
||||
tags = emmental.audio.tagger.tag_file(task.path, task.mtime)
|
||||
if tags is not None:
|
||||
for artist in tags.artists:
|
||||
self.__check_artist(artist)
|
||||
|
||||
self.set_result(path=task.path, tags=tags)
|
||||
|
||||
def do_stop(self) -> None:
|
||||
"""Close the connection before stopping."""
|
||||
if self._connection:
|
||||
self._connection.close()
|
||||
self._connection = None
|
||||
|
||||
def tag_file(self, path: pathlib.Path,
|
||||
*, mtime: float | None = None) -> None:
|
||||
"""Tag a file."""
|
||||
self.set_task(path=path, mtime=mtime)
|
||||
|
||||
|
||||
def untag_track(db: GObject.TYPE_PYOBJECT, track: tracks.Track) -> None:
|
||||
|
|
|
@ -200,6 +200,12 @@ class Table(table.Table):
|
|||
return self.sql(f"UPDATE tracks SET {column}=? WHERE trackid=?",
|
||||
newval, track.trackid)
|
||||
|
||||
def delete_listens(self, listenids: list[int]) -> None:
|
||||
"""Delete the listens indicated by the provided listenids."""
|
||||
self.sql.executemany("""DELETE FROM listenbrainz_queue
|
||||
WHERE listenid=?""",
|
||||
*[(id,) for id in listenids])
|
||||
|
||||
def get_artists(self, track: Track) -> list[table.Row]:
|
||||
"""Get the set of Artists for a specific Track."""
|
||||
rows = self.sql("""SELECT artistid FROM artist_tracks_view
|
||||
|
@ -212,6 +218,14 @@ class Table(table.Table):
|
|||
WHERE trackid=?""", track.trackid).fetchall()
|
||||
return [self.sql.genres.rows.get(row["genreid"]) for row in rows]
|
||||
|
||||
def get_n_listens(self, n: int) -> list[tuple]:
|
||||
"""Get the n most recent listens from the listenbrainz queue."""
|
||||
cur = self.sql("""SELECT listenid, trackid, timestamp
|
||||
FROM listenbrainz_queue ORDER BY timestamp DESC
|
||||
LIMIT ?""", n)
|
||||
return [(row["listenid"], self.rows[row["trackid"]], row["timestamp"])
|
||||
for row in cur.fetchall()]
|
||||
|
||||
def map_sort_order(self, ordering: str) -> dict[int, int]:
|
||||
"""Get a lookup table for Track sort keys."""
|
||||
ordering = ordering if len(ordering) > 0 else "trackid"
|
||||
|
@ -270,9 +284,17 @@ class Table(table.Table):
|
|||
self.sql.playlists.most_played.reload_tracks(idle=True)
|
||||
self.sql.playlists.queued.remove_track(track)
|
||||
self.sql.playlists.unplayed.remove_track(track)
|
||||
self.emit("track-played", track)
|
||||
|
||||
self.sql.commit()
|
||||
|
||||
@GObject.Signal(arg_types=(Track,))
|
||||
def track_played(self, track: Track) -> None:
|
||||
"""Signal that a Track was played."""
|
||||
if track is not None:
|
||||
self.sql("""INSERT INTO listenbrainz_queue (trackid, timestamp)
|
||||
VALUES (?, ?)""", track.trackid, track.lastplayed)
|
||||
|
||||
|
||||
class TrackidSet(GObject.GObject):
|
||||
"""Manage a set of Track IDs."""
|
||||
|
|
|
@ -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;
|
|
@ -24,6 +24,9 @@ CSS_PRIORITY = gi.repository.Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
|||
CSS_PROVIDER = gi.repository.Gtk.CssProvider()
|
||||
CSS_PROVIDER.load_from_path(str(CSS_FILE))
|
||||
|
||||
CACHE_DIR = pathlib.Path(xdg.BaseDirectory.save_cache_path("emmental"))
|
||||
CACHE_DIR = CACHE_DIR / DEBUG_STR.lstrip("-")
|
||||
|
||||
DATA_DIR = pathlib.Path(xdg.BaseDirectory.save_data_path("emmental"))
|
||||
|
||||
RESOURCE_PATH = "/com/nowheycreamery/emmental"
|
||||
|
@ -40,6 +43,13 @@ def add_style():
|
|||
CSS_PROVIDER, CSS_PRIORITY)
|
||||
|
||||
|
||||
def has_icon(icon_name: str):
|
||||
"""Check if the icon theme has a specific icon."""
|
||||
display = gi.repository.Gdk.Display.get_default()
|
||||
theme = gi.repository.Gtk.IconTheme.get_for_display(display)
|
||||
return theme.has_icon(icon_name)
|
||||
|
||||
|
||||
def __version_string(subsystem, major, minor, micro):
|
||||
return f" ⋅ {subsystem} {major}.{minor}.{micro}"
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ from ..action import ActionEntry
|
|||
from .. import db
|
||||
from .. import buttons
|
||||
from .. import gsetup
|
||||
from . import listenbrainz
|
||||
from . import open
|
||||
from . import replaygain
|
||||
from . import volume
|
||||
|
@ -34,6 +35,8 @@ class Header(Gtk.HeaderBar):
|
|||
sql = GObject.Property(type=db.Connection)
|
||||
title = GObject.Property(type=str)
|
||||
subtitle = GObject.Property(type=str)
|
||||
listenbrainz_token = GObject.Property(type=str)
|
||||
show_sidebar = GObject.Property(type=bool, default=False)
|
||||
bg_enabled = GObject.Property(type=bool, default=False)
|
||||
bg_volume = GObject.Property(type=float, default=0.5)
|
||||
rg_enabled = GObject.Property(type=bool, default=False)
|
||||
|
@ -43,9 +46,27 @@ class Header(Gtk.HeaderBar):
|
|||
def __init__(self, sql: db.Connection, title: str):
|
||||
"""Initialize the HeaderBar."""
|
||||
super().__init__(title=title, subtitle=SUBTITLE, sql=sql)
|
||||
self._open = open.Button()
|
||||
self._title = Adw.WindowTitle(title=self.title, subtitle=self.subtitle,
|
||||
tooltip_text=gsetup.env_string())
|
||||
|
||||
icon = "sidebar-show-symbolic"
|
||||
self._show_sidebar = Gtk.ToggleButton(icon_name=icon, has_frame=False)
|
||||
self._open = open.OpenRow()
|
||||
self._listenbrainz = listenbrainz.ListenBrainzRow()
|
||||
|
||||
self._menu_box = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
|
||||
self._menu_box.add_css_class("boxed-list")
|
||||
self._menu_box.append(self._open)
|
||||
self._menu_box.append(self._listenbrainz)
|
||||
|
||||
if __debug__:
|
||||
self._settings = settings.Row(sql)
|
||||
self._menu_box.append(self._settings)
|
||||
|
||||
icon = "open-menu-symbolic"
|
||||
self._menu_button = buttons.PopoverButton(popover_child=self._menu_box,
|
||||
icon_name=icon)
|
||||
|
||||
self._volume = volume.VolumeRow()
|
||||
self._volume_icon = Gtk.Image(icon_name=_volume_icon(self.volume))
|
||||
self._background = volume.BackgroundRow()
|
||||
|
@ -56,18 +77,21 @@ class Header(Gtk.HeaderBar):
|
|||
self._icons.append(self._volume_icon)
|
||||
self._icons.append(self._background_icon)
|
||||
|
||||
self._box = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
|
||||
self._box.add_css_class("boxed-list")
|
||||
self._box.append(self._volume)
|
||||
self._box.append(self._background)
|
||||
self._box.append(self._replaygain)
|
||||
self._vol_box = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
|
||||
self._vol_box.add_css_class("boxed-list")
|
||||
self._vol_box.append(self._volume)
|
||||
self._vol_box.append(self._background)
|
||||
self._vol_box.append(self._replaygain)
|
||||
|
||||
self._button = buttons.PopoverButton(popover_child=self._box,
|
||||
child=self._icons,
|
||||
has_frame=False, margin_end=6)
|
||||
self._vol_button = buttons.PopoverButton(popover_child=self._vol_box,
|
||||
child=self._icons,
|
||||
has_frame=False, margin_end=6)
|
||||
|
||||
self.bind_property("title", self._title, "title")
|
||||
self.bind_property("subtitle", self._title, "subtitle")
|
||||
self.bind_property("listenbrainz-token", self._listenbrainz, "text")
|
||||
self.bind_property("show-sidebar", self._show_sidebar, "active",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.bind_property("bg-enabled", self._background, "enabled",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.bind_property("bg-volume", self._background, "volume",
|
||||
|
@ -79,18 +103,15 @@ class Header(Gtk.HeaderBar):
|
|||
self.bind_property("volume", self._volume, "volume",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
|
||||
self.pack_start(self._open)
|
||||
if __debug__:
|
||||
self._window = settings.Window(sql)
|
||||
self._settings = Gtk.Button(icon_name="settings-symbolic",
|
||||
tooltip_text="open settings editor")
|
||||
self._settings.connect("clicked", self.__run_settings)
|
||||
self.pack_start(self._settings)
|
||||
self.pack_start(self._show_sidebar)
|
||||
self.pack_start(self._menu_button)
|
||||
|
||||
self.pack_end(self._button)
|
||||
self.pack_end(self._vol_button)
|
||||
self.set_title_widget(self._title)
|
||||
|
||||
self._menu_button.props.popover.connect("closed", self.__menu_closed)
|
||||
self._open.connect("track-requested", self.__track_requested)
|
||||
self._listenbrainz.connect("apply", self.__listenbrainz_apply)
|
||||
self.connect("notify", self.__notify)
|
||||
|
||||
def __run_settings(self, button: Gtk.Button) -> None:
|
||||
|
@ -113,12 +134,35 @@ class Header(Gtk.HeaderBar):
|
|||
status = (f"volume: {round(self.volume * 100)}%\n"
|
||||
f"background listening: {bg_status}\n"
|
||||
f"normalizing: {rg_status}")
|
||||
self._button.set_tooltip_text(status)
|
||||
self._vol_button.set_tooltip_text(status)
|
||||
|
||||
def __track_requested(self, button: open.Button,
|
||||
def __listenbrainz_apply(self, entry: Adw.PasswordEntryRow) -> None:
|
||||
self.listenbrainz_token = entry.get_text()
|
||||
self._menu_button.popdown()
|
||||
|
||||
def __menu_closed(self, popover: Gtk.Popover) -> None:
|
||||
self._listenbrainz.props.text = self.listenbrainz_token
|
||||
|
||||
def __track_requested(self, button: open.OpenRow,
|
||||
path: pathlib.Path) -> None:
|
||||
self.emit("track-requested", path)
|
||||
|
||||
@GObject.Property(type=bool, default=True)
|
||||
def listenbrainz_token_valid(self) -> bool:
|
||||
"""Check if we think the listenbrainz token is valid."""
|
||||
return not self._listenbrainz.has_css_class("warning")
|
||||
|
||||
@listenbrainz_token_valid.setter
|
||||
def listenbrainz_token_valid(self, valid: bool) -> None:
|
||||
if valid:
|
||||
self._menu_button.remove_css_class("warning")
|
||||
self._listenbrainz.remove_css_class("warning")
|
||||
else:
|
||||
win = self.get_ancestor(Gtk.Window)
|
||||
win.post_toast("listenbrainz: user token is invalid")
|
||||
self._menu_button.add_css_class("warning")
|
||||
self._listenbrainz.add_css_class("warning")
|
||||
|
||||
@property
|
||||
def accelerators(self) -> list[ActionEntry]:
|
||||
"""Get a list of accelerators for the Header."""
|
||||
|
@ -128,7 +172,9 @@ class Header(Gtk.HeaderBar):
|
|||
ActionEntry("increase-volume", self._volume.increment,
|
||||
"<Shift><Control>Up"),
|
||||
ActionEntry("toggle-bg-mode", self._background.activate,
|
||||
"<Shift><Control>b")]
|
||||
"<Shift><Control>b"),
|
||||
ActionEntry("toggle-sidebar", self._show_sidebar.activate,
|
||||
"<Control>bracketright")]
|
||||
if __debug__:
|
||||
res.append(ActionEntry("edit-settings", self._settings.activate,
|
||||
"<Shift><Control>s"))
|
||||
|
|
|
@ -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,19 +1,21 @@
|
|||
# Copyright 2023 (c) Anna Schumaker.
|
||||
"""A custom Button that opens a FileDialog to select a file for playback."""
|
||||
"""A custom Adw.ActionRow to select a file for playback."""
|
||||
import pathlib
|
||||
from gi.repository import GObject
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Gio
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
|
||||
|
||||
class Button(Gtk.Button):
|
||||
"""Our pre-configured open button."""
|
||||
class OpenRow(Adw.ActionRow):
|
||||
"""Our pre-configured open Adw.ActionRow."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize our open button."""
|
||||
super().__init__(icon_name="document-open-symbolic",
|
||||
tooltip_text="open a file for playback")
|
||||
"""Initialize our open ActionRow."""
|
||||
super().__init__(activatable=True, title="Open File",
|
||||
subtitle="Select a file for playback")
|
||||
self._prefix = Gtk.Image(icon_name="document-open-symbolic")
|
||||
self._filters = Gio.ListStore()
|
||||
self._filter = Gtk.FileFilter(name="Audio Files",
|
||||
mime_types=["inode/directory",
|
||||
|
@ -23,6 +25,9 @@ class Button(Gtk.Button):
|
|||
|
||||
self._filters.append(self._filter)
|
||||
|
||||
self.connect("activated", self.__on_activated)
|
||||
self.add_prefix(self._prefix)
|
||||
|
||||
def __async_ready(self, dialog: Gtk.FileDialog, task: Gio.Task) -> None:
|
||||
try:
|
||||
file = dialog.open_finish(task)
|
||||
|
@ -30,8 +35,9 @@ class Button(Gtk.Button):
|
|||
except GLib.Error:
|
||||
pass
|
||||
|
||||
def do_clicked(self) -> None:
|
||||
"""Handle a click event."""
|
||||
def __on_activated(self, row: Adw.ActionRow) -> None:
|
||||
"""Handle activating an OpenRow."""
|
||||
self.get_ancestor(Gtk.Popover).popdown()
|
||||
self._dialog.open(self.get_ancestor(Gtk.Window), None,
|
||||
self.__async_ready)
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
|
@ -46,10 +46,10 @@ class Card(Gtk.Box):
|
|||
large_icon=True,
|
||||
has_frame=False, sensitive=False,
|
||||
valign=Gtk.Align.CENTER)
|
||||
self._jump = buttons.Button(icon_name="go-jump", has_frame=False,
|
||||
self._jump = buttons.Button(icon_name="arrow4-down-symbolic",
|
||||
tooltip_text="scroll to current track",
|
||||
large_icon=True, sensitive=False,
|
||||
valign=Gtk.Align.CENTER)
|
||||
has_frame=False, valign=Gtk.Align.CENTER)
|
||||
self._seeker = seeker.Scale(sensitive=False)
|
||||
|
||||
self.bind_property("artwork", self._artwork, "filepath")
|
||||
|
|
|
@ -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
|
||||
|
@ -26,7 +27,7 @@ class Card(Gtk.Box):
|
|||
sensitive=False, **kwargs)
|
||||
self._header = Gtk.CenterBox()
|
||||
self._filter = entry.Filter("playlists", hexpand=True)
|
||||
self._jump = Gtk.Button(icon_name="go-jump-symbolic",
|
||||
self._jump = Gtk.Button(icon_name="arrow4-down-symbolic",
|
||||
tooltip_text="scroll to current playlist")
|
||||
self._playlists = playlist.Section(self.sql.playlists)
|
||||
self._artists = artist.Section(self.sql.artists, self.sql.albums)
|
||||
|
@ -51,7 +52,7 @@ class Card(Gtk.Box):
|
|||
|
||||
self._filter.connect("search-changed", self.__search_changed)
|
||||
self._jump.connect("clicked", self.__jump_to_playlist)
|
||||
self.sql.connect("table-loaded", self.__table_loaded)
|
||||
self.sql.connect("notify::loaded", self.__database_loaded)
|
||||
|
||||
self._header.add_css_class("toolbar")
|
||||
self.add_css_class("card")
|
||||
|
@ -62,31 +63,40 @@ class Card(Gtk.Box):
|
|||
def __search_changed(self, entry: entry.Filter) -> None:
|
||||
self.sql.filter(entry.get_query())
|
||||
|
||||
def __table_loaded(self, sql: db.Connection, table: db.table.Table):
|
||||
if self.get_sensitive() is False:
|
||||
if False not in {tbl.loaded for tbl in sql.playlist_tables()}:
|
||||
self.set_sensitive(True)
|
||||
self.select_playlist(sql.active_playlist)
|
||||
if len(sql.libraries) == 0:
|
||||
self._libraries.extra_widget.emit("clicked")
|
||||
def __database_loaded(self, sql: db.Connection, param: GObject.ParamSpec):
|
||||
self.set_sensitive(sql.loaded)
|
||||
if sql.loaded is True:
|
||||
self.select_playlist(sql.active_playlist, 150)
|
||||
if len(sql.libraries) == 0:
|
||||
self._libraries.extra_widget.emit("clicked")
|
||||
|
||||
def select_playlist(self, playlist: db.playlist.Playlist) -> None:
|
||||
"""Set the current active playlist."""
|
||||
def __select_playlist(self, playlist: db.playlist.Playlist) -> bool:
|
||||
if playlist is not None:
|
||||
match playlist.table:
|
||||
case self.sql.playlists:
|
||||
section = self._playlists
|
||||
case self.sql.artists | self.sql.albums | self.sql.media:
|
||||
section = self._artists
|
||||
case self.sql.genres:
|
||||
section = self._genres
|
||||
case self.sql.decades | self.sql.years:
|
||||
section = self._decades
|
||||
case self.sql.libraries:
|
||||
section = self._libraries
|
||||
|
||||
section.active = True
|
||||
section = self.table_section(playlist.table)
|
||||
if not section.active:
|
||||
section.active = True
|
||||
return GLib.SOURCE_CONTINUE
|
||||
section.select_playlist(playlist)
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
def select_playlist(self, playlist: db.playlist.Playlist,
|
||||
timeout: int = 0) -> None:
|
||||
"""Set the current active playlist."""
|
||||
GLib.timeout_add(timeout, self.__select_playlist, playlist)
|
||||
|
||||
def table_section(self, table: db.playlist.Table) -> section.Section:
|
||||
"""Get the Section associated with a specific Playlist Table."""
|
||||
match table:
|
||||
case self.sql.playlists:
|
||||
return self._playlists
|
||||
case self.sql.artists | self.sql.albums | self.sql.media:
|
||||
return self._artists
|
||||
case self.sql.genres:
|
||||
return self._genres
|
||||
case self.sql.decades | self.sql.years:
|
||||
return self._decades
|
||||
case self.sql.libraries:
|
||||
return self._libraries
|
||||
|
||||
@property
|
||||
def accelerators(self) -> list[ActionEntry]:
|
||||
|
|
|
@ -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,7 +39,7 @@ class Section(section.Section):
|
|||
|
||||
def __init__(self, table=db.libraries.Table):
|
||||
"""Initialize our library path section."""
|
||||
super().__init__(table, LibraryRow, icon_name="library-music",
|
||||
super().__init__(table, LibraryRow, icon_name="library-music-symbolic",
|
||||
title="Library Paths", subtitle="0 library paths")
|
||||
self.extra_widget = Gtk.Button(icon_name="folder-new", has_frame=False,
|
||||
tooltip_text="add new library path")
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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()
|
|
@ -5,6 +5,7 @@ from gi.repository import Gio
|
|||
from gi.repository import Gtk
|
||||
from . import sorter
|
||||
from .. import buttons
|
||||
from .. import gsetup
|
||||
|
||||
|
||||
class VisibleRow(Gtk.ListBoxRow):
|
||||
|
@ -107,7 +108,7 @@ class ShuffleButton(buttons.ImageToggle):
|
|||
"""Initialize a Shuffle Button."""
|
||||
super().__init__(active_icon_name="media-playlist-shuffle",
|
||||
active_tooltip_text="shuffle: enabled",
|
||||
inactive_icon_name="media-playlist-consecutive",
|
||||
inactive_icon_name=self.get_inactive_icon(),
|
||||
inactive_tooltip_text="shuffle: disabled",
|
||||
large_icon=False, icon_opacity=0.5,
|
||||
has_frame=False, **kwargs)
|
||||
|
@ -115,6 +116,13 @@ class ShuffleButton(buttons.ImageToggle):
|
|||
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 SortRow(Gtk.ListBoxRow):
|
||||
|
@ -180,7 +188,7 @@ class SortButton(buttons.PopoverButton):
|
|||
"""Initialize the Sort button."""
|
||||
super().__init__(has_frame=False, model=sorter.SortOrderModel(),
|
||||
tooltip_text="configure playlist sort order",
|
||||
icon_name="view-list-ordered-symbolic", **kwargs)
|
||||
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)
|
||||
|
|
|
@ -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):
|
||||
|
@ -278,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):
|
||||
|
@ -293,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:
|
||||
|
|
|
@ -81,13 +81,9 @@ class TrackView(Gtk.ScrolledWindow):
|
|||
|
||||
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())
|
||||
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:
|
||||
|
|
|
@ -4,6 +4,7 @@ from gi.repository import GObject
|
|||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
from .action import ActionEntry
|
||||
from . import layout
|
||||
|
||||
|
||||
def _make_pane(orientation: Gtk.Orientation, position: int = 0,
|
||||
|
@ -12,7 +13,7 @@ def _make_pane(orientation: Gtk.Orientation, position: int = 0,
|
|||
pane = Gtk.Paned(orientation=orientation, hexpand=True, vexpand=True,
|
||||
shrink_start_child=False, resize_start_child=False,
|
||||
start_child=start_child, end_child=end_child,
|
||||
position=position)
|
||||
position=position, margin_start=8)
|
||||
pane.add_css_class("emmental-pane")
|
||||
return pane
|
||||
|
||||
|
@ -29,7 +30,7 @@ class Window(Adw.Window):
|
|||
|
||||
header = GObject.Property(type=Gtk.Widget)
|
||||
sidebar = GObject.Property(type=Gtk.Widget)
|
||||
sidebar_size = GObject.Property(type=int, default=300)
|
||||
show_sidebar = GObject.Property(type=bool, default=False)
|
||||
now_playing = GObject.Property(type=Gtk.Widget)
|
||||
now_playing_size = GObject.Property(type=int, default=250)
|
||||
tracklist = GObject.Property(type=Gtk.Widget)
|
||||
|
@ -38,26 +39,25 @@ class Window(Adw.Window):
|
|||
def __init__(self, version: str, **kwargs):
|
||||
"""Initialize our Window."""
|
||||
super().__init__(icon_name="emmental", title=version,
|
||||
default_width=1600, default_height=900, **kwargs)
|
||||
default_width=1600, default_height=900,
|
||||
width_request=525, height_request=500, **kwargs)
|
||||
self._box = Gtk.Box.new(Gtk.Orientation.VERTICAL, 0)
|
||||
self._header = Adw.Bin(child=self.header)
|
||||
self._inner_pane = _make_pane(Gtk.Orientation.VERTICAL,
|
||||
position=self.now_playing_size,
|
||||
start_child=self.now_playing,
|
||||
end_child=self.tracklist)
|
||||
self._outer_pane = _make_pane(Gtk.Orientation.HORIZONTAL,
|
||||
position=self.sidebar_size,
|
||||
start_child=self.sidebar,
|
||||
end_child=self._inner_pane)
|
||||
self._toast = Adw.ToastOverlay(child=self._outer_pane)
|
||||
self._layout = layout.Layout(content=self._inner_pane,
|
||||
sidebar=self.sidebar)
|
||||
self._toast = Adw.ToastOverlay(child=self._layout)
|
||||
|
||||
self._outer_pane.add_css_class("emmental-padding")
|
||||
self._layout.add_css_class("emmental-padding")
|
||||
if __debug__:
|
||||
self.add_css_class("devel")
|
||||
|
||||
self.bind_property("header", self._header, "child")
|
||||
self.bind_property("sidebar", self._outer_pane, "start-child")
|
||||
self.bind_property("sidebar-size", self._outer_pane, "position",
|
||||
self.bind_property("sidebar", self._layout, "sidebar")
|
||||
self.bind_property("show-sidebar", self._layout, "show-sidebar",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.bind_property("now-playing", self._inner_pane, "start-child")
|
||||
self.bind_property("now-playing-size", self._inner_pane, "position",
|
||||
|
@ -66,6 +66,9 @@ class Window(Adw.Window):
|
|||
|
||||
self.connect("notify::focus-widget", self.__notify_focus_widget)
|
||||
|
||||
for breakpoint in self._layout.breakpoints:
|
||||
self.add_breakpoint(breakpoint)
|
||||
|
||||
self._box.append(self._header)
|
||||
self._box.append(self._toast)
|
||||
self.set_content(self._box)
|
||||
|
|
|
@ -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,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);
|
|
@ -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):
|
||||
|
|
|
@ -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()):
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
# Copyright 2022 (c) Anna Schumaker
|
||||
"""Tests our Mutagen wrapper."""
|
||||
import pathlib
|
||||
import threading
|
||||
import unittest.mock
|
||||
import emmental.db.tagger
|
||||
import emmental.thread
|
||||
import tests.util
|
||||
|
||||
|
||||
|
@ -276,8 +276,8 @@ class TestTaggerThread(tests.util.TestCase):
|
|||
|
||||
def test_init(self, mock_file: unittest.mock.Mock):
|
||||
"""Test that the tagger thread was initialized properly."""
|
||||
self.assertIsInstance(self.tagger, threading.Thread)
|
||||
self.assertIsInstance(self.tagger._condition, threading.Condition)
|
||||
self.assertIsInstance(self.tagger, emmental.thread.Thread)
|
||||
self.assertIsNone(self.tagger._connection)
|
||||
self.assertTrue(self.tagger.is_alive())
|
||||
|
||||
def test_stop(self, mock_file: unittest.mock.Mock):
|
||||
|
@ -285,74 +285,49 @@ class TestTaggerThread(tests.util.TestCase):
|
|||
mock_connection = unittest.mock.Mock()
|
||||
mock_connection.close = unittest.mock.Mock()
|
||||
|
||||
self.tagger._file = "abcde"
|
||||
self.tagger._mtime = 12345
|
||||
self.tagger._connection = mock_connection
|
||||
self.tagger.stop()
|
||||
|
||||
with unittest.mock.patch.object(self.tagger._condition, "notify",
|
||||
wraps=self.tagger._condition.notify) \
|
||||
as mock_notify:
|
||||
self.tagger.stop()
|
||||
self.assertIsNone(self.tagger._file)
|
||||
self.assertIsNone(self.tagger._mtime)
|
||||
mock_notify.assert_called()
|
||||
|
||||
self.assertFalse(self.tagger.is_alive())
|
||||
self.assertIsNone(self.tagger._connection)
|
||||
mock_connection.close.assert_called()
|
||||
|
||||
def test_tag_file(self, mock_file: unittest.mock.Mock):
|
||||
"""Test asking the thread to tag a file."""
|
||||
path = pathlib.Path("/a/b/c.ogg")
|
||||
|
||||
self.assertIsInstance(self.tagger.ready, threading.Event)
|
||||
self.assertIsNone(self.tagger._file)
|
||||
self.assertIsNone(self.tagger._tags)
|
||||
self.assertIsNone(self.tagger._mtime)
|
||||
self.assertTrue(self.tagger.ready.is_set())
|
||||
|
||||
mock_file.return_value = None
|
||||
|
||||
self.tagger.ready.set()
|
||||
self.tagger._tags = 12345
|
||||
self.tagger.tag_file(path, None)
|
||||
self.assertFalse(self.tagger.ready.is_set())
|
||||
self.assertEqual(self.tagger._file, path)
|
||||
self.assertIsNone(self.tagger._mtime)
|
||||
self.assertIsNone(self.tagger._tags)
|
||||
self.tagger.tag_file(path, mtime=None)
|
||||
self.assertEqual(self.tagger._task, {"path": path, "mtime": None})
|
||||
|
||||
self.tagger.ready.wait()
|
||||
self.assertIsNone(self.tagger._tags)
|
||||
mock_file.assert_called_with(pathlib.Path("/a/b/c.ogg"), None)
|
||||
|
||||
mock_file.return_value = self.make_tags(dict())
|
||||
self.tagger.tag_file(path, 12345)
|
||||
self.assertEqual(self.tagger._mtime, 12345)
|
||||
self.tagger.tag_file(path, mtime=12345)
|
||||
self.assertEqual(self.tagger._task, {"path": path, "mtime": 12345})
|
||||
|
||||
self.tagger.ready.wait()
|
||||
self.assertIsNotNone(self.tagger._tags)
|
||||
mock_file.assert_called_with(self.tagger._file, 12345)
|
||||
mock_file.assert_called_with(path, 12345)
|
||||
|
||||
def test_get_result(self, mock_file: unittest.mock.Mock):
|
||||
"""Test creating a Tags structure after tagging."""
|
||||
mock_file.return_value = None
|
||||
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), None)
|
||||
self.assertTupleEqual(self.tagger.get_result(self.sql, self.library),
|
||||
(None, None))
|
||||
self.assertIsNone(self.tagger.get_result(db=self.sql,
|
||||
library=self.library))
|
||||
|
||||
track_path = pathlib.Path("/a/b/c.ogg")
|
||||
self.tagger.tag_file(track_path, mtime=None)
|
||||
self.tagger.ready.wait()
|
||||
self.assertTupleEqual(self.tagger.get_result(self.sql, self.library),
|
||||
(pathlib.Path("/a/b/c.ogg"), None))
|
||||
self.assertIsNone(self.tagger._file)
|
||||
self.assertTupleEqual(self.tagger.get_result(db=self.sql,
|
||||
library=self.library),
|
||||
(track_path, None))
|
||||
|
||||
mock_file.return_value = self.make_tags(dict())
|
||||
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), None)
|
||||
self.tagger.tag_file(track_path, mtime=None)
|
||||
self.tagger.ready.wait()
|
||||
(file, tags) = self.tagger.get_result(self.sql, self.library)
|
||||
self.assertEqual(file, pathlib.Path("/a/b/c.ogg"))
|
||||
self.assertIsInstance(tags, emmental.db.tagger.Tags)
|
||||
self.assertIsNone(self.tagger._file)
|
||||
self.assertIsNone(self.tagger._mtime)
|
||||
self.assertIsNone(self.tagger._tags)
|
||||
res = self.tagger.get_result(db=self.sql, library=self.library)
|
||||
self.assertTupleEqual(res, (track_path, res[1]))
|
||||
|
||||
@unittest.mock.patch("emmental.db.connection.Connection.__call__")
|
||||
@unittest.mock.patch("musicbrainzngs.get_artist_by_id")
|
||||
|
@ -370,7 +345,7 @@ class TestTaggerThread(tests.util.TestCase):
|
|||
mock_cursor.fetchone = unittest.mock.Mock(return_value=None)
|
||||
mock_connection.return_value = mock_cursor
|
||||
|
||||
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), None)
|
||||
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), mtime=None)
|
||||
self.tagger.ready.wait()
|
||||
self.assertEqual(audio_tags.artists[0].name, "Some Artist")
|
||||
self.assertEqual(audio_tags.artists[1].name, "Some Artist")
|
||||
|
@ -394,7 +369,7 @@ class TestTaggerThread(tests.util.TestCase):
|
|||
|
||||
self.assertIsNone(self.tagger._connection)
|
||||
|
||||
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), None)
|
||||
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"), mtime=None)
|
||||
self.tagger.ready.wait()
|
||||
self.assertIsInstance(self.tagger._connection,
|
||||
emmental.db.connection.Connection)
|
||||
|
|
|
@ -247,10 +247,14 @@ class TestTrackTable(tests.util.TestCase):
|
|||
|
||||
def test_create_restore(self):
|
||||
"""Test restoring saved track data."""
|
||||
now = datetime.datetime.now()
|
||||
now = datetime.datetime.utcnow()
|
||||
today = now.date()
|
||||
yesterday = today - datetime.timedelta(days=1)
|
||||
self.sql("""INSERT INTO saved_track_data
|
||||
(mbid, favorite, playcount, lastplayed, laststarted)
|
||||
VALUES (?, ?, ?, ? , ?)""", "ab-cd-ef", True, 42, now, now)
|
||||
(mbid, favorite, playcount,
|
||||
lastplayed, laststarted, added)
|
||||
VALUES (?, ?, ?, ? , ?, ?)""",
|
||||
"ab-cd-ef", True, 42, now, now, yesterday)
|
||||
|
||||
track1 = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
||||
self.medium, self.year)
|
||||
|
@ -258,6 +262,7 @@ class TestTrackTable(tests.util.TestCase):
|
|||
self.assertEqual(track1.playcount, 0)
|
||||
self.assertIsNone(track1.lastplayed)
|
||||
self.assertIsNone(track1.laststarted)
|
||||
self.assertEqual(track1.added, today)
|
||||
|
||||
row = self.sql("SELECT COUNT(*) FROM saved_track_data").fetchone()
|
||||
self.assertEqual(row["COUNT(*)"], 1)
|
||||
|
@ -268,6 +273,7 @@ class TestTrackTable(tests.util.TestCase):
|
|||
self.assertEqual(track2.playcount, 42)
|
||||
self.assertEqual(track2.lastplayed, now)
|
||||
self.assertEqual(track2.laststarted, now)
|
||||
self.assertEqual(track2.added, yesterday)
|
||||
|
||||
row = self.sql("SELECT COUNT(*) FROM saved_track_data").fetchone()
|
||||
self.assertEqual(row["COUNT(*)"], 0)
|
||||
|
@ -286,6 +292,20 @@ class TestTrackTable(tests.util.TestCase):
|
|||
|
||||
self.assertFalse(track.delete())
|
||||
|
||||
def test_delete_listens(self):
|
||||
"""Test deleting listens from the listenbrainz_queue."""
|
||||
track1 = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
||||
self.medium, self.year, length=10)
|
||||
track2 = self.tracks.create(self.library, pathlib.Path("/a/b/2.ogg"),
|
||||
self.medium, self.year, length=10)
|
||||
|
||||
for track in [track1, track2]:
|
||||
track.start()
|
||||
track.stop(9)
|
||||
|
||||
self.tracks.delete_listens([1, 2])
|
||||
self.assertListEqual(self.tracks.get_n_listens(5), [])
|
||||
|
||||
def test_delete_save(self):
|
||||
"""Test saving track data when a track is deleted."""
|
||||
now = datetime.datetime.now()
|
||||
|
@ -308,6 +328,7 @@ class TestTrackTable(tests.util.TestCase):
|
|||
self.assertEqual(rows[0]["laststarted"], now)
|
||||
self.assertEqual(rows[0]["lastplayed"], now)
|
||||
self.assertEqual(rows[0]["playcount"], 42)
|
||||
self.assertEqual(rows[0]["added"], datetime.datetime.utcnow().date())
|
||||
|
||||
def test_filter(self):
|
||||
"""Test filtering the Track table."""
|
||||
|
@ -478,6 +499,40 @@ class TestTrackTable(tests.util.TestCase):
|
|||
self.assertListEqual(self.tracks.get_genres(track),
|
||||
[genre1, genre2])
|
||||
|
||||
def test_get_n_listens(self):
|
||||
"""Test the get_n_listens() function."""
|
||||
track1 = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
||||
self.medium, self.year, length=10)
|
||||
track2 = self.tracks.create(self.library, pathlib.Path("/a/b/2.ogg"),
|
||||
self.medium, self.year, length=12)
|
||||
|
||||
self.assertListEqual(self.tracks.get_n_listens(2), [])
|
||||
|
||||
track1.start()
|
||||
track1.stop(8)
|
||||
ts1 = track1.lastplayed
|
||||
self.assertListEqual(self.tracks.get_n_listens(2),
|
||||
[(1, track1, ts1)])
|
||||
|
||||
track2.start()
|
||||
track2.stop(11)
|
||||
ts2 = track2.lastplayed
|
||||
self.assertListEqual(self.tracks.get_n_listens(2),
|
||||
[(2, track2, ts2),
|
||||
(1, track1, ts1)])
|
||||
|
||||
track1.start()
|
||||
track1.stop(9)
|
||||
ts3 = track1.lastplayed
|
||||
self.assertListEqual(self.tracks.get_n_listens(2),
|
||||
[(3, track1, ts3),
|
||||
(2, track2, ts2)])
|
||||
|
||||
self.assertListEqual(self.tracks.get_n_listens(4),
|
||||
[(3, track1, ts3),
|
||||
(2, track2, ts2),
|
||||
(1, track1, ts1)])
|
||||
|
||||
def test_mark_path_active(self):
|
||||
"""Test marking a path as active."""
|
||||
self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
||||
|
@ -527,6 +582,8 @@ class TestTrackTable(tests.util.TestCase):
|
|||
"""Test marking that a Track has stopped playback."""
|
||||
track = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
||||
self.medium, self.year, length=10)
|
||||
track_played = unittest.mock.Mock()
|
||||
self.tracks.connect("track-played", track_played)
|
||||
|
||||
track.start()
|
||||
with unittest.mock.patch.object(self.sql, "commit",
|
||||
|
@ -542,9 +599,13 @@ class TestTrackTable(tests.util.TestCase):
|
|||
self.assertIsNone(track.lastplayed)
|
||||
self.assertIsNone(self.tracks.current_track)
|
||||
|
||||
cur = self.sql("SELECT trackid, timestamp FROM listenbrainz_queue")
|
||||
self.assertListEqual(cur.fetchall(), [])
|
||||
|
||||
self.playlists.most_played.reload_tracks.assert_not_called()
|
||||
self.playlists.queued.remove_track.assert_not_called()
|
||||
self.playlists.unplayed.remove_track.assert_not_called()
|
||||
track_played.assert_not_called()
|
||||
|
||||
track.start()
|
||||
with unittest.mock.patch.object(self.sql, "commit",
|
||||
|
@ -559,14 +620,22 @@ class TestTrackTable(tests.util.TestCase):
|
|||
self.assertEqual(row["lastplayed"], track.laststarted)
|
||||
self.assertEqual(track.lastplayed, track.laststarted)
|
||||
|
||||
cur = self.sql("SELECT trackid, timestamp FROM listenbrainz_queue")
|
||||
row = cur.fetchall()[0]
|
||||
self.assertEqual(row["trackid"], track.trackid)
|
||||
self.assertEqual(row["timestamp"], track.lastplayed)
|
||||
|
||||
self.playlists.most_played.reload_tracks.assert_called()
|
||||
self.playlists.queued.remove_track.assert_called_with(track)
|
||||
self.playlists.unplayed.remove_track.assert_called_with(track)
|
||||
track_played.assert_called_with(self.tracks, track)
|
||||
|
||||
def test_stop_restarted_track(self):
|
||||
"""Test marking that a restarted Track has stopped playback."""
|
||||
track = self.tracks.create(self.library, pathlib.Path("/a/b/1.ogg"),
|
||||
self.medium, self.year, length=10)
|
||||
track_played = unittest.mock.Mock()
|
||||
self.tracks.connect("track-played", track_played)
|
||||
|
||||
track.restart()
|
||||
track.stop(3)
|
||||
|
@ -581,9 +650,13 @@ class TestTrackTable(tests.util.TestCase):
|
|||
self.assertIsNone(track.restarted)
|
||||
self.assertIsNone(self.tracks.current_track)
|
||||
|
||||
cur = self.sql("SELECT trackid, timestamp FROM listenbrainz_queue")
|
||||
self.assertListEqual(cur.fetchall(), [])
|
||||
|
||||
self.playlists.most_played.reload_tracks.assert_not_called()
|
||||
self.playlists.queued.remove_track.assert_not_called()
|
||||
self.playlists.unplayed.remove_track.assert_not_called()
|
||||
track_played.assert_not_called()
|
||||
|
||||
track.restart()
|
||||
restarted = track.restarted
|
||||
|
@ -597,9 +670,15 @@ class TestTrackTable(tests.util.TestCase):
|
|||
self.assertEqual(row["laststarted"], restarted)
|
||||
self.assertEqual(track.laststarted, restarted)
|
||||
|
||||
cur = self.sql("SELECT trackid, timestamp FROM listenbrainz_queue")
|
||||
row = cur.fetchall()[0]
|
||||
self.assertEqual(row["trackid"], track.trackid)
|
||||
self.assertEqual(row["timestamp"], track.lastplayed)
|
||||
|
||||
self.playlists.most_played.reload_tracks.assert_called_with(idle=True)
|
||||
self.playlists.queued.remove_track.assert_called_with(track)
|
||||
self.playlists.unplayed.remove_track.assert_called_with(track)
|
||||
track_played.assert_called_with(self.tracks, track)
|
||||
|
||||
def test_current_track(self):
|
||||
"""Test the current-track and have-current-track properties."""
|
||||
|
|
|
@ -36,31 +36,103 @@ class TestHeader(tests.util.TestCase):
|
|||
self.assertEqual(self.header._title.get_tooltip_text(),
|
||||
emmental.gsetup.env_string())
|
||||
|
||||
def test_show_sidebar(self):
|
||||
"""Check that the show sidebar button works as expected."""
|
||||
self.assertIsInstance(self.header._show_sidebar, Gtk.ToggleButton)
|
||||
self.assertEqual(self.header._show_sidebar.props.icon_name,
|
||||
"sidebar-show-symbolic")
|
||||
self.assertFalse(self.header._show_sidebar.props.has_frame)
|
||||
self.assertFalse(self.header._show_sidebar.props.active)
|
||||
|
||||
self.assertFalse(self.header.show_sidebar)
|
||||
self.header.show_sidebar = True
|
||||
self.assertTrue(self.header._show_sidebar.props.active)
|
||||
|
||||
self.header._show_sidebar.props.active = False
|
||||
self.assertFalse(self.header.show_sidebar)
|
||||
|
||||
def test_open(self):
|
||||
"""Check that the Open button works as expected."""
|
||||
self.assertIsInstance(self.header._open, emmental.header.open.Button)
|
||||
"""Check that the Open ActionRow works as expected."""
|
||||
self.assertIsInstance(self.header._open, emmental.header.open.OpenRow)
|
||||
self.assertEqual(self.header._menu_box.get_row_at_index(0),
|
||||
self.header._open)
|
||||
|
||||
signal = unittest.mock.Mock()
|
||||
self.header.connect("track-requested", signal)
|
||||
self.header._open.emit("track-requested", pathlib.Path("/a/b/c/1.ogg"))
|
||||
signal.assert_called_with(self.header, pathlib.Path("/a/b/c/1.ogg"))
|
||||
|
||||
def test_listenbrainz(self):
|
||||
"""Check that the ListenBrainzRow is set up correctly."""
|
||||
self.assertIsInstance(self.header._listenbrainz, Adw.PasswordEntryRow)
|
||||
self.assertEqual(self.header._menu_box.get_row_at_index(1),
|
||||
self.header._listenbrainz)
|
||||
|
||||
self.assertEqual(self.header.listenbrainz_token, "")
|
||||
self.assertEqual(self.header._listenbrainz.props.text, "")
|
||||
|
||||
self.header.listenbrainz_token = "abcde"
|
||||
self.assertEqual(self.header._listenbrainz.props.text, "abcde")
|
||||
|
||||
with unittest.mock.patch.object(self.header._menu_button,
|
||||
"popdown") as mock_popdown:
|
||||
self.header._listenbrainz.props.text = "fghij"
|
||||
self.header._listenbrainz.emit("apply")
|
||||
self.assertEqual(self.header.listenbrainz_token, "fghij")
|
||||
mock_popdown.assert_called()
|
||||
|
||||
self.header._listenbrainz.props.text = "abcde"
|
||||
self.header._menu_button.get_popover().emit("closed")
|
||||
self.assertEqual(self.header._listenbrainz.props.text, "fghij")
|
||||
|
||||
def test_listenbrainz_token_valid(self):
|
||||
"""Test the listenbrainz-token-valid property."""
|
||||
win = Gtk.Window(titlebar=self.header)
|
||||
win.post_toast = unittest.mock.Mock()
|
||||
|
||||
self.assertTrue(self.header.listenbrainz_token_valid)
|
||||
|
||||
self.header.listenbrainz_token_valid = False
|
||||
self.assertTrue(self.header._menu_button.has_css_class("warning"))
|
||||
self.assertTrue(self.header._listenbrainz.has_css_class("warning"))
|
||||
self.assertFalse(self.header.listenbrainz_token_valid)
|
||||
win.post_toast.assert_called_with(
|
||||
"listenbrainz: user token is invalid")
|
||||
|
||||
win.post_toast.reset_mock()
|
||||
self.header.listenbrainz_token_valid = True
|
||||
self.assertFalse(self.header._menu_button.has_css_class("warning"))
|
||||
self.assertFalse(self.header._listenbrainz.has_css_class("warning"))
|
||||
self.assertTrue(self.header.listenbrainz_token_valid)
|
||||
win.post_toast.assert_not_called()
|
||||
|
||||
def test_settings(self):
|
||||
"""Check that the Settings window is set up correctly."""
|
||||
self.assertIsInstance(self.header._settings, Gtk.Button)
|
||||
self.assertIsInstance(self.header._window,
|
||||
emmental.header.settings.Window)
|
||||
"""Check that the SettingsRow is set up correctly."""
|
||||
self.assertIsInstance(self.header._settings,
|
||||
emmental.header.settings.Row)
|
||||
self.assertEqual(self.header._menu_box.get_row_at_index(2),
|
||||
self.header._settings)
|
||||
|
||||
self.assertEqual(self.header.sql, self.sql)
|
||||
self.assertEqual(self.header._settings.get_icon_name(),
|
||||
"settings-symbolic")
|
||||
self.assertEqual(self.header._settings.get_tooltip_text(),
|
||||
"open settings editor")
|
||||
def test_menu_button(self):
|
||||
"""Check that the menu popover button is set up properly."""
|
||||
self.assertIsInstance(self.header._menu_button,
|
||||
emmental.buttons.PopoverButton)
|
||||
self.assertIsNotNone(self.header._menu_button.props.parent)
|
||||
|
||||
with unittest.mock.patch.object(self.header._window,
|
||||
"present") as mock_present:
|
||||
self.header._settings.emit("clicked")
|
||||
mock_present.assert_called()
|
||||
self.assertEqual(self.header._menu_button.props.icon_name,
|
||||
"open-menu-symbolic")
|
||||
self.assertEqual(self.header._menu_button.popover_child,
|
||||
self.header._menu_box)
|
||||
|
||||
def test_menu_popover_child(self):
|
||||
"""Check that the menu popover button child was set up correctly."""
|
||||
self.assertIsInstance(self.header._menu_box, Gtk.ListBox)
|
||||
self.assertEqual(self.header._menu_box.get_selection_mode(),
|
||||
Gtk.SelectionMode.NONE)
|
||||
self.assertTrue(self.header._menu_box.has_css_class("boxed-list"))
|
||||
|
||||
self.assertEqual(self.header._menu_box.get_row_at_index(0),
|
||||
self.header._open)
|
||||
|
||||
def test_volume_icons(self):
|
||||
"""Check that the volume icons box is set up properly."""
|
||||
|
@ -95,7 +167,7 @@ class TestHeader(tests.util.TestCase):
|
|||
self.assertEqual(self.header._volume.volume, vol)
|
||||
self.assertEqual(self.header._volume_icon.get_icon_name(),
|
||||
f"audio-volume-{icon}-symbolic")
|
||||
self.assertEqual(self.header._button.get_tooltip_text(),
|
||||
self.assertEqual(self.header._vol_button.get_tooltip_text(),
|
||||
f"volume: {i*10}%\n"
|
||||
"background listening: off\nnormalizing: off")
|
||||
|
||||
|
@ -113,19 +185,19 @@ class TestHeader(tests.util.TestCase):
|
|||
self.assertTrue(self.header._background.enabled)
|
||||
self.assertEqual(self.header._background_icon.get_icon_name(),
|
||||
"sound-wave-alt")
|
||||
self.assertEqual(self.header._button.get_tooltip_text(),
|
||||
self.assertEqual(self.header._vol_button.get_tooltip_text(),
|
||||
"volume: 100%\nbackground listening: 50%\n"
|
||||
"normalizing: off")
|
||||
|
||||
self.header.bg_volume = 0.75
|
||||
self.assertEqual(self.header._background.volume, 0.75)
|
||||
self.assertEqual(self.header._button.get_tooltip_text(),
|
||||
self.assertEqual(self.header._vol_button.get_tooltip_text(),
|
||||
"volume: 100%\nbackground listening: 75%\n"
|
||||
"normalizing: off")
|
||||
|
||||
self.header._background.volume = 0.25
|
||||
self.assertEqual(self.header.bg_volume, 0.25)
|
||||
self.assertEqual(self.header._button.get_tooltip_text(),
|
||||
self.assertEqual(self.header._vol_button.get_tooltip_text(),
|
||||
"volume: 100%\nbackground listening: 25%\n"
|
||||
"normalizing: off")
|
||||
|
||||
|
@ -145,7 +217,7 @@ class TestHeader(tests.util.TestCase):
|
|||
self.header.rg_mode = "track"
|
||||
self.assertTrue(self.header._replaygain.enabled)
|
||||
self.assertEqual(self.header._replaygain.mode, "track")
|
||||
self.assertEqual(self.header._button.get_tooltip_text(),
|
||||
self.assertEqual(self.header._vol_button.get_tooltip_text(),
|
||||
"volume: 100%\nbackground listening: off\n"
|
||||
"normalizing: track mode")
|
||||
|
||||
|
@ -153,32 +225,34 @@ class TestHeader(tests.util.TestCase):
|
|||
self.header._replaygain.mode = "album"
|
||||
self.assertFalse(self.header.rg_enabled)
|
||||
self.assertEqual(self.header.rg_mode, "album")
|
||||
self.assertEqual(self.header._button.get_tooltip_text(),
|
||||
self.assertEqual(self.header._vol_button.get_tooltip_text(),
|
||||
"volume: 100%\nbackground listening: off\n"
|
||||
"normalizing: off")
|
||||
|
||||
def test_popover_button(self):
|
||||
"""Check that the menu popover button was set up correctly."""
|
||||
self.assertIsInstance(self.header._button,
|
||||
def test_volume_popover_button(self):
|
||||
"""Check that the volume popover button was set up correctly."""
|
||||
self.assertIsInstance(self.header._vol_button,
|
||||
emmental.buttons.PopoverButton)
|
||||
self.assertEqual(self.header._button.popover_child, self.header._box)
|
||||
self.assertEqual(self.header._vol_button.popover_child,
|
||||
self.header._vol_box)
|
||||
|
||||
self.assertEqual(self.header._button.get_child(), self.header._icons)
|
||||
self.assertEqual(self.header._button.get_margin_end(), 6)
|
||||
self.assertFalse(self.header._button.get_has_frame())
|
||||
self.assertEqual(self.header._vol_button.get_child(),
|
||||
self.header._icons)
|
||||
self.assertEqual(self.header._vol_button.get_margin_end(), 6)
|
||||
self.assertFalse(self.header._vol_button.get_has_frame())
|
||||
|
||||
def test_popover_child(self):
|
||||
"""Check that the menu popover button child was set up correctly."""
|
||||
self.assertIsInstance(self.header._box, Gtk.ListBox)
|
||||
self.assertEqual(self.header._box.get_selection_mode(),
|
||||
def test_volume_popover_child(self):
|
||||
"""Check that the volume popover button child was set up correctly."""
|
||||
self.assertIsInstance(self.header._vol_box, Gtk.ListBox)
|
||||
self.assertEqual(self.header._vol_box.get_selection_mode(),
|
||||
Gtk.SelectionMode.NONE)
|
||||
self.assertTrue(self.header._box.has_css_class("boxed-list"))
|
||||
self.assertTrue(self.header._vol_box.has_css_class("boxed-list"))
|
||||
|
||||
self.assertEqual(self.header._box.get_row_at_index(0),
|
||||
self.assertEqual(self.header._vol_box.get_row_at_index(0),
|
||||
self.header._volume)
|
||||
self.assertEqual(self.header._box.get_row_at_index(1),
|
||||
self.assertEqual(self.header._vol_box.get_row_at_index(1),
|
||||
self.header._background)
|
||||
self.assertEqual(self.header._box.get_row_at_index(2),
|
||||
self.assertEqual(self.header._vol_box.get_row_at_index(2),
|
||||
self.header._replaygain)
|
||||
|
||||
def test_accelerators(self):
|
||||
|
@ -190,6 +264,8 @@ class TestHeader(tests.util.TestCase):
|
|||
"<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")]
|
||||
|
||||
|
|
|
@ -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,58 +1,69 @@
|
|||
# Copyright 2023 (c) Anna Schumaker.
|
||||
"""Tests our Open button."""
|
||||
"""Tests our Open Adw.ActionRow."""
|
||||
import emmental.header.open
|
||||
import pathlib
|
||||
import unittest
|
||||
from gi.repository import Gio
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
|
||||
|
||||
class TestButton(unittest.TestCase):
|
||||
"""Test the Open button."""
|
||||
class TestOpenRow(unittest.TestCase):
|
||||
"""Test the Open row."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
self.button = emmental.header.open.Button()
|
||||
self.row = emmental.header.open.OpenRow()
|
||||
|
||||
def test_button(self):
|
||||
"""Check that the button was set up properly."""
|
||||
self.assertIsInstance(self.button, Gtk.Button)
|
||||
self.assertEqual(self.button.get_icon_name(), "document-open-symbolic")
|
||||
self.assertEqual(self.button.get_tooltip_text(),
|
||||
"open a file for playback")
|
||||
def test_action_row(self):
|
||||
"""Check that the action row was set up properly."""
|
||||
self.assertIsInstance(self.row, Adw.ActionRow)
|
||||
self.assertIsInstance(self.row._prefix, Gtk.Image)
|
||||
|
||||
self.assertEqual(self.row.props.title, "Open File")
|
||||
self.assertEqual(self.row.props.subtitle, "Select a file for playback")
|
||||
self.assertTrue(self.row.props.activatable)
|
||||
|
||||
self.assertEqual(self.row._prefix.props.icon_name,
|
||||
"document-open-symbolic")
|
||||
|
||||
def test_filter(self):
|
||||
"""Check that the file filter is set up properly."""
|
||||
self.assertIsInstance(self.button._filter, Gtk.FileFilter)
|
||||
self.assertIsInstance(self.button._filters, Gio.ListStore)
|
||||
self.assertIsInstance(self.row._filter, Gtk.FileFilter)
|
||||
self.assertIsInstance(self.row._filters, Gio.ListStore)
|
||||
|
||||
self.assertEqual(self.button._filter.get_name(), "Audio Files")
|
||||
self.assertEqual(self.button._filters[0], self.button._filter)
|
||||
self.assertEqual(self.row._filter.get_name(), "Audio Files")
|
||||
self.assertEqual(self.row._filters[0], self.row._filter)
|
||||
|
||||
def test_dialog(self):
|
||||
"""Check that the file dialog is set up properly."""
|
||||
self.assertIsInstance(self.button._dialog, Gtk.FileDialog)
|
||||
self.assertEqual(self.button._dialog.get_title(), "Pick a Track")
|
||||
self.assertEqual(self.button._dialog.get_filters(),
|
||||
self.button._filters)
|
||||
self.assertTrue(self.button._dialog.get_modal())
|
||||
self.assertIsInstance(self.row._dialog, Gtk.FileDialog)
|
||||
self.assertEqual(self.row._dialog.get_title(), "Pick a Track")
|
||||
self.assertEqual(self.row._dialog.get_filters(),
|
||||
self.row._filters)
|
||||
self.assertTrue(self.row._dialog.get_modal())
|
||||
|
||||
def test_clicked(self):
|
||||
"""Test clicking on the button."""
|
||||
with unittest.mock.patch.object(self.button._dialog,
|
||||
"open") as mock_open:
|
||||
self.button.emit("clicked")
|
||||
mock_open.assert_called_with(None, None,
|
||||
self.button._Button__async_ready)
|
||||
def test_activate(self):
|
||||
"""Test activating an OpenRow."""
|
||||
listbox = Gtk.ListBox()
|
||||
popover = Gtk.Popover(child=listbox)
|
||||
listbox.append(self.row)
|
||||
|
||||
with unittest.mock.patch.object(self.button._dialog,
|
||||
with unittest.mock.patch.object(popover, "popdown") as mock_popdown:
|
||||
with unittest.mock.patch.object(self.row._dialog,
|
||||
"open") as mock_open:
|
||||
self.row.emit("activated")
|
||||
mock_popdown.assert_called()
|
||||
mock_open.assert_called_with(None, None,
|
||||
self.row._OpenRow__async_ready)
|
||||
|
||||
with unittest.mock.patch.object(self.row._dialog,
|
||||
"open_finish") as mock_finish:
|
||||
task = Gio.Task()
|
||||
signal = unittest.mock.Mock()
|
||||
mock_finish.return_value = Gio.File.new_for_path("/a/b/c/1.ogg")
|
||||
self.button.connect("track-requested", signal)
|
||||
self.row.connect("track-requested", signal)
|
||||
|
||||
self.button._Button__async_ready(self.button._dialog, task)
|
||||
self.row._OpenRow__async_ready(self.row._dialog, task)
|
||||
mock_finish.assert_called_with(task)
|
||||
signal.assert_called_with(self.button,
|
||||
pathlib.Path("/a/b/c/1.ogg"))
|
||||
signal.assert_called_with(self.row, pathlib.Path("/a/b/c/1.ogg"))
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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")
|
|
@ -115,7 +115,7 @@ class TestNowPlaying(unittest.TestCase):
|
|||
self.assertEqual(self.card._favorite.get_next_sibling(),
|
||||
self.card._jump)
|
||||
|
||||
self.assertEqual(self.card._jump.icon_name, "go-jump")
|
||||
self.assertEqual(self.card._jump.icon_name, "arrow4-down-symbolic")
|
||||
self.assertEqual(self.card._jump.get_tooltip_text(),
|
||||
"scroll to current track")
|
||||
self.assertEqual(self.card._jump.get_valign(), Gtk.Align.CENTER)
|
||||
|
|
|
@ -69,18 +69,14 @@ class TestIcon(unittest.TestCase):
|
|||
"""Test the filepath property."""
|
||||
self.assertIsNone(self.icon.filepath)
|
||||
|
||||
with unittest.mock.patch("gi.repository.Gdk.Texture.new_from_filename",
|
||||
wraps=Gdk.Texture.new_from_filename) \
|
||||
as mock_new:
|
||||
self.icon.filepath = tests.util.COVER_JPG
|
||||
mock_new.assert_called_with(str(tests.util.COVER_JPG))
|
||||
self.assertIsInstance(self.icon._icon.get_custom_image(),
|
||||
Gdk.Texture)
|
||||
self.icon.filepath = tests.util.COVER_JPG
|
||||
texture = self.icon._icon.get_custom_image()
|
||||
self.assertIsInstance(texture, Gdk.Texture)
|
||||
self.assertDictEqual(emmental.texture.CACHE,
|
||||
{tests.util.COVER_JPG: texture})
|
||||
|
||||
mock_new.reset_mock()
|
||||
self.icon.filepath = None
|
||||
self.assertIsNone(self.icon._icon.get_custom_image())
|
||||
mock_new.assert_not_called()
|
||||
self.icon.filepath = None
|
||||
self.assertIsNone(self.icon._icon.get_custom_image())
|
||||
|
||||
|
||||
class TestSettable(unittest.TestCase):
|
||||
|
@ -123,11 +119,15 @@ class TestSettable(unittest.TestCase):
|
|||
task = Gio.Task()
|
||||
cover_path = str(tests.util.COVER_JPG)
|
||||
mock_finish.return_value = Gio.File.new_for_path(cover_path)
|
||||
emmental.texture.CACHE[tests.util.COVER_JPG] = "abcde"
|
||||
|
||||
self.icon._Settable__async_ready(self.icon._dialog, task)
|
||||
mock_finish.assert_called_with(task)
|
||||
self.assertEqual(self.icon.filepath, tests.util.COVER_JPG)
|
||||
|
||||
texture = emmental.texture.CACHE[tests.util.COVER_JPG]
|
||||
self.assertIsInstance(texture, Gdk.Texture)
|
||||
|
||||
def test_clearing(self):
|
||||
"""Test clearing the icon by canceling the FileDialog."""
|
||||
mock_set_initial_file = unittest.mock.Mock()
|
||||
|
|
|
@ -25,7 +25,7 @@ class TestLibraries(tests.util.TestCase):
|
|||
emmental.sidebar.library.LibraryRow)
|
||||
|
||||
self.assertEqual(self.libraries.table, self.sql.libraries)
|
||||
self.assertEqual(self.libraries.icon_name, "library-music")
|
||||
self.assertEqual(self.libraries.icon_name, "library-music-symbolic")
|
||||
self.assertEqual(self.libraries.title, "Library Paths")
|
||||
|
||||
def test_extra_widget(self):
|
||||
|
|
|
@ -4,7 +4,6 @@ import emmental.db
|
|||
import emmental.sidebar.section
|
||||
import tests.util
|
||||
import unittest.mock
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Gtk
|
||||
|
||||
|
||||
|
@ -105,18 +104,12 @@ class TestSection(tests.util.TestCase):
|
|||
def test_select_playlist(self):
|
||||
"""Test selecting a specific playlist."""
|
||||
self.section.do_get_subtitle = unittest.mock.Mock(return_value="")
|
||||
|
||||
playlist_selected = unittest.mock.Mock()
|
||||
self.section.connect("playlist-selected", playlist_selected)
|
||||
playlist = self.table.create("Test Playlist")
|
||||
playlist_selected.assert_not_called()
|
||||
|
||||
with unittest.mock.patch.object(self.section._listview,
|
||||
"activate_action") as mock_action:
|
||||
"scroll_to") as mock_scroll_to:
|
||||
self.section.select_playlist(playlist)
|
||||
playlist_selected.assert_called_with(self.section, playlist)
|
||||
mock_action.assert_called_with("list.scroll-to-item",
|
||||
GLib.Variant.new_uint32(0))
|
||||
mock_scroll_to.assert_called_with(0, Gtk.ListScrollFlags.SELECT)
|
||||
|
||||
def test_playlist_selected(self):
|
||||
"""Test selecting a playlist in the list."""
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import emmental.sidebar
|
||||
import tests.util
|
||||
import unittest.mock
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Gtk
|
||||
|
||||
|
||||
|
@ -53,7 +54,7 @@ class TestSidebar(tests.util.TestCase):
|
|||
self.sidebar._jump)
|
||||
|
||||
self.assertEqual(self.sidebar._jump.get_icon_name(),
|
||||
"go-jump-symbolic")
|
||||
"arrow4-down-symbolic")
|
||||
self.assertEqual(self.sidebar._jump.get_tooltip_text(),
|
||||
"scroll to current playlist")
|
||||
|
||||
|
@ -65,22 +66,20 @@ class TestSidebar(tests.util.TestCase):
|
|||
|
||||
def test_sensitivity_and_startup(self):
|
||||
"""Test setting the sidebar sensitivity when all tables have loaded."""
|
||||
tables = [t for t in self.sql.playlist_tables()]
|
||||
self.sidebar.select_playlist = unittest.mock.Mock()
|
||||
self.sidebar._libraries.extra_widget.emit = unittest.mock.Mock()
|
||||
|
||||
for table in tables:
|
||||
self.assertFalse(self.sidebar.get_sensitive())
|
||||
self.sidebar.select_playlist.assert_not_called()
|
||||
self.sidebar._libraries.extra_widget.emit.assert_not_called()
|
||||
self.sql.emit("table-loaded", table)
|
||||
|
||||
self.assertFalse(self.sidebar.get_sensitive())
|
||||
self.sql.loaded = True
|
||||
self.assertTrue(self.sidebar.get_sensitive())
|
||||
self.sidebar.select_playlist.assert_called()
|
||||
|
||||
playlist = self.sql.playlists.collection
|
||||
self.sidebar.select_playlist.assert_called_with(playlist, 150)
|
||||
self.sidebar._libraries.extra_widget.emit.assert_called_with("clicked")
|
||||
|
||||
self.sidebar.select_playlist.reset_mock()
|
||||
self.sql.emit("table-loaded", tables[0])
|
||||
self.sql.loaded = False
|
||||
self.assertFalse(self.sidebar.get_sensitive())
|
||||
self.sidebar.select_playlist.assert_not_called()
|
||||
|
||||
def test_show_all_artists(self):
|
||||
|
@ -147,45 +146,48 @@ class TestSidebar(tests.util.TestCase):
|
|||
|
||||
def test_select_playlist(self):
|
||||
"""Test setting the active playlist."""
|
||||
self.assertEqual(self.sidebar._Card__select_playlist(None),
|
||||
GLib.SOURCE_REMOVE)
|
||||
|
||||
playlist = self.sql.playlists.create("Test Playlist")
|
||||
self.sidebar.select_playlist(playlist)
|
||||
self.assertTrue(self.sidebar._playlists.active)
|
||||
self.assertEqual(self.sidebar.selected_playlist, playlist)
|
||||
with unittest.mock.patch.object(self.sidebar._playlists,
|
||||
"select_playlist") as mock_select:
|
||||
self.assertEqual(self.sidebar._Card__select_playlist(playlist),
|
||||
GLib.SOURCE_CONTINUE)
|
||||
self.assertTrue(self.sidebar._playlists.active)
|
||||
mock_select.assert_not_called()
|
||||
|
||||
artist = self.sql.artists.create("Test Artist")
|
||||
album = self.sql.albums.create("Test Album", "Test Artist", "2023")
|
||||
medium = self.sql.media.create(album, "Test Medium", number=1)
|
||||
self.assertEqual(self.sidebar._Card__select_playlist(playlist),
|
||||
GLib.SOURCE_REMOVE)
|
||||
mock_select.assert_called_with(playlist)
|
||||
|
||||
self.sidebar._artists.select_playlist = unittest.mock.Mock()
|
||||
for plist in [artist, album, medium]:
|
||||
self.sidebar._artists.select_playlist.reset_mock()
|
||||
self.sidebar._artists.active = False
|
||||
with unittest.mock.patch.object(GLib, "timeout_add") as mock_to:
|
||||
self.sidebar.select_playlist(playlist)
|
||||
mock_to.assert_called_with(0, self.sidebar._Card__select_playlist,
|
||||
playlist)
|
||||
self.sidebar.select_playlist(playlist, 42)
|
||||
mock_to.assert_called_with(42, self.sidebar._Card__select_playlist,
|
||||
playlist)
|
||||
|
||||
self.sidebar.select_playlist(plist)
|
||||
self.assertTrue(self.sidebar._artists.active)
|
||||
self.sidebar._artists.select_playlist.assert_called_with(plist)
|
||||
|
||||
genre = self.sql.genres.create("Test Genre")
|
||||
self.sidebar.select_playlist(genre)
|
||||
self.assertTrue(self.sidebar._genres.active)
|
||||
self.assertEqual(self.sidebar.selected_playlist, genre)
|
||||
|
||||
decade = self.sql.decades.create(1990)
|
||||
year = self.sql.years.create(1990)
|
||||
|
||||
self.sidebar._decades.select_playlist = unittest.mock.Mock()
|
||||
for plist in [decade, year]:
|
||||
self.sidebar._decades.select_playlist.reset_mock()
|
||||
self.sidebar._decades.active = False
|
||||
|
||||
self.sidebar.select_playlist(plist)
|
||||
self.assertTrue(self.sidebar._decades.active)
|
||||
self.sidebar._decades.select_playlist.assert_called_with(plist)
|
||||
|
||||
library = self.sql.libraries.create("/a/b/c")
|
||||
self.sidebar.select_playlist(library)
|
||||
self.assertTrue(self.sidebar._libraries.active)
|
||||
self.assertEqual(self.sidebar.selected_playlist, library)
|
||||
def test_table_section(self):
|
||||
"""Test converting a Playlist database table into a Section."""
|
||||
self.assertEqual(self.sidebar.table_section(self.sql.playlists),
|
||||
self.sidebar._playlists)
|
||||
self.assertEqual(self.sidebar.table_section(self.sql.artists),
|
||||
self.sidebar._artists)
|
||||
self.assertEqual(self.sidebar.table_section(self.sql.albums),
|
||||
self.sidebar._artists)
|
||||
self.assertEqual(self.sidebar.table_section(self.sql.media),
|
||||
self.sidebar._artists)
|
||||
self.assertEqual(self.sidebar.table_section(self.sql.genres),
|
||||
self.sidebar._genres)
|
||||
self.assertEqual(self.sidebar.table_section(self.sql.decades),
|
||||
self.sidebar._decades)
|
||||
self.assertEqual(self.sidebar.table_section(self.sql.years),
|
||||
self.sidebar._decades)
|
||||
self.assertEqual(self.sidebar.table_section(self.sql.libraries),
|
||||
self.sidebar._libraries)
|
||||
self.assertIsNone(self.sidebar.table_section(None))
|
||||
|
||||
def test_accelerators(self):
|
||||
"""Check that the accelerators list is set up properly."""
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
# Copyright 2023 (c) Anna Schumaker.
|
||||
"""Test our functions for callbacks at a specific time."""
|
||||
import datetime
|
||||
import unittest.mock
|
||||
import emmental.alarm
|
||||
from gi.repository import GLib
|
||||
|
||||
|
||||
class TestAlarm(unittest.TestCase):
|
||||
"""Test case for callbacks at a specific time."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
emmental.alarm._GSOURCE_MAPPING.clear()
|
||||
emmental.alarm._NEXT_ALARM_ID = 1
|
||||
self.midnight = datetime.time(hour=0, minute=0, second=0)
|
||||
|
||||
def test_state(self):
|
||||
"""Test our global state."""
|
||||
self.assertDictEqual(emmental.alarm._GSOURCE_MAPPING, {})
|
||||
self.assertEqual(emmental.alarm._NEXT_ALARM_ID, 1)
|
||||
|
||||
def test_calc_seconds(self):
|
||||
"""Test calculating the seconds until the next alarm."""
|
||||
now = datetime.datetime.now()
|
||||
time = (now + datetime.timedelta(minutes=2)).time()
|
||||
self.assertEqual(emmental.alarm._calc_seconds(time), 120)
|
||||
|
||||
time = (now - datetime.timedelta(minutes=2)).time()
|
||||
self.assertEqual(emmental.alarm._calc_seconds(time), 86280)
|
||||
|
||||
@unittest.mock.patch("gi.repository.GLib.timeout_add_seconds")
|
||||
def test_set_alarm(self, mock_timeout_add: unittest.mock.Mock):
|
||||
"""Test setting an alarm."""
|
||||
callback = unittest.mock.Mock()
|
||||
seconds = emmental.alarm._calc_seconds(self.midnight)
|
||||
mock_timeout_add.return_value = 42
|
||||
|
||||
srcid = emmental.alarm.set_alarm(self.midnight, callback)
|
||||
mock_timeout_add.assert_called_with(seconds, emmental.alarm._do_alarm,
|
||||
self.midnight, callback, 1)
|
||||
self.assertEqual(srcid, 1)
|
||||
self.assertEqual(emmental.alarm._NEXT_ALARM_ID, 2)
|
||||
self.assertEqual(emmental.alarm._GSOURCE_MAPPING[1], 42)
|
||||
|
||||
@unittest.mock.patch("gi.repository.GLib.source_remove")
|
||||
@unittest.mock.patch("gi.repository.GLib.timeout_add_seconds")
|
||||
def test_cancel_alarm(self, mock_timeout_add: unittest.mock.Mock,
|
||||
mock_source_remove: unittest.mock.Mock):
|
||||
"""Test cancelling an alarm."""
|
||||
callback = unittest.mock.Mock()
|
||||
mock_timeout_add.return_value = 42
|
||||
srcid = emmental.alarm.set_alarm(self.midnight, callback)
|
||||
|
||||
emmental.alarm.cancel_alarm(srcid)
|
||||
mock_source_remove.assert_called_with(42)
|
||||
self.assertNotIn(srcid, emmental.alarm._GSOURCE_MAPPING)
|
||||
|
||||
@unittest.mock.patch("gi.repository.GLib.timeout_add_seconds")
|
||||
def test_do_alarm(self, mock_timeout_add: unittest.mock.Mock):
|
||||
"""Test triggering an alarm."""
|
||||
callback = unittest.mock.Mock()
|
||||
seconds = emmental.alarm._calc_seconds(self.midnight)
|
||||
emmental.alarm._GSOURCE_MAPPING[1] = 2
|
||||
mock_timeout_add.return_value = 42
|
||||
|
||||
self.assertEqual(emmental.alarm._do_alarm(self.midnight, callback, 1),
|
||||
GLib.SOURCE_REMOVE)
|
||||
callback.assert_called()
|
||||
mock_timeout_add.assert_called_with(seconds, emmental.alarm._do_alarm,
|
||||
self.midnight, callback, 1)
|
||||
self.assertEqual(emmental.alarm._GSOURCE_MAPPING[1], 42)
|
|
@ -21,10 +21,10 @@ class TestEmmental(unittest.TestCase):
|
|||
def test_version(self):
|
||||
"""Check that version constants have been set properly."""
|
||||
self.assertEqual(emmental.MAJOR_VERSION, 3)
|
||||
self.assertEqual(emmental.MINOR_VERSION, 0)
|
||||
self.assertEqual(emmental.MICRO_VERSION, 6)
|
||||
self.assertEqual(emmental.VERSION_NUMBER, "3.0.6")
|
||||
self.assertEqual(emmental.VERSION_STRING, "Emmental 3.0.6-debug")
|
||||
self.assertEqual(emmental.MINOR_VERSION, 2)
|
||||
self.assertEqual(emmental.MICRO_VERSION, 0)
|
||||
self.assertEqual(emmental.VERSION_NUMBER, "3.2.0")
|
||||
self.assertEqual(emmental.VERSION_STRING, "Emmental 3.2.0-debug")
|
||||
|
||||
def test_application(self):
|
||||
"""Check that the application instance is initialized properly."""
|
||||
|
@ -49,12 +49,15 @@ class TestEmmental(unittest.TestCase):
|
|||
self.assertIsNone(self.application.mpris)
|
||||
self.assertIsNone(self.application.factory)
|
||||
self.assertIsNone(self.application.player)
|
||||
self.assertIsNone(self.application.lbrainz)
|
||||
self.assertIsNone(self.application.win)
|
||||
|
||||
self.application.emit("startup")
|
||||
self.assertIsInstance(self.application.db, emmental.db.Connection)
|
||||
self.assertIsInstance(self.application.mpris,
|
||||
emmental.mpris2.Connection)
|
||||
self.assertIsInstance(self.application.lbrainz,
|
||||
emmental.listenbrainz.ListenBrainz)
|
||||
self.assertIsInstance(self.application.player, emmental.audio.Player)
|
||||
self.assertIsInstance(self.application.factory,
|
||||
emmental.playlist.Factory)
|
||||
|
@ -63,7 +66,7 @@ class TestEmmental(unittest.TestCase):
|
|||
mock_startup.assert_called()
|
||||
mock_load.assert_called()
|
||||
mock_add_window.assert_called_with(self.application.win)
|
||||
mock_set_useragent.assert_called_with("emmental-debug", "3.0.6")
|
||||
mock_set_useragent.assert_called_with("emmental-debug", "3.2.0")
|
||||
|
||||
@unittest.mock.patch("sys.stdout")
|
||||
@unittest.mock.patch("gi.repository.Adw.Application.add_window")
|
||||
|
@ -84,12 +87,16 @@ class TestEmmental(unittest.TestCase):
|
|||
"""Test that the shutdown signal works as expected."""
|
||||
db = self.application.db = emmental.db.Connection()
|
||||
mpris = self.application.mpris = emmental.mpris2.Connection()
|
||||
lbrainz = self.application.lbrainz = \
|
||||
emmental.listenbrainz.ListenBrainz(
|
||||
self.application.db)
|
||||
self.application.win = emmental.window.Window("Test 1.2.3")
|
||||
player = self.application.player = emmental.audio.Player()
|
||||
|
||||
self.application.emit("shutdown")
|
||||
self.assertIsNone(self.application.db)
|
||||
self.assertIsNone(self.application.mpris)
|
||||
self.assertIsNone(self.application.lbrainz)
|
||||
self.assertIsNone(self.application.player)
|
||||
self.assertIsNone(self.application.win)
|
||||
|
||||
|
@ -97,6 +104,7 @@ class TestEmmental(unittest.TestCase):
|
|||
self.assertFalse(db.connected)
|
||||
self.assertEqual(player.get_state(), gi.repository.Gst.State.NULL)
|
||||
mock_close.assert_called()
|
||||
self.assertFalse(lbrainz._thread.is_alive())
|
||||
|
||||
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
||||
def test_window_widgets(self, mock_stdout: io.StringIO):
|
||||
|
@ -105,6 +113,8 @@ class TestEmmental(unittest.TestCase):
|
|||
self.application.factory = emmental.playlist.Factory(
|
||||
self.application.db)
|
||||
self.application.player = emmental.audio.Player()
|
||||
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
|
||||
self.application.db)
|
||||
win = self.application.build_window()
|
||||
|
||||
self.assertIsInstance(win, emmental.window.Window)
|
||||
|
@ -125,12 +135,15 @@ class TestEmmental(unittest.TestCase):
|
|||
self.application.factory = emmental.playlist.Factory(
|
||||
self.application.db)
|
||||
self.application.player = emmental.audio.Player()
|
||||
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
|
||||
self.application.db)
|
||||
self.application.build_window()
|
||||
|
||||
for action, accel in [("app.open-file", "<Control>o"),
|
||||
("app.decrease-volume", "<Shift><Control>Down"),
|
||||
("app.increase-volume", "<Shift><Control>Up"),
|
||||
("app.toggle-bg-mode", "<Shift><Control>b"),
|
||||
("app.toggle-sidebar", "<Control>bracketright"),
|
||||
("app.edit-settings", "<Shift><Control>s")]:
|
||||
self.assertEqual(self.application.get_accels_for_action(action),
|
||||
[accel])
|
||||
|
@ -146,6 +159,8 @@ class TestEmmental(unittest.TestCase):
|
|||
self.application.factory = emmental.playlist.Factory(
|
||||
self.application.db)
|
||||
self.application.player = emmental.audio.Player()
|
||||
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
|
||||
self.application.db)
|
||||
win = self.application.build_window()
|
||||
|
||||
for action, accel in [("app.toggle-favorite", ["<Control>f"]),
|
||||
|
@ -203,6 +218,8 @@ class TestEmmental(unittest.TestCase):
|
|||
self.application.factory = emmental.playlist.Factory(
|
||||
self.application.db)
|
||||
self.application.player = emmental.audio.Player()
|
||||
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
|
||||
self.application.db)
|
||||
win = self.application.build_window()
|
||||
|
||||
for action, accel in [("app.focus-search-playlist",
|
||||
|
@ -225,6 +242,8 @@ class TestEmmental(unittest.TestCase):
|
|||
self.application.factory = emmental.playlist.Factory(
|
||||
self.application.db)
|
||||
self.application.player = emmental.audio.Player()
|
||||
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
|
||||
self.application.db)
|
||||
win = self.application.build_window()
|
||||
|
||||
for action, accel in [("app.focus-search-track", "<Control>slash"),
|
||||
|
@ -249,6 +268,8 @@ class TestEmmental(unittest.TestCase):
|
|||
"""Test that the Playlist Factory is wired up properly."""
|
||||
self.application.db = emmental.db.Connection()
|
||||
self.application.player = emmental.audio.Player()
|
||||
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
|
||||
self.application.db)
|
||||
self.application.factory = emmental.playlist.Factory(
|
||||
self.application.db)
|
||||
self.application.win = self.application.build_window()
|
||||
|
@ -263,6 +284,31 @@ class TestEmmental(unittest.TestCase):
|
|||
self.assertEqual(self.application.factory.db_previous,
|
||||
self.application.db.playlists.previous)
|
||||
|
||||
def test_listenbrainz(self):
|
||||
"""Test that listenbrainz is wired up properly."""
|
||||
self.application.db = emmental.db.Connection()
|
||||
self.application.player = emmental.audio.Player()
|
||||
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
|
||||
self.application.db)
|
||||
self.application.factory = emmental.playlist.Factory(
|
||||
self.application.db)
|
||||
self.application.win = self.application.build_window()
|
||||
|
||||
with unittest.mock.patch.object(self.application.lbrainz,
|
||||
"submit_listens") as mock_submit:
|
||||
self.application.connect_listenbrainz()
|
||||
|
||||
self.application.db.tracks.emit("track-played", None)
|
||||
mock_submit.assert_called()
|
||||
|
||||
self.application.lbrainz.stop()
|
||||
|
||||
self.application.win.header.listenbrainz_token = "abcde"
|
||||
self.assertEqual(self.application.lbrainz.user_token, "abcde")
|
||||
|
||||
self.application.lbrainz.valid_token = False
|
||||
self.assertFalse(self.application.win.header.listenbrainz_token_valid)
|
||||
|
||||
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
||||
def test_replaygain(self, mock_stdout: io.StringIO):
|
||||
"""Test setting replaygain modes."""
|
||||
|
@ -270,6 +316,8 @@ class TestEmmental(unittest.TestCase):
|
|||
self.application.factory = emmental.playlist.Factory(
|
||||
self.application.db)
|
||||
self.application.player = emmental.audio.Player()
|
||||
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
|
||||
self.application.db)
|
||||
win = self.application.build_window()
|
||||
player = self.application.player
|
||||
|
||||
|
@ -285,6 +333,8 @@ class TestEmmental(unittest.TestCase):
|
|||
self.application.factory = emmental.playlist.Factory(
|
||||
self.application.db)
|
||||
self.application.player = emmental.audio.Player()
|
||||
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
|
||||
self.application.db)
|
||||
win = self.application.build_window()
|
||||
player = self.application.player
|
||||
|
||||
|
@ -292,3 +342,19 @@ class TestEmmental(unittest.TestCase):
|
|||
win.header.bg_volume = 0.5
|
||||
self.assertTrue(player.bg_enabled)
|
||||
self.assertEqual(player.bg_volume, 0.5)
|
||||
|
||||
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
|
||||
def test_show_sidebar(self, mock_stdout: io.StringIO):
|
||||
"""Test showing the sidebar."""
|
||||
self.application.db = emmental.db.Connection()
|
||||
self.application.factory = emmental.playlist.Factory(
|
||||
self.application.db)
|
||||
self.application.player = emmental.audio.Player()
|
||||
self.application.lbrainz = emmental.listenbrainz.ListenBrainz(
|
||||
self.application.db)
|
||||
win = self.application.build_window()
|
||||
|
||||
win.show_sidebar = True
|
||||
self.assertTrue(win.header.show_sidebar)
|
||||
win.header.show_sidebar = False
|
||||
self.assertFalse(win.show_sidebar)
|
||||
|
|
|
@ -62,11 +62,22 @@ class TestGSetup(unittest.TestCase):
|
|||
self.assertIsInstance(emmental.gsetup.RESOURCE,
|
||||
gi.repository.Gio.Resource)
|
||||
|
||||
def test_cache_dir(self):
|
||||
"""Check that the CACHE_DIR points to the right place."""
|
||||
cache_path = xdg.BaseDirectory.save_cache_path("emmental")
|
||||
self.assertEqual(emmental.gsetup.CACHE_DIR,
|
||||
pathlib.Path(cache_path) / "debug")
|
||||
|
||||
def test_data_dir(self):
|
||||
"""Check that the DATA_DIR points to the right place."""
|
||||
data_path = xdg.BaseDirectory.save_data_path("emmental")
|
||||
self.assertEqual(emmental.gsetup.DATA_DIR, pathlib.Path(data_path))
|
||||
|
||||
def test_has_icon(self):
|
||||
"""Check that has_icon() works as expected."""
|
||||
self.assertTrue(emmental.gsetup.has_icon("media-playback-start"))
|
||||
self.assertFalse(emmental.gsetup.has_icon("no-such-icon"))
|
||||
|
||||
def test_env_string(self):
|
||||
"""Check that the env_string() function works as expected."""
|
||||
self.assertRegex(emmental.gsetup.env_string(),
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
# Copyright 2023 (c) Anna Schumaker.
|
||||
"""Tests our adaptable layout widget."""
|
||||
import unittest
|
||||
import emmental.layout
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
|
||||
|
||||
class TestLayout(unittest.TestCase):
|
||||
"""Test case for our adaptable layout."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
self.layout = emmental.layout.Layout()
|
||||
|
||||
def test_constants(self):
|
||||
"""Check constant variables."""
|
||||
self.assertEqual(emmental.layout.MIN_WIDTH,
|
||||
Adw.BreakpointConditionLengthType.MIN_WIDTH)
|
||||
|
||||
def test_init(self):
|
||||
"""Check that the layout is set up properly."""
|
||||
self.assertIsInstance(self.layout, Adw.Bin)
|
||||
self.assertIsInstance(self.layout._split_view, Adw.OverlaySplitView)
|
||||
self.assertTrue(self.layout._split_view.props.collapsed)
|
||||
|
||||
def test_wide_view(self):
|
||||
"""Test the layout when we have a wide window."""
|
||||
self.assertFalse(self.layout.wide_view)
|
||||
self.assertEqual(self.layout.props.child, self.layout._split_view)
|
||||
|
||||
self.layout.wide_view = True
|
||||
self.assertFalse(self.layout._split_view.props.collapsed)
|
||||
|
||||
def test_content(self):
|
||||
"""Test the content widget property."""
|
||||
self.assertIsNone(self.layout.content)
|
||||
|
||||
widget = Gtk.Label()
|
||||
self.layout.content = widget
|
||||
self.assertEqual(self.layout._split_view.props.content, widget)
|
||||
self.assertEqual(self.layout.content, widget)
|
||||
|
||||
widget2 = Gtk.Label()
|
||||
layout2 = emmental.layout.Layout(content=widget2)
|
||||
self.assertEqual(layout2.content, widget2)
|
||||
|
||||
def test_sidebar(self):
|
||||
"""Test the sidebar widget property."""
|
||||
self.assertIsNone(self.layout.sidebar)
|
||||
|
||||
widget = Gtk.Label()
|
||||
self.layout.sidebar = widget
|
||||
self.assertEqual(self.layout._split_view.props.sidebar, widget)
|
||||
self.assertEqual(self.layout.sidebar, widget)
|
||||
|
||||
widget2 = Gtk.Label()
|
||||
layout2 = emmental.layout.Layout(sidebar=widget2)
|
||||
self.assertEqual(layout2.sidebar, widget2)
|
||||
|
||||
def test_show_sidebar(self):
|
||||
"""Test the show-sidebar property."""
|
||||
self.assertFalse(self.layout.show_sidebar)
|
||||
self.assertFalse(self.layout._split_view.props.show_sidebar)
|
||||
|
||||
self.layout.show_sidebar = True
|
||||
self.assertTrue(self.layout._split_view.props.show_sidebar)
|
||||
|
||||
self.layout._split_view.props.show_sidebar = False
|
||||
self.assertFalse(self.layout.show_sidebar)
|
||||
|
||||
@unittest.mock.patch("gi.repository.Adw.Breakpoint.add_setter")
|
||||
def test_breakpoints(self, mock_add_setter: unittest.mock.Mock):
|
||||
"""Test the layout breakpoints property."""
|
||||
points = self.layout.breakpoints
|
||||
self.assertEqual(len(points), 1)
|
||||
|
||||
self.assertIsInstance(points[0], Adw.Breakpoint)
|
||||
condition = points[0].props.condition
|
||||
self.assertIsInstance(condition, Adw.BreakpointCondition)
|
||||
self.assertEqual(condition.to_string(), "min-width: 1000sp")
|
||||
|
||||
mock_add_setter.assert_called_once()
|
||||
args = mock_add_setter.mock_calls[0].args
|
||||
self.assertEqual(args[0], self.layout)
|
||||
self.assertEqual(args[1], "wide-view")
|
||||
self.assertTrue(args[2].get_boolean())
|
|
@ -21,11 +21,23 @@ class TestSettings(unittest.TestCase):
|
|||
self.settings = self.app.db.settings
|
||||
self.win = self.app.win
|
||||
self.player = self.app.player
|
||||
self.lbrainz = self.app.lbrainz
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up."""
|
||||
self.app.do_shutdown()
|
||||
|
||||
def test_save_listenbrainz_token(self, new_callable=io.StringIO):
|
||||
"""Check saving and loading the listenbrainz token."""
|
||||
self.assertEqual(self.settings["listenbrainz.token"], "")
|
||||
self.assertEqual(self.win.header.listenbrainz_token, "")
|
||||
|
||||
self.win.header.listenbrainz_token = "abcde"
|
||||
self.assertEqual(self.settings["listenbrainz.token"], "abcde")
|
||||
|
||||
win = self.app.build_window()
|
||||
self.assertEqual(win.header.listenbrainz_token, "abcde")
|
||||
|
||||
def test_save_window_size(self, new_callable=io.StringIO):
|
||||
"""Check saving and loading window size from the database."""
|
||||
self.assertEqual(self.settings["window.width"], 1600)
|
||||
|
@ -38,6 +50,16 @@ class TestSettings(unittest.TestCase):
|
|||
win = self.app.build_window()
|
||||
self.assertEqual(win.get_default_size(), (100, 200))
|
||||
|
||||
def test_save_show_sidebar(self, mock_stdout: io.StringIO):
|
||||
"""Check saving and loading the show-sidebar property."""
|
||||
self.assertFalse(self.settings["sidebar.show"])
|
||||
|
||||
self.win.show_sidebar = True
|
||||
self.assertTrue(self.settings["sidebar.show"])
|
||||
|
||||
win = self.app.build_window()
|
||||
self.assertTrue(win.show_sidebar)
|
||||
|
||||
def test_save_volume(self, mock_stdout: io.StringIO):
|
||||
"""Check saving and loading volume from the database."""
|
||||
self.assertEqual(self.settings["audio.volume"], 1.0)
|
||||
|
@ -111,16 +133,6 @@ class TestSettings(unittest.TestCase):
|
|||
|
||||
self.assertFalse(self.app.build_window().now_playing.prefer_artist)
|
||||
|
||||
def test_save_sidebar_size(self, mock_stdout: io.StringIO):
|
||||
"""Check saving and loading the sidebar widget size."""
|
||||
self.assertEqual(self.win.sidebar_size, 300)
|
||||
self.assertEqual(self.settings["sidebar.size"], 300)
|
||||
|
||||
self.win.sidebar_size = 400
|
||||
self.assertEqual(self.settings["sidebar.size"], 400)
|
||||
|
||||
self.assertEqual(self.app.build_window().sidebar_size, 400)
|
||||
|
||||
def test_save_sidebar_show_all_artists(self, mock_stdout: io.StringIO):
|
||||
"""Check saving and loading the show-all artists setting."""
|
||||
self.assertFalse(self.win.sidebar.show_all_artists)
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
# Copyright 2023 (c) Anna Schumaker.
|
||||
"""Tests our Gdk.Texture cache."""
|
||||
import emmental.texture
|
||||
import os
|
||||
import pathlib
|
||||
import tempfile
|
||||
import tests.util
|
||||
import unittest
|
||||
from gi.repository import Gdk
|
||||
|
||||
|
||||
class TestTextureCache(unittest.TestCase):
|
||||
"""Test our custom cache dictionary."""
|
||||
|
||||
def setUpClass():
|
||||
"""Clear the existing cache before testing."""
|
||||
emmental.texture.CACHE.clear()
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
cover = tests.util.COVER_JPG.absolute().relative_to("/")
|
||||
self.target = emmental.texture.CACHE_PATH / cover
|
||||
self.target2 = self.target.with_name("cover2.jpg")
|
||||
self.cache = emmental.texture._TextureCache()
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up."""
|
||||
self.target2.unlink(missing_ok=True)
|
||||
(path := self.target).unlink(missing_ok=True)
|
||||
while (path := path.parent) != emmental.texture.CACHE_PATH:
|
||||
if path.is_dir():
|
||||
path.rmdir()
|
||||
|
||||
def test_path(self):
|
||||
"""Test the on-disk path of the texture cache."""
|
||||
self.assertIsInstance(emmental.texture.TEMP_DIR,
|
||||
tempfile.TemporaryDirectory)
|
||||
self.assertEqual(emmental.texture.CACHE_PATH,
|
||||
pathlib.Path(emmental.texture.TEMP_DIR.name))
|
||||
self.assertTrue(emmental.texture.CACHE_PATH.is_dir())
|
||||
|
||||
def test_init(self):
|
||||
"""Test that the cache dict is initialized properly."""
|
||||
self.assertIsInstance(emmental.texture.CACHE,
|
||||
emmental.texture._TextureCache)
|
||||
self.assertDictEqual(emmental.texture.CACHE, {})
|
||||
|
||||
self.assertIsInstance(self.cache, dict)
|
||||
self.assertDictEqual(self.cache, {})
|
||||
|
||||
def test_drop(self):
|
||||
"""Test dropping items from the cache."""
|
||||
self.cache[tests.util.COVER_JPG]
|
||||
self.cache.drop(tests.util.COVER_JPG)
|
||||
self.assertDictEqual(self.cache, {})
|
||||
self.assertFalse(self.target.exists())
|
||||
|
||||
self.cache[tests.util.COVER_JPG]
|
||||
self.cache.clear()
|
||||
self.cache.drop(tests.util.COVER_JPG)
|
||||
self.assertFalse(self.target.exists())
|
||||
|
||||
def test_getitem(self):
|
||||
"""Test getting and creating items in the cache dict."""
|
||||
self.assertIsNone(self.cache[None])
|
||||
self.assertIsNone(self.cache[pathlib.Path("/no/such/path")])
|
||||
self.assertDictEqual(self.cache, {})
|
||||
self.assertListEqual(list(emmental.texture.CACHE_PATH.iterdir()), [])
|
||||
|
||||
texture = self.cache[tests.util.COVER_JPG]
|
||||
self.assertIsInstance(texture, Gdk.Texture)
|
||||
self.assertDictEqual(self.cache, {tests.util.COVER_JPG: texture})
|
||||
self.assertEqual(self.cache[tests.util.COVER_JPG], texture)
|
||||
self.assertTrue(self.target.is_file())
|
||||
|
||||
self.cache.clear()
|
||||
self.assertIsInstance(self.cache[tests.util.COVER_JPG], Gdk.Texture)
|
||||
|
||||
def test_getitem_cache_only(self):
|
||||
"""Test getting a cached item with deleted source path."""
|
||||
cover2 = tests.util.COVER_JPG.with_name("cover2.jpg")
|
||||
texture = self.cache[tests.util.COVER_JPG]
|
||||
self.cache[cover2] = texture
|
||||
del self.cache[tests.util.COVER_JPG]
|
||||
|
||||
self.assertEqual(self.cache[cover2], texture)
|
||||
|
||||
self.cache.clear()
|
||||
self.target.rename(self.target2)
|
||||
self.assertIsInstance(self.cache[cover2], Gdk.Texture)
|
||||
|
||||
def test_mtime_update(self):
|
||||
"""Test updating an item in the cache."""
|
||||
texture = self.cache[tests.util.COVER_JPG]
|
||||
os.utime(self.target, (123456789, 123456789))
|
||||
|
||||
new = self.cache[tests.util.COVER_JPG]
|
||||
self.assertIsInstance(new, Gdk.Texture)
|
||||
self.assertNotEqual(new, texture)
|
|
@ -0,0 +1,118 @@
|
|||
# Copyright 2024 (c) Anna Schumaker.
|
||||
"""Tests our common Thread class."""
|
||||
import emmental.thread
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
|
||||
class TestData(unittest.TestCase):
|
||||
"""Tests our thread Data class."""
|
||||
|
||||
def test_init_kwargs(self):
|
||||
"""Tests initializing the data class with keyword args."""
|
||||
data = emmental.thread.Data(a=1, b=2)
|
||||
self.assertEqual(data.a, 1)
|
||||
self.assertEqual(data.b, 2)
|
||||
self.assertEqual(repr(data), "Data(a=1, b=2)")
|
||||
|
||||
def test_init_values_dict(self):
|
||||
"""Test initializing the data class with a dictionary of values."""
|
||||
data = emmental.thread.Data({"a": 1, "b": 2})
|
||||
self.assertEqual(data.a, 1)
|
||||
self.assertEqual(data.b, 2)
|
||||
self.assertEqual(repr(data), "Data(a=1, b=2)")
|
||||
|
||||
def test_init_both(self):
|
||||
"""Test initializing the data class with both."""
|
||||
data = emmental.thread.Data({"a": 1, "b": 2}, b=3, c='4')
|
||||
self.assertEqual(data.a, 1)
|
||||
self.assertEqual(data.b, 3)
|
||||
self.assertEqual(data.c, '4')
|
||||
self.assertEqual(repr(data), "Data(a=1, b=3, c='4')")
|
||||
|
||||
def test_compare(self):
|
||||
"""Test comparing two data classes."""
|
||||
data1 = emmental.thread.Data({"a": 1, "b": 2})
|
||||
data2 = emmental.thread.Data({"c": 3, "d": 4})
|
||||
self.assertTrue(data1 == data1)
|
||||
self.assertTrue(data1 == {"a": 1, "b": 2})
|
||||
self.assertFalse(data1 == data2)
|
||||
self.assertFalse(data1 == {"c": 2, "d": 4})
|
||||
self.assertFalse(data1 == 3)
|
||||
|
||||
|
||||
class TestThread(unittest.TestCase):
|
||||
"""Tests our Thread class."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
self.thread = emmental.thread.Thread()
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up."""
|
||||
self.thread.stop()
|
||||
|
||||
def test_init(self):
|
||||
"""Check that the Thread was initialized properly."""
|
||||
self.assertIsInstance(self.thread, threading.Thread)
|
||||
self.assertIsInstance(self.thread.ready, threading.Event)
|
||||
self.assertIsInstance(self.thread._condition, threading.Condition)
|
||||
|
||||
self.assertIsNone(self.thread._task)
|
||||
self.assertIsNone(self.thread._result)
|
||||
|
||||
self.assertTrue(self.thread.is_alive())
|
||||
self.assertTrue(self.thread.ready.is_set())
|
||||
|
||||
def test_set_get_result(self):
|
||||
"""Test the set_result() and get_result() functions."""
|
||||
with unittest.mock.patch.object(self.thread, "do_get_result",
|
||||
wraps=self.thread.do_get_result) \
|
||||
as mock_get_result:
|
||||
self.assertIsNone(self.thread.get_result())
|
||||
mock_get_result.assert_not_called()
|
||||
|
||||
self.thread.ready.clear()
|
||||
self.thread._result = {"res": "abcde"}
|
||||
self.assertIsNone(self.thread.get_result())
|
||||
mock_get_result.assert_not_called()
|
||||
|
||||
self.thread.set_result(res="fghij")
|
||||
self.assertTrue(self.thread.ready.is_set())
|
||||
self.assertIsInstance(self.thread._result, emmental.thread.Data)
|
||||
self.assertEqual(self.thread._result, {"res": "fghij"})
|
||||
self.assertEqual(self.thread.get_result(), {"res": "fghij"})
|
||||
self.assertIsNone(self.thread._result)
|
||||
mock_get_result.assert_called_with({"res": "fghij"})
|
||||
|
||||
result = {"res1": "klmno", "res2": "pqrst"}
|
||||
self.thread.set_result(**result)
|
||||
self.assertEqual(self.thread.get_result(other="other", arg="arg"),
|
||||
result)
|
||||
mock_get_result.assert_called_with(result,
|
||||
other="other", arg="arg")
|
||||
|
||||
def test_set_task(self):
|
||||
"""Test the set_task() function."""
|
||||
self.thread._result = "abcde"
|
||||
|
||||
with unittest.mock.patch.object(self.thread, "do_run_task",
|
||||
wraps=self.thread.do_run_task) \
|
||||
as mock_run_task:
|
||||
self.thread.set_task(arg="test")
|
||||
self.assertIsInstance(self.thread._task, emmental.thread.Data)
|
||||
self.assertEqual(self.thread._task, {"arg": "test"})
|
||||
self.assertIsNone(self.thread._result)
|
||||
self.thread.ready.wait()
|
||||
mock_run_task.assert_called_with(self.thread._task)
|
||||
|
||||
def test_stop(self):
|
||||
"""Test stopping the Thread."""
|
||||
self.thread._task = ("test", "task")
|
||||
|
||||
with unittest.mock.patch.object(self.thread, "do_stop") as mock_stop:
|
||||
self.thread.stop()
|
||||
self.assertFalse(self.thread.is_alive())
|
||||
self.assertFalse(self.thread.ready.is_set())
|
||||
self.assertIsNone(self.thread._task)
|
||||
mock_stop.assert_called()
|
|
@ -22,8 +22,8 @@ class TestWindow(unittest.TestCase):
|
|||
self.assertIsInstance(self.window, Adw.Window)
|
||||
self.assertIsInstance(self.window._box, Gtk.Box)
|
||||
self.assertIsInstance(self.window._header, Adw.Bin)
|
||||
self.assertIsInstance(self.window._outer_pane, Gtk.Paned)
|
||||
self.assertIsInstance(self.window._inner_pane, Gtk.Paned)
|
||||
self.assertIsInstance(self.window._layout, emmental.layout.Layout)
|
||||
self.assertIsInstance(self.window._toast, Adw.ToastOverlay)
|
||||
self.assertTrue(self.window.has_css_class("devel"))
|
||||
|
||||
|
@ -31,6 +31,9 @@ class TestWindow(unittest.TestCase):
|
|||
self.assertEqual(self.window.get_title(), "Test 1.2.3")
|
||||
self.assertEqual(self.window.get_default_size(), (1600, 900))
|
||||
|
||||
self.assertEqual(self.window.props.width_request, 525)
|
||||
self.assertEqual(self.window.props.height_request, 500)
|
||||
|
||||
def test_content(self):
|
||||
"""Check that the Window content is set up properly."""
|
||||
self.assertEqual(self.window._box.get_orientation(),
|
||||
|
@ -41,22 +44,19 @@ class TestWindow(unittest.TestCase):
|
|||
self.window._header)
|
||||
self.assertEqual(self.window._header.get_next_sibling(),
|
||||
self.window._toast)
|
||||
self.assertEqual(self.window._toast.get_child(),
|
||||
self.window._outer_pane)
|
||||
self.assertEqual(self.window._outer_pane.get_end_child(),
|
||||
self.window._inner_pane)
|
||||
self.assertTrue(self.window._outer_pane.has_css_class(
|
||||
self.assertEqual(self.window._toast.get_child(), self.window._layout)
|
||||
self.assertEqual(self.window._layout.content, self.window._inner_pane)
|
||||
self.assertTrue(self.window._layout.has_css_class(
|
||||
"emmental-padding"))
|
||||
|
||||
subtests = [(self.window._outer_pane, Gtk.Orientation.HORIZONTAL),
|
||||
(self.window._inner_pane, Gtk.Orientation.VERTICAL)]
|
||||
for pane, orientation in subtests:
|
||||
self.assertEqual(pane.get_orientation(), orientation)
|
||||
self.assertFalse(pane.get_shrink_start_child())
|
||||
self.assertFalse(pane.get_resize_start_child())
|
||||
self.assertTrue(pane.get_hexpand())
|
||||
self.assertTrue(pane.get_vexpand())
|
||||
self.assertTrue(pane.has_css_class("emmental-pane"))
|
||||
self.assertEqual(self.window._inner_pane.get_orientation(),
|
||||
Gtk.Orientation.VERTICAL)
|
||||
self.assertEqual(self.window._inner_pane.get_margin_start(), 8)
|
||||
self.assertFalse(self.window._inner_pane.get_shrink_start_child())
|
||||
self.assertFalse(self.window._inner_pane.get_resize_start_child())
|
||||
self.assertTrue(self.window._inner_pane.get_hexpand())
|
||||
self.assertTrue(self.window._inner_pane.get_vexpand())
|
||||
self.assertTrue(self.window._inner_pane.has_css_class("emmental-pane"))
|
||||
|
||||
def test_header(self):
|
||||
"""Check setting a widget to the header area."""
|
||||
|
@ -72,26 +72,22 @@ class TestWindow(unittest.TestCase):
|
|||
"""Check setting a widget to the sidebar area."""
|
||||
self.assertIsNone(self.window.sidebar)
|
||||
self.window.sidebar = Gtk.Label()
|
||||
self.assertEqual(self.window._outer_pane.get_start_child(),
|
||||
self.window.sidebar)
|
||||
self.assertEqual(self.window._layout.sidebar, self.window.sidebar)
|
||||
|
||||
window2 = emmental.window.Window(version="1.2.3", sidebar=Gtk.Label())
|
||||
self.assertIsInstance(window2.sidebar, Gtk.Label)
|
||||
self.assertEqual(window2._outer_pane.get_start_child(),
|
||||
window2.sidebar)
|
||||
self.assertEqual(window2._layout.sidebar, window2.sidebar)
|
||||
|
||||
def test_sidebar_size(self):
|
||||
"""Check setting the size of the sidebar area."""
|
||||
self.assertEqual(self.window.sidebar_size, 300)
|
||||
self.assertEqual(self.window._outer_pane.get_position(), 300)
|
||||
def test_show_sidebar(self):
|
||||
"""Check setting the show-sidebar property."""
|
||||
self.assertFalse(self.window.show_sidebar)
|
||||
self.assertFalse(self.window._layout.show_sidebar)
|
||||
|
||||
self.window.sidebar_size = 100
|
||||
self.assertEqual(self.window.sidebar_size, 100)
|
||||
self.assertEqual(self.window._outer_pane.get_position(), 100)
|
||||
self.window.show_sidebar = True
|
||||
self.assertTrue(self.window._layout.show_sidebar)
|
||||
|
||||
self.window._outer_pane.set_position(200)
|
||||
self.assertEqual(self.window.sidebar_size, 200)
|
||||
self.assertEqual(self.window._outer_pane.get_position(), 200)
|
||||
self.window._layout.show_sidebar = False
|
||||
self.assertFalse(self.window.show_sidebar)
|
||||
|
||||
def test_now_playing(self):
|
||||
"""Check setting a widget to the now_playing area."""
|
||||
|
@ -170,3 +166,10 @@ class TestWindow(unittest.TestCase):
|
|||
self.assertEqual(accels[0].name, "reset-focus")
|
||||
self.assertEqual(accels[0].func, self.window.set_focus)
|
||||
self.assertListEqual(accels[0].accels, ["Escape"])
|
||||
|
||||
@unittest.mock.patch("emmental.window.Window.add_breakpoint")
|
||||
def test_breakpoints(self, mock_add_breakpoint: unittest.mock.Mock):
|
||||
"""Test that the Window breakpoints are set up properly."""
|
||||
window2 = emmental.window.Window(version="1.2.3")
|
||||
self.assertEqual(len(mock_add_breakpoint.mock_calls),
|
||||
len(window2._layout.breakpoints))
|
||||
|
|
|
@ -195,13 +195,50 @@ class TestShuffleButtons(unittest.TestCase):
|
|||
"media-playlist-shuffle")
|
||||
self.assertEqual(self.shuffle.active_tooltip_text, "shuffle: enabled")
|
||||
|
||||
self.assertEqual(self.shuffle.inactive_icon_name,
|
||||
"media-playlist-consecutive")
|
||||
self.assertEqual(self.shuffle.inactive_tooltip_text,
|
||||
"shuffle: disabled")
|
||||
|
||||
self.assertAlmostEqual(self.shuffle.icon_opacity, 0.5, delta=0.005)
|
||||
|
||||
@unittest.mock.patch("emmental.gsetup.has_icon")
|
||||
def test_get_inactive_icon(self, mock_has_icon: unittest.mock.Mock):
|
||||
"""Test the get_inactive_icon() function."""
|
||||
mock_has_icon.return_value = True
|
||||
self.assertEqual(self.shuffle.get_inactive_icon(),
|
||||
"media-playlist-normal")
|
||||
mock_has_icon.assert_called()
|
||||
|
||||
mock_has_icon.return_value = False
|
||||
self.assertEqual(self.shuffle.get_inactive_icon(),
|
||||
"media-playlist-consecutive")
|
||||
|
||||
@unittest.mock.patch("emmental.gsetup.has_icon")
|
||||
def test_inactive_icon_name(self, mock_has_icon: unittest.mock.Mock):
|
||||
"""Test setting the inactive icon name."""
|
||||
mock_has_icon.return_value = True
|
||||
button = emmental.tracklist.buttons.ShuffleButton()
|
||||
mock_has_icon.assert_called_with("media-playlist-normal")
|
||||
self.assertEqual(button.inactive_icon_name, "media-playlist-normal")
|
||||
|
||||
mock_has_icon.return_value = False
|
||||
button = emmental.tracklist.buttons.ShuffleButton()
|
||||
self.assertEqual(button.inactive_icon_name,
|
||||
"media-playlist-consecutive")
|
||||
|
||||
@unittest.mock.patch("emmental.gsetup.has_icon")
|
||||
def test_toggled(self, mock_has_icon: unittest.mock.Mock):
|
||||
"""Test changing the icon when toggled."""
|
||||
mock_has_icon.return_value = True
|
||||
self.shuffle.active = True
|
||||
self.assertEqual(self.shuffle.inactive_icon_name,
|
||||
"media-playlist-normal")
|
||||
mock_has_icon.assert_called()
|
||||
|
||||
mock_has_icon.return_value = False
|
||||
self.shuffle.active = False
|
||||
self.assertEqual(self.shuffle.inactive_icon_name,
|
||||
"media-playlist-consecutive")
|
||||
|
||||
def test_opacity(self):
|
||||
"""Test adjusting the opacity based on active state."""
|
||||
self.shuffle.active = True
|
||||
|
@ -340,7 +377,7 @@ class TestSortButton(unittest.TestCase):
|
|||
"""Test that the Sort button is configured correctly."""
|
||||
self.assertIsInstance(self.sort, emmental.buttons.PopoverButton)
|
||||
self.assertEqual(self.sort.get_icon_name(),
|
||||
"view-list-ordered-symbolic")
|
||||
"list-compact-symbolic")
|
||||
self.assertEqual(self.sort.get_tooltip_text(),
|
||||
"configure playlist sort order")
|
||||
self.assertFalse(self.sort.get_has_frame())
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
"""Tests our Tracklist Footer."""
|
||||
import unittest
|
||||
import emmental.tracklist.footer
|
||||
from gi.repository import Pango
|
||||
from gi.repository import Gtk
|
||||
|
||||
|
||||
|
@ -22,6 +23,8 @@ class TestFooter(unittest.TestCase):
|
|||
self.assertIsInstance(self.footer._count, Gtk.Label)
|
||||
self.assertEqual(self.footer._count.get_xalign(), 0.0)
|
||||
self.assertEqual(self.footer.get_start_widget(), self.footer._count)
|
||||
self.assertEqual(self.footer._count.get_ellipsize(),
|
||||
Pango.EllipsizeMode.START)
|
||||
|
||||
self.assertEqual(self.footer.count, 0)
|
||||
self.assertEqual(self.footer._count.get_text(), "Showing 0 tracks")
|
||||
|
@ -36,6 +39,8 @@ class TestFooter(unittest.TestCase):
|
|||
self.assertEqual(self.footer._selected.get_xalign(), 0.5)
|
||||
self.assertEqual(self.footer.get_center_widget(),
|
||||
self.footer._selected)
|
||||
self.assertEqual(self.footer._selected.get_ellipsize(),
|
||||
Pango.EllipsizeMode.MIDDLE)
|
||||
|
||||
self.assertEqual(self.footer.selected, 0)
|
||||
self.assertEqual(self.footer._selected.get_text(), "")
|
||||
|
@ -51,6 +56,8 @@ class TestFooter(unittest.TestCase):
|
|||
self.assertIsInstance(self.footer._runtime, Gtk.Label)
|
||||
self.assertEqual(self.footer._runtime.get_xalign(), 1.0)
|
||||
self.assertEqual(self.footer.get_end_widget(), self.footer._runtime)
|
||||
self.assertEqual(self.footer._runtime.get_ellipsize(),
|
||||
Pango.EllipsizeMode.END)
|
||||
|
||||
self.assertEqual(self.footer.runtime, 0.0)
|
||||
self.assertEqual(self.footer._runtime.get_text(),
|
||||
|
|
|
@ -8,7 +8,6 @@ import emmental.tracklist.row
|
|||
import tests.util
|
||||
import unittest.mock
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gdk
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
|
||||
|
@ -193,9 +192,6 @@ class TestTrackRowWidgets(tests.util.TestCase):
|
|||
|
||||
def test_album_cover(self):
|
||||
"""Test the Album Cover widget."""
|
||||
self.assertDictEqual(emmental.tracklist.row.AlbumCover.Cache, {})
|
||||
cache = emmental.tracklist.row.AlbumCover.Cache
|
||||
|
||||
row = emmental.tracklist.row.AlbumCover(self.listitem, "cover")
|
||||
self.assertIsInstance(row, emmental.tracklist.row.TrackRow)
|
||||
self.assertIsInstance(row.child, Gtk.Picture)
|
||||
|
@ -206,10 +202,9 @@ class TestTrackRowWidgets(tests.util.TestCase):
|
|||
row.bind()
|
||||
self.assertEqual(row.filepath, tests.util.COVER_JPG)
|
||||
|
||||
self.assertEqual(len(cache), 1)
|
||||
self.assertIsInstance(cache[tests.util.COVER_JPG], Gdk.Texture)
|
||||
self.assertEqual(len(emmental.texture.CACHE), 1)
|
||||
self.assertEqual(row.child.get_paintable(),
|
||||
cache[tests.util.COVER_JPG])
|
||||
emmental.texture.CACHE[tests.util.COVER_JPG])
|
||||
self.assertTrue(row.child.get_has_tooltip())
|
||||
|
||||
self.album.cover = None
|
||||
|
|
Loading…
Reference in New Issue