Compare commits
318 Commits
emmental-2
...
main
Author | SHA1 | Date |
---|---|---|
Anna Schumaker | c3818a2b18 | |
Anna Schumaker | 19c47be056 | |
Anna Schumaker | dbc60e1c5f | |
Anna Schumaker | 6779535cf1 | |
Anna Schumaker | 2ae5fd0969 | |
Anna Schumaker | b1490fd447 | |
Anna Schumaker | 4c5d3c78c0 | |
Anna Schumaker | 84a832389f | |
Anna Schumaker | 924f65fddd | |
Anna Schumaker | 14c153733d | |
Anna Schumaker | efe2611422 | |
Anna Schumaker | c49a23b046 | |
Anna Schumaker | a944af7f3e | |
Anna Schumaker | c5867badae | |
Anna Schumaker | e85bdcc7f4 | |
Anna Schumaker | c7dca6164e | |
Anna Schumaker | eada937b7a | |
Anna Schumaker | d373c33283 | |
Anna Schumaker | 1db187dba5 | |
Anna Schumaker | 0d100ec752 | |
Anna Schumaker | c4e827bc5a | |
Anna Schumaker | a4e0968ef4 | |
Anna Schumaker | 58a1df1d1d | |
Anna Schumaker | 3c25dc2c7f | |
Anna Schumaker | c37ae94a5d | |
Anna Schumaker | e6a219017d | |
Anna Schumaker | 3c15515faf | |
Anna Schumaker | 6c6ebf3676 | |
Anna Schumaker | ad8fd70f9a | |
Anna Schumaker | 8c316d0126 | |
Anna Schumaker | 3f153e1423 | |
Anna Schumaker | a08273535c | |
Anna Schumaker | ae1c611959 | |
Anna Schumaker | e73b6c09e7 | |
Anna Schumaker | b02fd609f7 | |
Anna Schumaker | 3241830c8e | |
Anna Schumaker | 97659f212d | |
Anna Schumaker | d22a9b23a1 | |
Anna Schumaker | 29693dcf84 | |
Anna Schumaker | bee48deac6 | |
Anna Schumaker | 5e096fa704 | |
Anna Schumaker | 6ebf29a632 | |
Anna Schumaker | a4f30d87e6 | |
Anna Schumaker | 51b290e1f0 | |
Anna Schumaker | fa203a72dd | |
Anna Schumaker | 3b8fb8531e | |
Anna Schumaker | 3e73ce0650 | |
Anna Schumaker | 17e4d85f1b | |
Anna Schumaker | 24675bf202 | |
Anna Schumaker | 072264a77c | |
Anna Schumaker | e7526f595f | |
Anna Schumaker | 7d2ec00da7 | |
Anna Schumaker | 70d7f5fa70 | |
Anna Schumaker | 2504f4b91d | |
Anna Schumaker | 7358183fef | |
Anna Schumaker | c195e68216 | |
Anna Schumaker | 1397e6e9e3 | |
Anna Schumaker | 717fdf39cd | |
Anna Schumaker | 9cf980d967 | |
Anna Schumaker | 87d8a2ae3a | |
Anna Schumaker | ddfd37130b | |
Anna Schumaker | 5011db344e | |
Anna Schumaker | 9f240bbc8b | |
Anna Schumaker | f6481f0182 | |
Anna Schumaker | 3d6350d7bd | |
Anna Schumaker | eb6b4d8ef4 | |
Anna Schumaker | f7349cd864 | |
Anna Schumaker | 30bcd30328 | |
Anna Schumaker | ef99951f74 | |
Anna Schumaker | 0fd391a9fd | |
Anna Schumaker | bc92e72265 | |
Anna Schumaker | 8dae0ed7bd | |
Anna Schumaker | 1707f87e45 | |
Anna Schumaker | 7e99fd1ba0 | |
Anna Schumaker | a8e7078308 | |
Anna Schumaker | 1d0813f217 | |
Anna Schumaker | 725619faf5 | |
Anna Schumaker | 6607e5b0ad | |
Anna Schumaker | 73019d8eb4 | |
Anna Schumaker | 6032e549a5 | |
Anna Schumaker | ba4907ec34 | |
Anna Schumaker | 06771ecab6 | |
Anna Schumaker | 17b2a82e20 | |
Anna Schumaker | c0c516fb70 | |
Anna Schumaker | 3cddde0986 | |
Anna Schumaker | 4f15bde850 | |
Anna Schumaker | 5ee86a9b5e | |
Anna Schumaker | 85c18fb5fe | |
Anna Schumaker | 67b508384c | |
Anna Schumaker | 929beb2a97 | |
Anna Schumaker | f400366210 | |
Anna Schumaker | 0c66b13209 | |
Anna Schumaker | e846c957a5 | |
Anna Schumaker | 37f81825b1 | |
Anna Schumaker | c375d2366a | |
Anna Schumaker | d807f8bd36 | |
Anna Schumaker | a13e481754 | |
Anna Schumaker | afb0ba5d68 | |
Anna Schumaker | 0307fae362 | |
Anna Schumaker | a6cd453c63 | |
Anna Schumaker | 7079076857 | |
Anna Schumaker | 8d72e1375f | |
Anna Schumaker | 87b92ffc90 | |
Anna Schumaker | bb4ca1e9c4 | |
Anna Schumaker | 087c378e59 | |
Anna Schumaker | 1f434358de | |
Anna Schumaker | 41cb325ad0 | |
Anna Schumaker | 0c1e5fcace | |
Anna Schumaker | 9cb927aabb | |
Anna Schumaker | 397c693aef | |
Anna Schumaker | eb162154b5 | |
Anna Schumaker | 2b5cdaa197 | |
Anna Schumaker | c5f9608c49 | |
Anna Schumaker | cae93cae11 | |
Anna Schumaker | 01a37dbbc1 | |
Anna Schumaker | 14c487c295 | |
Anna Schumaker | 57dd2c280e | |
Anna Schumaker | 59fb7d12f3 | |
Anna Schumaker | 93cdd9137a | |
Anna Schumaker | 45e95cc8c1 | |
Anna Schumaker | fa5cd55fce | |
Anna Schumaker | f307c92edb | |
Anna Schumaker | 8afd1a6240 | |
Anna Schumaker | 84fbd94aa1 | |
Anna Schumaker | 7155fa9db5 | |
Anna Schumaker | 0e40e6a4e8 | |
Anna Schumaker | deea9caa37 | |
Anna Schumaker | a626a1f3c4 | |
Anna Schumaker | dae588bfaf | |
Anna Schumaker | 03e5b9ad1b | |
Anna Schumaker | 5b0a0f54e4 | |
Anna Schumaker | dd9d6268ff | |
Anna Schumaker | a86ce6165d | |
Anna Schumaker | e624566919 | |
Anna Schumaker | b9a25ce5af | |
Anna Schumaker | bb9ecdbb5d | |
Anna Schumaker | 8b249b4b3e | |
Anna Schumaker | 55d7eb3d45 | |
Anna Schumaker | 8b1be777c1 | |
Anna Schumaker | 50474c7fd1 | |
Anna Schumaker | 6e4e83cb40 | |
Anna Schumaker | 78ea2904a1 | |
Anna Schumaker | a6f59d9378 | |
Anna Schumaker | ff9724a274 | |
Anna Schumaker | 911aeb84a1 | |
Anna Schumaker | ee1152bcc4 | |
Anna Schumaker | ed4a484a31 | |
Anna Schumaker | 83355f7e96 | |
Anna Schumaker | b326320156 | |
Anna Schumaker | 0c77e509c3 | |
Anna Schumaker | ed1d990e74 | |
Anna Schumaker | 2d19d78eb6 | |
Anna Schumaker | f1e18549ff | |
Anna Schumaker | ff1d772a05 | |
Anna Schumaker | a485a3806b | |
Anna Schumaker | 481c4856c7 | |
Anna Schumaker | 61dfc2a586 | |
Anna Schumaker | 97bf9d48db | |
Anna Schumaker | b3dcd3c0b9 | |
Anna Schumaker | 1ffc300eda | |
Anna Schumaker | bed518cd77 | |
Anna Schumaker | 915a59a46b | |
Anna Schumaker | 9edfc4a5b0 | |
Anna Schumaker | 999a3eb523 | |
Anna Schumaker | 2c2462c3d6 | |
Anna Schumaker | 0d27a09233 | |
Anna Schumaker | a687b564a9 | |
Anna Schumaker | 6bbc423193 | |
Anna Schumaker | cf056d6ec5 | |
Anna Schumaker | fd584e516a | |
Anna Schumaker | 820eda4c46 | |
Anna Schumaker | b0734a41d0 | |
Anna Schumaker | f5ef144419 | |
Anna Schumaker | 55486c20c3 | |
Anna Schumaker | 14bcef6e52 | |
Anna Schumaker | 6592b97cbd | |
Anna Schumaker | 2d18ce422e | |
Anna Schumaker | 58934d9b46 | |
Anna Schumaker | 45ddb22cc7 | |
Anna Schumaker | edaa275ba5 | |
Anna Schumaker | 298b58a54e | |
Anna Schumaker | 4ce571ebf8 | |
Anna Schumaker | fdfc12fbd2 | |
Anna Schumaker | 7d26d89405 | |
Anna Schumaker | 9a3d095081 | |
Anna Schumaker | 1aec9df0a8 | |
Anna Schumaker | eea763f133 | |
Anna Schumaker | 3cda4caa76 | |
Anna Schumaker | e0e7b556be | |
Anna Schumaker | 7920b3d5a8 | |
Anna Schumaker | e39d128488 | |
Anna Schumaker | 99eb4abee3 | |
Anna Schumaker | 0524085602 | |
Anna Schumaker | 36ca0b9818 | |
Anna Schumaker | 8e55de26d1 | |
Anna Schumaker | b4d8a7cfaa | |
Anna Schumaker | 6762916899 | |
Anna Schumaker | 99496ca8bf | |
Anna Schumaker | 85c42216ab | |
Anna Schumaker | 6eec4dbfc3 | |
Anna Schumaker | 1b9458c278 | |
Anna Schumaker | 11560d781e | |
Anna Schumaker | ff835832c8 | |
Anna Schumaker | 8a16b4e05f | |
Anna Schumaker | dc8ccff311 | |
Anna Schumaker | 08687882a3 | |
Anna Schumaker | 24cb87d298 | |
Anna Schumaker | afb599dcf4 | |
Anna Schumaker | 2c629c887c | |
Anna Schumaker | 4be92c7326 | |
Anna Schumaker | 1c0712e673 | |
Anna Schumaker | 0dcdcd4a68 | |
Anna Schumaker | 0de8089d59 | |
Anna Schumaker | 3ba46db064 | |
Anna Schumaker | f8494cf47b | |
Anna Schumaker | 0f2a5aee9d | |
Anna Schumaker | c396839316 | |
Anna Schumaker | 2f239bd94d | |
Anna Schumaker | 711c04cb29 | |
Anna Schumaker | d49b033b0d | |
Anna Schumaker | eb0b005c75 | |
Anna Schumaker | b316184bf1 | |
Anna Schumaker | 976f465cec | |
Anna Schumaker | 70799fa50f | |
Anna Schumaker | dcd63015b8 | |
Anna Schumaker | 0adb0d472b | |
Anna Schumaker | bf4fa68991 | |
Anna Schumaker | b25ca24dc3 | |
Anna Schumaker | 2542a6cbd7 | |
Anna Schumaker | 0584a2398a | |
Anna Schumaker | 673c6910e9 | |
Anna Schumaker | cdae9541e9 | |
Anna Schumaker | cd4caf7df8 | |
Anna Schumaker | 710f3fba80 | |
Anna Schumaker | 9d7763a730 | |
Anna Schumaker | 40c463da81 | |
Anna Schumaker | 5545cb106d | |
Anna Schumaker | 6131640e25 | |
Anna Schumaker | 300ee18569 | |
Anna Schumaker | 24c1a31367 | |
Anna Schumaker | b7f1a05967 | |
Anna Schumaker | c0edbd9bff | |
Anna Schumaker | 0cf5f80eb4 | |
Anna Schumaker | 2dc5d9ed0a | |
Anna Schumaker | aeeee1417a | |
Anna Schumaker | 87606f8fac | |
Anna Schumaker | 51a13a8a04 | |
Anna Schumaker | 1730b7e92c | |
Anna Schumaker | 1b38c4d6ec | |
Anna Schumaker | d3bdaaa063 | |
Anna Schumaker | d57509425b | |
Anna Schumaker | 8a9c90a7ff | |
Anna Schumaker | d22b3c0ce2 | |
Anna Schumaker | 9944d07bba | |
Anna Schumaker | e502a7e8cb | |
Anna Schumaker | 1832a56786 | |
Anna Schumaker | ad3d4840e8 | |
Anna Schumaker | ec5c4ddd2c | |
Anna Schumaker | 6cade5d779 | |
Anna Schumaker | 00f6ee9238 | |
Anna Schumaker | 011dbd114b | |
Anna Schumaker | f85cdb5b49 | |
Anna Schumaker | b3d04805d7 | |
Anna Schumaker | ee8db58fb2 | |
Anna Schumaker | 7ff1a3d60c | |
Anna Schumaker | 000dbd7018 | |
Anna Schumaker | 2ff03bba18 | |
Anna Schumaker | e0becbb059 | |
Anna Schumaker | a5db116d42 | |
Anna Schumaker | 2ed34d3465 | |
Anna Schumaker | 3157c53423 | |
Anna Schumaker | bf8d7fac1b | |
Anna Schumaker | 7cd77d3aed | |
Anna Schumaker | 69c59438c2 | |
Anna Schumaker | b3c2dd25fb | |
Anna Schumaker | 8ec5239acc | |
Anna Schumaker | 4bced82b1f | |
Anna Schumaker | dbc2ec03f2 | |
Anna Schumaker | c434f6672e | |
Anna Schumaker | 997b1de012 | |
Anna Schumaker | 81fdfe66cb | |
Anna Schumaker | c899c15c42 | |
Anna Schumaker | 318b2564ce | |
Anna Schumaker | 35d0d815ca | |
Anna Schumaker | 51096104ce | |
Anna Schumaker | 88e4fa4b0c | |
Anna Schumaker | d105b15e02 | |
Anna Schumaker | 93dc476706 | |
Anna Schumaker | d134b303ab | |
Anna Schumaker | 47bc858630 | |
Anna Schumaker | 5d0d522e3c | |
Anna Schumaker | bb1d01f951 | |
Anna Schumaker | 3f28799437 | |
Anna Schumaker | 081cae4cd8 | |
Anna Schumaker | 847c15f64b | |
Anna Schumaker | 4cd1e89493 | |
Anna Schumaker | 50270bd04c | |
Anna Schumaker | 236a1e60c2 | |
Anna Schumaker | 711fa0da5b | |
Anna Schumaker | 5d3fb980af | |
Anna Schumaker | 47d5f0c0c6 | |
Anna Schumaker | 3cf730a5cc | |
Anna Schumaker | 6ede296ba6 | |
Anna Schumaker | a73063a04c | |
Anna Schumaker | 5d1c11e64e | |
Anna Schumaker | 767f0c1584 | |
Anna Schumaker | b1cd1706ed | |
Anna Schumaker | 788ca374a8 | |
Anna Schumaker | 651f24672b | |
Anna Schumaker | 2eef68f76f | |
Anna Schumaker | 8c8135fc23 | |
Anna Schumaker | deb4f3d252 | |
Anna Schumaker | 61fc252172 | |
Anna Schumaker | 482a199731 | |
Anna Schumaker | 4be26c5fee | |
Anna Schumaker | 5cd5d2640d | |
Anna Schumaker | 4072ea97d4 | |
Anna Schumaker | 08ea7342dc |
|
@ -3,4 +3,8 @@
|
|||
*.coverage
|
||||
*.ui~
|
||||
*.txt
|
||||
*.patch
|
||||
*.tar.gz
|
||||
PKGBUILD
|
||||
emmental.gresource*
|
||||
emmental/mpris2/*.xml
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
[submodule "aur"]
|
||||
path = aur
|
||||
url = ssh://aur@aur.archlinux.org/emmental.git
|
||||
[submodule "mpris-spec"]
|
||||
path = mpris-spec
|
||||
url = https://gitlab.freedesktop.org/mpris/mpris-spec.git
|
||||
|
|
92
Makefile
92
Makefile
|
@ -5,36 +5,90 @@ export PREFIX = /usr/local
|
|||
export EMMENTAL_LIB = ${PREFIX}/lib/emmental
|
||||
export EMMENTAL_BIN = ${PREFIX}/bin
|
||||
export EMMENTAL_SHARE = ${PREFIX}/share
|
||||
export EMMENTAL_DESKTOP = ${EMMENTAL_SHARE}/applications/com.nowheycreamery.emmental.desktop
|
||||
|
||||
export EMMENTAL_MAJOR = $(shell grep \^MAJOR lib/version.py | awk -F= '{ gsub(/ /,""); print $$2}')
|
||||
export EMMENTAL_MINOR = $(shell grep \^MINOR lib/version.py | awk -F= '{ gsub(/ /,""); print $$2}')
|
||||
export EMMENTAL_TARGZ = https://git.nowheycreamery.com/anna/emmental/archive/emmental-${EMMENTAL_MAJOR}.${EMMENTAL_MINOR}.tar.gz
|
||||
export EMMENTAL_CSUM = $(shell curl -s ${EMMENTAL_TARGZ} | sha256sum | awk '{print $$1}')
|
||||
all: emmental.gresource mpris2 flake8
|
||||
|
||||
clean:
|
||||
find . -type f -name "*gresource*" -exec rm {} \+
|
||||
find . -type d -name __pycache__ -exec rm -r {} \+
|
||||
find data/ -type d -name "Test Album" -exec rm -r {} \+
|
||||
find data/ -type d -name "Test Library" -exec rm -r {} \+
|
||||
find emmental/mpris2/ -type f -name "*.xml" -exec rm {} \+
|
||||
|
||||
.PHONY:flake8
|
||||
flake8:
|
||||
flake8 emmental/ tests/
|
||||
|
||||
mpris-spec/Makefile:
|
||||
git submodule init mpris-spec
|
||||
git submodule update
|
||||
|
||||
emmental/mpris2/MediaPlayer2.xml: mpris-spec/Makefile
|
||||
cp mpris-spec/spec/org.mpris.MediaPlayer2.xml emmental/mpris2/MediaPlayer2.xml
|
||||
|
||||
emmental/mpris2/Player.xml: mpris-spec/Makefile
|
||||
cp mpris-spec/spec/org.mpris.MediaPlayer2.Player.xml emmental/mpris2/Player.xml
|
||||
|
||||
.PHONY: mpris2
|
||||
mpris2: emmental/mpris2/MediaPlayer2.xml emmental/mpris2/Player.xml
|
||||
|
||||
.PHONY: emmental.gresource.xml
|
||||
emmental.gresource.xml:
|
||||
exec tools/find-resources.py
|
||||
|
||||
.PHONY: emmental.gresource
|
||||
emmental.gresource: emmental.gresource.xml
|
||||
glib-compile-resources emmental.gresource.xml
|
||||
|
||||
.PHONY: install.app
|
||||
install.app:
|
||||
find ./emmental -type f -not -path "*/__pycache__/*" \
|
||||
-exec install -v -C -D -m 755 "{}" "$(EMMENTAL_LIB)/{}" \;
|
||||
install -C -v -m 644 emmental.py $(EMMENTAL_LIB)/emmental.py
|
||||
|
||||
.PHONY: install.icons
|
||||
install.icons:
|
||||
install -C -v -m 644 emmental.gresource $(EMMENTAL_LIB)/emmental.gresource
|
||||
install -C -v -m 644 icons/scalable/apps/emmental.svg $(EMMENTAL_LIB)/emmental.svg
|
||||
|
||||
.PHONY: install.desktop
|
||||
install.desktop:
|
||||
desktop-file-install --set-key=Exec --set-value $(EMMENTAL_BIN)/emmental \
|
||||
--set-key=Icon --set-value=$(EMMENTAL_LIB)/emmental.svg \
|
||||
--rebuild-mime-info-cache \
|
||||
--dir=$(EMMENTAL_SHARE)/applications com.nowheycreamery.emmental.desktop
|
||||
|
||||
.PHONY: install
|
||||
install:
|
||||
exec tools/install.sh
|
||||
install: emmental.gresource mpris2 install.app install.icons install.desktop
|
||||
mkdir -p $(EMMENTAL_BIN)
|
||||
echo -e "#!/bin/bash\npython -O $(EMMENTAL_LIB)/emmental.py \$$*" > $(EMMENTAL_BIN)/emmental
|
||||
chmod 655 $(EMMENTAL_BIN)/emmental
|
||||
|
||||
.PHONY: uninstall
|
||||
uninstall:
|
||||
rm -fv ${EMMENTAL_BIN}/emmental
|
||||
rm -rfv ${EMMENTAL_LIB}
|
||||
rm -fv ${EMMENTAL_SHARE}/icons/hicolor/scalable/apps/emmental*.svg
|
||||
rm -fv ${EMMENTAL_SHARE}/applications/emmental.desktop
|
||||
rm -f ${EMMENTAL_SHARE}/applications/com.nowheycreamery.emmental.desktop
|
||||
rm -f ${EMMENTAL_BIN}/emmental
|
||||
rm -rf ${EMMENTAL_LIB}/emmental/
|
||||
|
||||
.PHONY: pkgbuild.pkgver
|
||||
pkgbuild.pkgver:
|
||||
$(eval MAJOR := $(shell grep \^MAJOR_VERSION emmental/__init__.py | awk -F= '{ gsub(/ /,""); print $$2}'))
|
||||
$(eval MINOR := $(shell grep \^MINOR_VERSION emmental/__init__.py | awk -F= '{ gsub(/ /,""); print $$2}'))
|
||||
$(eval MICRO := $(shell grep \^MICRO_VERSION emmental/__init__.py | awk -F= '{ gsub(/ /,""); print $$2}'))
|
||||
sed -i 's/^pkgver=.*/pkgver=${MAJOR}.${MINOR}.${MICRO}/' aur/PKGBUILD
|
||||
|
||||
.PHONY: pkgbuild.sha256sum
|
||||
pkgbuild.sha256sum:
|
||||
$(eval TAG := $(shell git describe --abbrev=0))
|
||||
$(eval CHECKSUM := $(shell git archive --prefix=emmental/ --format tar.gz $(TAG) | sha256sum | awk '{print $$1}'))
|
||||
sed -i 's/^sha256sums=.*/sha256sums=(${CHECKSUM})/' aur/PKGBUILD
|
||||
|
||||
.PHONY: pkgbuild
|
||||
pkgbuild:
|
||||
cp data/PKGBUILD aur/
|
||||
sed -i 's|{MAJOR}.{MINOR}|${EMMENTAL_MAJOR}.${EMMENTAL_MINOR}|' aur/PKGBUILD
|
||||
sed -i 's|{SHA256SUM}|${EMMENTAL_CSUM}|' aur/PKGBUILD
|
||||
pkgbuild: pkgbuild.pkgver pkgbuild.sha256sum
|
||||
cd aur && makepkg --printsrcinfo > .SRCINFO
|
||||
|
||||
.PHONY: pytest
|
||||
pytest: emmental.gresource mpris2
|
||||
pytest
|
||||
|
||||
.PHONY: tests
|
||||
tests:
|
||||
python tools/generate_tracks.py
|
||||
python -m unittest discover -v
|
||||
tests: pytest flake8
|
||||
|
|
67
README.md
67
README.md
|
@ -1,3 +1,66 @@
|
|||
# emmental
|
||||
# Emmental
|
||||
Emmental is a music player built using Python, GStreamer, and GTK.
|
||||
It tries to make it really easy to listen to your music, the default
|
||||
"Collection" playlist contains all your music files and is a fallback when
|
||||
other playlists run out of tracks.
|
||||
|
||||
A new music player built around Python and GTK
|
||||
## Features
|
||||
* MPRIS2
|
||||
* ReplayGain
|
||||
* Gapless playback
|
||||
* Background listening mode
|
||||
* Automatically pause after a user-configured number of tracks
|
||||
* Playlist creation and management
|
||||
* Automatic playlists based on Artists, Albums, Genres, Decades, and Years
|
||||
* Multiple library path support
|
||||
* Plays all audio formats supported by GStreamer
|
||||
* Renamed and updated tracks detection (using MusicBrainzIDs)
|
||||
|
||||
## Dependencies
|
||||
* Python3
|
||||
* dateutil
|
||||
* gobject
|
||||
* liblistenbrainz
|
||||
* musicbrainzngs
|
||||
* mutagen
|
||||
* pyxdg
|
||||
* GStreamer
|
||||
* GStreamer good plugins (optional)
|
||||
* GStreamer bad plugins (optional)
|
||||
* GStreamer ugly plugins (optional)
|
||||
* GTK4
|
||||
* xdg-user-dirs-gtk
|
||||
* Libadwaita
|
||||
|
||||
## Installing
|
||||
Running `make install` will install Emmental to `/usr/local` by default.
|
||||
This can be changed during install:
|
||||
```
|
||||
PREFIX=/usr make install
|
||||
```
|
||||
|
||||
ArchLinux users can also install Emmental though the
|
||||
[AUR](https://aur.archlinux.org/packages/emmental)
|
||||
|
||||
## Q & A
|
||||
### 1. What's with the name? Why 'emmental'?
|
||||
Emmental was the cheese used in a
|
||||
[late-2018 experiment](https://www.smithsonianmag.com/smart-news/hip-hop-and-mozart-improve-flavor-swiss-cheese-180971721/)
|
||||
to learn if playing music while a cheese ages has an impact on flavor
|
||||
(spoiler alert: it did). I have a habit of naming projects and computers after
|
||||
cheeses, so when I started this project I named it after the cheese used in
|
||||
this experiment.
|
||||
|
||||
### 2. How do I edit my tracks?
|
||||
Emmental doesn't have a built-in tag editor but it can detect when track
|
||||
files have been edited in an external program. I highly recommend using a
|
||||
dedicated tag editing program like
|
||||
[MusicBrainz Picard](https://picard.musicbrainz.org/), it does a much better
|
||||
job at tag editing than I ever will.
|
||||
|
||||
### 3. What is the ReplayGain "Decide automatically" option?
|
||||
ReplayGain has two operating modes, "track" and "album", that the user can
|
||||
select between. Emmental builds on this and can automatically choose between
|
||||
the two based on the source playlist for a given track. This means if the
|
||||
current track comes from an Album playlist, ReplayGain will use "album mode"
|
||||
and if it comes from other playlists it will use "track mode".
|
||||
|
|
|
@ -1,25 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
from gi.repository import Gtk
|
||||
from . import artwork
|
||||
from . import controls
|
||||
from . import nowplaying
|
||||
from . import player
|
||||
from . import scale
|
||||
|
||||
Player = player.Player()
|
||||
|
||||
def Artwork(): return artwork.Artwork(Player)
|
||||
|
||||
class Header(Gtk.HeaderBar):
|
||||
def __init__(self):
|
||||
Gtk.HeaderBar.__init__(self)
|
||||
self.pack_start(controls.AudioControls(Player, Player.Autopause))
|
||||
self.pack_end(scale.ScaleButtonBox(scale.SeekScale(Player)))
|
||||
self.set_title_widget(nowplaying.NowPlaying(Player))
|
||||
|
||||
|
||||
def play_track(track):
|
||||
if track == Player.track:
|
||||
return False
|
||||
Player.play_track(track)
|
||||
return True
|
|
@ -1,46 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
from gi.repository import Gtk, GdkPixbuf, Gst
|
||||
|
||||
class Artwork(Gtk.AspectFrame):
|
||||
def __init__(self, player):
|
||||
Gtk.AspectFrame.__init__(self)
|
||||
self.picture = Gtk.Picture()
|
||||
self.frame = Gtk.Frame()
|
||||
self.frame.set_child(self.picture)
|
||||
|
||||
self.set_child(self.frame)
|
||||
self.set_obey_child(False)
|
||||
self.set_margin_start(5)
|
||||
self.set_margin_end(5)
|
||||
self.set_margin_top(5)
|
||||
self.set_margin_bottom(5)
|
||||
self.set_ratio(1.0)
|
||||
|
||||
self.player = player
|
||||
self.player.connect("artwork", self.on_artwork)
|
||||
self.player.connect("track-changed", self.on_track_changed)
|
||||
self.on_track_changed(player, None, player.track)
|
||||
|
||||
def __set_from_cover_jpg__(self, track):
|
||||
cover = track.path.parent / "cover.jpg"
|
||||
if cover.exists():
|
||||
self.picture.set_filename(str(cover))
|
||||
return True
|
||||
return False
|
||||
|
||||
def on_artwork(self, player, sample):
|
||||
buffer = sample.get_buffer()
|
||||
(res, map) = buffer.map(Gst.MapFlags.READ)
|
||||
if res:
|
||||
loader = GdkPixbuf.PixbufLoader()
|
||||
loader.write(map.data)
|
||||
self.picture.set_pixbuf(loader.get_pixbuf())
|
||||
loader.close()
|
||||
buffer.unmap(map)
|
||||
|
||||
def on_track_changed(self, player, prev, new):
|
||||
if not (new and self.__set_from_cover_jpg__(new)):
|
||||
display = self.picture.get_display()
|
||||
theme = Gtk.IconTheme.get_for_display(display)
|
||||
icon = theme.lookup_icon("emmental", [ ], 1024, 1, 0, 0)
|
||||
self.picture.set_file(icon.get_file())
|
163
audio/bass.py
163
audio/bass.py
|
@ -1,163 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import gi
|
||||
gi.require_version("Gst", "1.0")
|
||||
|
||||
import lib
|
||||
import sys
|
||||
from gi.repository import GObject
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Gst
|
||||
Gst.init(sys.argv)
|
||||
|
||||
from . import replaygain
|
||||
|
||||
TIMEOUT = 100
|
||||
|
||||
|
||||
class BassPlayer(GObject.GObject):
|
||||
def __init__(self):
|
||||
GObject.GObject.__init__(self)
|
||||
lib.settings.initialize("audio.replaygain", "disabled")
|
||||
lib.settings.initialize("audio.volume", 1.0)
|
||||
|
||||
self.audio = replaygain.ReplayGainSink()
|
||||
self.video = Gst.ElementFactory.make("fakesink")
|
||||
|
||||
self.playbin = Gst.ElementFactory.make("playbin")
|
||||
self.playbin.set_property("audio-sink", self.audio)
|
||||
self.playbin.set_property("video-sink", self.video)
|
||||
self.playbin.set_property("volume", lib.settings.get_float("audio.volume"))
|
||||
self.playbin.set_state(Gst.State.READY)
|
||||
self.set_property("replaygain", lib.settings.get("audio.replaygain"))
|
||||
|
||||
self.bus.add_signal_watch()
|
||||
self.bus.connect("message::eos", self.__eos__)
|
||||
self.bus.connect("message::state-changed", self.state_changed)
|
||||
self.bus.connect("message::stream-start", self.stream_start)
|
||||
self.bus.connect("message::state-changed", self.state_changed)
|
||||
self.bus.connect("message::tag", self.tag)
|
||||
|
||||
self.timeout = None
|
||||
|
||||
def __eos__(self, bus, message):
|
||||
self.emit("eos")
|
||||
|
||||
@GObject.Property
|
||||
def bus(self):
|
||||
return self.playbin.get_bus()
|
||||
|
||||
@GObject.Property
|
||||
def duration(self):
|
||||
(res, dur) = self.playbin.query_duration(Gst.Format.TIME)
|
||||
return dur if res == True else 0
|
||||
|
||||
@GObject.Property
|
||||
def playing(self):
|
||||
(ret, state, pending) = self.playbin.get_state(Gst.CLOCK_TIME_NONE)
|
||||
return state == Gst.State.PLAYING
|
||||
|
||||
@playing.setter
|
||||
def playing(self, playing):
|
||||
state = Gst.State.PLAYING if playing else Gst.State.PAUSED
|
||||
self.playbin.set_state(state)
|
||||
|
||||
@GObject.Property
|
||||
def play_percent(self):
|
||||
if self.playbin.clock == None or self.duration == 0:
|
||||
return 0
|
||||
runtime = self.playbin.clock.get_time() - self.playbin.base_time
|
||||
return runtime / self.duration
|
||||
|
||||
@GObject.Property
|
||||
def position(self):
|
||||
(res, pos) = self.playbin.query_position(Gst.Format.TIME)
|
||||
return pos if res == True else 0
|
||||
|
||||
@position.setter
|
||||
def position(self, pos):
|
||||
self.playbin.seek_simple(Gst.Format.TIME,
|
||||
Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT,
|
||||
pos)
|
||||
|
||||
@GObject.Property
|
||||
def replaygain(self):
|
||||
return self.audio.get_property("mode")
|
||||
|
||||
@replaygain.setter
|
||||
def replaygain(self, mode):
|
||||
lib.settings.set("audio.replaygain", mode)
|
||||
self.audio.set_property("mode", mode)
|
||||
|
||||
@GObject.Property
|
||||
def uri(self):
|
||||
return self.playbin.get_property("uri")
|
||||
|
||||
@uri.setter
|
||||
def uri(self, uri):
|
||||
if uri:
|
||||
self.playbin.set_property("uri", uri)
|
||||
else:
|
||||
self.playbin.set_state(Gst.State.READY)
|
||||
|
||||
@GObject.Property
|
||||
def volume(self):
|
||||
return self.playbin.get_property("volume")
|
||||
|
||||
@volume.setter
|
||||
def volume(self, vol):
|
||||
self.playbin.set_property("volume", vol)
|
||||
lib.settings.set("audio.volume", vol)
|
||||
|
||||
def state_changed(self, bus, message):
|
||||
if message.src == self.playbin:
|
||||
(old, new, pending) = message.parse_state_changed()
|
||||
if new == Gst.State.PLAYING:
|
||||
self.emit("playback-start")
|
||||
else:
|
||||
self.emit("playback-paused")
|
||||
|
||||
def stream_start(self, bus, message):
|
||||
self.emit("duration-changed")
|
||||
|
||||
def tag(self, bus, message):
|
||||
(res, sample) = message.parse_tag().get_sample("image")
|
||||
if res:
|
||||
self.emit("artwork", sample)
|
||||
|
||||
def timeout_function(self):
|
||||
self.emit("position-changed")
|
||||
return GLib.SOURCE_CONTINUE
|
||||
|
||||
@GObject.Signal
|
||||
def about_to_finish(self):
|
||||
pass
|
||||
|
||||
@GObject.Signal(arg_types=(Gst.Sample,))
|
||||
def artwork(self, sample):
|
||||
pass
|
||||
|
||||
@GObject.Signal
|
||||
def duration_changed(self):
|
||||
pass
|
||||
|
||||
@GObject.Signal
|
||||
def eos(self):
|
||||
pass
|
||||
|
||||
@GObject.Signal
|
||||
def playback_start(self):
|
||||
if not self.timeout:
|
||||
self.timeout = GLib.timeout_add(TIMEOUT, self.timeout_function)
|
||||
|
||||
@GObject.Signal
|
||||
def playback_paused(self):
|
||||
if self.timeout:
|
||||
GLib.source_remove(self.timeout)
|
||||
self.timeout = None
|
||||
|
||||
@GObject.Signal
|
||||
def position_changed(self):
|
||||
remaining = self.duration - self.position
|
||||
if remaining < 2 * Gst.SECOND:
|
||||
if remaining + (TIMEOUT * Gst.MSECOND) >= 2 * Gst.SECOND:
|
||||
self.emit("about-to-finish")
|
|
@ -1,152 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
from gi.repository import Gtk
|
||||
from . import scale
|
||||
|
||||
class ControlButton(Gtk.Button):
|
||||
def __init__(self, player, icon):
|
||||
Gtk.Button.__init__(self)
|
||||
self.add_css_class("large-icons")
|
||||
self.set_icon_name(icon)
|
||||
self.player = player
|
||||
|
||||
|
||||
class PreviousButton(ControlButton):
|
||||
def __init__(self, player):
|
||||
ControlButton.__init__(self, player, "media-skip-backward")
|
||||
|
||||
def do_clicked(self):
|
||||
self.player.previous()
|
||||
|
||||
|
||||
class NextButton(ControlButton):
|
||||
def __init__(self, player):
|
||||
ControlButton.__init__(self, player, "media-skip-forward")
|
||||
|
||||
def do_clicked(self):
|
||||
self.player.next()
|
||||
|
||||
|
||||
class PlayPauseButton(ControlButton):
|
||||
def __init__(self, player):
|
||||
ControlButton.__init__(self, player, "media-playback-start")
|
||||
self.player.connect("playback-start", self.playback_start)
|
||||
self.player.connect("playback-paused", self.playback_paused)
|
||||
|
||||
def do_clicked(self):
|
||||
self.player.playpause()
|
||||
|
||||
def playback_start(self, player):
|
||||
self.set_icon_name("media-playback-pause")
|
||||
|
||||
def playback_paused(self, player):
|
||||
self.set_icon_name("media-playback-start")
|
||||
|
||||
|
||||
class ControlScaleBox(scale.ScaleButtonBox):
|
||||
def __init__(self, scalectrl):
|
||||
icon = Gtk.Image()
|
||||
icon.add_css_class("large-icons")
|
||||
scalectrl.connect("value-changed", self.on_value_changed, icon)
|
||||
scale.ScaleButtonBox.__init__(self, scalectrl)
|
||||
self.on_value_changed(scalectrl, icon)
|
||||
self.prepend(icon)
|
||||
|
||||
def on_value_changed(self, scale, icon):
|
||||
pass
|
||||
|
||||
|
||||
class AutoPauseControlBox(ControlScaleBox):
|
||||
def __init__(self, apscale):
|
||||
apscale.unparent()
|
||||
ControlScaleBox.__init__(self, apscale)
|
||||
|
||||
def on_value_changed(self, scale, icon):
|
||||
name = "start" if scale.get_value() == -1 else "pause"
|
||||
icon.set_from_icon_name(f"media-playback-{name}")
|
||||
|
||||
|
||||
class VolumeControlBox(ControlScaleBox):
|
||||
def __init__(self, player):
|
||||
ControlScaleBox.__init__(self, scale.VolumeScale(player))
|
||||
|
||||
def on_value_changed(self, scale, icon):
|
||||
value = scale.get_value()
|
||||
if value == 0: name = "muted"
|
||||
elif value <= 1/3: name = "low"
|
||||
elif value <= 2/3: name = "medium"
|
||||
else: name = "high"
|
||||
icon.set_from_icon_name(f"audio-volume-{name}-symbolic")
|
||||
|
||||
|
||||
class ReplayGainComboBox(Gtk.ComboBoxText):
|
||||
def __init__(self, player):
|
||||
Gtk.ComboBoxText.__init__(self)
|
||||
self.modes = [ "disabled", "track", "album" ]
|
||||
self.player = player
|
||||
|
||||
self.append_text("ReplayGain Disabled")
|
||||
self.append_text("ReplayGain Track Mode")
|
||||
self.append_text("ReplayGain Album Mode")
|
||||
self.set_active(self.modes.index(player.replaygain))
|
||||
self.set_can_focus(False)
|
||||
|
||||
def do_changed(self):
|
||||
self.player.set_property("replaygain", self.modes[self.get_active()])
|
||||
|
||||
|
||||
class ReplayGainControl(Gtk.Box):
|
||||
def __init__(self, player):
|
||||
Gtk.Box.__init__(self)
|
||||
self.icon = Gtk.Image.new_from_icon_name("audio-headphones")
|
||||
self.icon.add_css_class("large-icons")
|
||||
self.rgcombo = ReplayGainComboBox(player)
|
||||
|
||||
self.append(self.icon)
|
||||
self.append(self.rgcombo)
|
||||
|
||||
|
||||
class ControlsPopover(Gtk.Popover):
|
||||
def __init__(self, player, apscale):
|
||||
Gtk.Popover.__init__(self)
|
||||
self.box = Gtk.Box.new(Gtk.Orientation.VERTICAL, 0)
|
||||
self.box.append(AutoPauseControlBox(apscale))
|
||||
self.box.append(VolumeControlBox(player))
|
||||
self.box.append(ReplayGainControl(player))
|
||||
self.set_child(self.box)
|
||||
|
||||
|
||||
class MenuIcon(Gtk.Overlay):
|
||||
def __init__(self, apscale):
|
||||
Gtk.Overlay.__init__(self)
|
||||
self.icon = Gtk.Image.new_from_icon_name("pan-down-symbolic")
|
||||
self.icon.set_margin_top(5)
|
||||
|
||||
self.label = Gtk.Label()
|
||||
self.label.set_markup("<small> </small>")
|
||||
self.label.set_yalign(0)
|
||||
|
||||
apscale.connect("value-changed", self.on_value_changed)
|
||||
self.add_overlay(self.icon)
|
||||
self.add_overlay(self.label)
|
||||
|
||||
def on_value_changed(self, scale):
|
||||
value = int(scale.get_value())
|
||||
text = str(value) if value > -1 else " "
|
||||
self.label.set_markup(f"<small>{text}</small>")
|
||||
|
||||
|
||||
class MenuButton(Gtk.MenuButton):
|
||||
def __init__(self, player, apscale):
|
||||
Gtk.MenuButton.__init__(self)
|
||||
self.set_popover(ControlsPopover(player, apscale))
|
||||
self.get_first_child().set_child(MenuIcon(apscale))
|
||||
|
||||
|
||||
class AudioControls(Gtk.Box):
|
||||
def __init__(self, player, apscale):
|
||||
Gtk.Box.__init__(self)
|
||||
self.add_css_class("linked")
|
||||
self.append(PreviousButton(player))
|
||||
self.append(PlayPauseButton(player))
|
||||
self.append(NextButton(player))
|
||||
self.append(MenuButton(player, apscale))
|
|
@ -1,39 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Gtk
|
||||
|
||||
class TrackTitle(Gtk.Label):
|
||||
def __init__(self, player):
|
||||
Gtk.Label.__init__(self)
|
||||
player.connect("track-changed", self.on_track_changed)
|
||||
self.on_track_changed(player, None, player.track)
|
||||
self.add_css_class("title")
|
||||
|
||||
def on_track_changed(self, player, old, new):
|
||||
text = new.title if new else "Emmental"
|
||||
self.set_markup(f"<big>{GLib.markup_escape_text(text)}</big>")
|
||||
|
||||
|
||||
class TrackArtist(Gtk.Label):
|
||||
def __init__(self, player):
|
||||
Gtk.Label.__init__(self)
|
||||
player.connect("track-changed", self.on_track_changed)
|
||||
self.on_track_changed(player, None, player.track)
|
||||
self.add_css_class("subtitle")
|
||||
|
||||
def on_track_changed(self, player, old, new):
|
||||
text = f"by {new.artist.name}" if new else "The Cheesy Music Player"
|
||||
self.set_markup(f"<big>{GLib.markup_escape_text(text)}</big>")
|
||||
|
||||
|
||||
class NowPlaying(Gtk.ScrolledWindow):
|
||||
def __init__(self, player):
|
||||
Gtk.ScrolledWindow.__init__(self)
|
||||
self.box = Gtk.Box.new(Gtk.Orientation.VERTICAL, 0)
|
||||
self.box.append(TrackTitle(player))
|
||||
self.box.append(TrackArtist(player))
|
||||
|
||||
self.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.NEVER)
|
||||
self.set_valign(Gtk.Align.CENTER)
|
||||
self.set_hexpand(True)
|
||||
self.set_child(self.box)
|
|
@ -1,78 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import db
|
||||
import lib
|
||||
from gi.repository import GObject
|
||||
from . import bass
|
||||
from . import scale
|
||||
|
||||
|
||||
class Player(bass.BassPlayer):
|
||||
def __init__(self):
|
||||
bass.BassPlayer.__init__(self)
|
||||
self.Autopause = scale.AutoPauseScale()
|
||||
self.playlist = None
|
||||
self.track = None
|
||||
|
||||
self.set_playlist(db.find_playlist(lib.settings.get_int("audio.plstateid")))
|
||||
self.change_track(db.track.Table.get(lib.settings.get_int("audio.trackid")))
|
||||
|
||||
def change_track(self, track, reset=False, add_prev=True):
|
||||
if self.track and self.play_percent > (2 / 3):
|
||||
self.track.played()
|
||||
if reset:
|
||||
self.uri = None
|
||||
self.emit("track-changed", self.track, track)
|
||||
if track and add_prev:
|
||||
db.user.Table.find("Previous").add_track(track)
|
||||
|
||||
def do_about_to_finish(self):
|
||||
if self.Autopause.get_value() != 0:
|
||||
self.Autopause.decrement()
|
||||
self.change_track(self.playlist.next_track())
|
||||
|
||||
def do_eos(self):
|
||||
self.Autopause.decrement()
|
||||
self.change_track(self.playlist.next_track(), reset=True)
|
||||
self.playing = self.Autopause.keep_playing
|
||||
|
||||
def play(self): self.playing = True
|
||||
def pause(self): self.playing = False
|
||||
def playpause(self): self.playing = not self.playing
|
||||
|
||||
def play_track(self, track, add_prev=True):
|
||||
self.change_track(track, reset=True, add_prev=add_prev)
|
||||
self.play()
|
||||
|
||||
def next(self):
|
||||
if track := db.user.Table.find("Previous").next_track():
|
||||
self.play_track(track, add_prev=False)
|
||||
else:
|
||||
if (track := self.playlist.next_track()) == None:
|
||||
self.set_playlist(db.user.Table.find("Collection"))
|
||||
track = self.playlist.next_track()
|
||||
self.play_track(track)
|
||||
|
||||
def previous(self):
|
||||
if track := db.user.Table.find("Previous").previous_track():
|
||||
self.play_track(track, add_prev=False)
|
||||
|
||||
def set_playlist(self, plist):
|
||||
if plist is None:
|
||||
plist = db.user.Table.find("Collection")
|
||||
if plist != self.playlist:
|
||||
self.emit("playlist-changed", self.playlist, plist)
|
||||
|
||||
@GObject.Signal(arg_types=(db.track.Track, db.track.Track))
|
||||
def track_changed(self, prev, new):
|
||||
self.track = new
|
||||
if self.track:
|
||||
lib.settings.set("audio.trackid", new.rowid)
|
||||
self.uri = new.path.absolute().as_uri()
|
||||
|
||||
@GObject.Signal(arg_types=(db.playlist.Playlist, db.playlist.Playlist))
|
||||
def playlist_changed(self, prev, new):
|
||||
self.playlist = new
|
||||
if self.playlist:
|
||||
if new.current >= new.get_n_tracks() - 1:
|
||||
new.current = -1
|
||||
lib.settings.set("audio.plstateid", new.plstateid)
|
|
@ -1,52 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gst
|
||||
|
||||
|
||||
class ReplayGainSink(Gst.Bin):
|
||||
def __init__(self):
|
||||
Gst.Bin.__init__(self)
|
||||
self.selector = Gst.ElementFactory.make("output-selector")
|
||||
self.funnel = Gst.ElementFactory.make("funnel")
|
||||
self.audiosink = Gst.ElementFactory.make("autoaudiosink")
|
||||
self.rgvolume = Gst.ElementFactory.make("rgvolume")
|
||||
self.rglimiter = Gst.ElementFactory.make("rglimiter")
|
||||
|
||||
for element in [ self.selector, self.funnel, self.audiosink,
|
||||
self.rgvolume, self.rglimiter ]:
|
||||
self.add(element)
|
||||
|
||||
# No ReplayGain: selector -> funnel -> audiosink
|
||||
self.shortcut = self.selector.get_request_pad("src_%u")
|
||||
self.shortcut.link(self.funnel.get_request_pad("sink_%u"))
|
||||
self.funnel.link(self.audiosink)
|
||||
|
||||
# Replaygain: selector -> rgvolume -> rglimiter -> funnel -> audiosink
|
||||
self.replaygain = self.selector.get_request_pad("src_%u")
|
||||
self.replaygain.link(self.rgvolume.get_static_pad("sink"))
|
||||
self.rgvolume.link(self.rglimiter)
|
||||
self.rglimiter.get_static_pad("src").link(
|
||||
self.funnel.get_request_pad("sink_%u"))
|
||||
|
||||
self.selector.set_property("pad-negotiation-mode", 1)
|
||||
self.selector.set_property("active-pad", self.shortcut)
|
||||
|
||||
pad = self.selector.get_static_pad("sink")
|
||||
ghost = Gst.GhostPad.new("sink", pad)
|
||||
ghost.set_active(True)
|
||||
self.add_pad(ghost)
|
||||
|
||||
@GObject.Property
|
||||
def mode(self):
|
||||
if self.selector.get_property("active-pad") == self.shortcut:
|
||||
return "disabled"
|
||||
album_mode = self.rgvolume.get_property("album-mode")
|
||||
return "album" if album_mode else "track"
|
||||
|
||||
@mode.setter
|
||||
def mode(self, mode):
|
||||
if mode == "disabled":
|
||||
self.selector.set_property("active-pad", self.shortcut)
|
||||
else:
|
||||
self.rgvolume.set_property("album-mode", mode == "album")
|
||||
self.selector.set_property("active-pad", self.replaygain)
|
132
audio/scale.py
132
audio/scale.py
|
@ -1,132 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Gst
|
||||
|
||||
class ScalePlus(Gtk.Scale):
|
||||
def __init__(self, min, max, step, page):
|
||||
Gtk.Scale.__init__(self)
|
||||
self.set_range(min, max)
|
||||
self.set_increments(step, page)
|
||||
self.set_value(min)
|
||||
self.set_draw_value(True)
|
||||
self.set_hexpand(True)
|
||||
self.set_format_value_func(self.format_value)
|
||||
|
||||
def __change_value__(self, n, scroll):
|
||||
value = self.get_value()
|
||||
self.set_value(value + n)
|
||||
new = self.get_value()
|
||||
if value == new:
|
||||
return None
|
||||
self.emit("change-value", scroll, new)
|
||||
return new
|
||||
|
||||
def decrement(self):
|
||||
adjustment = self.get_adjustment()
|
||||
return self.__change_value__(-adjustment.get_step_increment(),
|
||||
Gtk.ScrollType.STEP_BACKWARD)
|
||||
|
||||
def increment(self):
|
||||
adjustment = self.get_adjustment()
|
||||
return self.__change_value__(adjustment.get_step_increment(),
|
||||
Gtk.ScrollType.STEP_FORWARD)
|
||||
|
||||
def format_value(self, scale, value):
|
||||
return str(value)
|
||||
|
||||
|
||||
class SeekScale(ScalePlus):
|
||||
def __init__(self, player):
|
||||
ScalePlus.__init__(self, 0, player.duration,
|
||||
5 * Gst.SECOND, 30 * Gst.SECOND)
|
||||
self.set_size_request(200, -1)
|
||||
self.player = player
|
||||
self.player.connect("duration-changed", self.duration_changed)
|
||||
self.player.connect("position-changed", self.position_changed)
|
||||
|
||||
def do_change_value(self, scroll, value):
|
||||
self.player.position = value
|
||||
|
||||
def duration_changed(self, player):
|
||||
self.set_range(0, player.duration)
|
||||
|
||||
def position_changed(self, player):
|
||||
self.set_value(player.position)
|
||||
|
||||
def format_value(self, scale, value):
|
||||
position = int(value / Gst.SECOND)
|
||||
duration = int(self.get_adjustment().get_upper() / Gst.SECOND)
|
||||
(p_m, p_s) = divmod(position, 60)
|
||||
(r_m, r_s) = divmod(duration - position, 60)
|
||||
return f"{p_m:02}:{p_s:02} / {r_m:02}:{r_s:02}"
|
||||
|
||||
|
||||
class AutoPauseScale(ScalePlus):
|
||||
def __init__(self):
|
||||
ScalePlus.__init__(self, -1, 99, 1, 5)
|
||||
self.keep_playing = True
|
||||
self.set_digits(0)
|
||||
|
||||
def about_to_pause(self):
|
||||
return self.get_value() == 0
|
||||
|
||||
def format_value(self, scale, value):
|
||||
match int(value):
|
||||
case -1: return "Keep Playing"
|
||||
case 0: return "This Track"
|
||||
case 1: return "Next Track"
|
||||
case _: return f"{int(value)} Tracks"
|
||||
|
||||
def decrement(self):
|
||||
self.keep_playing = not self.about_to_pause()
|
||||
super().decrement()
|
||||
|
||||
|
||||
class VolumeScale(ScalePlus):
|
||||
def __init__(self, player):
|
||||
ScalePlus.__init__(self, 0.0, 1.0, 0.05, 0.25)
|
||||
self.player = player
|
||||
self.set_value(player.volume)
|
||||
|
||||
def do_change_value(self, scroll, value):
|
||||
self.set_value(value)
|
||||
self.player.volume = value
|
||||
|
||||
def format_value(self, scale, value):
|
||||
return f"{int(value * 100)}%"
|
||||
|
||||
|
||||
class ScaleButton(Gtk.Button):
|
||||
def __init__(self, scale, icon):
|
||||
Gtk.Button.__init__(self)
|
||||
self.add_css_class("normal-icons")
|
||||
self.add_css_class("flat")
|
||||
self.set_valign(Gtk.Align.END)
|
||||
self.set_icon_name(icon)
|
||||
self.scale = scale
|
||||
|
||||
|
||||
class DecrementButton(ScaleButton):
|
||||
def __init__(self, scale):
|
||||
ScaleButton.__init__(self, scale, "list-remove-symbolic")
|
||||
|
||||
def do_clicked(self):
|
||||
self.scale.decrement()
|
||||
|
||||
|
||||
class IncrementButton(ScaleButton):
|
||||
def __init__(self, scale):
|
||||
ScaleButton.__init__(self, scale, "list-add-symbolic")
|
||||
|
||||
def do_clicked(self):
|
||||
self.scale.increment()
|
||||
|
||||
|
||||
class ScaleButtonBox(Gtk.Box):
|
||||
def __init__(self, scale):
|
||||
Gtk.Box.__init__(self)
|
||||
self.set_orientation(Gtk.Orientation.HORIZONTAL)
|
||||
self.append(DecrementButton(scale))
|
||||
self.append(scale)
|
||||
self.append(IncrementButton(scale))
|
|
@ -1,29 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import unittest
|
||||
from gi.repository import Gtk
|
||||
from . import artwork
|
||||
|
||||
class FakePlayer:
|
||||
def __init__(self):
|
||||
self.track = None
|
||||
def connect(self, name, cb): pass
|
||||
|
||||
class TestAudioArtwork(unittest.TestCase):
|
||||
def test_audio_artwork_init(self):
|
||||
fake = FakePlayer()
|
||||
art = artwork.Artwork(fake)
|
||||
|
||||
self.assertIsInstance(art, Gtk.AspectFrame)
|
||||
self.assertIsInstance(art.frame, Gtk.Frame)
|
||||
self.assertIsInstance(art.picture, Gtk.Picture)
|
||||
|
||||
self.assertEqual(art.player, fake)
|
||||
self.assertEqual(art.get_child(), art.frame)
|
||||
self.assertEqual(art.frame.get_child(), art.picture)
|
||||
self.assertEqual(art.get_obey_child(), False)
|
||||
self.assertEqual(art.get_ratio(), 1.0)
|
||||
|
||||
self.assertEqual(art.get_margin_start(), 5)
|
||||
self.assertEqual(art.get_margin_end(), 5)
|
||||
self.assertEqual(art.get_margin_top(), 5)
|
||||
self.assertEqual(art.get_margin_bottom(), 5)
|
|
@ -1,32 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import audio
|
||||
import db
|
||||
import pathlib
|
||||
import unittest
|
||||
from gi.repository import Gtk
|
||||
|
||||
test_album = pathlib.Path("./data/Test Album/")
|
||||
test_track = test_album / "01 - Test Track.ogg"
|
||||
|
||||
class TestAudio(unittest.TestCase):
|
||||
def test_init(self):
|
||||
self.assertIsInstance(audio.Player, audio.player.Player)
|
||||
|
||||
def test_play_track(self):
|
||||
db.reset()
|
||||
track = db.make_fake_track(1, 10, "Test Track", test_track, test_album)
|
||||
|
||||
self.assertTrue(audio.play_track(track))
|
||||
self.assertTrue(audio.Player.playing)
|
||||
self.assertFalse(audio.play_track(track))
|
||||
audio.Player.playing = False
|
||||
|
||||
def test_header(self):
|
||||
header = audio.Header()
|
||||
self.assertIsInstance(header, Gtk.HeaderBar)
|
||||
self.assertIsInstance(header.get_title_widget(),
|
||||
audio.nowplaying.NowPlaying)
|
||||
|
||||
def test_widgets(self):
|
||||
self.assertIsInstance(audio.Artwork(),
|
||||
audio.artwork.Artwork)
|
|
@ -1,137 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import lib
|
||||
import pathlib
|
||||
import time
|
||||
import unittest
|
||||
from gi.repository import GObject
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Gst
|
||||
from . import bass
|
||||
from . import replaygain
|
||||
|
||||
main_context = GLib.main_context_default()
|
||||
test_album = pathlib.Path("./data/Test Album/")
|
||||
test_track = test_album / "01 - Test Track.ogg"
|
||||
test_uri = test_track.absolute().as_uri()
|
||||
|
||||
class TestBassPlayer(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.duration_changed = None
|
||||
|
||||
def on_duration_changed(self, player): self.duration_changed = player.duration
|
||||
def on_playback_start(self, player): self.playing = True
|
||||
def on_playback_paused(self, player): self.playing = False
|
||||
|
||||
def test_bass_player_init(self):
|
||||
base = bass.BassPlayer()
|
||||
self.assertIsInstance(base, GObject.GObject)
|
||||
self.assertIsInstance(base.audio, replaygain.ReplayGainSink)
|
||||
self.assertIsInstance(base.video, Gst.Element)
|
||||
self.assertIsInstance(base.playbin, Gst.Element)
|
||||
self.assertEqual(base.playbin.get_property("audio-sink"), base.audio)
|
||||
self.assertEqual(base.playbin.get_property("video-sink"), base.video)
|
||||
self.assertEqual(base.playbin.get_state(Gst.CLOCK_TIME_NONE)[1],
|
||||
Gst.State.READY)
|
||||
self.assertIsNone(base.timeout)
|
||||
|
||||
def test_bass_player_bus(self):
|
||||
base = bass.BassPlayer()
|
||||
self.assertIsInstance(base.bus, Gst.Bus)
|
||||
|
||||
def test_bass_player_duration(self):
|
||||
base = bass.BassPlayer()
|
||||
base.connect("duration-changed", self.on_duration_changed)
|
||||
self.assertEqual(base.get_property("duration"), 0)
|
||||
|
||||
base.set_property("uri", test_uri)
|
||||
base.set_property("playing", False)
|
||||
|
||||
iterations = 0
|
||||
while self.duration_changed == None:
|
||||
main_context.iteration(may_block=False)
|
||||
if (iterations := iterations +1) == 100000:
|
||||
break
|
||||
|
||||
self.assertEqual(base.get_property("duration"), 10 * Gst.SECOND)
|
||||
self.assertEqual(self.duration_changed, 10 * Gst.SECOND)
|
||||
|
||||
def test_bass_player_playing(self):
|
||||
base = bass.BassPlayer()
|
||||
base.connect("playback-start", self.on_playback_start)
|
||||
base.connect("playback-paused", self.on_playback_paused)
|
||||
base.set_property("uri", test_uri)
|
||||
self.assertFalse(base.get_property("playing"))
|
||||
|
||||
base.set_property("playing", True)
|
||||
(ret, state, pending) = base.playbin.get_state(Gst.CLOCK_TIME_NONE)
|
||||
self.assertEqual(state, Gst.State.PLAYING)
|
||||
self.assertTrue(base.get_property("playing"))
|
||||
while main_context.iteration(may_block=False): pass
|
||||
self.assertIsNotNone(base.timeout)
|
||||
|
||||
base.set_property("playing", False)
|
||||
(ret, state, pending) = base.playbin.get_state(Gst.CLOCK_TIME_NONE)
|
||||
self.assertEqual(state, Gst.State.PAUSED)
|
||||
self.assertFalse(base.get_property("playing"))
|
||||
while main_context.iteration(may_block=False): pass
|
||||
self.assertIsNone(base.timeout)
|
||||
|
||||
def test_basic_player_position(self):
|
||||
base = bass.BassPlayer()
|
||||
self.assertEqual(base.get_property("position"), 0)
|
||||
|
||||
base.set_property("uri", test_uri)
|
||||
base.set_property("playing", False)
|
||||
time.sleep(0.1)
|
||||
while main_context.iteration(may_block=False): time.sleep(0.005)
|
||||
|
||||
base.set_property("position", 5 * Gst.SECOND)
|
||||
time.sleep(0.2)
|
||||
while main_context.iteration(may_block=False): time.sleep(0.005)
|
||||
self.assertGreater(base.get_property("position"), 0)
|
||||
|
||||
def test_bass_player_replaygain(self):
|
||||
lib.settings.reset()
|
||||
base = bass.BassPlayer()
|
||||
self.assertEqual(lib.settings.get("audio.replaygain"), "disabled")
|
||||
self.assertEqual(base.get_property("replaygain"), "disabled")
|
||||
|
||||
base.set_property("replaygain", "track")
|
||||
self.assertEqual(base.audio.get_property("mode"), "track")
|
||||
self.assertEqual(base.get_property("replaygain"), "track")
|
||||
self.assertEqual(lib.settings.get("audio.replaygain"), "track")
|
||||
|
||||
base.set_property("replaygain", "disabled")
|
||||
self.assertEqual(base.audio.get_property("mode"), "disabled")
|
||||
self.assertEqual(base.get_property("replaygain"), "disabled")
|
||||
self.assertEqual(lib.settings.get("audio.replaygain"), "disabled")
|
||||
|
||||
base.set_property("replaygain", "album")
|
||||
self.assertEqual(base.audio.get_property("mode"), "album")
|
||||
self.assertEqual(base.get_property("replaygain"), "album")
|
||||
self.assertEqual(lib.settings.get("audio.replaygain"), "album")
|
||||
|
||||
def test_bass_player_uri(self):
|
||||
base = bass.BassPlayer()
|
||||
|
||||
self.assertIsNone(base.get_property("uri"))
|
||||
base.set_property("uri", test_uri)
|
||||
self.assertEqual(base.get_property("uri"), test_uri)
|
||||
|
||||
base.playbin.set_state(Gst.State.PAUSED)
|
||||
base.set_property("uri", None)
|
||||
self.assertEqual(base.playbin.get_state(Gst.CLOCK_TIME_NONE)[1],
|
||||
Gst.State.READY)
|
||||
|
||||
def test_bass_player_volume(self):
|
||||
lib.settings.reset()
|
||||
base = bass.BassPlayer()
|
||||
|
||||
self.assertEqual(lib.settings.get_float("audio.volume"), 1.0)
|
||||
self.assertEqual(base.get_property("volume"), 1.0)
|
||||
base.set_property("volume", 0.5)
|
||||
self.assertEqual(base.get_property("volume"), 0.5)
|
||||
self.assertEqual(lib.settings.get_float("audio.volume"), 0.5)
|
||||
|
||||
base2 = bass.BassPlayer()
|
||||
self.assertEqual(base2.get_property("volume"), 0.5)
|
|
@ -1,241 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import unittest
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Gst
|
||||
from . import controls
|
||||
from . import scale
|
||||
|
||||
class FakePlayer(GObject.GObject):
|
||||
def __init__(self):
|
||||
GObject.GObject.__init__(self)
|
||||
self.prev = False
|
||||
self.nxt = False
|
||||
self.play = False
|
||||
self.vol = 1.0
|
||||
self.rgain = "disabled"
|
||||
|
||||
@GObject.Property
|
||||
def playing(self): return self.play
|
||||
@playing.setter
|
||||
def playing(self, newval):
|
||||
self.play = newval
|
||||
self.emit("playback-start" if newval else "playback-paused")
|
||||
|
||||
def previous(self): self.prev = True
|
||||
def next(self): self.nxt = True
|
||||
|
||||
@GObject.Property
|
||||
def volume(self): return self.vol
|
||||
def playpause(self):
|
||||
self.playing = not self.playing
|
||||
|
||||
@GObject.Property
|
||||
def replaygain(self): return self.rgain
|
||||
@replaygain.setter
|
||||
def replaygain(self, newval): self.rgain = newval
|
||||
|
||||
@GObject.Signal
|
||||
def playback_start(self): pass
|
||||
@GObject.Signal
|
||||
def playback_paused(self): pass
|
||||
|
||||
|
||||
class TestControlButton(unittest.TestCase):
|
||||
def test_control_button(self):
|
||||
fake = FakePlayer()
|
||||
ctrl = controls.ControlButton(fake, "missing-icon")
|
||||
self.assertIsInstance(ctrl, Gtk.Button)
|
||||
self.assertTrue(ctrl.has_css_class("large-icons"))
|
||||
self.assertEqual(ctrl.get_icon_name(), "missing-icon")
|
||||
self.assertEqual(ctrl.player, fake)
|
||||
|
||||
|
||||
class TestPreviousButton(unittest.TestCase):
|
||||
def test_previous_button(self):
|
||||
fake = FakePlayer()
|
||||
prev = controls.PreviousButton(fake)
|
||||
|
||||
self.assertIsInstance(prev, controls.ControlButton)
|
||||
self.assertEqual(prev.get_icon_name(), "media-skip-backward")
|
||||
prev.emit("clicked")
|
||||
self.assertTrue(fake.prev)
|
||||
|
||||
|
||||
class TestNextButton(unittest.TestCase):
|
||||
def test_next_button(self):
|
||||
fake = FakePlayer()
|
||||
next = controls.NextButton(fake)
|
||||
|
||||
self.assertIsInstance(next, controls.ControlButton)
|
||||
self.assertEqual(next.get_icon_name(), "media-skip-forward")
|
||||
next.emit("clicked")
|
||||
self.assertTrue(fake.nxt)
|
||||
|
||||
|
||||
class TestPlayPauseButton(unittest.TestCase):
|
||||
def test_play_pause_button(self):
|
||||
fake = FakePlayer()
|
||||
play = controls.PlayPauseButton(fake)
|
||||
|
||||
self.assertIsInstance(play, controls.ControlButton)
|
||||
self.assertEqual(play.get_icon_name(), "media-playback-start")
|
||||
|
||||
play.emit("clicked")
|
||||
self.assertTrue(fake.play)
|
||||
self.assertEqual(play.get_icon_name(), "media-playback-pause")
|
||||
|
||||
play.emit("clicked")
|
||||
self.assertFalse(fake.play)
|
||||
self.assertEqual(play.get_icon_name(), "media-playback-start")
|
||||
|
||||
|
||||
class TestControlScaleBox(unittest.TestCase):
|
||||
def test_control_scale_box(self):
|
||||
splus = scale.ScalePlus(1, 10, 1, 5)
|
||||
ctrlbox = controls.ControlScaleBox(splus)
|
||||
self.assertIsInstance(ctrlbox, scale.ScaleButtonBox)
|
||||
self.assertIsInstance(ctrlbox.get_first_child(), Gtk.Image)
|
||||
self.assertTrue(ctrlbox.get_first_child().has_css_class("large-icons"))
|
||||
|
||||
|
||||
class TestAutoPauseControlBox(unittest.TestCase):
|
||||
def test_autopause_control_box(self):
|
||||
apscale = scale.AutoPauseScale()
|
||||
apbox = controls.AutoPauseControlBox(apscale)
|
||||
icon = apbox.get_first_child()
|
||||
|
||||
self.assertIsInstance(apbox, controls.ControlScaleBox)
|
||||
apscale.set_value(-1)
|
||||
self.assertEqual(icon.get_icon_name(), "media-playback-start")
|
||||
apscale.set_value(0)
|
||||
self.assertEqual(icon.get_icon_name(), "media-playback-pause")
|
||||
|
||||
|
||||
class TestVolumeControlbox(unittest.TestCase):
|
||||
def test_volume_control_box(self):
|
||||
fake = FakePlayer()
|
||||
vcb = controls.VolumeControlBox(fake)
|
||||
icon = vcb.get_first_child()
|
||||
volume = icon.get_next_sibling().scale
|
||||
|
||||
self.assertIsInstance(vcb, controls.ControlScaleBox)
|
||||
self.assertIsInstance(volume, scale.VolumeScale)
|
||||
|
||||
volume.set_value(0)
|
||||
self.assertEqual(icon.get_icon_name(), "audio-volume-muted-symbolic")
|
||||
volume.set_value(0.3)
|
||||
self.assertEqual(icon.get_icon_name(), "audio-volume-low-symbolic")
|
||||
volume.set_value(0.6)
|
||||
self.assertEqual(icon.get_icon_name(), "audio-volume-medium-symbolic")
|
||||
volume.set_value(0.9)
|
||||
self.assertEqual(icon.get_icon_name(), "audio-volume-high-symbolic")
|
||||
|
||||
|
||||
class TestReplayGainComboBox(unittest.TestCase):
|
||||
def test_replay_gain_combobox(self):
|
||||
fake = FakePlayer()
|
||||
combo = controls.ReplayGainComboBox(fake)
|
||||
|
||||
self.assertIsInstance(combo, Gtk.ComboBoxText)
|
||||
self.assertEqual(combo.player, fake)
|
||||
self.assertEqual(combo.modes, [ "disabled", "track", "album" ])
|
||||
self.assertEqual(combo.get_active_text(), "ReplayGain Disabled")
|
||||
|
||||
combo.set_active(1)
|
||||
self.assertEqual(combo.get_active_text(), "ReplayGain Track Mode")
|
||||
self.assertEqual(fake.replaygain, "track")
|
||||
|
||||
combo.set_active(2)
|
||||
self.assertEqual(combo.get_active_text(), "ReplayGain Album Mode")
|
||||
self.assertEqual(fake.replaygain, "album")
|
||||
|
||||
|
||||
class TestReplayGainControl(unittest.TestCase):
|
||||
def test_replay_gain_control(self):
|
||||
fake = FakePlayer()
|
||||
rgc = controls.ReplayGainControl(fake)
|
||||
|
||||
self.assertIsInstance(rgc, Gtk.Box)
|
||||
self.assertIsInstance(rgc.icon, Gtk.Image)
|
||||
self.assertIsInstance(rgc.rgcombo, controls.ReplayGainComboBox)
|
||||
|
||||
self.assertEqual(rgc.get_orientation(), Gtk.Orientation.HORIZONTAL)
|
||||
self.assertEqual(rgc.icon.get_icon_name(), "audio-headphones")
|
||||
self.assertEqual(rgc.get_first_child(), rgc.icon)
|
||||
self.assertEqual(rgc.icon.get_next_sibling(), rgc.rgcombo)
|
||||
self.assertTrue(rgc.icon.has_css_class("large-icons"))
|
||||
|
||||
|
||||
class TestControlsPopover(unittest.TestCase):
|
||||
def test_controls_popover(self):
|
||||
fake = FakePlayer()
|
||||
apscale = scale.AutoPauseScale()
|
||||
pop = controls.ControlsPopover(fake, apscale)
|
||||
|
||||
self.assertIsInstance(pop, Gtk.Popover)
|
||||
self.assertIsInstance(pop.box, Gtk.Box)
|
||||
|
||||
child = pop.box.get_first_child()
|
||||
self.assertIsInstance(child, controls.AutoPauseControlBox)
|
||||
child = child.get_next_sibling()
|
||||
self.assertIsInstance(child, controls.VolumeControlBox)
|
||||
child = child.get_next_sibling()
|
||||
self.assertIsInstance(child, controls.ReplayGainControl)
|
||||
|
||||
self.assertEqual(pop.get_child(), pop.box)
|
||||
self.assertEqual(pop.box.get_orientation(), Gtk.Orientation.VERTICAL)
|
||||
|
||||
|
||||
class TestControlsMenuIcon(unittest.TestCase):
|
||||
def test_controls_menu_icon(self):
|
||||
apscale = scale.AutoPauseScale()
|
||||
icon = controls.MenuIcon(apscale)
|
||||
|
||||
self.assertIsInstance(icon, Gtk.Overlay)
|
||||
self.assertIsInstance(icon.icon, Gtk.Image)
|
||||
self.assertIsInstance(icon.label, Gtk.Label)
|
||||
|
||||
self.assertEqual(icon.icon.get_icon_name(), "pan-down-symbolic")
|
||||
self.assertEqual(icon.icon.get_margin_top(), 5)
|
||||
self.assertEqual(icon.label.get_text(), " ")
|
||||
self.assertEqual(icon.label.get_yalign(), 0)
|
||||
|
||||
apscale.set_value(0)
|
||||
self.assertEqual(icon.label.get_text(), "0")
|
||||
|
||||
self.assertIn(icon.icon, icon)
|
||||
self.assertIn(icon.label, icon)
|
||||
|
||||
|
||||
class TestControlsMenuButton(unittest.TestCase):
|
||||
def test_controls_menu_button(self):
|
||||
fake = FakePlayer()
|
||||
apscale = scale.AutoPauseScale()
|
||||
menu = controls.MenuButton(fake, apscale)
|
||||
|
||||
self.assertIsInstance(menu, Gtk.MenuButton)
|
||||
self.assertIsInstance(menu.get_popover(),
|
||||
controls.ControlsPopover)
|
||||
self.assertIsInstance(menu.get_first_child().get_child(),
|
||||
controls.MenuIcon)
|
||||
|
||||
|
||||
class TestAudioControls(unittest.TestCase):
|
||||
def test_audio_controls(self):
|
||||
fake = FakePlayer()
|
||||
apscale = scale.AutoPauseScale()
|
||||
ctrl = controls.AudioControls(fake, apscale)
|
||||
|
||||
self.assertIsInstance(ctrl, Gtk.Box)
|
||||
self.assertEqual(ctrl.get_orientation(), Gtk.Orientation.HORIZONTAL)
|
||||
self.assertTrue(ctrl.has_css_class("linked"))
|
||||
|
||||
child = ctrl.get_first_child()
|
||||
self.assertIsInstance(child, controls.PreviousButton)
|
||||
child = child.get_next_sibling()
|
||||
self.assertIsInstance(child, controls.PlayPauseButton)
|
||||
child = child.get_next_sibling()
|
||||
self.assertIsInstance(child, controls.NextButton)
|
||||
child = child.get_next_sibling()
|
||||
self.assertIsInstance(child, controls.MenuButton)
|
|
@ -1,70 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import db
|
||||
import unittest
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
from . import nowplaying
|
||||
|
||||
class FakeArtist(GObject.GObject):
|
||||
def __init__(self):
|
||||
self.name = "Test Artist"
|
||||
|
||||
class FakeTrack(GObject.GObject):
|
||||
def __init__(self):
|
||||
GObject.GObject.__init__(self)
|
||||
self.title = "Test Title"
|
||||
self.artist = FakeArtist()
|
||||
|
||||
class FakePlayer(GObject.GObject):
|
||||
def __init__(self):
|
||||
GObject.GObject.__init__(self)
|
||||
self.track = None
|
||||
|
||||
@GObject.Signal(arg_types=(FakeTrack,FakeTrack))
|
||||
def track_changed(self, prev, new): pass
|
||||
|
||||
|
||||
class TestAudioTrackTitle(unittest.TestCase):
|
||||
def test_track_title(self):
|
||||
fake = FakePlayer()
|
||||
title = nowplaying.TrackTitle(fake)
|
||||
|
||||
self.assertIsInstance(title, Gtk.Label)
|
||||
self.assertTrue(title.has_css_class("title"))
|
||||
self.assertEqual(title.get_text(), "Emmental")
|
||||
|
||||
fake.emit("track-changed", None, FakeTrack())
|
||||
self.assertEqual(title.get_text(), "Test Title")
|
||||
|
||||
|
||||
class TestAudioTrackArtist(unittest.TestCase):
|
||||
def test_track_artist(self):
|
||||
fake = FakePlayer()
|
||||
artist = nowplaying.TrackArtist(fake)
|
||||
|
||||
self.assertIsInstance(artist, Gtk.Label)
|
||||
self.assertTrue(artist.has_css_class("subtitle"))
|
||||
self.assertEqual(artist.get_text(), "The Cheesy Music Player")
|
||||
|
||||
fake.emit("track-changed", None, FakeTrack())
|
||||
self.assertEqual(artist.get_text(), "by Test Artist")
|
||||
|
||||
|
||||
class TestNowPlaying(unittest.TestCase):
|
||||
def test_now_playing(self):
|
||||
fake = FakePlayer()
|
||||
now = nowplaying.NowPlaying(fake)
|
||||
child = now.box.get_first_child()
|
||||
viewport = now.get_child()
|
||||
|
||||
self.assertIsInstance(now, Gtk.ScrolledWindow)
|
||||
self.assertIsInstance(now.box, Gtk.Box)
|
||||
self.assertIsInstance(child, nowplaying.TrackTitle)
|
||||
self.assertIsInstance(child.get_next_sibling(), nowplaying.TrackArtist)
|
||||
|
||||
self.assertEqual(viewport.get_child(), now.box)
|
||||
self.assertEqual(now.get_valign(), Gtk.Align.CENTER)
|
||||
self.assertEqual(now.get_policy(), (Gtk.PolicyType.AUTOMATIC,
|
||||
Gtk.PolicyType.NEVER))
|
||||
|
||||
self.assertTrue(now.get_hexpand())
|
|
@ -1,146 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import db
|
||||
import lib
|
||||
import pathlib
|
||||
import scanner
|
||||
import unittest
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Gst
|
||||
from . import bass
|
||||
from . import player
|
||||
from . import scale
|
||||
|
||||
main_context = GLib.main_context_default()
|
||||
test_album = pathlib.Path("./data/Test Album/")
|
||||
test_track = test_album / "01 - Test Track.ogg"
|
||||
test_uri = test_track.absolute().as_uri()
|
||||
|
||||
|
||||
class TestPlayer(unittest.TestCase):
|
||||
def setUpClass():
|
||||
db.reset()
|
||||
lib = db.library.Table.find(test_album)
|
||||
scanner.Queue.push(scanner.task.DirectoryTask(lib, test_album))
|
||||
while scanner.Queue.run() == GLib.SOURCE_CONTINUE: pass
|
||||
|
||||
def setUp(self):
|
||||
self.changed = None
|
||||
lib.settings.reset()
|
||||
self.library = db.library.Table.lookup(test_album)
|
||||
self.track = db.track.Table.lookup(test_track)
|
||||
|
||||
def on_track_changed(self, player, prev, new):
|
||||
self.changed = (prev, new)
|
||||
|
||||
def on_playlist_changed(self, player, prev, new):
|
||||
self.changed = (prev, new)
|
||||
|
||||
def test_init(self):
|
||||
play = player.Player()
|
||||
self.assertIsInstance(play, bass.BassPlayer)
|
||||
self.assertIsInstance(play.bus, Gst.Bus)
|
||||
self.assertIsInstance(play.Autopause, scale.AutoPauseScale)
|
||||
self.assertIsNone(play.track)
|
||||
|
||||
self.assertEqual(play.playlist, db.user.Table.find("Collection"))
|
||||
self.assertEqual(lib.settings.get_int("audio.plstateid"),
|
||||
db.user.Table.find("Collection").plstateid)
|
||||
|
||||
def test_set_playlist(self):
|
||||
collection = db.user.Table.find("Collection")
|
||||
plist = db.user.Table.find("Test Playlist")
|
||||
play = player.Player()
|
||||
play.connect("playlist-changed", self.on_playlist_changed)
|
||||
|
||||
play.set_playlist(plist)
|
||||
self.assertEqual(play.playlist, plist)
|
||||
self.assertEqual(self.changed, (collection, plist))
|
||||
self.assertEqual(lib.settings.get_int("audio.plstateid"), plist.plstateid)
|
||||
|
||||
self.changed = None
|
||||
play.set_playlist(plist)
|
||||
self.assertIsNone(self.changed)
|
||||
|
||||
play2 = player.Player()
|
||||
self.assertEqual(play2.playlist, plist)
|
||||
|
||||
play2.set_playlist(None)
|
||||
self.assertEqual(play2.playlist, collection)
|
||||
|
||||
def test_set_playlist_reset(self):
|
||||
plist = db.user.Table.find("Test Playlist")
|
||||
plist.add_track(db.make_fake_track(1, 1, "Test 1", "/a/b/c/1.ogg"))
|
||||
plist.current = 0
|
||||
|
||||
play = player.Player()
|
||||
play.set_playlist(plist)
|
||||
self.assertEqual(plist.current, -1)
|
||||
|
||||
def test_change_track(self):
|
||||
play = player.Player()
|
||||
play.connect("track-changed", self.on_track_changed)
|
||||
|
||||
self.assertEqual(play.get_property("uri"), None)
|
||||
|
||||
play.change_track(self.track, reset=True)
|
||||
self.assertEqual(play.track, self.track)
|
||||
self.assertEqual(lib.settings.get_int("audio.trackid"), self.track.rowid)
|
||||
self.assertEqual(self.changed, (None, self.track) )
|
||||
|
||||
db.sql.execute("DELETE FROM temp_playlist_map")
|
||||
play2 = player.Player()
|
||||
self.assertEqual(play2.track, self.track)
|
||||
self.assertEqual(db.user.Table.find("Previous").get_track(0), self.track)
|
||||
|
||||
def test_play_track(self):
|
||||
play = player.Player()
|
||||
play.play_track(self.track)
|
||||
self.assertEqual(play.track, self.track)
|
||||
self.assertTrue(play.playing)
|
||||
play.pause()
|
||||
|
||||
def test_play_pause(self):
|
||||
play = player.Player()
|
||||
|
||||
play.play_track(self.track)
|
||||
self.assertEqual(play.track, self.track)
|
||||
|
||||
play.pause()
|
||||
self.assertFalse(play.playing)
|
||||
|
||||
play.play()
|
||||
self.assertTrue(play.playing)
|
||||
|
||||
play.playpause()
|
||||
self.assertFalse(play.playing)
|
||||
|
||||
play.playpause()
|
||||
self.assertTrue(play.playing)
|
||||
|
||||
play.pause()
|
||||
self.assertFalse(play.playing)
|
||||
|
||||
def test_next_previous(self):
|
||||
collection = db.user.Table.find("Collection")
|
||||
play = player.Player()
|
||||
play.set_playlist(collection)
|
||||
play.connect("track-changed", self.on_track_changed)
|
||||
|
||||
track0 = collection.get_track(0)
|
||||
track1 = collection.get_track(1)
|
||||
|
||||
play.next()
|
||||
self.assertEqual(play.track, track0)
|
||||
self.assertEqual(self.changed, (None, track0))
|
||||
|
||||
play.next()
|
||||
self.assertEqual(play.track, track1)
|
||||
self.assertEqual(self.changed, (track0, track1))
|
||||
|
||||
play.previous()
|
||||
self.assertEqual(play.track, track0)
|
||||
self.assertEqual(self.changed, (track1, track0))
|
||||
|
||||
play.next()
|
||||
self.assertEqual(play.track, track1)
|
||||
self.assertEqual(self.changed, (track0, track1))
|
|
@ -1,38 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import unittest
|
||||
from gi.repository import Gst
|
||||
from . import replaygain
|
||||
|
||||
class TestReplayGainSink(unittest.TestCase):
|
||||
def test_replay_gain_sink_init(self):
|
||||
rgsink = replaygain.ReplayGainSink()
|
||||
|
||||
self.assertIsInstance(rgsink, Gst.Bin)
|
||||
self.assertIsInstance(rgsink.selector, Gst.Element)
|
||||
self.assertIsInstance(rgsink.funnel, Gst.Element)
|
||||
self.assertIsInstance(rgsink.audiosink, Gst.Element)
|
||||
self.assertIsInstance(rgsink.rgvolume, Gst.Element)
|
||||
self.assertIsInstance(rgsink.rglimiter, Gst.Element)
|
||||
|
||||
self.assertIsInstance(rgsink.shortcut, Gst.Pad)
|
||||
self.assertIsInstance(rgsink.replaygain, Gst.Pad)
|
||||
self.assertIsInstance(rgsink.get_static_pad("sink"), Gst.GhostPad)
|
||||
|
||||
def test_replay_gain_sink_mode(self):
|
||||
rgsink = replaygain.ReplayGainSink()
|
||||
self.assertEqual(rgsink.get_property("mode"), "disabled")
|
||||
|
||||
rgsink.set_property("mode", "album")
|
||||
self.assertEqual(rgsink.get_property("mode"), "album")
|
||||
self.assertEqual(rgsink.selector.get_property("active-pad"),
|
||||
rgsink.replaygain)
|
||||
|
||||
rgsink.set_property("mode", "track")
|
||||
self.assertEqual(rgsink.get_property("mode"), "track")
|
||||
self.assertEqual(rgsink.selector.get_property("active-pad"),
|
||||
rgsink.replaygain)
|
||||
|
||||
rgsink.set_property("mode", "disabled")
|
||||
self.assertEqual(rgsink.get_property("mode"), "disabled")
|
||||
self.assertEqual(rgsink.selector.get_property("active-pad"),
|
||||
rgsink.shortcut)
|
|
@ -1,256 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import unittest
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Gst
|
||||
from . import scale
|
||||
|
||||
class FakePlayer(GObject.GObject):
|
||||
def __init__(self, position, duration, volume):
|
||||
GObject.GObject.__init__(self)
|
||||
self.pos = position
|
||||
self.dur = duration
|
||||
self.vol = volume
|
||||
|
||||
@GObject.Property
|
||||
def position(self): return self.pos
|
||||
@position.setter
|
||||
def position(self, newval):
|
||||
self.pos = newval
|
||||
self.emit("position-changed")
|
||||
|
||||
@GObject.Signal
|
||||
def position_changed(self): pass
|
||||
|
||||
@GObject.Property
|
||||
def duration(self): return self.dur
|
||||
@GObject.Signal
|
||||
def duration_changed(self): pass
|
||||
|
||||
def seek(self, value): self.seek_val = value
|
||||
|
||||
@GObject.Property
|
||||
def volume(self): return self.vol
|
||||
@volume.setter
|
||||
def volume(self, volume): self.vol = volume
|
||||
|
||||
|
||||
class TestScalePlus(unittest.TestCase):
|
||||
def on_change_value(self, scale, scroll, value):
|
||||
self.change_value = (scroll, value)
|
||||
|
||||
def test_scale_plus_init(self):
|
||||
splus = scale.ScalePlus(1, 10, 2, 5)
|
||||
adj = splus.get_adjustment()
|
||||
|
||||
self.assertIsInstance(splus, Gtk.Scale)
|
||||
self.assertTrue(splus.get_draw_value())
|
||||
self.assertTrue(splus.get_hexpand())
|
||||
self.assertEqual(splus.get_value_pos(), Gtk.PositionType.TOP)
|
||||
|
||||
self.assertEqual(adj.get_value(), 1)
|
||||
self.assertEqual(adj.get_lower(), 1)
|
||||
self.assertEqual(adj.get_upper(), 10)
|
||||
self.assertEqual(adj.get_step_increment(), 2)
|
||||
self.assertEqual(adj.get_page_increment(), 5)
|
||||
|
||||
def test_scale_plus_decrement(self):
|
||||
splus = scale.ScalePlus(1, 10, 2, 5)
|
||||
splus.set_value(10)
|
||||
splus.connect("change-value", self.on_change_value)
|
||||
|
||||
for i in [ 8, 6, 4, 2, 1 ]:
|
||||
self.assertEqual(splus.decrement(), i)
|
||||
self.assertEqual(splus.format_value(splus, i), str(i))
|
||||
self.assertEqual(self.change_value,
|
||||
(Gtk.ScrollType.STEP_BACKWARD, i))
|
||||
|
||||
self.change_value = None
|
||||
self.assertIsNone(splus.decrement())
|
||||
self.assertIsNone(self.change_value)
|
||||
|
||||
def test_scale_plus_increment(self):
|
||||
splus = scale.ScalePlus(1, 10, 2, 5)
|
||||
splus.connect("change-value", self.on_change_value)
|
||||
|
||||
for i in [ 3, 5, 7, 9, 10 ]:
|
||||
self.assertEqual(splus.increment(), i)
|
||||
self.assertEqual(splus.format_value(splus, i), str(i))
|
||||
self.assertEqual(self.change_value,
|
||||
(Gtk.ScrollType.STEP_FORWARD, i))
|
||||
|
||||
self.change_value = None
|
||||
self.assertIsNone(splus.increment())
|
||||
self.assertIsNone(self.change_value)
|
||||
|
||||
|
||||
class TestSeekScale(unittest.TestCase):
|
||||
def test_seek_scale_init(self):
|
||||
fake = FakePlayer(0, 5 * Gst.SECOND, 1)
|
||||
seek = scale.SeekScale(fake)
|
||||
adj = seek.get_adjustment()
|
||||
|
||||
self.assertIsInstance(seek, scale.ScalePlus)
|
||||
|
||||
self.assertEqual(seek.get_size_request(), (200, -1))
|
||||
self.assertEqual(seek.player, fake)
|
||||
self.assertEqual(adj.get_value(), 0)
|
||||
self.assertEqual(adj.get_lower(), 0)
|
||||
self.assertEqual(adj.get_upper(), 5 * Gst.SECOND)
|
||||
self.assertEqual(adj.get_step_increment(), 5 * Gst.SECOND)
|
||||
self.assertEqual(adj.get_page_increment(), 30 * Gst.SECOND)
|
||||
|
||||
def test_seek_scale_duration(self):
|
||||
fake = FakePlayer(0, 2 * Gst.SECOND, 1)
|
||||
seek = scale.SeekScale(fake)
|
||||
adj = seek.get_adjustment()
|
||||
|
||||
self.assertEqual(adj.get_upper(), 2 * Gst.SECOND)
|
||||
fake.dur = 3 * Gst.SECOND
|
||||
fake.emit("duration-changed")
|
||||
self.assertEqual(adj.get_upper(), 3 * Gst.SECOND)
|
||||
|
||||
def test_seek_scale_position(self):
|
||||
fake = FakePlayer(0, 15 * Gst.SECOND, 1)
|
||||
seek = scale.SeekScale(fake)
|
||||
adj = seek.get_adjustment()
|
||||
|
||||
fake.position = 3 * Gst.SECOND
|
||||
self.assertEqual(seek.player, fake)
|
||||
self.assertEqual(adj.get_value(), 3 * Gst.SECOND)
|
||||
self.assertEqual(adj.get_lower(), 0)
|
||||
|
||||
def test_seek_scale_values(self):
|
||||
fake = FakePlayer(0, 15 * Gst.SECOND, 1)
|
||||
seek = scale.SeekScale(fake)
|
||||
|
||||
seek.increment()
|
||||
self.assertEqual(fake.pos, 5 * Gst.SECOND)
|
||||
self.assertEqual(seek.format_value(seek, 5 * Gst.SECOND),
|
||||
"00:05 / 00:10")
|
||||
|
||||
seek.decrement()
|
||||
self.assertEqual(fake.pos, 0)
|
||||
|
||||
|
||||
class TestAutoPauseScale(unittest.TestCase):
|
||||
def test_autopause_scale_init(self):
|
||||
pause = scale.AutoPauseScale()
|
||||
adj = pause.get_adjustment()
|
||||
|
||||
self.assertIsInstance(pause, scale.ScalePlus)
|
||||
self.assertEqual(pause.get_digits(), 0)
|
||||
self.assertEqual(adj.get_value(), -1)
|
||||
self.assertEqual(adj.get_lower(), -1)
|
||||
self.assertEqual(adj.get_upper(), 99)
|
||||
self.assertEqual(adj.get_step_increment(), 1)
|
||||
self.assertEqual(adj.get_page_increment(), 5)
|
||||
self.assertTrue(pause.keep_playing)
|
||||
|
||||
def test_autopause_scale_values(self):
|
||||
pause = scale.AutoPauseScale()
|
||||
self.assertEqual(pause.format_value(pause, -1), "Keep Playing")
|
||||
self.assertEqual(pause.format_value(pause, 0), "This Track")
|
||||
self.assertEqual(pause.format_value(pause, 1), "Next Track")
|
||||
self.assertEqual(pause.format_value(pause, 2), "2 Tracks")
|
||||
|
||||
def test_keep_playing(self):
|
||||
pause = scale.AutoPauseScale()
|
||||
pause.set_value(2)
|
||||
|
||||
pause.decrement()
|
||||
self.assertEqual(pause.get_value(), 1)
|
||||
self.assertFalse(pause.about_to_pause())
|
||||
self.assertTrue(pause.keep_playing)
|
||||
|
||||
pause.decrement()
|
||||
self.assertEqual(pause.get_value(), 0)
|
||||
self.assertTrue(pause.about_to_pause())
|
||||
self.assertTrue(pause.keep_playing)
|
||||
|
||||
pause.decrement()
|
||||
self.assertEqual(pause.get_value(), -1)
|
||||
self.assertFalse(pause.about_to_pause())
|
||||
self.assertFalse(pause.keep_playing)
|
||||
|
||||
pause.decrement()
|
||||
self.assertEqual(pause.get_value(), -1)
|
||||
self.assertFalse(pause.about_to_pause())
|
||||
self.assertTrue(pause.keep_playing)
|
||||
|
||||
|
||||
class TestVolumeScale(unittest.TestCase):
|
||||
def test_volume_scale_init(self):
|
||||
fake = FakePlayer(0, 5 * Gst.SECOND, 1.0)
|
||||
volume = scale.VolumeScale(fake)
|
||||
adj = volume.get_adjustment()
|
||||
|
||||
self.assertIsInstance(volume, scale.ScalePlus)
|
||||
self.assertEqual(volume.player, fake)
|
||||
self.assertEqual(adj.get_value(), 1.0)
|
||||
self.assertEqual(adj.get_lower(), 0.0)
|
||||
self.assertEqual(adj.get_upper(), 1.0)
|
||||
self.assertEqual(adj.get_step_increment(), 0.05)
|
||||
self.assertEqual(adj.get_page_increment(), 0.25)
|
||||
|
||||
fake.volume = 0.5
|
||||
vol2 = scale.VolumeScale(fake)
|
||||
self.assertEqual(vol2.get_value(), 0.5)
|
||||
|
||||
def test_volume_scale_values(self):
|
||||
fake = FakePlayer(0, 15 * Gst.SECOND, 0.5)
|
||||
volume = scale.VolumeScale(fake)
|
||||
|
||||
volume.increment()
|
||||
self.assertEqual(fake.volume, 0.55)
|
||||
self.assertEqual(volume.format_value(volume, 0.55), "55%")
|
||||
|
||||
volume.decrement()
|
||||
self.assertEqual(fake.volume, 0.50)
|
||||
self.assertEqual(volume.format_value(volume, 0.50), "50%")
|
||||
|
||||
|
||||
class TestScaleButton(unittest.TestCase):
|
||||
def test_scale_button(self):
|
||||
splus = scale.ScalePlus(0, 5, 1, 1)
|
||||
sbutt = scale.ScaleButton(splus, "missing-icon")
|
||||
|
||||
self.assertIsInstance(sbutt, Gtk.Button)
|
||||
self.assertEqual(sbutt.get_valign(), Gtk.Align.END)
|
||||
self.assertTrue(sbutt.has_css_class("normal-icons"))
|
||||
self.assertTrue(sbutt.has_css_class("flat"))
|
||||
|
||||
|
||||
class TestDecrementButton(unittest.TestCase):
|
||||
def test_decrement_button(self):
|
||||
splus = scale.ScalePlus(0, 5, 1, 1)
|
||||
dec = scale.DecrementButton(splus)
|
||||
splus.set_value(1)
|
||||
|
||||
self.assertIsInstance(dec, scale.ScaleButton)
|
||||
self.assertEqual(dec.get_icon_name(), "list-remove-symbolic")
|
||||
dec.emit("clicked")
|
||||
self.assertEqual(splus.get_value(), 0)
|
||||
|
||||
|
||||
class TestIncrementButton(unittest.TestCase):
|
||||
def test_increment_button(self):
|
||||
splus = scale.ScalePlus(0, 5, 1, 1)
|
||||
inc = scale.IncrementButton(splus)
|
||||
|
||||
self.assertIsInstance(inc, scale.ScaleButton)
|
||||
self.assertEqual(inc.get_icon_name(), "list-add-symbolic")
|
||||
inc.emit("clicked")
|
||||
self.assertEqual(splus.get_value(), 1)
|
||||
|
||||
|
||||
class TestScaleButtonBox(unittest.TestCase):
|
||||
def test_scale_button_box(self):
|
||||
splus = scale.ScalePlus(0, 5, 1, 1)
|
||||
sbox = scale.ScaleButtonBox(splus)
|
||||
|
||||
self.assertIsInstance(sbox, Gtk.Box)
|
||||
self.assertEqual(sbox.get_orientation(), Gtk.Orientation.HORIZONTAL)
|
||||
self.assertIsInstance(sbox.get_first_child(), scale.DecrementButton)
|
||||
self.assertIsInstance(sbox.get_last_child(), scale.IncrementButton)
|
||||
self.assertIsInstance(splus.get_next_sibling(), scale.IncrementButton)
|
2
aur
2
aur
|
@ -1 +1 @@
|
|||
Subproject commit 9110968ced4b17efab9430a3b0cb5ed44f040399
|
||||
Subproject commit 543220f35990f5b5a69eac5a6c06e69eae6ffb82
|
|
@ -0,0 +1,12 @@
|
|||
[Desktop Entry]
|
||||
Type=Application
|
||||
Version=1.5
|
||||
Name=Emmental
|
||||
GenericName=Music Player
|
||||
Comment=The Cheesy Music Player
|
||||
DBusActivatable=false
|
||||
Terminal=false
|
||||
MimeType=application/musepack;application/ogg;application/x-ape;application/x-flac;application/x-id3;application/x-musepack;application/x-ogg;application/x-ogm-audio;audio/aac;audio/ape;audio/flac;audio/mp;audio/mp3;audio/mp4;audio/mpc;audio/mpeg;audio/mpeg3;audio/mpegurl;audio/musepack;audio/ogg;audio/vnd.rn-realaudio;audio/vorbis;audio/x-ape;audio/x-flac;audio/x-it;audio/x-m4a;audio/x-mod;audio/x-mp;audio/x-mp3;audio/x-mpc;audio/x-mpeg;audio/x-mpeg-3;audio/x-mpegurl;audio/x-ms-wma;audio/x-musepack;audio/x-ogg;audio/x-oggflac;audio/x-pn-realaudio;audio/x-s3m;audio/x-scpls;audio/x-speex;audio/x-stm;audio/x-vorbis;audio/x-vorbis+ogg;audio/x-wav;audio/x-xm;
|
||||
Categories=AudioVideo;Audio;Music;Player;GTK;GNOME;
|
||||
SingleMainWindow=true
|
||||
StartupWMClass=emmental.py
|
|
@ -1,19 +0,0 @@
|
|||
# Maintainer: Anna Schumaker <anna@nowheycreamery.com>
|
||||
pkgname=emmental
|
||||
pkgver={MAJOR}.{MINOR}
|
||||
pkgrel=1
|
||||
pkgdesc='The cheesy music player'
|
||||
url='https://www.git.nowheycreamery.com/anna/emmental'
|
||||
arch=('any')
|
||||
license=('GPL3')
|
||||
depends=('python' 'python-gobject' 'python-mutagen' 'python-pyxdg' 'gtk4' 'gstreamer' 'gst-plugins-base')
|
||||
optdepends=('gst-plugins-good' 'gst-plugins-bad' 'gst-plugins-ugly')
|
||||
source=("https://git.nowheycreamery.com/anna/emmental/archive/emmental-$pkgver.tar.gz")
|
||||
sha256sums=({SHA256SUM})
|
||||
|
||||
package() {
|
||||
cd "$pkgname"
|
||||
make PREFIX="$pkgdir/usr" install
|
||||
sed -i "s|$pkgdir||" $pkgdir/usr/bin/emmental
|
||||
sed -i "s|$pkgdir||" $pkgdir/usr/share/applications/emmental.desktop
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
python -O {EMMENTAL_LIB}/emmental.py $*
|
|
@ -1,10 +0,0 @@
|
|||
[Desktop Entry]
|
||||
Type=Application
|
||||
Version=1.5
|
||||
Name=Emmental
|
||||
GenericName=Music Player
|
||||
Comment=Listen to your music
|
||||
Exec={EMMENTAL_BIN}/emmental
|
||||
Icon=emmental
|
||||
Terminal=false
|
||||
Categories=AudioVideo;Audio;
|
Binary file not shown.
Before Width: | Height: | Size: 28 KiB |
|
@ -1,60 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="512"
|
||||
height="512"
|
||||
viewBox="0 0 135.46666 135.46666"
|
||||
version="1.1"
|
||||
id="svg3294"
|
||||
inkscape:version="1.1 (c4e8f9ed74, 2021-05-24)"
|
||||
sodipodi:docname="emmental-favorites.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview3296"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:document-units="px"
|
||||
showgrid="false"
|
||||
width="512mm"
|
||||
units="px"
|
||||
inkscape:zoom="1"
|
||||
inkscape:cx="416.5"
|
||||
inkscape:cy="263.5"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1005"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="49"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer2"
|
||||
showborder="true"
|
||||
borderlayer="false" />
|
||||
<defs
|
||||
id="defs3291">
|
||||
<linearGradient
|
||||
id="linearGradient3404"
|
||||
inkscape:swatch="solid">
|
||||
<stop
|
||||
style="stop-color:#ff0000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop3402" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer2"
|
||||
inkscape:label="Layer 2"
|
||||
style="display:inline">
|
||||
<path
|
||||
id="rect928"
|
||||
style="mix-blend-mode:multiply;fill:#ff0000;fill-opacity:1;fill-rule:nonzero;stroke:#004600;stroke-width:1.13263;stroke-dasharray:0, 12.4589;stroke-opacity:1;paint-order:markers fill stroke"
|
||||
d="M 8.3491862,17.674821 A 48.298388,38.079963 55.167577 0 0 0.47527528,35.642866 48.298388,38.079963 55.167577 0 0 15.646486,80.346627 l 13.02172,14.124094 26.043443,28.248199 c 7.214034,7.82476 18.829408,7.82476 26.043442,0 L 106.79853,94.470721 119.82026,80.346627 A 38.079963,48.298388 34.832423 0 0 126.33111,16.788177 38.079963,48.298388 34.832423 0 0 67.73337,23.850231 48.298388,38.079963 55.167577 0 0 26.518717,7.3946768 48.298388,38.079963 55.167577 0 0 8.3491862,17.674821 Z" />
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 2.2 KiB |
|
@ -1,66 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import datetime
|
||||
import lib
|
||||
import pathlib
|
||||
|
||||
def new_db():
|
||||
from . import sql
|
||||
try:
|
||||
return sql.execute("SELECT COUNT(*) from tracks").fetchone()[0] == 0
|
||||
except:
|
||||
return True
|
||||
|
||||
|
||||
from . import artist
|
||||
from . import album
|
||||
from . import disc
|
||||
from . import genre
|
||||
from . import decade
|
||||
from . import year
|
||||
from . import library
|
||||
from . import track
|
||||
from . import state
|
||||
from . import user
|
||||
|
||||
from . import sql
|
||||
def _search_table(table, key, search):
|
||||
if row := sql.execute(f"SELECT {key} FROM {table} "
|
||||
f"WHERE plstateid=?", [ search ]).fetchone():
|
||||
return row[0]
|
||||
|
||||
def find_playlist(plstateid):
|
||||
if playlistid := _search_table("playlists", "playlistid", plstateid):
|
||||
return user.Table.get(playlistid)
|
||||
if artistid := _search_table("artists", "artistid", plstateid):
|
||||
return artist.Table.get(artistid)
|
||||
if albumid := _search_table("albums", "albumid", plstateid):
|
||||
return album.Table.get(albumid)
|
||||
if discid := _search_table("discs", "discid", plstateid):
|
||||
return disc.Table.get(discid)
|
||||
if genreid := _search_table("genres", "genreid", plstateid):
|
||||
return genre.Table.get(genreid)
|
||||
if decadeid := _search_table("decades", "decadeid", plstateid):
|
||||
return decade.Table.get(decadeid)
|
||||
if yearid := _search_table("years", "yearid", plstateid):
|
||||
return year.Table.get(yearid)
|
||||
if libraryid := _search_table("libraries", "libraryid", plstateid):
|
||||
return library.Table.get(libraryid)
|
||||
return None
|
||||
|
||||
def make_fake_track(trackno, length, title, path, lib="/a/b/c", art="Test Artist",
|
||||
alb="Test Album", disk=1, subtitle=None, yeer=2021, mnth=3, dy=18):
|
||||
lib = library.Table.find(pathlib.Path(lib))
|
||||
art = artist.Table.find(art, art)
|
||||
alb = art.find_album(alb, datetime.date(yeer, mnth, dy))
|
||||
disk = alb.find_disc(disk, subtitle)
|
||||
dec = decade.Table.find((yeer // 10) * 10)
|
||||
yeer = dec.find_year(yeer)
|
||||
return track.Table.insert(lib, art, alb, disk, dec, yeer, trackno,
|
||||
length, title, pathlib.Path(path))
|
||||
|
||||
def reset():
|
||||
mods = [ track, state, user, artist, album,
|
||||
disc, genre, decade, year, library ]
|
||||
for mod in mods: mod.Table.reset()
|
||||
|
||||
if lib.version.TESTING: reset()
|
64
db/album.py
64
db/album.py
|
@ -1,64 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
#
|
||||
# Table: albums
|
||||
# +---------+----------+-----------+------+------+
|
||||
# | albumid | artistid | plstateid | name | sort |
|
||||
# +---------+----------+-----------+------+------+
|
||||
from gi.repository import GObject
|
||||
from . import disc
|
||||
from . import playlist
|
||||
from . import sql
|
||||
|
||||
class Album(playlist.ParentPlaylist):
|
||||
def __init__(self, row):
|
||||
playlist.ParentPlaylist.__init__(self, row, "media-optical-cd-audio")
|
||||
self._name = row["name"]
|
||||
self._release = row["release"]
|
||||
|
||||
@GObject.Property
|
||||
def name(self): return self._name
|
||||
|
||||
@GObject.Property
|
||||
def release(self): return self._release
|
||||
|
||||
def delete(self): Table.delete(self)
|
||||
|
||||
def find_disc(self, number, subtitle):
|
||||
return self.find_child(number, subtitle)
|
||||
|
||||
def get_child_table(self): return disc.Table
|
||||
def lookup_child(self, number, subtitle):
|
||||
return disc.Table.lookup(self, number)
|
||||
|
||||
|
||||
class AlbumTable(playlist.ChildModel):
|
||||
def __init__(self):
|
||||
playlist.ChildModel.__init__(self, "albums", "artistid", "sort")
|
||||
|
||||
def do_create(self):
|
||||
sql.execute("CREATE TABLE IF NOT EXISTS albums "
|
||||
"(albumid INTEGER PRIMARY KEY, "
|
||||
" artistid INTEGER, "
|
||||
" plstateid INTEGER NOT NULL, "
|
||||
" release DATE NOT NULL, "
|
||||
" name TEXT, "
|
||||
" sort TEXT, "
|
||||
" FOREIGN KEY(artistid) REFERENCES artists(artistid), "
|
||||
" FOREIGN KEY(plstateid) REFERENCES playlist_states(plstateid), "
|
||||
" UNIQUE(artistid, release, name))")
|
||||
|
||||
def do_factory(self, row):
|
||||
return Album(row)
|
||||
|
||||
def do_insert(self, plstate, artist, name, release):
|
||||
return sql.execute("INSERT INTO albums (artistid, plstateid, release, name, sort) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
[ artist.rowid, plstate.rowid, release, name, name.casefold() ])
|
||||
|
||||
def do_lookup(self, artist, name, release):
|
||||
return sql.execute("SELECT * FROM albums "
|
||||
"WHERE (artistid=? AND name=? AND release=?)",
|
||||
[ artist.rowid, name, release ])
|
||||
|
||||
|
||||
Table = AlbumTable()
|
54
db/artist.py
54
db/artist.py
|
@ -1,54 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
#
|
||||
# Table: artists
|
||||
# +----------+-----------+------+------+
|
||||
# | artistid | plstateid | name | sort |
|
||||
# +----------+-----------+------+------+
|
||||
from gi.repository import GObject
|
||||
from . import album
|
||||
from . import playlist
|
||||
from . import sql
|
||||
|
||||
class Artist(playlist.ParentPlaylist):
|
||||
def __init__(self, row):
|
||||
playlist.ParentPlaylist.__init__(self, row, "avatar-default-symbolic")
|
||||
self._name = row["name"]
|
||||
|
||||
@GObject.Property
|
||||
def name(self): return self._name
|
||||
|
||||
def delete(self): Table.delete(self)
|
||||
|
||||
def find_album(self, name, release): return self.find_child(name, release)
|
||||
|
||||
def get_child_table(self): return album.Table
|
||||
|
||||
|
||||
class ArtistTable(playlist.Model):
|
||||
def __init__(self):
|
||||
playlist.Model.__init__(self, "artists", "sort")
|
||||
|
||||
def do_create(self):
|
||||
sql.execute("CREATE TABLE IF NOT EXISTS artists "
|
||||
"(artistid INTEGER PRIMARY KEY, "
|
||||
" plstateid INTEGER NOT NULL, "
|
||||
" name TEXT UNIQUE, "
|
||||
" sort TEXT, "
|
||||
" FOREIGN KEY(plstateid) REFERENCES playlist_states(plstateid))")
|
||||
|
||||
def do_factory(self, row):
|
||||
return Artist(row)
|
||||
|
||||
def do_insert(self, plstate, name, sort):
|
||||
return sql.execute("INSERT INTO artists (plstateid, name, sort) "
|
||||
"VALUES (?, ?, ?)",
|
||||
[ plstate.rowid, name, sort.casefold() ])
|
||||
|
||||
def do_lookup(self, name):
|
||||
return sql.execute("SELECT * FROM artists WHERE name=?", [ name ])
|
||||
|
||||
def find(self, name, sort):
|
||||
return res if (res := self.lookup(name)) else self.insert(name, sort)
|
||||
|
||||
|
||||
Table = ArtistTable()
|
51
db/decade.py
51
db/decade.py
|
@ -1,51 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
#
|
||||
# Table: decades
|
||||
# +----------+-----------+--------+
|
||||
# | decadeid | plstateid | decade |
|
||||
# +----------+-----------+--------+
|
||||
from gi.repository import GObject
|
||||
from . import playlist
|
||||
from . import sql
|
||||
from . import year
|
||||
|
||||
class Decade(playlist.ParentPlaylist):
|
||||
def __init__(self, row):
|
||||
playlist.ParentPlaylist.__init__(self, row, "x-office-calendar")
|
||||
self._decade = row["decade"]
|
||||
|
||||
@GObject.Property
|
||||
def decade(self): return self._decade
|
||||
|
||||
@GObject.Property
|
||||
def name(self): return f"{self._decade}s"
|
||||
|
||||
def delete(self): Table.delete(self)
|
||||
def find_year(self, yr): return self.find_child(yr)
|
||||
def get_child_table(self): return year.Table
|
||||
def lookup_child(self, yr): return year.Table.lookup(yr)
|
||||
|
||||
|
||||
class DecadeTable(playlist.Model):
|
||||
def __init__(self):
|
||||
playlist.Model.__init__(self, "decades", "decade")
|
||||
|
||||
def do_create(self):
|
||||
sql.execute("CREATE TABLE IF NOT EXISTS decades "
|
||||
"(decadeid INTEGER PRIMARY KEY, "
|
||||
" plstateid INTEGER NOT NULL, "
|
||||
" decade INTEGER UNIQUE, "
|
||||
" FOREIGN KEY(plstateid) REFERENCES playlist_states(plstateid))")
|
||||
|
||||
def do_factory(self, row):
|
||||
return Decade(row)
|
||||
|
||||
def do_insert(self, plstate, decade):
|
||||
return sql.execute("INSERT INTO decades (plstateid,decade) "
|
||||
"VALUES (?, ?)", [ plstate.rowid, decade ])
|
||||
|
||||
def do_lookup(self, decade):
|
||||
return sql.execute("SELECT * FROM decades WHERE decade=?", [ decade ])
|
||||
|
||||
|
||||
Table = DecadeTable()
|
61
db/disc.py
61
db/disc.py
|
@ -1,61 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
#
|
||||
# Table: discs
|
||||
# +--------+---------+--------+----------+
|
||||
# | discid | albumid | number | subtitle |
|
||||
# +--------+---------+--------+----------+
|
||||
from gi.repository import GObject
|
||||
from . import playlist
|
||||
from . import sql
|
||||
|
||||
class Disc(playlist.Playlist):
|
||||
def __init__(self, row):
|
||||
playlist.Playlist.__init__(self, row, "media-optical")
|
||||
self._number = row["number"]
|
||||
self._subtitle = row["subtitle"]
|
||||
|
||||
def delete(self): Table.delete(self)
|
||||
|
||||
@GObject.Property
|
||||
def name(self):
|
||||
if self._subtitle:
|
||||
return f"{self._number}: {self._subtitle}"
|
||||
return f"Disc {self._number}"
|
||||
|
||||
@GObject.Property
|
||||
def number(self): return self._number
|
||||
|
||||
@GObject.Property
|
||||
def subtitle(self):
|
||||
return self._subtitle if self._subtitle else ""
|
||||
|
||||
|
||||
class DiscTable(playlist.ChildModel):
|
||||
def __init__(self):
|
||||
playlist.ChildModel.__init__(self, "discs", "albumid", "number")
|
||||
|
||||
def do_create(self):
|
||||
sql.execute("CREATE TABLE IF NOT EXISTS discs "
|
||||
"(discid INTEGER PRIMARY KEY, "
|
||||
" albumid INTEGER, "
|
||||
" plstateid INTEGER NOT NULL, "
|
||||
" number INTEGER, "
|
||||
" subtitle TEXT, "
|
||||
" FOREIGN KEY(albumid) REFERENCES albums(albumid), "
|
||||
" FOREIGN KEY(plstateid) REFERENCES playlist_states(plstateid), "
|
||||
" UNIQUE(albumid, number))")
|
||||
|
||||
def do_factory(self, row):
|
||||
return Disc(row)
|
||||
|
||||
def do_insert(self, plstate, album, number, subtitle):
|
||||
return sql.execute("INSERT INTO discs (albumid, plstateid, number, subtitle) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
[ album.rowid, plstate.rowid, number, subtitle ])
|
||||
|
||||
def do_lookup(self, album, number):
|
||||
return sql.execute("SELECT * FROM discs WHERE (albumid=? AND number=?)",
|
||||
[ album.rowid, number ])
|
||||
|
||||
|
||||
Table = DiscTable()
|
64
db/genre.py
64
db/genre.py
|
@ -1,64 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
#
|
||||
# Table: genres
|
||||
# +---------+-----------+------+------+
|
||||
# | genreid | plstateid | name | sort |
|
||||
# +---------+-----------+------+------+
|
||||
#
|
||||
# Index: genre_index
|
||||
# +-----------------+
|
||||
# | name -> genreid |
|
||||
# +-----------------+
|
||||
from gi.repository import GObject
|
||||
from . import playlist
|
||||
from . import track
|
||||
from . import sql
|
||||
|
||||
class Genre(playlist.MappedPlaylist):
|
||||
def __init__(self, row):
|
||||
playlist.MappedPlaylist.__init__(self, row, "emblem-generic", "genre_map")
|
||||
self._name = row["name"]
|
||||
|
||||
def delete(self):
|
||||
self.clear()
|
||||
Table.delete(self)
|
||||
|
||||
@GObject.Property
|
||||
def name(self): return self._name
|
||||
|
||||
|
||||
class GenreTable(playlist.Model):
|
||||
def __init__(self):
|
||||
playlist.Model.__init__(self, "genres", "sort")
|
||||
|
||||
def do_create(self):
|
||||
sql.execute("CREATE TABLE IF NOT EXISTS genres "
|
||||
"(genreid INTEGER PRIMARY KEY, "
|
||||
" plstateid INTEGER NOT NULL, "
|
||||
" name TEXT UNIQUE, "
|
||||
" sort TEXT, "
|
||||
" FOREIGN KEY(plstateid) REFERENCES playlist_states(plstateid))")
|
||||
sql.execute("CREATE TABLE IF NOT EXISTS genre_map "
|
||||
"(genreid INTEGER, "
|
||||
" trackid INTEGER, "
|
||||
" FOREIGN KEY(genreid) REFERENCES genres(genreid), "
|
||||
" FOREIGN KEY(trackid) REFERENCES tracks(trackid), "
|
||||
" UNIQUE(genreid, trackid))")
|
||||
|
||||
def do_drop(self):
|
||||
sql.execute("DROP TABLE genres")
|
||||
sql.execute("DROP TABLE genre_map")
|
||||
|
||||
def do_factory(self, row):
|
||||
return Genre(row)
|
||||
|
||||
def do_insert(self, plstate, name):
|
||||
return sql.execute("INSERT INTO genres (plstateid, name, sort) "
|
||||
"VALUES (?, ?, ?)",
|
||||
[ plstate.rowid, name, name.casefold() ])
|
||||
|
||||
def do_lookup(self, name):
|
||||
return sql.execute("SELECT * FROM genres WHERE name=?", [ name ])
|
||||
|
||||
|
||||
Table = GenreTable()
|
|
@ -1,69 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
#
|
||||
# Table: libraries
|
||||
# +-----------+-----------+---------+------+
|
||||
# | libraryid | plstateid | enabled | path |
|
||||
# +-----------+-----------+---------+------+
|
||||
import pathlib
|
||||
from gi.repository import GObject
|
||||
from . import sql
|
||||
from . import playlist
|
||||
from . import track
|
||||
from . import user
|
||||
|
||||
class Library(playlist.Playlist):
|
||||
def __init__(self, row):
|
||||
playlist.Playlist.__init__(self, row, "folder-music")
|
||||
self._path = pathlib.Path(row["path"])
|
||||
self._enabled = row["enabled"]
|
||||
|
||||
@GObject.Property
|
||||
def name(self): return str(self._path)
|
||||
|
||||
@GObject.Property
|
||||
def path(self): return self._path
|
||||
|
||||
@GObject.Property(type=bool,default=True)
|
||||
def enabled(self): return self._enabled
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, newval):
|
||||
sql.execute("UPDATE libraries SET enabled=? WHERE libraryid=?",
|
||||
[ newval, self.rowid ])
|
||||
sql.commit()
|
||||
self._enabled = newval
|
||||
user.Table.find("Collection").refresh()
|
||||
|
||||
def delete(self): Table.delete(self)
|
||||
|
||||
def tracks(self):
|
||||
cursor = sql.execute(f"SELECT trackid FROM tracks "
|
||||
"WHERE libraryid=?", [ self.rowid ])
|
||||
return [ track.Table.get(row["trackid"]) for row in cursor.fetchall() ]
|
||||
|
||||
|
||||
|
||||
class LibraryTable(playlist.Model):
|
||||
def __init__(self):
|
||||
playlist.Model.__init__(self, "libraries", "path")
|
||||
|
||||
def do_create(self):
|
||||
sql.execute("CREATE TABLE IF NOT EXISTS libraries "
|
||||
"(libraryid INTEGER PRIMARY KEY, "
|
||||
" plstateid INTEGER NOT NULL, "
|
||||
" enabled INTEGER DEFAULT 1, "
|
||||
" path TEXT UNIQUE, "
|
||||
" FOREIGN KEY (plstateid) REFERENCES playlist_states(plstateid))")
|
||||
|
||||
def do_factory(self, row):
|
||||
return Library(row)
|
||||
|
||||
def do_insert(self, plstate, path):
|
||||
return sql.execute("INSERT INTO libraries (plstateid, path) "
|
||||
"VALUES (?, ?)", [ plstate.rowid, str(path) ])
|
||||
|
||||
def do_lookup(self, path):
|
||||
return sql.execute("SELECT * FROM libraries WHERE path=?", [ str(path) ])
|
||||
|
||||
|
||||
Table = LibraryTable()
|
273
db/playlist.py
273
db/playlist.py
|
@ -1,273 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import random
|
||||
from gi.repository import GObject
|
||||
from . import sql
|
||||
from . import state
|
||||
from . import table
|
||||
|
||||
class Playlist(GObject.GObject):
|
||||
def __init__(self, row, icon_name):
|
||||
GObject.GObject.__init__(self)
|
||||
self._rowkey = row.keys()[0]
|
||||
self._rowid = row[self._rowkey]
|
||||
self._plstate = state.Table.get(row["plstateid"])
|
||||
self._icon_name = icon_name
|
||||
|
||||
def delete(self): raise NotImplementedError
|
||||
|
||||
def has_children(self): return False
|
||||
def can_add_remove_tracks(self): return False
|
||||
|
||||
def get_n_tracks(self):
|
||||
cur = sql.execute(f"SELECT COUNT(*) FROM tracks "
|
||||
f"WHERE {self._rowkey}=?", [ self._rowid ])
|
||||
return cur.fetchone()[0]
|
||||
|
||||
def get_track(self, n):
|
||||
order = ', '.join(self.plist_state.sort)
|
||||
row = sql.execute(f"SELECT * FROM tracks "
|
||||
f"INNER JOIN artists USING(artistid) "
|
||||
f"INNER JOIN albums USING(albumid) "
|
||||
f"INNER JOIN discs USING(discid) "
|
||||
f"WHERE tracks.{self._rowkey}=? "
|
||||
f"ORDER BY {order} LIMIT 1 OFFSET ?",
|
||||
[ self.rowid, n ]).fetchone()
|
||||
return track.Table.factory(row)
|
||||
|
||||
def get_tracks(self):
|
||||
order = ', '.join(self.plist_state.sort)
|
||||
rows = sql.execute(f"SELECT * FROM tracks "
|
||||
f"INNER JOIN artists USING(artistid) "
|
||||
f"INNER JOIN albums USING(albumid) "
|
||||
f"INNER JOIN discs USING(discid) "
|
||||
f"WHERE tracks.{self._rowkey}=? "
|
||||
f"ORDER BY {order}", [ self.rowid ]).fetchall()
|
||||
return [ track.Table.factory(row) for row in rows ]
|
||||
|
||||
def get_current_track(self):
|
||||
return self.get_track(self.current) if self.current > -1 else None
|
||||
|
||||
def get_track_index(self, track):
|
||||
order = ', '.join(self.plist_state.sort)
|
||||
cur = sql.execute(f"SELECT * FROM (SELECT trackid,ROW_NUMBER() "
|
||||
f"OVER (ORDER BY {order}) "
|
||||
f"FROM tracks "
|
||||
f"INNER JOIN artists USING(artistid) "
|
||||
f"INNER JOIN albums USING(albumid) "
|
||||
f"INNER JOIN discs USING(discid) "
|
||||
f"WHERE tracks.{self._rowkey}=?) "
|
||||
f"WHERE trackid=?", [ self.rowid, track.rowid ])
|
||||
return cur.fetchone()[1] - 1
|
||||
|
||||
def track_adjusts_current(self, track):
|
||||
if self.current > -1:
|
||||
if (index := self.get_track_index(track)) != None:
|
||||
return index <= self.current
|
||||
return False
|
||||
|
||||
def add_track(self, track):
|
||||
self.emit("track-added", track)
|
||||
|
||||
def remove_track(self, track, adjust_current):
|
||||
self.emit("track-removed", track, adjust_current)
|
||||
|
||||
def next_track(self):
|
||||
n = self.get_n_tracks()
|
||||
if self.random and n > 1:
|
||||
self.current += random.randint(1, int((3*n)/4))
|
||||
else:
|
||||
self.current += 1
|
||||
return self.get_current_track()
|
||||
|
||||
def refresh(self):
|
||||
self.emit("refreshed")
|
||||
|
||||
@GObject.Property
|
||||
def name(self): raise NotImplementedError
|
||||
|
||||
@GObject.Property
|
||||
def icon_name(self): return self._icon_name
|
||||
|
||||
@GObject.Property
|
||||
def plist_state(self): return self._plstate
|
||||
|
||||
@GObject.Property
|
||||
def current(self): return self._plstate.get_property("current")
|
||||
|
||||
@current.setter
|
||||
def current(self, newval):
|
||||
n_tracks = self.get_n_tracks()
|
||||
if newval >= n_tracks:
|
||||
if n_tracks == 0: newval = -1
|
||||
elif self.random: newval = newval % n_tracks
|
||||
elif self.loop: newval = 0
|
||||
else: newval = n_tracks
|
||||
self._plstate.set_property("current", newval)
|
||||
|
||||
@GObject.Property(type=bool,default=False)
|
||||
def loop(self): return self._plstate.get_property("loop")
|
||||
|
||||
@loop.setter
|
||||
def loop(self, newval): self._plstate.set_property("loop", newval)
|
||||
|
||||
@GObject.Property(type=bool,default=False)
|
||||
def random(self): return self._plstate.get_property("random")
|
||||
|
||||
@random.setter
|
||||
def random(self, newval): self._plstate.set_property("random", newval)
|
||||
|
||||
@GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||
def sort(self): return self._plstate.get_property("sort")
|
||||
|
||||
@sort.setter
|
||||
def sort(self, newval):
|
||||
track = self.get_current_track()
|
||||
self._plstate.set_property("sort", newval)
|
||||
if track:
|
||||
self.current = self.get_track_index(track)
|
||||
self.refresh()
|
||||
|
||||
@GObject.Property
|
||||
def rowid(self): return self._rowid
|
||||
|
||||
@GObject.Property
|
||||
def rowkey(self): return self._rowkey
|
||||
|
||||
@GObject.Property
|
||||
def plstateid(self): return self._plstate.rowid
|
||||
|
||||
@GObject.Signal
|
||||
def refreshed(self): pass
|
||||
|
||||
@GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT,))
|
||||
def track_added(self, track):
|
||||
if self.track_adjusts_current(track):
|
||||
self.current += 1
|
||||
|
||||
@GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT,bool))
|
||||
def track_removed(self, track, adjust_current):
|
||||
if adjust_current:
|
||||
self.current -= 1
|
||||
|
||||
|
||||
class MappedPlaylist(Playlist):
|
||||
def __init__(self, row, icon_name, map_table):
|
||||
Playlist.__init__(self, row, icon_name)
|
||||
self._map_table = map_table
|
||||
|
||||
@GObject.Property
|
||||
def map_table(self): return self._map_table
|
||||
|
||||
def can_add_remove_tracks(self): return self._map_table == "playlist_map"
|
||||
|
||||
def add_track(self, track):
|
||||
res = sql.execute(f"INSERT OR IGNORE INTO {self.map_table} "
|
||||
f"({self.rowkey}, trackid) VALUES (?, ?)",
|
||||
[ self.rowid, track.rowid ]).rowcount == 1
|
||||
if res:
|
||||
super().add_track(track)
|
||||
return res
|
||||
|
||||
def clear(self):
|
||||
sql.execute(f"DELETE FROM {self.map_table} "
|
||||
f"WHERE {self.rowkey}=?", [ self.rowid ])
|
||||
|
||||
def get_n_tracks(self):
|
||||
cur = sql.execute(f"SELECT COUNT(*) FROM {self.map_table} "
|
||||
f"WHERE {self._rowkey}=?", [ self._rowid ])
|
||||
return cur.fetchone()[0]
|
||||
|
||||
def get_track(self, n):
|
||||
order = ', '.join(self.plist_state.sort)
|
||||
row = sql.execute(f"SELECT * FROM tracks "
|
||||
f"INNER JOIN {self.map_table} USING(trackid) "
|
||||
f"INNER JOIN artists USING(artistid) "
|
||||
f"INNER JOIN albums USING(albumid) "
|
||||
f"INNER JOIN discs USING(discid) "
|
||||
f"WHERE {self._rowkey}=? "
|
||||
f"ORDER BY {order} LIMIT 1 OFFSET ?",
|
||||
[ self._rowid, n ]).fetchone()
|
||||
return track.Table.factory(row)
|
||||
|
||||
def get_tracks(self):
|
||||
order = ', '.join(self.plist_state.sort)
|
||||
rows = sql.execute(f"SELECT * FROM tracks "
|
||||
f"INNER JOIN {self.map_table} USING(trackid) "
|
||||
f"INNER JOIN artists USING(artistid) "
|
||||
f"INNER JOIN albums USING(albumid) "
|
||||
f"INNER JOIN discs USING(discid) "
|
||||
f"WHERE {self._rowkey}=? ORDER BY {order}",
|
||||
[ self._rowid ]).fetchall()
|
||||
return [ track.Table.factory(row) for row in rows ]
|
||||
|
||||
|
||||
def get_track_index(self, track):
|
||||
order = ', '.join(self.plist_state.sort)
|
||||
row = sql.execute(f"SELECT * FROM (SELECT trackid,{self._rowkey},ROW_NUMBER() "
|
||||
f"OVER (ORDER BY {order}) "
|
||||
f"FROM tracks "
|
||||
f"INNER JOIN {self.map_table} USING (trackid) "
|
||||
f"INNER JOIN artists USING(artistid) "
|
||||
f"INNER JOIN albums USING(albumid) "
|
||||
f"INNER JOIN discs USING(discid)) "
|
||||
f"WHERE {self._rowkey}=? AND trackid=?",
|
||||
[ self.rowid, track.rowid ]).fetchone()
|
||||
return row[2] - 1 if row else None
|
||||
|
||||
def remove_track(self, track):
|
||||
adjust_current = self.track_adjusts_current(track)
|
||||
res = sql.execute(f"DELETE FROM {self.map_table} "
|
||||
f"WHERE {self.rowkey}=? AND trackid=?",
|
||||
[ self.rowid, track.rowid ]).rowcount == 1
|
||||
if res:
|
||||
super().remove_track(track, adjust_current)
|
||||
return res
|
||||
|
||||
|
||||
class ParentPlaylist(Playlist):
|
||||
def has_children(self): return True
|
||||
def get_child_table(self): raise NotImplementedError
|
||||
|
||||
def get_n_children(self):
|
||||
return self.get_child_table().get_n_children(self)
|
||||
|
||||
def get_child(self, n):
|
||||
return self.get_child_table().get_child(self, n)
|
||||
|
||||
def get_child_index(self, child):
|
||||
return self.get_child_table().get_child_index(self, child)
|
||||
|
||||
def find_child(self, *args):
|
||||
if (res := self.lookup_child(*args)) == None:
|
||||
res = self.get_child_table().insert(self, *args)
|
||||
self.emit("children-changed", self.get_child_index(res), 0, 1)
|
||||
return res
|
||||
|
||||
def lookup_child(self, *args):
|
||||
return self.get_child_table().lookup(self, *args)
|
||||
|
||||
@GObject.Signal(arg_types=(int,int,int))
|
||||
def children_changed(self, pos, rm, add): pass
|
||||
|
||||
|
||||
class Model(table.Model):
|
||||
def insert(self, *args, **kwargs):
|
||||
loop = kwargs.pop("loop", False)
|
||||
sort = kwargs.pop("sort", state.DefaultSort)
|
||||
return super().insert(state.Table.insert(loop=loop,sort=sort), *args)
|
||||
|
||||
def delete(self, plist):
|
||||
state.Table.delete(plist.plist_state)
|
||||
return super().delete(plist)
|
||||
|
||||
|
||||
class ChildModel(table.Child):
|
||||
def insert(self, *args, **kwargs):
|
||||
return super().insert(state.Table.insert(), *args)
|
||||
|
||||
def delete(self, plist):
|
||||
state.Table.delete(plist.plist_state)
|
||||
return super().delete(plist)
|
||||
|
||||
|
||||
from . import track
|
15
db/sql.py
15
db/sql.py
|
@ -1,15 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import lib
|
||||
import sqlite3
|
||||
|
||||
File = lib.data.emmental_data / "emmental.sqlite"
|
||||
|
||||
Connection = sqlite3.connect(File, detect_types=sqlite3.PARSE_DECLTYPES, check_same_thread=False)
|
||||
Connection.row_factory = sqlite3.Row
|
||||
|
||||
commit = Connection.commit
|
||||
execute = Connection.execute
|
||||
|
||||
def optimize():
|
||||
Connection.execute("PRAGMA analysis_limit=1000")
|
||||
Connection.execute("PRAGMA optimize")
|
80
db/state.py
80
db/state.py
|
@ -1,80 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
#
|
||||
# Table: playlist_states
|
||||
# +-----------+--------+------+---------+------+
|
||||
# | plstateid | random | loop | current | sort |
|
||||
# +-----------+--------+------+---------+------+
|
||||
from gi.repository import GObject
|
||||
from . import sql
|
||||
from . import table
|
||||
|
||||
DefaultSort = [ "artists.sort ASC", "albums.release ASC", "albums.sort ASC",
|
||||
"discs.number ASC", "tracks.number ASC", "tracks.trackid ASC" ]
|
||||
|
||||
class PlaylistState(GObject.GObject):
|
||||
def __init__(self, row):
|
||||
GObject.GObject.__init__(self)
|
||||
self._plstateid = row["plstateid"]
|
||||
self._random = row["random"]
|
||||
self._loop = row["loop"]
|
||||
self._current = row["current"]
|
||||
self._sort = row["sort"]
|
||||
|
||||
@GObject.Property
|
||||
def rowid(self): return self._plstateid
|
||||
|
||||
@GObject.Property(type=bool,default=False)
|
||||
def random(self): return self._random
|
||||
|
||||
@random.setter
|
||||
def random(self, newval): self._random = self.update("random", newval)
|
||||
|
||||
@GObject.Property(type=bool,default=False)
|
||||
def loop(self): return self._loop
|
||||
|
||||
@loop.setter
|
||||
def loop(self, newval): self._loop = self.update("loop", newval)
|
||||
|
||||
@GObject.Property(type=int)
|
||||
def current(self): return self._current
|
||||
|
||||
@current.setter
|
||||
def current(self, newval): self._current = self.update("current", newval)
|
||||
|
||||
@GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||
def sort(self): return self._sort.split(",")
|
||||
|
||||
@sort.setter
|
||||
def sort(self, newval):
|
||||
if "tracks.trackid ASC" not in newval:
|
||||
newval.append("tracks.trackid ASC")
|
||||
self._sort = self.update("sort", ",".join(newval))
|
||||
|
||||
def update(self, column, newval):
|
||||
sql.execute(f"UPDATE playlist_states SET {column}=? WHERE plstateid=?",
|
||||
[ newval, self.rowid ])
|
||||
sql.commit()
|
||||
return newval
|
||||
|
||||
|
||||
class PlaylistStateTable(table.Table):
|
||||
def __init__(self):
|
||||
table.Table.__init__(self, "playlist_states")
|
||||
|
||||
def do_create(self):
|
||||
sql.execute("CREATE TABLE IF NOT EXISTS playlist_states "
|
||||
"(plstateid INTEGER PRIMARY KEY, "
|
||||
" random INTEGER DEFAULT 0, "
|
||||
" loop INTEGER DEFAULT 0, "
|
||||
" current INTEGER DEFAULT -1, "
|
||||
" sort TEXT)")
|
||||
|
||||
def do_factory(self, row):
|
||||
return PlaylistState(row)
|
||||
|
||||
def do_insert(self, random=False, loop=False, sort=DefaultSort):
|
||||
return sql.execute("INSERT INTO playlist_states (random, loop, sort) "
|
||||
"VALUES (?, ?, ?)", (random, loop, ",".join(sort)))
|
||||
|
||||
|
||||
Table = PlaylistStateTable()
|
115
db/table.py
115
db/table.py
|
@ -1,115 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gio
|
||||
from . import sql
|
||||
|
||||
class Table:
|
||||
def __init__(self, table):
|
||||
self.cache = dict()
|
||||
self.table = table
|
||||
self.do_create()
|
||||
|
||||
def do_create(self): raise NotImplementedError
|
||||
def do_factory(self, row): raise NotImplementedError
|
||||
def do_insert(self, *args): raise NotImplementedError
|
||||
def do_lookup(self, *args): raise NotImplementedError
|
||||
|
||||
def do_drop(self):
|
||||
sql.execute(f"DROP TABLE {self.table}")
|
||||
|
||||
def delete(self, obj):
|
||||
del self.cache[obj.rowid]
|
||||
sql.execute(f"DELETE FROM {self.table} WHERE rowid=?", [ obj.rowid ])
|
||||
|
||||
def factory(self, row):
|
||||
if row:
|
||||
return self.cache.setdefault(row[0], self.do_factory(row))
|
||||
|
||||
def find(self, *args):
|
||||
return ret if (ret := self.lookup(*args)) else self.insert(*args)
|
||||
|
||||
def get(self, rowid):
|
||||
if (row := self.cache.get(rowid)):
|
||||
return row
|
||||
cur = sql.execute(f"SELECT * FROM {self.table} WHERE rowid=?", [ rowid ])
|
||||
return self.factory(cur.fetchone())
|
||||
|
||||
def insert(self, *args, **kwargs):
|
||||
return self.get(self.do_insert(*args, **kwargs).lastrowid)
|
||||
|
||||
def lookup(self, *args):
|
||||
return self.factory(self.do_lookup(*args).fetchone())
|
||||
|
||||
def reset(self):
|
||||
self.do_drop()
|
||||
self.cache.clear()
|
||||
self.do_create()
|
||||
|
||||
|
||||
class Model(GObject.GObject, Gio.ListModel, Table):
|
||||
def __init__(self, table, order):
|
||||
GObject.GObject.__init__(self)
|
||||
self.order = order
|
||||
Table.__init__(self, table)
|
||||
|
||||
def do_get_item_type(self):
|
||||
return GObject.TYPE_PYOBJECT
|
||||
|
||||
def do_get_n_items(self):
|
||||
return sql.execute(f"SELECT COUNT(*) FROM {self.table}").fetchone()[0]
|
||||
|
||||
def do_get_item(self, n):
|
||||
cur = sql.execute(f"SELECT * FROM {self.table} ORDER BY {self.order} "
|
||||
"LIMIT 1 OFFSET ?", [ n ])
|
||||
return self.factory(cur.fetchone())
|
||||
|
||||
def get_item_index(self, item):
|
||||
cur = sql.execute("SELECT * FROM (SELECT rowid,ROW_NUMBER() "
|
||||
f"OVER (ORDER BY {self.order}) "
|
||||
f"FROM {self.table}) "
|
||||
"WHERE rowid=?", [ item.rowid ])
|
||||
return cur.fetchone()[1] - 1
|
||||
|
||||
def delete(self, item):
|
||||
pos = self.get_item_index(item)
|
||||
super().delete(item)
|
||||
self.emit("items-changed", pos, 1, 0)
|
||||
|
||||
def insert(self, *args):
|
||||
row = super().insert(*args)
|
||||
pos = self.get_item_index(row)
|
||||
self.emit("items-changed", pos, 0, 1)
|
||||
return row
|
||||
|
||||
def reset(self):
|
||||
n = self.get_n_items()
|
||||
super().reset()
|
||||
self.emit("items-changed", 0, n, self.get_n_items())
|
||||
|
||||
|
||||
class Child(Table):
|
||||
def __init__(self, table, parent, order):
|
||||
Table.__init__(self, table)
|
||||
self.parent = parent
|
||||
self.order = order
|
||||
|
||||
def get_n_children(self, parent):
|
||||
cur = sql.execute(f"SELECT COUNT(*) FROM {self.table} "
|
||||
f"WHERE {self.parent}=?", [ parent.rowid ])
|
||||
return cur.fetchone()[0]
|
||||
|
||||
def get_child(self, parent, n):
|
||||
cur = sql.execute(f"SELECT * FROM {self.table} WHERE {self.parent}=? "
|
||||
f"ORDER BY {self.order} LIMIT 1 OFFSET ?",
|
||||
[ parent.rowid, n ])
|
||||
return self.factory(cur.fetchone())
|
||||
|
||||
def get_child_index(self, parent, child):
|
||||
cur = sql.execute(f"SELECT * FROM (SELECT rowid,ROW_NUMBER() "
|
||||
f"OVER (ORDER BY {self.order}) "
|
||||
f"FROM {self.table} "
|
||||
f"WHERE {self.parent}=?)"
|
||||
f"WHERE rowid=?", [ parent.rowid, child.rowid ])
|
||||
return cur.fetchone()[1] - 1
|
||||
|
||||
def find(self, *args): raise NotImplementedError
|
|
@ -1,90 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import datetime
|
||||
import db
|
||||
import sqlite3
|
||||
import unittest
|
||||
from gi.repository import GObject
|
||||
|
||||
class TestAlbum(unittest.TestCase):
|
||||
def track_added(self, album, added):
|
||||
self.added = added
|
||||
|
||||
def track_removed(self, album, removed, adjusted_current):
|
||||
self.removed = (removed, False)
|
||||
|
||||
def setUp(self):
|
||||
db.reset()
|
||||
|
||||
def test_init(self):
|
||||
artist = db.artist.Table.find("Test Artist", "Test Sort")
|
||||
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
|
||||
self.assertIsInstance(album, db.playlist.ParentPlaylist)
|
||||
self.assertEqual(album.get_property("name"), "Test Album")
|
||||
self.assertEqual(album.get_property("icon-name"), "media-optical-cd-audio")
|
||||
self.assertEqual(album.get_property("release"), datetime.date(2021, 3, 18))
|
||||
self.assertEqual(album.get_child_table(), db.disc.Table)
|
||||
|
||||
def test_delete(self):
|
||||
artist = db.artist.Table.find("Test Artist", "Test Sort")
|
||||
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
|
||||
album.delete()
|
||||
self.assertIsNone(db.album.Table.lookup(artist, "Test Album",
|
||||
datetime.date(2021, 3, 18)))
|
||||
|
||||
def test_find_disc(self):
|
||||
artist = db.artist.Table.find("Test Artist", "Test Sort")
|
||||
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
|
||||
disc = album.find_disc(1, None)
|
||||
self.assertIsInstance(disc, db.disc.Disc)
|
||||
|
||||
def test_tracks(self):
|
||||
artist = db.artist.Table.find("Test Artist", "Test Sort")
|
||||
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
|
||||
album.connect("track-added", self.track_added)
|
||||
self.assertEqual(album.get_n_tracks(), 0)
|
||||
self.assertEqual(album.get_tracks(), [ ])
|
||||
|
||||
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
|
||||
self.assertEqual(album.get_n_tracks(), 1)
|
||||
self.assertEqual(album.get_track(0), track)
|
||||
self.assertEqual(album.get_tracks(), [ track ])
|
||||
self.assertEqual(album.get_track_index(track), 0)
|
||||
self.assertEqual(self.added, track)
|
||||
|
||||
album.connect("track-removed", self.track_removed)
|
||||
db.track.Table.delete(track)
|
||||
self.assertEqual(album.get_tracks(), [ ])
|
||||
self.assertEqual(self.removed, (track, False))
|
||||
|
||||
|
||||
class TestAlbumTable(unittest.TestCase):
|
||||
def setUp(self):
|
||||
db.reset()
|
||||
|
||||
def test_init(self):
|
||||
table = db.album.AlbumTable()
|
||||
self.assertIsInstance(table, db.playlist.ChildModel)
|
||||
self.assertEqual(table.table, "albums")
|
||||
self.assertEqual(table.parent, "artistid")
|
||||
self.assertEqual(table.order, "sort")
|
||||
|
||||
self.assertIsInstance(db.album.Table, db.album.AlbumTable)
|
||||
db.sql.execute("SELECT albumid,artistid,plstateid,release,name,sort FROM albums")
|
||||
|
||||
def test_insert(self):
|
||||
artist = db.artist.Table.insert("Test Artist", "Test Sort")
|
||||
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
|
||||
|
||||
self.assertIsInstance(album, db.album.Album)
|
||||
self.assertEqual(album._name, "Test Album")
|
||||
self.assertEqual(album._rowkey, "albumid")
|
||||
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
db.album.Table.insert(artist, "Test Album",
|
||||
datetime.date(2021, 3, 18))
|
||||
|
||||
def test_lookup(self):
|
||||
artist = db.artist.Table.insert("Test Artist", "Test Sort")
|
||||
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
|
||||
self.assertEqual(db.album.Table.lookup(artist, "Test Album", datetime.date(2021, 3, 18)), album)
|
||||
self.assertIsNone(db.album.Table.lookup(artist, "none", datetime.date(1, 1, 1)))
|
|
@ -1,82 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import datetime
|
||||
import db
|
||||
import sqlite3
|
||||
import unittest
|
||||
from gi.repository import GObject
|
||||
|
||||
class TestArtist(unittest.TestCase):
|
||||
def track_added(self, artist, added):
|
||||
self.added = added
|
||||
|
||||
def track_removed(self, artist, removed, adjusted_current):
|
||||
self.removed = (removed, adjusted_current)
|
||||
|
||||
def setUp(self):
|
||||
db.reset()
|
||||
|
||||
def test_init(self):
|
||||
artist = db.artist.Table.find("Test Artist", "Test Sort")
|
||||
self.assertIsInstance(artist, db.playlist.ParentPlaylist)
|
||||
self.assertEqual(artist.get_property("name"), "Test Artist")
|
||||
self.assertEqual(artist.get_property("icon-name"), "avatar-default-symbolic")
|
||||
self.assertEqual(artist.get_child_table(), db.album.Table)
|
||||
|
||||
def test_delete(self):
|
||||
artist = db.artist.Table.find("Test Artist", "Test Sort")
|
||||
artist.delete()
|
||||
self.assertIsNone(db.artist.Table.lookup("Test Artist"))
|
||||
|
||||
def test_find_album(self):
|
||||
artist = db.artist.Table.find("Test Artist", "Test Sort")
|
||||
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
|
||||
self.assertIsInstance(album, db.album.Album)
|
||||
|
||||
def test_tracks(self):
|
||||
artist = db.artist.Table.find("Test Artist", "Test Sort")
|
||||
artist.connect("track-added", self.track_added)
|
||||
self.assertEqual(artist.get_n_tracks(), 0)
|
||||
self.assertEqual(artist.get_tracks(), [ ])
|
||||
|
||||
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
|
||||
self.assertEqual(artist.get_n_tracks(), 1)
|
||||
self.assertEqual(artist.get_track(0), track)
|
||||
self.assertEqual(artist.get_tracks(), [ track ])
|
||||
self.assertEqual(artist.get_track_index(track), 0)
|
||||
self.assertEqual(self.added, track)
|
||||
|
||||
artist.connect("track-removed", self.track_removed)
|
||||
db.track.Table.delete(track)
|
||||
self.assertEqual(artist.get_tracks(), [ ])
|
||||
self.assertEqual(self.removed, (track, False))
|
||||
|
||||
|
||||
class TestArtistTable(unittest.TestCase):
|
||||
def setUp(self):
|
||||
db.reset()
|
||||
|
||||
def test_init(self):
|
||||
table = db.artist.ArtistTable()
|
||||
self.assertIsInstance(table, db.playlist.Model)
|
||||
self.assertEqual(table.table, "artists")
|
||||
self.assertEqual(table.order, "sort")
|
||||
|
||||
self.assertIsInstance(db.artist.Table, db.artist.ArtistTable)
|
||||
db.sql.execute("SELECT artistid,plstateid,name,sort FROM artists")
|
||||
|
||||
def test_insert(self):
|
||||
table = db.artist.ArtistTable()
|
||||
artist = table.insert("Test Artist", "Test Sort")
|
||||
|
||||
self.assertIsInstance(artist, db.artist.Artist)
|
||||
self.assertEqual(artist._name, "Test Artist")
|
||||
self.assertEqual(artist._rowkey, "artistid")
|
||||
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
db.artist.Table.insert("Test Artist", "Test Sort")
|
||||
|
||||
def test_lookup(self):
|
||||
table = db.artist.ArtistTable()
|
||||
artist = table.insert("Test Artist", "Test Sort")
|
||||
self.assertEqual(table.lookup("Test Artist"), artist)
|
||||
self.assertIsNone(table.lookup("none"))
|
|
@ -1,20 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import db
|
||||
import unittest
|
||||
|
||||
class TestDB(unittest.TestCase):
|
||||
def test_new_db(self):
|
||||
db.reset()
|
||||
self.assertTrue(db.new_db())
|
||||
db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
|
||||
self.assertFalse(db.new_db())
|
||||
|
||||
def test_find_playlist(self):
|
||||
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
|
||||
collection = db.user.Table.find("Collection")
|
||||
genre = db.genre.Table.find("Test Genre")
|
||||
|
||||
self.assertIsNone(db.find_playlist(123456))
|
||||
for plist in [ collection, track.artist, track.album, track.disc,
|
||||
genre, track.decade, track.year, track.library ]:
|
||||
self.assertEqual(db.find_playlist(plist.plist_state.rowid), plist)
|
|
@ -1,93 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import db
|
||||
import sqlite3
|
||||
import unittest
|
||||
from gi.repository import GObject
|
||||
|
||||
class TestDecade(unittest.TestCase):
|
||||
def children_changed(self, decade, pos, rm, add):
|
||||
self.changed = (pos, rm, add)
|
||||
|
||||
def track_added(self, decade, added):
|
||||
self.added = added
|
||||
|
||||
def track_removed(self, decade, removed, adjusted_current):
|
||||
self.removed = (removed, adjusted_current)
|
||||
|
||||
def setUp(self):
|
||||
db.reset()
|
||||
|
||||
def test_init(self):
|
||||
decade = db.decade.Table.insert(2020)
|
||||
self.assertIsInstance(decade, db.playlist.ParentPlaylist)
|
||||
self.assertEqual(decade.get_property("name"), "2020s")
|
||||
self.assertEqual(decade.get_property("icon-name"), "x-office-calendar")
|
||||
self.assertEqual(decade.get_child_table(), db.year.Table)
|
||||
|
||||
def test_decade(self):
|
||||
decade = db.decade.Table.insert(2020)
|
||||
self.assertEqual(decade._decade, 2020)
|
||||
self.assertEqual(decade.get_property("decade"), 2020)
|
||||
|
||||
def test_delete(self):
|
||||
decade = db.decade.Table.find(2020)
|
||||
decade.delete()
|
||||
self.assertIsNone(db.decade.Table.lookup(2020))
|
||||
|
||||
def test_find_year(self):
|
||||
decade = db.decade.Table.insert(2020)
|
||||
decade.connect("children-changed", self.children_changed)
|
||||
|
||||
year = decade.find_year(2021)
|
||||
self.assertIsInstance(year, db.year.Year)
|
||||
self.assertEqual(self.changed, (0, 0, 1))
|
||||
|
||||
def test_tracks(self):
|
||||
decade = db.decade.Table.insert(2020)
|
||||
decade.connect("track-added", self.track_added)
|
||||
self.assertEqual(decade.get_n_tracks(), 0)
|
||||
self.assertEqual(decade.get_tracks(), [ ])
|
||||
|
||||
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
|
||||
self.assertEqual(decade.get_n_tracks(), 1)
|
||||
self.assertEqual(decade.get_track(0), track)
|
||||
self.assertEqual(decade.get_tracks(), [ track ])
|
||||
self.assertEqual(decade.get_track_index(track), 0)
|
||||
self.assertEqual(self.added, track)
|
||||
|
||||
decade.connect("track-removed", self.track_removed)
|
||||
db.track.Table.delete(track)
|
||||
self.assertEqual(decade.get_tracks(), [ ])
|
||||
self.assertEqual(self.removed, (track, False))
|
||||
|
||||
|
||||
class TestDecadeTable(unittest.TestCase):
|
||||
def setUp(self):
|
||||
db.reset()
|
||||
|
||||
def test_init(self):
|
||||
table = db.decade.DecadeTable()
|
||||
self.assertIsInstance(table, db.playlist.Model)
|
||||
self.assertEqual(table.table, "decades")
|
||||
self.assertEqual(table.order, "decade")
|
||||
|
||||
self.assertIsInstance(db.decade.Table, db.decade.DecadeTable)
|
||||
db.sql.execute("SELECT decadeid,plstateid,decade FROM decades")
|
||||
|
||||
def test_insert(self):
|
||||
table = db.decade.DecadeTable()
|
||||
decade = table.insert(2020)
|
||||
|
||||
self.assertIsInstance(decade, db.decade.Decade)
|
||||
self.assertEqual(decade._decade, 2020)
|
||||
self.assertEqual(decade._rowkey, "decadeid")
|
||||
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
db.decade.Table.insert(2020)
|
||||
|
||||
def test_lookup(self):
|
||||
table = db.decade.DecadeTable()
|
||||
decade = table.insert(2020)
|
||||
|
||||
self.assertEqual(table.lookup(2020), decade)
|
||||
self.assertIsNone(table.lookup(2021))
|
105
db/test_disc.py
105
db/test_disc.py
|
@ -1,105 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import datetime
|
||||
import db
|
||||
import sqlite3
|
||||
import unittest
|
||||
from gi.repository import GObject
|
||||
|
||||
class TestDisc(unittest.TestCase):
|
||||
def track_added(self, disc, added):
|
||||
self.added = added
|
||||
|
||||
def track_removed(self, disc, removed, adjusted_current):
|
||||
self. removed = (removed, adjusted_current)
|
||||
|
||||
def setUp(self):
|
||||
db.reset()
|
||||
|
||||
def make_disc(self, artist, album, discno, subtitle):
|
||||
artist = db.artist.Table.find(artist, artist)
|
||||
album = artist.find_album(album, datetime.date(2021, 3, 18))
|
||||
return album.find_disc(discno, subtitle)
|
||||
|
||||
def test_init(self):
|
||||
disc = self.make_disc("Test Artist", "Test Album", 1, "")
|
||||
self.assertIsInstance(disc, db.playlist.Playlist)
|
||||
self.assertEqual(disc.get_property("icon-name"), "media-optical")
|
||||
self.assertEqual(disc.get_property("number"), 1)
|
||||
|
||||
def test_delete(self):
|
||||
artist = db.artist.Table.find("Test Artist", "Test Artist")
|
||||
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
|
||||
disc = album.find_disc(1, "")
|
||||
disc.delete()
|
||||
self.assertIsNone(db.disc.Table.lookup(album, 1))
|
||||
|
||||
def test_subtitle(self):
|
||||
disc = self.make_disc("Test Artist", "Test Album", 1, "Test Subtitle")
|
||||
self.assertEqual(disc._subtitle, "Test Subtitle")
|
||||
self.assertEqual(disc.get_property("subtitle"), "Test Subtitle")
|
||||
self.assertEqual(disc.get_property("name"), "1: Test Subtitle")
|
||||
|
||||
def test_subtitle_none(self):
|
||||
disc = self.make_disc("Test Artist", "Test Album", 1, None)
|
||||
self.assertEqual(disc._subtitle, None)
|
||||
self.assertEqual(disc.get_property("subtitle"), "")
|
||||
self.assertEqual(disc.get_property("name"), "Disc 1")
|
||||
|
||||
def test_subtitle_len_0(self):
|
||||
disc = self.make_disc("Test Artist", "Test Album", 1, "")
|
||||
self.assertEqual(disc._subtitle, "")
|
||||
self.assertEqual(disc.get_property("subtitle"), "")
|
||||
self.assertEqual(disc.get_property("name"), "Disc 1")
|
||||
|
||||
def test_tracks(self):
|
||||
disc = self.make_disc("Test Artist", "Test Album", 1, "Test Subtitle")
|
||||
disc.connect("track-added", self.track_added)
|
||||
self.assertEqual(disc.get_n_tracks(), 0)
|
||||
self.assertEqual(disc.get_tracks(), [ ])
|
||||
|
||||
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
|
||||
self.assertEqual(disc.get_n_tracks(), 1)
|
||||
self.assertEqual(disc.get_track(0), track)
|
||||
self.assertEqual(disc.get_tracks(), [ track ])
|
||||
self.assertEqual(disc.get_track_index(track), 0)
|
||||
self.assertEqual(self.added, track)
|
||||
|
||||
disc.connect("track-removed", self.track_removed)
|
||||
db.track.Table.delete(track)
|
||||
self.assertEqual(disc.get_tracks(), [ ])
|
||||
self.assertEqual(self.removed, (track, False))
|
||||
|
||||
|
||||
class TestDiscTable(unittest.TestCase):
|
||||
def setUp(self):
|
||||
db.reset()
|
||||
|
||||
def test_init(self):
|
||||
table = db.disc.DiscTable()
|
||||
self.assertIsInstance(table, db.playlist.ChildModel)
|
||||
self.assertEqual(table.table, "discs")
|
||||
self.assertEqual(table.parent, "albumid")
|
||||
self.assertEqual(table.order, "number")
|
||||
|
||||
self.assertIsInstance(db.disc.Table, db.disc.DiscTable)
|
||||
db.sql.execute("SELECT discid,albumid,plstateid,number,subtitle FROM discs")
|
||||
|
||||
def test_insert(self):
|
||||
artist = db.artist.Table.insert("Test Artist", "Test Sort")
|
||||
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
|
||||
disc = db.disc.Table.insert(album, 1, "subtitle")
|
||||
|
||||
self.assertIsInstance(disc, db.disc.Disc)
|
||||
self.assertEqual(disc._number, 1)
|
||||
self.assertEqual(disc._subtitle, "subtitle")
|
||||
self.assertEqual(disc._rowkey, "discid")
|
||||
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
db.disc.Table.insert(album, 1, "subtitle")
|
||||
|
||||
def test_lookup(self):
|
||||
artist = db.artist.Table.insert("Test Artist", "Test Sort")
|
||||
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
|
||||
disc = album.find_disc(1, None)
|
||||
self.assertEqual(db.disc.Table.lookup(album, 1), disc)
|
||||
self.assertIsNone(db.disc.Table.lookup(album, "none"))
|
|
@ -1,85 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import db
|
||||
import sqlite3
|
||||
import unittest
|
||||
from gi.repository import GObject
|
||||
from . import sql
|
||||
|
||||
|
||||
class TestGenre(unittest.TestCase):
|
||||
def track_added(self, genre, added):
|
||||
self.added = added
|
||||
|
||||
def track_removed(self, genre, removed, adjusted_current):
|
||||
self.removed = (removed, adjusted_current)
|
||||
|
||||
def setUp(self):
|
||||
db.reset()
|
||||
|
||||
def test_init(self):
|
||||
genre = db.genre.Table.find("Test Genre")
|
||||
self.assertIsInstance(genre, db.playlist.MappedPlaylist)
|
||||
self.assertEqual(genre._name, "Test Genre")
|
||||
self.assertEqual(genre.get_property("name"), "Test Genre")
|
||||
self.assertEqual(genre.get_property("icon-name"), "emblem-generic")
|
||||
self.assertEqual(genre.get_property("map-table"), "genre_map")
|
||||
|
||||
def test_delete(self):
|
||||
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
|
||||
genre = db.genre.Table.find("Test Genre")
|
||||
genre.add_track(track)
|
||||
genre.delete()
|
||||
self.assertEqual(genre.get_n_tracks(), 0)
|
||||
self.assertIsNone(db.genre.Table.lookup("Test Genre"))
|
||||
|
||||
def test_add_remove_track(self):
|
||||
genre = db.genre.Table.find("Test Genre")
|
||||
genre.connect("track-added", self.track_added)
|
||||
self.assertEqual(genre.get_n_tracks(), 0)
|
||||
self.assertEqual(genre.get_tracks(), [ ])
|
||||
|
||||
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
|
||||
self.assertTrue(genre.add_track(track))
|
||||
self.assertEqual(genre.get_n_tracks(), 1)
|
||||
self.assertEqual(genre.get_track(0), track)
|
||||
self.assertEqual(genre.get_tracks(), [ track ])
|
||||
self.assertEqual(genre.get_track_index(track), 0)
|
||||
self.assertEqual(self.added, track)
|
||||
|
||||
genre.connect("track-removed", self.track_removed)
|
||||
self.assertTrue(genre.remove_track(track))
|
||||
self.assertFalse(genre.remove_track(track))
|
||||
self.assertEqual(genre.get_n_tracks(), 0)
|
||||
self.assertEqual(genre.get_tracks(), [ ])
|
||||
self.assertEqual(self.removed, (track, False))
|
||||
|
||||
|
||||
class TestGenreTable(unittest.TestCase):
|
||||
def setUp(self):
|
||||
db.reset()
|
||||
|
||||
def test_init(self):
|
||||
table = db.genre.GenreTable()
|
||||
self.assertIsInstance(table, db.playlist.Model)
|
||||
self.assertEqual(table.table, "genres")
|
||||
self.assertEqual(table.order, "sort")
|
||||
|
||||
self.assertIsInstance(db.genre.Table, db.genre.GenreTable)
|
||||
db.sql.execute("SELECT genreid,plstateid,name,sort FROM genres")
|
||||
db.sql.execute("SELECT genreid,trackid FROM genre_map")
|
||||
|
||||
def test_insert(self):
|
||||
table = db.genre.GenreTable()
|
||||
genre = table.insert("Test Genre")
|
||||
|
||||
self.assertIsInstance(genre, db.genre.Genre)
|
||||
self.assertEqual(genre._name, "Test Genre")
|
||||
self.assertEqual(genre._rowkey, "genreid")
|
||||
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
db.genre.Table.insert("Test Genre")
|
||||
|
||||
def test_lookup(self):
|
||||
genre = db.genre.Table.insert("Test Genre")
|
||||
self.assertEqual(db.genre.Table.lookup("Test Genre"), genre)
|
||||
self.assertIsNone(db.genre.Table.lookup("none"))
|
|
@ -1,103 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import db
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import unittest
|
||||
from gi.repository import GObject
|
||||
|
||||
|
||||
class TestLibrary(unittest.TestCase):
|
||||
def track_added(self, library, added):
|
||||
self.added = added
|
||||
|
||||
def track_removed(self, library, removed, adjusted_current):
|
||||
self. removed = (removed, adjusted_current)
|
||||
|
||||
def setUp(self):
|
||||
db.reset()
|
||||
|
||||
def test_init(self):
|
||||
library = db.library.Table.insert(pathlib.Path("/a/b/c"))
|
||||
self.assertIsInstance(library, db.playlist.Playlist)
|
||||
self.assertEqual(library.get_property("name"), "/a/b/c")
|
||||
self.assertEqual(library.get_property("icon-name"), "folder-music")
|
||||
|
||||
def test_delete(self):
|
||||
library = db.library.Table.find(pathlib.Path("/a/b/c"))
|
||||
library.delete()
|
||||
self.assertIsNone(db.library.Table.lookup(pathlib.Path("/a/b/c")))
|
||||
|
||||
def test_path(self):
|
||||
library = db.library.Table.insert(pathlib.Path("/a/b/c"))
|
||||
self.assertIsInstance(library._path, pathlib.Path)
|
||||
self.assertEqual(library.get_property("path"), library._path)
|
||||
|
||||
def test_enabled(self):
|
||||
library = db.library.Table.insert(pathlib.Path("/a/b/c"))
|
||||
self.assertTrue(library._enabled)
|
||||
self.assertTrue(library.get_property("enabled"))
|
||||
|
||||
library.enabled = False
|
||||
self.assertFalse(library._enabled)
|
||||
|
||||
def test_tracks(self):
|
||||
library = db.library.Table.insert(pathlib.Path("/a/b/c"))
|
||||
library.connect("track-added", self.track_added)
|
||||
self.assertEqual(library.get_n_tracks(), 0)
|
||||
self.assertEqual(library.get_tracks(), [ ])
|
||||
|
||||
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
|
||||
self.assertEqual(library.get_n_tracks(), 1)
|
||||
self.assertEqual(library.get_track(0), track)
|
||||
self.assertEqual(library.get_tracks(), [ track ])
|
||||
self.assertEqual(library.get_track_index(track), 0)
|
||||
self.assertEqual(self.added, track)
|
||||
|
||||
library.connect("track-removed", self.track_removed)
|
||||
db.track.Table.delete(track)
|
||||
self.assertEqual(library.get_tracks(), [ ])
|
||||
self.assertEqual(self.removed, (track, False))
|
||||
|
||||
|
||||
class TestLibraryTable(unittest.TestCase):
|
||||
def setUp(self):
|
||||
db.reset()
|
||||
|
||||
def test_init(self):
|
||||
table = db.library.LibraryTable()
|
||||
self.assertIsInstance(table, db.playlist.Model)
|
||||
self.assertIsInstance(table, db.table.Model)
|
||||
self.assertEqual(table.table, "libraries")
|
||||
self.assertEqual(table.order, "path")
|
||||
|
||||
self.assertIsInstance(db.library.Table, db.library.LibraryTable)
|
||||
db.sql.execute("SELECT libraryid,plstateid,enabled,path FROM libraries")
|
||||
|
||||
def test_insert(self):
|
||||
table = db.library.LibraryTable()
|
||||
library = table.insert(pathlib.Path("/a/b/c"))
|
||||
|
||||
self.assertIsInstance(library, db.library.Library)
|
||||
self.assertIsInstance(library._plstate, db.state.PlaylistState)
|
||||
self.assertEqual(library._rowkey, "libraryid")
|
||||
self.assertEqual(library._path, pathlib.Path("/a/b/c"))
|
||||
self.assertTrue(library._enabled)
|
||||
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
db.library.Table.insert(pathlib.Path("/a/b/c"))
|
||||
|
||||
def test_delete(self):
|
||||
table = db.library.LibraryTable()
|
||||
library = table.insert(pathlib.Path("/a/b/c"))
|
||||
state = library.plist_state
|
||||
|
||||
table.delete(library)
|
||||
self.assertIsNone(db.library.Table.lookup(pathlib.Path("/a/b/c")))
|
||||
self.assertIsNone(db.state.Table.get(state.rowid))
|
||||
|
||||
def test_lookup(self):
|
||||
table = db.library.LibraryTable()
|
||||
library = table.insert(pathlib.Path("/a/b/c"))
|
||||
|
||||
self.assertEqual(table.lookup(pathlib.Path("/a/b/c")), library)
|
||||
self.assertIsNone(table.lookup(pathlib.Path("/a/b/d")))
|
|
@ -1,245 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import db
|
||||
import random
|
||||
import unittest
|
||||
from gi.repository import GObject
|
||||
from . import playlist
|
||||
|
||||
class TestRow:
|
||||
def __init__(self): pass
|
||||
def keys(self): return [ "testid", "plstateid" ]
|
||||
def __getitem__(self, key):
|
||||
if key == "testid" or key == 0: return 1
|
||||
return 10
|
||||
|
||||
class TestPlaylist(unittest.TestCase):
|
||||
def setUp(self): db.reset()
|
||||
|
||||
def on_refresh(self, plist): self.refreshed = True
|
||||
|
||||
def test_init(self):
|
||||
db.reset()
|
||||
plist = playlist.Playlist(TestRow(), "missing-icon")
|
||||
|
||||
self.assertIsInstance(plist, GObject.GObject)
|
||||
self.assertFalse(plist.has_children())
|
||||
self.assertFalse(plist.can_add_remove_tracks())
|
||||
|
||||
self.assertEqual(plist._rowid, 1)
|
||||
self.assertEqual(plist._rowkey, "testid")
|
||||
self.assertEqual(plist._icon_name, "missing-icon")
|
||||
|
||||
self.assertEqual(plist.get_property("rowid"), 1)
|
||||
self.assertEqual(plist.get_property("rowkey"), "testid")
|
||||
self.assertEqual(plist.get_property("icon-name"), "missing-icon")
|
||||
|
||||
self.assertIsNone(plist._plstate)
|
||||
self.assertIsNone(plist.get_property("plist_state"))
|
||||
|
||||
with self.assertRaises(NotImplementedError):
|
||||
plist.get_property("name")
|
||||
with self.assertRaises(NotImplementedError):
|
||||
plist.delete()
|
||||
|
||||
def test_add_track(self):
|
||||
plist = db.user.Table.find("Test Playlist")
|
||||
track1 = db.make_fake_track(1, 1, "Track 1", "/a/b/c/1.ogg")
|
||||
track2 = db.make_fake_track(2, 2, "Track 2", "/a/b/c/2.ogg")
|
||||
track3 = db.make_fake_track(3, 3, "Track 3", "/a/b/c/3.ogg")
|
||||
plist.sort = [ "tracks.number ASC" ]
|
||||
|
||||
plist.add_track(track2)
|
||||
self.assertEqual(plist.get_track(0), track2)
|
||||
self.assertEqual(plist.current, -1)
|
||||
self.assertFalse(plist.track_adjusts_current(track2))
|
||||
|
||||
plist.current = 0
|
||||
self.assertTrue(plist.track_adjusts_current(track2))
|
||||
|
||||
plist.add_track(track1)
|
||||
self.assertEqual(plist.get_track(0), track1)
|
||||
self.assertEqual(plist.get_track(1), track2)
|
||||
self.assertEqual(plist.current, 1)
|
||||
|
||||
plist.add_track(track3)
|
||||
self.assertEqual(plist.get_track(0), track1)
|
||||
self.assertEqual(plist.get_track(1), track2)
|
||||
self.assertEqual(plist.get_track(2), track3)
|
||||
self.assertEqual(plist.current, 1)
|
||||
|
||||
def test_remove_track(self):
|
||||
plist = db.user.Table.find("Test Playlist")
|
||||
track1 = db.make_fake_track(1, 1, "Track 1", "/a/b/c/1.ogg")
|
||||
track2 = db.make_fake_track(2, 2, "Track 2", "/a/b/c/2.ogg")
|
||||
track3 = db.make_fake_track(3, 3, "Track 3", "/a/b/c/3.ogg")
|
||||
plist.sort = [ "tracks.number ASC" ]
|
||||
|
||||
plist.add_track(track1)
|
||||
plist.add_track(track2)
|
||||
plist.add_track(track3)
|
||||
plist.current = 1
|
||||
|
||||
self.assertTrue(plist.track_adjusts_current(track1))
|
||||
self.assertTrue(plist.track_adjusts_current(track2))
|
||||
self.assertFalse(plist.track_adjusts_current(track3))
|
||||
|
||||
plist.remove_track(track3)
|
||||
self.assertEqual(plist.current, 1)
|
||||
plist.remove_track(track1)
|
||||
self.assertEqual(plist.current, 0)
|
||||
plist.remove_track(track2)
|
||||
self.assertEqual(plist.current, -1)
|
||||
|
||||
def test_current(self):
|
||||
plist = db.user.Table.find("Test Playlist")
|
||||
self.assertEqual(plist.get_property("current"), -1)
|
||||
self.assertIsNone(plist.get_current_track())
|
||||
plist.set_property("current", 1)
|
||||
self.assertEqual(plist.get_property("current"), -1)
|
||||
|
||||
plist.add_track(db.make_fake_track(1, 1, "Track 1", "/a/b/c/1.ogg"))
|
||||
plist.add_track(db.make_fake_track(2, 2, "Track 2", "/a/b/c/2.ogg"))
|
||||
|
||||
plist.set_property("current", 0)
|
||||
self.assertEqual(plist.get_property("current"), 0)
|
||||
self.assertEqual(plist.get_current_track(), plist.get_track(0))
|
||||
|
||||
plist.set_property("current", 1)
|
||||
self.assertEqual(plist.get_property("current"), 1)
|
||||
self.assertEqual(plist.get_current_track(), plist.get_track(1))
|
||||
|
||||
plist.set_property("current", 2)
|
||||
self.assertEqual(plist.get_property("current"), 2)
|
||||
self.assertIsNone(plist.get_current_track())
|
||||
|
||||
plist.set_property("current", 3)
|
||||
self.assertEqual(plist.get_property("current"), 2)
|
||||
self.assertIsNone(plist.get_current_track())
|
||||
|
||||
def test_loop(self):
|
||||
plist = db.user.Table.find("Test Playlist")
|
||||
self.assertFalse(plist.get_property("loop"))
|
||||
plist.set_property("loop", True)
|
||||
self.assertTrue(plist.get_property("loop"))
|
||||
self.assertTrue(plist.plist_state.get_property("loop"))
|
||||
|
||||
plist.set_property("current", 0)
|
||||
self.assertEqual(plist.get_property("current"), -1)
|
||||
|
||||
plist.add_track(db.make_fake_track(1, 1, "Track 1", "/a/b/c/1.ogg"))
|
||||
plist.add_track(db.make_fake_track(2, 2, "Track 2", "/a/b/c/2.ogg"))
|
||||
|
||||
plist.set_property("current", 0)
|
||||
self.assertEqual(plist.get_current_track(), plist.get_track(0))
|
||||
plist.set_property("current", 1)
|
||||
self.assertEqual(plist.get_current_track(), plist.get_track(1))
|
||||
plist.set_property("current", 2)
|
||||
self.assertEqual(plist.get_property("current"), 0)
|
||||
self.assertEqual(plist.get_current_track(), plist.get_track(0))
|
||||
|
||||
def test_random(self):
|
||||
plist = db.user.Table.find("Test Playlist")
|
||||
self.assertFalse(plist.get_property("random"))
|
||||
plist.set_property("random", True)
|
||||
self.assertTrue(plist.get_property("random"))
|
||||
self.assertTrue(plist.plist_state.get_property("random"))
|
||||
|
||||
plist.set_property("current", 0)
|
||||
self.assertEqual(plist.get_property("current"), -1)
|
||||
|
||||
plist.add_track(db.make_fake_track(1, 1, "Track 1", "/a/b/c/1.ogg"))
|
||||
plist.add_track(db.make_fake_track(2, 2, "Track 2", "/a/b/c/2.ogg"))
|
||||
|
||||
plist.set_property("current", 0)
|
||||
self.assertEqual(plist.get_current_track(), plist.get_track(0))
|
||||
plist.set_property("current", 5)
|
||||
self.assertEqual(plist.get_property("current"), 1)
|
||||
self.assertEqual(plist.get_current_track(), plist.get_track(1))
|
||||
|
||||
def test_sort(self):
|
||||
plist = db.user.Table.find("Test Playlist")
|
||||
plist.connect("refreshed", self.on_refresh)
|
||||
self.assertEqual(plist.get_property("sort"), plist.plist_state.sort)
|
||||
|
||||
plist.set_property("sort", [ "tracks.number ASC" ])
|
||||
self.assertEqual(plist.get_property("sort"),
|
||||
[ "tracks.number ASC", "tracks.trackid ASC" ])
|
||||
self.assertEqual(plist.plist_state.get_property("sort"),
|
||||
[ "tracks.number ASC", "tracks.trackid ASC" ])
|
||||
self.assertEqual(plist.get_property("current"), -1)
|
||||
self.assertTrue(self.refreshed)
|
||||
|
||||
plist.add_track(db.make_fake_track(1, 1, "Track 1", "/a/b/c/1.ogg"))
|
||||
plist.add_track(db.make_fake_track(2, 2, "Track 2", "/a/b/c/2.ogg"))
|
||||
|
||||
plist.set_property("sort", [ "tracks.number DESC" ])
|
||||
self.assertEqual(plist.get_property("sort"),
|
||||
[ "tracks.number DESC", "tracks.trackid ASC" ])
|
||||
self.assertEqual(plist.get_property("current"), -1)
|
||||
|
||||
plist.set_property("current", 0)
|
||||
self.assertEqual(plist.get_current_track().number, 2)
|
||||
plist.set_property("sort", [ "tracks.number ASC" ])
|
||||
self.assertEqual(plist.get_property("current"), 1)
|
||||
|
||||
def test_passthrough_plstateid(self):
|
||||
plist = db.user.Table.find("Test Playlist")
|
||||
self.assertEqual(plist.plstateid, plist.plist_state.rowid)
|
||||
|
||||
def test_next_track(self):
|
||||
plist = db.user.Table.find("Test Playlist")
|
||||
self.assertIsNone(plist.next_track())
|
||||
self.assertEqual(plist.get_property("current"), -1)
|
||||
|
||||
plist.add_track(db.make_fake_track(1, 1, "Track 1", "/a/b/c/1.ogg"))
|
||||
plist.add_track(db.make_fake_track(2, 2, "Track 2", "/a/b/c/2.ogg"))
|
||||
self.assertEqual(plist.next_track(), plist.get_track(0))
|
||||
self.assertEqual(plist.next_track(), plist.get_track(1))
|
||||
self.assertIsNone(plist.next_track())
|
||||
self.assertIsNone(plist.next_track())
|
||||
|
||||
def test_random_next_track(self):
|
||||
plist = db.user.Table.find("Test Playlist")
|
||||
plist.random = True
|
||||
self.assertIsNone(plist.next_track())
|
||||
self.assertEqual(plist.get_property("current"), -1)
|
||||
|
||||
plist.add_track(db.make_fake_track(1, 1, "Track 1", "/a/b/c/1.ogg"))
|
||||
self.assertEqual(plist.next_track(), plist.get_track(0))
|
||||
self.assertEqual(plist.get_property("current"), 0)
|
||||
|
||||
plist.add_track(db.make_fake_track(2, 2, "Track 2", "/a/b/c/2.ogg"))
|
||||
plist.add_track(db.make_fake_track(3, 3, "Track 3", "/a/b/c/3.ogg"))
|
||||
|
||||
random.seed(20210318)
|
||||
plist.current = -1
|
||||
self.assertEqual(plist.next_track(), plist.get_track(1))
|
||||
self.assertEqual(plist.next_track(), plist.get_track(2))
|
||||
self.assertEqual(plist.next_track(), plist.get_track(0))
|
||||
self.assertEqual(plist.next_track(), plist.get_track(2))
|
||||
|
||||
|
||||
class TestMappedPlaylist(unittest.TestCase):
|
||||
def test_init(self):
|
||||
mapped = playlist.MappedPlaylist(TestRow(), "missing-icon", "test_map")
|
||||
|
||||
self.assertIsInstance(mapped, playlist.Playlist)
|
||||
self.assertEqual(mapped._map_table, "test_map")
|
||||
self.assertEqual(mapped.get_property("map-table"), "test_map")
|
||||
|
||||
|
||||
class TestParentPlaylist(unittest.TestCase):
|
||||
def test_init(self):
|
||||
parent = playlist.ParentPlaylist(TestRow(), "missing-icon")
|
||||
|
||||
self.assertIsInstance(parent, playlist.Playlist)
|
||||
self.assertTrue(parent.has_children())
|
||||
|
||||
with self.assertRaises(NotImplementedError):
|
||||
parent.get_child_table()
|
||||
with self.assertRaises(NotImplementedError):
|
||||
parent.get_n_children()
|
||||
with self.assertRaises(NotImplementedError):
|
||||
parent.get_child(0)
|
||||
with self.assertRaises(NotImplementedError):
|
||||
parent.get_child_index(0)
|
|
@ -1,14 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import lib
|
||||
import sqlite3
|
||||
import unittest
|
||||
from . import sql
|
||||
|
||||
class TestSQL(unittest.TestCase):
|
||||
def test_init(self):
|
||||
self.assertEqual(sql.File, lib.data.emmental_data / "emmental.sqlite")
|
||||
self.assertIsInstance(sql.Connection, sqlite3.Connection)
|
||||
self.assertEqual(sql.Connection.row_factory, sqlite3.Row)
|
||||
|
||||
self.assertEqual(sql.commit, sql.Connection.commit)
|
||||
self.assertEqual(sql.execute, sql.Connection.execute)
|
|
@ -1,82 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import db
|
||||
import sqlite3
|
||||
import unittest
|
||||
from gi.repository import GObject
|
||||
|
||||
|
||||
class TestPlaylistState(unittest.TestCase):
|
||||
def test_init(self):
|
||||
state = db.state.Table.insert()
|
||||
|
||||
self.assertIsInstance(state, GObject.GObject)
|
||||
self.assertIsNotNone(state._plstateid)
|
||||
self.assertEqual(state.rowid, state._plstateid)
|
||||
|
||||
def test_random(self):
|
||||
state = db.state.Table.insert()
|
||||
self.assertFalse(state._random)
|
||||
self.assertFalse(state.get_property("random"))
|
||||
|
||||
state.random = True
|
||||
self.assertTrue(state._random)
|
||||
|
||||
def test_loop(self):
|
||||
state = db.state.Table.insert()
|
||||
self.assertFalse(state._loop)
|
||||
self.assertFalse(state.get_property("loop"))
|
||||
|
||||
state.loop = True
|
||||
self.assertTrue(state._loop)
|
||||
|
||||
def test_current(self):
|
||||
state = db.state.Table.insert()
|
||||
self.assertEqual(state._current, -1)
|
||||
self.assertEqual(state.current, -1)
|
||||
|
||||
state.current = 3
|
||||
self.assertEqual(state._current, 3)
|
||||
|
||||
def test_sort(self):
|
||||
state = db.state.Table.insert()
|
||||
self.assertEqual(state._sort, ",".join(db.state.DefaultSort))
|
||||
self.assertEqual(state.sort, db.state.DefaultSort)
|
||||
|
||||
state.sort = [ "test", "sort" ]
|
||||
self.assertEqual(state._sort, "test,sort,tracks.trackid ASC" )
|
||||
state.sort = [ ]
|
||||
self.assertEqual(state._sort, "tracks.trackid ASC" )
|
||||
|
||||
|
||||
class TestPlaylistStateTable(unittest.TestCase):
|
||||
def setUp(self):
|
||||
db.reset()
|
||||
|
||||
def test_init(self):
|
||||
table = db.state.PlaylistStateTable()
|
||||
self.assertIsInstance(table, db.table.Table)
|
||||
self.assertEqual(table.table, "playlist_states")
|
||||
|
||||
self.assertIsInstance(db.state.Table, db.state.PlaylistStateTable)
|
||||
db.sql.execute("SELECT plstateid,random,loop,current,sort "
|
||||
"FROM playlist_states")
|
||||
|
||||
def test_default_sort(self):
|
||||
self.assertEqual(db.state.DefaultSort[0], "artists.sort ASC")
|
||||
self.assertEqual(db.state.DefaultSort[1], "albums.release ASC")
|
||||
self.assertEqual(db.state.DefaultSort[2], "albums.sort ASC")
|
||||
self.assertEqual(db.state.DefaultSort[3], "discs.number ASC")
|
||||
self.assertEqual(db.state.DefaultSort[4], "tracks.number ASC")
|
||||
|
||||
def test_insert(self):
|
||||
table = db.state.PlaylistStateTable()
|
||||
state = table.insert()
|
||||
|
||||
self.assertFalse(state.random)
|
||||
self.assertFalse(state.loop)
|
||||
self.assertEqual(state.current, -1)
|
||||
self.assertEqual(state.sort, db.state.DefaultSort)
|
||||
|
||||
def test_lookup(self):
|
||||
with self.assertRaises(NotImplementedError):
|
||||
db.state.Table.lookup()
|
189
db/test_table.py
189
db/test_table.py
|
@ -1,189 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import unittest
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gio
|
||||
from . import sql
|
||||
from . import table
|
||||
|
||||
class FakeRow(GObject.GObject):
|
||||
def __init__(self, data):
|
||||
GObject.GObject.__init__(self)
|
||||
self.rowid = data["fakeid"]
|
||||
self.name = data["name"]
|
||||
|
||||
class FakeTable(table.Table):
|
||||
def __init__(self):
|
||||
table.Table.__init__(self, "fake_table")
|
||||
self.reset()
|
||||
|
||||
def do_create(self):
|
||||
sql.execute("CREATE TABLE IF NOT EXISTS fake_table "
|
||||
"(fakeid INTEGER PRIMARY KEY, name TEXT UNIQUE)")
|
||||
|
||||
def do_factory(self, row):
|
||||
return FakeRow(row)
|
||||
|
||||
def do_insert(self, name):
|
||||
return sql.execute("INSERT INTO fake_table (name) VALUES (?)", [ name ])
|
||||
|
||||
def do_lookup(self, name):
|
||||
return sql.execute("SELECT * FROM fake_table WHERE name=?", [ name ])
|
||||
|
||||
class FakeModel(table.Model, FakeTable):
|
||||
def __init__(self):
|
||||
table.Model.__init__(self, "fake_table", "lower(name)")
|
||||
self.reset()
|
||||
|
||||
class FakeChild(table.Child):
|
||||
def __init__(self):
|
||||
table.Child.__init__(self, "fake_child", "parentid", "lower(name)")
|
||||
self.reset()
|
||||
|
||||
def do_create(self):
|
||||
sql.execute("CREATE TABLE IF NOT EXISTS fake_child "
|
||||
"(fakeid INTEGER PRIMARY KEY, "
|
||||
"parentid INTEGER, "
|
||||
"name TEXT UNIQUE, "
|
||||
"FOREIGN KEY(parentid) REFERENCES fake_table(fakeid))")
|
||||
|
||||
def do_factory(self, row):
|
||||
return FakeRow(row)
|
||||
|
||||
def do_insert(self, parent, name):
|
||||
return sql.execute("INSERT INTO fake_child (parentid, name) "
|
||||
"VALUES (?,?)", [ parent.rowid, name ])
|
||||
|
||||
|
||||
class TestTable(unittest.TestCase):
|
||||
def test_init(self):
|
||||
fake = FakeTable()
|
||||
self.assertIsInstance(fake.cache, dict)
|
||||
self.assertEqual(fake.table, "fake_table")
|
||||
|
||||
def test_interface(self):
|
||||
with self.assertRaises(NotImplementedError):
|
||||
table.Table.do_create(None)
|
||||
with self.assertRaises(NotImplementedError):
|
||||
table.Table.do_factory(None, None)
|
||||
with self.assertRaises(NotImplementedError):
|
||||
table.Table.do_insert(None, "Text")
|
||||
with self.assertRaises(NotImplementedError):
|
||||
table.Table.do_lookup(None, "Text")
|
||||
|
||||
def test_insert_delete(self):
|
||||
fake = FakeTable()
|
||||
row = fake.insert("Test Name")
|
||||
self.assertIsInstance(row, FakeRow)
|
||||
self.assertEqual(fake.cache[1], row)
|
||||
self.assertEqual(fake.get(1), row)
|
||||
|
||||
fake.delete(row)
|
||||
self.assertEqual(fake.cache, { })
|
||||
|
||||
def test_find(self):
|
||||
fake = FakeTable()
|
||||
row = fake.find("Test Name")
|
||||
self.assertIsInstance(row, FakeRow)
|
||||
self.assertEqual(fake.cache[1], row)
|
||||
self.assertEqual(fake.find("Test Name"), row)
|
||||
|
||||
def test_lookup(self):
|
||||
fake = FakeTable()
|
||||
row = fake.insert("Test Name")
|
||||
fake.cache.clear()
|
||||
|
||||
row = fake.lookup("Test Name")
|
||||
self.assertEqual(row.name, "Test Name")
|
||||
self.assertEqual(fake.cache, { 1 : row })
|
||||
self.assertEqual(fake.lookup("Test Name"), row)
|
||||
|
||||
def test_reset(self):
|
||||
fake = FakeTable()
|
||||
sql.execute("SELECT fakeid,name FROM fake_table")
|
||||
fake.insert("Test Name")
|
||||
|
||||
fake.reset()
|
||||
self.assertEqual(fake.cache, { })
|
||||
|
||||
|
||||
class TestModel(unittest.TestCase):
|
||||
def items_changed(self, table, pos, rm, add):
|
||||
self.changed = (pos, rm, add)
|
||||
|
||||
def setUp(self):
|
||||
self.changed = None
|
||||
|
||||
def test_init(self):
|
||||
fake = FakeModel()
|
||||
self.assertIsInstance(fake, GObject.GObject)
|
||||
self.assertIsInstance(fake, Gio.ListModel)
|
||||
self.assertIsInstance(fake, table.Table)
|
||||
self.assertEqual(fake.order, "lower(name)")
|
||||
|
||||
def test_insert_delete(self):
|
||||
fake = FakeModel()
|
||||
fake.connect("items-changed", self.items_changed)
|
||||
|
||||
row = fake.insert("Test Row")
|
||||
self.assertEqual(self.changed, (0, 0, 1))
|
||||
|
||||
fake.delete(row)
|
||||
self.assertEqual(self.changed, (0, 1, 0))
|
||||
|
||||
def test_model(self):
|
||||
fake = FakeModel()
|
||||
self.assertEqual(fake.get_item_type(), GObject.TYPE_PYOBJECT)
|
||||
self.assertEqual(fake.get_n_items(), 0)
|
||||
|
||||
c = fake.insert("C")
|
||||
self.assertEqual(fake.get_n_items(), 1)
|
||||
self.assertEqual(fake.get_item(0), c)
|
||||
|
||||
b = fake.insert("B")
|
||||
self.assertEqual(fake.get_n_items(), 2)
|
||||
self.assertEqual(fake.get_item(0), b)
|
||||
self.assertEqual(fake.get_item(1), c)
|
||||
|
||||
a = fake.insert("A")
|
||||
self.assertEqual(fake.get_n_items(), 3)
|
||||
self.assertEqual(fake.get_item(0), a)
|
||||
self.assertEqual(fake.get_item(1), b)
|
||||
self.assertEqual(fake.get_item(2), c)
|
||||
|
||||
def test_reset(self):
|
||||
fake = FakeModel()
|
||||
fake.insert("Test Row")
|
||||
fake.insert("Test Row 2")
|
||||
fake.connect("items-changed", self.items_changed)
|
||||
fake.reset()
|
||||
self.assertEqual(self.changed, (0, 2, 0))
|
||||
|
||||
|
||||
class TestChild(unittest.TestCase):
|
||||
def test_init(self):
|
||||
child = FakeChild()
|
||||
|
||||
self.assertIsInstance(child, table.Table)
|
||||
self.assertEqual(child.parent, "parentid")
|
||||
self.assertEqual(child.order, "lower(name)")
|
||||
|
||||
def test_children(self):
|
||||
model = FakeModel()
|
||||
child = FakeChild()
|
||||
parent = model.insert("Fake Parent")
|
||||
|
||||
self.assertEqual(child.get_n_children(parent), 0)
|
||||
|
||||
c = child.insert(parent, "C")
|
||||
b = child.insert(parent, "B")
|
||||
a = child.insert(parent, "A")
|
||||
|
||||
self.assertEqual(child.get_n_children(parent), 3)
|
||||
|
||||
self.assertEqual(child.get_child(parent, 0), a)
|
||||
self.assertEqual(child.get_child(parent, 1), b)
|
||||
self.assertEqual(child.get_child(parent, 2), c)
|
||||
|
||||
self.assertEqual(child.get_child_index(parent, a), 0)
|
||||
self.assertEqual(child.get_child_index(parent, b), 1)
|
||||
self.assertEqual(child.get_child_index(parent, c), 2)
|
|
@ -1,95 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import datetime
|
||||
import db
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import unittest
|
||||
from gi.repository import GObject
|
||||
|
||||
class TestTrack(unittest.TestCase):
|
||||
def setUp(self):
|
||||
db.reset()
|
||||
|
||||
def test_init(self):
|
||||
track = db.make_fake_track(1, 1.234, "Test Title", "/a/b/c/1.ogg")
|
||||
self.assertIsInstance(track.library, db.library.Library)
|
||||
self.assertIsInstance(track.artist, db.artist.Artist)
|
||||
self.assertIsInstance(track.album, db.album.Album)
|
||||
self.assertIsInstance(track.disc, db.disc.Disc)
|
||||
self.assertIsInstance(track.decade, db.decade.Decade)
|
||||
self.assertIsInstance(track.year, db.year.Year)
|
||||
self.assertEqual(track.number, 1)
|
||||
self.assertEqual(track.playcount, 0)
|
||||
self.assertIsNone(track.lastplayed, None)
|
||||
self.assertEqual(track.length, 1.234)
|
||||
self.assertEqual(track.title, "Test Title")
|
||||
|
||||
def test_genres(self):
|
||||
track = db.make_fake_track(1, 1.234, "Test Title", "/a/b/c/1.ogg")
|
||||
self.assertEqual(track.genres(), [ ])
|
||||
|
||||
genre = db.genre.Table.find("Test Genre")
|
||||
genre.add_track(track)
|
||||
self.assertEqual(track.genres(), [ genre ])
|
||||
|
||||
db.track.Table.delete(track)
|
||||
self.assertEqual(genre.get_n_tracks(), 0)
|
||||
|
||||
def test_playlists(self):
|
||||
new = db.user.Table.lookup("New Tracks")
|
||||
favorites = db.user.Table.lookup("Favorites")
|
||||
|
||||
track = db.make_fake_track(1, 1.234, "Test Title", "/a/b/c/1.ogg")
|
||||
self.assertEqual(track.playlists(), [ new ])
|
||||
favorites.add_track(track)
|
||||
self.assertEqual(track.playlists(), [ favorites, new ])
|
||||
|
||||
db.track.Table.delete(track)
|
||||
self.assertEqual(new.get_n_tracks(), 0)
|
||||
self.assertEqual(favorites.get_n_tracks(), 0)
|
||||
|
||||
def test_played(self):
|
||||
track = db.make_fake_track(1, 1.234, "Test Title", "/a/b/c/1.ogg")
|
||||
track.played()
|
||||
self.assertEqual(track.playcount, 1)
|
||||
self.assertEqual(track.lastplayed.date(), datetime.date.today())
|
||||
|
||||
|
||||
class TestTrackTable(unittest.TestCase):
|
||||
def setUp(self):
|
||||
db.reset()
|
||||
|
||||
def test_init(self):
|
||||
table = db.track.TrackTable()
|
||||
self.assertIsInstance(table, db.table.Table)
|
||||
self.assertEqual(table.table, "tracks")
|
||||
|
||||
self.assertIsInstance(db.track.Table, db.track.TrackTable)
|
||||
db.sql.execute("SELECT trackid,libraryid,artistid,albumid,discid,decadeid,yearid FROM tracks")
|
||||
db.sql.execute("SELECT number,playcount,lastplayed,length,title,path FROM tracks")
|
||||
|
||||
def test_insert(self):
|
||||
library = db.library.Table.find(pathlib.Path("/a/b/c"))
|
||||
artist = db.artist.Table.find("Test Artist", "test artist")
|
||||
album = artist.find_album("Test Album", datetime.date(2021, 3, 18))
|
||||
disc = album.find_disc(1, None)
|
||||
decade = db.decade.Table.find(2020)
|
||||
year = decade.find_year(2021)
|
||||
track = db.track.Table.insert(library, artist, album, disc, decade,
|
||||
year, 1, 1.234, "Test Title",
|
||||
pathlib.Path("/a/b/c/d.efg"))
|
||||
|
||||
self.assertIsInstance(track, db.track.Track)
|
||||
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
db.track.Table.insert(library, artist, album, disc, decade, year,
|
||||
1, 1.234, "Test Title", pathlib.Path("/a/b/c/d.efg"))
|
||||
|
||||
def test_lookup(self):
|
||||
track = db.make_fake_track(1, 1.234, "Test Title", "/a/b/c/d.efg")
|
||||
self.assertEqual(db.track.Table.lookup(pathlib.Path("/a/b/c/d.efg")), track)
|
||||
self.assertIsNone(db.library.Table.lookup(pathlib.Path("/a/b/d/h.ijk")))
|
||||
|
||||
def test_find(self):
|
||||
with self.assertRaises(NotImplementedError):
|
||||
db.track.Table.find(pathlib.Path("/a/b/c/d.efg"))
|
386
db/test_user.py
386
db/test_user.py
|
@ -1,386 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import db
|
||||
import sqlite3
|
||||
import unittest
|
||||
from gi.repository import GObject
|
||||
from . import sql
|
||||
|
||||
class TestCollection(unittest.TestCase):
|
||||
def track_added(self, plist, added):
|
||||
self.added = added
|
||||
|
||||
def track_removed(self, plist, removed, adjusted_current):
|
||||
self.removed = (removed, adjusted_current)
|
||||
|
||||
def refreshed(self, plist):
|
||||
self.refreshed = True
|
||||
|
||||
def setUp(self): db.reset()
|
||||
|
||||
def test_init(self):
|
||||
collection = db.user.Table.find("Collection")
|
||||
self.assertIsInstance(collection, db.playlist.Playlist)
|
||||
self.assertIsInstance(collection, db.user.Collection)
|
||||
self.assertEqual(collection.name, "Collection")
|
||||
self.assertEqual(collection.icon_name, "media-playback-start")
|
||||
self.assertTrue(collection.plist_state.loop)
|
||||
|
||||
def test_tracks(self):
|
||||
collection = db.user.Table.find("Collection")
|
||||
collection.connect("track-added", self.track_added)
|
||||
self.assertEqual(collection.get_n_tracks(), 0)
|
||||
self.assertEqual(collection.get_tracks(), [ ])
|
||||
|
||||
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
|
||||
self.assertEqual(collection.get_n_tracks(), 1)
|
||||
self.assertEqual(collection.get_track(0), track)
|
||||
self.assertEqual(collection.get_tracks(), [ track ])
|
||||
self.assertEqual(collection.get_track_index(track), 0)
|
||||
self.assertEqual(self.added, track)
|
||||
|
||||
collection.connect("track-removed", self.track_removed)
|
||||
db.track.Table.delete(track)
|
||||
self.assertEqual(collection.get_tracks(), [ ])
|
||||
self.assertEqual(self.removed, (track, False))
|
||||
|
||||
def test_library_enabled(self):
|
||||
collection = db.user.Table.find("Collection")
|
||||
track1 = db.make_fake_track(1, 1, "Test Track 1", "/a/b/c/1.ogg")
|
||||
track2 = db.make_fake_track(2, 2, "Test Track 2", "/a/b/c/2.ogg")
|
||||
self.assertEqual(collection.get_n_tracks(), 2)
|
||||
self.assertEqual(collection.get_tracks(), [ track1, track2 ])
|
||||
collection.connect("refreshed", self.refreshed)
|
||||
|
||||
track1.library.enabled = False
|
||||
self.assertEqual(collection.get_n_tracks(), 0)
|
||||
self.assertEqual(collection.get_tracks(), [ ])
|
||||
self.assertTrue(self.refreshed)
|
||||
|
||||
self.refreshed = None
|
||||
track1.library.enabled = True
|
||||
self.assertEqual(collection.get_n_tracks(), 2)
|
||||
self.assertEqual(collection.get_tracks(), [ track1, track2 ])
|
||||
self.assertTrue(self.refreshed)
|
||||
|
||||
|
||||
class TestFavorites(unittest.TestCase):
|
||||
def track_added(self, plist, added):
|
||||
self.added = added
|
||||
|
||||
def track_removed(self, plist, removed, adjusted_current):
|
||||
self.removed = (removed, False)
|
||||
|
||||
def setUp(self): db.reset()
|
||||
|
||||
def test_init(self):
|
||||
favorites = db.user.Table.find("Favorites")
|
||||
self.assertIsInstance(favorites, db.playlist.MappedPlaylist)
|
||||
self.assertIsInstance(favorites, db.user.UserPlaylist)
|
||||
self.assertEqual(favorites.name, "Favorites")
|
||||
self.assertEqual(favorites.icon_name, "emmental-favorites")
|
||||
self.assertEqual(favorites.map_table, "playlist_map")
|
||||
self.assertFalse(favorites.plist_state.loop)
|
||||
self.assertTrue(favorites.can_add_remove_tracks())
|
||||
|
||||
def test_add_remove_track(self):
|
||||
favorites = db.user.Table.find("Favorites")
|
||||
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
|
||||
favorites.connect("track-added", self.track_added)
|
||||
self.assertEqual(favorites.get_n_tracks(), 0)
|
||||
self.assertEqual(favorites.get_tracks(), [ ])
|
||||
|
||||
self.assertTrue(favorites.add_track(track))
|
||||
self.assertFalse(favorites.add_track(track))
|
||||
self.assertEqual(favorites.get_n_tracks(), 1)
|
||||
self.assertEqual(favorites.get_track(0), track)
|
||||
self.assertEqual(favorites.get_tracks(), [ track ])
|
||||
self.assertEqual(favorites.get_track_index(track), 0)
|
||||
self.assertEqual(self.added, track)
|
||||
|
||||
favorites.connect("track-removed", self.track_removed)
|
||||
self.assertTrue(favorites.remove_track(track))
|
||||
self.assertFalse(favorites.remove_track(track))
|
||||
self.assertEqual(favorites.get_n_tracks(), 0)
|
||||
self.assertEqual(favorites.get_tracks(), [ ])
|
||||
self.assertEqual(self.removed, (track, False))
|
||||
|
||||
|
||||
class TestNewTracks(unittest.TestCase):
|
||||
def track_added(self, plist, added):
|
||||
self.added = added
|
||||
|
||||
def track_removed(self, plist, removed, adjusted_current):
|
||||
self.removed = (removed, adjusted_current)
|
||||
|
||||
def setUp(self): db.reset()
|
||||
|
||||
def test_init(self):
|
||||
new = db.user.Table.find("New Tracks")
|
||||
self.assertIsInstance(new, db.playlist.MappedPlaylist)
|
||||
self.assertIsInstance(new, db.user.UserPlaylist)
|
||||
self.assertEqual(new.name, "New Tracks")
|
||||
self.assertEqual(new.icon_name, "starred")
|
||||
self.assertEqual(new.map_table, "temp_playlist_map")
|
||||
self.assertFalse(new.plist_state.loop)
|
||||
self.assertFalse(new.can_add_remove_tracks())
|
||||
|
||||
def test_add_remove_track(self):
|
||||
new = db.user.Table.find("New Tracks")
|
||||
new.connect("track-added", self.track_added)
|
||||
self.assertEqual(new.get_n_tracks(), 0)
|
||||
self.assertEqual(new.get_tracks(), [ ])
|
||||
|
||||
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
|
||||
self.assertEqual(new.get_n_tracks(), 1)
|
||||
self.assertEqual(new.get_track(0), track)
|
||||
self.assertEqual(new.get_tracks(), [ track ])
|
||||
self.assertEqual(new.get_track_index(track), 0)
|
||||
self.added = track
|
||||
|
||||
new.connect("track-removed", self.track_removed)
|
||||
self.assertTrue(new.remove_track(track))
|
||||
self.assertFalse(new.remove_track(track))
|
||||
self.assertEqual(new.get_n_tracks(), 0)
|
||||
self.assertEqual(new.get_tracks(), [ ])
|
||||
self.assertEqual(self.removed, (track, False))
|
||||
|
||||
|
||||
class TestPrevious(unittest.TestCase):
|
||||
def track_added(self, plist, added):
|
||||
self.added = added
|
||||
|
||||
def track_removed(self, plist, removed, adjusted_current):
|
||||
self.removed = (removed, adjusted_current)
|
||||
|
||||
def setUp(self): db.reset()
|
||||
|
||||
def test_init(self):
|
||||
previous = db.user.Table.find("Previous")
|
||||
self.assertIsInstance(previous, db.user.UserPlaylist)
|
||||
self.assertIsInstance(previous, db.user.Previous)
|
||||
self.assertEqual(previous.name, "Previous")
|
||||
self.assertEqual(previous.icon_name, "media-skip-backward")
|
||||
self.assertEqual(previous.map_table, "temp_playlist_map")
|
||||
self.assertEqual(previous.plist_state.sort, [ "temp_playlist_map.rowid DESC" ])
|
||||
self.assertFalse(previous.plist_state.loop)
|
||||
self.assertFalse(previous.can_add_remove_tracks())
|
||||
|
||||
def test_add_remove_track(self):
|
||||
previous = db.user.Table.find("Previous")
|
||||
track1 = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
|
||||
track2 = db.make_fake_track(2, 2, "Test Track 2", "/a/b/c/2.ogg")
|
||||
previous.connect("track-added", self.track_added)
|
||||
self.assertEqual(previous.get_n_tracks(), 0)
|
||||
self.assertEqual(previous.get_tracks(), [ ])
|
||||
|
||||
self.assertTrue(previous.add_track(track1))
|
||||
self.assertEqual(previous.get_n_tracks(), 1)
|
||||
self.assertEqual(previous.get_track(0), track1)
|
||||
self.assertEqual(previous.get_tracks(), [ track1 ])
|
||||
self.assertEqual(previous.get_track_index(track1), 0)
|
||||
self.assertEqual(self.added, track1)
|
||||
|
||||
self.assertTrue(previous.add_track(track2))
|
||||
self.assertEqual(previous.get_n_tracks(), 2)
|
||||
self.assertEqual(previous.get_track(0), track2)
|
||||
self.assertEqual(previous.get_track(1), track1)
|
||||
self.assertEqual(previous.get_tracks(), [ track2, track1 ])
|
||||
self.assertEqual(previous.get_track_index(track2), 0)
|
||||
self.assertEqual(previous.get_track_index(track1), 1)
|
||||
self.assertEqual(self.added, track2)
|
||||
|
||||
previous.connect("track-removed", self.track_removed)
|
||||
self.assertTrue(previous.add_track(track1))
|
||||
self.assertEqual(previous.get_n_tracks(), 2)
|
||||
self.assertEqual(previous.get_track(0), track1)
|
||||
self.assertEqual(previous.get_track(1), track2)
|
||||
self.assertEqual(previous.get_tracks(), [ track1, track2 ])
|
||||
self.assertEqual(previous.get_track_index(track1), 0)
|
||||
self.assertEqual(previous.get_track_index(track2), 1)
|
||||
self.assertEqual(self.removed, (track1, False))
|
||||
self.assertEqual(self.added, track1)
|
||||
|
||||
self.assertTrue(previous.remove_track(track1))
|
||||
self.assertFalse(previous.remove_track(track1))
|
||||
self.assertEqual(previous.get_n_tracks(), 1)
|
||||
self.assertEqual(previous.get_tracks(), [ track2 ])
|
||||
self.assertEqual(previous.get_track_index(track2), 0)
|
||||
self.assertEqual(self.removed, (track1, True))
|
||||
|
||||
def test_previous_track(self):
|
||||
previous = db.user.Table.find("Previous")
|
||||
self.assertEqual(previous.get_property("current"), -1)
|
||||
|
||||
previous.add_track(db.make_fake_track(1, 1, "Track 1", "/a/b/c/1.ogg"))
|
||||
self.assertEqual(previous.get_property("current"), 0)
|
||||
previous.add_track(db.make_fake_track(2, 2, "Track 2", "/a/b/c/2.ogg"))
|
||||
self.assertEqual(previous.get_property("current"), 0)
|
||||
previous.add_track(db.make_fake_track(3, 3, "Track 3", "/a/b/c/3.ogg"))
|
||||
self.assertEqual(previous.get_property("current"), 0)
|
||||
|
||||
self.assertEqual(previous.previous_track(), previous.get_track(1))
|
||||
self.assertEqual(previous.previous_track(), previous.get_track(2))
|
||||
self.assertIsNone(previous.previous_track())
|
||||
self.assertIsNone(previous.previous_track())
|
||||
|
||||
def test_next_track(self):
|
||||
previous = db.user.Table.find("Previous")
|
||||
previous.add_track(db.make_fake_track(1, 1, "Track 1", "/a/b/c/1.ogg"))
|
||||
previous.add_track(db.make_fake_track(2, 2, "Track 2", "/a/b/c/2.ogg"))
|
||||
previous.add_track(db.make_fake_track(3, 3, "Track 3", "/a/b/c/3.ogg"))
|
||||
previous.current = 2
|
||||
|
||||
self.assertEqual(previous.next_track(), previous.get_track(1))
|
||||
self.assertEqual(previous.current, 1)
|
||||
self.assertEqual(previous.next_track(), previous.get_track(0))
|
||||
self.assertEqual(previous.current, 0)
|
||||
self.assertIsNone(previous.next_track())
|
||||
self.assertEqual(previous.current, -1)
|
||||
self.assertIsNone(previous.next_track())
|
||||
self.assertEqual(previous.current, -1)
|
||||
|
||||
|
||||
class TestQueuedTracks(unittest.TestCase):
|
||||
def track_added(self, plist, added):
|
||||
self.added = added
|
||||
|
||||
def track_removed(self, plist, removed, adjusted_current):
|
||||
self.removed = (removed, adjusted_current)
|
||||
|
||||
def setUp(self): db.reset()
|
||||
|
||||
def test_init(self):
|
||||
queued = db.user.Table.find("Queued Tracks")
|
||||
self.assertIsInstance(queued, db.user.UserPlaylist)
|
||||
self.assertIsInstance(queued, db.user.QueuedTracks)
|
||||
self.assertEqual(queued.name, "Queued Tracks")
|
||||
self.assertEqual(queued.icon_name, "media-skip-forward")
|
||||
self.assertEqual(queued.map_table, "playlist_map")
|
||||
self.assertEqual(queued.plist_state.sort, [ "playlist_map.rowid ASC" ])
|
||||
self.assertFalse(queued.plist_state.loop)
|
||||
self.assertTrue(queued.can_add_remove_tracks())
|
||||
|
||||
def test_add_remove_track(self):
|
||||
queued = db.user.Table.find("Queued Tracks")
|
||||
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
|
||||
queued.connect("track-added", self.track_added)
|
||||
self.assertEqual(queued.get_n_tracks(), 0)
|
||||
self.assertEqual(queued.get_tracks(), [ ])
|
||||
|
||||
self.assertTrue(queued.add_track(track))
|
||||
self.assertFalse(queued.add_track(track))
|
||||
self.assertEqual(queued.get_n_tracks(), 1)
|
||||
self.assertEqual(queued.get_track(0), track)
|
||||
self.assertEqual(queued.get_tracks(), [ track ])
|
||||
self.assertEqual(queued.get_track_index(track), 0)
|
||||
self.assertEqual(self.added, track)
|
||||
|
||||
queued.connect("track-removed", self.track_removed)
|
||||
self.assertTrue(queued.remove_track(track))
|
||||
self.assertFalse(queued.remove_track(track))
|
||||
self.assertEqual(queued.get_n_tracks(), 0)
|
||||
self.assertEqual(queued.get_tracks(), [ ])
|
||||
self.assertEqual(self.removed, (track, False))
|
||||
|
||||
def test_next_track(self):
|
||||
queued = db.user.Table.find("Queued Tracks")
|
||||
queued.add_track(db.make_fake_track(1, 1, "Test 1", "/a/b/c/1.ogg"))
|
||||
queued.add_track(db.make_fake_track(2, 2, "Test 2", "/a/b/c/2.ogg"))
|
||||
queued.add_track(db.make_fake_track(3, 3, "Test 3", "/a/b/c/3.ogg"))
|
||||
|
||||
self.assertEqual(queued.next_track(), db.track.Table.lookup("/a/b/c/1.ogg"))
|
||||
self.assertEqual(queued.get_n_tracks(), 2)
|
||||
self.assertEqual(queued.next_track(), db.track.Table.lookup("/a/b/c/2.ogg"))
|
||||
self.assertEqual(queued.get_n_tracks(), 1)
|
||||
self.assertEqual(queued.next_track(), db.track.Table.lookup("/a/b/c/3.ogg"))
|
||||
self.assertEqual(queued.get_n_tracks(), 0)
|
||||
self.assertIsNone(queued.next_track())
|
||||
|
||||
|
||||
class TestUserPlaylist(unittest.TestCase):
|
||||
def track_added(self, plist, added):
|
||||
self.added = added
|
||||
|
||||
def track_removed(self, plist, removed, adjusted_current):
|
||||
self.removed = (removed, adjusted_current)
|
||||
|
||||
def setUp(self): db.reset()
|
||||
|
||||
def test_init(self):
|
||||
plist = db.user.Table.find("Test Playlist")
|
||||
self.assertIsInstance(plist, db.playlist.MappedPlaylist)
|
||||
self.assertIsInstance(plist, db.user.UserPlaylist)
|
||||
self.assertEqual(plist.name, "Test Playlist")
|
||||
self.assertEqual(plist.icon_name, "audio-x-generic")
|
||||
self.assertEqual(plist.map_table, "playlist_map")
|
||||
self.assertFalse(plist.plist_state.loop)
|
||||
self.assertTrue(plist.can_add_remove_tracks())
|
||||
|
||||
def test_delete(self):
|
||||
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
|
||||
plist = db.user.Table.find("Test Playlist")
|
||||
plist.add_track(track)
|
||||
plist.delete()
|
||||
self.assertEqual(plist.get_n_tracks(), 0)
|
||||
self.assertIsNone(db.user.Table.lookup("Test Playlist"))
|
||||
|
||||
def test_add_remove_track(self):
|
||||
plist = db.user.Table.find("Test Playlist")
|
||||
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
|
||||
plist.connect("track-added", self.track_added)
|
||||
self.assertEqual(plist.get_n_tracks(), 0)
|
||||
self.assertEqual(plist.get_tracks(), [ ])
|
||||
|
||||
self.assertTrue(plist.add_track(track))
|
||||
self.assertFalse(plist.add_track(track))
|
||||
self.assertEqual(plist.get_n_tracks(), 1)
|
||||
self.assertEqual(plist.get_track(0), track)
|
||||
self.assertEqual(plist.get_tracks(), [ track ])
|
||||
self.assertEqual(plist.get_track_index(track), 0)
|
||||
self.assertEqual(self.added, track)
|
||||
|
||||
plist.connect("track-removed", self.track_removed)
|
||||
self.assertTrue(plist.remove_track(track))
|
||||
self.assertFalse(plist.remove_track(track))
|
||||
self.assertEqual(plist.get_n_tracks(), 0)
|
||||
self.assertEqual(plist.get_tracks(), [ ])
|
||||
self.assertEqual(self.removed, (track, False))
|
||||
|
||||
|
||||
class TestUserTable(unittest.TestCase):
|
||||
def setUp(self):
|
||||
db.reset()
|
||||
|
||||
def test_init(self):
|
||||
table = db.user.UserTable()
|
||||
self.assertIsInstance(table, db.playlist.Model)
|
||||
self.assertEqual(table.table, "playlists")
|
||||
self.assertEqual(table.order, "sort")
|
||||
|
||||
self.assertIsInstance(db.user.Table, db.user.UserTable)
|
||||
db.sql.execute("SELECT playlistid,plstateid,name,sort FROM playlists")
|
||||
|
||||
def test_insert(self):
|
||||
table = db.user.UserTable()
|
||||
playlist = table.find("Test Playlist")
|
||||
|
||||
self.assertIsInstance(playlist, db.user.UserPlaylist)
|
||||
self.assertEqual(playlist._name, "Test Playlist")
|
||||
self.assertEqual(playlist._rowkey, "playlistid")
|
||||
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
db.user.Table.insert("Test Playlist")
|
||||
|
||||
def test_lookup(self):
|
||||
playlist = db.user.Table.insert("Test Playlist")
|
||||
self.assertEqual(db.user.Table.lookup("Test Playlist"), playlist)
|
||||
self.assertIsNone(db.user.Table.lookup("none"))
|
||||
|
||||
def test_default_playlists(self):
|
||||
table = db.user.UserTable()
|
||||
self.assertEqual(table.get_n_items(), 5)
|
||||
self.assertEqual(table.get_item(0).name, "Collection")
|
||||
self.assertEqual(table.get_item(1).name, "Favorites")
|
||||
self.assertEqual(table.get_item(2).name, "New Tracks")
|
||||
self.assertEqual(table.get_item(3).name, "Previous")
|
||||
self.assertEqual(table.get_item(4).name, "Queued Tracks")
|
|
@ -1,79 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import db
|
||||
import sqlite3
|
||||
import unittest
|
||||
from gi.repository import GObject
|
||||
|
||||
class TestYear(unittest.TestCase):
|
||||
def track_added(self, plist, added):
|
||||
self.added = added
|
||||
|
||||
def track_removed(self, plist, removed, adjusted_current):
|
||||
self.removed = (removed, adjusted_current)
|
||||
|
||||
def setUp(self):
|
||||
db.reset()
|
||||
|
||||
def test_init(self):
|
||||
decade = db.decade.Table.find(2020)
|
||||
year = decade.find_year(2021)
|
||||
self.assertIsInstance(year, db.playlist.Playlist)
|
||||
self.assertEqual(year.get_property("name"), "2021")
|
||||
self.assertEqual(year.get_property("year"), 2021)
|
||||
self.assertEqual(year.get_property("icon-name"), "x-office-calendar")
|
||||
|
||||
def test_delete(self):
|
||||
decade = db.decade.Table.find(2020)
|
||||
year = decade.find_year(2021)
|
||||
year.delete()
|
||||
self.assertIsNone(db.year.Table.lookup(2021))
|
||||
|
||||
def test_tracks(self):
|
||||
decade = db.decade.Table.find(2020)
|
||||
year = decade.find_year(2021)
|
||||
year.connect("track-added", self.track_added)
|
||||
self.assertEqual(year.get_n_tracks(), 0)
|
||||
self.assertEqual(year.get_tracks(), [ ])
|
||||
|
||||
track = db.make_fake_track(1, 1, "Test Track", "/a/b/c/1.ogg")
|
||||
self.assertEqual(year.get_n_tracks(), 1)
|
||||
self.assertEqual(year.get_track(0), track)
|
||||
self.assertEqual(year.get_tracks(), [ track ])
|
||||
self.assertEqual(year.get_track_index(track), 0)
|
||||
self.assertEqual(self.added, track)
|
||||
|
||||
year.connect("track-removed", self.track_removed)
|
||||
db.track.Table.delete(track)
|
||||
self.assertEqual(year.get_tracks(), [ ])
|
||||
self.assertEqual(self.removed, (track, False))
|
||||
|
||||
|
||||
class TestYearTable(unittest.TestCase):
|
||||
def setUp(self):
|
||||
db.reset()
|
||||
|
||||
def test_init(self):
|
||||
table = db.year.YearTable()
|
||||
self.assertIsInstance(table, db.playlist.ChildModel)
|
||||
self.assertEqual(table.table, "years")
|
||||
self.assertEqual(table.order, "year")
|
||||
|
||||
self.assertIsInstance(db.year.Table, db.year.YearTable)
|
||||
db.sql.execute("SELECT yearid,decadeid,plstateid,year FROM years")
|
||||
|
||||
def test_insert(self):
|
||||
decade = db.decade.Table.insert(2020)
|
||||
year = decade.find_year(2021)
|
||||
|
||||
self.assertIsInstance(year, db.year.Year)
|
||||
self.assertEqual(year._year, 2021)
|
||||
self.assertEqual(year._rowkey, "yearid")
|
||||
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
db.year.Table.insert(decade, 2021)
|
||||
|
||||
def test_lookup(self):
|
||||
decade = db.decade.Table.find(2020)
|
||||
year = decade.find_year(2021)
|
||||
self.assertEqual(db.year.Table.lookup(2021), year)
|
||||
self.assertIsNone(db.year.Table.lookup(2022))
|
178
db/track.py
178
db/track.py
|
@ -1,178 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
#
|
||||
# Table: tracks
|
||||
# +---------+-----------+------------+---------+--------+--------+
|
||||
# | trackid | libraryid | artistid | albumid | discid | yearid |
|
||||
# +---------+-----------+------------+---------+--------+--------+
|
||||
# | number | playcount | lastplayed | length | title | path |
|
||||
# +---------+-----------+------------+---------+--------+--------|
|
||||
import datetime
|
||||
import pathlib
|
||||
from gi.repository import GObject
|
||||
from . import artist
|
||||
from . import album
|
||||
from . import decade
|
||||
from . import disc
|
||||
from . import genre
|
||||
from . import library
|
||||
from . import sql
|
||||
from . import table
|
||||
from . import user
|
||||
from . import year
|
||||
|
||||
class Track(GObject.GObject):
|
||||
def __init__(self, row):
|
||||
GObject.GObject.__init__(self)
|
||||
self._trackid = row["trackid"]
|
||||
self._library = library.Table.get(row["libraryid"])
|
||||
self._artist = artist.Table.get(row["artistid"])
|
||||
self._album = album.Table.get(row["albumid"])
|
||||
self._disc = disc.Table.get(row["discid"])
|
||||
self._decade = decade.Table.get(row["decadeid"])
|
||||
self._year = year.Table.get(row["yearid"])
|
||||
self._number = row["number"]
|
||||
self._playcount = row["playcount"]
|
||||
self._lastplayed = row["lastplayed"]
|
||||
self._length = row["length"]
|
||||
self._title = row["title"]
|
||||
self._path = pathlib.Path(row["path"])
|
||||
|
||||
@GObject.Property
|
||||
def rowid(self): return self._trackid
|
||||
|
||||
@GObject.Property
|
||||
def library(self): return self._library
|
||||
|
||||
@GObject.Property
|
||||
def artist(self): return self._artist
|
||||
|
||||
@GObject.Property
|
||||
def album(self): return self._album
|
||||
|
||||
@GObject.Property
|
||||
def disc(self): return self._disc
|
||||
|
||||
@GObject.Property
|
||||
def decade(self): return self._decade
|
||||
|
||||
@GObject.Property
|
||||
def year(self): return self._year
|
||||
|
||||
@GObject.Property
|
||||
def number(self): return self._number
|
||||
|
||||
@GObject.Property
|
||||
def playcount(self): return self._playcount
|
||||
|
||||
@playcount.setter
|
||||
def playcount(self, newval):
|
||||
self._playcount = self.update("playcount", newval)
|
||||
|
||||
@GObject.Property
|
||||
def lastplayed(self): return self._lastplayed
|
||||
|
||||
@lastplayed.setter
|
||||
def lastplayed(self, newval):
|
||||
self._lastplayed = self.update("lastplayed", newval)
|
||||
|
||||
@GObject.Property
|
||||
def length(self): return self._length
|
||||
|
||||
@GObject.Property
|
||||
def title(self): return self._title
|
||||
|
||||
@GObject.Property
|
||||
def path(self): return self._path
|
||||
|
||||
def genres(self):
|
||||
rows = sql.execute(f"SELECT genreid FROM genre_map WHERE trackid=?",
|
||||
[ self.rowid ]).fetchall()
|
||||
return [ genre.Table.get(row[0]) for row in rows ]
|
||||
|
||||
def playlists(self):
|
||||
rows = sql.execute(f"SELECT playlistid FROM playlist_map UNION "
|
||||
f"SELECT playlistid FROM temp_playlist_map "
|
||||
f"WHERE trackid=?", [ self.rowid ]).fetchall()
|
||||
return [ user.Table.get(row[0]) for row in rows ]
|
||||
|
||||
def update(self, column, newval):
|
||||
sql.execute(f"UPDATE tracks SET {column}=? WHERE trackid=?",
|
||||
[ newval, self.rowid ])
|
||||
return newval
|
||||
|
||||
def played(self):
|
||||
self.playcount += 1
|
||||
self.lastplayed = datetime.datetime.now()
|
||||
sql.commit()
|
||||
|
||||
|
||||
class TrackTable(table.Table):
|
||||
def __init__(self):
|
||||
table.Table.__init__(self, "tracks")
|
||||
|
||||
def do_create(self):
|
||||
sql.execute("CREATE TABLE IF NOT EXISTS tracks "
|
||||
"(trackid INTEGER PRIMARY KEY, "
|
||||
" libraryid INTEGER, "
|
||||
" artistid INTEGER, "
|
||||
" albumid INTEGER, "
|
||||
" discid INTEGER, "
|
||||
" decadeid INTEGER, "
|
||||
" yearid INTEGER, "
|
||||
" number INTEGER, "
|
||||
" playcount INTEGER DEFAULT 0,"
|
||||
" lastplayed TIMESTAMP DEFAULT NULL, "
|
||||
" length REAL, "
|
||||
" title TEXT, "
|
||||
" path TEXT UNIQUE, "
|
||||
" FOREIGN KEY(libraryid) REFERENCES libraries(libraryid), "
|
||||
" FOREIGN KEY(artistid) REFERENCES artists(artistid), "
|
||||
" FOREIGN KEY(albumid) REFERENCES albums(albumid), "
|
||||
" FOREIGN KEY(discid) REFERENCES discs(discid), "
|
||||
" FOREIGN KEY(yearid) REFERENCES years(yearid))")
|
||||
|
||||
def do_factory(self, row):
|
||||
return Track(row)
|
||||
|
||||
def do_insert(self, library, artist, album, disc, decade,
|
||||
year, number, length, title, path):
|
||||
return sql.execute("INSERT INTO tracks (libraryid, artistid, albumid, "
|
||||
"discid, decadeid, yearid, "
|
||||
"number, length, title, path) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
[ library.rowid, artist.rowid, album.rowid, disc.rowid,
|
||||
decade.rowid, year.rowid, number, length, title, str(path) ])
|
||||
|
||||
def do_lookup(self, path):
|
||||
return sql.execute("SELECT * FROM tracks WHERE path=?", [ str(path) ])
|
||||
|
||||
def delete(self, track):
|
||||
for plist in track.genres() + track.playlists():
|
||||
plist.remove_track(track)
|
||||
|
||||
plists = [ track.artist, track.album, track.disc,
|
||||
track.decade, track.year, track.library,
|
||||
user.Table.find("Collection") ]
|
||||
adjust = [ p.track_adjusts_current(track) for p in plists ]
|
||||
|
||||
super().delete(track)
|
||||
for (plist, adjust) in zip(plists, adjust):
|
||||
plist.remove_track(track, adjust)
|
||||
|
||||
def find(self, *args):
|
||||
raise NotImplementedError
|
||||
|
||||
def insert(self, library, artist, album, disc, decade,
|
||||
year, number, length, title, path):
|
||||
track = super().insert(library, artist, album, disc, decade,
|
||||
year, number, length, title, path)
|
||||
|
||||
user.Table.find("New Tracks").add_track(track)
|
||||
for plist in [ artist, album, disc, decade, year, library,
|
||||
user.Table.find("Collection") ]:
|
||||
plist.add_track(track)
|
||||
|
||||
return track
|
||||
|
||||
|
||||
Table = TrackTable()
|
170
db/user.py
170
db/user.py
|
@ -1,170 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
#
|
||||
# Table: playlists
|
||||
# +------ ---+-----------+------+------+
|
||||
# | playlistid | plstateid | name | sort |
|
||||
# +-------- -+-----------+------+------+
|
||||
from gi.repository import GObject
|
||||
from . import playlist
|
||||
from . import sql
|
||||
from . import state
|
||||
from . import track
|
||||
|
||||
|
||||
class Collection(playlist.Playlist):
|
||||
def __init__(self, row):
|
||||
playlist.Playlist.__init__(self, row, "media-playback-start")
|
||||
self._name = row["name"]
|
||||
|
||||
def get_n_tracks(self):
|
||||
cur = sql.execute("SELECT COUNT(*) FROM tracks "
|
||||
"JOIN libraries USING(libraryid) WHERE enabled=1")
|
||||
return cur.fetchone()[0]
|
||||
|
||||
def get_track(self, n):
|
||||
order = ', '.join(self.plist_state.sort)
|
||||
row = sql.execute(f"SELECT * FROM tracks "
|
||||
f"INNER JOIN artists USING(artistid) "
|
||||
f"INNER JOIN albums USING(albumid) "
|
||||
f"INNER JOIN discs USING(discid) "
|
||||
f"INNER JOIN libraries USING(libraryid) "
|
||||
f"WHERE libraries.enabled=1 "
|
||||
f"ORDER BY {order} LIMIT 1 OFFSET ?",
|
||||
[ n ]).fetchone()
|
||||
return track.Table.factory(row)
|
||||
|
||||
def get_tracks(self):
|
||||
order = ', '.join(self.plist_state.sort)
|
||||
rows = sql.execute(f"SELECT * FROM tracks "
|
||||
f"INNER JOIN artists USING(artistid) "
|
||||
f"INNER JOIN albums USING(albumid) "
|
||||
f"INNER JOIN discs USING(discid) "
|
||||
f"INNER JOIN libraries USING(libraryid) "
|
||||
f"WHERE libraries.enabled=1 "
|
||||
f"ORDER BY {order}").fetchall()
|
||||
return [ track.Table.factory(row) for row in rows ]
|
||||
|
||||
def get_track_index(self, track):
|
||||
order = ', '.join(self.plist_state.sort)
|
||||
cur = sql.execute(f"SELECT * FROM (SELECT trackid,ROW_NUMBER() "
|
||||
f"OVER (ORDER BY {order}) "
|
||||
f"FROM tracks "
|
||||
f"INNER JOIN artists USING(artistid) "
|
||||
f"INNER JOIN albums USING(albumid) "
|
||||
f"INNER JOIN discs USING(discid) "
|
||||
f"INNER JOIN libraries USING(libraryid) "
|
||||
f"WHERE libraries.enabled=1) "
|
||||
f"WHERE trackid=?", [ track.rowid ])
|
||||
return cur.fetchone()[1] - 1
|
||||
|
||||
@GObject.Property
|
||||
def name(self): return self._name
|
||||
|
||||
|
||||
class UserPlaylist(playlist.MappedPlaylist):
|
||||
def __init__(self, row, icon_name, map_table):
|
||||
playlist.MappedPlaylist.__init__(self, row, icon_name, map_table)
|
||||
self._name = row["name"]
|
||||
|
||||
def delete(self):
|
||||
self.clear()
|
||||
Table.delete(self)
|
||||
sql.commit()
|
||||
|
||||
@GObject.Property
|
||||
def name(self): return self._name
|
||||
|
||||
|
||||
class Previous(UserPlaylist):
|
||||
def __init__(self, row):
|
||||
UserPlaylist.__init__(self, row, "media-skip-backward", "temp_playlist_map")
|
||||
|
||||
def add_track(self, track):
|
||||
if self.get_track_index(track):
|
||||
self.remove_track(track)
|
||||
super().add_track(track)
|
||||
self.current = 0
|
||||
return True
|
||||
|
||||
def next_track(self):
|
||||
self.current = max(-1, self.current - 1)
|
||||
return self.get_current_track()
|
||||
|
||||
def previous_track(self):
|
||||
return super().next_track()
|
||||
|
||||
|
||||
class QueuedTracks(UserPlaylist):
|
||||
def __init__(self, row):
|
||||
UserPlaylist.__init__(self, row, "media-skip-forward", "playlist_map")
|
||||
|
||||
def next_track(self):
|
||||
if track := super().next_track():
|
||||
self.remove_track(track)
|
||||
return track
|
||||
|
||||
|
||||
class UserTable(playlist.Model):
|
||||
def __init__(self):
|
||||
playlist.Model.__init__(self, "playlists", "sort")
|
||||
|
||||
def do_create(self):
|
||||
sql.execute("CREATE TABLE IF NOT EXISTS playlists "
|
||||
"(playlistid INTEGER PRIMARY KEY, "
|
||||
" plstateid INTEGER NOT NULL, "
|
||||
" name TEXT UNIQUE, "
|
||||
" sort TEXT, "
|
||||
" FOREIGN KEY (plstateid) REFERENCES playlist_states(plstateid))")
|
||||
sql.execute(f"CREATE TABLE IF NOT EXISTS playlist_map "
|
||||
"(playlistid INTEGER, "
|
||||
" trackid INTEGER, "
|
||||
" FOREIGN KEY(playlistid) REFERENCES playlists(playlistid), "
|
||||
" FOREIGN KEY(trackid) REFERENCES tracks(trackid), "
|
||||
" UNIQUE(playlistid, trackid))")
|
||||
sql.execute(f"CREATE TEMPORARY TABLE IF NOT EXISTS temp_playlist_map "
|
||||
"(playlistid INTEGER, "
|
||||
" trackid INTEGER, "
|
||||
" FOREIGN KEY(playlistid) REFERENCES playlists(playlistid), "
|
||||
" FOREIGN KEY(trackid) REFERENCES tracks(trackid), "
|
||||
" UNIQUE(playlistid, trackid))")
|
||||
|
||||
self.find("Collection", loop=True)
|
||||
self.find("Favorites")
|
||||
self.find("New Tracks")
|
||||
self.find("Previous", sort=["temp_playlist_map.rowid DESC"])
|
||||
self.find("Queued Tracks", sort=["playlist_map.rowid ASC"])
|
||||
|
||||
def do_drop(self):
|
||||
sql.execute("DROP TABLE playlists")
|
||||
sql.execute("DROP TABLE playlist_map")
|
||||
sql.execute("DROP TABLE temp_playlist_map")
|
||||
|
||||
def do_factory(self, row):
|
||||
match row["name"]:
|
||||
case "Collection":
|
||||
return Collection(row)
|
||||
case "Favorites":
|
||||
return UserPlaylist(row, "emmental-favorites", "playlist_map")
|
||||
case "New Tracks":
|
||||
return UserPlaylist(row, "starred", "temp_playlist_map")
|
||||
case "Previous":
|
||||
return Previous(row)
|
||||
case "Queued Tracks":
|
||||
return QueuedTracks(row)
|
||||
case _:
|
||||
return UserPlaylist(row, "audio-x-generic", "playlist_map")
|
||||
|
||||
def do_insert(self, plstate, name):
|
||||
return sql.execute("INSERT INTO playlists (plstateid, name, sort) "
|
||||
"VALUES (?, ?, ?)", [ plstate.rowid, name, name.casefold() ])
|
||||
|
||||
def do_lookup(self, name):
|
||||
return sql.execute("SELECT * FROM playlists WHERE name=?", [ name ])
|
||||
|
||||
def find(self, name, loop=False, sort=state.DefaultSort):
|
||||
if (res := self.lookup(name)) == None:
|
||||
res = self.insert(name, loop=loop, sort=sort)
|
||||
return res
|
||||
|
||||
|
||||
Table = UserTable()
|
49
db/year.py
49
db/year.py
|
@ -1,49 +0,0 @@
|
|||
# Copyright 2021 (c) Anna Schumaker.
|
||||
#
|
||||
# Table: years
|
||||
# +--------+----------+-----------+------+
|
||||
# | yearid | decadeid | plstateid | year |
|
||||
# +--------+----------+-----------+------+
|
||||
from gi.repository import GObject
|
||||
from . import playlist
|
||||
from . import sql
|
||||
|
||||
class Year(playlist.Playlist):
|
||||
def __init__(self, row):
|
||||
playlist.Playlist.__init__(self, row, "x-office-calendar")
|
||||
self._year = row["year"]
|
||||
|
||||
def delete(self): Table.delete(self)
|
||||
|
||||
@GObject.Property
|
||||
def name(self): return str(self._year)
|
||||
|
||||
@GObject.Property
|
||||
def year(self): return self._year
|
||||
|
||||
|
||||
class YearTable(playlist.ChildModel):
|
||||
def __init__(self):
|
||||
playlist.ChildModel.__init__(self, "years", "decadeid", "year")
|
||||
|
||||
def do_create(self):
|
||||
sql.execute("CREATE TABLE IF NOT EXISTS years "
|
||||
"(yearid INTEGER PRIMARY KEY, "
|
||||
" decadeid INTEGER, "
|
||||
" plstateid INTEGER NOT NULL, "
|
||||
" year INTEGER UNIQUE, "
|
||||
" FOREIGN KEY(decadeid) REFERENCES decades(decadeid), "
|
||||
" FOREIGN KEY(plstateid) REFERENCES playlist_states(plstateid))")
|
||||
|
||||
def do_insert(self, plstate, dec, year):
|
||||
return sql.execute("INSERT INTO years (decadeid, plstateid, year) "
|
||||
"VALUES (?, ?, ?)", [ dec.rowid, plstate.rowid, year ])
|
||||
|
||||
def do_factory(self, row):
|
||||
return Year(row)
|
||||
|
||||
def do_lookup(self, year):
|
||||
return sql.execute("SELECT * FROM years WHERE year=?", [ year ])
|
||||
|
||||
|
||||
Table = YearTable()
|
34
emmental.py
34
emmental.py
|
@ -1,32 +1,8 @@
|
|||
#!/usr/bin/python
|
||||
# Copyright 2021 (c) Anna Schumaker.
|
||||
import lib
|
||||
lib.settings.load()
|
||||
|
||||
import db
|
||||
import scanner
|
||||
import ui
|
||||
from gi.repository import Gtk
|
||||
|
||||
class Application(Gtk.Application):
|
||||
def __init__(self, *args, **kwargs):
|
||||
app_id = f"org.gtk.emmental{'-debug' if __debug__ else ''}"
|
||||
Gtk.Application.__init__(self, *args, application_id=app_id, **kwargs)
|
||||
|
||||
def do_startup(self):
|
||||
Gtk.Application.do_startup(self)
|
||||
self.add_window(ui.window.Window())
|
||||
for i in range(db.library.Table.get_n_items()):
|
||||
scanner.update_library(db.library.Table.get_item(i))
|
||||
|
||||
def do_activate(self):
|
||||
for window in self.get_windows():
|
||||
window.present()
|
||||
|
||||
def do_shutdown(self):
|
||||
Gtk.Application.do_shutdown(self)
|
||||
scanner.Queue.clear()
|
||||
db.sql.optimize()
|
||||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""The main Emmental application."""
|
||||
import sys
|
||||
import emmental
|
||||
|
||||
if __name__ == "__main__":
|
||||
Application().run()
|
||||
emmental.Application().run(sys.argv)
|
||||
|
|
|
@ -0,0 +1,343 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""Set up our Application."""
|
||||
import musicbrainzngs
|
||||
import pathlib
|
||||
from . import gsetup
|
||||
from . import action
|
||||
from . import audio
|
||||
from . import db
|
||||
from . import header
|
||||
from . import listenbrainz
|
||||
from . import mpris2
|
||||
from . import nowplaying
|
||||
from . import options
|
||||
from . import playlist
|
||||
from . import sidebar
|
||||
from . import tracklist
|
||||
from . import window
|
||||
from gi.repository import GObject
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Gio
|
||||
from gi.repository import Adw
|
||||
|
||||
MAJOR_VERSION = 3
|
||||
MINOR_VERSION = 2
|
||||
MICRO_VERSION = 0
|
||||
|
||||
VERSION_NUMBER = f"{MAJOR_VERSION}.{MINOR_VERSION}.{MICRO_VERSION}"
|
||||
VERSION_STRING = f"Emmental {VERSION_NUMBER}{gsetup.DEBUG_STR}"
|
||||
|
||||
|
||||
class Application(Adw.Application):
|
||||
"""Our custom Adw.Application."""
|
||||
|
||||
db = GObject.Property(type=db.Connection)
|
||||
factory = GObject.Property(type=playlist.Factory)
|
||||
mpris = GObject.Property(type=mpris2.Connection)
|
||||
player = GObject.Property(type=audio.Player)
|
||||
lbrainz = GObject.Property(type=listenbrainz.ListenBrainz)
|
||||
win = GObject.Property(type=window.Window)
|
||||
|
||||
autopause = GObject.Property(type=int, default=-1, minimum=-1, maximum=99)
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize an Application."""
|
||||
super().__init__(application_id=gsetup.APPLICATION_ID,
|
||||
resource_base_path=gsetup.RESOURCE_PATH,
|
||||
flags=Gio.ApplicationFlags.HANDLES_OPEN)
|
||||
self.add_main_option_entries([options.Version])
|
||||
|
||||
def __add_accelerators(self, accels: list[action.ActionEntry]) -> None:
|
||||
for entry in accels:
|
||||
self.add_action(entry.action)
|
||||
self.set_accels_for_action(f"app.{entry.name}", entry.accels)
|
||||
|
||||
def __load_file(self, file: pathlib.Path,
|
||||
*, gapless: bool = False) -> None:
|
||||
self.__stop_current_track()
|
||||
if gapless:
|
||||
self.player.file = file
|
||||
else:
|
||||
self.player.stop()
|
||||
self.player.file = file
|
||||
self.player.play()
|
||||
|
||||
def __load_path(self, src: GObject.GObject, path: pathlib.Path) -> None:
|
||||
if (track := self.db.tracks.lookup(path=path)) is not None:
|
||||
self.__load_track(track)
|
||||
self.__load_file(path)
|
||||
|
||||
def __load_track(self, track: GObject.GObject, *, gapless: bool = False,
|
||||
rg_auto: str = "track", restart: bool = False) -> None:
|
||||
self.__load_file(track.path, gapless=gapless)
|
||||
if restart:
|
||||
track.restart()
|
||||
else:
|
||||
track.start()
|
||||
self.__set_replaygain(rg_auto=rg_auto)
|
||||
self.__on_jump()
|
||||
|
||||
def __pick_next_track(self, *, user: bool, gapless: bool = False) -> None:
|
||||
(track, rg_auto, restart) = self.factory.next_track(user=user)
|
||||
self.__load_track(track, gapless=gapless,
|
||||
rg_auto=rg_auto, restart=restart)
|
||||
|
||||
def __on_jump(self, nowplay: nowplaying.Card | None = None) -> None:
|
||||
"""Handle a jump event."""
|
||||
self.win.tracklist.scroll_to_track(self.db.tracks.current_track)
|
||||
|
||||
def __on_seek(self, nowplay: nowplaying.Card, newpos: float) -> None:
|
||||
"""Handle a seek event."""
|
||||
self.player.seek(newpos)
|
||||
self.mpris.player.seeked(newpos)
|
||||
|
||||
def __seek(self, player: mpris2.player.Player, offset: float) -> None:
|
||||
self.player.seek(self.player.position + offset)
|
||||
|
||||
def __set_position(self, player: mpris2.player.Player,
|
||||
trackid: str, position: float) -> None:
|
||||
self.player.seek(position)
|
||||
|
||||
def __set_replaygain(self, *args, rg_auto="track") -> None:
|
||||
enabled = self.db.settings["audio.replaygain.enabled"]
|
||||
mode = self.db.settings["audio.replaygain.mode"]
|
||||
mode = rg_auto if mode == "auto" else mode
|
||||
self.player.set_replaygain(enabled, mode)
|
||||
|
||||
def __stop_current_track(self) -> None:
|
||||
if self.db.tracks.current_track is not None:
|
||||
self.db.tracks.current_track.stop(self.player.playtime)
|
||||
|
||||
def __system_next(self, player: audio.Player, gapless: bool) -> None:
|
||||
self.player.pause_on_load = self.autopause == 0
|
||||
if self.autopause >= 0:
|
||||
self.autopause -= 1
|
||||
self.__pick_next_track(user=False, gapless=gapless)
|
||||
|
||||
def __user_next(self, *args) -> None:
|
||||
self.__pick_next_track(user=True)
|
||||
|
||||
def __user_previous(self, *args) -> None:
|
||||
self.__load_track(self.factory.previous_track(),
|
||||
rg_auto="track", restart=True)
|
||||
|
||||
def __track_requested(self, factory: playlist.Factory, track,
|
||||
rg_auto: str, restarted: bool) -> None:
|
||||
self.__load_track(track, rg_auto=rg_auto, restart=restarted)
|
||||
|
||||
def __tracks_table_loaded(self, track_table, param) -> None:
|
||||
if track_table.current_track is not None:
|
||||
self.player.file = track_table.current_track.path
|
||||
self.player.pause()
|
||||
track_table.current_track.start()
|
||||
self.__on_jump()
|
||||
|
||||
def build_header(self) -> header.Header:
|
||||
"""Build a new header instance."""
|
||||
hdr = header.Header(sql=self.db, title=VERSION_STRING)
|
||||
for prop in ["bg-enabled", "bg-volume", "volume"]:
|
||||
hdr.bind_property(prop, self.player, prop)
|
||||
hdr.bind_property("listenbrainz-token", self.lbrainz, "user-token")
|
||||
for (setting, property) in [("audio.volume", "volume"),
|
||||
("audio.background.enabled", "bg-enabled"),
|
||||
("audio.background.volume", "bg-volume"),
|
||||
("audio.replaygain.enabled", "rg-enabled"),
|
||||
("audio.replaygain.mode", "rg-mode"),
|
||||
("listenbrainz.token",
|
||||
"listenbrainz_token")]:
|
||||
self.db.settings.bind_setting(setting, hdr, property)
|
||||
|
||||
self.__add_accelerators(hdr.accelerators)
|
||||
|
||||
hdr.connect("notify::rg-enabled", self.__set_replaygain)
|
||||
hdr.connect("notify::rg-mode", self.__set_replaygain)
|
||||
hdr.connect("track-requested", self.__load_path)
|
||||
self.__set_replaygain()
|
||||
return hdr
|
||||
|
||||
def build_now_playing(self) -> nowplaying.Card:
|
||||
"""Build a new now playing card."""
|
||||
playing = nowplaying.Card()
|
||||
playing.bind_property("autopause", self, "autopause",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
for prop in ["title", "album", "artist", "album-artist", "playing",
|
||||
"position", "duration", "artwork", "have-track"]:
|
||||
self.player.bind_property(prop, playing, prop)
|
||||
self.db.tracks.bind_property("have-current-track",
|
||||
playing, "have-db-track")
|
||||
self.db.tracks.bind_property("current-favorite", playing, "favorite",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.factory.bind_property("can-go-next", playing, "have-next")
|
||||
self.factory.bind_property("can-go-previous", playing, "have-previous")
|
||||
self.db.settings.bind_setting("now-playing.prefer-artist",
|
||||
playing, "prefer-artist")
|
||||
|
||||
self.__add_accelerators(playing.accelerators)
|
||||
|
||||
playing.connect("jump", self.__on_jump)
|
||||
playing.connect("play", self.player.play)
|
||||
playing.connect("pause", self.player.pause)
|
||||
playing.connect("seek", self.__on_seek)
|
||||
playing.connect("next", self.__user_next)
|
||||
playing.connect("previous", self.__user_previous)
|
||||
return playing
|
||||
|
||||
def build_sidebar(self) -> sidebar.Card:
|
||||
"""Build a new sidebar card."""
|
||||
side_bar = sidebar.Card(sql=self.db)
|
||||
self.db.settings.bind_setting("sidebar.artists.show-all", side_bar,
|
||||
"show-all-artists")
|
||||
self.__add_accelerators(side_bar.accelerators)
|
||||
return side_bar
|
||||
|
||||
def build_tracklist(self) -> tracklist.Card:
|
||||
"""Build a new tracklist card."""
|
||||
track_list = tracklist.Card(sql=self.db)
|
||||
for column in track_list.columns:
|
||||
name = column.get_title().lower().replace(" ", "-")
|
||||
self.db.settings.bind_setting(f"tracklist.{name}.size",
|
||||
column, "fixed-width")
|
||||
self.db.settings.bind_setting(f"tracklist.{name}.visible",
|
||||
column, "visible")
|
||||
self.factory.bind_property("visible-playlist", track_list, "playlist")
|
||||
|
||||
self.__add_accelerators(track_list.accelerators)
|
||||
return track_list
|
||||
|
||||
def build_window(self) -> window.Window:
|
||||
"""Build a new window instance."""
|
||||
win = window.Window(VERSION_STRING,
|
||||
header=self.build_header(),
|
||||
now_playing=self.build_now_playing(),
|
||||
sidebar=self.build_sidebar(),
|
||||
tracklist=self.build_tracklist())
|
||||
win.bind_property("show-sidebar", win.header, "show-sidebar",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
win.bind_property("user-editing", win.now_playing, "editing")
|
||||
|
||||
for (setting, property) in [("window.width", "default-width"),
|
||||
("window.height", "default-height"),
|
||||
("now-playing.size", "now-playing-size"),
|
||||
("sidebar.show", "show-sidebar")]:
|
||||
self.db.settings.bind_setting(setting, win, property)
|
||||
|
||||
self.__add_accelerators(win.accelerators)
|
||||
return win
|
||||
|
||||
def connect_mpris2(self) -> None:
|
||||
"""Connect the mpris2 properties and functions."""
|
||||
self.mpris.app.link_property("Fullscreen", self.win, "fullscreened")
|
||||
self.mpris.app.connect("Raise", self.win.present)
|
||||
self.mpris.app.connect("Quit", self.win.close)
|
||||
|
||||
for tag in ["artist", "album", "album-artist", "album-disc-number",
|
||||
"title", "track-number", "duration", "file", "artwork"]:
|
||||
self.player.bind_property(tag, self.mpris.player, tag)
|
||||
for (prop, mpris_prop) in [("have-track", "CanPlay"),
|
||||
("have-track", "CanPause"),
|
||||
("have-track", "CanSeek"),
|
||||
("status", "PlaybackStatus"),
|
||||
("position", "Position")]:
|
||||
self.player.bind_property(prop, self.mpris.player, mpris_prop)
|
||||
for (prop, mpris_prop) in [("active-shuffle", "Shuffle"),
|
||||
("active-loop", "LoopStatus")]:
|
||||
self.factory.bind_property(prop, self.mpris.player, mpris_prop,
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
for (prop, mpris_prop) in [("can-go-next", "CanGoNext"),
|
||||
("can-go-previous", "CanGoPrevious")]:
|
||||
self.factory.bind_property(prop, self.mpris.player, mpris_prop)
|
||||
self.mpris.player.link_property("Volume", self.win.header, "volume")
|
||||
|
||||
self.mpris.player.connect("OpenPath", self.__load_path)
|
||||
self.mpris.player.connect("Next", self.__user_next)
|
||||
self.mpris.player.connect("Previous", self.__user_previous)
|
||||
self.mpris.player.connect("Pause", self.player.pause)
|
||||
self.mpris.player.connect("Play", self.player.play)
|
||||
self.mpris.player.connect("PlayPause", self.player.play_pause)
|
||||
self.mpris.player.connect("Seek", self.__seek)
|
||||
self.mpris.player.connect("SetPosition", self.__set_position)
|
||||
self.mpris.player.connect("Stop", self.player.stop)
|
||||
|
||||
def connect_listenbrainz(self) -> None:
|
||||
"""Connect the listenbrainz client."""
|
||||
self.db.tracks.bind_property("current-track",
|
||||
self.lbrainz, "now-playing")
|
||||
self.lbrainz.bind_property("valid-token", self.win.header,
|
||||
"listenbrainz-token-valid")
|
||||
|
||||
self.db.tracks.connect("track-played", self.lbrainz.submit_listens)
|
||||
|
||||
def connect_playlist_factory(self) -> None:
|
||||
"""Connect the playlist factory properties."""
|
||||
self.db.playlists.bind_property("previous",
|
||||
self.factory, "db-previous")
|
||||
self.win.sidebar.bind_property("selected-playlist",
|
||||
self.factory, "db-visible")
|
||||
self.factory.connect("track-requested", self.__track_requested)
|
||||
|
||||
def connect_player(self) -> None:
|
||||
"""Connect the audio.Player."""
|
||||
self.player.connect("about-to-finish", self.__system_next, True)
|
||||
self.player.connect("eos", self.__system_next, False)
|
||||
|
||||
def do_handle_local_options(self, opts: GLib.VariantDict) -> int:
|
||||
"""Handle any command line options."""
|
||||
if opts.contains("version"):
|
||||
print(VERSION_STRING)
|
||||
gsetup.print_env()
|
||||
return 0
|
||||
return -1
|
||||
|
||||
def do_startup(self) -> None:
|
||||
"""Handle the Adw.Application::startup signal."""
|
||||
Adw.Application.do_startup(self)
|
||||
self.db = db.Connection()
|
||||
self.mpris = mpris2.Connection()
|
||||
self.lbrainz = listenbrainz.ListenBrainz(self.db)
|
||||
self.factory = playlist.Factory(self.db)
|
||||
self.player = audio.Player()
|
||||
|
||||
gsetup.add_style()
|
||||
musicbrainzngs.set_useragent(f"emmental{gsetup.DEBUG_STR}",
|
||||
VERSION_NUMBER)
|
||||
self.db.tracks.connect("notify::loaded", self.__tracks_table_loaded)
|
||||
self.db.load()
|
||||
|
||||
self.win = self.build_window()
|
||||
self.add_window(self.win)
|
||||
self.connect_mpris2()
|
||||
self.connect_listenbrainz()
|
||||
self.connect_playlist_factory()
|
||||
self.connect_player()
|
||||
|
||||
def do_activate(self) -> None:
|
||||
"""Handle the Adw.Application::activate signal."""
|
||||
Adw.Application.do_activate(self)
|
||||
self.win.present()
|
||||
|
||||
def do_open(self, files: list, n_files: int, hint: str) -> None:
|
||||
"""Play an audio file passed from the command line."""
|
||||
if n_files > 0:
|
||||
path = pathlib.Path(files[0].get_path())
|
||||
self.db.tracks.mark_path_active(path)
|
||||
self.__load_path(None, path)
|
||||
self.activate()
|
||||
|
||||
def do_shutdown(self) -> None:
|
||||
"""Handle the Adw.Application::shutdown signal."""
|
||||
Adw.Application.do_shutdown(self)
|
||||
if self.player is not None:
|
||||
self.player.shutdown()
|
||||
self.player = None
|
||||
if self.win is not None:
|
||||
self.win.close()
|
||||
self.win = None
|
||||
if self.lbrainz is not None:
|
||||
self.lbrainz.stop()
|
||||
self.lbrainz = None
|
||||
if self.mpris is not None:
|
||||
self.mpris.disconnect()
|
||||
self.mpris = None
|
||||
if self.db is not None:
|
||||
self.db.close()
|
||||
self.db = None
|
|
@ -0,0 +1,39 @@
|
|||
# Copyright 2023 (c) Anna Schumaker.
|
||||
"""A custom ActionEntry that works in Python."""
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gio
|
||||
from gi.repository import Gtk
|
||||
|
||||
|
||||
class ActionEntry(GObject.GObject):
|
||||
"""Our own AcitionEntry class to make accelerators easier."""
|
||||
|
||||
enabled = GObject.Property(type=bool, default=True)
|
||||
|
||||
def __init__(self, name: str, func: callable, *accels: tuple[str],
|
||||
enabled: tuple[GObject.GObject, str] | None = None):
|
||||
"""Initialize an ActionEntry."""
|
||||
super().__init__()
|
||||
|
||||
for accel in accels:
|
||||
if not Gtk.accelerator_parse(accel)[0]:
|
||||
raise ValueError
|
||||
|
||||
self.accels = list(accels)
|
||||
self.func = func
|
||||
|
||||
if enabled is not None:
|
||||
self.enabled = enabled[0].get_property(enabled[1])
|
||||
enabled[0].bind_property(enabled[1], self, "enabled")
|
||||
|
||||
self.action = Gio.SimpleAction(name=name, enabled=self.enabled)
|
||||
self.action.connect("activate", self.__activate)
|
||||
self.bind_property("enabled", self.action, "enabled")
|
||||
|
||||
def __activate(self, action: Gio.SimpleAction, param) -> None:
|
||||
self.func()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get then name of this ActionEntry."""
|
||||
return self.action.get_name()
|
|
@ -0,0 +1,48 @@
|
|||
# Copyright 2023 (c) Anna Schumaker.
|
||||
"""Functions for configuring a callback at a specific time."""
|
||||
import datetime
|
||||
import math
|
||||
from gi.repository import GLib
|
||||
|
||||
_GSOURCE_MAPPING = dict()
|
||||
_NEXT_ALARM_ID = 1
|
||||
|
||||
|
||||
def _calc_seconds(time: datetime.time) -> int:
|
||||
"""Calculate the number of seconds until the given time."""
|
||||
now = datetime.datetime.now()
|
||||
then = datetime.datetime.combine(now.date(), time)
|
||||
|
||||
if now >= then:
|
||||
then += datetime.timedelta(days=1)
|
||||
|
||||
return math.ceil((then - now).total_seconds())
|
||||
|
||||
|
||||
def __set_alarm(time: datetime.time, func: callable, alarm_id: int) -> None:
|
||||
gsrcid = GLib.timeout_add_seconds(_calc_seconds(time), _do_alarm,
|
||||
time, func, alarm_id)
|
||||
_GSOURCE_MAPPING[alarm_id] = gsrcid
|
||||
return alarm_id
|
||||
|
||||
|
||||
def _do_alarm(time: datetime.time, func: callable, alarm_id: int) -> bool:
|
||||
"""Run an alarm callback."""
|
||||
func()
|
||||
__set_alarm(time, func, alarm_id)
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
|
||||
def set_alarm(time: datetime.time, func: callable) -> int:
|
||||
"""Register a callback to be called at a specific time."""
|
||||
global _NEXT_ALARM_ID
|
||||
|
||||
res = __set_alarm(time, func, _NEXT_ALARM_ID)
|
||||
_NEXT_ALARM_ID += 1
|
||||
return res
|
||||
|
||||
|
||||
def cancel_alarm(alarm_id: int) -> None:
|
||||
"""Cancel an alarm."""
|
||||
GLib.source_remove(_GSOURCE_MAPPING[alarm_id])
|
||||
del _GSOURCE_MAPPING[alarm_id]
|
|
@ -0,0 +1,238 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""A custom GObject managing a GStreamer playbin."""
|
||||
import pathlib
|
||||
from gi.repository import GObject
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Gst
|
||||
from . import filter
|
||||
from .. import path
|
||||
from .. import tmpdir
|
||||
|
||||
|
||||
UPDATE_INTERVAL = 100
|
||||
SEEK_FLAGS = Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT
|
||||
|
||||
|
||||
class Player(GObject.GObject):
|
||||
"""Wraps a GStreamer Playbin with an interface for our application."""
|
||||
|
||||
artist = GObject.Property(type=str)
|
||||
album = GObject.Property(type=str)
|
||||
album_artist = GObject.Property(type=str)
|
||||
album_disc_number = GObject.Property(type=int)
|
||||
title = GObject.Property(type=str)
|
||||
track_number = GObject.Property(type=int)
|
||||
position = GObject.Property(type=float, default=0)
|
||||
duration = GObject.Property(type=float, default=0)
|
||||
volume = GObject.Property(type=float, default=1.0)
|
||||
artwork = GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||
|
||||
file = GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||
playing = GObject.Property(type=bool, default=False)
|
||||
status = GObject.Property(type=str, default="Stopped")
|
||||
have_track = GObject.Property(type=bool, default=False)
|
||||
almost_done = GObject.Property(type=bool, default=False)
|
||||
playtime = GObject.Property(type=float)
|
||||
savedtime = GObject.Property(type=float)
|
||||
|
||||
bg_enabled = GObject.Property(type=bool, default=False)
|
||||
bg_volume = GObject.Property(type=float, default=0.5)
|
||||
pause_on_load = GObject.Property(type=bool, default=False)
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the audio Player."""
|
||||
super().__init__()
|
||||
self._filter = filter.Filter()
|
||||
self._timeout = None
|
||||
|
||||
self._playbin = Gst.ElementFactory.make("playbin")
|
||||
self._playbin.set_property("audio-filter", self._filter)
|
||||
self._playbin.set_property("video-sink",
|
||||
Gst.ElementFactory.make("fakesink"))
|
||||
self._playbin.set_state(Gst.State.READY)
|
||||
|
||||
bus = self._playbin.get_bus()
|
||||
bus.add_signal_watch()
|
||||
bus.connect("message::async-done", self.__msg_async_done)
|
||||
bus.connect("message::eos", self.__msg_eos)
|
||||
bus.connect("message::state-changed", self.__msg_state_changed)
|
||||
bus.connect("message::stream-start", self.__msg_stream_start)
|
||||
bus.connect("message::tag", self.__msg_tags)
|
||||
|
||||
self.bind_property("volume", self._playbin, "volume")
|
||||
self.bind_property("bg-enabled", self._filter, "bg-enabled")
|
||||
self.bind_property("bg-volume", self._filter, "bg-volume")
|
||||
|
||||
self.connect("notify::file", self.__notify_file)
|
||||
|
||||
def __check_last_second(self) -> None:
|
||||
if self.duration - self.position <= 2 * (Gst.SECOND / Gst.USECOND):
|
||||
if not self.almost_done:
|
||||
self.emit("about-to-finish")
|
||||
|
||||
def __get_current_playtime(self) -> float:
|
||||
if not self._playbin.clock:
|
||||
return 0.0
|
||||
time = self._playbin.clock.get_time() - self._playbin.base_time
|
||||
return time / Gst.SECOND
|
||||
|
||||
def __msg_async_done(self, bus: Gst.Bus, message: Gst.Message) -> None:
|
||||
self.__update_position()
|
||||
|
||||
def __msg_eos(self, bus: Gst.Bus, message: Gst.Message) -> None:
|
||||
self.emit("eos")
|
||||
|
||||
def __msg_state_changed(self, bus: Gst.Bus, message: Gst.Message) -> None:
|
||||
if message.src == self._playbin:
|
||||
(old, new, pending) = message.parse_state_changed()
|
||||
match (self.status, new, pending):
|
||||
case ("Playing", Gst.State.PLAYING, _) | \
|
||||
("Paused", Gst.State.PAUSED, _) | \
|
||||
("Stopped", Gst.State.READY, _) | \
|
||||
("Stopped", Gst.State.NULL, _):
|
||||
pass
|
||||
case (_, Gst.State.PLAYING, Gst.State.VOID_PENDING):
|
||||
print("audio: state changed to 'playing'")
|
||||
self.status = "Playing"
|
||||
self.playing = True
|
||||
case (_, Gst.State.PAUSED, Gst.State.VOID_PENDING):
|
||||
print("audio: state changed to 'paused'")
|
||||
self.status = "Paused"
|
||||
self.playing = False
|
||||
case (_, Gst.State.READY, Gst.State.VOID_PENDING) | \
|
||||
(_, Gst.State.NULL, Gst.State.VOID_PENDING):
|
||||
print("audio: state changed to 'stopped'")
|
||||
self.status = "Stopped"
|
||||
self.playing = False
|
||||
|
||||
self.__update_timeout()
|
||||
|
||||
def __msg_stream_start(self, bus: Gst.Bus, message: Gst.Message) -> None:
|
||||
self.emit("file-loaded",
|
||||
path.from_uri(self._playbin.get_property("current-uri")))
|
||||
|
||||
def __msg_tags(self, bus: Gst.Bus, message: Gst.Message) -> None:
|
||||
taglist = message.parse_tag()
|
||||
for tag in ["artist", "album", "album-artist", "album-disc-number",
|
||||
"title", "track-number", "artwork"]:
|
||||
match tag:
|
||||
case "artwork":
|
||||
(res, sample) = taglist.get_sample("image")
|
||||
if res:
|
||||
buffer = sample.get_buffer()
|
||||
(res, map) = buffer.map(Gst.MapFlags.READ)
|
||||
if res:
|
||||
value = tmpdir.cover_jpg(map.data)
|
||||
buffer.unmap(map)
|
||||
case "track-number" | "album-disc-number":
|
||||
(res, value) = taglist.get_uint(tag)
|
||||
case _:
|
||||
(res, value) = taglist.get_string(tag)
|
||||
if res and self.get_property(tag) != value:
|
||||
self.set_property(tag, value)
|
||||
|
||||
def __notify_file(self, player: GObject.GObject, param) -> None:
|
||||
if self.file:
|
||||
uri = self.file.as_uri()
|
||||
print(f"audio: loading {uri}")
|
||||
self._playbin.set_property("uri", uri)
|
||||
|
||||
def __reset_properties(self, *, duration: float = 0.0,
|
||||
artwork: pathlib.Path | None = None) -> None:
|
||||
for tag in ["artist", "album-artist", "album", "title"]:
|
||||
self.set_property(tag, "")
|
||||
for tag in ["album-disc-number", "track-number",
|
||||
"position", "playtime", "savedtime"]:
|
||||
self.set_property(tag, 0)
|
||||
|
||||
self.almost_done = False
|
||||
self.pause_on_load = False
|
||||
self.artwork = artwork
|
||||
self.duration = duration
|
||||
|
||||
def __update_position(self) -> bool:
|
||||
(res, pos) = self._playbin.query_position(Gst.Format.TIME)
|
||||
self.position = pos / Gst.USECOND if res else 0
|
||||
self.playtime = self.__get_current_playtime() + self.savedtime
|
||||
self.__check_last_second()
|
||||
return GLib.SOURCE_CONTINUE
|
||||
|
||||
def __update_timeout(self) -> None:
|
||||
if self.playing and self._timeout is None:
|
||||
self._timeout = GLib.timeout_add(UPDATE_INTERVAL,
|
||||
self.__update_position)
|
||||
elif self.playing is False and self._timeout is not None:
|
||||
GLib.source_remove(self._timeout)
|
||||
self._timeout = None
|
||||
|
||||
def get_replaygain(self) -> tuple[bool, str | None]:
|
||||
"""Get the current ReplayGain mode."""
|
||||
mode = self._filter.rg_mode
|
||||
return (False, None) if mode == "disabled" else (True, mode)
|
||||
|
||||
def get_state(self) -> Gst.State:
|
||||
"""Get the current state of the Player."""
|
||||
return self._playbin.get_state(Gst.CLOCK_TIME_NONE).state
|
||||
|
||||
def pause(self, *args) -> None:
|
||||
"""Pause playback."""
|
||||
self.set_state_sync(Gst.State.PAUSED)
|
||||
|
||||
def play(self, *args) -> None:
|
||||
"""Start playback."""
|
||||
self.set_state_sync(Gst.State.PLAYING)
|
||||
|
||||
def play_pause(self, *args) -> None:
|
||||
"""Start or Pause playback."""
|
||||
state = Gst.State.PAUSED if self.playing else Gst.State.PLAYING
|
||||
self.set_state_sync(state)
|
||||
|
||||
def seek(self, newpos: float, *args) -> None:
|
||||
"""Seek to a different point in the stream."""
|
||||
self.savedtime += self.__get_current_playtime()
|
||||
self._playbin.seek_simple(Gst.Format.TIME, SEEK_FLAGS,
|
||||
newpos * Gst.USECOND)
|
||||
|
||||
def set_replaygain(self, enabled: bool, mode: str) -> None:
|
||||
"""Set the ReplayGain mode."""
|
||||
self._filter.rg_mode = mode if enabled else "disabled"
|
||||
|
||||
def set_state_sync(self, state: Gst.State) -> None:
|
||||
"""Set the state of the playbin, and wait for it to change."""
|
||||
if self._playbin.set_state(state) == Gst.StateChangeReturn.ASYNC:
|
||||
self.get_state()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Shut down the player."""
|
||||
self._playbin.set_state(Gst.State.NULL)
|
||||
|
||||
def stop(self, *args) -> None:
|
||||
"""Stop playback."""
|
||||
self.set_state_sync(Gst.State.READY)
|
||||
|
||||
@GObject.Signal
|
||||
def about_to_finish(self) -> None:
|
||||
"""Signal that playback is almost done."""
|
||||
print("audio: about to finish")
|
||||
self.almost_done = True
|
||||
|
||||
@GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT,))
|
||||
def file_loaded(self, file: pathlib.Path) -> None:
|
||||
"""Signal that a new URI has started."""
|
||||
print("audio: file loaded")
|
||||
if self.pause_on_load:
|
||||
self._playbin.set_state(Gst.State.PAUSED)
|
||||
(res, dur) = self._playbin.query_duration(Gst.Format.TIME)
|
||||
cover = self.file.parent / "cover.jpg"
|
||||
self.__reset_properties(duration=(dur / Gst.USECOND if res else 0),
|
||||
artwork=(cover if cover.is_file() else None))
|
||||
self.have_track = True
|
||||
|
||||
@GObject.Signal
|
||||
def eos(self) -> None:
|
||||
"""Signal that the current track has ended."""
|
||||
print("audio: end of stream")
|
||||
self.set_state_sync(Gst.State.READY)
|
||||
self.__reset_properties()
|
||||
self.have_track = False
|
||||
self.file = None
|
|
@ -0,0 +1,45 @@
|
|||
# Copyright 2023 (c) Anna Schumaker.
|
||||
"""A custom Gst.Bin with our audio filter effects."""
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gst
|
||||
from . import replaygain
|
||||
|
||||
|
||||
class Filter(Gst.Bin):
|
||||
"""The audio filter element."""
|
||||
|
||||
bg_enabled = GObject.Property(type=bool, default=False)
|
||||
bg_volume = GObject.Property(type=float, default=0.5)
|
||||
rg_mode = GObject.Property(type=str, default="disabled")
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the audio filter."""
|
||||
super().__init__()
|
||||
self._replaygain = replaygain.Filter()
|
||||
self._volume = Gst.ElementFactory.make("volume")
|
||||
|
||||
self.add(self._replaygain)
|
||||
self.add(self._volume)
|
||||
|
||||
rg_pad = self._replaygain.get_static_pad("src")
|
||||
rg_pad.link(self._volume.get_static_pad("sink"))
|
||||
|
||||
self.__add_ghost_pad("sink", self._replaygain)
|
||||
self.__add_ghost_pad("src", self._volume)
|
||||
|
||||
self.connect("notify", self.__notify)
|
||||
|
||||
def __add_ghost_pad(self, pad: str, elm: Gst.Element) -> None:
|
||||
self.add_pad(Gst.GhostPad.new(pad, elm.get_static_pad(pad)))
|
||||
|
||||
def __notify(self, filter: Gst.Bin, param: GObject.ParamSpec) -> None:
|
||||
match param.name:
|
||||
case "bg-enabled" | "bg-volume":
|
||||
vol = self.bg_volume if self.bg_enabled else 1.0
|
||||
if vol != self._volume.get_property("volume"):
|
||||
vs = f"{round(vol * 100)}%" if self.bg_enabled else "off"
|
||||
print(f"audio: setting background listening to {vs}")
|
||||
self._volume.set_property("volume", vol)
|
||||
case "rg-mode":
|
||||
if self.rg_mode != self._replaygain.mode:
|
||||
self._replaygain.mode = self.rg_mode
|
|
@ -0,0 +1,62 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""A custom Gst.Bin for selecting ReplayGain mode."""
|
||||
import collections
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gst
|
||||
|
||||
RequestPads = collections.namedtuple("RequestPads", ["src", "sink"])
|
||||
|
||||
|
||||
class Filter(Gst.Bin):
|
||||
"""The ReplayGain filter element."""
|
||||
|
||||
mode = GObject.Property(type=str, default="disabled")
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the ReplayGain element."""
|
||||
super().__init__()
|
||||
self._src = Gst.ElementFactory.make("output-selector")
|
||||
self._sink = Gst.ElementFactory.make("input-selector")
|
||||
self._rgalbum = Gst.ElementFactory.make("rgvolume")
|
||||
self._rgtrack = Gst.ElementFactory.make("rgvolume")
|
||||
self._rglimit = Gst.ElementFactory.make("rglimiter")
|
||||
|
||||
for elm in [self._src, self._rgalbum, self._rgtrack,
|
||||
self._rglimit, self._sink]:
|
||||
self.add(elm)
|
||||
|
||||
self._disabled = self.__request_pads(self._rglimit)
|
||||
self._album_mode = self.__request_pads(self._rgalbum)
|
||||
self._track_mode = self.__request_pads(self._rgtrack)
|
||||
|
||||
self._rgalbum.set_property("pre-amp", 6.0)
|
||||
self._rgtrack.set_property("pre-amp", 6.0)
|
||||
self._rgtrack.set_property("album-mode", False)
|
||||
|
||||
self._src.set_property("pad-negotiation-mode", 2)
|
||||
self._src.set_property("active-pad", self._disabled.src)
|
||||
self._sink.set_property("active-pad", self._disabled.sink)
|
||||
|
||||
self.__add_ghost_pad("sink", self._src)
|
||||
self.__add_ghost_pad("src", self._sink)
|
||||
|
||||
self.connect("notify::mode", self.__notify_mode)
|
||||
|
||||
def __add_ghost_pad(self, pad: str, elm: Gst.Element) -> None:
|
||||
self.add_pad(Gst.GhostPad.new(pad, elm.get_static_pad(pad)))
|
||||
|
||||
def __request_pads(self, elm: Gst.Element) -> RequestPads:
|
||||
pads = RequestPads(src=self._src.request_pad_simple("src_%u"),
|
||||
sink=self._sink.request_pad_simple("sink_%u"))
|
||||
pads.src.link(elm.get_static_pad("sink"))
|
||||
elm.get_static_pad("src").link(pads.sink)
|
||||
return pads
|
||||
|
||||
def __notify_mode(self, filter: Gst.Bin, param) -> None:
|
||||
match self.mode:
|
||||
case "album": pads = self._album_mode
|
||||
case "track": pads = self._track_mode
|
||||
case _: pads = self._disabled
|
||||
print(f"audio: setting ReplayGain mode to '{self.mode}'")
|
||||
self._src.set_property("active-pad", pads.src)
|
||||
self._sink.set_property("active-pad", pads.sink)
|
|
@ -0,0 +1,168 @@
|
|||
# Copyright 2023 (c) Anna Schumaker
|
||||
"""Extract tags from an audio file."""
|
||||
import dataclasses
|
||||
import mutagen
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class _Artist:
|
||||
"""Class for holding Artist-related tags."""
|
||||
|
||||
name: str
|
||||
mbid: str
|
||||
|
||||
def __lt__(self, rhs) -> bool:
|
||||
lhs = (self.name is not None, self.name, self.mbid)
|
||||
return lhs < (rhs.name is not None, rhs.name, rhs.mbid)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class _Album:
|
||||
"""Class for holding Album-related tags."""
|
||||
|
||||
name: str
|
||||
mbid: str
|
||||
artist: str
|
||||
release: str
|
||||
cover: pathlib.Path
|
||||
artists: list[_Artist]
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class _Medium:
|
||||
"""Class for holding Medium-related tags."""
|
||||
|
||||
number: int
|
||||
name: str
|
||||
type: str
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class _Track:
|
||||
"""Class for holding Track-related tags."""
|
||||
|
||||
artist: str
|
||||
length: int
|
||||
mbid: int
|
||||
mtime: float
|
||||
number: int
|
||||
title: str
|
||||
|
||||
|
||||
class _Tags:
|
||||
"""Extract tags found in the Mutagen tag dictionary."""
|
||||
|
||||
def __init__(self, file: pathlib.Path, tags: dict,
|
||||
length: int = 0, mtime: float = 0.0):
|
||||
"""Initialize the Tagger."""
|
||||
self.file = file
|
||||
self.tags = tags
|
||||
|
||||
self.artists = sorted(self.list_artists())
|
||||
|
||||
self.album = _Album(tags.get("album", [""])[0],
|
||||
tags.get("musicbrainz_releasegroupid", [""])[0],
|
||||
self.get_album_artist(),
|
||||
self.get_release(),
|
||||
file.parent / "cover.jpg",
|
||||
sorted(self.list_album_artists()))
|
||||
|
||||
self.medium = _Medium(int(tags.get("discnumber", [1])[0]),
|
||||
tags.get("discsubtitle", [""])[0],
|
||||
tags.get("media", [""])[0])
|
||||
|
||||
self.track = _Track(tags.get("artist", [""])[0],
|
||||
length,
|
||||
tags.get("musicbrainz_releasetrackid", [""])[0],
|
||||
mtime,
|
||||
int(tags.get("tracknumber", [0])[0]),
|
||||
tags.get("title", [""])[0])
|
||||
|
||||
self.genres = sorted(self.list_genres())
|
||||
self.year = self.get_year()
|
||||
|
||||
def get_album_artist(self) -> str:
|
||||
"""Find the album artist of the file."""
|
||||
if (res := self.tags.get("albumartist")) is None:
|
||||
res = self.tags.get("artist", [""])
|
||||
return res[0]
|
||||
|
||||
def list_album_artists(self) -> list[_Artist]:
|
||||
"""Find the list of album artists for the track."""
|
||||
artists = self.tags.get("albumartist", [])
|
||||
mbids = self.tags.get("musicbrainz_albumartistid", len(artists) * [""])
|
||||
|
||||
if len(artists) != len(mbids):
|
||||
artists = [None] * len(mbids)
|
||||
|
||||
map = {a.mbid: a for a in self.artists}
|
||||
map.update({(a.name, a.mbid): a for a in self.artists})
|
||||
return [map.get(m, map.get((a, m))) for (a, m) in zip(artists, mbids)]
|
||||
|
||||
def list_artists(self) -> list[_Artist]:
|
||||
"""Find the list of artists for the track."""
|
||||
artists = self.tags.get("artists", [])
|
||||
mbids = self.tags.get("musicbrainz_artistid", len(artists) * [""])
|
||||
found = set()
|
||||
need = set()
|
||||
|
||||
if len(artists) == 0 and len(mbids) == 0:
|
||||
res = {(a, "") for a in self.tags.get("artist", [])}
|
||||
elif len(artists) == len(mbids):
|
||||
res = {(a, m) for (a, m) in zip(artists, mbids)}
|
||||
found.update({m for m in mbids if len(m)})
|
||||
else:
|
||||
res = {(None, mbid) for mbid in mbids}
|
||||
need.update({mbid for mbid in mbids if len(mbid)})
|
||||
|
||||
albumartists = self.tags.get("albumartist", [])
|
||||
mbids = self.tags.get("musicbrainz_albumartistid",
|
||||
len(albumartists) * [""])
|
||||
if len(albumartists) == len(mbids):
|
||||
res.update({(a, m) for (a, m) in zip(albumartists, mbids)})
|
||||
found.update({m for m in mbids if len(m)})
|
||||
else:
|
||||
res.update({(None, mbid) for mbid in mbids})
|
||||
need.update({mbid for mbid in mbids if len(mbid)})
|
||||
|
||||
res.difference_update({(None, mbid) for mbid in found & need})
|
||||
return [_Artist(a, m) for (a, m) in list(res)]
|
||||
|
||||
def list_genres(self) -> list[str]:
|
||||
"""Find the genres of the file."""
|
||||
res = []
|
||||
|
||||
for genre in self.tags.get("genre", []):
|
||||
res.extend([g.strip() for g in re.split("[,;/]", genre)])
|
||||
|
||||
for reltype in self.tags.get("releasetype", []):
|
||||
match reltype:
|
||||
case "album" | "compilation": continue
|
||||
case "ep": res.append("EP")
|
||||
case _: res.append(reltype.title())
|
||||
|
||||
return res
|
||||
|
||||
def get_release(self) -> str:
|
||||
"""Find the release date of the file."""
|
||||
if (res := self.tags.get("originaldate")) is None:
|
||||
if (res := self.tags.get("originalyear")) is None:
|
||||
if (res := self.tags.get("date")) is None:
|
||||
res = self.tags.get("year", [""])
|
||||
return res[0]
|
||||
|
||||
def get_year(self) -> int | None:
|
||||
"""Find the year in the release string."""
|
||||
if len(self.album.release):
|
||||
return int(re.match(r"\d+", self.album.release).group(0))
|
||||
|
||||
|
||||
def tag_file(file: pathlib.Path, mtime: float | None) -> _Tags | None:
|
||||
"""Tag the requested file."""
|
||||
if file.is_file():
|
||||
file_mtime = file.stat().st_mtime
|
||||
if mtime is None or file_mtime > mtime:
|
||||
if (tags := mutagen.File(file)) is not None:
|
||||
return _Tags(file, tags, tags.info.length, file_mtime)
|
|
@ -0,0 +1,152 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""Helper classes for Buttons."""
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
|
||||
|
||||
class Button(Gtk.Button):
|
||||
"""A Gtk.Button with extra properties and default large size."""
|
||||
|
||||
icon_name = GObject.Property(type=str)
|
||||
icon_opacity = GObject.Property(type=float, default=1.0,
|
||||
minimum=0.0, maximum=1.0)
|
||||
|
||||
def __init__(self, large_icon: bool = False, **kwargs):
|
||||
"""Initialize a Button."""
|
||||
super().__init__(focusable=False, **kwargs)
|
||||
icon_size = Gtk.IconSize.LARGE if large_icon else Gtk.IconSize.NORMAL
|
||||
self._image = Gtk.Image(icon_name=self.icon_name, icon_size=icon_size,
|
||||
opacity=self.icon_opacity)
|
||||
self.bind_property("icon-name", self._image, "icon-name")
|
||||
self.bind_property("icon-opacity", self._image, "opacity")
|
||||
self.set_child(self._image)
|
||||
|
||||
@GObject.Property(type=bool, default=False)
|
||||
def large_icon(self) -> bool:
|
||||
"""Get if this Button has a large icon."""
|
||||
return self._image.get_icon_size() == Gtk.IconSize.LARGE
|
||||
|
||||
@large_icon.setter
|
||||
def large_icon(self, newval: bool) -> None:
|
||||
size = Gtk.IconSize.LARGE if newval else Gtk.IconSize.NORMAL
|
||||
self._image.set_icon_size(size)
|
||||
|
||||
|
||||
class PopoverButton(Gtk.MenuButton):
|
||||
"""A MenuButton with a Gtk.Popover attached."""
|
||||
|
||||
popover_child = GObject.Property(type=Gtk.Widget)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize a popover.Button."""
|
||||
super().__init__(popover=Gtk.Popover(), **kwargs)
|
||||
self.bind_property("popover-child", self.get_popover(), "child")
|
||||
self.get_popover().set_child(self.popover_child)
|
||||
|
||||
def popdown(self):
|
||||
"""Close the popover."""
|
||||
self.get_popover().popdown()
|
||||
|
||||
|
||||
class SplitButton(Gtk.Box):
|
||||
"""A Button and secondary widget packed together."""
|
||||
|
||||
icon_name = GObject.Property(type=str)
|
||||
large_icon = GObject.Property(type=bool, default=False)
|
||||
|
||||
def __init__(self, secondary: Gtk.Button, **kwargs):
|
||||
"""Initialize a Split Button."""
|
||||
super().__init__(**kwargs)
|
||||
self._primary = Button(hexpand=True, icon_name=self.icon_name,
|
||||
large_icon=self.large_icon)
|
||||
self._separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL,
|
||||
margin_top=12, margin_bottom=12)
|
||||
self._secondary = secondary
|
||||
|
||||
self.bind_property("icon-name", self._primary, "icon-name")
|
||||
self.bind_property("large-icon", self._primary, "large-icon")
|
||||
self._primary.connect("activate", self.__activate)
|
||||
self._primary.connect("clicked", self.__clicked)
|
||||
|
||||
self.append(self._primary)
|
||||
self.append(self._separator)
|
||||
self.append(secondary)
|
||||
|
||||
self.add_css_class("emmental-splitbutton")
|
||||
|
||||
def __activate(self, button: Button) -> None:
|
||||
self.emit("activate-primary")
|
||||
|
||||
def __clicked(self, button: Button) -> None:
|
||||
self.emit("clicked")
|
||||
|
||||
def activate(self, *args) -> None:
|
||||
"""Activate the primary button."""
|
||||
self._primary.activate()
|
||||
|
||||
@GObject.Property(type=Gtk.Button, flags=GObject.ParamFlags.READABLE)
|
||||
def secondary(self) -> Gtk.Button:
|
||||
"""Get the secondary button attached to the SplitButton."""
|
||||
return self._secondary
|
||||
|
||||
@GObject.Signal
|
||||
def activate_primary(self) -> None:
|
||||
"""Signal that the primary button has been activated."""
|
||||
|
||||
@GObject.Signal
|
||||
def clicked(self) -> None:
|
||||
"""Signal that the primary button has been clicked."""
|
||||
|
||||
|
||||
class ImageToggle(Button):
|
||||
"""Inspired by a ToggleButton, but changes image based on state."""
|
||||
|
||||
active_icon_name = GObject.Property(type=str)
|
||||
active_tooltip_text = GObject.Property(type=str)
|
||||
|
||||
inactive_icon_name = GObject.Property(type=str)
|
||||
inactive_tooltip_text = GObject.Property(type=str)
|
||||
|
||||
def __init__(self, active_icon_name: str, inactive_icon_name: str,
|
||||
active_tooltip_text: str | None = None,
|
||||
inactive_tooltip_text: str | None = None,
|
||||
*, active: bool = False, **kwargs) -> None:
|
||||
"""Initialize an ImageToggle button."""
|
||||
super().__init__(active_icon_name=active_icon_name,
|
||||
inactive_icon_name=inactive_icon_name,
|
||||
icon_name=inactive_icon_name,
|
||||
active_tooltip_text=active_tooltip_text,
|
||||
inactive_tooltip_text=inactive_tooltip_text,
|
||||
tooltip_text=inactive_tooltip_text,
|
||||
active=active, **kwargs)
|
||||
self.connect("notify", self.__notify)
|
||||
|
||||
def __notify(self, toggle: Button, param: GObject.ParamSpec) -> None:
|
||||
match (param.name, self.active):
|
||||
case ("active-tooltip-text", True) | \
|
||||
("inactive-tooltip-text", False):
|
||||
self.set_tooltip_text(self.get_property(param.name))
|
||||
|
||||
def do_clicked(self) -> None:
|
||||
"""Handle a click event."""
|
||||
self.active = not self.active
|
||||
|
||||
@GObject.Property(type=bool, default=False)
|
||||
def active(self) -> bool:
|
||||
"""Get the active state."""
|
||||
return self.icon_name == self.active_icon_name
|
||||
|
||||
@active.setter
|
||||
def active(self, newval: bool) -> None:
|
||||
if newval != self.active:
|
||||
if newval:
|
||||
self.icon_name = self.active_icon_name
|
||||
self.props.tooltip_text = self.active_tooltip_text
|
||||
else:
|
||||
self.icon_name = self.inactive_icon_name
|
||||
self.props.tooltip_text = self.inactive_tooltip_text
|
||||
self.emit("toggled")
|
||||
|
||||
@GObject.Signal
|
||||
def toggled(self) -> None:
|
||||
"""Active state has been toggled."""
|
|
@ -0,0 +1,114 @@
|
|||
# Copyright 2022 (c) Anna Schumaker
|
||||
"""Easily work with our underlying sqlite3 database."""
|
||||
import pathlib
|
||||
from gi.repository import GObject
|
||||
from typing import Generator
|
||||
from . import albums
|
||||
from . import artists
|
||||
from . import connection
|
||||
from . import decades
|
||||
from . import genres
|
||||
from . import libraries
|
||||
from . import playlist
|
||||
from . import media
|
||||
from . import playlists
|
||||
from . import settings
|
||||
from . import table
|
||||
from . import tracks
|
||||
from . import years
|
||||
|
||||
|
||||
SQL_V1_SCRIPT = pathlib.Path(__file__).parent / "emmental.sql"
|
||||
SQL_V2_SCRIPT = pathlib.Path(__file__).parent / "upgrade-v2.sql"
|
||||
SQL_V3_SCRIPT = pathlib.Path(__file__).parent / "upgrade-v3.sql"
|
||||
|
||||
|
||||
class Connection(connection.Connection):
|
||||
"""Connect to the database."""
|
||||
|
||||
active_playlist = GObject.Property(type=playlist.Playlist)
|
||||
loaded = GObject.Property(type=bool, default=False)
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize a sqlite connection."""
|
||||
super().__init__()
|
||||
self.__check_version()
|
||||
|
||||
self.settings = settings.Table(self)
|
||||
self.playlists = playlists.Table(self)
|
||||
self.artists = artists.Table(self)
|
||||
self.albums = albums.Table(self, queue=self.artists.queue)
|
||||
self.media = media.Table(self, queue=self.artists.queue)
|
||||
self.genres = genres.Table(self)
|
||||
self.decades = decades.Table(self)
|
||||
self.years = years.Table(self, queue=self.decades.queue)
|
||||
self.libraries = libraries.Table(self)
|
||||
|
||||
self.tracks = tracks.Table(self)
|
||||
|
||||
def __check_loaded(self) -> None:
|
||||
for tbl in list(self.playlist_tables()) + [self.tracks]:
|
||||
if tbl.loaded is False:
|
||||
return
|
||||
self.loaded = True
|
||||
|
||||
def __check_version(self) -> None:
|
||||
user_version = self("PRAGMA user_version").fetchone()["user_version"]
|
||||
match user_version:
|
||||
case 0:
|
||||
self.executescript(SQL_V1_SCRIPT)
|
||||
self.executescript(SQL_V2_SCRIPT)
|
||||
self.executescript(SQL_V3_SCRIPT)
|
||||
case 1:
|
||||
self.executescript(SQL_V2_SCRIPT)
|
||||
self.executescript(SQL_V3_SCRIPT)
|
||||
case 2:
|
||||
self.executescript(SQL_V3_SCRIPT)
|
||||
case 3: pass
|
||||
case _:
|
||||
raise Exception(f"Unsupported data version: {user_version}")
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the database connection."""
|
||||
self.settings.stop()
|
||||
for tbl in self.playlist_tables():
|
||||
tbl.stop()
|
||||
self.tracks.stop()
|
||||
|
||||
super().close()
|
||||
|
||||
def filter(self, glob: str) -> None:
|
||||
"""Filter the playlist tables."""
|
||||
for tbl in self.playlist_tables():
|
||||
tbl.filter(glob)
|
||||
|
||||
def load(self) -> None:
|
||||
"""Load the database tables."""
|
||||
self.settings.load()
|
||||
for tbl in self.playlist_tables():
|
||||
tbl.load()
|
||||
self.tracks.load()
|
||||
|
||||
def playlist_tables(self) -> Generator[playlist.Table, None, None]:
|
||||
"""Iterate over each playlist table."""
|
||||
for tbl in [self.playlists, self.artists, self.albums, self.media,
|
||||
self.genres, self.decades, self.years, self.libraries]:
|
||||
yield tbl
|
||||
|
||||
def set_active_playlist(self, plist: playlist.Playlist) -> None:
|
||||
"""Set the currently active playlist."""
|
||||
if self.active_playlist == plist:
|
||||
return
|
||||
if self.active_playlist is not None:
|
||||
self.active_playlist.active = False
|
||||
|
||||
self.active_playlist = plist
|
||||
|
||||
if plist is not None:
|
||||
plist.active = True
|
||||
|
||||
@GObject.Signal(arg_types=(table.Table,))
|
||||
def table_loaded(self, tbl: table.Table) -> None:
|
||||
"""Signal that a table has been loaded."""
|
||||
tbl.loaded = True
|
||||
self.__check_loaded()
|
|
@ -0,0 +1,157 @@
|
|||
# Copyright 2022 (c) Anna Schumaker
|
||||
"""A custom Gio.ListModel for working with albums."""
|
||||
import pathlib
|
||||
import sqlite3
|
||||
from gi.repository import GObject
|
||||
from .media import Medium
|
||||
from .. import format
|
||||
from . import playlist
|
||||
from . import tracks
|
||||
|
||||
|
||||
class Album(playlist.Playlist):
|
||||
"""Our custom Album with a ListModel representing mediums."""
|
||||
|
||||
albumid = GObject.Property(type=int)
|
||||
artist = GObject.Property(type=str)
|
||||
release = GObject.Property(type=str)
|
||||
mbid = GObject.Property(type=str)
|
||||
cover = GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize an Album object."""
|
||||
super().__init__(**kwargs)
|
||||
self.add_children(self.table.sql.media,
|
||||
self.table.get_mediumids(self))
|
||||
|
||||
def add_medium(self, medium: Medium) -> None:
|
||||
"""Add a Medium to this Album."""
|
||||
self.add_child(medium)
|
||||
|
||||
def get_artists(self) -> list[playlist.Playlist]:
|
||||
"""Get a list of artists for this album."""
|
||||
return self.table.get_artists(self)
|
||||
|
||||
def get_media(self) -> list[Medium]:
|
||||
"""Get a list of media for this album."""
|
||||
return self.table.get_media(self)
|
||||
|
||||
def has_medium(self, medium: Medium) -> bool:
|
||||
"""Check if a Medium is from this Album."""
|
||||
return self.has_child(medium)
|
||||
|
||||
def remove_medium(self, medium: Medium) -> None:
|
||||
"""Remove a Medium from this Album."""
|
||||
return self.remove_child(medium)
|
||||
|
||||
@property
|
||||
def primary_key(self) -> int:
|
||||
"""Get the Album primary key."""
|
||||
return self.albumid
|
||||
|
||||
@GObject.Property(type=playlist.Playlist)
|
||||
def parent(self) -> playlist.Playlist | None:
|
||||
"""Get the parent playlist of this Album."""
|
||||
artists = self.get_artists()
|
||||
return artists[0] if len(artists) else None
|
||||
|
||||
|
||||
class Table(playlist.Table):
|
||||
"""Our Album Table."""
|
||||
|
||||
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
|
||||
"""Initialize the Album Table."""
|
||||
super().__init__(sql=sql, autodelete=True,
|
||||
system_tracks=False, **kwargs)
|
||||
|
||||
def do_add_track(self, album: Album, track: tracks.Track) -> bool:
|
||||
"""Verify adding a Track to the Album playlist."""
|
||||
return track.get_medium().get_album() == album
|
||||
|
||||
def do_construct(self, **kwargs) -> Album:
|
||||
"""Construct a new album."""
|
||||
return Album(**kwargs)
|
||||
|
||||
def do_get_sort_key(self, album: Album) -> tuple[tuple, bool,
|
||||
str, tuple, str]:
|
||||
"""Get a sort key for the requested Artist."""
|
||||
return (format.sort_key(album.name),
|
||||
len(album.mbid) == 0, album.mbid.casefold(),
|
||||
format.sort_key(album.artist),
|
||||
album.release)
|
||||
|
||||
def do_remove_track(self, album: Album, track: tracks.Track) -> bool:
|
||||
"""Verify removing a Track from the Album playlist."""
|
||||
return True
|
||||
|
||||
def do_sql_delete(self, album: Album) -> sqlite3.Cursor:
|
||||
"""Delete an album."""
|
||||
for artist in album.get_artists():
|
||||
artist.remove_album(album)
|
||||
for medium in album.get_media():
|
||||
medium.delete()
|
||||
return self.sql("DELETE FROM albums WHERE albumid=?", album.albumid)
|
||||
|
||||
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
|
||||
"""Search for albums matching the search text."""
|
||||
return self.sql("""SELECT albumid FROM album_artist_view
|
||||
WHERE CASEFOLD(album) GLOB :glob
|
||||
OR CASEFOLD(medium) GLOB :glob""", glob=glob)
|
||||
|
||||
def do_sql_insert(self, name: str, artist: str,
|
||||
release: str, *, mbid: str = "",
|
||||
cover: pathlib.Path = None) -> sqlite3.Cursor | None:
|
||||
"""Create a new album."""
|
||||
if cur := self.sql("""INSERT INTO albums
|
||||
(name, artist, release, mbid, cover)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
name, artist, release, mbid, cover):
|
||||
return self.sql("SELECT * FROM albums_view WHERE albumid=?",
|
||||
cur.lastrowid)
|
||||
|
||||
def do_sql_select_all(self) -> sqlite3.Cursor:
|
||||
"""Load albums from the database."""
|
||||
return self.sql("SELECT * FROM albums_view")
|
||||
|
||||
def do_sql_select_one(self, name: str = None,
|
||||
artist: str = None, release: str = None,
|
||||
*, mbid: str = "") -> sqlite3.Cursor:
|
||||
"""Look up an albums by name, mbid, artist, and release."""
|
||||
where = ["mbid=?"]
|
||||
args = [mbid.lower()]
|
||||
|
||||
if None not in (name, artist, release):
|
||||
where.extend(["CASEFOLD(name)=?",
|
||||
"CASEFOLD(artist)=?", "release=?"])
|
||||
args.extend([name.casefold(), artist.casefold(), release])
|
||||
|
||||
return self.sql(f"""SELECT albumid FROM albums
|
||||
WHERE {" AND ".join(where)}""", *args)
|
||||
|
||||
def do_sql_select_trackids(self, album: Album) -> sqlite3.Cursor:
|
||||
"""Load an Album's Tracks from the database."""
|
||||
return self.sql("""SELECT trackid FROM album_tracks_view
|
||||
WHERE albumid=?""", album.albumid)
|
||||
|
||||
def do_sql_update(self, album: Album, column: str, newval) -> bool:
|
||||
"""Rename an album."""
|
||||
return self.sql(f"UPDATE albums SET {column}=? WHERE albumid=?",
|
||||
newval, album.albumid)
|
||||
|
||||
def get_artists(self, album: Album) -> list[playlist.Playlist]:
|
||||
"""Get the list of artists for this album."""
|
||||
rows = self.sql("""SELECT artistid FROM album_artist_link
|
||||
WHERE albumid=?""", album.albumid).fetchall()
|
||||
artists = [self.sql.artists.rows.get(row["artistid"]) for row in rows]
|
||||
return list(filter(None, artists))
|
||||
|
||||
def get_media(self, album: Album) -> list[Medium]:
|
||||
"""Get the list of media for this album."""
|
||||
return [self.sql.media.rows.get(id)
|
||||
for id in self.get_mediumids(album)]
|
||||
|
||||
def get_mediumids(self, album: Album) -> set[int]:
|
||||
"""Get the set of mediumids for this album."""
|
||||
rows = self.sql("SELECT mediumid FROM media WHERE albumid=?",
|
||||
album.albumid)
|
||||
return {row["mediumid"] for row in rows.fetchall()}
|
|
@ -0,0 +1,147 @@
|
|||
# Copyright 2022 (c) Anna Schumaker
|
||||
"""A custom Gio.ListModel for working with artists."""
|
||||
import sqlite3
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
from .albums import Album
|
||||
from .. import format
|
||||
from . import playlist
|
||||
from . import table
|
||||
|
||||
|
||||
class Artist(playlist.Playlist):
|
||||
"""Our custom Artist object."""
|
||||
|
||||
artistid = GObject.Property(type=int)
|
||||
mbid = GObject.Property(type=str)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize an Artist object."""
|
||||
super().__init__(**kwargs)
|
||||
self.add_children(self.table.sql.albums,
|
||||
self.table.get_albumids(self))
|
||||
|
||||
def add_album(self, album: Album) -> None:
|
||||
"""Add an Album to this Artist."""
|
||||
if self.table.add_album(self, album):
|
||||
self.add_child(album)
|
||||
|
||||
def has_album(self, album: Album) -> bool:
|
||||
"""Check if the Artist has this Album."""
|
||||
return self.has_child(album)
|
||||
|
||||
def remove_album(self, album: Album) -> None:
|
||||
"""Remove an album from this Artist."""
|
||||
self.table.remove_album(self, album)
|
||||
self.remove_child(album)
|
||||
|
||||
@property
|
||||
def primary_key(self) -> int:
|
||||
"""Get the Artist primary key."""
|
||||
return self.artistid
|
||||
|
||||
|
||||
class Filter(table.KeySet):
|
||||
"""Custom filter to hide artists without albums."""
|
||||
|
||||
show_all = GObject.Property(type=bool, default=False)
|
||||
|
||||
def __init__(self, show_all: bool = False):
|
||||
"""Initialize the Artist filter."""
|
||||
super().__init__(show_all=show_all)
|
||||
self.connect("notify::show-all", self.__notify_show_all)
|
||||
|
||||
def __notify_show_all(self, filter: table.KeySet, param) -> None:
|
||||
self.changed(Gtk.FilterChange.LESS_STRICT if self.show_all else
|
||||
Gtk.FilterChange.MORE_STRICT)
|
||||
|
||||
def do_get_strictness(self) -> Gtk.FilterMatch:
|
||||
"""Get the strictness of the filter."""
|
||||
res = super().do_get_strictness()
|
||||
if not self.show_all and res == Gtk.FilterMatch.ALL:
|
||||
return Gtk.FilterMatch.SOME
|
||||
return res
|
||||
|
||||
def do_match(self, artist: Artist) -> bool:
|
||||
"""Check if the artist matches the filter."""
|
||||
res = super().do_match(artist)
|
||||
if not self.show_all and res:
|
||||
return artist.child_set.keyset.n_keys > 0
|
||||
return res
|
||||
|
||||
|
||||
class Table(playlist.Table):
|
||||
"""Our Artist Table."""
|
||||
|
||||
show_all = GObject.Property(type=bool, default=False)
|
||||
|
||||
def __init__(self, sql: GObject.TYPE_PYOBJECT,
|
||||
show_all: bool = False, **kwargs):
|
||||
"""Initialize an Artist model."""
|
||||
super().__init__(sql=sql, show_all=show_all, autodelete=True,
|
||||
filter=Filter(show_all=show_all), **kwargs)
|
||||
self.bind_property("show-all", self.get_filter(), "show-all")
|
||||
|
||||
def do_construct(self, **kwargs) -> Artist:
|
||||
"""Construct a new artist."""
|
||||
return Artist(**kwargs)
|
||||
|
||||
def do_get_sort_key(self, artist: Artist) -> tuple[tuple, bool, str]:
|
||||
"""Get a sort key for the requested Playlist."""
|
||||
return (format.sort_key(artist.name),
|
||||
len(artist.mbid) == 0,
|
||||
artist.mbid.casefold())
|
||||
|
||||
def do_sql_delete(self, artist: Artist) -> bool:
|
||||
"""Delete an artist."""
|
||||
return self.sql("DELETE FROM artists WHERE artistid=?",
|
||||
artist.artistid)
|
||||
|
||||
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
|
||||
"""Search for artists matching the search text."""
|
||||
return self.sql("""SELECT artistid FROM album_artist_view
|
||||
WHERE CASEFOLD(artist) GLOB :glob
|
||||
OR CASEFOLD(album) GLOB :glob
|
||||
OR CASEFOLD(medium) GLOB :glob""", glob=glob)
|
||||
|
||||
def do_sql_insert(self, name: str,
|
||||
mbid: str = "") -> sqlite3.Cursor | None:
|
||||
"""Create a new artist."""
|
||||
if cur := self.sql("INSERT INTO artists (name, mbid) VALUES (?, ?)",
|
||||
name, mbid):
|
||||
return self.sql("SELECT * FROM artists_view WHERE artistid=?",
|
||||
cur.lastrowid)
|
||||
|
||||
def do_sql_select_all(self) -> sqlite3.Cursor:
|
||||
"""Load artists from the database."""
|
||||
return self.sql("SELECT * FROM artists_view")
|
||||
|
||||
def do_sql_select_one(self, name: str | None = None,
|
||||
*, mbid: str = "") -> sqlite3.Cursor:
|
||||
"""Look up an artist by name and mbid."""
|
||||
where = "mbid=? AND CASEFOLD(name)=?" if name else "mbid=?"
|
||||
args = [mbid.lower(), name.casefold()] if name else [mbid.lower()]
|
||||
return self.sql(f"SELECT artistid FROM artists WHERE {where}", *args)
|
||||
|
||||
def do_sql_update(self, artist: Artist,
|
||||
column: str, newval) -> sqlite3.Cursor:
|
||||
"""Update an artist."""
|
||||
return self.sql(f"UPDATE artists SET {column}=? WHERE artistid=?",
|
||||
newval, artist.artistid)
|
||||
|
||||
def add_album(self, artist: Artist, album: Album) -> bool:
|
||||
"""Add an album to this artist."""
|
||||
return self.sql("INSERT INTO album_artist_link VALUES (?, ?)",
|
||||
artist.artistid, album.albumid) is not None
|
||||
|
||||
def get_albumids(self, artist: Artist) -> set[int]:
|
||||
"""Get an Artist's associated albumids from the database."""
|
||||
cur = self.sql("""SELECT albumid FROM album_artist_link
|
||||
WHERE artistid=?""", artist.artistid)
|
||||
return {row["albumid"] for row in cur.fetchall()}
|
||||
|
||||
def remove_album(self, artist: Artist, album: Album) -> bool:
|
||||
"""Remove an album from this artist."""
|
||||
return self.sql("""DELETE FROM album_artist_link
|
||||
WHERE artistid=? AND albumid=?""",
|
||||
artist.artistid, album.albumid).rowcount == 1
|
|
@ -0,0 +1,95 @@
|
|||
# Copyright 2022 (c) Anna Schumaker
|
||||
"""Easily work with our underlying sqlite3 database."""
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import sys
|
||||
from gi.repository import GObject
|
||||
from .. import gsetup
|
||||
|
||||
|
||||
DATA_FILE = gsetup.DATA_DIR / f"emmental{gsetup.DEBUG_STR}.sqlite3"
|
||||
DATABASE = ":memory:" if "unittest" in sys.modules else DATA_FILE
|
||||
|
||||
|
||||
def adapt_path(path: pathlib.Path) -> str:
|
||||
"""Adapt a pathlib.Path into a sqlite3 string."""
|
||||
return str(path)
|
||||
|
||||
|
||||
def convert_path(path: bytes) -> pathlib.Path:
|
||||
"""Convert a path string into a pathlib.Path object."""
|
||||
return pathlib.Path(path.decode())
|
||||
|
||||
|
||||
sqlite3.register_adapter(pathlib.PosixPath, adapt_path)
|
||||
sqlite3.register_converter("path", convert_path)
|
||||
|
||||
|
||||
class Connection(GObject.GObject):
|
||||
"""Connect to the database."""
|
||||
|
||||
connected = GObject.Property(type=bool, default=True)
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize a sqlite connection."""
|
||||
super().__init__()
|
||||
self._sql = sqlite3.connect(DATABASE,
|
||||
detect_types=sqlite3.PARSE_DECLTYPES)
|
||||
self._sql.create_function("CASEFOLD", 1,
|
||||
lambda s: s.casefold() if s else None,
|
||||
deterministic=True)
|
||||
self._sql.row_factory = sqlite3.Row
|
||||
self._sql("PRAGMA foreign_keys = ON")
|
||||
|
||||
def __call__(self, statement: str,
|
||||
*args, **kwargs) -> sqlite3.Cursor | None:
|
||||
"""Execute a SQL statement."""
|
||||
try:
|
||||
return self._sql.execute(statement, args if len(args) else kwargs)
|
||||
except sqlite3.IntegrityError:
|
||||
return None
|
||||
|
||||
def __del__(self) -> None:
|
||||
"""Clean up before exiting."""
|
||||
self.close()
|
||||
|
||||
def __enter__(self) -> None:
|
||||
"""Begin a transaction."""
|
||||
if not self._sql.in_transaction:
|
||||
self._sql.commit()
|
||||
self._sql.execute("BEGIN")
|
||||
|
||||
def __exit__(self, exp_type, exp_value, traceback) -> bool:
|
||||
"""Either commit or rollback an active transaction."""
|
||||
if exp_type is None:
|
||||
self._sql.commit()
|
||||
else:
|
||||
self._sql.rollback()
|
||||
return exp_type is None
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the database connection."""
|
||||
if self.connected:
|
||||
self._sql.commit()
|
||||
self._sql.execute("PRAGMA optimize")
|
||||
self._sql.close()
|
||||
self.connected = False
|
||||
|
||||
def commit(self) -> None:
|
||||
"""Commit pending changes."""
|
||||
self._sql.commit()
|
||||
|
||||
def executemany(self, statement: str, *args) -> sqlite3.Cursor | None:
|
||||
"""Execute several similar SQL statements at once."""
|
||||
try:
|
||||
return self._sql.executemany(statement, args)
|
||||
except sqlite3.InternalError:
|
||||
return None
|
||||
|
||||
def executescript(self, script: pathlib.Path) -> sqlite3.Cursor | None:
|
||||
"""Execute a SQL script."""
|
||||
if script.is_file():
|
||||
with open(script) as f:
|
||||
cur = self._sql.executescript(f.read())
|
||||
self.commit()
|
||||
return cur
|
|
@ -0,0 +1,109 @@
|
|||
# Copyright 2022 (c) Anna Schumaker
|
||||
"""A custom Gio.ListModel for working with decades."""
|
||||
import sqlite3
|
||||
from gi.repository import GObject
|
||||
from .years import Year
|
||||
from . import playlist
|
||||
from . import tracks
|
||||
|
||||
|
||||
class Decade(playlist.Playlist):
|
||||
"""Our custom Decade object."""
|
||||
|
||||
decade = GObject.Property(type=int)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize a Decade object."""
|
||||
super().__init__(**kwargs)
|
||||
self.add_children(self.table.sql.years,
|
||||
self.table.get_yearids(self))
|
||||
|
||||
def add_year(self, year: Year) -> None:
|
||||
"""Add a year to this decade."""
|
||||
self.add_child(year)
|
||||
|
||||
def get_years(self) -> list[Year]:
|
||||
"""Get a list of years for this decade."""
|
||||
return self.table.get_years(self)
|
||||
|
||||
def has_year(self, year: Year) -> bool:
|
||||
"""Check if the year is in this decade."""
|
||||
return self.has_child(year)
|
||||
|
||||
def remove_year(self, year: Year) -> None:
|
||||
"""Remove a year from this decade."""
|
||||
self.remove_child(year)
|
||||
|
||||
@property
|
||||
def primary_key(self) -> int:
|
||||
"""Get the primary key of this Decade."""
|
||||
return self.decade
|
||||
|
||||
|
||||
class Table(playlist.Table):
|
||||
"""Our Decade Table."""
|
||||
|
||||
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
|
||||
"""Initialize the Decade table."""
|
||||
super().__init__(sql=sql, autodelete=True,
|
||||
system_tracks=False, **kwargs)
|
||||
|
||||
def do_add_track(self, decade: Decade, track: tracks.Track) -> bool:
|
||||
"""Verify adding a Track to the Decade playlist."""
|
||||
return (track.year // 10 * 10) == decade.decade
|
||||
|
||||
def do_construct(self, **kwargs) -> Decade:
|
||||
"""Construct a new Decade playlist."""
|
||||
return Decade(**kwargs)
|
||||
|
||||
def do_get_sort_key(self, decade: Decade) -> int:
|
||||
"""Get the sort key for the requested decade."""
|
||||
return decade.decade
|
||||
|
||||
def do_remove_track(self, decade: Decade, track: tracks.Track) -> bool:
|
||||
"""Verify removing a Track from the Decade playlist."""
|
||||
return True
|
||||
|
||||
def do_sql_delete(self, decade: Decade) -> sqlite3.Cursor:
|
||||
"""Delete a decade."""
|
||||
for year in decade.get_years():
|
||||
year.delete()
|
||||
return self.sql("DELETE FROM decades WHERE decade=?", decade.decade)
|
||||
|
||||
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
|
||||
"""Search for decades matching the search text."""
|
||||
return self.sql("""SELECT decade FROM decades_view
|
||||
WHERE CASEFOLD(name) GLOB :glob
|
||||
UNION SELECT (year / 10 * 10) AS decade
|
||||
FROM years WHERE year GLOB :glob""", glob=glob)
|
||||
|
||||
def do_sql_insert(self, year: int) -> sqlite3.Cursor | None:
|
||||
"""Create a new Decade playlist."""
|
||||
decade = year // 10 * 10
|
||||
if self.sql("INSERT INTO decades (decade) VALUES (?)", decade):
|
||||
return self.sql("SELECT * FROM decades_view WHERE decade=?",
|
||||
decade)
|
||||
|
||||
def do_sql_select_all(self) -> sqlite3.Cursor:
|
||||
"""Load Decades from the database."""
|
||||
return self.sql("SELECT * FROM decades_view")
|
||||
|
||||
def do_sql_select_one(self, year: int) -> sqlite3.Cursor:
|
||||
"""Look up an decade by year."""
|
||||
return self.sql("SELECT decade FROM decades WHERE decade=?",
|
||||
year // 10 * 10)
|
||||
|
||||
def do_sql_select_trackids(self, decade: Decade) -> sqlite3.Cursor:
|
||||
"""Load a Decade's Tracks from the database."""
|
||||
return self.sql("""SELECT trackid FROM decade_tracks_view
|
||||
WHERE decade=?""", decade.decade)
|
||||
|
||||
def get_yearids(self, decade: Decade) -> set[int]:
|
||||
"""Get the set of years for this decade."""
|
||||
rows = self.sql("SELECT year FROM years WHERE (year / 10 * 10)=?",
|
||||
decade.decade)
|
||||
return {row["year"] for row in rows}
|
||||
|
||||
def get_years(self, decade: Decade) -> list[Year]:
|
||||
"""Get the list of years for this decade."""
|
||||
return [self.sql.years.rows.get(yr) for yr in self.get_yearids(decade)]
|
|
@ -0,0 +1,633 @@
|
|||
/* Copyright 2022 (c) Anna Schumaker */
|
||||
|
||||
PRAGMA user_version = 1;
|
||||
|
||||
|
||||
/**************************************
|
||||
* *
|
||||
* Application Settings *
|
||||
* *
|
||||
**************************************/
|
||||
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
CHECK (type IN ("gint", "gdouble", "gboolean", "gchararray"))
|
||||
);
|
||||
|
||||
|
||||
/*************************************
|
||||
* *
|
||||
* Playlist Properties *
|
||||
* *
|
||||
*************************************/
|
||||
|
||||
CREATE TABLE playlist_properties (
|
||||
propertyid INTEGER PRIMARY KEY,
|
||||
active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
loop STRING NOT NULL DEFAULT "None",
|
||||
shuffle BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order STRING NOT NULL DEFAULT "",
|
||||
current_trackid INTEGER DEFAULT NULL REFERENCES tracks (trackid)
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE,
|
||||
CHECK (loop IN ("None", "Track", "Playlist"))
|
||||
);
|
||||
|
||||
CREATE TRIGGER playlists_active_trigger
|
||||
AFTER UPDATE OF active ON playlist_properties
|
||||
FOR EACH ROW BEGIN
|
||||
UPDATE playlist_properties
|
||||
SET active = FALSE
|
||||
WHERE propertyid != NEW.propertyid AND active == TRUE;
|
||||
END;
|
||||
|
||||
|
||||
/*******************************************
|
||||
* *
|
||||
* User and System Playlists *
|
||||
* *
|
||||
*******************************************/
|
||||
|
||||
CREATE TABLE playlists (
|
||||
playlistid INTEGER PRIMARY KEY,
|
||||
propertyid INTEGER REFERENCES playlist_properties(propertyid)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
image PATH
|
||||
);
|
||||
|
||||
CREATE VIEW playlists_view AS
|
||||
SELECT playlistid, propertyid, name, image,
|
||||
active, loop, shuffle, sort_order, current_trackid
|
||||
FROM playlists
|
||||
JOIN playlist_properties USING (propertyid);
|
||||
|
||||
CREATE TRIGGER playlists_insert_trigger AFTER INSERT ON playlists
|
||||
BEGIN
|
||||
INSERT INTO playlist_properties (active, loop, sort_order)
|
||||
VALUES (NEW.name == "Collection",
|
||||
IIF(NEW.name == "Collection", "Playlist", "None"),
|
||||
CASE
|
||||
WHEN NEW.name == "Most Played Tracks"
|
||||
THEN "playcount DESC, albumartist, album, mediumno, number"
|
||||
WHEN NEW.name == "Previous Tracks"
|
||||
THEN "laststarted DESC"
|
||||
ELSE "albumartist, album, mediumno, number"
|
||||
END);
|
||||
UPDATE playlists SET propertyid = last_insert_rowid()
|
||||
WHERE playlistid = NEW.playlistid;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER playlists_delete_trigger AFTER DELETE ON playlists
|
||||
BEGIN
|
||||
DELETE FROM playlist_properties WHERE propertyid = OLD.propertyid;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER collection_loop_trigger
|
||||
BEFORE UPDATE OF loop ON playlist_properties
|
||||
WHEN NEW.loop == "None" AND NEW.propertyid == (SELECT propertyid
|
||||
FROM playlists
|
||||
WHERE name='Collection')
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, "Collection playlist cannot disable loop");
|
||||
END;
|
||||
|
||||
CREATE TRIGGER previous_loop_trigger
|
||||
BEFORE UPDATE OF loop ON playlist_properties
|
||||
WHEN NEW.loop != "None" AND NEW.propertyid == (SELECT propertyid
|
||||
FROM playlists
|
||||
WHERE name='Previous Tracks')
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, "Previous Tracks cannot be looped");
|
||||
END;
|
||||
|
||||
CREATE TRIGGER previous_shuffle_trigger
|
||||
BEFORE UPDATE OF shuffle ON playlist_properties
|
||||
WHEN NEW.shuffle = TRUE AND NEW.propertyid == (SELECT propertyid
|
||||
FROM playlists
|
||||
WHERE name='Previous Tracks')
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, "Previous Tracks cannot be shuffled");
|
||||
END;
|
||||
|
||||
CREATE TRIGGER previous_sort_order_trigger
|
||||
BEFORE UPDATE OF sort_order ON playlist_properties
|
||||
WHEN NEW.sort_order != "laststarted DESC" AND NEW.propertyid == (SELECT propertyid
|
||||
FROM playlists
|
||||
WHERE name='Previous Tracks')
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, "Previous Tracks cannot be sorted");
|
||||
END;
|
||||
|
||||
|
||||
/*************************
|
||||
* *
|
||||
* Artists *
|
||||
* *
|
||||
*************************/
|
||||
|
||||
CREATE TABLE artists (
|
||||
artistid INTEGER PRIMARY KEY,
|
||||
propertyid INTEGER REFERENCES playlist_properties (propertyid)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
name TEXT NOT NULL COLLATE NOCASE,
|
||||
mbid TEXT NOT NULL DEFAULT "" COLLATE NOCASE,
|
||||
UNIQUE (name, mbid)
|
||||
);
|
||||
|
||||
CREATE VIEW artists_view AS
|
||||
SELECT artistid, propertyid, name, mbid,
|
||||
active, loop, shuffle, sort_order, current_trackid
|
||||
FROM artists
|
||||
JOIN playlist_properties USING (propertyid);
|
||||
|
||||
|
||||
CREATE TRIGGER artists_insert_trigger AFTER INSERT ON artists
|
||||
BEGIN
|
||||
INSERT INTO playlist_properties (active, sort_order)
|
||||
VALUES (False, "release, album, mediumno, number");
|
||||
UPDATE artists SET propertyid = last_insert_rowid(),
|
||||
mbid = LOWER(NEW.mbid)
|
||||
WHERE artistid = NEW.artistid;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER artists_delete_trigger AFTER DELETE ON artists
|
||||
BEGIN
|
||||
DELETE FROM playlist_properties WHERE propertyid = OLD.propertyid;
|
||||
END;
|
||||
|
||||
|
||||
/************************
|
||||
* *
|
||||
* Albums *
|
||||
* *
|
||||
************************/
|
||||
|
||||
CREATE TABLE albums (
|
||||
albumid INTEGER PRIMARY KEY,
|
||||
propertyid INTEGER REFERENCES playlist_properties (propertyid)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
name TEXT NOT NULL COLLATE NOCASE,
|
||||
artist TEXT NOT NULL COLLATE NOCASE,
|
||||
release TEXT NOT NULL,
|
||||
mbid TEXT NOT NULL DEFAULT "" COLLATE NOCASE,
|
||||
cover PATH,
|
||||
UNIQUE (name, mbid, artist, release)
|
||||
);
|
||||
|
||||
CREATE VIEW albums_view AS
|
||||
SELECT albumid, propertyid, name, mbid, artist, release, cover,
|
||||
active, loop, shuffle, sort_order, current_trackid
|
||||
FROM albums
|
||||
JOIN playlist_properties USING (propertyid);
|
||||
|
||||
|
||||
CREATE TRIGGER albums_insert_trigger AFTER INSERT ON albums
|
||||
BEGIN
|
||||
INSERT INTO playlist_properties (active, sort_order)
|
||||
VALUES (False, "mediumno, number");
|
||||
UPDATE albums SET propertyid = last_insert_rowid(),
|
||||
mbid = LOWER(NEW.mbid)
|
||||
WHERE albumid = NEW.albumid;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER albums_delete_trigger AFTER DELETE ON albums
|
||||
BEGIN
|
||||
DELETE FROM playlist_properties WHERE propertyid = OLD.propertyid;
|
||||
END;
|
||||
|
||||
|
||||
/*************************
|
||||
* *
|
||||
* Mediums *
|
||||
* *
|
||||
*************************/
|
||||
|
||||
CREATE TABLE media (
|
||||
mediumid INTEGER PRIMARY KEY,
|
||||
propertyid INTEGER REFERENCES playlist_properties (propertyid)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
albumid INTEGER NOT NULL REFERENCES albums (albumid)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
number INTEGER NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT "" COLLATE NOCASE,
|
||||
type TEXT NOT NULL DEFAULT "" COLLATE NOCASE,
|
||||
UNIQUE (albumid, number, type)
|
||||
);
|
||||
|
||||
CREATE VIEW media_view AS
|
||||
SELECT mediumid, propertyid, albumid, number, name, type,
|
||||
active, loop, shuffle, sort_order, current_trackid
|
||||
FROM media
|
||||
JOIN playlist_properties USING (propertyid);
|
||||
|
||||
CREATE TRIGGER media_insert_trigger AFTER INSERT ON media
|
||||
BEGIN
|
||||
INSERT INTO playlist_properties (active, sort_order)
|
||||
VALUES (False, "mediumno, number");
|
||||
UPDATE media SET propertyid = last_insert_rowid()
|
||||
WHERE mediumid = NEW.mediumid;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER media_delete_trigger AFTER DELETE ON media
|
||||
BEGIN
|
||||
DELETE FROM playlist_properties WHERE propertyid = OLD.propertyid;
|
||||
END;
|
||||
|
||||
|
||||
/*******************************************************
|
||||
* *
|
||||
* Artist <--> Album <--> Medium Linking *
|
||||
* *
|
||||
*******************************************************/
|
||||
|
||||
CREATE TABLE album_artist_link (
|
||||
artistid INTEGER NOT NULL REFERENCES artists (artistid)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
albumid INTEGER NOT NULL REFERENCES albums (albumid)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
UNIQUE (artistid, albumid)
|
||||
);
|
||||
|
||||
CREATE VIEW album_artist_view AS
|
||||
SELECT artistid, artists.name as artist,
|
||||
albumid, COALESCE(albums.name, "") as album,
|
||||
media.mediumid, COALESCE(media.name, "") as medium
|
||||
FROM artists
|
||||
LEFT JOIN album_artist_link USING (artistid)
|
||||
LEFT JOIN albums USING (albumid)
|
||||
LEFT JOIN media USING (albumid);
|
||||
|
||||
|
||||
/************************
|
||||
* *
|
||||
* Genres *
|
||||
* *
|
||||
************************/
|
||||
|
||||
CREATE TABLE genres (
|
||||
genreid INTEGER PRIMARY KEY,
|
||||
propertyid INTEGER REFERENCES playlist_properties (propertyid)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE
|
||||
);
|
||||
|
||||
CREATE VIEW genres_view AS
|
||||
SELECT genreid, propertyid, name,
|
||||
active, loop, shuffle, sort_order, current_trackid
|
||||
FROM genres
|
||||
JOIN playlist_properties USING (propertyid);
|
||||
|
||||
CREATE TRIGGER genres_insert_trigger AFTER INSERT ON genres
|
||||
BEGIN
|
||||
INSERT INTO playlist_properties (active, sort_order)
|
||||
VALUES (False, "albumartist, album, mediumno, number");
|
||||
UPDATE genres SET propertyid = last_insert_rowid()
|
||||
WHERE genreid = NEW.genreid;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER genres_delete_trigger AFTER DELETE ON genres
|
||||
BEGIN
|
||||
DELETE FROM playlist_properties WHERE propertyid = OLD.propertyid;
|
||||
END;
|
||||
|
||||
|
||||
/*************************
|
||||
* *
|
||||
* Decades *
|
||||
* *
|
||||
*************************/
|
||||
|
||||
CREATE TABLE decades (
|
||||
decade INTEGER PRIMARY KEY,
|
||||
propertyid INTEGER REFERENCES playlist_properties (propertyid)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
CHECK (decade % 10 = 0)
|
||||
);
|
||||
|
||||
CREATE VIEW decades_view AS
|
||||
SELECT decade, propertyid, FORMAT("The %ds", decade) as name,
|
||||
active, loop, shuffle, sort_order, current_trackid
|
||||
FROM decades
|
||||
JOIN playlist_properties USING (propertyid);
|
||||
|
||||
CREATE TRIGGER decades_insert_trigger AFTER INSERT ON decades
|
||||
BEGIN
|
||||
INSERT INTO playlist_properties (active, sort_order)
|
||||
VALUES (False, "release, albumartist, album, mediumno, number");
|
||||
UPDATE decades SET propertyid = last_insert_rowid()
|
||||
WHERE decade = NEW.decade;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER decades_delete_trigger AFTER DELETE ON decades
|
||||
BEGIN
|
||||
DELETE FROM playlist_properties WHERE propertyid = OLD.propertyid;
|
||||
END;
|
||||
|
||||
|
||||
/***********************
|
||||
* *
|
||||
* Years *
|
||||
* *
|
||||
***********************/
|
||||
|
||||
CREATE TABLE years (
|
||||
year INTEGER PRIMARY KEY,
|
||||
propertyid INTEGER REFERENCES playlist_properties (propertyid)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE VIEW years_view AS
|
||||
SELECT year, propertyid, FORMAT("%s", year) as name,
|
||||
active, loop, shuffle, sort_order, current_trackid
|
||||
FROM years
|
||||
JOIN playlist_properties USING (propertyid);
|
||||
|
||||
CREATE TRIGGER years_insert_trigger AFTER INSERT ON years
|
||||
BEGIN
|
||||
INSERT INTO playlist_properties (active, sort_order)
|
||||
VALUES (False, "release, albumartist, album, mediumno, number");
|
||||
UPDATE years SET propertyid = last_insert_rowid()
|
||||
WHERE year = NEW.year;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER years_delete_trigger AFTER DELETE ON years
|
||||
BEGIN
|
||||
DELETE FROM playlist_properties WHERE propertyid = OLD.propertyid;
|
||||
END;
|
||||
|
||||
|
||||
/*******************************
|
||||
* *
|
||||
* Library Paths *
|
||||
* *
|
||||
*******************************/
|
||||
|
||||
CREATE TABLE libraries (
|
||||
libraryid INTEGER PRIMARY KEY,
|
||||
propertyid INTEGER REFERENCES playlist_properties (propertyid)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
path PATH UNIQUE,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
deleting BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE VIEW libraries_view AS
|
||||
SELECT libraryid, propertyid, path, path as name, enabled,
|
||||
active, loop, shuffle, sort_order, current_trackid
|
||||
FROM libraries
|
||||
JOIN playlist_properties USING (propertyid);
|
||||
|
||||
CREATE TRIGGER libraries_insert_trigger AFTER INSERT ON libraries
|
||||
BEGIN
|
||||
INSERT INTO playlist_properties (active, sort_order)
|
||||
VALUES (False, "filepath");
|
||||
UPDATE libraries SET propertyid = last_insert_rowid()
|
||||
WHERE libraryid = NEW.libraryid;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER libraries_delete_trigger AFTER DELETE ON libraries
|
||||
BEGIN
|
||||
DELETE FROM playlist_properties WHERE propertyid = OLD.propertyid;
|
||||
END;
|
||||
|
||||
|
||||
/************************
|
||||
* *
|
||||
* Tracks *
|
||||
* *
|
||||
************************/
|
||||
|
||||
CREATE TABLE tracks (
|
||||
trackid INTEGER PRIMARY KEY,
|
||||
libraryid INTEGER REFERENCES libraries (libraryid)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
mediumid INTEGER REFERENCES media (mediumid)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
year INTEGER REFERENCES years (year)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
path PATH NOT NULL,
|
||||
mbid TEXT NOT NULL DEFAULT "" COLLATE NOCASE,
|
||||
title TEXT NOT NULL,
|
||||
number INTEGER NOT NULL,
|
||||
length REAL NOT NULL,
|
||||
artist TEXT NOT NULL,
|
||||
mtime REAL NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
favorite BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
playcount INTEGER NOT NULL DEFAULT 0,
|
||||
added DATE DEFAULT CURRENT_DATE,
|
||||
laststarted TIMESTAMP,
|
||||
lastplayed TIMESTAMP,
|
||||
UNIQUE (libraryid, path)
|
||||
);
|
||||
|
||||
CREATE VIEW track_info_view AS
|
||||
SELECT trackid, tracks.mediumid, tracks.number, length, playcount,
|
||||
laststarted, lastplayed, title, tracks.artist,
|
||||
tracks.path as filepath,
|
||||
media.number as mediumno, COALESCE(media.name, "") as medium,
|
||||
albums.albumid, COALESCE(albums.name, "") as album,
|
||||
COALESCE(albums.release, "") as release,
|
||||
COALESCE(albums.artist, "") as albumartist,
|
||||
libraries.deleting
|
||||
FROM tracks
|
||||
LEFT JOIN media USING (mediumid)
|
||||
LEFT JOIN albums USING (albumid)
|
||||
LEFT JOIN libraries USING (libraryid);
|
||||
|
||||
CREATE TRIGGER tracks_active_trigger
|
||||
AFTER UPDATE OF active ON tracks
|
||||
FOR EACH ROW BEGIN
|
||||
UPDATE tracks
|
||||
SET active = FALSE
|
||||
WHERE trackid != NEW.trackid and active == TRUE;
|
||||
END;
|
||||
|
||||
|
||||
/*********************************************
|
||||
* *
|
||||
* Track <--> Playlist Linking *
|
||||
* *
|
||||
*********************************************/
|
||||
|
||||
CREATE TABLE system_tracks (
|
||||
trackid INTEGER REFERENCES tracks (trackid)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
propertyid INTEGER REFERENCES playlist_properties (propertyid)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
UNIQUE(trackid, propertyid)
|
||||
);
|
||||
|
||||
CREATE TABLE user_tracks (
|
||||
trackid INTEGER REFERENCES tracks (trackid)
|
||||
ON DELETE CASCADE
|
||||
ON DELETE CASCADE,
|
||||
propertyid INTEGER REFERENCES playlist_properties (propertyid)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
position INTEGER,
|
||||
UNIQUE(trackid, propertyid)
|
||||
);
|
||||
|
||||
CREATE VIEW system_tracks_view AS
|
||||
SELECT trackid, system_tracks.propertyid
|
||||
FROM system_tracks
|
||||
JOIN tracks USING (trackid)
|
||||
JOIN libraries USING (libraryid)
|
||||
WHERE libraries.deleting = FALSE;
|
||||
|
||||
CREATE VIEW user_tracks_view AS
|
||||
SELECT trackid, user_tracks.propertyid, user_tracks.position
|
||||
FROM user_tracks
|
||||
JOIN tracks USING (trackid)
|
||||
JOIN libraries USING (libraryid)
|
||||
WHERE libraries.deleting = FALSE;
|
||||
|
||||
CREATE VIEW collection_view AS
|
||||
SELECT tracks.trackid FROM tracks
|
||||
JOIN libraries USING (libraryid)
|
||||
WHERE libraries.enabled = TRUE AND libraries.deleting = FALSE;
|
||||
|
||||
CREATE VIEW favorite_view AS
|
||||
SELECT tracks.trackid FROM tracks
|
||||
JOIN libraries USING (libraryid)
|
||||
WHERE tracks.favorite = TRUE AND libraries.deleting = FALSE;
|
||||
|
||||
CREATE VIEW most_played_view AS
|
||||
SELECT tracks.trackid FROM tracks
|
||||
JOIN libraries USING (libraryid)
|
||||
WHERE tracks.playcount > (SELECT CEIL(AVG(playcount))
|
||||
FROM tracks WHERE playcount>0)
|
||||
AND libraries.deleting = FALSE;
|
||||
|
||||
CREATE VIEW new_tracks_view AS
|
||||
SELECT tracks.trackid FROM tracks
|
||||
JOIN libraries USING (libraryid)
|
||||
WHERE tracks.added > DATE('now', 'localtime', '-7 days')
|
||||
AND libraries.deleting = FALSE;
|
||||
|
||||
CREATE VIEW unplayed_tracks_view AS
|
||||
SELECT tracks.trackid FROM tracks
|
||||
JOIN libraries USING (libraryid)
|
||||
WHERE tracks.playcount == 0 AND libraries.deleting = FALSE;
|
||||
|
||||
CREATE VIEW artist_tracks_view AS
|
||||
SELECT tracks.trackid, artists.artistid
|
||||
FROM tracks
|
||||
JOIN system_tracks USING (trackid)
|
||||
JOIN artists USING (propertyid)
|
||||
JOIN libraries USING (libraryid)
|
||||
WHERE libraries.deleting = False;
|
||||
|
||||
CREATE VIEW album_tracks_view AS
|
||||
SELECT tracks.trackid, albums.albumid
|
||||
FROM tracks
|
||||
JOIN media USING (mediumid)
|
||||
JOIN albums USING (albumid)
|
||||
JOIN libraries USING (libraryid)
|
||||
WHERE libraries.deleting = False;
|
||||
|
||||
CREATE VIEW medium_tracks_view AS
|
||||
SELECT tracks.trackid, media.mediumid
|
||||
FROM tracks
|
||||
JOIN media USING (mediumid)
|
||||
JOIN libraries USING (libraryid)
|
||||
WHERE libraries.deleting = False;
|
||||
|
||||
CREATE VIEW genre_tracks_view AS
|
||||
SELECT tracks.trackid, genres.genreid
|
||||
FROM tracks
|
||||
JOIN system_tracks USING (trackid)
|
||||
JOIN genres USING (propertyid)
|
||||
JOIN libraries USING (libraryid)
|
||||
WHERE libraries.deleting = False;
|
||||
|
||||
CREATE VIEW decade_tracks_view AS
|
||||
SELECT tracks.trackid, decades.decade
|
||||
FROM tracks
|
||||
JOIN decades ON (tracks.year / 10 * 10) = decades.decade
|
||||
JOIN libraries USING (libraryid)
|
||||
WHERE libraries.deleting = False;
|
||||
|
||||
CREATE VIEW year_tracks_view AS
|
||||
SELECT tracks.trackid, years.year
|
||||
FROM tracks
|
||||
JOIN years USING (year)
|
||||
JOIN libraries USING (libraryid)
|
||||
WHERE libraries.deleting = False;
|
||||
|
||||
CREATE VIEW library_tracks_view AS
|
||||
SELECT tracks.trackid, libraries.libraryid
|
||||
FROM tracks
|
||||
JOIN libraries USING (libraryid)
|
||||
WHERE libraries.deleting = False;
|
||||
|
||||
|
||||
/****************************************************
|
||||
* *
|
||||
* Data saved when Tracks are deleted *
|
||||
* *
|
||||
****************************************************/
|
||||
|
||||
CREATE TABLE saved_track_data (
|
||||
mbid TEXT PRIMARY KEY,
|
||||
favorite BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
playcount INTEGER NOT NULL DEFAULT 0,
|
||||
lastplayed TIMESTAMP DEFAULT NULL,
|
||||
laststarted TIMESTAMP DEFAULT NULL
|
||||
);
|
||||
|
||||
CREATE TRIGGER tracks_delete_save BEFORE DELETE ON tracks
|
||||
WHEN OLD.mbid != "" BEGIN
|
||||
INSERT INTO saved_track_data
|
||||
(mbid, favorite, playcount, lastplayed, laststarted)
|
||||
VALUES (OLD.mbid, OLD.favorite, OLD.playcount,
|
||||
OLD.lastplayed, OLD.laststarted);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER tracks_insert_restore AFTER INSERT ON tracks
|
||||
WHEN NEW.mbid != "" BEGIN
|
||||
UPDATE tracks SET favorite = saved_track_data.favorite,
|
||||
playcount = saved_track_data.playcount,
|
||||
lastplayed = saved_track_data.lastplayed,
|
||||
laststarted = saved_track_data.laststarted
|
||||
FROM saved_track_data
|
||||
WHERE tracks.mbid = saved_track_data.mbid AND
|
||||
tracks.mbid = NEW.mbid;
|
||||
DELETE FROM saved_track_data WHERE mbid = NEW.mbid;
|
||||
END;
|
||||
|
||||
|
||||
/******************************************
|
||||
* *
|
||||
* Create Default Playlists *
|
||||
* *
|
||||
******************************************/
|
||||
|
||||
INSERT INTO playlists (name) VALUES
|
||||
("Collection"),
|
||||
("Favorite Tracks"),
|
||||
("Most Played Tracks"),
|
||||
("New Tracks"),
|
||||
("Previous Tracks"),
|
||||
("Queued Tracks"),
|
||||
("Unplayed Tracks");
|
|
@ -0,0 +1,63 @@
|
|||
# Copyright 2022 (c) Anna Schumaker
|
||||
"""A custom Gio.ListModel for genres."""
|
||||
import sqlite3
|
||||
from gi.repository import GObject
|
||||
from .. import format
|
||||
from . import playlist
|
||||
|
||||
|
||||
class Genre(playlist.Playlist):
|
||||
"""Our custom Genre object representing a single genre."""
|
||||
|
||||
genreid = GObject.Property(type=int)
|
||||
|
||||
@property
|
||||
def primary_key(self) -> int:
|
||||
"""Get this Gener's primary key."""
|
||||
return self.genreid
|
||||
|
||||
|
||||
class Table(playlist.Table):
|
||||
"""Our Genre Table."""
|
||||
|
||||
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
|
||||
"""Initialize the Genres Table."""
|
||||
super().__init__(sql=sql, autodelete=True, **kwargs)
|
||||
|
||||
def do_construct(self, **kwargs) -> Genre:
|
||||
"""Construct a new Genre."""
|
||||
return Genre(**kwargs)
|
||||
|
||||
def do_get_sort_key(self, genre: Genre) -> tuple[tuple[str], int]:
|
||||
"""Get a sort key for the Genre."""
|
||||
return (format.sort_key(genre.name), genre.genreid)
|
||||
|
||||
def do_sql_delete(self, genre: Genre) -> sqlite3.Cursor:
|
||||
"""Delete a genre."""
|
||||
return self.sql("DELETE FROM genres WHERE genreid=?", genre.genreid)
|
||||
|
||||
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
|
||||
"""Search for genres matching the search text."""
|
||||
return self.sql("""SELECT genreid FROM genres
|
||||
WHERE CASEFOLD(name) GLOB ?""", glob)
|
||||
|
||||
def do_sql_insert(self, name: str) -> sqlite3.Cursor | None:
|
||||
"""Create a new genre."""
|
||||
if cur := self.sql("INSERT INTO genres (name) VALUES (?)", name):
|
||||
return self.sql("SELECT * FROM genres_view WHERE genreid=?",
|
||||
cur.lastrowid)
|
||||
|
||||
def do_sql_select_all(self) -> sqlite3.Cursor:
|
||||
"""Load genres from the database."""
|
||||
return self.sql("SELECT * FROM genres_view")
|
||||
|
||||
def do_sql_select_one(self, name: str) -> sqlite3.Cursor:
|
||||
"""Look up a genre by name."""
|
||||
return self.sql("SELECT genreid FROM genres WHERE CASEFOLD(name)=?",
|
||||
name.casefold())
|
||||
|
||||
def do_sql_update(self, genre: playlist.Playlist,
|
||||
column: str, newval) -> sqlite3.Cursor:
|
||||
"""Update a genre."""
|
||||
return self.sql(f"UPDATE genres SET {column}=? WHERE genreid=?",
|
||||
newval, genre.genreid)
|
|
@ -0,0 +1,94 @@
|
|||
# Copyright 2022 (c) Anna Schumaker
|
||||
"""Idle queues to assid with large database operations."""
|
||||
import typing
|
||||
from gi.repository import GObject
|
||||
from gi.repository import GLib
|
||||
|
||||
|
||||
class Queue(GObject.GObject):
|
||||
"""A base class Idle Queue."""
|
||||
|
||||
total = GObject.Property(type=int)
|
||||
progress = GObject.Property(type=float)
|
||||
running = GObject.Property(type=bool, default=False)
|
||||
enabled = GObject.Property(type=bool, default=True)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize an Idle Queue."""
|
||||
super().__init__(**kwargs)
|
||||
self._tasks = []
|
||||
self._idle_id = None
|
||||
|
||||
def __getitem__(self, n: int) -> tuple:
|
||||
"""Get the n-th task in the queue."""
|
||||
return self._tasks[n] if n < len(self._tasks) else None
|
||||
|
||||
def __run_next_task(self) -> None:
|
||||
task = self._tasks[0]
|
||||
if task[0](*task[1:]):
|
||||
self._tasks.pop(0)
|
||||
|
||||
def __start(self) -> None:
|
||||
if not self.running:
|
||||
self.running = True
|
||||
self._idle_id = GLib.idle_add(self.run_task)
|
||||
self.__update_counters()
|
||||
|
||||
def __update_counters(self) -> bool:
|
||||
if (pending := len(self._tasks)) == 0:
|
||||
self.cancel()
|
||||
return GLib.SOURCE_REMOVE
|
||||
self.progress = 1 - (pending / self.total)
|
||||
return GLib.SOURCE_CONTINUE
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Cancel all pending tasks."""
|
||||
if self._idle_id is not None:
|
||||
GLib.source_remove(self._idle_id)
|
||||
|
||||
self._tasks.clear()
|
||||
self.progress = 0.0
|
||||
self.total = 0
|
||||
self.running = False
|
||||
self._idle_id = None
|
||||
|
||||
def cancel_task(self, func: typing.Callable) -> None:
|
||||
"""Remove all instances of a specific task from the Idle Queue."""
|
||||
self._tasks = [t for t in self._tasks if t[0] != func]
|
||||
self.__update_counters()
|
||||
|
||||
def complete(self) -> None:
|
||||
"""Complete all pending tasks."""
|
||||
if self.running:
|
||||
while len(self._tasks) > 0:
|
||||
self.__run_next_task()
|
||||
self.cancel()
|
||||
|
||||
def push(self, func: typing.Callable, *args,
|
||||
now: bool = False, first: bool = False) -> bool | None:
|
||||
"""Add a task to the Idle Queue."""
|
||||
if not self.enabled or now:
|
||||
return func(*args)
|
||||
|
||||
pos = 0 if first else len(self._tasks)
|
||||
self._tasks.insert(pos, (func, *args))
|
||||
self.total += 1
|
||||
self.__start()
|
||||
|
||||
def push_many(self, func: typing.Callable, args: list[tuple[any]],
|
||||
now: bool = False) -> None:
|
||||
"""Add several tasks to the Idle Queue."""
|
||||
if not self.enabled or now:
|
||||
for arg in args:
|
||||
func(*arg)
|
||||
else:
|
||||
self._tasks.extend([(func, *arg) for arg in args])
|
||||
self.total += len(args)
|
||||
self.__start()
|
||||
|
||||
def run_task(self) -> bool:
|
||||
"""Manually run the next task."""
|
||||
if len(self._tasks) > 0:
|
||||
self.__run_next_task()
|
||||
return self.__update_counters()
|
||||
return GLib.SOURCE_REMOVE
|
|
@ -0,0 +1,192 @@
|
|||
# Copyright 2022 (c) Anna Schumaker
|
||||
"""A custom Gio.ListModel for working with libraries."""
|
||||
import pathlib
|
||||
import sqlite3
|
||||
from gi.repository import GObject
|
||||
from .. import path
|
||||
from . import idle
|
||||
from . import playlist
|
||||
from . import tagger
|
||||
from . import tracks
|
||||
|
||||
|
||||
class Library(playlist.Playlist):
|
||||
"""Our custom Library with path and enabled properties."""
|
||||
|
||||
libraryid = GObject.Property(type=int)
|
||||
path = GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||
enabled = GObject.Property(type=bool, default=True)
|
||||
deleting = GObject.Property(type=bool, default=False)
|
||||
|
||||
queue = GObject.Property(type=idle.Queue)
|
||||
readdir = GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||
tagger = GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||
online = GObject.Property(type=bool, default=False)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize our Library object."""
|
||||
super().__init__(queue=idle.Queue(), **kwargs)
|
||||
self.scan()
|
||||
|
||||
def __check_trackid(self, trackid: int) -> bool:
|
||||
track = self.table.sql.tracks.rows.get(trackid)
|
||||
if track is not None and not track.path.exists():
|
||||
tagger.untag_track(self.table.sql, track)
|
||||
track.delete()
|
||||
return True
|
||||
|
||||
def __queue_delete(self) -> bool:
|
||||
self.table.delete(self)
|
||||
self.table.sql.tracks.load()
|
||||
return True
|
||||
|
||||
def __queue_tracks(self) -> bool:
|
||||
if (files := self.readdir.poll_result()) is None:
|
||||
self.__stop_thread("readdir")
|
||||
self.queue.push(self.__stop_thread, "tagger")
|
||||
return True
|
||||
|
||||
self.queue.push_many(self.__tag_track, [(f,) for f in files])
|
||||
return False
|
||||
|
||||
def __reload_playlist_tracks(self, playlist: playlist.Playlist) -> bool:
|
||||
playlist.reload_tracks(idle=False)
|
||||
return True
|
||||
|
||||
def __tag_track(self, path: pathlib.Path) -> bool:
|
||||
if self.tagger.ready.is_set():
|
||||
result = self.tagger.get_result(db=self.table.sql, library=self)
|
||||
if result is None:
|
||||
track = self.table.sql.tracks.lookup(self, path=path)
|
||||
mtime = track.mtime if track else None
|
||||
self.tagger.tag_file(path, mtime=mtime)
|
||||
else:
|
||||
return True
|
||||
return False
|
||||
|
||||
def __scan_library(self) -> bool:
|
||||
self.readdir = path.readdir_async(self.path)
|
||||
if self.readdir is not None:
|
||||
self.online = True
|
||||
self.load_tracks()
|
||||
self.queue.push_many(self.__check_trackid,
|
||||
[(tid,) for tid in self.tracks.trackids])
|
||||
self.queue.push(self.__queue_tracks)
|
||||
self.tagger = tagger.Thread()
|
||||
return True
|
||||
|
||||
def __stop_thread(self, thread_name: str) -> bool:
|
||||
if (thread := self.get_property(thread_name)) is not None:
|
||||
thread.stop()
|
||||
self.set_property(thread_name, None)
|
||||
return True
|
||||
|
||||
def do_update(self, column: str) -> bool:
|
||||
"""Update a Library playlist."""
|
||||
match column:
|
||||
case "readdir" | "tagger": pass
|
||||
case "online": self.table.notify_online(self)
|
||||
case _: return super().do_update(column)
|
||||
return True
|
||||
|
||||
def delete(self) -> bool:
|
||||
"""Delete this Library."""
|
||||
if self.deleting is False:
|
||||
self.stop()
|
||||
self.deleting = True
|
||||
|
||||
self.table.sql.tracks.clear()
|
||||
for tbl in self.table.sql.playlist_tables():
|
||||
if tbl is not self:
|
||||
self.queue.push_many(self.__reload_playlist_tracks,
|
||||
[(plist,) for plist in tbl.store])
|
||||
self.queue.push(self.__queue_delete)
|
||||
return True
|
||||
return False
|
||||
|
||||
def scan(self) -> None:
|
||||
"""Scan the Library."""
|
||||
if not self.queue.running:
|
||||
self.queue.push(self.__scan_library)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop this Library's background work."""
|
||||
self.__stop_thread("readdir")
|
||||
self.__stop_thread("tagger")
|
||||
self.queue.cancel()
|
||||
|
||||
@property
|
||||
def primary_key(self) -> int:
|
||||
"""Get this library's primary key."""
|
||||
return self.libraryid
|
||||
|
||||
|
||||
class Table(playlist.Table):
|
||||
"""Our Library ListModel."""
|
||||
|
||||
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
|
||||
"""Initialize the Libraries Table."""
|
||||
super().__init__(sql=sql, system_tracks=False, **kwargs)
|
||||
|
||||
def do_add_track(self, library: Library, track: tracks.Track) -> bool:
|
||||
"""Verify adding a Track to a Library playlist."""
|
||||
return track.get_library() == library
|
||||
|
||||
def do_construct(self, **kwargs) -> Library:
|
||||
"""Construct a new library."""
|
||||
return Library(**kwargs)
|
||||
|
||||
def do_remove_track(self, library: Library, track: tracks.Track) -> bool:
|
||||
"""Verify removing a Track from a Library playlist."""
|
||||
return True
|
||||
|
||||
def do_sql_delete(self, library: Library) -> sqlite3.Cursor:
|
||||
"""Delete a library."""
|
||||
return self.sql("DELETE FROM libraries WHERE libraryid=?",
|
||||
library.libraryid)
|
||||
|
||||
def do_sql_insert(self, path: pathlib.Path) -> sqlite3.Cursor:
|
||||
"""Create a new library."""
|
||||
if cur := self.sql("INSERT INTO libraries (path) VALUES (?)", path):
|
||||
return self.sql("SELECT * FROM libraries_view WHERE libraryid=?",
|
||||
cur.lastrowid)
|
||||
|
||||
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
|
||||
"""Search for libraries matching the search text."""
|
||||
return self.sql("""SELECT libraryid FROM libraries_view
|
||||
WHERE name GLOB ?""", glob)
|
||||
|
||||
def do_sql_select_all(self) -> sqlite3.Cursor:
|
||||
"""Load libraries from the database."""
|
||||
return self.sql("SELECT * FROM libraries_view")
|
||||
|
||||
def do_sql_select_one(self, path: pathlib.Path) -> sqlite3.Cursor:
|
||||
"""Look up a library by path."""
|
||||
return self.sql("SELECT libraryid FROM libraries WHERE path=?", path)
|
||||
|
||||
def do_sql_select_trackids(self, library: Library) -> sqlite3.Cursor:
|
||||
"""Load a Library's Tracks from the database."""
|
||||
return self.sql("""SELECT trackid FROM library_tracks_view
|
||||
WHERE libraryid=?""", library.libraryid)
|
||||
|
||||
def do_sql_update(self, library: Library, column: str, newval) -> bool:
|
||||
"""Update a Library playlist."""
|
||||
if column == "enabled" and self.sql.playlists.collection:
|
||||
self.sql.playlists.collection.reload_tracks(idle=True)
|
||||
return self.sql(f"UPDATE libraries SET {column}=? WHERE rowid=?",
|
||||
newval, library.libraryid)
|
||||
|
||||
def notify_online(self, library: Library) -> None:
|
||||
"""Notify that a library's online status has changed."""
|
||||
if not library.online or self.loaded:
|
||||
self.emit("library-online", library)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop any background work."""
|
||||
for library in self.store:
|
||||
library.stop()
|
||||
super().stop()
|
||||
|
||||
@GObject.Signal(arg_types=(Library,))
|
||||
def library_online(self, library: Library) -> None:
|
||||
"""Signal that a library online status has changed."""
|
|
@ -0,0 +1,135 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""A custom Gio.ListModel for managing individual media in an album."""
|
||||
import sqlite3
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
from .. import format
|
||||
from . import playlist
|
||||
from . import table
|
||||
from . import tracks
|
||||
|
||||
|
||||
class Medium(playlist.Playlist):
|
||||
"""Our custom Medium object representing a single disc in an album."""
|
||||
|
||||
mediumid = GObject.Property(type=int)
|
||||
albumid = GObject.Property(type=int)
|
||||
number = GObject.Property(type=int, default=1)
|
||||
type = GObject.Property(type=str)
|
||||
|
||||
def get_album(self) -> playlist.Playlist:
|
||||
"""Get this Medium's Album."""
|
||||
return self.table.sql.albums.rows.get(self.albumid)
|
||||
|
||||
def rename(self, new_name: str) -> bool:
|
||||
"""Rename this medium."""
|
||||
return self.table.rename(self, new_name)
|
||||
|
||||
@property
|
||||
def primary_key(self) -> int:
|
||||
"""Get this Medium's primary key."""
|
||||
return self.mediumid
|
||||
|
||||
@GObject.Property(type=playlist.Playlist)
|
||||
def parent(self) -> playlist.Playlist | None:
|
||||
"""Get this Medium's parent playlist."""
|
||||
return self.get_album()
|
||||
|
||||
|
||||
class Filter(table.KeySet):
|
||||
"""Custom filter to hide media with empty names."""
|
||||
|
||||
def do_get_strictness(self) -> Gtk.FilterMatch:
|
||||
"""Get the strictness of the filter."""
|
||||
if (res := super().do_get_strictness()) == Gtk.FilterMatch.ALL:
|
||||
res = Gtk.FilterMatch.SOME
|
||||
return res
|
||||
|
||||
def do_match(self, medium: Medium) -> bool:
|
||||
"""Check if the Medium matches the filter."""
|
||||
return len(medium.name) > 0 if super().do_match(medium) else False
|
||||
|
||||
|
||||
class Table(playlist.Table):
|
||||
"""Our Media Table."""
|
||||
|
||||
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
|
||||
"""Initialize the Media Table."""
|
||||
super().__init__(sql=sql, filter=Filter(), autodelete=True,
|
||||
system_tracks=False, **kwargs)
|
||||
|
||||
def do_construct(self, **kwargs) -> Medium:
|
||||
"""Construct a new medium."""
|
||||
return Medium(**kwargs)
|
||||
|
||||
def do_add_track(self, medium: Medium, track: tracks.Track) -> bool:
|
||||
"""Verify adding a Track to the Medium playlist."""
|
||||
return track.get_medium() == medium
|
||||
|
||||
def do_get_sort_key(self, medium: Medium) -> tuple[int, int, tuple, str]:
|
||||
"""Get the sort key for a medium."""
|
||||
return (medium.albumid, medium.number,
|
||||
format.sort_key(medium.name), medium.type)
|
||||
|
||||
def do_remove_track(self, medium: Medium, track: tracks.Track) -> bool:
|
||||
"""Verify removing a Track from the Medium playlist."""
|
||||
return True
|
||||
|
||||
def do_sql_delete(self, medium: Medium) -> sqlite3.Cursor:
|
||||
"""Delete a medium."""
|
||||
medium.get_album().remove_medium(medium)
|
||||
return self.sql("DELETE FROM media WHERE mediumid=?",
|
||||
medium.mediumid)
|
||||
|
||||
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
|
||||
"""Search for media names matching the search text."""
|
||||
return self.sql("""SELECT mediumid FROM media
|
||||
WHERE CASEFOLD(name) GLOB ?""", glob)
|
||||
|
||||
def do_sql_insert(self, album: playlist.Playlist, name: str,
|
||||
*, number: int, type: str = "") -> sqlite3.Cursor | None:
|
||||
"""Create a new medium."""
|
||||
if cur := self.sql("""INSERT INTO media (albumid, number, name, type)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
album.albumid, number, name, type):
|
||||
return self.sql("SELECT * FROM media_view WHERE mediumid=?",
|
||||
cur.lastrowid)
|
||||
|
||||
def do_sql_select_all(self) -> sqlite3.Cursor:
|
||||
"""Load media from the database."""
|
||||
return self.sql("SELECT * FROM media_view")
|
||||
|
||||
def do_sql_select_one(self, album: playlist.Playlist,
|
||||
*, number: int, type: str = "") -> sqlite3.Cursor:
|
||||
"""Look up a medium by album, number, and type."""
|
||||
return self.sql("""SELECT mediumid FROM media
|
||||
WHERE albumid=? AND number=? AND type=?""",
|
||||
album.albumid, number, type)
|
||||
|
||||
def do_sql_select_trackids(self, medium: Medium) -> sqlite3.Cursor:
|
||||
"""Load a Medium's Tracks from the database."""
|
||||
return self.sql("""SELECT trackid FROM medium_tracks_view
|
||||
WHERE mediumid=?""", medium.mediumid)
|
||||
|
||||
def do_sql_update(self, medium: Medium,
|
||||
column: str, newval) -> sqlite3.Cursor:
|
||||
"""Update a medium."""
|
||||
return self.sql(f"UPDATE media SET {column}=? WHERE mediumid=?",
|
||||
newval, medium.mediumid)
|
||||
|
||||
def create(self, album: playlist.Playlist,
|
||||
*args, **kwargs) -> Medium | None:
|
||||
"""Create a new Medium playlist."""
|
||||
if (medium := super().create(album, *args, **kwargs)) is not None:
|
||||
album.add_medium(medium)
|
||||
return medium
|
||||
|
||||
def rename(self, medium: Medium, new_name: str) -> bool:
|
||||
"""Rename a medium."""
|
||||
if (new_name := new_name.strip()) != medium.name:
|
||||
if self.update(medium, "name", new_name):
|
||||
self.store.remove(medium)
|
||||
medium.name = new_name
|
||||
self.store.append(medium)
|
||||
return True
|
||||
return False
|
|
@ -0,0 +1,309 @@
|
|||
# Copyright 2022 (c) Anna Schumaker
|
||||
"""A customized Gio.ListStore for tracking Playlist GObjects."""
|
||||
import sqlite3
|
||||
import typing
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gio
|
||||
from gi.repository import Gtk
|
||||
from .tracks import Track, TrackidSet
|
||||
from .. import format
|
||||
from . import table
|
||||
|
||||
|
||||
class Playlist(table.Row):
|
||||
"""Our shared Playlist Row object."""
|
||||
|
||||
propertyid = GObject.Property(type=int)
|
||||
|
||||
name = GObject.Property(type=str)
|
||||
active = GObject.Property(type=bool, default=False)
|
||||
|
||||
loop = GObject.Property(type=str, default="None")
|
||||
shuffle = GObject.Property(type=bool, default=False)
|
||||
sort_order = GObject.Property(type=str)
|
||||
|
||||
tracks = GObject.Property(type=TrackidSet)
|
||||
n_tracks = GObject.Property(type=int)
|
||||
user_tracks = GObject.Property(type=bool, default=False)
|
||||
tracks_loaded = GObject.Property(type=bool, default=False)
|
||||
tracks_movable = GObject.Property(type=bool, default=False)
|
||||
current_trackid = GObject.Property(type=int)
|
||||
|
||||
child_set = GObject.Property(type=table.TableSubset)
|
||||
children = GObject.Property(type=Gtk.FilterListModel)
|
||||
|
||||
def __init__(self, table: Gio.ListModel, propertyid: int,
|
||||
name: str, current_trackid: int | None = 0, **kwargs):
|
||||
"""Initialize a Playlist object."""
|
||||
current_trackid = 0 if current_trackid is None else current_trackid
|
||||
super().__init__(table=table, propertyid=propertyid, name=name,
|
||||
current_trackid=current_trackid,
|
||||
tracks=TrackidSet(), **kwargs)
|
||||
self.tracks.bind_property("n-trackids", self, "n-tracks")
|
||||
|
||||
def __add_track(self, track: Track) -> bool:
|
||||
self.tracks.add_track(track)
|
||||
return True
|
||||
|
||||
def __remove_track(self, track: Track) -> bool:
|
||||
self.tracks.remove_track(track)
|
||||
self.table.remove_track(self, track)
|
||||
return True
|
||||
|
||||
def add_children(self, child_table: table.Table, child_keys: set) -> None:
|
||||
"""Create a FilterListModel for this playlist's children."""
|
||||
self.child_set = table.TableSubset(child_table, keys=child_keys)
|
||||
self.children = Gtk.FilterListModel.new(self.child_set,
|
||||
child_table.get_filter())
|
||||
|
||||
def do_update(self, column: str) -> bool:
|
||||
"""Update a Playlist object."""
|
||||
match column:
|
||||
case "propertyid" | "name" | "n-tracks" | "child-set" | \
|
||||
"children" | "user-tracks" | "tracks-loaded" | \
|
||||
"tracks-movable": pass
|
||||
case _: return super().do_update(column)
|
||||
return True
|
||||
|
||||
def add_child(self, child: typing.Self) -> None:
|
||||
"""Add a child Playlist to this Playlist."""
|
||||
self.child_set.add_row(child)
|
||||
if self.child_set.keyset.n_keys == 1:
|
||||
self.table.refilter(Gtk.FilterChange.LESS_STRICT)
|
||||
|
||||
def add_track(self, track: Track, *, idle: bool = False) -> None:
|
||||
"""Add a Track to this Playlist."""
|
||||
if self.table.add_track(self, track):
|
||||
self.table.queue.push(self.__add_track, track, now=not idle)
|
||||
|
||||
def get_track_order(self) -> dict[int, int]:
|
||||
"""Get a dictionary mapping for trackid -> sorted position."""
|
||||
return self.table.get_track_order(self)
|
||||
|
||||
def has_child(self, child: typing.Self) -> bool:
|
||||
"""Check if this Playlist has a specific child Playlist."""
|
||||
return child in self.child_set
|
||||
|
||||
def has_track(self, track: Track) -> bool:
|
||||
"""Check if a Track is on this Playlist."""
|
||||
return track in self.tracks
|
||||
|
||||
def load_tracks(self) -> bool:
|
||||
"""Load this Playlist's Tracks (if they haven't been loaded yet)."""
|
||||
if not self.tracks_loaded:
|
||||
self.tracks.trackids = self.table.get_trackids(self)
|
||||
self.tracks_loaded = True
|
||||
return True
|
||||
|
||||
def move_track_down(self, track: Track) -> bool:
|
||||
"""Move a track down in the sort order."""
|
||||
return self.table.move_track_down(self, track)
|
||||
|
||||
def move_track_up(self, track: Track) -> bool:
|
||||
"""Move a track up in the sort order."""
|
||||
return self.table.move_track_up(self, track)
|
||||
|
||||
def reload_tracks(self, *, idle: bool = False) -> None:
|
||||
"""Load this Playlist's Tracks."""
|
||||
self.tracks_loaded = False
|
||||
self.table.queue.push(self.load_tracks, now=not idle)
|
||||
|
||||
def remove_child(self, child: typing.Self) -> None:
|
||||
"""Remove a child Playlist from this Playlist."""
|
||||
self.child_set.remove_row(child)
|
||||
if self.child_set.keyset.n_keys == 0:
|
||||
self.table.refilter(Gtk.FilterChange.MORE_STRICT)
|
||||
|
||||
def remove_track(self, track: table.Row, *, idle: bool = False) -> None:
|
||||
"""Remove a Track from this Playlist."""
|
||||
self.table.queue.push(self.__remove_track, track, now=not idle)
|
||||
|
||||
def rename(self, new_name: str) -> bool:
|
||||
"""Rename this playlist."""
|
||||
return self.table.rename(self, new_name)
|
||||
|
||||
@GObject.Property(type=table.Row)
|
||||
def parent(self) -> table.Row | None:
|
||||
"""Get this playlist's parent playlist."""
|
||||
return None
|
||||
|
||||
|
||||
class Table(table.Table):
|
||||
"""A table.Table with extra functionality for Playlists."""
|
||||
|
||||
active_playlist = GObject.Property(type=Playlist)
|
||||
treemodel = GObject.Property(type=Gtk.TreeListModel)
|
||||
|
||||
autodelete = GObject.Property(type=bool, default=False)
|
||||
system_tracks = GObject.Property(type=bool, default=True)
|
||||
|
||||
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
|
||||
"""Initialize a Playlist Table."""
|
||||
super().__init__(sql=sql, **kwargs)
|
||||
self.treemodel = Gtk.TreeListModel.new(root=self,
|
||||
passthrough=False,
|
||||
autoexpand=False,
|
||||
create_func=self.__create_tree)
|
||||
|
||||
def __do_autodelete(self, plist: Playlist) -> bool:
|
||||
if plist.n_tracks == 0:
|
||||
self.delete(plist)
|
||||
return True
|
||||
|
||||
def __autodelete(self, plist: Playlist):
|
||||
if self.autodelete:
|
||||
self.queue.push(self.__do_autodelete, plist)
|
||||
|
||||
def __create_tree(self, plist: Playlist) -> Gtk.FilterListModel | None:
|
||||
return plist.children
|
||||
|
||||
def __refilter(self, change_how: Gtk.FilterChange) -> bool:
|
||||
self.get_filter().changed(change_how)
|
||||
return True
|
||||
|
||||
def do_add_track(self, playlist: Playlist, track: Track) -> bool:
|
||||
"""Add a Track to the Playlist."""
|
||||
raise NotImplementedError
|
||||
|
||||
def do_get_sort_key(self, playlist: Playlist) -> tuple[str]:
|
||||
"""Get a sort key for the requested Playlist."""
|
||||
return format.sort_key(playlist.name)
|
||||
|
||||
def do_get_user_track_order(self, playlist: Playlist) -> dict[int, int]:
|
||||
"""Get a mapping of sort keys for the tracks in this Playlist."""
|
||||
raise NotImplementedError
|
||||
|
||||
def do_move_track_down(self, playlist: Playlist, track: Track) -> bool:
|
||||
"""Move a track down in the sort order."""
|
||||
raise NotImplementedError
|
||||
|
||||
def do_move_track_up(self, playlist: Playlist, track: Track) -> bool:
|
||||
"""Move a track up in the sort order."""
|
||||
raise NotImplementedError
|
||||
|
||||
def do_remove_track(self, playlist: Playlist, track: Track) -> bool:
|
||||
"""Remove a Track from the Playlist."""
|
||||
raise NotImplementedError
|
||||
|
||||
def do_sql_select_trackids(self, playlist: Playlist) -> sqlite3.Cursor:
|
||||
"""Select the trackids that are in this Playlist."""
|
||||
raise NotImplementedError
|
||||
|
||||
def add_system_track(self, playlist: Playlist, track: Track) -> bool:
|
||||
"""Add a Track to a system Playlist."""
|
||||
cur = self.sql("""INSERT INTO system_tracks (propertyid, trackid)
|
||||
VALUES (?, ?)""", playlist.propertyid, track.trackid)
|
||||
return cur and cur.rowcount == 1
|
||||
|
||||
def add_track(self, playlist: Playlist, track: Track) -> bool:
|
||||
"""Add a Track to a Playlist."""
|
||||
if track is None or track.get_library().deleting:
|
||||
return False
|
||||
if self.system_tracks:
|
||||
return self.add_system_track(playlist, track)
|
||||
return self.do_add_track(playlist, track)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the Table."""
|
||||
self.active_playlist = None
|
||||
super().clear()
|
||||
|
||||
def construct(self, propertyid: int, name: str, **kwargs) -> Playlist:
|
||||
"""Construct a new Playlist object."""
|
||||
res = super().construct(propertyid=propertyid, name=name, **kwargs)
|
||||
if res.active:
|
||||
self.sql.set_active_playlist(res)
|
||||
res.reload_tracks(idle=True)
|
||||
return res
|
||||
|
||||
def delete(self, playlist: Playlist) -> bool:
|
||||
"""Delete a playlist from the database."""
|
||||
if playlist.active:
|
||||
self.sql.set_active_playlist(None)
|
||||
return super().delete(playlist)
|
||||
|
||||
def get_sql_system_trackids(self, playlist: Playlist) -> sqlite3.Cursor:
|
||||
"""Load a System Playlist's Tracks from the database."""
|
||||
return self.sql("""SELECT trackid FROM system_tracks_view
|
||||
WHERE propertyid=?""", playlist.propertyid)
|
||||
|
||||
def get_trackids(self, playlist: Playlist) -> set[int]:
|
||||
"""Load a Playlist's Tracks from the database."""
|
||||
if self.system_tracks:
|
||||
cur = self.get_sql_system_trackids(playlist)
|
||||
else:
|
||||
cur = self.do_sql_select_trackids(playlist)
|
||||
|
||||
res = {row["trackid"] for row in cur.fetchall()}
|
||||
self.__autodelete(playlist)
|
||||
return res
|
||||
|
||||
def get_track_order(self, playlist: Playlist) -> dict[int, int]:
|
||||
"""Get the track sort order for a playlist."""
|
||||
if playlist.tracks_movable and playlist.sort_order == "user":
|
||||
return self.do_get_user_track_order(playlist)
|
||||
return self.sql.tracks.map_sort_order(playlist.sort_order)
|
||||
|
||||
def move_track_down(self, playlist: Playlist, track: Track) -> bool:
|
||||
"""Move a track down in the playlist."""
|
||||
if not playlist.tracks_movable:
|
||||
return False
|
||||
if res := self.do_move_track_down(playlist, track):
|
||||
if playlist.sort_order != "user":
|
||||
playlist.sort_order = "user"
|
||||
return res
|
||||
|
||||
def move_track_up(self, playlist: Playlist, track: Track) -> bool:
|
||||
"""Move a track up in the playlist."""
|
||||
if not playlist.tracks_movable:
|
||||
return False
|
||||
if res := self.do_move_track_up(playlist, track):
|
||||
if playlist.sort_order != "user":
|
||||
playlist.sort_order = "user"
|
||||
return res
|
||||
|
||||
def refilter(self, change_how: Gtk.FilterChange) -> None:
|
||||
"""Schedule refiltering the Table."""
|
||||
self.queue.cancel_task(self.__refilter)
|
||||
self.queue.push(self.__refilter, change_how, first=True)
|
||||
|
||||
def remove_system_track(self, playlist: Playlist, track: Track) -> bool:
|
||||
"""Remove a Track from a system Playlist."""
|
||||
return self.sql("""DELETE FROM system_tracks
|
||||
WHERE propertyid=? AND trackid=?""",
|
||||
playlist.propertyid, track.trackid).rowcount == 1
|
||||
|
||||
def remove_track(self, playlist: Playlist, track: Track) -> bool:
|
||||
"""Remove a Track from a Playlist."""
|
||||
if self.system_tracks:
|
||||
res = self.remove_system_track(playlist, track)
|
||||
else:
|
||||
res = self.do_remove_track(playlist, track)
|
||||
|
||||
self.__autodelete(playlist)
|
||||
return res
|
||||
|
||||
def update(self, playlist: Playlist, column: str, newval) -> bool:
|
||||
"""Update a Playlist in the Database."""
|
||||
match column:
|
||||
case "active" | "loop" | "shuffle" | \
|
||||
"sort-order" | "current-trackid":
|
||||
return self.update_playlist_property(playlist, column, newval)
|
||||
case _:
|
||||
return super().update(playlist, column, newval)
|
||||
|
||||
def update_playlist_property(self, playlist: Playlist,
|
||||
column: str, newval) -> bool:
|
||||
"""Update the playlists_common table."""
|
||||
match column:
|
||||
case "active":
|
||||
self.active_playlist = playlist if playlist.active else None
|
||||
case "current-trackid":
|
||||
column = "current_trackid"
|
||||
newval = None if newval == 0 else newval
|
||||
case "sort-order":
|
||||
column = "sort_order"
|
||||
|
||||
return self.sql(f"""UPDATE playlist_properties
|
||||
SET {column}=? WHERE propertyid=?""",
|
||||
newval, playlist.propertyid) is not None
|
|
@ -0,0 +1,237 @@
|
|||
# Copyright 2022 (c) Anna Schumaker
|
||||
"""A custom Gio.ListModel for working with playlists."""
|
||||
import datetime
|
||||
import sqlite3
|
||||
from gi.repository import GObject
|
||||
from .. import alarm
|
||||
from . import playlist
|
||||
from . import tracks
|
||||
|
||||
|
||||
class Playlist(playlist.Playlist):
|
||||
"""Our custom Playlist with an image filepath."""
|
||||
|
||||
playlistid = GObject.Property(type=int)
|
||||
image = GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||
|
||||
def do_update(self, column: str) -> None:
|
||||
"""Update a playlist object."""
|
||||
match (self.name, column, self.get_property(column)):
|
||||
case ("Collection", "loop", "None"):
|
||||
self.loop = "Playlist"
|
||||
case ("Collection", "n-tracks", 0):
|
||||
self.table.have_collection_tracks = False
|
||||
case ("Collection", "n-tracks", _):
|
||||
self.table.have_collection_tracks = True
|
||||
case ("Previous Tracks", "loop", "Playlist") | \
|
||||
("Previous Tracks", "loop", "Track"):
|
||||
self.loop = "None"
|
||||
case ("Previous Tracks", "shuffle", True):
|
||||
self.shuffle = False
|
||||
case ("Previous Tracks", "sort-order", _):
|
||||
if self.sort_order != "laststarted DESC":
|
||||
self.sort_order = "laststarted DESC"
|
||||
case (_, _, _): super().do_update(column)
|
||||
|
||||
def rename(self, new_name: str) -> bool:
|
||||
"""Rename this playlist."""
|
||||
return self.table.rename(self, new_name)
|
||||
|
||||
@property
|
||||
def primary_key(self) -> int:
|
||||
"""Get the playlist primary key."""
|
||||
return self.playlistid
|
||||
|
||||
|
||||
class Table(playlist.Table):
|
||||
"""Our Playlist Table."""
|
||||
|
||||
collection = GObject.Property(type=Playlist)
|
||||
favorites = GObject.Property(type=Playlist)
|
||||
most_played = GObject.Property(type=Playlist)
|
||||
new_tracks = GObject.Property(type=Playlist)
|
||||
previous = GObject.Property(type=Playlist)
|
||||
queued = GObject.Property(type=Playlist)
|
||||
unplayed = GObject.Property(type=Playlist)
|
||||
|
||||
have_collection_tracks = GObject.Property(type=bool, default=False)
|
||||
|
||||
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
|
||||
"""Initialize the Playlists Table."""
|
||||
super().__init__(sql=sql, system_tracks=False, **kwargs)
|
||||
alarm.set_alarm(datetime.time(hour=0, minute=0, second=5),
|
||||
self.__at_midnight)
|
||||
|
||||
def __at_midnight(self) -> None:
|
||||
self.new_tracks.reload_tracks()
|
||||
|
||||
def __move_user_trackid(self, playlist: Playlist, trackid: int,
|
||||
*, offset: int) -> bool:
|
||||
order = self.get_track_order(playlist)
|
||||
tracks = sorted(playlist.tracks.trackids, key=order.get)
|
||||
start = tracks.index(trackid)
|
||||
|
||||
new = start + offset
|
||||
if not (0 <= new < len(tracks)):
|
||||
return False
|
||||
|
||||
tracks[start] = tracks[new]
|
||||
tracks[new] = trackid
|
||||
|
||||
# Note: We write out all trackids so we don't have to update during
|
||||
# do_add_track() and do_remove_track()
|
||||
args = [(i, playlist.propertyid, t) for (i, t) in enumerate(tracks)]
|
||||
self.sql.executemany("""UPDATE user_tracks SET position=?
|
||||
WHERE propertyid=? AND trackid=?""", *args)
|
||||
return True
|
||||
|
||||
def do_construct(self, **kwargs) -> Playlist:
|
||||
"""Construct a new playlist."""
|
||||
match (plist := Playlist(**kwargs)).name:
|
||||
case "Collection": self.collection = plist
|
||||
case "Favorite Tracks":
|
||||
self.favorites = plist
|
||||
self.favorites.user_tracks = True
|
||||
case "Most Played Tracks": self.most_played = plist
|
||||
case "New Tracks": self.new_tracks = plist
|
||||
case "Previous Tracks":
|
||||
self.previous = plist
|
||||
self.sql("DELETE FROM system_tracks WHERE propertyid=?",
|
||||
self.previous.propertyid)
|
||||
case "Queued Tracks":
|
||||
self.queued = plist
|
||||
self.queued.user_tracks = True
|
||||
self.queued.tracks_movable = True
|
||||
case "Unplayed Tracks": self.unplayed = plist
|
||||
case _:
|
||||
plist.user_tracks = True
|
||||
plist.tracks_movable = True
|
||||
return plist
|
||||
|
||||
def do_add_track(self, playlist: Playlist, track: tracks.Track) -> bool:
|
||||
"""Add a Track to the requested Playlist."""
|
||||
match playlist:
|
||||
case self.collection: return track.get_library().enabled
|
||||
case self.most_played: view = "most_played_view"
|
||||
case self.new_tracks: view = "new_tracks_view"
|
||||
case self.favorites:
|
||||
track.update_properties(favorite=True)
|
||||
return True
|
||||
case self.previous:
|
||||
self.add_system_track(playlist, track)
|
||||
return True
|
||||
case self.queued:
|
||||
self.sql.set_active_playlist(playlist)
|
||||
return self.add_user_track(playlist, track)
|
||||
case self.unplayed: return track.playcount == 0
|
||||
case _: return self.add_user_track(playlist, track)
|
||||
|
||||
return self.sql(f"SELECT ? IN {view}", track.trackid).fetchone()[0]
|
||||
|
||||
def do_get_user_track_order(self, playlist: Playlist) -> dict[int, int]:
|
||||
"""Get the user-configured sort order for a playlist."""
|
||||
cur = self.sql("""SELECT trackid FROM user_tracks WHERE propertyid=?
|
||||
ORDER BY position NULLS LAST, rowid""",
|
||||
playlist.propertyid)
|
||||
return {row["trackid"]: i for (i, row) in enumerate(cur.fetchall())}
|
||||
|
||||
def do_move_track_down(self, playlist: Playlist,
|
||||
track: tracks.Track) -> bool:
|
||||
"""Move a track down in the user sort order."""
|
||||
return self.__move_user_trackid(playlist, track.trackid, offset=1)
|
||||
|
||||
def do_move_track_up(self, playlist: Playlist,
|
||||
track: tracks.Track) -> bool:
|
||||
"""Move a track up in the user sort order."""
|
||||
return self.__move_user_trackid(playlist, track.trackid, offset=-1)
|
||||
|
||||
def do_remove_track(self, playlist: Playlist, track: tracks.Track) -> bool:
|
||||
"""Remove a Track from the requested Playlist."""
|
||||
match playlist:
|
||||
case self.collection: return True
|
||||
case self.most_played: return True
|
||||
case self.new_tracks: return True
|
||||
case self.unplayed: return True
|
||||
case self.favorites:
|
||||
track.update_properties(favorite=False)
|
||||
return True
|
||||
case self.previous:
|
||||
return self.remove_system_track(playlist, track)
|
||||
case _: return self.remove_user_track(playlist, track)
|
||||
|
||||
def do_sql_delete(self, playlist: Playlist) -> sqlite3.Cursor:
|
||||
"""Delete a playlist."""
|
||||
return self.sql("DELETE FROM playlists WHERE playlistid=?",
|
||||
playlist.playlistid)
|
||||
|
||||
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
|
||||
"""Search for playlists matching the search text."""
|
||||
return self.sql("""SELECT playlistid FROM playlists
|
||||
WHERE CASEFOLD(name) GLOB ?""", glob)
|
||||
|
||||
def do_sql_insert(self, name: str, **kwargs) -> sqlite3.Cursor | None:
|
||||
"""Insert a new playlist into the database."""
|
||||
if (cur := self.sql("INSERT INTO playlists (name) VALUES (?)", name)):
|
||||
return self.sql("SELECT * FROM playlists_view WHERE playlistid=?",
|
||||
cur.lastrowid)
|
||||
|
||||
def do_sql_select_all(self) -> sqlite3.Cursor:
|
||||
"""Load playlists from the database."""
|
||||
return self.sql("SELECT * FROM playlists_view")
|
||||
|
||||
def do_sql_select_trackids(self, playlist: Playlist) -> sqlite3.Cursor:
|
||||
"""Load Tracks from the database."""
|
||||
match playlist:
|
||||
case self.collection: view = "collection_view"
|
||||
case self.favorites: view = "favorite_view"
|
||||
case self.most_played: view = "most_played_view"
|
||||
case self.new_tracks: view = "new_tracks_view"
|
||||
case self.unplayed: view = "unplayed_tracks_view"
|
||||
case self.previous: return self.get_sql_system_trackids(playlist)
|
||||
case _: return self.get_sql_user_trackids(playlist)
|
||||
|
||||
return self.sql(f"SELECT trackid FROM {view}")
|
||||
|
||||
def do_sql_select_one(self, name: str) -> sqlite3.Cursor:
|
||||
"""Look up a playlist by name."""
|
||||
return self.sql("SELECT playlistid FROM playlists WHERE name=?", name)
|
||||
|
||||
def do_sql_update(self, playlist: Playlist,
|
||||
column: str, newval) -> sqlite3.Cursor:
|
||||
"""Update a playlist."""
|
||||
return self.sql(f"UPDATE playlists SET {column}=? WHERE playlistid=?",
|
||||
newval, playlist.playlistid)
|
||||
|
||||
def add_user_track(self, playlist: Playlist, track: tracks.Track) -> bool:
|
||||
"""Add a Track to the User Tracks table."""
|
||||
cur = self.sql("""INSERT INTO user_tracks (propertyid, trackid)
|
||||
VALUES (?, ?)""", playlist.propertyid, track.trackid)
|
||||
return cur and cur.rowcount == 1
|
||||
|
||||
def get_sql_user_trackids(self, playlist: Playlist) -> sqlite3.Cursor:
|
||||
"""Load user Tracks from the database."""
|
||||
return self.sql("""SELECT trackid FROM user_tracks_view
|
||||
WHERE propertyid=?""", playlist.propertyid)
|
||||
|
||||
def create(self, name: str) -> Playlist:
|
||||
"""Create a new Playlist."""
|
||||
if len(name := name.strip()) > 0:
|
||||
return super().create(name)
|
||||
|
||||
def remove_user_track(self, playlist: Playlist,
|
||||
track: tracks.Track) -> bool:
|
||||
"""Remove a track from the User Tracks table."""
|
||||
return self.sql("""DELETE FROM user_tracks
|
||||
WHERE propertyid=? AND trackid=?""",
|
||||
playlist.propertyid, track.trackid).rowcount == 1
|
||||
|
||||
def rename(self, playlist: Playlist, new_name: str) -> bool:
|
||||
"""Rename a Playlist."""
|
||||
if len(new_name := new_name.strip()) > 0:
|
||||
if playlist.name != new_name:
|
||||
if self.update(playlist, "name", new_name):
|
||||
self.store.remove(playlist)
|
||||
playlist.name = new_name
|
||||
self.store.append(playlist)
|
||||
return True
|
||||
return False
|
|
@ -0,0 +1,112 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""Easy access to the settings table in our database."""
|
||||
import sqlite3
|
||||
from gi.repository import GObject
|
||||
from . import idle
|
||||
from . import table
|
||||
|
||||
|
||||
class Setting(table.Row):
|
||||
"""Base class for settings."""
|
||||
|
||||
key = GObject.Property(type=str)
|
||||
|
||||
@property
|
||||
def primary_key(self) -> str:
|
||||
"""Get the primary key for this setting."""
|
||||
return self.key
|
||||
|
||||
|
||||
class IntSetting(Setting):
|
||||
"""An integer setting."""
|
||||
|
||||
value = GObject.Property(type=int)
|
||||
|
||||
|
||||
class FloatSetting(Setting):
|
||||
"""A float setting."""
|
||||
|
||||
value = GObject.Property(type=float)
|
||||
|
||||
|
||||
class BoolSetting(Setting):
|
||||
"""A boolean setting."""
|
||||
|
||||
value = GObject.Property(type=bool, default=False)
|
||||
|
||||
|
||||
class StringSetting(Setting):
|
||||
"""A string setting."""
|
||||
|
||||
value = GObject.Property(type=str)
|
||||
|
||||
|
||||
class Table(table.Table):
|
||||
"""Creates and manages our settings properties."""
|
||||
|
||||
def __init__(self, sql: GObject.TYPE_PYOBJECT):
|
||||
"""Initialize the settings table."""
|
||||
super().__init__(sql, queue=idle.Queue(enabled=False))
|
||||
|
||||
def __getitem__(self, key: str) -> int | float | str | bool | None:
|
||||
"""Get the value for a specific settings key."""
|
||||
if (setting := self.lookup(key)) is not None:
|
||||
return setting.value
|
||||
|
||||
def do_construct(self, type: str, value: any, **kwargs) -> table.Row:
|
||||
"""Construct a new settings row."""
|
||||
match type:
|
||||
case "gint":
|
||||
return IntSetting(value=int(value), **kwargs)
|
||||
case "gdouble":
|
||||
return FloatSetting(value=float(value), **kwargs)
|
||||
case "gboolean":
|
||||
value = str(value) == "True"
|
||||
return BoolSetting(value=value, **kwargs)
|
||||
case "gchararray":
|
||||
return StringSetting(value=value, **kwargs)
|
||||
|
||||
def do_get_sort_key(self, setting: table.Row) -> list[str]:
|
||||
"""Get the sort key for a specific setting."""
|
||||
return setting.key.casefold().split(".")
|
||||
|
||||
def do_sql_delete(self, setting: table.Row) -> sqlite3.Cursor:
|
||||
"""Delete a setting."""
|
||||
return self.sql("DELETE FROM settings WHERE key=?", setting.key)
|
||||
|
||||
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
|
||||
"""Filter the settings table."""
|
||||
return self.sql("""SELECT key FROM settings
|
||||
WHERE CASEFOLD(key) GLOB ?""", glob)
|
||||
|
||||
def do_sql_insert(self, key: str, type: str, value) -> sqlite3.Cursor:
|
||||
"""Create a new settings row."""
|
||||
return self.sql("""INSERT INTO settings (key, type, value)
|
||||
VALUES (?, ?, ?) RETURNING *""",
|
||||
key, type, str(value))
|
||||
|
||||
def do_sql_select_all(self) -> sqlite3.Cursor:
|
||||
"""Load settings from the database."""
|
||||
return self.sql("SELECT * FROM settings ORDER BY CASEFOLD(key)")
|
||||
|
||||
def do_sql_select_one(self, key: str) -> int | None:
|
||||
"""Look up a setting by key."""
|
||||
return self.sql("SELECT key FROM settings WHERE key=?", key)
|
||||
|
||||
def do_sql_update(self, setting: table.Row, column: str,
|
||||
newval: any) -> sqlite3.Cursor:
|
||||
"""Update a Setting."""
|
||||
return self.sql(f"UPDATE settings SET {column}=? WHERE key=?",
|
||||
str(newval), setting.key)
|
||||
|
||||
def bind_setting(self, key: str, target: GObject.GObject,
|
||||
property: str) -> None:
|
||||
"""Bind a setting to a target property."""
|
||||
if (setting := self.lookup(key=key)) is None:
|
||||
param = target.find_property(property)
|
||||
setting = self.create(key=key, type=param.value_type.name,
|
||||
value=target.get_property(property))
|
||||
else:
|
||||
target.set_property(property, setting.value)
|
||||
setting.bind_property("value", target, property,
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
|
@ -0,0 +1,347 @@
|
|||
# Copyright 2022 (c) Anna Schumaker
|
||||
"""Base classes for database objects."""
|
||||
import bisect
|
||||
import sqlite3
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gio
|
||||
from gi.repository import Gtk
|
||||
from .idle import Queue
|
||||
from .. import store
|
||||
|
||||
|
||||
class Row(GObject.GObject):
|
||||
"""A single row in a database table."""
|
||||
|
||||
table = GObject.Property(type=Gio.ListModel)
|
||||
|
||||
def __init__(self, table: Gio.ListModel, **kwargs):
|
||||
"""Initialize a database Row."""
|
||||
super().__init__(table=table, **kwargs)
|
||||
self.connect("notify", self.__notify)
|
||||
|
||||
def __notify(self, row: GObject.GObject, param: GObject.ParamSpec) -> None:
|
||||
match param.name:
|
||||
case "table": pass
|
||||
case _: self.do_update(param.name)
|
||||
|
||||
def do_update(self, column: str) -> bool:
|
||||
"""Update a Row in the database."""
|
||||
return self.table.update(self, column, self.get_property(column))
|
||||
|
||||
def delete(self) -> bool:
|
||||
"""Delete this Row."""
|
||||
return self.table.delete(self)
|
||||
|
||||
@property
|
||||
def primary_key(self) -> None:
|
||||
"""Get the primary key for this row."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class KeySet(Gtk.Filter):
|
||||
"""A Gtk.Filter that also acts as a Python Set."""
|
||||
|
||||
n_keys = GObject.Property(type=int)
|
||||
|
||||
def __init__(self, keys: set | None = None, **kwargs):
|
||||
"""Set up our KeySet."""
|
||||
super().__init__(**kwargs)
|
||||
self._keys = keys
|
||||
self.n_keys = len(keys) if keys is not None else -1
|
||||
|
||||
def __contains__(self, row: Row) -> bool:
|
||||
"""Check if a Row is in the KeySet."""
|
||||
return self._keys is None or row.primary_key in self._keys
|
||||
|
||||
def __sub__(self, rhs: Gtk.Filter) -> set[int]:
|
||||
"""Subtract two KeySets and return the result."""
|
||||
match (self._keys, rhs._keys):
|
||||
case (None, _): return None
|
||||
case (_, None): return self._keys
|
||||
case (_, _): return self._keys - rhs._keys
|
||||
|
||||
def __find_difference(self, new: set[any] | None) \
|
||||
-> tuple[set, set, Gtk.FilterChange | None]:
|
||||
if self._keys is None:
|
||||
if new is None:
|
||||
return (set(), set(), None)
|
||||
return (set(), new, Gtk.FilterChange.MORE_STRICT)
|
||||
elif new is None:
|
||||
return (self._keys, set(), Gtk.FilterChange.LESS_STRICT)
|
||||
|
||||
removed = self._keys - new
|
||||
added = new - self._keys
|
||||
match len(removed), len(added):
|
||||
case 0, 0: return (removed, added, None)
|
||||
case _, 0: return (removed, added, Gtk.FilterChange.MORE_STRICT)
|
||||
case 0, _: return (removed, added, Gtk.FilterChange.LESS_STRICT)
|
||||
case _, _: return (removed, added, Gtk.FilterChange.DIFFERENT)
|
||||
|
||||
def changed(self, how: Gtk.FilterChange) -> None:
|
||||
"""Notify that the KeySet has changed."""
|
||||
self.n_keys = len(self._keys) if self._keys is not None else -1
|
||||
super().changed(how)
|
||||
|
||||
def do_get_strictness(self) -> Gtk.FilterMatch:
|
||||
"""Get the strictness of the Gtk.Filter."""
|
||||
if self._keys is None:
|
||||
return Gtk.FilterMatch.ALL
|
||||
if len(self._keys) == 0:
|
||||
return Gtk.FilterMatch.NONE
|
||||
return Gtk.FilterMatch.SOME
|
||||
|
||||
def do_match(self, row: Row) -> bool:
|
||||
"""Check if the Row is in the KeySet."""
|
||||
return self._keys is None or row.primary_key in self._keys
|
||||
|
||||
def add_row(self, row: Row) -> None:
|
||||
"""Add a Row to the KeySet."""
|
||||
if row not in self:
|
||||
self._keys.add(row.primary_key)
|
||||
self.emit("key-added", row.primary_key)
|
||||
self.changed(Gtk.FilterChange.LESS_STRICT)
|
||||
|
||||
def remove_row(self, row: Row) -> None:
|
||||
"""Remove a Row from the KeySet."""
|
||||
if self._keys is not None and row in self:
|
||||
self._keys.discard(row.primary_key)
|
||||
self.emit("key-removed", row.primary_key)
|
||||
self.changed(Gtk.FilterChange.MORE_STRICT)
|
||||
|
||||
@property
|
||||
def keys(self) -> set[any]:
|
||||
"""Return the set of matching primary keys."""
|
||||
return self._keys
|
||||
|
||||
@keys.setter
|
||||
def keys(self, keys: set[any] | None) -> None:
|
||||
"""Set the matching primary keys."""
|
||||
(removed, added, change) = self.__find_difference(keys)
|
||||
if change is not None:
|
||||
self._keys = keys
|
||||
self.emit("keys-changed", removed, added)
|
||||
self.changed(change)
|
||||
|
||||
@GObject.Signal(arg_types=(int,))
|
||||
def key_added(self, key: int) -> None:
|
||||
"""Signal that a Row has been added to the KeySet."""
|
||||
|
||||
@GObject.Signal(arg_types=(int,))
|
||||
def key_removed(self, key: int) -> None:
|
||||
"""Signal that a Row has been removed from the KeySet."""
|
||||
|
||||
@GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT, GObject.TYPE_PYOBJECT))
|
||||
def keys_changed(self, removed: set | None, added: set | None) -> None:
|
||||
"""Signal that the KeySet has been directly modified."""
|
||||
|
||||
|
||||
class Table(Gtk.FilterListModel):
|
||||
"""An object that represents a database Table."""
|
||||
|
||||
sql = GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||
queue = GObject.Property(type=Queue)
|
||||
store = GObject.Property(type=Gio.ListModel)
|
||||
rows = GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||
|
||||
loaded = GObject.Property(type=bool, default=False)
|
||||
|
||||
def __init__(self, sql: GObject.TYPE_PYOBJECT,
|
||||
filter: KeySet | None = None,
|
||||
queue: Queue | None = None, **kwargs):
|
||||
"""Set up our Table object."""
|
||||
super().__init__(sql=sql, rows=dict(),
|
||||
store=store.SortedList(self.get_sort_key),
|
||||
filter=(filter if filter else KeySet()),
|
||||
queue=(queue if queue else Queue()), **kwargs)
|
||||
self.set_model(self.store)
|
||||
|
||||
def __clear_rows(self) -> None:
|
||||
self.rows.clear()
|
||||
self.store.clear()
|
||||
self.loaded = False
|
||||
|
||||
def __contains__(self, row: Row) -> bool:
|
||||
"""Check if the row is in the _rowid_map for this Table."""
|
||||
return self.index(row) is not None
|
||||
|
||||
def do_construct(self, *args, **kwargs) -> Row:
|
||||
"""Construct a new Row instance."""
|
||||
raise NotImplementedError
|
||||
|
||||
def do_get_sort_key(self, row: Row) -> any:
|
||||
"""Get a sort key for the requested row."""
|
||||
return None
|
||||
|
||||
def do_sql_delete(self, row: Row) -> bool:
|
||||
"""Delete a Row."""
|
||||
raise NotImplementedError
|
||||
|
||||
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
|
||||
"""Select matching rowids using GLOB."""
|
||||
raise NotImplementedError
|
||||
|
||||
def do_sql_insert(self, *args, **kwargs) -> sqlite3.Cursor:
|
||||
"""Create a new Row."""
|
||||
raise NotImplementedError
|
||||
|
||||
def do_sql_select_all(self) -> sqlite3.Cursor:
|
||||
"""Return all rows from the table."""
|
||||
raise NotImplementedError
|
||||
|
||||
def do_sql_select_one(self, *args, **kwargs) -> sqlite3.Cursor:
|
||||
"""Look up a single row."""
|
||||
raise NotImplementedError
|
||||
|
||||
def do_sql_update(self, row: Row, column: str, newval) -> sqlite3.Cursor:
|
||||
"""Update a row."""
|
||||
raise NotImplementedError
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the table."""
|
||||
self.stop()
|
||||
self.__clear_rows()
|
||||
|
||||
def construct(self, *args, **kwargs) -> Row:
|
||||
"""Construct a new Row instance."""
|
||||
return self.do_construct(table=self, *args, **kwargs)
|
||||
|
||||
def create(self, *args, **kwargs) -> Row | None:
|
||||
"""Create a new Row in the Table."""
|
||||
if cur := self.do_sql_insert(*args, **kwargs):
|
||||
return self.insert(self.construct(**cur.fetchone()))
|
||||
|
||||
def delete(self, row: Row) -> bool:
|
||||
"""Delete a Row from the Table."""
|
||||
if row in self and self.do_sql_delete(row).rowcount == 1:
|
||||
self.sql.commit()
|
||||
self.store.remove(row)
|
||||
del self.rows[row.primary_key]
|
||||
return True
|
||||
return False
|
||||
|
||||
def _filter_idle(self, glob: str) -> bool:
|
||||
rows = self.do_sql_glob(glob).fetchall()
|
||||
self.get_filter().keys = {row[0] for row in rows}
|
||||
return True
|
||||
|
||||
def filter(self, glob: str | None, *, now: bool = False) -> None:
|
||||
"""Filter the displayed Rows."""
|
||||
if glob is not None:
|
||||
self.queue.cancel_task(self._filter_idle)
|
||||
self.queue.push(self._filter_idle, glob, now=now, first=True)
|
||||
else:
|
||||
self.get_filter().keys = None
|
||||
|
||||
def get_sort_key(self, row: Row) -> tuple:
|
||||
"""Get a sort key for the requested row."""
|
||||
res = self.do_get_sort_key(row)
|
||||
return res if res is not None else row.primary_key
|
||||
|
||||
def index(self, row: Row) -> int | None:
|
||||
"""Find the index of a specific Row."""
|
||||
if row.table is self:
|
||||
return self.store.index(row)
|
||||
|
||||
def insert(self, row: Row) -> Row | None:
|
||||
"""Insert a Row in sorted position."""
|
||||
if row and row not in self:
|
||||
self.store.append(row)
|
||||
return self.rows.setdefault(row.primary_key, row)
|
||||
|
||||
def _load_idle(self) -> bool:
|
||||
self.__clear_rows()
|
||||
cur = self.do_sql_select_all()
|
||||
rows = [self.construct(**row) for row in cur.fetchall()]
|
||||
self.store.extend(rows)
|
||||
self.rows = {row.primary_key: row for row in rows}
|
||||
self.sql.emit("table-loaded", self)
|
||||
return True
|
||||
|
||||
def load(self, *, now: bool = False) -> None:
|
||||
"""Load the Table from the database."""
|
||||
self.queue.push(self._load_idle, now=now)
|
||||
|
||||
def lookup(self, *args, **kwargs) -> Row | None:
|
||||
"""Look up a Row in the database."""
|
||||
row = self.do_sql_select_one(*args, **kwargs).fetchone()
|
||||
return self.rows.get(row[0]) if row else None
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop any background work."""
|
||||
self.queue.cancel()
|
||||
|
||||
def update(self, row: Row, column: str, newval) -> bool:
|
||||
"""Update a Row."""
|
||||
return self.do_sql_update(row, column, newval) is not None
|
||||
|
||||
|
||||
class TableSubset(GObject.GObject, Gio.ListModel):
|
||||
"""A list model containing a subset of the rows in the source Table."""
|
||||
|
||||
keyset = GObject.Property(type=KeySet)
|
||||
table = GObject.Property(type=Table)
|
||||
n_rows = GObject.Property(type=int)
|
||||
|
||||
def __init__(self, table: Table, *, keys: set[any] | None = None):
|
||||
"""Initialize a KeySetModel."""
|
||||
super().__init__(keyset=KeySet(set() if keys is None else keys),
|
||||
table=table)
|
||||
self._items = []
|
||||
|
||||
self.keyset.connect("key-added", self.__on_key_added)
|
||||
self.keyset.connect("key-removed", self.__on_key_removed)
|
||||
self.table.connect("notify::loaded", self.__notify_table_loaded)
|
||||
|
||||
def __contains__(self, row: Row) -> bool:
|
||||
"""Check if the Row is in the internal KeySet."""
|
||||
return row in self.keyset
|
||||
|
||||
def __bisect(self, key: any) -> int | None:
|
||||
if self.table.loaded:
|
||||
sort_key = self.table.get_sort_key(self.table.rows[key])
|
||||
return bisect.bisect_left(self._items, sort_key,
|
||||
key=self.table.get_sort_key)
|
||||
return None
|
||||
|
||||
def __items_changed(self, position: int, removed: int, added: int) -> None:
|
||||
self.n_rows = len(self._items)
|
||||
self.items_changed(position, removed, added)
|
||||
|
||||
def __notify_table_loaded(self, table: Table, param) -> None:
|
||||
if table.loaded and self.keyset.n_keys > 0:
|
||||
self._items = sorted([table.rows[k] for k in self.keyset.keys],
|
||||
key=self.table.get_sort_key)
|
||||
self.__items_changed(0, 0, self.keyset.n_keys)
|
||||
elif not table.loaded and self.n_rows > 0:
|
||||
self._items = []
|
||||
self.__items_changed(0, self.n_rows, 0)
|
||||
|
||||
def __on_key_added(self, keyset: KeySet, key: any) -> None:
|
||||
if (pos := self.__bisect(key)) is not None:
|
||||
self._items.insert(pos, self.table.rows[key])
|
||||
self.__items_changed(pos, 0, 1)
|
||||
|
||||
def __on_key_removed(self, keyset: KeySet, key: any) -> None:
|
||||
if (pos := self.__bisect(key)) is not None:
|
||||
del self._items[pos]
|
||||
self.__items_changed(pos, 1, 0)
|
||||
|
||||
def do_get_item_type(self) -> GObject.GType:
|
||||
"""Get the Gio.ListModel item type."""
|
||||
return Row.__gtype__
|
||||
|
||||
def do_get_n_items(self) -> int:
|
||||
"""Get the number of Rows in the TableSubset."""
|
||||
return self.n_rows
|
||||
|
||||
def do_get_item(self, n: int) -> int:
|
||||
"""Get the nth item in the TableSubset."""
|
||||
return self._items[n] if n < len(self._items) else None
|
||||
|
||||
def add_row(self, row: Row) -> None:
|
||||
"""Add a row to the TableSubset."""
|
||||
self.keyset.add_row(row)
|
||||
|
||||
def remove_row(self, row: Row) -> None:
|
||||
"""Remove a row from the TableSubset."""
|
||||
self.keyset.remove_row(row)
|
|
@ -0,0 +1,242 @@
|
|||
# Copyright 2022 (c) Anna Schumaker
|
||||
"""A wrapper around Mutagen to help us read tags."""
|
||||
import emmental.audio.tagger
|
||||
import musicbrainzngs
|
||||
import pathlib
|
||||
from gi.repository import GObject
|
||||
from .. import audio
|
||||
from .. import thread
|
||||
from . import albums
|
||||
from . import artists
|
||||
from . import connection
|
||||
from . import decades
|
||||
from . import media
|
||||
from . import genres
|
||||
from . import playlist
|
||||
from . import tracks
|
||||
from . import years
|
||||
|
||||
|
||||
class Tags:
|
||||
"""Translate the audio.tagger._Tags object into Playlists."""
|
||||
|
||||
def __init__(self, db: GObject.TYPE_PYOBJECT,
|
||||
raw_tags: audio.tagger._Tags,
|
||||
library: playlist.Playlist):
|
||||
"""Initialize the Tags object."""
|
||||
self.db = db
|
||||
|
||||
with self.db:
|
||||
self.album = self.get_album(raw_tags.album)
|
||||
self.album_artists = [self.get_artist(artist)
|
||||
for artist in raw_tags.album.artists]
|
||||
self.artists = [self.get_artist(artist)
|
||||
for artist in raw_tags.artists]
|
||||
self.decade = self.get_decade(raw_tags.year)
|
||||
self.genres = list(filter(None, [self.get_genre(genre)
|
||||
for genre in raw_tags.genres]))
|
||||
self.medium = self.get_medium(raw_tags.medium)
|
||||
self.year = self.get_year(raw_tags.year)
|
||||
|
||||
self.track = self.get_track(library, raw_tags.file, raw_tags.track)
|
||||
|
||||
self.__update_album_artists()
|
||||
|
||||
def __update_album_artists(self) -> None:
|
||||
if self.album is not None:
|
||||
old = set(self.album.get_artists())
|
||||
new = set(self.album_artists)
|
||||
|
||||
for artist in old - new:
|
||||
artist.remove_album(self.album)
|
||||
for artist in new - old:
|
||||
artist.add_album(self.album)
|
||||
|
||||
def __update_track(self, track: tracks.Track,
|
||||
raw_track: audio.tagger._Track) -> None:
|
||||
orig_year = track.get_year()
|
||||
orig_decade = orig_year.parent
|
||||
orig_genres = set(track.get_genres())
|
||||
orig_medium = track.get_medium()
|
||||
orig_album = orig_medium.get_album()
|
||||
orig_artists = set(track.get_artists())
|
||||
|
||||
track.update_properties(mediumid=self.medium.mediumid,
|
||||
year=self.year.year,
|
||||
title=raw_track.title,
|
||||
number=raw_track.number,
|
||||
length=raw_track.length,
|
||||
artist=raw_track.artist,
|
||||
mbid=raw_track.mbid,
|
||||
mtime=raw_track.mtime)
|
||||
|
||||
self.__update_track_playlist_set(track, orig_artists,
|
||||
set(self.artists))
|
||||
self.__update_track_playlist_set(track, orig_genres, set(self.genres))
|
||||
|
||||
self.__update_track_playlist(track, orig_album, self.album)
|
||||
self.__update_track_playlist(track, orig_medium, self.medium)
|
||||
self.__update_track_playlist(track, orig_decade, self.decade)
|
||||
self.__update_track_playlist(track, orig_year, self.year)
|
||||
|
||||
def __update_track_playlist(self, track: tracks.Track,
|
||||
orig: playlist.Playlist,
|
||||
new: playlist.Playlist):
|
||||
if orig != new:
|
||||
orig.remove_track(track, idle=True)
|
||||
new.add_track(track, idle=True)
|
||||
|
||||
def __update_track_playlist_set(self, track: tracks.Track,
|
||||
orig: set[playlist.Playlist],
|
||||
new: set[playlist.Playlist]):
|
||||
for plist in orig - new:
|
||||
plist.remove_track(track, idle=True)
|
||||
for plist in new - orig:
|
||||
plist.add_track(track, idle=True)
|
||||
|
||||
def get_album(self, raw_album: audio.tagger._Album) -> albums.Album | None:
|
||||
"""Convert the raw album into an Album object."""
|
||||
if raw_album.name == "":
|
||||
return None
|
||||
|
||||
cover = raw_album.cover if raw_album.cover.is_file() else None
|
||||
album = self.db.albums.lookup(raw_album.name, raw_album.artist,
|
||||
raw_album.release, mbid=raw_album.mbid)
|
||||
if album is not None:
|
||||
if album.cover != cover:
|
||||
album.cover = cover
|
||||
return album
|
||||
return self.db.albums.create(raw_album.name, raw_album.artist,
|
||||
raw_album.release, mbid=raw_album.mbid,
|
||||
cover=cover)
|
||||
|
||||
def get_artist(self, raw_artist: audio.tagger._Artist) \
|
||||
-> artists.Artist | None:
|
||||
"""Convert the raw artist into an Artist object."""
|
||||
artist = self.db.artists.lookup(raw_artist.name, mbid=raw_artist.mbid)
|
||||
if artist is not None:
|
||||
return artist
|
||||
return self.db.artists.create(raw_artist.name, mbid=raw_artist.mbid)
|
||||
|
||||
def get_decade(self, raw_year: int | None) -> decades.Decade | None:
|
||||
"""Convert the raw year into a Decade object."""
|
||||
if raw_year:
|
||||
decade = self.db.decades.lookup(raw_year)
|
||||
return decade if decade else self.db.decades.create(raw_year)
|
||||
|
||||
def get_genre(self, raw_genre: str) -> genres.Genre:
|
||||
"""Convert the raw genre names into Genre objects."""
|
||||
genre = self.db.genres.lookup(raw_genre)
|
||||
return genre if genre else self.db.genres.create(raw_genre)
|
||||
|
||||
def get_medium(self, raw_medium: audio.tagger._Medium) \
|
||||
-> media.Medium | None:
|
||||
"""Convert the raw medium into a Medium object."""
|
||||
if self.album is None:
|
||||
return None
|
||||
|
||||
medium = self.db.media.lookup(self.album, number=raw_medium.number,
|
||||
type=raw_medium.type)
|
||||
if medium is not None:
|
||||
medium.rename(raw_medium.name)
|
||||
return medium
|
||||
return self.db.media.create(self.album, raw_medium.name,
|
||||
number=raw_medium.number,
|
||||
type=raw_medium.type)
|
||||
|
||||
def get_track(self, library: playlist.Playlist, filepath: pathlib.Path,
|
||||
raw_track: audio.tagger._Track) -> tracks.Track | None:
|
||||
"""Convert the raw track into a Track object."""
|
||||
if self.medium is None or self.year is None:
|
||||
return None
|
||||
|
||||
track = self.db.tracks.lookup(library, path=filepath)
|
||||
if track is not None:
|
||||
self.__update_track(track, raw_track)
|
||||
return track
|
||||
|
||||
track = self.db.tracks.create(library, filepath, self.medium,
|
||||
self.year, title=raw_track.title,
|
||||
number=raw_track.number,
|
||||
length=raw_track.length,
|
||||
artist=raw_track.artist,
|
||||
mbid=raw_track.mbid,
|
||||
mtime=raw_track.mtime)
|
||||
|
||||
for plist in [self.db.playlists.collection,
|
||||
self.db.playlists.new_tracks,
|
||||
self.db.playlists.unplayed,
|
||||
self.album, *self.artists, self.medium,
|
||||
*self.genres, self.decade, self.year, library]:
|
||||
plist.add_track(track, idle=True)
|
||||
return track
|
||||
|
||||
def get_year(self, raw_year: int | None) -> years.Year | None:
|
||||
"""Convert the raw year into a Year object."""
|
||||
if raw_year:
|
||||
year = self.db.years.lookup(raw_year)
|
||||
return year if year else self.db.years.create(raw_year)
|
||||
|
||||
|
||||
class Thread(thread.Thread):
|
||||
"""A thread for tagging files without blocking the UI."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the Tagger Thread."""
|
||||
super().__init__()
|
||||
self._connection = None
|
||||
|
||||
def __get_connection(self) -> connection.Connection:
|
||||
if not self._connection:
|
||||
self._connection = connection.Connection()
|
||||
return self._connection
|
||||
|
||||
def __check_artist(self, artist: audio.tagger._Artist) -> None:
|
||||
if artist.name is None and len(artist.mbid) > 0:
|
||||
sql = self.__get_connection()
|
||||
cur = sql("SELECT name FROM artists WHERE mbid=?", artist.mbid)
|
||||
if row := cur.fetchone():
|
||||
artist.name = row["name"]
|
||||
else:
|
||||
mb_res = musicbrainzngs.get_artist_by_id(artist.mbid)
|
||||
artist.name = mb_res["artist"]["name"]
|
||||
|
||||
def do_get_result(self, result: thread.Data, db: GObject.TYPE_PYOBJECT,
|
||||
library: playlist.Playlist) -> tuple:
|
||||
"""Return the resulting Tags structure."""
|
||||
tags = None if result.tags is None else Tags(db, result.tags, library)
|
||||
return (result.path, tags)
|
||||
|
||||
def do_run_task(self, task: thread.Data) -> None:
|
||||
"""Tag a file."""
|
||||
tags = emmental.audio.tagger.tag_file(task.path, task.mtime)
|
||||
if tags is not None:
|
||||
for artist in tags.artists:
|
||||
self.__check_artist(artist)
|
||||
|
||||
self.set_result(path=task.path, tags=tags)
|
||||
|
||||
def do_stop(self) -> None:
|
||||
"""Close the connection before stopping."""
|
||||
if self._connection:
|
||||
self._connection.close()
|
||||
self._connection = None
|
||||
|
||||
def tag_file(self, path: pathlib.Path,
|
||||
*, mtime: float | None = None) -> None:
|
||||
"""Tag a file."""
|
||||
self.set_task(path=path, mtime=mtime)
|
||||
|
||||
|
||||
def untag_track(db: GObject.TYPE_PYOBJECT, track: tracks.Track) -> None:
|
||||
"""Untag a Track."""
|
||||
medium = track.get_medium()
|
||||
year = track.get_year()
|
||||
|
||||
playlists = [plist for plist in db.playlists.store]
|
||||
playlists.extend([medium, medium.get_album()])
|
||||
playlists.extend(track.get_artists())
|
||||
playlists.extend([year, year.parent, track.get_library()])
|
||||
|
||||
for plist in playlists:
|
||||
plist.remove_track(track)
|
|
@ -0,0 +1,374 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""A custom Gio.ListModel for working with tracks."""
|
||||
import datetime
|
||||
import pathlib
|
||||
import random
|
||||
import sqlite3
|
||||
from typing import Iterable
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
from . import table
|
||||
|
||||
|
||||
PLAYED_THRESHOLD = 2 / 3
|
||||
|
||||
|
||||
class Track(table.Row):
|
||||
"""Our custom Track object."""
|
||||
|
||||
trackid = GObject.Property(type=int)
|
||||
libraryid = GObject.Property(type=int)
|
||||
mediumid = GObject.Property(type=int)
|
||||
year = GObject.Property(type=int)
|
||||
|
||||
active = GObject.Property(type=bool, default=False)
|
||||
favorite = GObject.Property(type=bool, default=False)
|
||||
|
||||
path = GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||
mbid = GObject.Property(type=str)
|
||||
title = GObject.Property(type=str)
|
||||
artist = GObject.Property(type=str)
|
||||
number = GObject.Property(type=int)
|
||||
length = GObject.Property(type=float)
|
||||
mtime = GObject.Property(type=float)
|
||||
playcount = GObject.Property(type=int)
|
||||
added = GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||
|
||||
laststarted = GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||
lastplayed = GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||
restarted = GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||
|
||||
def do_update(self, column: str) -> bool:
|
||||
"""Update a Track object."""
|
||||
match column:
|
||||
case "trackid" | "libraryid" | "active" | "path" | "playcount" | \
|
||||
"laststarted" | "lastplayed" | "restarted": pass
|
||||
case _: return super().do_update(column)
|
||||
return True
|
||||
|
||||
def get_artists(self) -> list[table.Row]:
|
||||
"""Get a list of Artists for this Track."""
|
||||
return self.table.get_artists(self)
|
||||
|
||||
def get_genres(self) -> list[table.Row]:
|
||||
"""Get a list of Genres for this Track."""
|
||||
return self.table.get_genres(self)
|
||||
|
||||
def get_library(self) -> table.Row | None:
|
||||
"""Get the Library associated with this Track."""
|
||||
return self.table.sql.libraries.rows.get(self.libraryid)
|
||||
|
||||
def get_medium(self) -> table.Row | None:
|
||||
"""Get the Medium associated with this Track."""
|
||||
return self.table.sql.media.rows.get(self.mediumid)
|
||||
|
||||
def get_year(self) -> table.Row | None:
|
||||
"""Get the Year associated with this Track."""
|
||||
return self.table.sql.years.rows.get(self.year)
|
||||
|
||||
def restart(self) -> None:
|
||||
"""Mark that a previously started track has been started again."""
|
||||
self.table.restart_track(self)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Mark that this track has started playback."""
|
||||
self.table.start_track(self)
|
||||
|
||||
def stop(self, play_time: float) -> None:
|
||||
"""Mark that this track has stopped playback."""
|
||||
self.table.stop_track(self, play_time / self.length > PLAYED_THRESHOLD)
|
||||
|
||||
def update_properties(self, **kwargs) -> None:
|
||||
"""Update one or more of this Track's properties."""
|
||||
for (property, newval) in kwargs.items():
|
||||
if self.get_property(property) != newval:
|
||||
self.set_property(property, newval)
|
||||
|
||||
@property
|
||||
def primary_key(self) -> int:
|
||||
"""Get the primary key for this Track."""
|
||||
return self.trackid
|
||||
|
||||
|
||||
class Filter(table.KeySet):
|
||||
"""A customized Filter that never sets strictness to FilterMatch.All."""
|
||||
|
||||
def do_get_strictness(self) -> Gtk.FilterMatch:
|
||||
"""Get the strictness of the filter."""
|
||||
if self.n_keys == 0:
|
||||
return Gtk.FilterMatch.NONE
|
||||
return Gtk.FilterMatch.SOME
|
||||
|
||||
|
||||
class Table(table.Table):
|
||||
"""A ListStore tailored for storing Track objects."""
|
||||
|
||||
have_current_track = GObject.Property(type=bool, default=False)
|
||||
current_track = GObject.Property(type=Track)
|
||||
current_favorite = GObject.Property(type=bool, default=False)
|
||||
|
||||
def __init__(self, sql: GObject.TYPE_PYOBJECT):
|
||||
"""Initialize a Track Table."""
|
||||
super().__init__(sql, filter=Filter())
|
||||
self.set_model(None)
|
||||
self.connect("notify::current-track", self.__notify_current_track)
|
||||
self.connect("notify::current-favorite",
|
||||
self.__notify_current_favorite)
|
||||
|
||||
def __notify_current_track(self, table: table.Table, param) -> None:
|
||||
if self.current_track is not None:
|
||||
self.have_current_track = True
|
||||
self.current_favorite = self.current_track.favorite
|
||||
self.sql.playlists.previous.add_track(self.current_track)
|
||||
else:
|
||||
self.have_current_track = False
|
||||
self.current_favorite = False
|
||||
|
||||
def __notify_current_favorite(self, table: table.Table, param) -> None:
|
||||
if self.current_track is not None:
|
||||
self.current_track.update_properties(
|
||||
favorite=self.current_favorite)
|
||||
elif self.current_favorite is True:
|
||||
self.current_favorite = False
|
||||
|
||||
def do_construct(self, **kwargs) -> Track:
|
||||
"""Construct a new Track instance."""
|
||||
if (track := Track(**kwargs)).active:
|
||||
self.current_track = track
|
||||
return track
|
||||
|
||||
def do_sql_delete(self, track: Track) -> sqlite3.Cursor:
|
||||
"""Delete a Track."""
|
||||
return self.sql("DELETE FROM tracks WHERE trackid=?", track.trackid)
|
||||
|
||||
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
|
||||
"""Filter the Track table."""
|
||||
return self.sql("""SELECT trackid FROM track_info_view WHERE
|
||||
CASEFOLD(title) GLOB :glob
|
||||
OR CASEFOLD(artist) GLOB :glob
|
||||
OR CASEFOLD(album) GLOB :glob
|
||||
OR CASEFOLD(albumartist) GLOB :glob
|
||||
OR CASEFOLD(medium) GLOB :glob
|
||||
OR release GLOB :glob""", glob=glob)
|
||||
|
||||
def do_sql_insert(self, library: table.Row, path: pathlib.Path,
|
||||
medium: table.Row, year: table.Row, *, title: str = "",
|
||||
number: int = 0, length: float = 0.0, artist: str = "",
|
||||
mbid: str = "", mtime: float = 0.0) -> sqlite3.Cursor:
|
||||
"""Insert a new Track into the database."""
|
||||
if cur := self.sql("""INSERT INTO tracks
|
||||
(libraryid, mediumid, path, year, title,
|
||||
number, length, artist, mbid, mtime)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING *""",
|
||||
library.libraryid, medium.mediumid, path, year.year,
|
||||
title, number, length, artist, mbid, mtime):
|
||||
return self.sql("SELECT * FROM tracks WHERE trackid=?",
|
||||
cur.lastrowid)
|
||||
|
||||
def do_sql_select_all(self) -> sqlite3.Cursor:
|
||||
"""Load Tracks from the database."""
|
||||
return self.sql("SELECT * FROM tracks")
|
||||
|
||||
def do_sql_select_one(self, library: table.Row = None,
|
||||
*, path: pathlib.Path = None,
|
||||
mbid: str = None) -> sqlite3.Cursor:
|
||||
"""Look up a Track in the database."""
|
||||
if path is None and mbid is None:
|
||||
raise KeyError("Either 'path' or 'mbid' are required")
|
||||
|
||||
args = [("libraryid=?", library.libraryid if library else None),
|
||||
("path=?", path), ("mbid=?", mbid)]
|
||||
|
||||
(where, args) = tuple(zip(*[arg for arg in args if None not in arg]))
|
||||
sql_where = " AND ".join(where)
|
||||
return self.sql(f"SELECT trackid FROM tracks WHERE {sql_where}", *args)
|
||||
|
||||
def do_sql_update(self, track: Track, column: str,
|
||||
newval: any) -> sqlite3.Cursor:
|
||||
"""Update a Track."""
|
||||
match (column, newval):
|
||||
case ("favorite", True):
|
||||
self.sql.playlists.favorites.add_track(track)
|
||||
if track == self.current_track:
|
||||
self.current_favorite = True
|
||||
case ("favorite", False):
|
||||
self.sql.playlists.favorites.remove_track(track)
|
||||
if track == self.current_track:
|
||||
self.current_favorite = False
|
||||
|
||||
return self.sql(f"UPDATE tracks SET {column}=? WHERE trackid=?",
|
||||
newval, track.trackid)
|
||||
|
||||
def delete_listens(self, listenids: list[int]) -> None:
|
||||
"""Delete the listens indicated by the provided listenids."""
|
||||
self.sql.executemany("""DELETE FROM listenbrainz_queue
|
||||
WHERE listenid=?""",
|
||||
*[(id,) for id in listenids])
|
||||
|
||||
def get_artists(self, track: Track) -> list[table.Row]:
|
||||
"""Get the set of Artists for a specific Track."""
|
||||
rows = self.sql("""SELECT artistid FROM artist_tracks_view
|
||||
WHERE trackid=?""", track.trackid).fetchall()
|
||||
return [self.sql.artists.rows.get(row["artistid"]) for row in rows]
|
||||
|
||||
def get_genres(self, track: Track) -> list[int]:
|
||||
"""Get the list of Genres for a specific Track."""
|
||||
rows = self.sql("""SELECT genreid FROM genre_tracks_view
|
||||
WHERE trackid=?""", track.trackid).fetchall()
|
||||
return [self.sql.genres.rows.get(row["genreid"]) for row in rows]
|
||||
|
||||
def get_n_listens(self, n: int) -> list[tuple]:
|
||||
"""Get the n most recent listens from the listenbrainz queue."""
|
||||
cur = self.sql("""SELECT listenid, trackid, timestamp
|
||||
FROM listenbrainz_queue ORDER BY timestamp DESC
|
||||
LIMIT ?""", n)
|
||||
return [(row["listenid"], self.rows[row["trackid"]], row["timestamp"])
|
||||
for row in cur.fetchall()]
|
||||
|
||||
def map_sort_order(self, ordering: str) -> dict[int, int]:
|
||||
"""Get a lookup table for Track sort keys."""
|
||||
ordering = ordering if len(ordering) > 0 else "trackid"
|
||||
cur = self.sql(f"""SELECT trackid FROM track_info_view
|
||||
ORDER BY {ordering}""")
|
||||
return {row["trackid"]: i for (i, row) in enumerate(cur.fetchall())}
|
||||
|
||||
def mark_path_active(self, path: pathlib.Path) -> None:
|
||||
"""Mark a specific track as active in the database.."""
|
||||
if self.sql("UPDATE tracks SET active=TRUE WHERE path=?",
|
||||
path).rowcount == 0:
|
||||
self.sql("UPDATE tracks SET active=FALSE WHERE active=TRUE")
|
||||
|
||||
def restart_track(self, track: Track) -> None:
|
||||
"""Mark that a Track has been restarted."""
|
||||
track.active = True
|
||||
track.restarted = datetime.datetime.utcnow()
|
||||
self.current_track = track
|
||||
|
||||
def start_track(self, track: Track) -> None:
|
||||
"""Mark that a Track has been started."""
|
||||
self.sql.playlists.previous.remove_track(track)
|
||||
|
||||
cur = self.sql("""UPDATE tracks SET active=TRUE, laststarted=?
|
||||
WHERE trackid=? RETURNING laststarted""",
|
||||
datetime.datetime.utcnow(), track.trackid)
|
||||
track.active = True
|
||||
track.laststarted = cur.fetchone()["laststarted"]
|
||||
self.current_track = track
|
||||
self.sql.commit()
|
||||
|
||||
def stop_track(self, track: Track, played: bool) -> None:
|
||||
"""Mark that a Track has been stopped."""
|
||||
args = [("active=?", False)]
|
||||
|
||||
if played:
|
||||
if track.restarted is not None:
|
||||
track.laststarted = track.restarted
|
||||
args.append(("laststarted=?", track.restarted))
|
||||
args.append(("lastplayed=?", track.laststarted))
|
||||
args.append(("playcount=?", track.playcount + 1))
|
||||
|
||||
(fields, vals) = tuple(zip(*args))
|
||||
update = ", ".join(fields)
|
||||
row = self.sql(f"""UPDATE tracks SET {update} WHERE trackid=?
|
||||
RETURNING lastplayed, playcount""",
|
||||
*vals, track.trackid).fetchone()
|
||||
|
||||
track.active = False
|
||||
track.playcount = row["playcount"]
|
||||
track.lastplayed = row["lastplayed"]
|
||||
track.restarted = None
|
||||
self.current_track = None
|
||||
|
||||
if played:
|
||||
self.sql.playlists.most_played.reload_tracks(idle=True)
|
||||
self.sql.playlists.queued.remove_track(track)
|
||||
self.sql.playlists.unplayed.remove_track(track)
|
||||
self.emit("track-played", track)
|
||||
|
||||
self.sql.commit()
|
||||
|
||||
@GObject.Signal(arg_types=(Track,))
|
||||
def track_played(self, track: Track) -> None:
|
||||
"""Signal that a Track was played."""
|
||||
if track is not None:
|
||||
self.sql("""INSERT INTO listenbrainz_queue (trackid, timestamp)
|
||||
VALUES (?, ?)""", track.trackid, track.lastplayed)
|
||||
|
||||
|
||||
class TrackidSet(GObject.GObject):
|
||||
"""Manage a set of Track IDs."""
|
||||
|
||||
n_trackids = GObject.Property(type=int)
|
||||
|
||||
def __init__(self, trackids: Iterable[int] = []):
|
||||
"""Initialize a TrackidSet."""
|
||||
super().__init__()
|
||||
self.__trackids = set(trackids)
|
||||
self.n_trackids = len(self.__trackids)
|
||||
|
||||
def __contains__(self, track: Track) -> bool:
|
||||
"""Check if a Track is in the set."""
|
||||
return track.trackid in self.__trackids
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Find the number of Tracks in the set."""
|
||||
return len(self.__trackids)
|
||||
|
||||
def __sub__(self, rhs):
|
||||
"""Subtract two TrackidSets."""
|
||||
return TrackidSet(self.__trackids - rhs.trackids)
|
||||
|
||||
def add_track(self, track: Track) -> None:
|
||||
"""Add a Track to the set."""
|
||||
if track.trackid not in self.__trackids:
|
||||
self.__trackids.add(track.trackid)
|
||||
self.n_trackids = len(self)
|
||||
self.emit("trackid-added", track.trackid)
|
||||
|
||||
def random_trackid(self) -> int | None:
|
||||
"""Get a random trackid from the set."""
|
||||
if len(self.__trackids) > 0:
|
||||
return random.choice(list(self.__trackids))
|
||||
|
||||
def remove_track(self, track: Track) -> None:
|
||||
"""Remove a Track from the set."""
|
||||
if track.trackid in self.__trackids:
|
||||
self.__trackids.discard(track.trackid)
|
||||
self.n_trackids = len(self)
|
||||
self.emit("trackid-removed", track.trackid)
|
||||
|
||||
@property
|
||||
def trackids(self) -> set:
|
||||
"""Get the set of trackids."""
|
||||
return self.__trackids
|
||||
|
||||
@trackids.setter
|
||||
def trackids(self, trackids: Iterable[int]) -> None:
|
||||
"""Add several trackids to the set at one time."""
|
||||
new_trackids = set(trackids)
|
||||
if self.__trackids.isdisjoint(new_trackids):
|
||||
self.__trackids = new_trackids
|
||||
self.n_trackids = len(self)
|
||||
self.emit("trackids-reset")
|
||||
else:
|
||||
removed = self.__trackids - new_trackids
|
||||
added = new_trackids - self.__trackids
|
||||
self.__trackids = new_trackids
|
||||
self.n_trackids = len(self)
|
||||
for id in removed:
|
||||
self.emit("trackid-removed", id)
|
||||
for id in added:
|
||||
self.emit("trackid-added", id)
|
||||
|
||||
@GObject.Signal(arg_types=(int,))
|
||||
def trackid_added(self, trackid: int) -> None:
|
||||
"""Signal that a Track has been added to the set."""
|
||||
|
||||
@GObject.Signal(arg_types=(int,))
|
||||
def trackid_removed(self, trackid: int) -> None:
|
||||
"""Signal that a Track has been removed from the set."""
|
||||
|
||||
@GObject.Signal
|
||||
def trackids_reset(self) -> None:
|
||||
"""Signal that the Tracks in the set have been reset."""
|
|
@ -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;
|
|
@ -0,0 +1,82 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""A custom Gio.ListModel for managing individual years."""
|
||||
import sqlite3
|
||||
from gi.repository import GObject
|
||||
from . import playlist
|
||||
from . import tracks
|
||||
|
||||
|
||||
class Year(playlist.Playlist):
|
||||
"""Our custom Year object."""
|
||||
|
||||
year = GObject.Property(type=int)
|
||||
|
||||
@property
|
||||
def primary_key(self) -> int:
|
||||
"""Get this year's primary key."""
|
||||
return self.year
|
||||
|
||||
@GObject.Property(type=playlist.Playlist)
|
||||
def parent(self) -> playlist.Playlist | None:
|
||||
"""Get this Year's parent playlist."""
|
||||
return self.table.sql.decades.lookup(self.year)
|
||||
|
||||
|
||||
class Table(playlist.Table):
|
||||
"""Our Year Table."""
|
||||
|
||||
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
|
||||
"""Initialize the Years table."""
|
||||
super().__init__(sql=sql, autodelete=True,
|
||||
system_tracks=False, **kwargs)
|
||||
|
||||
def do_add_track(self, year: Year, track: tracks.Track) -> bool:
|
||||
"""Verify adding a Track to the Year playlist."""
|
||||
return track.year == year.year
|
||||
|
||||
def do_construct(self, **kwargs) -> Year:
|
||||
"""Construct a new Year playlist."""
|
||||
return Year(**kwargs)
|
||||
|
||||
def do_get_sort_key(self, year: Year) -> tuple:
|
||||
"""Get the sort key for a specific year."""
|
||||
return year.year
|
||||
|
||||
def do_remove_track(self, year: Year, track: tracks.Track) -> bool:
|
||||
"""Verify removing a Track from the Year playlist."""
|
||||
return True
|
||||
|
||||
def do_sql_delete(self, year: Year) -> sqlite3.Cursor:
|
||||
"""Delete a year."""
|
||||
if year.parent is not None:
|
||||
year.parent.remove_year(year)
|
||||
return self.sql("DELETE FROM years WHERE year=?", year.year)
|
||||
|
||||
def do_sql_glob(self, glob: str) -> sqlite3.Cursor:
|
||||
"""Search for years matching the search text."""
|
||||
return self.sql("SELECT year FROM years_view WHERE name GLOB ?", glob)
|
||||
|
||||
def do_sql_insert(self, year: int) -> sqlite3.Cursor | None:
|
||||
"""Create a new Year playlist."""
|
||||
if self.sql("INSERT INTO years (year) VALUES (?)", year):
|
||||
return self.sql("SELECT * FROM years_view WHERE year=?", year)
|
||||
|
||||
def do_sql_select_all(self) -> sqlite3.Cursor:
|
||||
"""Load Years from the database."""
|
||||
return self.sql("SELECT * FROM years_view")
|
||||
|
||||
def do_sql_select_one(self, year: int) -> sqlite3.Cursor:
|
||||
"""Look up a year."""
|
||||
return self.sql("SELECT year FROM years WHERE year=?", year)
|
||||
|
||||
def do_sql_select_trackids(self, year: Year) -> sqlite3.Cursor:
|
||||
"""Load a Year's Tracks from the database."""
|
||||
return self.sql("""SELECT trackid FROM year_tracks_view
|
||||
WHERE year=?""", year.year)
|
||||
|
||||
def create(self, *args, **kwargs) -> Year | None:
|
||||
"""Create a new Year playlist."""
|
||||
if (year := super().create(*args, **kwargs)) is not None:
|
||||
if year.parent is not None:
|
||||
year.parent.add_year(year)
|
||||
return year
|
|
@ -0,0 +1,99 @@
|
|||
/* Copyright 2022 (c) Anna Schumaker. */
|
||||
|
||||
/* Make the Gtk.Paned separator transparent with extra padding */
|
||||
paned.emmental-pane>separator {
|
||||
opacity: 0;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
*.emmental-padding {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
box.emmental-splitbutton>button {
|
||||
border-radius: 0%;
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
box.emmental-splitbutton>menubutton>button {
|
||||
border-radius: 0%;
|
||||
margin-left: -1px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
row.emmental-active-row {
|
||||
font-weight: bold;
|
||||
background-color: alpha(@accent_color, 0.15);
|
||||
}
|
||||
|
||||
row.emmental-active-row:hover {
|
||||
background-color: alpha(@accent_color, 0.22);
|
||||
}
|
||||
|
||||
row.emmental-active-row:active {
|
||||
background-color: alpha(@accent_color, 0.31);
|
||||
}
|
||||
|
||||
row.emmental-active-row:selected {
|
||||
background-color: alpha(@accent_color, 0.25);
|
||||
}
|
||||
|
||||
row.emmental-active-row:selected:hover {
|
||||
background-color: alpha(@accent_color, 0.28);
|
||||
}
|
||||
|
||||
row.emmental-active-row:selected:active {
|
||||
background-color: alpha(@accent_color, 0.34);
|
||||
}
|
||||
|
||||
image.emmental-sidebar-arrow {
|
||||
transition: 250ms;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
image.emmental-sidebar-arrow:checked {
|
||||
transition: 250ms;
|
||||
transform: rotate(-180deg);
|
||||
color: @accent_color;
|
||||
}
|
||||
|
||||
box.emmental-sidebar-section>button {
|
||||
border-radius: 0%;
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
button.emmental-delete>image {
|
||||
color: @destructive_color;
|
||||
}
|
||||
|
||||
button.emmental-stop>image {
|
||||
color: @red_3;
|
||||
}
|
||||
|
||||
columnview.emmental-track-list > header {
|
||||
background-color: @card_bg_color;
|
||||
}
|
||||
|
||||
columnview.emmental-track-list > listview {
|
||||
background-color: @card_bg_color;
|
||||
}
|
||||
|
||||
columnview.emmental-track-list > listview > row > cell {
|
||||
padding: 0px 2px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
columnview.emmental-track-list > listview > row > cell > label {
|
||||
padding: 0px 4px;
|
||||
}
|
||||
|
||||
columnview.emmental-track-list > listview > row > cell > picture {
|
||||
padding: 4px 0px;
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
border-radius: 15%;
|
||||
}
|
||||
|
||||
box.emmental-move-buttons > button > image {
|
||||
color: @accent_color;
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""Customized Gtk.Entries for easier development."""
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import GObject
|
||||
from . import format
|
||||
|
||||
|
||||
class Filter(Gtk.SearchEntry):
|
||||
"""A Gtk.Entry that returns a filter query."""
|
||||
|
||||
def __init__(self, what: str, **kwargs):
|
||||
"""Set up the FilterEntry."""
|
||||
super().__init__(placeholder_text=f"type to filter {what}", **kwargs)
|
||||
|
||||
def get_placeholder_text(self) -> str:
|
||||
"""Get the entry's placeholder-text."""
|
||||
return self.get_property("placeholder-text")
|
||||
|
||||
def get_query(self) -> str | None:
|
||||
"""Get the query string for the entered text."""
|
||||
return format.search(self.get_text())
|
||||
|
||||
|
||||
class ValueBase(Gtk.Entry):
|
||||
"""Base class for value entries."""
|
||||
|
||||
def __init__(self, input_purpose: Gtk.InputPurpose, value, **kwargs):
|
||||
"""Initialize a ValueBase Entry."""
|
||||
super().__init__(input_purpose=input_purpose,
|
||||
value=value, text=str(value), **kwargs)
|
||||
self.connect("notify::value", self.__notify_value)
|
||||
|
||||
def __notify_value(self, entry: Gtk.Entry, param) -> None:
|
||||
self.set_text(str(self.value))
|
||||
|
||||
def do_activate(self) -> None:
|
||||
"""Handle the activate signal."""
|
||||
self.value = type(self.value)(self.get_text())
|
||||
|
||||
|
||||
class Integer(ValueBase):
|
||||
"""Entry for Integers."""
|
||||
|
||||
value = GObject.Property(type=int)
|
||||
|
||||
def __init__(self, value: int = 0, **kwargs):
|
||||
"""Initialize an Integer Entry."""
|
||||
super().__init__(Gtk.InputPurpose.DIGITS, value, **kwargs)
|
||||
|
||||
|
||||
class Float(ValueBase):
|
||||
"""Entry for Floats."""
|
||||
|
||||
value = GObject.Property(type=float)
|
||||
|
||||
def __init__(self, value: float = 0.0, **kwargs):
|
||||
"""Initialize a Float Entry."""
|
||||
super().__init__(Gtk.InputPurpose.NUMBER, value, **kwargs)
|
||||
|
||||
|
||||
class String(ValueBase):
|
||||
"""Entry for Strings."""
|
||||
|
||||
value = GObject.Property(type=str)
|
||||
|
||||
def __init__(self, value: str = "", **kwargs):
|
||||
"""Initialize a String Entry."""
|
||||
super().__init__(Gtk.InputPurpose.FREE_FORM, value, **kwargs)
|
|
@ -0,0 +1,201 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""A customized Gtk.SignalListItemFactory for easier use."""
|
||||
import typing
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gio
|
||||
from gi.repository import Gtk
|
||||
|
||||
|
||||
class ListRow(GObject.GObject):
|
||||
"""Extra state that we attach to the Gtk.ListItem."""
|
||||
|
||||
listitem = GObject.Property(type=Gtk.ListItem)
|
||||
|
||||
def __init__(self, listitem: Gtk.ListItem, **kwargs):
|
||||
"""Initialize a ListRow object."""
|
||||
GObject.GObject.__init__(self, listitem=listitem, **kwargs)
|
||||
self.bindings = []
|
||||
|
||||
def do_bind(self) -> None:
|
||||
"""Bind the list item to the child widget."""
|
||||
|
||||
def do_unbind(self) -> None:
|
||||
"""Unbind the list item from the child widget."""
|
||||
|
||||
def bind_active(self, item_prop: str) -> None:
|
||||
"""Bind a property to the Row's active property."""
|
||||
self.bind_and_set(self.item, item_prop, self, "active")
|
||||
|
||||
def bind_and_set(self, src: GObject.GObject, src_prop: str,
|
||||
dst: GObject.GObject, dst_prop: str,
|
||||
bidirectional: bool = False,
|
||||
invert_boolean: bool = False) -> None:
|
||||
"""Bind and set a property from the src object to the dst object."""
|
||||
f_bidir = GObject.BindingFlags.BIDIRECTIONAL if bidirectional else 0
|
||||
f_invrt = GObject.BindingFlags.INVERT_BOOLEAN if invert_boolean else 0
|
||||
|
||||
src_value = src.get_property(src_prop)
|
||||
value = not src_value if invert_boolean else src_value
|
||||
dst.set_property(dst_prop, value)
|
||||
self.bindings.append(src.bind_property(src_prop, dst, dst_prop,
|
||||
f_bidir | f_invrt))
|
||||
|
||||
def bind_and_set_property(self, item_prop: str, child_prop: str,
|
||||
bidirectional: bool = False,
|
||||
invert_boolean: bool = False) -> None:
|
||||
"""Bind and set a list item property."""
|
||||
self.bind_and_set(self.item, item_prop, self.child, child_prop,
|
||||
bidirectional, invert_boolean)
|
||||
|
||||
def bind(self) -> None:
|
||||
"""Bind the list item to the child widget."""
|
||||
self.do_bind()
|
||||
|
||||
def unbind(self) -> None:
|
||||
"""Unbind the list item from the child widget."""
|
||||
for binding in self.bindings:
|
||||
binding.unbind()
|
||||
self.bindings.clear()
|
||||
self.do_unbind()
|
||||
|
||||
@GObject.Property(type=bool, default=False)
|
||||
def active(self) -> bool:
|
||||
"""Get the active state of this Row."""
|
||||
if self.listrow is not None:
|
||||
return self.listrow.has_css_class("emmental-active-row")
|
||||
return False
|
||||
|
||||
@active.setter
|
||||
def active(self, newval: bool) -> None:
|
||||
if self.listrow is not None:
|
||||
if newval:
|
||||
self.listrow.add_css_class("emmental-active-row")
|
||||
else:
|
||||
self.listrow.remove_css_class("emmental-active-row")
|
||||
|
||||
@GObject.Property(type=Gtk.Widget)
|
||||
def child(self) -> Gtk.Widget | None:
|
||||
"""Get the child widget displayed by this Row."""
|
||||
return self.listitem.get_child()
|
||||
|
||||
@child.setter
|
||||
def child(self, newval: Gtk.Widget) -> None:
|
||||
self.listitem.set_child(newval)
|
||||
|
||||
@GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||
def item(self) -> GObject.TYPE_PYOBJECT:
|
||||
"""Get the list item for this Row."""
|
||||
return self.listitem.get_item()
|
||||
|
||||
@GObject.Property(type=Gtk.Widget)
|
||||
def listrow(self) -> Gtk.Widget:
|
||||
"""Get the listrow widget that our child widget is contained in."""
|
||||
return self.listitem.props.child.props.parent
|
||||
|
||||
|
||||
class InscriptionRow(ListRow):
|
||||
"""A ListRow for displaying Gtk.Inscription widgets."""
|
||||
|
||||
item_property = GObject.Property(type=str)
|
||||
|
||||
def __init__(self, listitem: Gtk.ListItem, item_property: str,
|
||||
xalign: float = 0.0, numeric: bool = False) -> None:
|
||||
"""Create a new Gtk.Label."""
|
||||
super().__init__(listitem, item_property=item_property)
|
||||
self.child = Gtk.Inscription(xalign=xalign)
|
||||
if numeric:
|
||||
self.child.add_css_class("numeric")
|
||||
|
||||
def do_bind(self) -> None:
|
||||
"""Bind a ListItem to the Label."""
|
||||
self.bind_and_set_property(self.item_property, "text")
|
||||
|
||||
|
||||
class TreeRow(ListRow):
|
||||
"""A ListRow for displaying child widgets in a Tree."""
|
||||
|
||||
n_children = GObject.Property(type=int)
|
||||
have_children = GObject.Property(type=bool, default=False)
|
||||
indented = GObject.Property(type=bool, default=True)
|
||||
|
||||
def __init__(self, listitem: Gtk.ListItem, **kwargs) -> None:
|
||||
"""Create a new TreeRow."""
|
||||
super().__init__(listitem, **kwargs)
|
||||
listitem.set_child(Gtk.TreeExpander(hide_expander=True,
|
||||
indent_for_icon=self.indented))
|
||||
self.bind_property("n-children", self, "have-children")
|
||||
self.bind_property("have-children", listitem.get_child(),
|
||||
"hide-expander",
|
||||
GObject.BindingFlags.INVERT_BOOLEAN)
|
||||
self.bind_property("indented", listitem.get_child(), "indent-for-icon")
|
||||
|
||||
def bind(self) -> None:
|
||||
"""Bind a TreeRow to the TreeExpander."""
|
||||
self.listitem.get_child().set_list_row(self.listitem.get_item())
|
||||
super().bind()
|
||||
|
||||
def bind_n_children(self, children: Gio.ListModel | None) -> None:
|
||||
"""Bind to the n-items property of the child listmodel."""
|
||||
if children is not None:
|
||||
self.bind_and_set(children, "n-items", self, "n-children")
|
||||
|
||||
def unbind(self) -> None:
|
||||
"""Unbind a TreeRow from the TreeExpander."""
|
||||
self.listitem.get_child().set_list_row(None)
|
||||
super().unbind()
|
||||
|
||||
@GObject.Property(type=Gtk.Widget)
|
||||
def child(self) -> Gtk.Widget | None:
|
||||
"""Get the child widget displayed by this Row."""
|
||||
return self.listitem.get_child().get_child()
|
||||
|
||||
@child.setter
|
||||
def child(self, newval=Gtk.Widget) -> None:
|
||||
self.listitem.get_child().set_child(newval)
|
||||
|
||||
@GObject.Property(type=GObject.TYPE_PYOBJECT)
|
||||
def item(self) -> GObject.TYPE_PYOBJECT:
|
||||
"""Get the list item for this Row."""
|
||||
return self.listitem.get_item().get_item()
|
||||
|
||||
|
||||
class Factory(Gtk.SignalListItemFactory):
|
||||
"""A customized Factory for making list row widgets."""
|
||||
|
||||
def __init__(self, row_type: typing.Type[ListRow], **kwargs):
|
||||
"""Initialize a ListFactory."""
|
||||
super().__init__()
|
||||
self.row_type = row_type
|
||||
|
||||
self.connect("setup", self.__setup, kwargs)
|
||||
self.connect("bind", self.__bind)
|
||||
self.connect("unbind", self.__unbind)
|
||||
self.connect("teardown", self.__teardown)
|
||||
|
||||
def __setup(self, factory: Gtk.SignalListItemFactory,
|
||||
listitem: Gtk.ListItem, kwargs: dict) -> None:
|
||||
listitem.listrow = self.row_type(listitem, **kwargs)
|
||||
|
||||
def __bind(self, factory: Gtk.SignalListItemFactory,
|
||||
listitem: Gtk.ListItem) -> None:
|
||||
listitem.listrow.bind()
|
||||
|
||||
def __unbind(self, factory: Gtk.SignalListItemFactory,
|
||||
listitem: Gtk.ListItem) -> None:
|
||||
listitem.listrow.unbind()
|
||||
|
||||
def __teardown(self, factory: Gtk.SignalListItemFactory,
|
||||
listitem: Gtk.ListItem) -> None:
|
||||
listitem.set_child(None)
|
||||
listitem.listrow = None
|
||||
|
||||
|
||||
class InscriptionFactory(Factory):
|
||||
"""A Factory that creates InscriptionRows."""
|
||||
|
||||
def __init__(self, item_property: str,
|
||||
script_type: typing.Type[InscriptionRow] = InscriptionRow,
|
||||
xalign: float = 0.0, numeric: bool = False):
|
||||
"""Initialize a LabelFactory."""
|
||||
super().__init__(row_type=script_type, item_property=item_property,
|
||||
xalign=xalign, numeric=numeric)
|
|
@ -0,0 +1,35 @@
|
|||
# Copyright 2022 (c) Anna Schumaker
|
||||
"""Helper functions for formatting strings."""
|
||||
import re
|
||||
|
||||
IGNORE_WORDS = set(["a", "an", "the", ""])
|
||||
|
||||
|
||||
def search(input: str) -> str | None:
|
||||
"""Translate the input string into a sqlite3 GLOB statement."""
|
||||
input = input.strip().casefold()
|
||||
if len(input) == 0:
|
||||
return None
|
||||
|
||||
if input[0] == "^":
|
||||
input = input[1:] if len(input) > 1 else "*"
|
||||
elif input[0] != "*":
|
||||
input = "*" + input
|
||||
|
||||
if input[-1] == "$":
|
||||
input = input[:-1]
|
||||
elif input[-1] != "*":
|
||||
input += "*"
|
||||
|
||||
return input
|
||||
|
||||
|
||||
def sort_key(input: str) -> tuple:
|
||||
"""Translate the input string into a sort key."""
|
||||
if len(input) == 0:
|
||||
return ()
|
||||
input = re.sub(r"[\"\'’“”]", "", input.casefold())
|
||||
res = re.split(r"[ /_-]", input)
|
||||
if len(res) > 1 and res[0] in IGNORE_WORDS:
|
||||
res = res[1:]
|
||||
return tuple(res)
|
|
@ -0,0 +1,80 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""Set up GObject Introspection, and custom styling, and icons."""
|
||||
import pathlib
|
||||
import sys
|
||||
import sqlite3
|
||||
import gi
|
||||
import xdg.BaseDirectory
|
||||
|
||||
gi.require_version("Pango", "1.0")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
gi.require_version("Gst", "1.0")
|
||||
|
||||
gi.importlib.import_module("gi.repository.Gio")
|
||||
gi.importlib.import_module("gi.repository.Gtk")
|
||||
gi.importlib.import_module("gi.repository.Gst").init(sys.argv)
|
||||
|
||||
DEBUG_STR = "-debug" if __debug__ else ""
|
||||
APPLICATION_ID = f"com.nowheycreamery.emmental{DEBUG_STR}"
|
||||
|
||||
CSS_FILE = pathlib.Path(__file__).parent / "emmental.css"
|
||||
CSS_PRIORITY = gi.repository.Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
||||
CSS_PROVIDER = gi.repository.Gtk.CssProvider()
|
||||
CSS_PROVIDER.load_from_path(str(CSS_FILE))
|
||||
|
||||
CACHE_DIR = pathlib.Path(xdg.BaseDirectory.save_cache_path("emmental"))
|
||||
CACHE_DIR = CACHE_DIR / DEBUG_STR.lstrip("-")
|
||||
|
||||
DATA_DIR = pathlib.Path(xdg.BaseDirectory.save_data_path("emmental"))
|
||||
|
||||
RESOURCE_PATH = "/com/nowheycreamery/emmental"
|
||||
RESOURCE_ICONS = f"{RESOURCE_PATH}/icons/scalable/apps"
|
||||
RESOURCE_FILE = pathlib.Path(__file__).parent.parent / "emmental.gresource"
|
||||
RESOURCE = gi.repository.Gio.Resource.load(str(RESOURCE_FILE))
|
||||
gi.repository.Gio.resources_register(RESOURCE)
|
||||
|
||||
|
||||
def add_style():
|
||||
"""Add our stylesheet to the default display."""
|
||||
style = gi.repository.Gtk.StyleContext
|
||||
style.add_provider_for_display(gi.repository.Gdk.Display.get_default(),
|
||||
CSS_PROVIDER, CSS_PRIORITY)
|
||||
|
||||
|
||||
def has_icon(icon_name: str):
|
||||
"""Check if the icon theme has a specific icon."""
|
||||
display = gi.repository.Gdk.Display.get_default()
|
||||
theme = gi.repository.Gtk.IconTheme.get_for_display(display)
|
||||
return theme.has_icon(icon_name)
|
||||
|
||||
|
||||
def __version_string(subsystem, major, minor, micro):
|
||||
return f" ⋅ {subsystem} {major}.{minor}.{micro}"
|
||||
|
||||
|
||||
def env_string() -> str:
|
||||
"""Return a string with the version numbers of our dependencies."""
|
||||
gst = gi.repository.Gst.version()
|
||||
strs = [__version_string("Python", sys.version_info.major,
|
||||
sys.version_info.minor, sys.version_info.micro),
|
||||
__version_string("Gtk", gi.repository.Gtk.MAJOR_VERSION,
|
||||
gi.repository.Gtk.MINOR_VERSION,
|
||||
gi.repository.Gtk.MICRO_VERSION),
|
||||
__version_string("Libadwaita", gi.repository.Adw.MAJOR_VERSION,
|
||||
gi.repository.Adw.MINOR_VERSION,
|
||||
gi.repository.Adw.MICRO_VERSION),
|
||||
__version_string("GStreamer", gst.major, gst.minor, gst.micro),
|
||||
__version_string("Pango", gi.repository.Pango.VERSION_MAJOR,
|
||||
gi.repository.Pango.VERSION_MINOR,
|
||||
gi.repository.Pango.VERSION_MICRO),
|
||||
__version_string("SQLite", sqlite3.sqlite_version_info[0],
|
||||
sqlite3.sqlite_version_info[1],
|
||||
sqlite3.sqlite_version_info[2])]
|
||||
return "\n".join(strs)
|
||||
|
||||
|
||||
def print_env() -> None:
|
||||
"""Print the environment versions to stdout."""
|
||||
print(env_string())
|
|
@ -0,0 +1,185 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""A custom Gtk.HeaderBar configured for our application."""
|
||||
import pathlib
|
||||
import typing
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
from ..action import ActionEntry
|
||||
from .. import db
|
||||
from .. import buttons
|
||||
from .. import gsetup
|
||||
from . import listenbrainz
|
||||
from . import open
|
||||
from . import replaygain
|
||||
from . import volume
|
||||
if __debug__:
|
||||
from . import settings
|
||||
|
||||
SUBTITLE = "The Cheesy Music Player"
|
||||
|
||||
|
||||
def _volume_icon(vol: float) -> str:
|
||||
if vol == 0.0:
|
||||
return "audio-volume-muted-symbolic"
|
||||
if vol <= 1/3:
|
||||
return "audio-volume-low-symbolic"
|
||||
if vol <= 2/3:
|
||||
return "audio-volume-medium-symbolic"
|
||||
return "audio-volume-high-symbolic"
|
||||
|
||||
|
||||
class Header(Gtk.HeaderBar):
|
||||
"""Our custom Gtk.HeaderBar containing window title and volume controls."""
|
||||
|
||||
sql = GObject.Property(type=db.Connection)
|
||||
title = GObject.Property(type=str)
|
||||
subtitle = GObject.Property(type=str)
|
||||
listenbrainz_token = GObject.Property(type=str)
|
||||
show_sidebar = GObject.Property(type=bool, default=False)
|
||||
bg_enabled = GObject.Property(type=bool, default=False)
|
||||
bg_volume = GObject.Property(type=float, default=0.5)
|
||||
rg_enabled = GObject.Property(type=bool, default=False)
|
||||
rg_mode = GObject.Property(type=str, default="auto")
|
||||
volume = GObject.Property(type=float, default=1.0)
|
||||
|
||||
def __init__(self, sql: db.Connection, title: str):
|
||||
"""Initialize the HeaderBar."""
|
||||
super().__init__(title=title, subtitle=SUBTITLE, sql=sql)
|
||||
self._title = Adw.WindowTitle(title=self.title, subtitle=self.subtitle,
|
||||
tooltip_text=gsetup.env_string())
|
||||
|
||||
icon = "sidebar-show-symbolic"
|
||||
self._show_sidebar = Gtk.ToggleButton(icon_name=icon, has_frame=False)
|
||||
self._open = open.OpenRow()
|
||||
self._listenbrainz = listenbrainz.ListenBrainzRow()
|
||||
|
||||
self._menu_box = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
|
||||
self._menu_box.add_css_class("boxed-list")
|
||||
self._menu_box.append(self._open)
|
||||
self._menu_box.append(self._listenbrainz)
|
||||
|
||||
if __debug__:
|
||||
self._settings = settings.Row(sql)
|
||||
self._menu_box.append(self._settings)
|
||||
|
||||
icon = "open-menu-symbolic"
|
||||
self._menu_button = buttons.PopoverButton(popover_child=self._menu_box,
|
||||
icon_name=icon)
|
||||
|
||||
self._volume = volume.VolumeRow()
|
||||
self._volume_icon = Gtk.Image(icon_name=_volume_icon(self.volume))
|
||||
self._background = volume.BackgroundRow()
|
||||
self._background_icon = Gtk.Image(icon_name="sound-wave")
|
||||
self._replaygain = replaygain.ReplayGainRow()
|
||||
|
||||
self._icons = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 6)
|
||||
self._icons.append(self._volume_icon)
|
||||
self._icons.append(self._background_icon)
|
||||
|
||||
self._vol_box = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
|
||||
self._vol_box.add_css_class("boxed-list")
|
||||
self._vol_box.append(self._volume)
|
||||
self._vol_box.append(self._background)
|
||||
self._vol_box.append(self._replaygain)
|
||||
|
||||
self._vol_button = buttons.PopoverButton(popover_child=self._vol_box,
|
||||
child=self._icons,
|
||||
has_frame=False, margin_end=6)
|
||||
|
||||
self.bind_property("title", self._title, "title")
|
||||
self.bind_property("subtitle", self._title, "subtitle")
|
||||
self.bind_property("listenbrainz-token", self._listenbrainz, "text")
|
||||
self.bind_property("show-sidebar", self._show_sidebar, "active",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.bind_property("bg-enabled", self._background, "enabled",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.bind_property("bg-volume", self._background, "volume",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.bind_property("rg-enabled", self._replaygain, "enabled",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.bind_property("rg-mode", self._replaygain, "mode",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.bind_property("volume", self._volume, "volume",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
|
||||
self.pack_start(self._show_sidebar)
|
||||
self.pack_start(self._menu_button)
|
||||
|
||||
self.pack_end(self._vol_button)
|
||||
self.set_title_widget(self._title)
|
||||
|
||||
self._menu_button.props.popover.connect("closed", self.__menu_closed)
|
||||
self._open.connect("track-requested", self.__track_requested)
|
||||
self._listenbrainz.connect("apply", self.__listenbrainz_apply)
|
||||
self.connect("notify", self.__notify)
|
||||
|
||||
def __run_settings(self, button: Gtk.Button) -> None:
|
||||
if __debug__:
|
||||
self._window.present()
|
||||
|
||||
def __notify(self, header: typing.Self, param: GObject.ParamSpec) -> None:
|
||||
match param.name:
|
||||
case "bg-enabled":
|
||||
icon = "sound-wave-alt" if self.bg_enabled else "sound-wave"
|
||||
self._background_icon.set_from_icon_name(icon)
|
||||
case "volume":
|
||||
self._volume_icon.set_from_icon_name(_volume_icon(self.volume))
|
||||
|
||||
bg_status = "off"
|
||||
if self.bg_enabled:
|
||||
bg_status = f"{round(self.bg_volume * 100)}%"
|
||||
|
||||
rg_status = f"{self.rg_mode} mode" if self.rg_enabled else "off"
|
||||
status = (f"volume: {round(self.volume * 100)}%\n"
|
||||
f"background listening: {bg_status}\n"
|
||||
f"normalizing: {rg_status}")
|
||||
self._vol_button.set_tooltip_text(status)
|
||||
|
||||
def __listenbrainz_apply(self, entry: Adw.PasswordEntryRow) -> None:
|
||||
self.listenbrainz_token = entry.get_text()
|
||||
self._menu_button.popdown()
|
||||
|
||||
def __menu_closed(self, popover: Gtk.Popover) -> None:
|
||||
self._listenbrainz.props.text = self.listenbrainz_token
|
||||
|
||||
def __track_requested(self, button: open.OpenRow,
|
||||
path: pathlib.Path) -> None:
|
||||
self.emit("track-requested", path)
|
||||
|
||||
@GObject.Property(type=bool, default=True)
|
||||
def listenbrainz_token_valid(self) -> bool:
|
||||
"""Check if we think the listenbrainz token is valid."""
|
||||
return not self._listenbrainz.has_css_class("warning")
|
||||
|
||||
@listenbrainz_token_valid.setter
|
||||
def listenbrainz_token_valid(self, valid: bool) -> None:
|
||||
if valid:
|
||||
self._menu_button.remove_css_class("warning")
|
||||
self._listenbrainz.remove_css_class("warning")
|
||||
else:
|
||||
win = self.get_ancestor(Gtk.Window)
|
||||
win.post_toast("listenbrainz: user token is invalid")
|
||||
self._menu_button.add_css_class("warning")
|
||||
self._listenbrainz.add_css_class("warning")
|
||||
|
||||
@property
|
||||
def accelerators(self) -> list[ActionEntry]:
|
||||
"""Get a list of accelerators for the Header."""
|
||||
res = [ActionEntry("open-file", self._open.activate, "<Control>o"),
|
||||
ActionEntry("decrease-volume", self._volume.decrement,
|
||||
"<Shift><Control>Down"),
|
||||
ActionEntry("increase-volume", self._volume.increment,
|
||||
"<Shift><Control>Up"),
|
||||
ActionEntry("toggle-bg-mode", self._background.activate,
|
||||
"<Shift><Control>b"),
|
||||
ActionEntry("toggle-sidebar", self._show_sidebar.activate,
|
||||
"<Control>bracketright")]
|
||||
if __debug__:
|
||||
res.append(ActionEntry("edit-settings", self._settings.activate,
|
||||
"<Shift><Control>s"))
|
||||
return res
|
||||
|
||||
@GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT,))
|
||||
def track_requested(self, path: pathlib.Path) -> None:
|
||||
"""Signal that a track has been requested."""
|
|
@ -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
|
|
@ -0,0 +1,46 @@
|
|||
# Copyright 2023 (c) Anna Schumaker.
|
||||
"""A custom Adw.ActionRow to select a file for playback."""
|
||||
import pathlib
|
||||
from gi.repository import GObject
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Gio
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
|
||||
|
||||
class OpenRow(Adw.ActionRow):
|
||||
"""Our pre-configured open Adw.ActionRow."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize our open ActionRow."""
|
||||
super().__init__(activatable=True, title="Open File",
|
||||
subtitle="Select a file for playback")
|
||||
self._prefix = Gtk.Image(icon_name="document-open-symbolic")
|
||||
self._filters = Gio.ListStore()
|
||||
self._filter = Gtk.FileFilter(name="Audio Files",
|
||||
mime_types=["inode/directory",
|
||||
"audio/*"])
|
||||
self._dialog = Gtk.FileDialog(filters=self._filters,
|
||||
title="Pick a Track")
|
||||
|
||||
self._filters.append(self._filter)
|
||||
|
||||
self.connect("activated", self.__on_activated)
|
||||
self.add_prefix(self._prefix)
|
||||
|
||||
def __async_ready(self, dialog: Gtk.FileDialog, task: Gio.Task) -> None:
|
||||
try:
|
||||
file = dialog.open_finish(task)
|
||||
self.emit("track-requested", pathlib.Path(file.get_path()))
|
||||
except GLib.Error:
|
||||
pass
|
||||
|
||||
def __on_activated(self, row: Adw.ActionRow) -> None:
|
||||
"""Handle activating an OpenRow."""
|
||||
self.get_ancestor(Gtk.Popover).popdown()
|
||||
self._dialog.open(self.get_ancestor(Gtk.Window), None,
|
||||
self.__async_ready)
|
||||
|
||||
@GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT,))
|
||||
def track_requested(self, file: pathlib.Path) -> None:
|
||||
"""Signal that a track has been requested."""
|
|
@ -0,0 +1,78 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""A widget for selecting ReplayGain mode."""
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
|
||||
|
||||
class CheckRow(Adw.ActionRow):
|
||||
"""A custom Adw.ActionRow displaying a Check Button."""
|
||||
|
||||
active = GObject.Property(type=bool, default=False)
|
||||
group = GObject.Property(type=Adw.ActionRow)
|
||||
mode = GObject.Property(type=str)
|
||||
|
||||
def __init__(self, mode: str, active: bool = False,
|
||||
group: Adw.ActionRow | None = None, **kwargs):
|
||||
"""Initialize the Check Row."""
|
||||
super().__init__(mode=mode, active=active, group=group, **kwargs)
|
||||
self._prefix = Gtk.CheckButton(active=active,
|
||||
group=group._prefix if group else None)
|
||||
|
||||
self.bind_property("active", self._prefix, "active",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
|
||||
self.set_activatable_widget(self._prefix)
|
||||
self.add_prefix(self._prefix)
|
||||
|
||||
def set_active(self, newval: bool) -> None:
|
||||
"""Set the active property."""
|
||||
if self.active != newval:
|
||||
self.active = newval
|
||||
|
||||
|
||||
class ReplayGainRow(Adw.ExpanderRow):
|
||||
"""Build up a widget for configuring ReplayGain settings."""
|
||||
|
||||
enabled = GObject.Property(type=bool, default=False)
|
||||
mode = GObject.Property(type=str, default="auto")
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the ReplayGain selector."""
|
||||
super().__init__(title="Volume Normalization",
|
||||
subtitle="Configure ReplayGain normalizing")
|
||||
self._switch = Gtk.Switch(valign=Gtk.Align.CENTER)
|
||||
self._automatic = CheckRow(title="Automatic Mode",
|
||||
subtitle="Emmental decides automatically",
|
||||
mode="auto", active=True)
|
||||
self._album = CheckRow(title="Album Mode",
|
||||
subtitle="Albums have the same volume",
|
||||
mode="album", group=self._automatic)
|
||||
self._track = CheckRow(title="Track Mode",
|
||||
subtitle="Tracks have the same volume",
|
||||
mode="track", group=self._automatic)
|
||||
|
||||
self.add_prefix(self._switch)
|
||||
self.add_row(self._automatic)
|
||||
self.add_row(self._album)
|
||||
self.add_row(self._track)
|
||||
|
||||
self.connect("notify::mode", self.__notify_mode)
|
||||
self._automatic.connect("notify::active", self.__row_activated)
|
||||
self._album.connect("notify::active", self.__row_activated)
|
||||
self._track.connect("notify::active", self.__row_activated)
|
||||
|
||||
self._switch.bind_property("active", self, "expanded",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.bind_property("enabled", self._switch, "active",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
|
||||
def __notify_mode(self, row: Adw.ExpanderRow, param) -> None:
|
||||
match self.mode:
|
||||
case "album": self._album.set_active(True)
|
||||
case "track": self._track.set_active(True)
|
||||
case _: self._automatic.set_active(True)
|
||||
|
||||
def __row_activated(self, row: CheckRow, param: GObject.ParamSpec) -> None:
|
||||
if row.active:
|
||||
self.mode = row.mode
|
|
@ -0,0 +1,84 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""A custom Gtk.Dialog for showing Settings."""
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
from .. import db
|
||||
from .. import entry
|
||||
from .. import factory
|
||||
|
||||
|
||||
class ValueRow(factory.ListRow):
|
||||
"""A Row for displaying settings values."""
|
||||
|
||||
def do_bind(self) -> None:
|
||||
"""Bind a db.Setting to this Row."""
|
||||
if isinstance(self.item.value, bool):
|
||||
self.child = Gtk.Switch(halign=Gtk.Align.START)
|
||||
self.bind_and_set_property("value", "active", bidirectional=True)
|
||||
elif isinstance(self.item.value, str):
|
||||
self.child = entry.String(has_frame=False)
|
||||
self.bind_and_set_property("value", "value", bidirectional=True)
|
||||
elif isinstance(self.item.value, int):
|
||||
self.child = entry.Integer(has_frame=False)
|
||||
self.bind_and_set_property("value", "value", bidirectional=True)
|
||||
elif isinstance(self.item.value, float):
|
||||
self.child = entry.Float(has_frame=False)
|
||||
self.bind_and_set_property("value", "value", bidirectional=True)
|
||||
|
||||
|
||||
class Window(Adw.Window):
|
||||
"""A custom window that displays the current settings."""
|
||||
|
||||
def __init__(self, sql: db.Connection):
|
||||
"""Initialize the Settings window."""
|
||||
super().__init__(default_width=500, default_height=500,
|
||||
title="Emmental Settings", icon_name="settings",
|
||||
hide_on_close=True,
|
||||
content=Gtk.Box.new(Gtk.Orientation.VERTICAL, 0))
|
||||
self._search = entry.Filter(what="settings")
|
||||
self._header = Gtk.HeaderBar(title_widget=self._search)
|
||||
self._selection = Gtk.NoSelection(model=sql.settings)
|
||||
self._view = Gtk.ColumnView(model=self._selection,
|
||||
show_row_separators=True)
|
||||
self._scroll = Gtk.ScrolledWindow(child=self._view, vexpand=True)
|
||||
|
||||
self._scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||||
|
||||
self.__append_column(factory.InscriptionFactory("key"),
|
||||
"Key", width=400)
|
||||
self.__append_column(factory.Factory(row_type=ValueRow),
|
||||
"Value", width=100)
|
||||
|
||||
self.get_content().append(self._header)
|
||||
self.get_content().append(self._scroll)
|
||||
|
||||
if __debug__:
|
||||
self.add_css_class("devel")
|
||||
self._search.connect("search-changed", self.__filter)
|
||||
|
||||
def __append_column(self, factory: factory.Factory,
|
||||
title: str, *, width: int) -> None:
|
||||
self._view.append_column(Gtk.ColumnViewColumn(factory=factory,
|
||||
title=title,
|
||||
fixed_width=width))
|
||||
|
||||
def __filter(self, entry: entry.Filter) -> None:
|
||||
self._selection.get_model().filter(entry.get_query())
|
||||
|
||||
|
||||
class Row(Adw.ActionRow):
|
||||
"""An Adw.ActionRow for opening the Settings Window."""
|
||||
|
||||
def __init__(self, sql: db.Connection):
|
||||
"""Initialize our settings ActionRow."""
|
||||
super().__init__(activatable=True, title="Edit Settings",
|
||||
subtitle="Open the settings editor (debug only)")
|
||||
self._prefix = Gtk.Image(icon_name="settings-symbolic")
|
||||
self._window = Window(sql)
|
||||
|
||||
self.connect("activated", self.__on_activated)
|
||||
self.add_prefix(self._prefix)
|
||||
|
||||
def __on_activated(self, row: Adw.ActionRow) -> None:
|
||||
self.get_ancestor(Gtk.Popover).popdown()
|
||||
self._window.present()
|
|
@ -0,0 +1,84 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""A custom Gtk.Box with controls for adjusting the volume."""
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Adw
|
||||
|
||||
STEP_SIZE = 0.05
|
||||
|
||||
|
||||
def format_value_func(scale, value: float) -> str:
|
||||
"""Format the volume value to a percentage."""
|
||||
return f"{round(value*100)} %"
|
||||
|
||||
|
||||
class VolumeRow(Gtk.ListBoxRow):
|
||||
"""A Gtk.Box containing widgets for adjusting the volume."""
|
||||
|
||||
volume = GObject.Property(type=float, default=1.0)
|
||||
|
||||
def __init__(self, volume: float = 1.0):
|
||||
"""Initialize our volume controls."""
|
||||
super().__init__(volume=volume)
|
||||
self._box = Gtk.Box()
|
||||
self._decrement = Gtk.Button(icon_name="list-remove-symbolic",
|
||||
tooltip_text="reduce the volume",
|
||||
valign=Gtk.Align.END, has_frame=False,
|
||||
margin_bottom=5)
|
||||
self._adjustment = Gtk.Adjustment.new(volume, 0.0, 1.0,
|
||||
STEP_SIZE, 0, 0)
|
||||
self._scale = Gtk.Scale(adjustment=self._adjustment, draw_value=True,
|
||||
valign=Gtk.Align.END, hexpand=True)
|
||||
self._increment = Gtk.Button(icon_name="list-add-symbolic",
|
||||
tooltip_text="increase the volume",
|
||||
valign=Gtk.Align.END, has_frame=False,
|
||||
margin_bottom=5)
|
||||
|
||||
self._scale.set_format_value_func(format_value_func)
|
||||
|
||||
self._box.append(self._decrement)
|
||||
self._box.append(self._scale)
|
||||
self._box.append(self._increment)
|
||||
self.set_child(self._box)
|
||||
|
||||
self._decrement.connect("clicked", self.decrement)
|
||||
self._scale.connect("value-changed", self.__value_changed)
|
||||
self._increment.connect("clicked", self.increment)
|
||||
|
||||
self.bind_property("volume", self._adjustment, "value",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
|
||||
def decrement(self, button: Gtk.Button | None = None) -> None:
|
||||
"""Decrease the volume by STEP_SIZE."""
|
||||
self._scale.set_value(self._scale.get_value() - STEP_SIZE)
|
||||
|
||||
def increment(self, button: Gtk.Button | None = None) -> None:
|
||||
"""Increase the volume by STEP_SIZE."""
|
||||
self._scale.set_value(self._scale.get_value() + STEP_SIZE)
|
||||
|
||||
def __value_changed(self, range: Gtk.Range) -> None:
|
||||
self.volume = range.get_value()
|
||||
|
||||
|
||||
class BackgroundRow(Adw.ExpanderRow):
|
||||
"""A VolumeRow for setting Background Listening volume."""
|
||||
|
||||
enabled = GObject.Property(type=bool, default=False)
|
||||
volume = GObject.Property(type=float, default=0.5)
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the BackgroundRow."""
|
||||
super().__init__(title="Background Listening",
|
||||
subtitle="Decrease the volume to help focus")
|
||||
self._switch = Gtk.Switch(valign=Gtk.Align.CENTER)
|
||||
self._volume = VolumeRow(volume=self.volume)
|
||||
|
||||
self.add_prefix(self._switch)
|
||||
self.add_row(self._volume)
|
||||
|
||||
self._switch.bind_property("active", self, "expanded",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.bind_property("enabled", self._switch, "active",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.bind_property("volume", self._volume, "volume",
|
||||
GObject.BindingFlags.BIDIRECTIONAL)
|
|
@ -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)
|
|
@ -0,0 +1,53 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""Implement the MPRIS2 Specification."""
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gio
|
||||
from .. import gsetup
|
||||
from . import application
|
||||
from . import player
|
||||
|
||||
MPRIS2_ID = f"org.mpris.MediaPlayer2.emmental{gsetup.DEBUG_STR}"
|
||||
|
||||
|
||||
class Connection(GObject.GObject):
|
||||
"""Our Mpris2 Object."""
|
||||
|
||||
dbus = GObject.Property(type=Gio.DBusConnection)
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Mpris2."""
|
||||
super().__init__()
|
||||
self.app = application.Application()
|
||||
self.player = player.Player()
|
||||
|
||||
self.bind_property("dbus", self.app, "dbus")
|
||||
self.bind_property("dbus", self.player, "dbus")
|
||||
|
||||
self._busid = Gio.bus_own_name(Gio.BusType.SESSION, MPRIS2_ID,
|
||||
Gio.BusNameOwnerFlags.NONE,
|
||||
self.__on_bus_acquired, None,
|
||||
self.__on_name_lost)
|
||||
|
||||
def __del__(self):
|
||||
"""Clean up."""
|
||||
self.disconnect()
|
||||
|
||||
def __on_bus_acquired(self, dbus: Gio.DBusConnection, name: str) -> None:
|
||||
self.dbus = dbus
|
||||
self.app.register(dbus)
|
||||
self.player.register(dbus)
|
||||
|
||||
def __on_name_lost(self, dbus: Gio.DBusConnection, name: str) -> None:
|
||||
self.app.unregister(dbus)
|
||||
self.player.unregister(dbus)
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect from dbus."""
|
||||
if self.dbus:
|
||||
self.app.unregister(self.dbus)
|
||||
self.player.unregister(self.dbus)
|
||||
self.dbus = None
|
||||
|
||||
if self._busid:
|
||||
Gio.bus_unown_name(self._busid)
|
||||
self._busid = None
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue