mirror of
https://github.com/rembo10/headphones.git
synced 2026-09-10 08:41:41 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b80e2b39b | ||
|
|
0ac947099c | ||
|
|
37a086d58d | ||
|
|
130cf51cc6 | ||
|
|
7e4a1e453b | ||
|
|
9c946b69e1 | ||
|
|
61ad94c0a0 | ||
|
|
56de4c63a7 | ||
|
|
1dfa31c275 | ||
|
|
e16087eb54 | ||
|
|
289293f23b | ||
|
|
59112c1a83 | ||
|
|
dd30ed460d | ||
|
|
bc54b5abaf | ||
|
|
05d316615a | ||
|
|
0eb8702a8e | ||
|
|
dfc6a6f93a | ||
|
|
1bb0b3c9bf | ||
|
|
89d5649b4f | ||
|
|
4b3b8d2795 | ||
|
|
eeff731858 | ||
|
|
63abd73942 | ||
|
|
c74bec2d29 | ||
|
|
bd74d21c83 | ||
|
|
41aee3d6bb | ||
|
|
396e75ed2d | ||
|
|
769e9c1730 | ||
|
|
33d38f33e5 | ||
|
|
876b6402c4 | ||
|
|
0e334a8f92 | ||
|
|
6218734783 | ||
|
|
da5ac9757a | ||
|
|
36107c11d5 | ||
|
|
5adf684dbe | ||
|
|
6bc3adbd33 | ||
|
|
c445a0732f | ||
|
|
20baed87ff | ||
|
|
ef9a2015eb | ||
|
|
583c71eca3 | ||
|
|
1e3c3c8f6c | ||
|
|
0a36166317 | ||
|
|
661f01319c | ||
|
|
6f8941d7be | ||
|
|
f7116edc9a | ||
|
|
ea69d56ab4 | ||
|
|
223e129477 | ||
|
|
4a5674a679 | ||
|
|
91c3a81cb1 | ||
|
|
e6921730c0 | ||
|
|
89ef0636a8 | ||
|
|
769ef001ec | ||
|
|
44b8ac79da | ||
|
|
5ada65b2a0 | ||
|
|
e433e7eda1 | ||
|
|
7de7dea1da | ||
|
|
86fb9bbd6c | ||
|
|
159474cf9b | ||
|
|
a2e7d4bdd1 | ||
|
|
b143a942ae | ||
|
|
c446dc263a | ||
|
|
60f718c2ce | ||
|
|
05df81be2f | ||
|
|
92cd62f0e9 | ||
|
|
602dfbabc2 | ||
|
|
c492a4122c | ||
|
|
fd55e84274 | ||
|
|
dba264cc5f | ||
|
|
7ff27a42ca | ||
|
|
8e1aa18b8f | ||
|
|
99cab7304d | ||
|
|
b5a9d52592 | ||
|
|
ca36938a83 | ||
|
|
de9d77a8ef | ||
|
|
0c3ebfbbb3 | ||
|
|
c575b861ed | ||
|
|
8323649c10 | ||
|
|
0dd84fad78 | ||
|
|
7dc25b3d33 | ||
|
|
5599e736ba | ||
|
|
e4e9a6b493 | ||
|
|
c50ee144df | ||
|
|
f5f32d902b | ||
|
|
f993d371a6 | ||
|
|
b5fb769bff | ||
|
|
28cbf6d803 | ||
|
|
89ddd937b0 | ||
|
|
5da950f61b | ||
|
|
7bff1b6c3c | ||
|
|
2baadd19cf | ||
|
|
2852aadc71 | ||
|
|
32e1854e13 | ||
|
|
b31daaa657 | ||
|
|
2401fc46ba | ||
|
|
0957d2d957 | ||
|
|
f8df9c502b | ||
|
|
52f071d8b0 | ||
|
|
a175ec5b5c | ||
|
|
f32ee0a9be | ||
|
|
f93702b82c | ||
|
|
0d4b006537 | ||
|
|
3f8477a442 | ||
|
|
bc9143b2a2 | ||
|
|
16c553196c | ||
|
|
b063ba412f | ||
|
|
aa406f191a | ||
|
|
f63b0cab81 | ||
|
|
beacc6ed1e | ||
|
|
028cfaa44c | ||
|
|
ac2949cf88 | ||
|
|
a098fae0f8 | ||
|
|
960e1f473f | ||
|
|
ffd72ed6f7 | ||
|
|
5eeab858d1 | ||
|
|
f64045168d | ||
|
|
f16d977e9b | ||
|
|
8274a69236 | ||
|
|
ce9f48e3d5 | ||
|
|
69735bdb22 | ||
|
|
f3e2143b4b | ||
|
|
2110eb9855 | ||
|
|
3c015990c5 | ||
|
|
0d3026b97d | ||
|
|
9cb8379c2d | ||
|
|
33c8ea7e2b | ||
|
|
561bed9fed | ||
|
|
7bdfa6a59d | ||
|
|
b887a941bf | ||
|
|
b76f5250a8 | ||
|
|
e372369b9e | ||
|
|
aaa0ea23e2 | ||
|
|
f8fe2cd532 | ||
|
|
bb362325ad | ||
|
|
56555ad550 | ||
|
|
456b441210 | ||
|
|
982594a4a5 | ||
|
|
eaded8dfd9 | ||
|
|
c583b95ee2 | ||
|
|
cda57dd733 | ||
|
|
60fbfce540 | ||
|
|
d8af2eaf4c | ||
|
|
9a571988ae | ||
|
|
b5b81a8bba | ||
|
|
e76551b4a8 | ||
|
|
74e071b1d7 | ||
|
|
327585327f | ||
|
|
4b445ca4df | ||
|
|
ef4a844233 | ||
|
|
80515658b0 | ||
|
|
e7d7dc6b6b | ||
|
|
aae0476967 | ||
|
|
aa6cf97324 | ||
|
|
8506671c5c | ||
|
|
022976d2f6 | ||
|
|
18d53167d0 | ||
|
|
9519bfddd4 | ||
|
|
0c556e32fa | ||
|
|
f9a02d9b3b | ||
|
|
3b6c986704 | ||
|
|
c78e8764a0 | ||
|
|
7f4d10c85d | ||
|
|
6b30ed0ba6 | ||
|
|
912d5c4d78 | ||
|
|
faa4bd82ae | ||
|
|
409f6b8dd5 | ||
|
|
4e79698b4e | ||
|
|
07b28605b5 | ||
|
|
c4403cc4de | ||
|
|
7e0742f29b | ||
|
|
b21aef221e | ||
|
|
20934f3ac8 | ||
|
|
715e497789 | ||
|
|
c0d389c283 | ||
|
|
6720575bd9 | ||
|
|
b1ec750d1d | ||
|
|
d9c78da997 | ||
|
|
1fa26e763d | ||
|
|
bb71b3d3d9 | ||
|
|
65e8e56e4a | ||
|
|
ee415d0e50 | ||
|
|
9db88a6c4f | ||
|
|
e5151a0a19 | ||
|
|
570234717a | ||
|
|
3f60d1f8de | ||
|
|
65d100c814 | ||
|
|
61bdab8621 | ||
|
|
8c94c9ecbf | ||
|
|
7fd895ecb5 | ||
|
|
213bc07646 | ||
|
|
1f081c181c | ||
|
|
db1d31900e | ||
|
|
2219c79ac3 | ||
|
|
d2782179aa | ||
|
|
d90a31afc7 | ||
|
|
1c236fe0f3 | ||
|
|
24165fb5f3 | ||
|
|
71da960386 | ||
|
|
a16c2d0a26 | ||
|
|
53de44a225 | ||
|
|
e32cff60e5 | ||
|
|
0ebba8d17c | ||
|
|
464db79c04 | ||
|
|
65019d8323 | ||
|
|
f131d316ca | ||
|
|
e6663c96b5 | ||
|
|
5f06d11fe9 | ||
|
|
6db2836662 | ||
|
|
cb209f8d98 | ||
|
|
5634e17a2e | ||
|
|
7c990edeaf | ||
|
|
9149f62760 | ||
|
|
872ec6b11c | ||
|
|
064a21a07c | ||
|
|
57c3c7b2e0 | ||
|
|
6dc75d7a44 | ||
|
|
5f3b7e05c5 | ||
|
|
61d3d8ad00 | ||
|
|
e36440f834 | ||
|
|
4029543d51 | ||
|
|
8aee9ac91f | ||
|
|
6e5e15f06f | ||
|
|
a72bd4cb7c | ||
|
|
d062ab553c | ||
|
|
a41edde12a | ||
|
|
7608927b4f | ||
|
|
24d7647c08 | ||
|
|
a14369cb7d | ||
|
|
3e81c4f3b6 | ||
|
|
84a6c19677 | ||
|
|
624a1d5509 | ||
|
|
8b6d35cfe2 | ||
|
|
867e502448 | ||
|
|
74275b969e | ||
|
|
275dde0630 | ||
|
|
46b470f30b | ||
|
|
fbfec95a8a | ||
|
|
1e75a18f55 | ||
|
|
a98849bf30 | ||
|
|
1b998e9d01 | ||
|
|
ebd6d3f3f9 | ||
|
|
1204afadbb | ||
|
|
cd08b72df7 | ||
|
|
c5ad7c98e2 | ||
|
|
4b8222d792 | ||
|
|
a3524ef8aa | ||
|
|
49ab8aa67d | ||
|
|
fa85639efb | ||
|
|
84bca81a9a | ||
|
|
206ea72a27 | ||
|
|
cc2aceb3d8 | ||
|
|
00b95aa6c3 | ||
|
|
a7632cc1e5 | ||
|
|
39f07025c6 | ||
|
|
658cb0403f | ||
|
|
db1519bcea | ||
|
|
885d1cc77c | ||
|
|
4005becd0a | ||
|
|
4c16b53c2a | ||
|
|
93dbf13d88 | ||
|
|
c5dd1a9002 | ||
|
|
18af7d6392 | ||
|
|
dbeb31d4fe | ||
|
|
0fb9234ebc | ||
|
|
6e4cec829a | ||
|
|
5a433c42e7 | ||
|
|
f808dd131b | ||
|
|
1bbf9a2318 | ||
|
|
ccff70d9c4 | ||
|
|
0347ca1bcc | ||
|
|
23564c14f9 | ||
|
|
93fca13510 | ||
|
|
1a39066cfc | ||
|
|
70b1574aa6 | ||
|
|
898f814dce | ||
|
|
9d425d33ab | ||
|
|
c6b0f58074 | ||
|
|
78b3802f8f | ||
|
|
833b90ccff | ||
|
|
c27a381286 | ||
|
|
ad29b43836 | ||
|
|
5cd9c51410 | ||
|
|
6476d42371 | ||
|
|
a48d9006ad | ||
|
|
c5c2057859 | ||
|
|
1889ab4825 | ||
|
|
0c81560f2c | ||
|
|
26005d6584 | ||
|
|
b0b0bcbdb7 | ||
|
|
546099fa2d | ||
|
|
c53fde6dca | ||
|
|
c9aeefc67a | ||
|
|
37594d3384 | ||
|
|
00dc5d8c38 | ||
|
|
ff8f6b1d00 | ||
|
|
aa60ba1fb1 | ||
|
|
3b8341f249 | ||
|
|
a68bcb6ee1 | ||
|
|
24a52a294c | ||
|
|
03ea67b20b | ||
|
|
4f20003f9a | ||
|
|
a92d13652d | ||
|
|
cf25fbc75d | ||
|
|
c1fb54ff1c | ||
|
|
bc484864ad | ||
|
|
465ffaa5a7 | ||
|
|
357c6bef0b | ||
|
|
d5de7f9b55 | ||
|
|
809beedcf8 | ||
|
|
40071ffaf9 | ||
|
|
501e3c1217 | ||
|
|
ebeb8a13a4 | ||
|
|
8be598c533 | ||
|
|
cf6a6a876f | ||
|
|
ea842a95ca | ||
|
|
f6f1328721 | ||
|
|
1e2901810f | ||
|
|
c5f67d070d | ||
|
|
527d2087c1 | ||
|
|
f6b5c0190d | ||
|
|
a5225dfc2e | ||
|
|
7920f9ef2a | ||
|
|
37d9cf11c3 | ||
|
|
fdc5078a85 | ||
|
|
e4332642f4 | ||
|
|
2d74797e86 | ||
|
|
97ccb60326 | ||
|
|
f9d331041d | ||
|
|
4b76e156a9 | ||
|
|
65660e57ca | ||
|
|
48e2722d9e | ||
|
|
91ec945a37 | ||
|
|
ba62e297b5 | ||
|
|
7f3d89aaa7 | ||
|
|
b7321e3121 | ||
|
|
2ecf0bcb41 | ||
|
|
fec92ea03e | ||
|
|
6a8ec5869b | ||
|
|
e7f585690d | ||
|
|
a76064e46c | ||
|
|
db2407dd75 | ||
|
|
d48a1bddde | ||
|
|
f83fc7fe4e | ||
|
|
3bd6f53788 | ||
|
|
37638369c1 | ||
|
|
39c5242f30 | ||
|
|
5d69984ef9 | ||
|
|
1f734d662e | ||
|
|
ff3855a436 | ||
|
|
30be69f08b | ||
|
|
e1bd9eabe7 | ||
|
|
997c78a08b | ||
|
|
e35d2277c3 | ||
|
|
f29352b864 | ||
|
|
2ca60b09d4 | ||
|
|
8ddf6ceb1f | ||
|
|
aabcc86d9c | ||
|
|
ecae9d0996 | ||
|
|
8eb0b02adb | ||
|
|
33417d4b7d | ||
|
|
ce296e691b | ||
|
|
eb49d303ad | ||
|
|
cf17287794 | ||
|
|
8be00cbee8 | ||
|
|
592f4a8bac | ||
|
|
42cb1f784a | ||
|
|
38529e6809 | ||
|
|
c1fe7df02d | ||
|
|
5c31f511f6 | ||
|
|
fc1c1ae31a | ||
|
|
ac0c61de98 | ||
|
|
fdb02cedea | ||
|
|
68fa9beb22 | ||
|
|
3075929824 | ||
|
|
754481ab13 | ||
|
|
8bf2d81998 | ||
|
|
80f795d32c | ||
|
|
9e6f2a4952 | ||
|
|
0146153d0a | ||
|
|
28079165c4 | ||
|
|
40884a7588 | ||
|
|
9b990c03a6 | ||
|
|
a72bbf29f6 | ||
|
|
7f740b1fc3 | ||
|
|
8ae0e6d4d3 | ||
|
|
f15ad447ca | ||
|
|
67c29560db | ||
|
|
8e787dca86 | ||
|
|
f06dd86a99 | ||
|
|
73f3cc64a5 | ||
|
|
2601de5c30 | ||
|
|
ee3ccfa598 | ||
|
|
396cbb7b64 | ||
|
|
c64f08b2b5 | ||
|
|
3cf82f4dac | ||
|
|
a43256e802 | ||
|
|
6f91694d51 | ||
|
|
d25ce692f9 | ||
|
|
f495fc01bc | ||
|
|
4aacbd2731 | ||
|
|
bb7b1f64ce | ||
|
|
548e0a9821 | ||
|
|
af23e322a1 | ||
|
|
b467e2db41 | ||
|
|
13f34a17b1 | ||
|
|
d0baeec53d | ||
|
|
6cb283cdf2 | ||
|
|
43f5fe421b | ||
|
|
09c041056e | ||
|
|
fda4658e92 |
+14
-9
@@ -1,3 +1,13 @@
|
|||||||
|
[Tt]est[Rr]esult*
|
||||||
|
/cache
|
||||||
|
/logs
|
||||||
|
.project
|
||||||
|
.pydevproject
|
||||||
|
|
||||||
|
# coverage generated:
|
||||||
|
/cover-html/
|
||||||
|
.coverage
|
||||||
|
.coveralls.yml
|
||||||
|
|
||||||
# Compiled source #
|
# Compiled source #
|
||||||
###################
|
###################
|
||||||
@@ -6,12 +16,13 @@
|
|||||||
*.pyproj
|
*.pyproj
|
||||||
*.sln
|
*.sln
|
||||||
|
|
||||||
# Logs and databases #
|
# Headphones files #
|
||||||
######################
|
######################
|
||||||
*.log
|
*.log
|
||||||
*.db*
|
*.db*
|
||||||
*.db-journal
|
*.db-journal
|
||||||
*.ini
|
*.ini
|
||||||
|
version.lock
|
||||||
logs/*
|
logs/*
|
||||||
cache/*
|
cache/*
|
||||||
|
|
||||||
@@ -19,6 +30,7 @@ cache/*
|
|||||||
##################
|
##################
|
||||||
*.crt
|
*.crt
|
||||||
*.key
|
*.key
|
||||||
|
*.csr
|
||||||
|
|
||||||
# OS generated files #
|
# OS generated files #
|
||||||
######################
|
######################
|
||||||
@@ -59,11 +71,4 @@ Thumbs.db
|
|||||||
obj/
|
obj/
|
||||||
[Rr]elease*/
|
[Rr]elease*/
|
||||||
_ReSharper*/
|
_ReSharper*/
|
||||||
[Tt]est[Rr]esult*
|
.vscode
|
||||||
/cache
|
|
||||||
/logs
|
|
||||||
.project
|
|
||||||
.pydevproject
|
|
||||||
|
|
||||||
|
|
||||||
headphones_docs
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
[pep8]
|
[pep8]
|
||||||
# E111 indentation is not a multiple of four
|
|
||||||
# E121 continuation line under-indented for hanging indent
|
# E121 continuation line under-indented for hanging indent
|
||||||
# E122 continuation line missing indentation or outdented
|
# E122 continuation line missing indentation or outdented
|
||||||
# E124 closing bracket does not match visual indentation
|
# E124 closing bracket does not match visual indentation
|
||||||
@@ -10,8 +9,7 @@
|
|||||||
# E261 at least two spaces before inline comment
|
# E261 at least two spaces before inline comment
|
||||||
# E262 inline comment should start with '# '
|
# E262 inline comment should start with '# '
|
||||||
# E265 block comment should start with '# '
|
# E265 block comment should start with '# '
|
||||||
# E302 expected 2 blank lines, found 1
|
|
||||||
# E501 line too long (312 > 160 characters)
|
# E501 line too long (312 > 160 characters)
|
||||||
# E502 the backslash is redundant between brackets
|
# E502 the backslash is redundant between brackets
|
||||||
ignore = E111,E121,E122,E123,E124,E125,E126,E127,E128,E261,E262,E265,E302,E501,E502
|
ignore = E121,E122,E123,E124,E125,E126,E127,E128,E261,E262,E265,E501,E502
|
||||||
max-line-length = 160
|
max-line-length = 160
|
||||||
+21
-4
@@ -2,19 +2,36 @@
|
|||||||
# http://about.travis-ci.org/docs/
|
# http://about.travis-ci.org/docs/
|
||||||
|
|
||||||
language: python
|
language: python
|
||||||
|
sudo: false
|
||||||
|
|
||||||
|
cache:
|
||||||
|
pip: true
|
||||||
|
directories:
|
||||||
|
- lib
|
||||||
|
|
||||||
# Available Python versions:
|
# Available Python versions:
|
||||||
# http://about.travis-ci.org/docs/user/ci-environment/#Python-VM-images
|
# http://about.travis-ci.org/docs/user/ci-environment/#Python-VM-images
|
||||||
python:
|
python:
|
||||||
- "2.6"
|
- "2.6"
|
||||||
- "2.7"
|
matrix:
|
||||||
|
include:
|
||||||
|
- python: "2.7"
|
||||||
|
env: SENDCOVERAGE=1
|
||||||
|
|
||||||
|
# pylint 1.4 does not run under python 2.6
|
||||||
install:
|
install:
|
||||||
- pip install pyOpenSSL
|
- pip install pyOpenSSL
|
||||||
- pip install pylint
|
- pip install pylint==1.3.1
|
||||||
- pip install pyflakes
|
- pip install pyflakes
|
||||||
- pip install pep8
|
- pip install pep8
|
||||||
|
# coverage stuff:
|
||||||
|
- pip install coveralls
|
||||||
|
- pip install coverage
|
||||||
script:
|
script:
|
||||||
- pep8 headphones
|
- pep8 headphones
|
||||||
- pylint --rcfile=pylintrc headphones
|
|
||||||
- pyflakes headphones
|
- pyflakes headphones
|
||||||
- nosetests headphones
|
- nosetests
|
||||||
|
|
||||||
|
after_success:
|
||||||
|
# coverage stuff:
|
||||||
|
- if [ $SENDCOVERAGE ]; then coveralls; fi
|
||||||
|
|||||||
@@ -66,8 +66,10 @@ Unmark album as wanted / i.e. mark as skipped
|
|||||||
|
|
||||||
### forceSearch
|
### forceSearch
|
||||||
force search for wanted albums - not launched in a separate thread so it may take a bit to complete
|
force search for wanted albums - not launched in a separate thread so it may take a bit to complete
|
||||||
### forceProcess
|
### forceProcess[&dir=/path/to/folder]
|
||||||
Force post process albums in download directory - also not launched in a separate thread
|
Force post process albums in download directory - also not launched in a separate thread
|
||||||
|
### forceActiveArtistsUpdate
|
||||||
|
force Active Artist Update - also not launched in a separate thread
|
||||||
|
|
||||||
### getVersion
|
### getVersion
|
||||||
Returns some version information: git_path, install_type, current_version, installed_version, commits_behind
|
Returns some version information: git_path, install_type, current_version, installed_version, commits_behind
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
v0.5 Released 10 Nov 2014
|
|
||||||
-------------------------
|
|
||||||
- Several bug fixes (please retest your posted issues)
|
|
||||||
- Cue splitter
|
|
||||||
- Other improvements
|
|
||||||
|
|
||||||
|
|
||||||
+300
@@ -0,0 +1,300 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## v0.5.13
|
||||||
|
Released 25 February 2016
|
||||||
|
|
||||||
|
Another hotfix update
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Fixed: Saving config with non-defined options
|
||||||
|
* Fixed: Pep8 errors
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.12...v0.5.13).
|
||||||
|
|
||||||
|
## v0.5.12
|
||||||
|
Released 25 February 2016
|
||||||
|
|
||||||
|
This is mostly a hotfix update
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Added: Experimental Deluge Support
|
||||||
|
* Fixed: Some pep8 stuff
|
||||||
|
* Improved: Use curly braces for pathrender optional variables
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.11...v0.5.12).
|
||||||
|
|
||||||
|
## v0.5.11
|
||||||
|
Released 20 February 2016
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Added: Soft chroot option
|
||||||
|
* Fixed: Post processing temporary directory fix (#2504)
|
||||||
|
* Fixed: Ubuntu init script (#2509)
|
||||||
|
* Fixed: Image cache uncaught exception (#2485)
|
||||||
|
* Improved: $Date/$date variable in folder renaming
|
||||||
|
* Improved: Reuse transmission session id
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.10...v0.5.11).
|
||||||
|
|
||||||
|
## v0.5.10
|
||||||
|
Released 29 January 2016
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Added: API option to post-process single folders
|
||||||
|
* Added: Ability to specify extension when re-encoding
|
||||||
|
* Added: Option to stop renaming folders
|
||||||
|
* Fixed: Utorrent torrents not being removed (#2385)
|
||||||
|
* Fixed: Torznab to transmission
|
||||||
|
* Fixed: Magnet folder names in history
|
||||||
|
* Fixed: Multiple torcache fixes
|
||||||
|
* Fixed: Updated requests & urllib3 to latest versions to fix errors with pyOpenSSL
|
||||||
|
* Improved: Use a temporary folder during post-processing
|
||||||
|
* Improved: Added verify_ssl_cert option
|
||||||
|
* Improved: Fixed track matching progress
|
||||||
|
* Improved: pylint, pep8 & pylint fixes
|
||||||
|
* Improved: Stop JS links from scrolling to the top of the page
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.9...v0.5.10).
|
||||||
|
|
||||||
|
## v0.5.9
|
||||||
|
Released 05 September 2015
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Added: Providers Strike, Jackett, custom Torznabs
|
||||||
|
* Added: Option to stop post-processing if no good match found (#2343)
|
||||||
|
* Fixed: Blackhole -> Magnet, limit to torcache
|
||||||
|
* Fixed: Kat 403 flac error
|
||||||
|
* Fixed: Last.fm errors
|
||||||
|
* Fixed: Pushover notifications
|
||||||
|
* Improved: Rutracker logging, switched to requests lib
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.8...v0.5.9).
|
||||||
|
|
||||||
|
## v0.5.8
|
||||||
|
Released 13 July 2015
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Added: Option to only include official extras
|
||||||
|
* Added: Option to wait until album release date before searching
|
||||||
|
* Fixed: NotifyMyAndroid notifications
|
||||||
|
* Fixed: Plex Notifications
|
||||||
|
* Fixed: Metacritic parsing
|
||||||
|
* Fixed: Pushbullet notifications
|
||||||
|
* Fixed: What.cd not honoring custom search term (#2279)
|
||||||
|
* Improved: XSS Search bug
|
||||||
|
* Improved: Config page layout
|
||||||
|
* Improved: Set localhost as default
|
||||||
|
* Improved: Better single artist scanning
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.7...v0.5.8).
|
||||||
|
|
||||||
|
## v0.5.7
|
||||||
|
Released 01 July 2015
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Improved: Moved pushover to use requests lib
|
||||||
|
* Improved: Plex tokens with Plex Home
|
||||||
|
* Improved: Added getLogs & clearLogs to api
|
||||||
|
* Improved: Cache MetaCritic scores. Added user-agent header to fix 403 errors
|
||||||
|
* Improved: Specify whether to delete folders when force post-processing
|
||||||
|
* Improved: Convert target bitrate to vbr preset for what.cd searching
|
||||||
|
* Improved: Switched Pushover to requests lib
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.6...v0.5.7).
|
||||||
|
|
||||||
|
## v0.5.6
|
||||||
|
Released 08 June 2015
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Added: Metacritic scores
|
||||||
|
* Added: Series support (e.g. Cafe Del Mar, Now That's What I Call Music, etc)
|
||||||
|
* Added: Filter out clean/edited/censored releases (#2198)
|
||||||
|
* Added: Button on the log page to toggle verbose/debug logging
|
||||||
|
* Fixed: Connecting to SABnzbd over https with python >= 2.7.9
|
||||||
|
* Fixed: Email Notifications with SSL
|
||||||
|
* Fixed: Don't limit musicbrainz results to first 100
|
||||||
|
* Fixed: nzbget url fix
|
||||||
|
* Fixed: OSX Notifications
|
||||||
|
* Improved: Cuesplit, allow wav, ape to be split
|
||||||
|
* Improved: Moved the 'freeze db' option to the advanced->misc. tab
|
||||||
|
* Improved: Moved kickass searching to json api, so it doesn't throw 404 errors anymore when there are no results
|
||||||
|
* Improved: SSL for headphones indexer
|
||||||
|
* Improved: Disable update dialog box if check_github is diabled
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.5...v0.5.6).
|
||||||
|
|
||||||
|
## v0.5.5
|
||||||
|
Released 04 May 2015
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Added: force ID3v2.3 during post processing (#2121)
|
||||||
|
* Added: MusicBrainz authentication (#2125)
|
||||||
|
* Added: Email notifications (addresses #1045)
|
||||||
|
* Fixed: Kickass url updated to kickass.to (#2119)
|
||||||
|
* Fixed: Piratebay returning 0 bytes for all files
|
||||||
|
* Fixed: Albums stopped automatically refreshing when adding an artist
|
||||||
|
* Fixed: Min/max sizes for target bitrate
|
||||||
|
* Fixed: Don't filter any results if looking for a specific download
|
||||||
|
* Fixed: Sort by size in the specific download table
|
||||||
|
* Fixed: Deal with beets recommendation.none correctly
|
||||||
|
* Improved: Close dialog window automatically when choosing a specific download
|
||||||
|
* Improved: Move some repetitive log messages to debug level
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.4...v0.5.5).
|
||||||
|
|
||||||
|
## v0.5.4
|
||||||
|
Released 05 February 2015
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Added: backported 'Scan Artist' feature from `sarakha63/headphones`
|
||||||
|
* Fixed: change file permissions of cache files according to settings (#2102)
|
||||||
|
* Fixed: hide Songkick if no regional events are available
|
||||||
|
* Fixed: only reschedule jobs if timeout changed (#2099)
|
||||||
|
* Fixed: limit dialog height (#2096)
|
||||||
|
* Improved: upgraded requests to 2.5.1
|
||||||
|
* Improved: upgraded unidecode to 0.04.17
|
||||||
|
* Improved: upgraded tzlocal to 1.1.2
|
||||||
|
* Improved: upgraded PyTZ to 2014.10
|
||||||
|
* Improved: upgraded mako to 1.0.1
|
||||||
|
* Improved: upgraded mutagen to 1.27
|
||||||
|
* Improved: upgraded beets to 1.3.10
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.3...v0.5.4).
|
||||||
|
|
||||||
|
## v0.5.3
|
||||||
|
Released 15 January 2015
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Added: update active artists via API (#2075)
|
||||||
|
* Fixed: queue instance lacks `add` method (#2055)
|
||||||
|
* Improved: better SSL error messages (#2058)
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.2...v0.5.3).
|
||||||
|
|
||||||
|
## v0.5.2
|
||||||
|
Released 28 December 2014
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Added: advanced option to ignore certain folders by patterns. (#2037)
|
||||||
|
* Added: advanced option to ignore certain files by patterns (library only)
|
||||||
|
* Added: specify optional paths to CUE splitting tools (#1938)
|
||||||
|
* Added: experimental support for OPB (provide details yourself!)
|
||||||
|
* Fixed: magnet to torrent conversion (#1926)
|
||||||
|
* Fixed: new KAT URL (#2043)
|
||||||
|
* Fixed: LMS notifications (#1564)
|
||||||
|
* Improved: notify user of SSL-related warnings, instead of silently failing.
|
||||||
|
* Improved: show all search results for 'Choose Specific Release'
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.1...v0.5.2).
|
||||||
|
|
||||||
|
## v0.5.1
|
||||||
|
Released 13 November 2014
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Added: allow one to disable interval tasks (#2002)
|
||||||
|
* Added: script to downgrade Headphones to last version that started (Linux only)
|
||||||
|
* Fixed: SSL issues in CherryPy. Self-generated certificates will be 2048 now (#1995)
|
||||||
|
* Fixed: Transmission URL detection (#1998)
|
||||||
|
* Fixed: missing dependencies for APScheduler and CherryPy (#2001)
|
||||||
|
* Improved: symlink infinite recursion detection
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5...v0.5.1).
|
||||||
|
|
||||||
|
## v0.5
|
||||||
|
Released 10 November 2014
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Added: CUE splitter
|
||||||
|
* Added: filter search result by MusicBrainz Release Group ID
|
||||||
|
* Added: follow symlinks while scanning library (#1953)
|
||||||
|
* Fixed: crash during post processing (#1897)
|
||||||
|
* Fixed: embedding lyrics (#1896)
|
||||||
|
* Fixed: HTTP errors with older versions of Python 2.6
|
||||||
|
* Fixed: jump back to top of page (#1948)
|
||||||
|
* Improved: parse MusicBrainz RGID first when post processing (#1952)
|
||||||
|
* Improved: Growl unicode characters (#1695)
|
||||||
|
* Improved: search handling for PB and KAT
|
||||||
|
* Improved: Last.FM API support (#1877)
|
||||||
|
* Improved: upgraded CherryPy to version 3.6.0
|
||||||
|
* Improved: upgraded Requests to version 2.4.1
|
||||||
|
* Improved: upgraded APScheduler to version 3.0.1
|
||||||
|
* Improved: lot of code refactoring
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.4...v0.5).
|
||||||
|
|
||||||
|
## v0.4
|
||||||
|
Released 20 September 2014
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Added: support for libav-tools (which replaces FFmpeg under Ubuntu)
|
||||||
|
* Added: option to freeze library when post processing
|
||||||
|
* Added: Songkick per area
|
||||||
|
* Added: rename original NFO file (#1797)
|
||||||
|
* Removed: removed dead search providers
|
||||||
|
* Fixed: removed left-overs of old packages
|
||||||
|
* Improved: rename by original folder (#1811)
|
||||||
|
* Improved: uTorrent fixess
|
||||||
|
* Improved: delayed loading of album art
|
||||||
|
* Improved: search result parser
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.3.4...v0.4).
|
||||||
|
|
||||||
|
## v0.3.4
|
||||||
|
Released 15 May 2014
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.3.3...v0.3.4).
|
||||||
|
|
||||||
|
## v0.3.3
|
||||||
|
Released 05 May 2014
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.3.2...v0.3.3).
|
||||||
|
|
||||||
|
## v0.3.2
|
||||||
|
Released 17 April 2014
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.3.1...v0.3.2).
|
||||||
|
|
||||||
|
## v0.3.1
|
||||||
|
Released 11 April 2014
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.3...v0.3.1).
|
||||||
|
|
||||||
|
## v0.3
|
||||||
|
Released 09 April 2014
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.2.3...v0.3).
|
||||||
|
|
||||||
|
## v0.2.3
|
||||||
|
Released 28 March 2014
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.2.2...v0.2.3).
|
||||||
|
|
||||||
|
## v0.2.2
|
||||||
|
Released 15 January 2014
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.2.1...v0.2.2).
|
||||||
|
|
||||||
|
## v0.2.1
|
||||||
|
Released 13 January 2014
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.2...v0.2.1).
|
||||||
|
|
||||||
|
## v0.2
|
||||||
|
Released 13 January 2014
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.1.2...v0.2).
|
||||||
|
|
||||||
|
## v0.1.2
|
||||||
|
Released 11 October 2013
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.1.1...v0.1.2).
|
||||||
|
|
||||||
|
## v0.1.1
|
||||||
|
Released 26 August 2013
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.1...v0.1.1).
|
||||||
|
|
||||||
|
## v0.1
|
||||||
|
Released 05 August 2013
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/2156e1341405d07c5bcfbe994f6b354b32d94cda...v0.1).
|
||||||
+25
-8
@@ -1,26 +1,28 @@
|
|||||||
# Contributing to Headphones
|
# Contributing to Headphones
|
||||||
|
|
||||||
## For users
|
## Issues
|
||||||
In case you read this because you are posting an issue, please take a minute and conside the things below. The issue tracker is not a support forum. It is primarily intended to submit bugs, improvements or feature requests. However, we are glad to help you, and make sure the problem is not caused by Headphones, but don't expect step-by-step answers.
|
In case you read this because you are posting an issue, please take a minute and conside the things below. The issue tracker is not a support forum. It is primarily intended to submit bugs, improvements or feature requests. However, we are glad to help you, and make sure the problem is not caused by Headphones, but don't expect step-by-step answers.
|
||||||
|
|
||||||
* Use the search function. Chances are that your problem is already discussed.
|
* Use the search function. Chances are that your problem is already discussed. Do not append to (closed) issues if your problem does not fit the discussion.
|
||||||
* Visit the [Troubleshooting](../../wiki/TroubleShooting) wiki first.
|
* Visit the [Troubleshooting](../../wiki/TroubleShooting) wiki first.
|
||||||
* Use [proper formatting](https://help.github.com/articles/github-flavored-markdown/). Paste your logs in code blocks.
|
* Use [proper formatting](https://help.github.com/articles/github-flavored-markdown/). Paste your logs in code blocks.
|
||||||
* Close your issue if you resolved it.
|
* Close your issue if you resolved it.
|
||||||
|
|
||||||
## For developers
|
## Pull Requests
|
||||||
If you think you can contribute code to the Headphones repository, do not hesitate to submit a pull request.
|
If you think you can contribute code to the Headphones repository, do not hesitate to submit a pull request.
|
||||||
|
|
||||||
### Branches
|
### Branches
|
||||||
All pull requests should be based on the `develop` branch. When you want to develop a new feature, clone the repository with `git clone origin/develop -b FEATURE_NAME`. Use meaningful commit messages.
|
All pull requests should be based on the `develop` branch, to minimize cross merges. When you want to develop a new feature, clone the repository with `git clone origin/develop -b FEATURE_NAME`. Use meaningful commit messages.
|
||||||
|
|
||||||
### Code compatibility
|
### Python Code
|
||||||
|
|
||||||
|
#### Compatibility
|
||||||
The code should work with Python 2.6 and 2.7. Note that Headphones runs on different platforms, including Network Attached Storage devices such as Synology.
|
The code should work with Python 2.6 and 2.7. Note that Headphones runs on different platforms, including Network Attached Storage devices such as Synology.
|
||||||
|
|
||||||
Re-use existing code. Do not hesitate to add logging in your code. You can the logger module `headphones.logger.*` for this. Web requests are invoked via `headphones.request.*` and derived ones. Use these methods to automatically add proper and meaningful error handling.
|
Re-use existing code. Do not hesitate to add logging in your code. You can the logger module `headphones.logger.*` for this. Web requests are invoked via `headphones.request.*` and derived ones. Use these methods to automatically add proper and meaningful error handling.
|
||||||
|
|
||||||
### Code conventions
|
#### Code conventions
|
||||||
Altough Headphones did not adapt a code convention in the past, we try to follow the [PEP8](http://legacy.python.org/dev/peps/pep-0008/) conventions for future code. A short summary to remind you (copied from http://wiki.ros.org/PyStyleGuide):
|
Although Headphones did not adapt a code convention in the past, we try to follow the [PEP8](http://legacy.python.org/dev/peps/pep-0008/) conventions for future code. A short summary to remind you (copied from http://wiki.ros.org/PyStyleGuide):
|
||||||
|
|
||||||
* 4 space indentation
|
* 4 space indentation
|
||||||
* 80 characters per line
|
* 80 characters per line
|
||||||
@@ -32,4 +34,19 @@ Altough Headphones did not adapt a code convention in the past, we try to follow
|
|||||||
* `self.__really_private_field`
|
* `self.__really_private_field`
|
||||||
* `_global`
|
* `_global`
|
||||||
|
|
||||||
Document your code!
|
#### Documentation
|
||||||
|
Document your code. Use docstrings See [PEP-257](https://www.python.org/dev/peps/pep-0257/) for more information.
|
||||||
|
|
||||||
|
#### Continuous Integration
|
||||||
|
Headphones has a configuration file for [travis-ci](https://travis-ci.org/). You can add your forked repo to Travis to have it check your code against PEP8, PyLint, and PyFlakes for you. Your pull request will show a green check mark or a red cross on each tested commit, depending on if linting passes.
|
||||||
|
|
||||||
|
### HTML/Template code
|
||||||
|
|
||||||
|
#### Compatibility
|
||||||
|
HTML5 compatible browsers are targetted. There is no specific mobile version of Headphones yet.
|
||||||
|
|
||||||
|
#### Conventions
|
||||||
|
* 4 space indentation
|
||||||
|
* `methodName`
|
||||||
|
* `variableName`
|
||||||
|
* `ClassName`
|
||||||
+26
-9
@@ -89,6 +89,10 @@ def main():
|
|||||||
if args.quiet:
|
if args.quiet:
|
||||||
headphones.QUIET = True
|
headphones.QUIET = True
|
||||||
|
|
||||||
|
# Do an intial setup of the logger.
|
||||||
|
logger.initLogger(console=not headphones.QUIET, log_dir=False,
|
||||||
|
verbose=headphones.VERBOSE)
|
||||||
|
|
||||||
if args.daemon:
|
if args.daemon:
|
||||||
if sys.platform == 'win32':
|
if sys.platform == 'win32':
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
@@ -103,8 +107,8 @@ def main():
|
|||||||
# If the pidfile already exists, headphones may still be running, so
|
# If the pidfile already exists, headphones may still be running, so
|
||||||
# exit
|
# exit
|
||||||
if os.path.exists(headphones.PIDFILE):
|
if os.path.exists(headphones.PIDFILE):
|
||||||
sys.exit(
|
raise SystemExit("PID file '%s' already exists. Exiting." %
|
||||||
"PID file '" + headphones.PIDFILE + "' already exists. Exiting.")
|
headphones.PIDFILE)
|
||||||
|
|
||||||
# The pidfile is only useful in daemon mode, make sure we can write the
|
# The pidfile is only useful in daemon mode, make sure we can write the
|
||||||
# file properly
|
# file properly
|
||||||
@@ -115,11 +119,10 @@ def main():
|
|||||||
with open(headphones.PIDFILE, 'w') as fp:
|
with open(headphones.PIDFILE, 'w') as fp:
|
||||||
fp.write("pid\n")
|
fp.write("pid\n")
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
raise SystemExit(
|
raise SystemExit("Unable to write PID file: %s", e)
|
||||||
"Unable to write PID file: %s [%d]", e.strerror, e.errno)
|
|
||||||
else:
|
else:
|
||||||
logger.warn(
|
logger.warn("Not running in daemon mode. PID file creation " \
|
||||||
"Not running in daemon mode. PID file creation disabled.")
|
"disabled.")
|
||||||
|
|
||||||
# Determine which data directory and config file to use
|
# Determine which data directory and config file to use
|
||||||
if args.datadir:
|
if args.datadir:
|
||||||
@@ -149,7 +152,10 @@ def main():
|
|||||||
headphones.DB_FILE = os.path.join(headphones.DATA_DIR, 'headphones.db')
|
headphones.DB_FILE = os.path.join(headphones.DATA_DIR, 'headphones.db')
|
||||||
|
|
||||||
# Read config and start logging
|
# Read config and start logging
|
||||||
|
try:
|
||||||
headphones.initialize(config_file)
|
headphones.initialize(config_file)
|
||||||
|
except headphones.exceptions.SoftChrootError as e:
|
||||||
|
raise SystemExit('FATAL ERROR')
|
||||||
|
|
||||||
if headphones.DAEMON:
|
if headphones.DAEMON:
|
||||||
headphones.daemonize()
|
headphones.daemonize()
|
||||||
@@ -164,6 +170,16 @@ def main():
|
|||||||
else:
|
else:
|
||||||
http_port = int(headphones.CONFIG.HTTP_PORT)
|
http_port = int(headphones.CONFIG.HTTP_PORT)
|
||||||
|
|
||||||
|
# Check if pyOpenSSL is installed. It is required for certificate generation
|
||||||
|
# and for CherryPy.
|
||||||
|
if headphones.CONFIG.ENABLE_HTTPS:
|
||||||
|
try:
|
||||||
|
import OpenSSL
|
||||||
|
except ImportError:
|
||||||
|
logger.warn("The pyOpenSSL module is missing. Install this " \
|
||||||
|
"module to enable HTTPS. HTTPS will be disabled.")
|
||||||
|
headphones.CONFIG.ENABLE_HTTPS = False
|
||||||
|
|
||||||
# Try to start the server. Will exit here is address is already in use.
|
# Try to start the server. Will exit here is address is already in use.
|
||||||
web_config = {
|
web_config = {
|
||||||
'http_port': http_port,
|
'http_port': http_port,
|
||||||
@@ -178,13 +194,14 @@ def main():
|
|||||||
}
|
}
|
||||||
webstart.initialize(web_config)
|
webstart.initialize(web_config)
|
||||||
|
|
||||||
|
# Start the background threads
|
||||||
|
headphones.start()
|
||||||
|
|
||||||
|
# Open webbrowser
|
||||||
if headphones.CONFIG.LAUNCH_BROWSER and not args.nolaunch:
|
if headphones.CONFIG.LAUNCH_BROWSER and not args.nolaunch:
|
||||||
headphones.launch_browser(headphones.CONFIG.HTTP_HOST, http_port,
|
headphones.launch_browser(headphones.CONFIG.HTTP_HOST, http_port,
|
||||||
headphones.CONFIG.HTTP_ROOT)
|
headphones.CONFIG.HTTP_ROOT)
|
||||||
|
|
||||||
# Start the background threads
|
|
||||||
headphones.start()
|
|
||||||
|
|
||||||
# Wait endlessy for a signal to happen
|
# Wait endlessy for a signal to happen
|
||||||
while True:
|
while True:
|
||||||
if not headphones.SIGNAL:
|
if not headphones.SIGNAL:
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
# Headphones
|
## Headphones
|
||||||
|
|
||||||
Headphones is an automated music downloader for NZB and Torrent, written in Python. It supports SABnzbd, NZBget, Transmission, µTorrent and Blackhole.
|
**Master Branch:** [](https://travis-ci.org/rembo10/headphones)
|
||||||
|
**Develop Branch:** [](https://travis-ci.org/rembo10/headphones)
|
||||||
|
|
||||||
|
Headphones is an automated music downloader for NZB and Torrent, written in Python. It supports SABnzbd, NZBget, Transmission, µTorrent, Deluge and Blackhole.
|
||||||
|
|
||||||
## Support & Discuss
|
## Support & Discuss
|
||||||
You are free to join the Headphones support community on IRC where you can ask questions, hang around and discuss anything related to HP.
|
You are free to join the Headphones support community on IRC where you can ask questions, hang around and discuss anything related to HP.
|
||||||
@@ -10,7 +13,7 @@ You are free to join the Headphones support community on IRC where you can ask q
|
|||||||
|
|
||||||
## Installation and Notes
|
## Installation and Notes
|
||||||
|
|
||||||
* [Installation page](../../wiki/Usage-guide) shows you how to install Headphones.
|
* [Installation page](../../wiki/Installation) shows you how to install Headphones.
|
||||||
* [Usage guide](../../wiki/Usage-guide) introduces you to Headphones.
|
* [Usage guide](../../wiki/Usage-guide) introduces you to Headphones.
|
||||||
* [Troubleshooting page](../../wiki/TroubleShooting) in the wiki can help you with common problems.
|
* [Troubleshooting page](../../wiki/TroubleShooting) in the wiki can help you with common problems.
|
||||||
|
|
||||||
@@ -19,7 +22,7 @@ You are free to join the Headphones support community on IRC where you can ask q
|
|||||||
1. Analyze your log, you just might find the solution yourself!
|
1. Analyze your log, you just might find the solution yourself!
|
||||||
2. You read the wiki and searched existing issues, but this is not solving your problem.
|
2. You read the wiki and searched existing issues, but this is not solving your problem.
|
||||||
3. Post the issue with a clear title, description and the HP log and use [proper markdown syntax](https://help.github.com/articles/github-flavored-markdown) to structure your text (code/log in code blocks).
|
3. Post the issue with a clear title, description and the HP log and use [proper markdown syntax](https://help.github.com/articles/github-flavored-markdown) to structure your text (code/log in code blocks).
|
||||||
4. Close your issue when it's solved! If you found the solution yourself please comment so that others benefit from it.
|
4. Close your issue when it's solved! If you found the solution yourself, please comment so that others benefit from it.
|
||||||
|
|
||||||
**Feature requests** can be reported on the GitHub issue tracker too:
|
**Feature requests** can be reported on the GitHub issue tracker too:
|
||||||
|
|
||||||
|
|||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Display information
|
||||||
|
echo "This script will remove *.pyc files. These files are generated by Python, but they can cause conflicts after an upgrade. It's safe to remove them, because they will be regenerated."
|
||||||
|
echo "Press enter to continue, or CTRL + C to quit."
|
||||||
|
read
|
||||||
|
|
||||||
|
# Remove the *.pyc
|
||||||
|
find "`dirname $0`/.." -type f -name "*.pyc" -exec rm -rf {} \;
|
||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Parameter check
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
echo "Syntax: $0 <data directory>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Version file check
|
||||||
|
if [ ! -s "$1/version.lock" ]; then
|
||||||
|
echo "Missing the version.lock file in the data folder, or the file is empty. Did you start Headphones at least once?"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Git installation check
|
||||||
|
if [ ! -x "$(command -v git)" ]; then
|
||||||
|
echo "Git is required to downgrade."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Display information
|
||||||
|
HASH=$(cat $1/version.lock)
|
||||||
|
|
||||||
|
echo "This script will try to downgrade Headphones to the last version that started, version $HASH. Make sure you have a backup of your config file and database, just in case!"
|
||||||
|
echo "Press enter to continue, or CTRL + C to quit."
|
||||||
|
read
|
||||||
|
|
||||||
|
# Downgrade
|
||||||
|
cd "`dirname $0`/.."
|
||||||
|
git reset --hard "$HASH"
|
||||||
|
|
||||||
|
echo "All done, Headphones should be downgraded to the last version that started."
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Ensure that we use the Headphones provided libraries.
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../lib"))
|
||||||
|
|
||||||
|
import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def can_import(module):
|
||||||
|
"""
|
||||||
|
Return True if a given module can be imported or not.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
__import__(module)
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Module can be imported
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_installation():
|
||||||
|
"""
|
||||||
|
Check if some core modules are available. Info is based on this topic:
|
||||||
|
https://github.com/rembo10/headphones/issues/2210.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if can_import("requests"):
|
||||||
|
import requests
|
||||||
|
requests_version = requests.__version__
|
||||||
|
else:
|
||||||
|
requests_version = "no"
|
||||||
|
|
||||||
|
if can_import("OpenSSL"):
|
||||||
|
import OpenSSL
|
||||||
|
openssl_version = OpenSSL.__version__
|
||||||
|
else:
|
||||||
|
openssl_version = "no"
|
||||||
|
|
||||||
|
if can_import("cryptography"):
|
||||||
|
import cryptography
|
||||||
|
cryptography_version = cryptography.__version__
|
||||||
|
else:
|
||||||
|
cryptography_version = "no"
|
||||||
|
|
||||||
|
if can_import("pyasn1"):
|
||||||
|
import pyasn1
|
||||||
|
pyasn1_version = pyasn1.__version__
|
||||||
|
else:
|
||||||
|
pyasn1_version = "no"
|
||||||
|
|
||||||
|
if can_import("ndg.httpsclient"):
|
||||||
|
from ndg import httpsclient
|
||||||
|
ndg_version = httpsclient.__date__
|
||||||
|
else:
|
||||||
|
ndg_version = "no"
|
||||||
|
|
||||||
|
# Print some system information.
|
||||||
|
sys.stdout.write(
|
||||||
|
"* Checking Python version: %s.%s.%s\n" % sys.version_info[:3])
|
||||||
|
sys.stdout.write("* Operating system: %s\n" % sys.platform)
|
||||||
|
|
||||||
|
sys.stdout.write(
|
||||||
|
"* Checking if requests can be imported: %s\n" % requests_version)
|
||||||
|
sys.stdout.write(
|
||||||
|
"* Checking if pyOpenSSL is installed: %s\n" % openssl_version)
|
||||||
|
sys.stdout.write(
|
||||||
|
"* Checking if cryptography is installed: %s\n" % cryptography_version)
|
||||||
|
sys.stdout.write(
|
||||||
|
"* Checking if pyasn1 is installed: %s\n" % pyasn1_version)
|
||||||
|
sys.stdout.write(
|
||||||
|
"* Checking if ndg.httpsclient is installed: %s\n" % ndg_version)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""
|
||||||
|
Test if the current Headphones installation can connect to SNI-enabled
|
||||||
|
servers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Read the URL to test.
|
||||||
|
if len(sys.argv) == 1:
|
||||||
|
url = "https://sni.velox.ch/"
|
||||||
|
else:
|
||||||
|
url = sys.argv[1]
|
||||||
|
|
||||||
|
# Check if it is a HTTPS website.
|
||||||
|
parts = urlparse.urlparse(url)
|
||||||
|
|
||||||
|
if parts.scheme.lower() != "https":
|
||||||
|
sys.stderr.write(
|
||||||
|
"Error: provided URL does not start with https://\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Gather information
|
||||||
|
check_installation()
|
||||||
|
|
||||||
|
# Do the request.
|
||||||
|
if not can_import("requests"):
|
||||||
|
sys.stderr.exit("Error: cannot continue without requests module!\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
sys.stdout.write("* Performing request: %s\n" % url)
|
||||||
|
|
||||||
|
import requests
|
||||||
|
requests.packages.urllib3.disable_warnings()
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
response = requests.get(url)
|
||||||
|
except requests.exceptions.SSLError as e:
|
||||||
|
sys.stdout.write(
|
||||||
|
"- Server certificate seems invalid. I will disable "
|
||||||
|
"certificate check and try again. You'll see the real "
|
||||||
|
"exception if it fails again.\n")
|
||||||
|
sys.stdout.write(
|
||||||
|
"* Retrying request with certificate verification off.\n")
|
||||||
|
response = requests.get(url)
|
||||||
|
except Exception as e:
|
||||||
|
sys.stdout.write(
|
||||||
|
"- An error occured while performing the request. The "
|
||||||
|
"exception was: %s\n" % e.message)
|
||||||
|
sys.stdout.write(
|
||||||
|
"- Consult the Troubleshooting wiki (https://github.com/"
|
||||||
|
"rembo10/headphones/wiki/Troubleshooting) before you post an "
|
||||||
|
"issue!")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Verify the response.
|
||||||
|
if response.status_code == 200:
|
||||||
|
sys.stdout.write("+ Got a valid response. All seems OK!\n")
|
||||||
|
else:
|
||||||
|
sys.stdout.write(
|
||||||
|
"- Server returned status code %s. Expected a status code 200.\n",
|
||||||
|
response.status_code)
|
||||||
|
sys.stdout.write(
|
||||||
|
"- However, I was able to communicate to the server!\n")
|
||||||
|
|
||||||
|
# E.g. `python sni_test.py https://example.org'.
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -14,15 +14,15 @@
|
|||||||
|
|
||||||
<a id="menu_link_delete" href="deleteAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}"><i class="fa fa-trash-o"></i> Delete Album</a>
|
<a id="menu_link_delete" href="deleteAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}"><i class="fa fa-trash-o"></i> Delete Album</a>
|
||||||
%if album['Status'] == 'Skipped' or album['Status'] == 'Ignored':
|
%if album['Status'] == 'Skipped' or album['Status'] == 'Ignored':
|
||||||
<a id="menu_link_wanted" href="#" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}&new=False', $(this),true)" data-success="'${album['AlbumTitle']}' added to queue"><i class="fa fa-heart"></i> Mark Album as Wanted</a>
|
<a id="menu_link_wanted" href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}&new=False', $(this),true)" data-success="'${album['AlbumTitle']}' added to queue"><i class="fa fa-heart"></i> Mark Album as Wanted</a>
|
||||||
%elif album['Status'] == 'Wanted':
|
%elif album['Status'] == 'Wanted':
|
||||||
<a id="menu_link_check" href="#" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}&new=True', $(this));" data-success="Forced checking successful"><i class="fa fa-search"></i> Force Check</a>
|
<a id="menu_link_check" href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}&new=True', $(this));" data-success="Forced checking successful"><i class="fa fa-search"></i> Force Check</a>
|
||||||
<a id="menu_link_skipped" href="#" onclick="doAjaxCall('unqueueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}', $(this),true);" data-success="'${album['AlbumTitle']}' marked as Skipped"><i class="fa fa-step-forward"></i> Mark Album as Skipped</a>
|
<a id="menu_link_skipped" href="javascript:void(0)" onclick="doAjaxCall('unqueueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}', $(this),true);" data-success="'${album['AlbumTitle']}' marked as Skipped"><i class="fa fa-step-forward"></i> Mark Album as Skipped</a>
|
||||||
%else:
|
%else:
|
||||||
<a id="menu_link_retry" href="#" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}&new=False', $(this),true);" data-success="Retrying the same version of '${album['AlbumTitle']}'"><i class="fa fa-refresh"></i> Retry Download</a>
|
<a id="menu_link_retry" href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}&new=False', $(this),true);" data-success="Retrying the same version of '${album['AlbumTitle']}'"><i class="fa fa-refresh"></i> Retry Download</a>
|
||||||
<a id="menu_link_new" href="#" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}&new=True', $(this),true);" data-success="Looking for a new version of '${album['AlbumTitle']}'"><i class="fa fa-download"></i> Try New Version</a>
|
<a id="menu_link_new" href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}&new=True', $(this),true);" data-success="Looking for a new version of '${album['AlbumTitle']}'"><i class="fa fa-download"></i> Try New Version</a>
|
||||||
%endif
|
%endif
|
||||||
<a class="menu_link_edit" id="album_chooser" href="#"><i class="fa fa-pencil"></i> Choose Alternate Release</a>
|
<a class="menu_link_edit" id="album_chooser" href="javascript:void(0)"><i class="fa fa-pencil"></i> Choose Alternate Release</a>
|
||||||
<div id="dialog" title="Choose an Alternate Release" style="display:none" class="configtable">
|
<div id="dialog" title="Choose an Alternate Release" style="display:none" class="configtable">
|
||||||
<div class="links">
|
<div class="links">
|
||||||
<%
|
<%
|
||||||
@@ -30,11 +30,12 @@
|
|||||||
%>
|
%>
|
||||||
%if not alternate_albums:
|
%if not alternate_albums:
|
||||||
<p>No alternate releases found. Try refreshing the artist (if the artist is being refreshed, please wait until it's finished)</p>
|
<p>No alternate releases found. Try refreshing the artist (if the artist is being refreshed, please wait until it's finished)</p>
|
||||||
<h2><a id="refresh_artist" onclick="doAjaxCall('refreshArtist?ArtistID=${album['ArtistID']}', $(this)), true" href="#" data-success="'${album['ArtistName']}' is being refreshed">Refresh Artist</a></h2>
|
<h2><a id="refresh_artist" onclick="doAjaxCall('refreshArtist?ArtistID=${album['ArtistID']}', $(this)), true" href="javascript:void(0)" data-success="'${album['ArtistName']}' is being refreshed">Refresh Artist</a></h2>
|
||||||
%else:
|
%else:
|
||||||
%for alternate_album in alternate_albums:
|
%for alternate_album in alternate_albums:
|
||||||
<%
|
<%
|
||||||
track_count = len(myDB.select("SELECT * from alltracks WHERE ReleaseID=?", [alternate_album['ReleaseID']]))
|
track_count = len(myDB.select("SELECT * from alltracks WHERE ReleaseID=?", [alternate_album['ReleaseID']]))
|
||||||
|
mb_link = "http://musicbrainz.org/release/" + alternate_album['ReleaseID']
|
||||||
have_track_count = len(myDB.select("SELECT * from alltracks WHERE ReleaseID=? AND Location IS NOT NULL", [alternate_album['ReleaseID']]))
|
have_track_count = len(myDB.select("SELECT * from alltracks WHERE ReleaseID=? AND Location IS NOT NULL", [alternate_album['ReleaseID']]))
|
||||||
if alternate_album['AlbumID'] == alternate_album['ReleaseID']:
|
if alternate_album['AlbumID'] == alternate_album['ReleaseID']:
|
||||||
alternate_album_name = "Headphones Default Release (" + str(alternate_album['ReleaseDate']) + ") [" + str(have_track_count) + "/" + str(track_count) + " tracks]"
|
alternate_album_name = "Headphones Default Release (" + str(alternate_album['ReleaseDate']) + ") [" + str(have_track_count) + "/" + str(track_count) + " tracks]"
|
||||||
@@ -42,12 +43,12 @@
|
|||||||
alternate_album_name = alternate_album['AlbumTitle'] + " (" + alternate_album['ReleaseCountry'] + ", " + str(alternate_album['ReleaseDate']) + ", " + alternate_album['ReleaseFormat'] + ") [" + str(have_track_count) + "/" + str(track_count) + " tracks]"
|
alternate_album_name = alternate_album['AlbumTitle'] + " (" + alternate_album['ReleaseCountry'] + ", " + str(alternate_album['ReleaseDate']) + ", " + alternate_album['ReleaseFormat'] + ") [" + str(have_track_count) + "/" + str(track_count) + " tracks]"
|
||||||
|
|
||||||
%>
|
%>
|
||||||
<a href="#" onclick="doAjaxCall('switchAlbum?AlbumID=${album['AlbumID']}&ReleaseID=${alternate_album['ReleaseID']}', $(this), 'table');" data-success="Switched release to: ${alternate_album_name}">${alternate_album_name}</a><br>
|
<a href="javascript:void(0)" onclick="doAjaxCall('switchAlbum?AlbumID=${album['AlbumID']}&ReleaseID=${alternate_album['ReleaseID']}', $(this), 'table');" data-success="Switched release to: ${alternate_album_name}">${alternate_album_name}</a><a href="${mb_link}" target="_blank">MB</a><br>
|
||||||
%endfor
|
%endfor
|
||||||
%endif
|
%endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="menu_link_edit" id="edit_search_term" href="#"><i class="fa fa-pencil"></i> Edit Search Term</a>
|
<a class="menu_link_edit" id="edit_search_term" href="javascript:void(0)"><i class="fa fa-pencil"></i> Edit Search Term</a>
|
||||||
<div id="dialog2" title="Enter your own search term for this album" style="display:none" class="configtable">
|
<div id="dialog2" title="Enter your own search term for this album" style="display:none" class="configtable">
|
||||||
<form action="editSearchTerm" method="GET" id="editSearchTerm">
|
<form action="editSearchTerm" method="GET" id="editSearchTerm">
|
||||||
<input type="hidden" name="AlbumID" value="${album['AlbumID']}">
|
<input type="hidden" name="AlbumID" value="${album['AlbumID']}">
|
||||||
@@ -57,14 +58,13 @@
|
|||||||
search_term = ""
|
search_term = ""
|
||||||
else:
|
else:
|
||||||
search_term = album['SearchTerm']
|
search_term = album['SearchTerm']
|
||||||
|
|
||||||
%>
|
%>
|
||||||
<input type="text" value="${search_term}" name="SearchTerm" size="40" />
|
<input type="text" value="${search_term}" name="SearchTerm" size="40" />
|
||||||
</div>
|
</div>
|
||||||
<input type="button" value="Save changes" onclick="doAjaxCall('editSearchTerm',$(this),'tabs',true);return false;" data-success="Search term updated"/>
|
<input type="button" value="Save changes" onclick="doAjaxCall('editSearchTerm',$(this),'tabs',true);return false;" data-success="Search term updated"/>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<a class="menu_link_edit" id="choose_specific_download" href="#" onclick="getAvailableDownloads()"><i class="fa fa-search"></i> Choose Specific Download</a>
|
<a class="menu_link_edit" id="choose_specific_download" href="javascript:void(0)" onclick="getAvailableDownloads()"><i class="fa fa-search"></i> Choose Specific Download</a>
|
||||||
<div id="choose_specific_download_dialog" title="Choose a specific download for this album" style="display:none" class="configtable">
|
<div id="choose_specific_download_dialog" title="Choose a specific download for this album" style="display:none" class="configtable">
|
||||||
<table class="display" id="downloads_table">
|
<table class="display" id="downloads_table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -73,6 +73,7 @@
|
|||||||
<th id="size">Size</th>
|
<th id="size">Size</th>
|
||||||
<th id="provider">Provider</th>
|
<th id="provider">Provider</th>
|
||||||
<th id="kind">Kind</th>
|
<th id="kind">Kind</th>
|
||||||
|
<th id="matches" title="Matches quality and/or seed settings.">Matches</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="downloads_table_body">
|
<tbody id="downloads_table_body">
|
||||||
@@ -200,11 +201,17 @@
|
|||||||
|
|
||||||
function initThisPage() {
|
function initThisPage() {
|
||||||
$('#album_chooser').click(function() {
|
$('#album_chooser').click(function() {
|
||||||
$('#dialog').dialog({ width: "500px" });
|
$('#dialog').dialog({
|
||||||
|
width: 500,
|
||||||
|
maxHeight: 500
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
$('#edit_search_term').click(function() {
|
$('#edit_search_term').click(function() {
|
||||||
$('#dialog2').dialog({ width: "500px" });
|
$('#dialog2').dialog({
|
||||||
|
width: 500,
|
||||||
|
maxHeight: 500
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
$('#refresh_artist').click(function() {
|
$('#refresh_artist').click(function() {
|
||||||
@@ -231,21 +238,26 @@
|
|||||||
feedback.fadeOut();
|
feedback.fadeOut();
|
||||||
search_results = data
|
search_results = data
|
||||||
for( var i = 0, len = data.length; i < len; i++ ) {
|
for( var i = 0, len = data.length; i < len; i++ ) {
|
||||||
$('#downloads_table_body').append('<tr><td id="title"><a href="#" onclick="downloadSpecificRelease('+i+')">'+data[i].title+'</a></td><td id="size"><span title='+data[i].size+'></span>'+(data[i].size / (1024*1024)).toFixed(2)+' MB</td><td id="provider">'+data[i].provider+'</td><td id="kind">'+data[i].kind+'</td></tr>');
|
$('#downloads_table_body').append('<tr><td id="title"><a href="javascript:void(0)" onclick="downloadSpecificRelease('+i+')">'+data[i].title+'</a></td><td id="size"><span title='+data[i].size+'></span>'+(data[i].size / (1024*1024)).toFixed(2)+' MB</td><td id="provider">'+data[i].provider+'</td><td id="kind">'+data[i].kind+'</td><td id="matches">'+data[i].matches+'</td></tr>');
|
||||||
}
|
}
|
||||||
$('#downloads_table').dataTable({
|
$('#downloads_table').dataTable({
|
||||||
"aoColumns": [
|
"aoColumns": [
|
||||||
null,
|
null,
|
||||||
{"sType": "title-numeric"},
|
{"sType": "title-numeric"},
|
||||||
null,
|
null,
|
||||||
null
|
null,
|
||||||
|
{"sType": "string"}
|
||||||
],
|
],
|
||||||
"aaSorting": [],
|
"aaSorting": [[ 4, 'desc']],
|
||||||
"bFilter": false,
|
"bFilter": false,
|
||||||
"bInfo": false,
|
"bInfo": false,
|
||||||
"bPaginate": false,
|
"bPaginate": false,
|
||||||
|
"bDestroy": true
|
||||||
|
});
|
||||||
|
$("#choose_specific_download_dialog").dialog({
|
||||||
|
width: "80%",
|
||||||
|
maxHeight: 500
|
||||||
});
|
});
|
||||||
$("#choose_specific_download_dialog").dialog({ width: "1000px" });
|
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -326,6 +338,21 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
jQuery.extend( jQuery.fn.dataTableExt.oSort, {
|
||||||
|
"title-numeric-pre": function ( a ) {
|
||||||
|
var x = a.match(/title="*(-?[0-9\.]+)/)[1];
|
||||||
|
return parseFloat( x );
|
||||||
|
},
|
||||||
|
|
||||||
|
"title-numeric-asc": function ( a, b ) {
|
||||||
|
return ((a < b) ? -1 : ((a > b) ? 1 : 0));
|
||||||
|
},
|
||||||
|
|
||||||
|
"title-numeric-desc": function ( a, b ) {
|
||||||
|
return ((a < b) ? 1 : ((a > b) ? -1 : 0));
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
getAlbumInfo();
|
getAlbumInfo();
|
||||||
initThisPage();
|
initThisPage();
|
||||||
|
|||||||
@@ -8,18 +8,19 @@
|
|||||||
<%def name="headerIncludes()">
|
<%def name="headerIncludes()">
|
||||||
<div id="subhead_container">
|
<div id="subhead_container">
|
||||||
<div id="subhead_menu">
|
<div id="subhead_menu">
|
||||||
<a id="menu_link_refresh" onclick="doSimpleAjaxCall('refreshArtist?ArtistID=${artist['ArtistID']}')" href="#"><i class="fa fa-refresh"></i> Refresh Artist</a>
|
<a id="menu_link_refresh" onclick="doSimpleAjaxCall('refreshArtist?ArtistID=${artist['ArtistID']}')" href="javascript:void(0)"><i class="fa fa-refresh"></i> Refresh Artist</a>
|
||||||
<a id="menu_link_delete" href="deleteArtist?ArtistID=${artist['ArtistID']}"><i class="fa fa-trash-o"></i> Delete Artist</a>
|
<a id="menu_link_delete" href="deleteArtist?ArtistID=${artist['ArtistID']}"><i class="fa fa-trash-o"></i> Delete Artist</a>
|
||||||
|
<a id="menu_link_scan" onclick="doAjaxCall('scanArtist?ArtistID=${artist['ArtistID']}', $(this)),'table'" href="javascript:void(0)" data-success="'${artist['ArtistName']}' was scanned"><i class="fa fa-refresh"></i> Scan Artist</a>
|
||||||
%if artist['Status'] == 'Paused':
|
%if artist['Status'] == 'Paused':
|
||||||
<a id="menu_link_resume" href="#" onclick="doAjaxCall('resumeArtist?ArtistID=${artist['ArtistID']}',$(this),true)" data-success="${artist['ArtistName']} resumed"><i class="fa fa-play"></i> Resume Artist</a>
|
<a id="menu_link_resume" href="javascript:void(0)" onclick="doAjaxCall('resumeArtist?ArtistID=${artist['ArtistID']}',$(this),true)" data-success="${artist['ArtistName']} resumed"><i class="fa fa-play"></i> Resume Artist</a>
|
||||||
%else:
|
%else:
|
||||||
<a id="menu_link_pauze" href="#" onclick="doAjaxCall('pauseArtist?ArtistID=${artist['ArtistID']}',$(this),true)" data-success="${artist['ArtistName']} paused"><i class="fa fa-pause"></i> Pause Artist</a>
|
<a id="menu_link_pauze" href="javascript:void(0)" onclick="doAjaxCall('pauseArtist?ArtistID=${artist['ArtistID']}',$(this),true)" data-success="${artist['ArtistName']} paused"><i class="fa fa-pause"></i> Pause Artist</a>
|
||||||
%endif
|
%endif
|
||||||
%if artist['IncludeExtras']:
|
%if artist['IncludeExtras']:
|
||||||
<a id="menu_link_removeextra" href="#" onclick="doAjaxCall('removeExtras?ArtistID=${artist['ArtistID']}&ArtistName=${artist['ArtistName']}',$(this),'submenu&table')" data-success="Extras removed for ${artist['ArtistName']}"><i class="fa fa-minus"></i> Remove Extras</a>
|
<a id="menu_link_removeextra" href="javascript:void(0)" onclick="doAjaxCall('removeExtras?ArtistID=${artist['ArtistID']}&ArtistName=${artist['ArtistName']}',$(this),'submenu&table')" data-success="Extras removed for ${artist['ArtistName']}"><i class="fa fa-minus"></i> Remove Extras</a>
|
||||||
<a class="menu_link_edit" id="menu_link_modifyextra" href="#"><i class="fa fa-pencil"></i> Modify Extras</a>
|
<a class="menu_link_edit" id="menu_link_modifyextra" href="javascript:void(0)"><i class="fa fa-pencil"></i> Modify Extras</a>
|
||||||
%else:
|
%else:
|
||||||
<a id="menu_link_getextra" href="#"><i class="fa fa-plus"></i> Get Extras</a>
|
<a id="menu_link_getextra" href="javascript:void(0)"><i class="fa fa-plus"></i> Get Extras</a>
|
||||||
%endif
|
%endif
|
||||||
<div id="dialog" title="Choose Which Extras to Fetch" style="display:none" class="configtable">
|
<div id="dialog" title="Choose Which Extras to Fetch" style="display:none" class="configtable">
|
||||||
<form action="getExtras" method="get" class="form">
|
<form action="getExtras" method="get" class="form">
|
||||||
@@ -69,6 +70,7 @@
|
|||||||
<th id="albumname">Name</th>
|
<th id="albumname">Name</th>
|
||||||
<th id="reldate">Date</th>
|
<th id="reldate">Date</th>
|
||||||
<th id="type">Type</th>
|
<th id="type">Type</th>
|
||||||
|
<th id="score">Metacritic</th>
|
||||||
<th id="status">Status</th>
|
<th id="status">Status</th>
|
||||||
<th id="have">Have</th>
|
<th id="have">Have</th>
|
||||||
<th id="bitrate">Bitrate</th>
|
<th id="bitrate">Bitrate</th>
|
||||||
@@ -124,18 +126,19 @@
|
|||||||
<td id="albumname"><a href="albumPage?AlbumID=${album['AlbumID']}">${album['AlbumTitle']}</a></td>
|
<td id="albumname"><a href="albumPage?AlbumID=${album['AlbumID']}">${album['AlbumTitle']}</a></td>
|
||||||
<td id="reldate">${album['ReleaseDate']}</td>
|
<td id="reldate">${album['ReleaseDate']}</td>
|
||||||
<td id="type">${album['Type']}</td>
|
<td id="type">${album['Type']}</td>
|
||||||
|
<td id="score">${album['CriticScore']}/${album['UserScore']}</td>
|
||||||
<td id="status">${album['Status']}
|
<td id="status">${album['Status']}
|
||||||
%if album['Status'] == 'Skipped' or album['Status'] == 'Ignored':
|
%if album['Status'] == 'Skipped' or album['Status'] == 'Ignored':
|
||||||
[<a href="#" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}',$(this),'table')" data-success="'${album['AlbumTitle']}' added to Wanted list">want</a>]
|
[<a href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}',$(this),'table')" data-success="'${album['AlbumTitle']}' added to Wanted list">want</a>]
|
||||||
%elif (album['Status'] == 'Wanted' or album['Status'] == 'Wanted Lossless'):
|
%elif (album['Status'] == 'Wanted' or album['Status'] == 'Wanted Lossless'):
|
||||||
[<a href="#" onclick="doAjaxCall('unqueueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}',$(this),'table')" data-success="'${album['AlbumTitle']}' skipped">skip</a>] [<a href="#" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}', $(this),'table')" data-success="Trying to download'${album['AlbumTitle']}'" title="Search if available for download">search</a>]
|
[<a href="javascript:void(0)" onclick="doAjaxCall('unqueueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}',$(this),'table')" data-success="'${album['AlbumTitle']}' skipped">skip</a>] [<a href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}', $(this),'table')" data-success="Trying to download'${album['AlbumTitle']}'" title="Search if available for download">search</a>]
|
||||||
%else:
|
%else:
|
||||||
[<a href="#" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}', $(this),'table')" data-success="Retrying the same version of '${album['AlbumTitle']}'" title="Retry the same download again">retry</a>][<a href="#" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}&new=True', $(this),'table')" title="Try a new download, skipping all previously tried nzbs" data-success="Downloading new version for '${album['AlbumTitle']}'" data-success="Looking for a new version of '${album['AlbumTitle']}'">new</a>]
|
[<a href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}', $(this),'table')" data-success="Retrying the same version of '${album['AlbumTitle']}'" title="Retry the same download again">retry</a>][<a href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}&new=True', $(this),'table')" title="Try a new download, skipping all previously tried nzbs" data-success="Downloading new version for '${album['AlbumTitle']}'" data-success="Looking for a new version of '${album['AlbumTitle']}'">new</a>]
|
||||||
%endif
|
%endif
|
||||||
%if albumformat in lossy_formats and album['Status'] == 'Skipped':
|
%if albumformat in lossy_formats and album['Status'] == 'Skipped':
|
||||||
[<a id="wantlossless" href="#" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}&lossless=True', $(this),'table')" data-success="Lossless version of '${album['AlbumTitle']}' added to queue">want lossless</a>]
|
[<a id="wantlossless" href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}&lossless=True', $(this),'table')" data-success="Lossless version of '${album['AlbumTitle']}' added to queue">want lossless</a>]
|
||||||
%elif albumformat in lossy_formats and (album['Status'] == 'Snatched' or album['Status'] == 'Downloaded'):
|
%elif albumformat in lossy_formats and (album['Status'] == 'Snatched' or album['Status'] == 'Downloaded'):
|
||||||
[<a id="wantlossless" href="#" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}&lossless=True', $(this),'table')" data-success="Retrying the same lossless version of '${album['AlbumTitle']}'">retry lossless</a>]
|
[<a id="wantlossless" href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}&lossless=True', $(this),'table')" data-success="Retrying the same lossless version of '${album['AlbumTitle']}'">retry lossless</a>]
|
||||||
%endif
|
%endif
|
||||||
</td>
|
</td>
|
||||||
<td id="have"><span title="${percent}"><span><div class="progress-container"><div style="width:${percent}%"><div class="havetracks">${havetracks}/${totaltracks}</div></div></div></td>
|
<td id="have"><span title="${percent}"><span><div class="progress-container"><div style="width:${percent}%"><div class="havetracks">${havetracks}/${totaltracks}</div></div></div></td>
|
||||||
@@ -189,19 +192,19 @@
|
|||||||
$.getJSON("https://api.songkick.com/api/3.0/artists/mbid:${artist['ArtistID']}/calendar.json?apikey=${headphones.CONFIG.SONGKICK_APIKEY}&jsoncallback=?",
|
$.getJSON("https://api.songkick.com/api/3.0/artists/mbid:${artist['ArtistID']}/calendar.json?apikey=${headphones.CONFIG.SONGKICK_APIKEY}&jsoncallback=?",
|
||||||
function(data){
|
function(data){
|
||||||
if (data['resultsPage'].totalEntries >= 1) {
|
if (data['resultsPage'].totalEntries >= 1) {
|
||||||
|
|
||||||
if (${songkick_filter_enabled}) {
|
if (${songkick_filter_enabled}) {
|
||||||
data.resultsPage.results.event = $.grep(data.resultsPage.results.event, function(element,index){
|
data.resultsPage.results.event = $.grep(data.resultsPage.results.event, function(element,index){
|
||||||
return element.venue.metroArea.id == ${songkick_location};
|
return element.venue.metroArea.id == ${songkick_location};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.resultsPage.results.event.length > 0) {
|
||||||
var tourDate;
|
var tourDate;
|
||||||
|
|
||||||
calendarDomNode.show();
|
calendarDomNode.show();
|
||||||
$("#artistImg").addClass('on-tour');
|
$("#artistImg").addClass('on-tour');
|
||||||
|
|
||||||
jQuery.each( data['resultsPage'].results.event, function( i, event ) {
|
jQuery.each(data.resultsPage.results.event, function(i, event) {
|
||||||
tourDate = template;
|
tourDate = template;
|
||||||
tourDate = tourDate.replace('URI',event.uri);
|
tourDate = tourDate.replace('URI',event.uri);
|
||||||
tourDate = tourDate.replace('NAME',event.displayName);
|
tourDate = tourDate.replace('NAME',event.displayName);
|
||||||
@@ -217,17 +220,17 @@
|
|||||||
$("li:gt(4)", this).hide(); /* :gt() is zero-indexed */
|
$("li:gt(4)", this).hide(); /* :gt() is zero-indexed */
|
||||||
$("li:nth-child(5)", this).after("<br><li class='more'><a href='#'>More...</a></li>"); /* :nth-child() is one-indexed */
|
$("li:nth-child(5)", this).after("<br><li class='more'><a href='#'>More...</a></li>"); /* :nth-child() is one-indexed */
|
||||||
});
|
});
|
||||||
$("li.more a").live("click", function() {
|
$("li.more").on("click", 'a', function() {
|
||||||
var li = $(this).parents("li:first");
|
var li = $(this).parents("li:first");
|
||||||
li.parent().children().show();
|
li.parent().children().show();
|
||||||
li.remove();
|
li.remove();
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
// data is JSON response object
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
var loadingMessage = false;
|
var loadingMessage = false;
|
||||||
@@ -282,6 +285,7 @@
|
|||||||
{ "sType": "date" },
|
{ "sType": "date" },
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
{ "sType": "title-numeric"},
|
{ "sType": "title-numeric"},
|
||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
|
|||||||
@@ -31,14 +31,14 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="container">
|
<div id="container">
|
||||||
<div id="ajaxMsg" class="ajaxMsg"></div>
|
<div id="ajaxMsg" class="ajaxMsg"></div>
|
||||||
% if not headphones.CURRENT_VERSION:
|
% if headphones.CONFIG.CHECK_GITHUB and not headphones.CURRENT_VERSION:
|
||||||
<div id="updatebar">
|
<div id="updatebar">
|
||||||
You're running an unknown version of Headphones. <a href="update">Update</a> or
|
You're running an unknown version of Headphones. <a href="update">Update</a> or
|
||||||
<a href="#" onclick="$('#updatebar').slideUp('slow');">Close</a>
|
<a href="javascript:void(0)" onclick="$('#updatebar').slideUp('slow');">Close</a>
|
||||||
</div>
|
</div>
|
||||||
% elif headphones.CURRENT_VERSION != headphones.LATEST_VERSION and headphones.COMMITS_BEHIND > 0 and headphones.INSTALL_TYPE != 'win':
|
% elif headphones.CONFIG.CHECK_GITHUB and headphones.CURRENT_VERSION != headphones.LATEST_VERSION and headphones.COMMITS_BEHIND > 0 and headphones.INSTALL_TYPE != 'win':
|
||||||
<div id="updatebar">
|
<div id="updatebar">
|
||||||
A <a href="https://github.com/${headphones.CONFIG.GIT_USER}/headphones/compare/${headphones.CURRENT_VERSION}...${headphones.LATEST_VERSION}"> newer version</a> is available. You're ${headphones.COMMITS_BEHIND} commits behind. <a href="update">Update</a> or <a href="#" onclick="$('#updatebar').slideUp('slow');">Close</a>
|
A <a href="https://github.com/${headphones.CONFIG.GIT_USER}/headphones/compare/${headphones.CURRENT_VERSION}...${headphones.LATEST_VERSION}"> newer version</a> is available. You're ${headphones.COMMITS_BEHIND} commits behind. <a href="update">Update</a> or <a href="javascript:void(0)" onclick="$('#updatebar').slideUp('slow');">Close</a>
|
||||||
</div>
|
</div>
|
||||||
% endif
|
% endif
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
<li><a href="extras">extras</a></li>
|
<li><a href="extras">extras</a></li>
|
||||||
<li><a href="manage">manage</a></li>
|
<li><a href="manage">manage</a></li>
|
||||||
<li><a href="history">history</a></li>
|
<li><a href="history">history</a></li>
|
||||||
<li><a href="logs" class="log">logs</a></li>
|
<li><a href="logs">logs</a></li>
|
||||||
<li><a href="config" class="config"><i class="fa fa-gear fa-lg"></i></a></li>
|
<li><a href="config" class="config"><i class="fa fa-gear fa-lg"></i></a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<div id="searchbar">
|
<div id="searchbar">
|
||||||
@@ -62,6 +62,7 @@
|
|||||||
<select name="type" id="search_type">
|
<select name="type" id="search_type">
|
||||||
<option value="artist">Artist</option>
|
<option value="artist">Artist</option>
|
||||||
<option value="album">Album</option>
|
<option value="album">Album</option>
|
||||||
|
<option value="series">Series</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="submit" value="Search"/>
|
<input type="submit" value="Search"/>
|
||||||
</form>
|
</form>
|
||||||
@@ -88,7 +89,7 @@
|
|||||||
<small>
|
<small>
|
||||||
<a href="shutdown"><i class="fa fa-power-off"></i> Shutdown</a> |
|
<a href="shutdown"><i class="fa fa-power-off"></i> Shutdown</a> |
|
||||||
<a href="restart"><i class="fa fa-power-off"></i> Restart</a> |
|
<a href="restart"><i class="fa fa-power-off"></i> Restart</a> |
|
||||||
<a href="#" onclick="doAjaxCall('checkGithub',$(this))" data-success="Checking for update successful" data-error="Error checking for update"><i class="fa fa-refresh"></i> Check for new version</a>
|
<a href="javascript:void(0)" onclick="doAjaxCall('checkGithub',$(this))" data-success="Checking for update successful" data-error="Error checking for update"><i class="fa fa-refresh"></i> Check for new version</a>
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div id="version">
|
<div id="version">
|
||||||
|
|||||||
+611
-265
File diff suppressed because it is too large
Load Diff
@@ -229,6 +229,17 @@ textarea,
|
|||||||
button {
|
button {
|
||||||
font: 99%;
|
font: 99%;
|
||||||
}
|
}
|
||||||
|
select {
|
||||||
|
-moz-border-radius: 5px;
|
||||||
|
-webkit-border-radius: 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #4F4F4F;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.25);
|
||||||
|
color: #fff;
|
||||||
|
padding: 3px 10px;
|
||||||
|
text-shadow: 0 -1px 1px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
textarea {
|
textarea {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
@@ -325,7 +336,7 @@ form fieldset small.heading {
|
|||||||
margin-top: -15px;
|
margin-top: -15px;
|
||||||
}
|
}
|
||||||
form .row {
|
form .row {
|
||||||
font-family: Helvetica, Arial;
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
form .row label {
|
form .row label {
|
||||||
@@ -334,7 +345,7 @@ form .row label {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: normal;
|
line-height: normal;
|
||||||
padding-top: 7px;
|
padding-top: 7px;
|
||||||
width: 175px;
|
width: 170px;
|
||||||
}
|
}
|
||||||
form .row label.inline {
|
form .row label.inline {
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
@@ -368,7 +379,7 @@ form .row small {
|
|||||||
display: block;
|
display: block;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
line-height: 12px;
|
line-height: 12px;
|
||||||
margin-left: 175px;
|
margin-left: 170px;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
}
|
}
|
||||||
form .left label {
|
form .left label {
|
||||||
@@ -408,6 +419,18 @@ form .checkbox small {
|
|||||||
form .indent input {
|
form .indent input {
|
||||||
margin-left: 15px;
|
margin-left: 15px;
|
||||||
}
|
}
|
||||||
|
form .suboptions {
|
||||||
|
margin-left: 15px;
|
||||||
|
}
|
||||||
|
.option{
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
input.bigcheck[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
ul,
|
ul,
|
||||||
ol {
|
ol {
|
||||||
margin-left: 2em;
|
margin-left: 2em;
|
||||||
@@ -1420,6 +1443,13 @@ div#artistheader h2 a {
|
|||||||
line-height: 10px;
|
line-height: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
}
|
}
|
||||||
|
.nopad {
|
||||||
|
margin-bottom: 0px !important;
|
||||||
|
margin-top: 0px !important;
|
||||||
|
padding-top: 0px !important;
|
||||||
|
padding-bottom: 0px !important;
|
||||||
|
padding: 0px 0px !important;
|
||||||
|
}
|
||||||
#album_table th#albumname,
|
#album_table th#albumname,
|
||||||
#upcoming_table th#artistname,
|
#upcoming_table th#artistname,
|
||||||
#wanted_table th#artistname {
|
#wanted_table th#artistname {
|
||||||
@@ -1445,6 +1475,7 @@ div#artistheader h2 a {
|
|||||||
#album_table td#albumname,
|
#album_table td#albumname,
|
||||||
#album_table td#reldate,
|
#album_table td#reldate,
|
||||||
#album_table td#type,
|
#album_table td#type,
|
||||||
|
#album_table td#score,
|
||||||
#track_table td#duration,
|
#track_table td#duration,
|
||||||
#upcoming_table td#select,
|
#upcoming_table td#select,
|
||||||
#upcoming_table td#status,
|
#upcoming_table td#status,
|
||||||
@@ -1455,6 +1486,7 @@ div#artistheader h2 a {
|
|||||||
}
|
}
|
||||||
#album_table td#status,
|
#album_table td#status,
|
||||||
#album_table td#bitrate,
|
#album_table td#bitrate,
|
||||||
|
#album_table td#score,
|
||||||
#album_table td#albumformat,
|
#album_table td#albumformat,
|
||||||
#album_table td#wantlossless {
|
#album_table td#wantlossless {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -124,7 +124,23 @@ table {
|
|||||||
|
|
||||||
|
|
||||||
// Forms
|
// Forms
|
||||||
select, input, textarea, button { font: 99%;}
|
select, input, textarea, button
|
||||||
|
{
|
||||||
|
font: 99%;
|
||||||
|
}
|
||||||
|
|
||||||
|
select
|
||||||
|
{
|
||||||
|
.rounded(5px);
|
||||||
|
background: #4F4F4F;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.25);
|
||||||
|
color: #fff;
|
||||||
|
padding: 3px 10px;
|
||||||
|
text-shadow: 0 -1px 1px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
textarea {overflow: auto;}
|
textarea {overflow: auto;}
|
||||||
input { .rounded(3px);}
|
input { .rounded(3px);}
|
||||||
input:invalid, textarea:invalid {
|
input:invalid, textarea:invalid {
|
||||||
@@ -190,7 +206,7 @@ form {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: normal;
|
line-height: normal;
|
||||||
padding-top: 7px;
|
padding-top: 7px;
|
||||||
width: 175px;
|
width: 170px;
|
||||||
|
|
||||||
&.inline {
|
&.inline {
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
@@ -216,7 +232,7 @@ form {
|
|||||||
display: block;
|
display: block;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
line-height: 12px;
|
line-height: 12px;
|
||||||
margin-left: 175px;
|
margin-left: 170px;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
<%def name="headerIncludes()">
|
<%def name="headerIncludes()">
|
||||||
<div id="subhead_container">
|
<div id="subhead_container">
|
||||||
<div id="subhead_menu">
|
<div id="subhead_menu">
|
||||||
<a id="menu_link_delete" href="#" onclick="doAjaxCall('clearhistory?type=all',$(this),'table')" data-success="All History cleared"><i class="fa fa-trash-o"></i> Clear All History</a>
|
<a id="menu_link_delete" href="javascript:void(0)" onclick="doAjaxCall('clearhistory?type=all',$(this),'table')" data-success="All History cleared"><i class="fa fa-trash-o"></i> Clear All History</a>
|
||||||
<a id="menu_link_delete" href="#" onclick="doAjaxCall('clearhistory?type=Processed',$(this),'table')" data-success="All Processed cleared"><i class="fa fa-trash-o"></i> Clear Processed</a>
|
<a id="menu_link_delete" href="javascript:void(0)" onclick="doAjaxCall('clearhistory?type=Processed',$(this),'table')" data-success="All Processed cleared"><i class="fa fa-trash-o"></i> Clear Processed</a>
|
||||||
<a id="menu_link_delete" href="#" onclick="doAjaxCall('clearhistory?type=Unprocessed',$(this),'table')" data-success="All Unprocessed cleared"><i class="fa fa-trash-o"></i> Clear Unprocessed</a>
|
<a id="menu_link_delete" href="javascript:void(0)" onclick="doAjaxCall('clearhistory?type=Unprocessed',$(this),'table')" data-success="All Unprocessed cleared"><i class="fa fa-trash-o"></i> Clear Unprocessed</a>
|
||||||
<a id="menu_link_delete" href="#" onclick="doAjaxCall('clearhistory?type=Snatched',$(this),'table')" data-success="All Snatched cleared"><i class="fa fa-trash-o"></i> Clear Snatched</a>
|
<a id="menu_link_delete" href="javascript:void(0)" onclick="doAjaxCall('clearhistory?type=Frozen',$(this),'table')" data-success="All Frozen cleared"><i class="fa fa-trash-o"></i> Clear Frozen</a>
|
||||||
|
<a id="menu_link_delete" href="javascript:void(0)" onclick="doAjaxCall('clearhistory?type=Snatched',$(this),'table')" data-success="All Snatched cleared"><i class="fa fa-trash-o"></i> Clear Snatched</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</%def>
|
</%def>
|
||||||
@@ -39,6 +40,8 @@
|
|||||||
grade = 'C'
|
grade = 'C'
|
||||||
elif item['Status'] == 'Unprocessed':
|
elif item['Status'] == 'Unprocessed':
|
||||||
grade = 'X'
|
grade = 'X'
|
||||||
|
elif item['Status'] == 'Frozen':
|
||||||
|
grade = 'X'
|
||||||
else:
|
else:
|
||||||
grade = 'U'
|
grade = 'U'
|
||||||
|
|
||||||
@@ -47,16 +50,23 @@
|
|||||||
fileid = 'nzb'
|
fileid = 'nzb'
|
||||||
if item['URL'].find('torrent') != -1:
|
if item['URL'].find('torrent') != -1:
|
||||||
fileid = 'torrent'
|
fileid = 'torrent'
|
||||||
|
if item['URL'].find('magnet:') != -1:
|
||||||
|
fileid = 'torrent'
|
||||||
if item['URL'].find('rutracker') != -1:
|
if item['URL'].find('rutracker') != -1:
|
||||||
fileid = 'torrent'
|
fileid = 'torrent'
|
||||||
|
if item['URL'].find('codeshy') != -1:
|
||||||
|
fileid = 'nzb'
|
||||||
|
|
||||||
|
folder = 'Folder: ' + item['FolderName']
|
||||||
|
|
||||||
%>
|
%>
|
||||||
<tr class="grade${grade}">
|
<tr class="grade${grade}">
|
||||||
<td id="dateadded">${item['DateAdded']}</td>
|
<td id="dateadded">${item['DateAdded']}</td>
|
||||||
<td id="filename">${cgi.escape(item['Title'], quote=True)} [<a href="${item['URL']}">${fileid}</a>]<a href="albumPage?AlbumID=${item['AlbumID']}">[album page]</a></td>
|
<td id="filename">${cgi.escape(item['Title'], quote=True)} [<a href="${item['URL']}">${fileid}</a>]<a href="albumPage?AlbumID=${item['AlbumID']}">[album page]</a></td>
|
||||||
<td id="size">${helpers.bytes_to_mb(item['Size'])}</td>
|
<td id="size">${helpers.bytes_to_mb(item['Size'])}</td>
|
||||||
<td id="status">${item['Status']}</td>
|
<td title="${folder}" id="status">${item['Status']}</td>
|
||||||
<td id="action">[<a href="#" onclick="doAjaxCall('queueAlbum?AlbumID=${item['AlbumID']}&redirect=history', $(this),'table')" data-success="Retrying download of '${cgi.escape(item['Title'], quote=True)}'">retry</a>][<a href="#" onclick="doAjaxCall('queueAlbum?AlbumID=${item['AlbumID']}&new=True&redirect=history',$(this),'table')" data-success="Looking for a new version of '${cgi.escape(item['Title'], quote=True)}'">new</a>]</td>
|
<td id="action">[<a href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${item['AlbumID']}&redirect=history', $(this),'table')" data-success="Retrying download of '${cgi.escape(item['Title'], quote=True)}'">retry</a>][<a href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${item['AlbumID']}&new=True&redirect=history',$(this),'table')" data-success="Looking for a new version of '${cgi.escape(item['Title'], quote=True)}'">new</a>]</td>
|
||||||
<td id="delete"><a href="#" onclick="doAjaxCall('clearhistory?date_added=${item['DateAdded']}&title=${cgi.escape(item['Title'], quote=True)}',$(this),'table')" data-success="${cgi.escape(item['Title'], quote=True)} cleared from history"><img src="interfaces/default/images/trashcan.png" height="18" width="18" id="trashcan" title="Clear this item from the history"></a>
|
<td id="delete"><a href="javascript:void(0)" onclick="doAjaxCall('clearhistory?date_added=${item['DateAdded']}&title=${cgi.escape(item['Title'], quote=True)}',$(this),'table')" data-success="${cgi.escape(item['Title'], quote=True)} cleared from history"><img src="interfaces/default/images/trashcan.png" height="18" width="18" id="trashcan" title="Clear this item from the history"></a>
|
||||||
</tr>
|
</tr>
|
||||||
%endfor
|
%endfor
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<div id="subhead_container">
|
<div id="subhead_container">
|
||||||
<div id="subhead_menu">
|
<div id="subhead_menu">
|
||||||
<a class="menu_link_edit" href="clearLogs"><i class="fa fa-trash-o"></i> Clear log</a>
|
<a class="menu_link_edit" href="clearLogs"><i class="fa fa-trash-o"></i> Clear log</a>
|
||||||
|
<a class="menu_link_edit" href="toggleVerbose"><i class="fa fa-pencil"></i> Toggle Debug Log</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</%def>
|
</%def>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<%def name="headerIncludes()">
|
<%def name="headerIncludes()">
|
||||||
<div id="subhead_container">
|
<div id="subhead_container">
|
||||||
<div id="subhead_menu">
|
<div id="subhead_menu">
|
||||||
<a class="menu_link_edit" id="manage_albums" href="#"><i class="fa fa-pencil"></i> Manage Albums</a>
|
<a class="menu_link_edit" id="manage_albums" href="javascript:void(0)"><i class="fa fa-pencil"></i> Manage Albums</a>
|
||||||
<div id="dialog" title="Choose Album Filter" style="display:none" class="configtable">
|
<div id="dialog" title="Choose Album Filter" style="display:none" class="configtable">
|
||||||
<div class="links">
|
<div class="links">
|
||||||
<a href="manageAlbums?Status=Downloaded"><i class="fa fa-check fa-fw"></i> Manage Downloaded Albums</a><br>
|
<a href="manageAlbums?Status=Downloaded"><i class="fa fa-check fa-fw"></i> Manage Downloaded Albums</a><br>
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
%>
|
%>
|
||||||
<input type="text" value="${lastfmvalue}" placeholder="Last.fm username" onfocus="if
|
<input type="text" value="${lastfmvalue}" placeholder="Last.fm username" onfocus="if
|
||||||
(this.value==this.defaultValue) this.value='';" name="username" id="username" size="18" />
|
(this.value==this.defaultValue) this.value='';" name="username" id="username" size="18" />
|
||||||
<a href="#" onclick="doAjaxCall('importLastFM?username=',$(this),'tabs');return false;" data-success="Last.fm username has been reset"><i class="fa fa-reply"></i> Reset username</a>
|
<a href="javascript:void(0)" onclick="doAjaxCall('importLastFM?username=',$(this),'tabs');return false;" data-success="Last.fm username has been reset"><i class="fa fa-reply"></i> Reset username</a>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<input type="button" value="Save changes" onclick="doAjaxCall('importLastFM',$(this),'tabs',true);return false;" data-success="Last.fm artists will be imported" data-error="Fill in a last.fm username"/>
|
<input type="button" value="Save changes" onclick="doAjaxCall('importLastFM',$(this),'tabs',true);return false;" data-success="Last.fm artists will be imported" data-error="Fill in a last.fm username"/>
|
||||||
@@ -121,10 +121,10 @@
|
|||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Force Search</legend>
|
<legend>Force Search</legend>
|
||||||
<div class="links">
|
<div class="links">
|
||||||
<a href="#" onclick="doAjaxCall('forceSearch',$(this))" data-success="Checking for wanted albums successful" data-error="Error checking wanted albums"><i class="fa fa-search fa-fw"></i> Force Check for Wanted Albums</a>
|
<a href="javascript:void(0)" onclick="doAjaxCall('forceSearch',$(this))" data-success="Checking for wanted albums successful" data-error="Error checking wanted albums"><i class="fa fa-search fa-fw"></i> Force Check for Wanted Albums</a>
|
||||||
<a href="#" onclick="doAjaxCall('forceUpdate',$(this))" data-success="Update active artists successful" data-error="Error forcing update artists"><i class="fa fa-heart fa-fw"></i> Force Update Active Artists [Fast]</a>
|
<a href="javascript:void(0)" onclick="doAjaxCall('forceUpdate',$(this))" data-success="Update active artists successful" data-error="Error forcing update artists"><i class="fa fa-heart fa-fw"></i> Force Update Active Artists [Fast]</a>
|
||||||
<a href="#" onclick="doAjaxCall('checkGithub',$(this))" data-success="Checking for update successful" data-error="Error checking for update"><i class="fa fa-refresh fa-fw"></i> Check for Headphones Updates</a>
|
<a href="javascript:void(0)" onclick="doAjaxCall('checkGithub',$(this))" data-success="Checking for update successful" data-error="Error checking for update"><i class="fa fa-refresh fa-fw"></i> Check for Headphones Updates</a>
|
||||||
<a href="#" id="delete_empty_artists"><i class="fa fa-trash-o fa-fw"></i> Delete empty Artists</a>
|
<a href="javascript:void(0)" id="delete_empty_artists"><i class="fa fa-trash-o fa-fw"></i> Delete empty Artists</a>
|
||||||
<div id="emptyartistdialog" title="Confirm Artist Deletion" style="display:none" class="configtable">
|
<div id="emptyartistdialog" title="Confirm Artist Deletion" style="display:none" class="configtable">
|
||||||
%if emptyArtists:
|
%if emptyArtists:
|
||||||
<h3>The following artists will be deleted:</h3>
|
<h3>The following artists will be deleted:</h3>
|
||||||
@@ -137,28 +137,27 @@
|
|||||||
No empty artists found.
|
No empty artists found.
|
||||||
%endif
|
%endif
|
||||||
</div>
|
</div>
|
||||||
<a href="#" onclick="doAjaxCall('forcePostProcess',$(this))" data-success="Post-Processor is being loaded" data-error="Error during Post-Processing"><i class="fa fa-wrench fa-fw"></i> Force Post-Process Albums in Download Folder</a>
|
<div id="post_process">
|
||||||
|
<a href="javascript:void(0)" class="btnOpenDialog"><i class="fa fa-wrench fa-fw"></i> Force Post-Process Albums in Download Folder</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<form action="forcePostProcess" method="GET">
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div class="row">
|
<div class="row" id="post_process_alternate">
|
||||||
<label>Force Post-Process Albums in Alternate Folder</label>
|
<label>Force Post-Process Albums in Alternate Folder</label>
|
||||||
<input type="text" value="" name="dir" id="dir" size="50" /><input type="submit" />
|
<input type="text" value="" name="dir" id="dir" size="50" />
|
||||||
|
<input type="button" class="btnOpenDialog" value="Submit" />
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
</form>
|
|
||||||
<form action="forcePostProcess" method="GET">
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div class="row">
|
<div class="row" id="post_process_single">
|
||||||
<label>Post-Process Single Folder</label>
|
<label>Post-Process Single Folder</label>
|
||||||
<input type="text" value="" name="album_dir" id="album_dir" size="50" /><input type="submit" />
|
<input type="text" value="" name="album_dir" id="album_dir" size="50" />
|
||||||
|
<input type="button" class="btnOpenDialog" value="Submit" />
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
</form>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -167,9 +166,9 @@
|
|||||||
<legend>Force Legacy</legend>
|
<legend>Force Legacy</legend>
|
||||||
<p>Please note that these functions will take a significant amount of time to complete.</p>
|
<p>Please note that these functions will take a significant amount of time to complete.</p>
|
||||||
<div class="links">
|
<div class="links">
|
||||||
<a href="#" onclick="doAjaxCall('forceFullUpdate',$(this))" data-success="Update active artists successful" data-error="Error forcing update artists"><i class="fa fa-heart fa-fw"></i> Force Update Active Artists [Comprehensive]</a>
|
<a href="javascript:void(0)" onclick="doAjaxCall('forceFullUpdate',$(this))" data-success="Update active artists successful" data-error="Error forcing update artists"><i class="fa fa-heart fa-fw"></i> Force Update Active Artists [Comprehensive]</a>
|
||||||
<BR>
|
<BR>
|
||||||
<a href="#" onclick="doAjaxCall('forceScan',$(this))" data-success="Library scan successful" data-error="Error forcing library scan"><i class="fa fa-refresh fa-fw"></i> Force Re-scan Library [Comprehensive]</a>
|
<a href="javascript:void(0)" onclick="doAjaxCall('forceScan',$(this))" data-success="Library scan successful" data-error="Error forcing library scan"><i class="fa fa-refresh fa-fw"></i> Force Re-scan Library [Comprehensive]</a>
|
||||||
<BR>
|
<BR>
|
||||||
<small>*Warning: If you choose [Force Re-scan Library], any manually ignored/matched artists/albums will be reset to "unmatched".</small>
|
<small>*Warning: If you choose [Force Re-scan Library], any manually ignored/matched artists/albums will be reset to "unmatched".</small>
|
||||||
|
|
||||||
@@ -177,8 +176,7 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<div id="dialog-confirm"></div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</%def>
|
</%def>
|
||||||
@@ -188,6 +186,55 @@
|
|||||||
$('#autoadd').append('<input type="hidden" name="scan" value=1 />');
|
$('#autoadd').append('<input type="hidden" name="scan" value=1 />');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function fnOpenNormalDialog(id) {
|
||||||
|
if (id === "post_process"){
|
||||||
|
var url = "forcePostProcess?"
|
||||||
|
}
|
||||||
|
if (id === "post_process_alternate"){
|
||||||
|
var dir = $('#dir').val();
|
||||||
|
var url = "forcePostProcess?dir=" + dir + "&"
|
||||||
|
}
|
||||||
|
if (id === "post_process_single"){
|
||||||
|
var dir = $('#album_dir').val();
|
||||||
|
var url = "forcePostProcess?album_dir=" + dir + "&"
|
||||||
|
}
|
||||||
|
var t = $('<a data-success="Post-Processor is being loaded" data-error="Error during Post-Processing">');
|
||||||
|
$("#dialog-confirm").html("Do you want to keep the original folder(s) after post-processing? If you click no, the folders still might be kept depending on your global settings");
|
||||||
|
|
||||||
|
// Define the Dialog and its properties.
|
||||||
|
$("#dialog-confirm").dialog({
|
||||||
|
resizable: false,
|
||||||
|
modal: true,
|
||||||
|
title: "Keep original folder(s)?",
|
||||||
|
height: 170,
|
||||||
|
width: 400,
|
||||||
|
buttons: {
|
||||||
|
"Yes": function () {
|
||||||
|
$(this).dialog('close');
|
||||||
|
doAjaxCall(url + "keep_original_folder=True", t);
|
||||||
|
},
|
||||||
|
"No": function () {
|
||||||
|
$(this).dialog('close');
|
||||||
|
doAjaxCall(url + "keep_original_folder=False", t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$('.btnOpenDialog').click(function(e){
|
||||||
|
e.preventDefault();
|
||||||
|
var parentId = $(this).closest('div').prop('id');
|
||||||
|
fnOpenNormalDialog(parentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
function callback(value) {
|
||||||
|
if (value) {
|
||||||
|
alert("Confirmed");
|
||||||
|
} else {
|
||||||
|
alert("Rejected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initThisPage() {
|
function initThisPage() {
|
||||||
$('#manage_albums').click(function() {
|
$('#manage_albums').click(function() {
|
||||||
$('#dialog').dialog();
|
$('#dialog').dialog();
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<%def name="body()">
|
<%def name="body()">
|
||||||
<div class="table_wrapper">
|
<div class="table_wrapper">
|
||||||
<div id="manageheader" class="title">
|
<div id="manageheader" class="title">
|
||||||
<h1 class="clearfix"><i class="fa fa-music"></i> Manage Manually Changed Albums</h1>
|
<h1 class="clearfix"><i class="fa fa-music"></i> Manage Manually Matched Albums</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table class="display" id="artist_table">
|
<table class="display" id="artist_table">
|
||||||
@@ -37,30 +37,30 @@
|
|||||||
old_album_clean = album['AlbumTitle'].replace('&','%26').replace('+', '%2B').replace("'","%27")
|
old_album_clean = album['AlbumTitle'].replace('&','%26').replace('+', '%2B').replace("'","%27")
|
||||||
%>
|
%>
|
||||||
<td id="artist">${album['ArtistName']}<BR>
|
<td id="artist">${album['ArtistName']}<BR>
|
||||||
<button id="restore_artist${count_albums}" onClick="restore_Artist(this.id)">(<-) Restore Artist</button>
|
<button id="reset_artist${count_albums}" onClick="reset_Artist(this.id)">(<-) Reset Artist</button>
|
||||||
<div id="restore_artist_dialog${count_albums}" title="Restore Artist" style="display:none">
|
<div id="reset_artist_dialog${count_albums}" title="Reset Artist" style="display:none">
|
||||||
<table>
|
<table>
|
||||||
<tr><td>Are you sure you want to restore Local Artist: ${album['ArtistName']} to unmatched?</td></tr>
|
<tr><td>Are you sure you want to reset Local Artist: ${album['ArtistName']} to unmatched?</td></tr>
|
||||||
<tr><td align="right"><BR>
|
<tr><td align="right"><BR>
|
||||||
%if album['AlbumStatus'] == "Ignored":
|
%if album['AlbumStatus'] == "Ignored":
|
||||||
<button href="#" onclick="doAjaxCall('markManual?action=unignoreArtist&existing_artist=${old_artist_clean}', $(this), 'page');" data-success="Successfully restored ${album['ArtistName']} to unmatched">Restore Artist</button>
|
<button href="javascript:void(0)" onclick="doAjaxCall('markManual?action=unignoreArtist&existing_artist=${old_artist_clean}', $(this), 'page');" data-success="Successfully reset ${album['ArtistName']} to unmatched">Reset Artist</button>
|
||||||
%elif album['AlbumStatus'] == "Matched":
|
%elif album['AlbumStatus'] == "Matched":
|
||||||
<button href="#" onclick="doAjaxCall('markManual?action=unmatchArtist&existing_artist=${old_artist_clean}', $(this), 'page');" data-success="Successfully restored ${album['ArtistName']} to unmatched">Restore Artist</button>
|
<button href="javascript:void(0)" onclick="doAjaxCall('markManual?action=unmatchArtist&existing_artist=${old_artist_clean}', $(this), 'page');" data-success="Successfully restored ${album['ArtistName']} to unmatched">Reset Artist</button>
|
||||||
%endif
|
%endif
|
||||||
</td></tr>
|
</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td id="album">${album['AlbumTitle']}<BR>
|
<td id="album">${album['AlbumTitle']}<BR>
|
||||||
<button id="restore_album${count_albums}" onClick="restore_Album(this.id)">(<-) Restore Album</button>
|
<button id="reset_album${count_albums}" onClick="reset_Album(this.id)">(<-) Reset Album</button>
|
||||||
<div id="restore_album_dialog${count_albums}" title="Restore Album" style="display:none">
|
<div id="reset_album_dialog${count_albums}" title="Reset Album" style="display:none">
|
||||||
<table>
|
<table>
|
||||||
<tr><td>Are you sure you want to restore Local Album: ${album['AlbumTitle']} to unmatched?</td></tr>
|
<tr><td>Are you sure you want to reset Local Album: ${album['AlbumTitle']} to unmatched?</td></tr>
|
||||||
<tr><td align="right"><BR>
|
<tr><td align="right"><BR>
|
||||||
%if album['AlbumStatus'] == "Ignored":
|
%if album['AlbumStatus'] == "Ignored":
|
||||||
<button href="#" onclick="doAjaxCall('markManual?action=unignoreAlbum&existing_artist=${old_artist_clean}&existing_album=${old_album_clean}', $(this), 'page');" data-success="Successfully restored ${album['AlbumTitle']} to unmatched">Restore Album</button>
|
<button href="javascript:void(0)" onclick="doAjaxCall('markManual?action=unignoreAlbum&existing_artist=${old_artist_clean}&existing_album=${old_album_clean}', $(this), 'page');" data-success="Successfully reset ${album['AlbumTitle']} to unmatched">Reset Album</button>
|
||||||
%elif album['AlbumStatus'] == "Matched":
|
%elif album['AlbumStatus'] == "Matched":
|
||||||
<button href="#" onclick="doAjaxCall('markManual?action=unmatchAlbum&existing_artist=${old_artist_clean}&existing_album=${old_album_clean}', $(this), 'page');" data-success="Successfully restored ${album['AlbumTitle']} to unmatched">Restore Album</button>
|
<button href="javascript:void(0)" onclick="doAjaxCall('markManual?action=unmatchAlbum&existing_artist=${old_artist_clean}&existing_album=${old_album_clean}', $(this), 'page');" data-success="Successfully reset ${album['AlbumTitle']} to unmatched">Reset Album</button>
|
||||||
%endif
|
%endif
|
||||||
</td></tr>
|
</td></tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -107,15 +107,15 @@
|
|||||||
initActions();
|
initActions();
|
||||||
});
|
});
|
||||||
|
|
||||||
function restore_Artist(clicked_id) {
|
function reset_Artist(clicked_id) {
|
||||||
n=clicked_id.replace("restore_artist","");
|
n=clicked_id.replace("reset_artist","");
|
||||||
$("#restore_artist_dialog"+n).dialog();
|
$("#reset_artist_dialog"+n).dialog();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function restore_Album(clicked_id) {
|
function reset_Album(clicked_id) {
|
||||||
n=clicked_id.replace("restore_album","");
|
n=clicked_id.replace("reset_album","");
|
||||||
$("#restore_album_dialog"+n).dialog();
|
$("#reset_album_dialog"+n).dialog();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
<table>
|
<table>
|
||||||
<tr><td>Are you sure you want to ignore Local Artist: ${album['ArtistName']} from future matching?</td></tr>
|
<tr><td>Are you sure you want to ignore Local Artist: ${album['ArtistName']} from future matching?</td></tr>
|
||||||
<tr><td align="right"><BR>
|
<tr><td align="right"><BR>
|
||||||
<button href="#" onclick="doAjaxCall('markUnmatched?action=ignoreArtist&existing_artist=${old_artist_clean}', $(this), 'page');" data-success="Successfully ignored ${album['ArtistName']} from future matching">Ignore Artist</button>
|
<button href="javascript:void(0)" onclick="doAjaxCall('markUnmatched?action=ignoreArtist&existing_artist=${old_artist_clean}', $(this), 'page');" data-success="Successfully ignored ${album['ArtistName']} from future matching">Ignore Artist</button>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
<tr><td></td><td align="right"><BR>
|
<tr><td></td><td align="right"><BR>
|
||||||
<button href="#" onclick="artist_matcher(${count_albums}, '${old_artist_js}')">Match Artist</button>
|
<button href="javascript:void(0)" onclick="artist_matcher(${count_albums}, '${old_artist_js}')">Match Artist</button>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
<table>
|
<table>
|
||||||
<tr><td>Are you sure you want to ignore Local Album: ${album['AlbumTitle']} from future matching?</td></tr>
|
<tr><td>Are you sure you want to ignore Local Album: ${album['AlbumTitle']} from future matching?</td></tr>
|
||||||
<tr><td align="right"><BR>
|
<tr><td align="right"><BR>
|
||||||
<button href="#" onclick="doAjaxCall('markUnmatched?action=ignoreAlbum&existing_artist=${old_artist_clean}&existing_album=${old_album_clean}', $(this), 'page');" data-success="Successfully ignored ${album['AlbumTitle']} from future matching">Ignore Album</button>
|
<button href="javascript:void(0)" onclick="doAjaxCall('markUnmatched?action=ignoreAlbum&existing_artist=${old_artist_clean}&existing_album=${old_album_clean}', $(this), 'page');" data-success="Successfully ignored ${album['AlbumTitle']} from future matching">Ignore Album</button>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
<tr><td></td><td align="right"><BR>
|
<tr><td></td><td align="right"><BR>
|
||||||
<button href="#" onclick="album_matcher(${count_albums}, '${old_artist_js}', '${old_album_js}')">Match Album</button>
|
<button href="javascript:void(0)" onclick="album_matcher(${count_albums}, '${old_artist_js}', '${old_album_js}')">Match Album</button>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,8 +17,12 @@
|
|||||||
<th id="reldate">Date</th>
|
<th id="reldate">Date</th>
|
||||||
<th id="scoresmall">Score</th>
|
<th id="scoresmall">Score</th>
|
||||||
<th id="mbrelid" style="display:none;"</th>
|
<th id="mbrelid" style="display:none;"</th>
|
||||||
%else:
|
%elif type == 'artist':
|
||||||
<th id="artistname">Artist Name</th>
|
<th id="artistname">Artist Name</th>
|
||||||
|
<th id="score">Score</th>
|
||||||
|
%else:
|
||||||
|
<th id="seriesname">Series Name</th>
|
||||||
|
<th id="type">Type</th>
|
||||||
<th id="score">Score</th>
|
<th id="score">Score</th>
|
||||||
%endif
|
%endif
|
||||||
<th id="mb"></th>
|
<th id="mb"></th>
|
||||||
@@ -40,8 +44,10 @@
|
|||||||
<tr class="grade${grade}">
|
<tr class="grade${grade}">
|
||||||
%if type == 'album':
|
%if type == 'album':
|
||||||
<td id="albumart" style=" text-align: center; vertical-align: middle;"><div id="artistImg"><img title="${result['albumid']}" class="albumArt" height="50" width="50" onerror="tryCCA(this, '${caa_group_url}')"></div></td>
|
<td id="albumart" style=" text-align: center; vertical-align: middle;"><div id="artistImg"><img title="${result['albumid']}" class="albumArt" height="50" width="50" onerror="tryCCA(this, '${caa_group_url}')"></div></td>
|
||||||
%else:
|
%elif type == 'artist':
|
||||||
<td id="albumart"><div id="artistImg"><img title="${result['id']}" class="albumArt" height="50" width="50"></div></td>
|
<td id="albumart"><div id="artistImg"><img title="${result['id']}" class="albumArt" height="50" width="50"></div></td>
|
||||||
|
%else:
|
||||||
|
<td id="albumart"></td>
|
||||||
%endif
|
%endif
|
||||||
%if type == 'album':
|
%if type == 'album':
|
||||||
<td id="albumname"><a href="addReleaseById?rid=${result['albumid']}&rgid=${result['rgid']}" title="${albuminfo}">${result['title']}</a></td>
|
<td id="albumname"><a href="addReleaseById?rid=${result['albumid']}&rgid=${result['rgid']}" title="${albuminfo}">${result['title']}</a></td>
|
||||||
@@ -52,9 +58,14 @@
|
|||||||
<td id="score"><a href="${result['albumurl']} "title="View on MusicBrainz"><div class="bar"><div class="score" style="width: ${result['score']}px">${result['score']}</div></div></a></td>
|
<td id="score"><a href="${result['albumurl']} "title="View on MusicBrainz"><div class="bar"><div class="score" style="width: ${result['score']}px">${result['score']}</div></div></a></td>
|
||||||
<td id="musicbrainz" style=" text-align: center; line-height: 0; vertical-align: middle;"><a href="${result['albumurl']}"><img src="interfaces/default/images/MusicBrainz_Album_Icon.png" title="View on MusicBrainz" height="20" width="20"></a></td>
|
<td id="musicbrainz" style=" text-align: center; line-height: 0; vertical-align: middle;"><a href="${result['albumurl']}"><img src="interfaces/default/images/MusicBrainz_Album_Icon.png" title="View on MusicBrainz" height="20" width="20"></a></td>
|
||||||
<td id="mbrelid" style="display:none;">${result['albumid']}</td>
|
<td id="mbrelid" style="display:none;">${result['albumid']}</td>
|
||||||
%else:
|
%elif type == 'artist':
|
||||||
<td id="artistname"><a href="addArtist?artistid=${result['id']}" title="${result['uniquename']}">${result['uniquename']}</a></td>
|
<td id="artistname"><a href="addArtist?artistid=${result['id']}" title="${result['uniquename']}">${result['uniquename']}</a></td>
|
||||||
<td id="score"><a href="${result['url']} "title="View on MusicBrainz"><div class="bar"><div class="score" style="width: ${result['score']}px">${result['score']}</div></div></a></td>
|
<td id="score"><a href="${result['url']} "title="View on MusicBrainz"><div class="bar"><div class="score" style="width: ${result['score']}px">${result['score']}</div></div></a></td>
|
||||||
|
<td id="musicbrainz" style=" text-align: center; line-height: 0; vertical-align: middle;"><a href="${result['url']}"><img src="interfaces/default/images/MusicBrainz_Artist_Icon.png" title="View on MusicBrainz" height="20" width="20"></a></td>
|
||||||
|
%else:
|
||||||
|
<td id="seriesname"><a href="addSeries?seriesid=${result['id']}" title="${result['uniquename']}">${result['uniquename']}</a></td>
|
||||||
|
<td id="type">${result['type']}</td>
|
||||||
|
<td id="score"><a href="${result['url']} "title="View on MusicBrainz"><div class="bar"><div class="score" style="width: ${result['score']}px">${result['score']}</div></div></a></td>
|
||||||
<td id="musicbrainz" style=" text-align: center; line-height: 0; vertical-align: middle;"><a href="${result['url']}"><img src="interfaces/default/images/MusicBrainz_Artist_Icon.png" title="View on MusicBrainz" height="20" width="20"></a></td>
|
<td id="musicbrainz" style=" text-align: center; line-height: 0; vertical-align: middle;"><a href="${result['url']}"><img src="interfaces/default/images/MusicBrainz_Artist_Icon.png" title="View on MusicBrainz" height="20" width="20"></a></td>
|
||||||
%endif
|
%endif
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<%def name="headerIncludes()">
|
<%def name="headerIncludes()">
|
||||||
<div id="subhead_container">
|
<div id="subhead_container">
|
||||||
<div id="subhead_menu">
|
<div id="subhead_menu">
|
||||||
<a href="#" id="menu_link_scan" onclick="doAjaxCall('forceSearch',$(this))" data-success="Checking for wanted albums successful" data-error="Error checking wanted albums"><i class="fa fa-search"></i> Force Check</a>
|
<a href="javascript:void(0)" id="menu_link_scan" onclick="doAjaxCall('forceSearch',$(this))" data-success="Checking for wanted albums successful" data-error="Error checking wanted albums"><i class="fa fa-search"></i> Force Check</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</%def>
|
</%def>
|
||||||
|
|||||||
+140
-47
@@ -16,19 +16,21 @@
|
|||||||
# NZBGet support added by CurlyMo <curlymoo1@gmail.com> as a part of
|
# NZBGet support added by CurlyMo <curlymoo1@gmail.com> as a part of
|
||||||
# XBian - XBMC on the Raspberry Pi
|
# XBian - XBMC on the Raspberry Pi
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import webbrowser
|
import webbrowser
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import cherrypy
|
import datetime
|
||||||
|
|
||||||
|
import os
|
||||||
|
import cherrypy
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from apscheduler.triggers.interval import IntervalTrigger
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
|
||||||
from headphones import versioncheck, logger
|
from headphones import versioncheck, logger
|
||||||
import headphones.config
|
import headphones.config
|
||||||
|
from headphones.softchroot import SoftChroot
|
||||||
|
import headphones.exceptions
|
||||||
|
|
||||||
# (append new extras to the end)
|
# (append new extras to the end)
|
||||||
POSSIBLE_EXTRAS = [
|
POSSIBLE_EXTRAS = [
|
||||||
@@ -64,6 +66,7 @@ CREATEPID = False
|
|||||||
PIDFILE = None
|
PIDFILE = None
|
||||||
|
|
||||||
SCHED = BackgroundScheduler()
|
SCHED = BackgroundScheduler()
|
||||||
|
SCHED_LOCK = threading.Lock()
|
||||||
|
|
||||||
INIT_LOCK = threading.Lock()
|
INIT_LOCK = threading.Lock()
|
||||||
_INITIALIZED = False
|
_INITIALIZED = False
|
||||||
@@ -72,6 +75,7 @@ started = False
|
|||||||
DATA_DIR = None
|
DATA_DIR = None
|
||||||
|
|
||||||
CONFIG = None
|
CONFIG = None
|
||||||
|
SOFT_CHROOT = None
|
||||||
|
|
||||||
DB_FILE = None
|
DB_FILE = None
|
||||||
|
|
||||||
@@ -92,10 +96,10 @@ UMASK = None
|
|||||||
|
|
||||||
|
|
||||||
def initialize(config_file):
|
def initialize(config_file):
|
||||||
|
|
||||||
with INIT_LOCK:
|
with INIT_LOCK:
|
||||||
|
|
||||||
global CONFIG
|
global CONFIG
|
||||||
|
global SOFT_CHROOT
|
||||||
global _INITIALIZED
|
global _INITIALIZED
|
||||||
global CURRENT_VERSION
|
global CURRENT_VERSION
|
||||||
global LATEST_VERSION
|
global LATEST_VERSION
|
||||||
@@ -125,12 +129,23 @@ def initialize(config_file):
|
|||||||
try:
|
try:
|
||||||
os.makedirs(CONFIG.LOG_DIR)
|
os.makedirs(CONFIG.LOG_DIR)
|
||||||
except OSError:
|
except OSError:
|
||||||
if VERBOSE:
|
CONFIG.LOG_DIR = None
|
||||||
sys.stderr.write(
|
|
||||||
'Unable to create the log directory. Logging to screen only.\n')
|
if not QUIET:
|
||||||
|
sys.stderr.write("Unable to create the log directory. " \
|
||||||
|
"Logging to screen only.\n")
|
||||||
|
|
||||||
# Start the logger, disable console if needed
|
# Start the logger, disable console if needed
|
||||||
logger.initLogger(console=not QUIET, verbose=VERBOSE)
|
logger.initLogger(console=not QUIET, log_dir=CONFIG.LOG_DIR,
|
||||||
|
verbose=VERBOSE)
|
||||||
|
|
||||||
|
try:
|
||||||
|
SOFT_CHROOT = SoftChroot(str(CONFIG.SOFT_CHROOT))
|
||||||
|
if SOFT_CHROOT.isEnabled():
|
||||||
|
logger.info("Soft-chroot enabled for dir: %s", str(CONFIG.SOFT_CHROOT))
|
||||||
|
except exceptions.SoftChrootError as e:
|
||||||
|
logger.error("SoftChroot error: %s", e)
|
||||||
|
raise e
|
||||||
|
|
||||||
if not CONFIG.CACHE_DIR:
|
if not CONFIG.CACHE_DIR:
|
||||||
# Put the cache dir in the data dir for now
|
# Put the cache dir in the data dir for now
|
||||||
@@ -138,13 +153,12 @@ def initialize(config_file):
|
|||||||
if not os.path.exists(CONFIG.CACHE_DIR):
|
if not os.path.exists(CONFIG.CACHE_DIR):
|
||||||
try:
|
try:
|
||||||
os.makedirs(CONFIG.CACHE_DIR)
|
os.makedirs(CONFIG.CACHE_DIR)
|
||||||
except OSError:
|
except OSError as e:
|
||||||
logger.error(
|
logger.error("Could not create cache dir '%s': %s", DATA_DIR, e)
|
||||||
'Could not create cache dir. Check permissions of datadir: %s', DATA_DIR)
|
|
||||||
|
|
||||||
# Sanity check for search interval. Set it to at least 6 hours
|
# Sanity check for search interval. Set it to at least 6 hours
|
||||||
if CONFIG.SEARCH_INTERVAL < 360:
|
if CONFIG.SEARCH_INTERVAL and CONFIG.SEARCH_INTERVAL < 360:
|
||||||
logger.info("Search interval too low. Resetting to 6 hour minimum")
|
logger.info("Search interval too low. Resetting to 6 hour minimum.")
|
||||||
CONFIG.SEARCH_INTERVAL = 360
|
CONFIG.SEARCH_INTERVAL = 360
|
||||||
|
|
||||||
# Initialize the database
|
# Initialize the database
|
||||||
@@ -154,12 +168,25 @@ def initialize(config_file):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Can't connect to the database: %s", e)
|
logger.error("Can't connect to the database: %s", e)
|
||||||
|
|
||||||
# Get the currently installed version - returns None, 'win32' or the git hash
|
# Get the currently installed version. Returns None, 'win32' or the git
|
||||||
# Also sets INSTALL_TYPE variable to 'win', 'git' or 'source'
|
# hash.
|
||||||
CURRENT_VERSION, CONFIG.GIT_BRANCH = versioncheck.getVersion()
|
CURRENT_VERSION, CONFIG.GIT_BRANCH = versioncheck.getVersion()
|
||||||
|
|
||||||
|
# Write current version to a file, so we know which version did work.
|
||||||
|
# This allowes one to restore to that version. The idea is that if we
|
||||||
|
# arrive here, most parts of Headphones seem to work.
|
||||||
|
if CURRENT_VERSION:
|
||||||
|
version_lock_file = os.path.join(DATA_DIR, "version.lock")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(version_lock_file, "w") as fp:
|
||||||
|
fp.write(CURRENT_VERSION)
|
||||||
|
except IOError as e:
|
||||||
|
logger.error("Unable to write current version to file '%s': %s",
|
||||||
|
version_lock_file, e)
|
||||||
|
|
||||||
# Check for new versions
|
# Check for new versions
|
||||||
if CONFIG.CHECK_GITHUB_ON_STARTUP:
|
if CONFIG.CHECK_GITHUB and CONFIG.CHECK_GITHUB_ON_STARTUP:
|
||||||
try:
|
try:
|
||||||
LATEST_VERSION = versioncheck.checkGithub()
|
LATEST_VERSION = versioncheck.checkGithub()
|
||||||
except:
|
except:
|
||||||
@@ -229,7 +256,6 @@ def daemonize():
|
|||||||
|
|
||||||
|
|
||||||
def launch_browser(host, port, root):
|
def launch_browser(host, port, root):
|
||||||
|
|
||||||
if host == '0.0.0.0':
|
if host == '0.0.0.0':
|
||||||
host = 'localhost'
|
host = 'localhost'
|
||||||
|
|
||||||
@@ -244,36 +270,85 @@ def launch_browser(host, port, root):
|
|||||||
logger.error('Could not launch browser: %s', e)
|
logger.error('Could not launch browser: %s', e)
|
||||||
|
|
||||||
|
|
||||||
def start():
|
def initialize_scheduler():
|
||||||
|
"""
|
||||||
|
Start the scheduled background tasks. Re-schedule if interval settings changed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from headphones import updater, searcher, librarysync, postprocessor, \
|
||||||
|
torrentfinished
|
||||||
|
|
||||||
|
with SCHED_LOCK:
|
||||||
|
|
||||||
|
# Check if scheduler should be started
|
||||||
|
start_jobs = not len(SCHED.get_jobs())
|
||||||
|
|
||||||
|
# Regular jobs
|
||||||
|
minutes = CONFIG.SEARCH_INTERVAL
|
||||||
|
schedule_job(searcher.searchforalbum, 'Search for Wanted', hours=0, minutes=minutes)
|
||||||
|
|
||||||
|
minutes = CONFIG.DOWNLOAD_SCAN_INTERVAL
|
||||||
|
schedule_job(postprocessor.checkFolder, 'Download Scan', hours=0, minutes=minutes)
|
||||||
|
|
||||||
|
hours = CONFIG.LIBRARYSCAN_INTERVAL
|
||||||
|
schedule_job(librarysync.libraryScan, 'Library Scan', hours=hours, minutes=0)
|
||||||
|
|
||||||
|
hours = CONFIG.UPDATE_DB_INTERVAL
|
||||||
|
schedule_job(updater.dbUpdate, 'MusicBrainz Update', hours=hours, minutes=0)
|
||||||
|
|
||||||
|
# Update check
|
||||||
|
if CONFIG.CHECK_GITHUB:
|
||||||
|
if CONFIG.CHECK_GITHUB_INTERVAL:
|
||||||
|
minutes = CONFIG.CHECK_GITHUB_INTERVAL
|
||||||
|
else:
|
||||||
|
minutes = 0
|
||||||
|
schedule_job(versioncheck.checkGithub, 'Check GitHub for updates', hours=0,
|
||||||
|
minutes=minutes)
|
||||||
|
|
||||||
|
# Remove Torrent + data if Post Processed and finished Seeding
|
||||||
|
minutes = CONFIG.TORRENT_REMOVAL_INTERVAL
|
||||||
|
schedule_job(torrentfinished.checkTorrentFinished, 'Torrent removal check', hours=0,
|
||||||
|
minutes=minutes)
|
||||||
|
|
||||||
|
# Start scheduler
|
||||||
|
if start_jobs and len(SCHED.get_jobs()):
|
||||||
|
try:
|
||||||
|
SCHED.start()
|
||||||
|
except Exception as e:
|
||||||
|
logger.info(e)
|
||||||
|
|
||||||
|
# Debug
|
||||||
|
# SCHED.print_jobs()
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_job(function, name, hours=0, minutes=0):
|
||||||
|
"""
|
||||||
|
Start scheduled job if starting or restarting headphones.
|
||||||
|
Reschedule job if Interval Settings have changed.
|
||||||
|
Remove job if if Interval Settings changed to 0
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
job = SCHED.get_job(name)
|
||||||
|
if job:
|
||||||
|
if hours == 0 and minutes == 0:
|
||||||
|
SCHED.remove_job(name)
|
||||||
|
logger.info("Removed background task: %s", name)
|
||||||
|
elif job.trigger.interval != datetime.timedelta(hours=hours, minutes=minutes):
|
||||||
|
SCHED.reschedule_job(name, trigger=IntervalTrigger(
|
||||||
|
hours=hours, minutes=minutes))
|
||||||
|
logger.info("Re-scheduled background task: %s", name)
|
||||||
|
elif hours > 0 or minutes > 0:
|
||||||
|
SCHED.add_job(function, id=name, trigger=IntervalTrigger(
|
||||||
|
hours=hours, minutes=minutes))
|
||||||
|
logger.info("Scheduled background task: %s", name)
|
||||||
|
|
||||||
|
|
||||||
|
def start():
|
||||||
global started
|
global started
|
||||||
|
|
||||||
if _INITIALIZED:
|
if _INITIALIZED:
|
||||||
|
initialize_scheduler()
|
||||||
# Start our scheduled background tasks
|
|
||||||
from headphones import updater, searcher, librarysync, postprocessor, torrentfinished
|
|
||||||
SCHED.add_job(updater.dbUpdate, trigger=IntervalTrigger(
|
|
||||||
hours=CONFIG.UPDATE_DB_INTERVAL))
|
|
||||||
SCHED.add_job(searcher.searchforalbum, trigger=IntervalTrigger(
|
|
||||||
minutes=CONFIG.SEARCH_INTERVAL))
|
|
||||||
SCHED.add_job(librarysync.libraryScan, trigger=IntervalTrigger(
|
|
||||||
hours=CONFIG.LIBRARYSCAN_INTERVAL))
|
|
||||||
|
|
||||||
if CONFIG.CHECK_GITHUB:
|
|
||||||
SCHED.add_job(versioncheck.checkGithub, trigger=IntervalTrigger(
|
|
||||||
minutes=CONFIG.CHECK_GITHUB_INTERVAL))
|
|
||||||
|
|
||||||
if CONFIG.DOWNLOAD_SCAN_INTERVAL > 0:
|
|
||||||
SCHED.add_job(postprocessor.checkFolder, trigger=IntervalTrigger(
|
|
||||||
minutes=CONFIG.DOWNLOAD_SCAN_INTERVAL))
|
|
||||||
|
|
||||||
# Remove Torrent + data if Post Processed and finished Seeding
|
|
||||||
if CONFIG.TORRENT_REMOVAL_INTERVAL > 0:
|
|
||||||
SCHED.add_job(torrentfinished.checkTorrentFinished, trigger=IntervalTrigger(
|
|
||||||
minutes=CONFIG.TORRENT_REMOVAL_INTERVAL))
|
|
||||||
|
|
||||||
SCHED.start()
|
|
||||||
|
|
||||||
started = True
|
started = True
|
||||||
|
|
||||||
|
|
||||||
@@ -284,16 +359,15 @@ def sig_handler(signum=None, frame=None):
|
|||||||
|
|
||||||
|
|
||||||
def dbcheck():
|
def dbcheck():
|
||||||
|
|
||||||
conn = sqlite3.connect(DB_FILE)
|
conn = sqlite3.connect(DB_FILE)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
c.execute(
|
c.execute(
|
||||||
'CREATE TABLE IF NOT EXISTS artists (ArtistID TEXT UNIQUE, ArtistName TEXT, ArtistSortName TEXT, DateAdded TEXT, Status TEXT, IncludeExtras INTEGER, LatestAlbum TEXT, ReleaseDate TEXT, AlbumID TEXT, HaveTracks INTEGER, TotalTracks INTEGER, LastUpdated TEXT, ArtworkURL TEXT, ThumbURL TEXT, Extras TEXT)')
|
'CREATE TABLE IF NOT EXISTS artists (ArtistID TEXT UNIQUE, ArtistName TEXT, ArtistSortName TEXT, DateAdded TEXT, Status TEXT, IncludeExtras INTEGER, LatestAlbum TEXT, ReleaseDate TEXT, AlbumID TEXT, HaveTracks INTEGER, TotalTracks INTEGER, LastUpdated TEXT, ArtworkURL TEXT, ThumbURL TEXT, Extras TEXT, Type TEXT, MetaCritic TEXT)')
|
||||||
# ReleaseFormat here means CD,Digital,Vinyl, etc. If using the default
|
# ReleaseFormat here means CD,Digital,Vinyl, etc. If using the default
|
||||||
# Headphones hybrid release, ReleaseID will equal AlbumID (AlbumID is
|
# Headphones hybrid release, ReleaseID will equal AlbumID (AlbumID is
|
||||||
# releasegroup id)
|
# releasegroup id)
|
||||||
c.execute(
|
c.execute(
|
||||||
'CREATE TABLE IF NOT EXISTS albums (ArtistID TEXT, ArtistName TEXT, AlbumTitle TEXT, AlbumASIN TEXT, ReleaseDate TEXT, DateAdded TEXT, AlbumID TEXT UNIQUE, Status TEXT, Type TEXT, ArtworkURL TEXT, ThumbURL TEXT, ReleaseID TEXT, ReleaseCountry TEXT, ReleaseFormat TEXT, SearchTerm TEXT)')
|
'CREATE TABLE IF NOT EXISTS albums (ArtistID TEXT, ArtistName TEXT, AlbumTitle TEXT, AlbumASIN TEXT, ReleaseDate TEXT, DateAdded TEXT, AlbumID TEXT UNIQUE, Status TEXT, Type TEXT, ArtworkURL TEXT, ThumbURL TEXT, ReleaseID TEXT, ReleaseCountry TEXT, ReleaseFormat TEXT, SearchTerm TEXT, CriticScore TEXT, UserScore TEXT)')
|
||||||
# Format here means mp3, flac, etc.
|
# Format here means mp3, flac, etc.
|
||||||
c.execute(
|
c.execute(
|
||||||
'CREATE TABLE IF NOT EXISTS tracks (ArtistID TEXT, ArtistName TEXT, AlbumTitle TEXT, AlbumASIN TEXT, AlbumID TEXT, TrackTitle TEXT, TrackDuration, TrackID TEXT, TrackNumber INTEGER, Location TEXT, BitRate INTEGER, CleanName TEXT, Format TEXT, ReleaseID TEXT)')
|
'CREATE TABLE IF NOT EXISTS tracks (ArtistID TEXT, ArtistName TEXT, AlbumTitle TEXT, AlbumASIN TEXT, AlbumID TEXT, TrackTitle TEXT, TrackDuration, TrackID TEXT, TrackNumber INTEGER, Location TEXT, BitRate INTEGER, CleanName TEXT, Format TEXT, ReleaseID TEXT)')
|
||||||
@@ -519,12 +593,31 @@ def dbcheck():
|
|||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
c.execute('ALTER TABLE albums ADD COLUMN SearchTerm TEXT DEFAULT NULL')
|
c.execute('ALTER TABLE albums ADD COLUMN SearchTerm TEXT DEFAULT NULL')
|
||||||
|
|
||||||
|
try:
|
||||||
|
c.execute('SELECT CriticScore from albums')
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
c.execute('ALTER TABLE albums ADD COLUMN CriticScore TEXT DEFAULT NULL')
|
||||||
|
|
||||||
|
try:
|
||||||
|
c.execute('SELECT UserScore from albums')
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
c.execute('ALTER TABLE albums ADD COLUMN UserScore TEXT DEFAULT NULL')
|
||||||
|
|
||||||
|
try:
|
||||||
|
c.execute('SELECT Type from artists')
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
c.execute('ALTER TABLE artists ADD COLUMN Type TEXT DEFAULT NULL')
|
||||||
|
|
||||||
|
try:
|
||||||
|
c.execute('SELECT MetaCritic from artists')
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
c.execute('ALTER TABLE artists ADD COLUMN MetaCritic TEXT DEFAULT NULL')
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
c.close()
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
def shutdown(restart=False, update=False):
|
def shutdown(restart=False, update=False):
|
||||||
|
|
||||||
cherrypy.engine.exit()
|
cherrypy.engine.exit()
|
||||||
SCHED.shutdown(wait=False)
|
SCHED.shutdown(wait=False)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#import unittest
|
||||||
|
#import mock
|
||||||
|
from headphones.unittestcompat import TestCase
|
||||||
|
|
||||||
|
import headphones.albumart
|
||||||
|
|
||||||
|
|
||||||
|
# no tests...
|
||||||
|
class AlbumArtTest(TestCase):
|
||||||
|
def test_nothing(self):
|
||||||
|
x = 100 - 2 * 50
|
||||||
|
if x:
|
||||||
|
headphones.albumart.getAlbumArt('asdf')
|
||||||
|
self.assertTrue(True)
|
||||||
@@ -53,7 +53,6 @@ def switch(AlbumID, ReleaseID):
|
|||||||
c.get_artwork_from_cache(AlbumID=AlbumID)
|
c.get_artwork_from_cache(AlbumID=AlbumID)
|
||||||
|
|
||||||
for track in newtrackdata:
|
for track in newtrackdata:
|
||||||
|
|
||||||
controlValueDict = {"TrackID": track['TrackID'],
|
controlValueDict = {"TrackID": track['TrackID'],
|
||||||
"AlbumID": AlbumID}
|
"AlbumID": AlbumID}
|
||||||
|
|
||||||
@@ -79,15 +78,18 @@ def switch(AlbumID, ReleaseID):
|
|||||||
have_track_count = len(myDB.select(
|
have_track_count = len(myDB.select(
|
||||||
'SELECT * from tracks WHERE AlbumID=? AND Location IS NOT NULL', [AlbumID]))
|
'SELECT * from tracks WHERE AlbumID=? AND Location IS NOT NULL', [AlbumID]))
|
||||||
|
|
||||||
if oldalbumdata['Status'] == 'Skipped' and ((have_track_count / float(total_track_count)) >= (headphones.CONFIG.ALBUM_COMPLETION_PCT / 100.0)):
|
if oldalbumdata['Status'] == 'Skipped' and ((have_track_count / float(total_track_count)) >= (
|
||||||
|
headphones.CONFIG.ALBUM_COMPLETION_PCT / 100.0)):
|
||||||
myDB.action(
|
myDB.action(
|
||||||
'UPDATE albums SET Status=? WHERE AlbumID=?', ['Downloaded', AlbumID])
|
'UPDATE albums SET Status=? WHERE AlbumID=?', ['Downloaded', AlbumID])
|
||||||
|
|
||||||
# Update have track counts on index
|
# Update have track counts on index
|
||||||
totaltracks = len(myDB.select(
|
totaltracks = len(myDB.select(
|
||||||
'SELECT TrackTitle from tracks WHERE ArtistID=? AND AlbumID IN (SELECT AlbumID FROM albums WHERE Status != "Ignored")', [newalbumdata['ArtistID']]))
|
'SELECT TrackTitle from tracks WHERE ArtistID=? AND AlbumID IN (SELECT AlbumID FROM albums WHERE Status != "Ignored")',
|
||||||
|
[newalbumdata['ArtistID']]))
|
||||||
havetracks = len(myDB.select(
|
havetracks = len(myDB.select(
|
||||||
'SELECT TrackTitle from tracks WHERE ArtistID=? AND Location IS NOT NULL', [newalbumdata['ArtistID']]))
|
'SELECT TrackTitle from tracks WHERE ArtistID=? AND Location IS NOT NULL',
|
||||||
|
[newalbumdata['ArtistID']]))
|
||||||
|
|
||||||
controlValueDict = {"ArtistID": newalbumdata['ArtistID']}
|
controlValueDict = {"ArtistID": newalbumdata['ArtistID']}
|
||||||
|
|
||||||
|
|||||||
+36
-13
@@ -13,20 +13,25 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from headphones import db, mb, importer, searcher, cache, postprocessor, versioncheck, logger
|
|
||||||
|
|
||||||
import headphones
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
cmd_list = ['getIndex', 'getArtist', 'getAlbum', 'getUpcoming', 'getWanted', 'getSimilar', 'getHistory', 'getLogs',
|
from headphones import db, mb, updater, importer, searcher, cache, postprocessor, versioncheck, \
|
||||||
'findArtist', 'findAlbum', 'addArtist', 'delArtist', 'pauseArtist', 'resumeArtist', 'refreshArtist',
|
logger
|
||||||
'addAlbum', 'queueAlbum', 'unqueueAlbum', 'forceSearch', 'forceProcess', 'getVersion', 'checkGithub',
|
import headphones
|
||||||
'shutdown', 'restart', 'update', 'getArtistArt', 'getAlbumArt', 'getArtistInfo', 'getAlbumInfo',
|
|
||||||
'getArtistThumb', 'getAlbumThumb', 'choose_specific_download', 'download_specific_release']
|
cmd_list = ['getIndex', 'getArtist', 'getAlbum', 'getUpcoming', 'getWanted', 'getSnatched',
|
||||||
|
'getSimilar', 'getHistory', 'getLogs',
|
||||||
|
'findArtist', 'findAlbum', 'addArtist', 'delArtist', 'pauseArtist', 'resumeArtist',
|
||||||
|
'refreshArtist',
|
||||||
|
'addAlbum', 'queueAlbum', 'unqueueAlbum', 'forceSearch', 'forceProcess',
|
||||||
|
'forceActiveArtistsUpdate',
|
||||||
|
'getVersion', 'checkGithub', 'shutdown', 'restart', 'update', 'getArtistArt',
|
||||||
|
'getAlbumArt',
|
||||||
|
'getArtistInfo', 'getAlbumInfo', 'getArtistThumb', 'getAlbumThumb', 'clearLogs',
|
||||||
|
'choose_specific_download', 'download_specific_release']
|
||||||
|
|
||||||
|
|
||||||
class Api(object):
|
class Api(object):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|
||||||
self.apikey = None
|
self.apikey = None
|
||||||
@@ -165,12 +170,23 @@ class Api(object):
|
|||||||
"SELECT * from albums WHERE Status='Wanted'")
|
"SELECT * from albums WHERE Status='Wanted'")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def _getSnatched(self, **kwargs):
|
||||||
|
self.data = self._dic_from_query(
|
||||||
|
"SELECT * from albums WHERE Status='Snatched'")
|
||||||
|
return
|
||||||
|
|
||||||
def _getSimilar(self, **kwargs):
|
def _getSimilar(self, **kwargs):
|
||||||
self.data = self._dic_from_query('SELECT * from lastfmcloud')
|
self.data = self._dic_from_query('SELECT * from lastfmcloud')
|
||||||
return
|
return
|
||||||
|
|
||||||
def _getLogs(self, **kwargs):
|
def _getLogs(self, **kwargs):
|
||||||
pass
|
self.data = headphones.LOG_LIST
|
||||||
|
return
|
||||||
|
|
||||||
|
def _clearLogs(self, **kwargs):
|
||||||
|
headphones.LOG_LIST = []
|
||||||
|
self.data = 'Cleared log'
|
||||||
|
return
|
||||||
|
|
||||||
def _findArtist(self, **kwargs):
|
def _findArtist(self, **kwargs):
|
||||||
if 'name' not in kwargs:
|
if 'name' not in kwargs:
|
||||||
@@ -316,10 +332,18 @@ class Api(object):
|
|||||||
searcher.searchforalbum()
|
searcher.searchforalbum()
|
||||||
|
|
||||||
def _forceProcess(self, **kwargs):
|
def _forceProcess(self, **kwargs):
|
||||||
self.dir = None
|
if 'album_dir' in kwargs:
|
||||||
if 'dir' in kwargs:
|
album_dir = kwargs['album_dir']
|
||||||
|
dir = None
|
||||||
|
postprocessor.forcePostProcess(self, dir, album_dir)
|
||||||
|
elif 'dir' in kwargs:
|
||||||
self.dir = kwargs['dir']
|
self.dir = kwargs['dir']
|
||||||
postprocessor.forcePostProcess(self.dir)
|
postprocessor.forcePostProcess(self.dir)
|
||||||
|
else:
|
||||||
|
postprocessor.forcePostProcess()
|
||||||
|
|
||||||
|
def _forceActiveArtistsUpdate(self, **kwargs):
|
||||||
|
updater.dbUpdate()
|
||||||
|
|
||||||
def _getVersion(self, **kwargs):
|
def _getVersion(self, **kwargs):
|
||||||
self.data = {
|
self.data = {
|
||||||
@@ -417,7 +441,6 @@ class Api(object):
|
|||||||
results_as_dicts = []
|
results_as_dicts = []
|
||||||
|
|
||||||
for result in results:
|
for result in results:
|
||||||
|
|
||||||
result_dict = {
|
result_dict = {
|
||||||
'title': result[0],
|
'title': result[0],
|
||||||
'size': result[1],
|
'size': result[1],
|
||||||
|
|||||||
+65
-42
@@ -14,8 +14,8 @@
|
|||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import headphones
|
|
||||||
|
|
||||||
|
import headphones
|
||||||
from headphones import db, helpers, logger, lastfm, request
|
from headphones import db, helpers, logger, lastfm, request
|
||||||
|
|
||||||
LASTFM_API_KEY = "690e1ed3bc00bc91804cd8f7fe5ed6d4"
|
LASTFM_API_KEY = "690e1ed3bc00bc91804cd8f7fe5ed6d4"
|
||||||
@@ -23,20 +23,22 @@ LASTFM_API_KEY = "690e1ed3bc00bc91804cd8f7fe5ed6d4"
|
|||||||
|
|
||||||
class Cache(object):
|
class Cache(object):
|
||||||
"""
|
"""
|
||||||
This class deals with getting, storing and serving up artwork (album
|
This class deals with getting, storing and serving up artwork (album art,
|
||||||
art, artist images, etc) and info/descriptions (album info, artist descrptions)
|
artist images, etc) and info/descriptions (album info, artist descrptions)
|
||||||
to and from the cache folder. This can be called from within a web interface,
|
to and from the cache folder. This can be called from within a web
|
||||||
for example, using the helper functions getInfo(id) and getArtwork(id), to utilize the cached
|
interface. For example, using the helper functions `getInfo(id)` and
|
||||||
images rather than having to retrieve them every time the page is reloaded.
|
`getArtwork(id)`, to utilize the cached images rather than having to
|
||||||
|
retrieve them every time the page is reloaded.
|
||||||
|
|
||||||
So you can call cache.getArtwork(id) which will return an absolute path
|
You can call `getArtwork(id)` which will return an absolute path to the
|
||||||
to the image file on the local machine, or if the cache directory
|
image file on the local machine, or if the cache directory doesn't exist,
|
||||||
doesn't exist, or can not be written to, it will return a url to the image.
|
or can not be written to, it will return a url to the image.
|
||||||
|
|
||||||
Call cache.getInfo(id) to grab the artist/album info; will return the text description
|
Call `getInfo(id)` to grab the artist/album info. This will return the
|
||||||
|
text description.
|
||||||
|
|
||||||
The basic format for art in the cache is <musicbrainzid>.<date>.<ext>
|
The basic format for art in the cache is `<musicbrainzid>.<date>.<ext>`
|
||||||
and for info it is <musicbrainzid>.<date>.txt
|
and for info it is `<musicbrainzid>.<date>.txt`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
path_to_art_cache = os.path.join(headphones.CONFIG.CACHE_DIR, 'artwork')
|
path_to_art_cache = os.path.join(headphones.CONFIG.CACHE_DIR, 'artwork')
|
||||||
@@ -114,7 +116,7 @@ class Cache(object):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
for image in images:
|
for image in images:
|
||||||
if image['size'] == 'medium':
|
if image['size'] == 'medium' and '#text' in image:
|
||||||
thumb_url = image['#text']
|
thumb_url = image['#text']
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -180,13 +182,18 @@ class Cache(object):
|
|||||||
if ArtistID:
|
if ArtistID:
|
||||||
self.id = ArtistID
|
self.id = ArtistID
|
||||||
self.id_type = 'artist'
|
self.id_type = 'artist'
|
||||||
db_info = myDB.action('SELECT Summary, Content, LastUpdated FROM descriptions WHERE ArtistID=?', [self.id]).fetchone()
|
db_info = myDB.action(
|
||||||
|
'SELECT Summary, Content, LastUpdated FROM descriptions WHERE ArtistID=?',
|
||||||
|
[self.id]).fetchone()
|
||||||
else:
|
else:
|
||||||
self.id = AlbumID
|
self.id = AlbumID
|
||||||
self.id_type = 'album'
|
self.id_type = 'album'
|
||||||
db_info = myDB.action('SELECT Summary, Content, LastUpdated FROM descriptions WHERE ReleaseGroupID=?', [self.id]).fetchone()
|
db_info = myDB.action(
|
||||||
|
'SELECT Summary, Content, LastUpdated FROM descriptions WHERE ReleaseGroupID=?',
|
||||||
|
[self.id]).fetchone()
|
||||||
|
|
||||||
if not db_info or not db_info['LastUpdated'] or not self._is_current(date=db_info['LastUpdated']):
|
if not db_info or not db_info['LastUpdated'] or not self._is_current(
|
||||||
|
date=db_info['LastUpdated']):
|
||||||
|
|
||||||
self._update_cache()
|
self._update_cache()
|
||||||
info_dict = {'Summary': self.info_summary, 'Content': self.info_content}
|
info_dict = {'Summary': self.info_summary, 'Content': self.info_content}
|
||||||
@@ -197,10 +204,10 @@ class Cache(object):
|
|||||||
return info_dict
|
return info_dict
|
||||||
|
|
||||||
def get_image_links(self, ArtistID=None, AlbumID=None):
|
def get_image_links(self, ArtistID=None, AlbumID=None):
|
||||||
'''
|
"""
|
||||||
Here we're just going to open up the last.fm url, grab the image links and return them
|
Here we're just going to open up the last.fm url, grab the image links and return them
|
||||||
Won't save any image urls, or save the artwork in the cache. Useful for search results, etc.
|
Won't save any image urls, or save the artwork in the cache. Useful for search results, etc.
|
||||||
'''
|
"""
|
||||||
if ArtistID:
|
if ArtistID:
|
||||||
|
|
||||||
self.id_type = 'artist'
|
self.id_type = 'artist'
|
||||||
@@ -271,9 +278,10 @@ class Cache(object):
|
|||||||
logger.warn('Error deleting file from the cache: %s', thumb_file)
|
logger.warn('Error deleting file from the cache: %s', thumb_file)
|
||||||
|
|
||||||
def _update_cache(self):
|
def _update_cache(self):
|
||||||
'''
|
"""
|
||||||
Since we call the same url for both info and artwork, we'll update both at the same time
|
Since we call the same url for both info and artwork, we'll update both at the same time
|
||||||
'''
|
"""
|
||||||
|
|
||||||
myDB = db.DBConnection()
|
myDB = db.DBConnection()
|
||||||
|
|
||||||
# Since lastfm uses release ids rather than release group ids for albums, we have to do a artist + album search for albums
|
# Since lastfm uses release ids rather than release group ids for albums, we have to do a artist + album search for albums
|
||||||
@@ -306,14 +314,19 @@ class Cache(object):
|
|||||||
logger.debug('No artist thumbnail image found')
|
logger.debug('No artist thumbnail image found')
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
dbalbum = myDB.action(
|
||||||
dbalbum = myDB.action('SELECT ArtistName, AlbumTitle, ReleaseID FROM albums WHERE AlbumID=?', [self.id]).fetchone()
|
'SELECT ArtistName, AlbumTitle, ReleaseID FROM albums WHERE AlbumID=?',
|
||||||
|
[self.id]).fetchone()
|
||||||
if dbalbum['ReleaseID'] != self.id:
|
if dbalbum['ReleaseID'] != self.id:
|
||||||
data = lastfm.request_lastfm("album.getinfo", mbid=dbalbum['ReleaseID'], api_key=LASTFM_API_KEY)
|
data = lastfm.request_lastfm("album.getinfo", mbid=dbalbum['ReleaseID'],
|
||||||
|
api_key=LASTFM_API_KEY)
|
||||||
if not data:
|
if not data:
|
||||||
data = lastfm.request_lastfm("album.getinfo", artist=dbalbum['ArtistName'], album=dbalbum['AlbumTitle'], api_key=LASTFM_API_KEY)
|
data = lastfm.request_lastfm("album.getinfo", artist=dbalbum['ArtistName'],
|
||||||
|
album=dbalbum['AlbumTitle'],
|
||||||
|
api_key=LASTFM_API_KEY)
|
||||||
else:
|
else:
|
||||||
data = lastfm.request_lastfm("album.getinfo", artist=dbalbum['ArtistName'], album=dbalbum['AlbumTitle'], api_key=LASTFM_API_KEY)
|
data = lastfm.request_lastfm("album.getinfo", artist=dbalbum['ArtistName'],
|
||||||
|
album=dbalbum['AlbumTitle'], api_key=LASTFM_API_KEY)
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
return
|
return
|
||||||
@@ -339,7 +352,8 @@ class Cache(object):
|
|||||||
if not thumb_url:
|
if not thumb_url:
|
||||||
logger.debug('No album thumbnail image found')
|
logger.debug('No album thumbnail image found')
|
||||||
|
|
||||||
#Save the content & summary to the database no matter what if we've opened up the url
|
# Save the content & summary to the database no matter what if we've
|
||||||
|
# opened up the url
|
||||||
if self.id_type == 'artist':
|
if self.id_type == 'artist':
|
||||||
controlValueDict = {"ArtistID": self.id}
|
controlValueDict = {"ArtistID": self.id}
|
||||||
else:
|
else:
|
||||||
@@ -354,7 +368,8 @@ class Cache(object):
|
|||||||
# Save the image URL to the database
|
# Save the image URL to the database
|
||||||
if image_url:
|
if image_url:
|
||||||
if self.id_type == 'artist':
|
if self.id_type == 'artist':
|
||||||
myDB.action('UPDATE artists SET ArtworkURL=? WHERE ArtistID=?', [image_url, self.id])
|
myDB.action('UPDATE artists SET ArtworkURL=? WHERE ArtistID=?',
|
||||||
|
[image_url, self.id])
|
||||||
else:
|
else:
|
||||||
myDB.action('UPDATE albums SET ArtworkURL=? WHERE AlbumID=?', [image_url, self.id])
|
myDB.action('UPDATE albums SET ArtworkURL=? WHERE AlbumID=?', [image_url, self.id])
|
||||||
|
|
||||||
@@ -365,7 +380,8 @@ class Cache(object):
|
|||||||
else:
|
else:
|
||||||
myDB.action('UPDATE albums SET ThumbURL=? WHERE AlbumID=?', [thumb_url, self.id])
|
myDB.action('UPDATE albums SET ThumbURL=? WHERE AlbumID=?', [thumb_url, self.id])
|
||||||
|
|
||||||
# Should we grab the artwork here if we're just grabbing thumbs or info?? Probably not since the files can be quite big
|
# Should we grab the artwork here if we're just grabbing thumbs or
|
||||||
|
# info? Probably not since the files can be quite big
|
||||||
if image_url and self.query_type == 'artwork':
|
if image_url and self.query_type == 'artwork':
|
||||||
artwork = request.request_content(image_url, timeout=20)
|
artwork = request.request_content(image_url, timeout=20)
|
||||||
|
|
||||||
@@ -374,7 +390,9 @@ class Cache(object):
|
|||||||
if not os.path.isdir(self.path_to_art_cache):
|
if not os.path.isdir(self.path_to_art_cache):
|
||||||
try:
|
try:
|
||||||
os.makedirs(self.path_to_art_cache)
|
os.makedirs(self.path_to_art_cache)
|
||||||
except Exception as e:
|
os.chmod(self.path_to_art_cache,
|
||||||
|
int(headphones.CONFIG.FOLDER_PERMISSIONS, 8))
|
||||||
|
except OSError as e:
|
||||||
logger.error('Unable to create artwork cache dir. Error: %s', e)
|
logger.error('Unable to create artwork cache dir. Error: %s', e)
|
||||||
self.artwork_errors = True
|
self.artwork_errors = True
|
||||||
self.artwork_url = image_url
|
self.artwork_url = image_url
|
||||||
@@ -388,17 +406,22 @@ class Cache(object):
|
|||||||
|
|
||||||
ext = os.path.splitext(image_url)[1]
|
ext = os.path.splitext(image_url)[1]
|
||||||
|
|
||||||
artwork_path = os.path.join(self.path_to_art_cache, self.id + '.' + helpers.today() + ext)
|
artwork_path = os.path.join(self.path_to_art_cache,
|
||||||
|
self.id + '.' + helpers.today() + ext)
|
||||||
try:
|
try:
|
||||||
with open(artwork_path, 'wb') as f:
|
with open(artwork_path, 'wb') as f:
|
||||||
f.write(artwork)
|
f.write(artwork)
|
||||||
except IOError as e:
|
|
||||||
|
os.chmod(artwork_path, int(headphones.CONFIG.FILE_PERMISSIONS, 8))
|
||||||
|
except (OSError, IOError) as e:
|
||||||
logger.error('Unable to write to the cache dir: %s', e)
|
logger.error('Unable to write to the cache dir: %s', e)
|
||||||
self.artwork_errors = True
|
self.artwork_errors = True
|
||||||
self.artwork_url = image_url
|
self.artwork_url = image_url
|
||||||
|
|
||||||
# Grab the thumbnail as well if we're getting the full artwork (as long as it's missing/outdated
|
# Grab the thumbnail as well if we're getting the full artwork (as long
|
||||||
if thumb_url and self.query_type in ['thumb', 'artwork'] and not (self.thumb_files and self._is_current(self.thumb_files[0])):
|
# as it's missing/outdated.
|
||||||
|
if thumb_url and self.query_type in ['thumb', 'artwork'] and not (
|
||||||
|
self.thumb_files and self._is_current(self.thumb_files[0])):
|
||||||
artwork = request.request_content(thumb_url, timeout=20)
|
artwork = request.request_content(thumb_url, timeout=20)
|
||||||
|
|
||||||
if artwork:
|
if artwork:
|
||||||
@@ -406,7 +429,9 @@ class Cache(object):
|
|||||||
if not os.path.isdir(self.path_to_art_cache):
|
if not os.path.isdir(self.path_to_art_cache):
|
||||||
try:
|
try:
|
||||||
os.makedirs(self.path_to_art_cache)
|
os.makedirs(self.path_to_art_cache)
|
||||||
except Exception as e:
|
os.chmod(self.path_to_art_cache,
|
||||||
|
int(headphones.CONFIG.FOLDER_PERMISSIONS, 8))
|
||||||
|
except OSError as e:
|
||||||
logger.error('Unable to create artwork cache dir. Error: %s' + e)
|
logger.error('Unable to create artwork cache dir. Error: %s' + e)
|
||||||
self.thumb_errors = True
|
self.thumb_errors = True
|
||||||
self.thumb_url = thumb_url
|
self.thumb_url = thumb_url
|
||||||
@@ -415,23 +440,25 @@ class Cache(object):
|
|||||||
for thumb_file in self.thumb_files:
|
for thumb_file in self.thumb_files:
|
||||||
try:
|
try:
|
||||||
os.remove(thumb_file)
|
os.remove(thumb_file)
|
||||||
except Exception as e:
|
except OSError as e:
|
||||||
logger.error('Error deleting file from the cache: %s', thumb_file)
|
logger.error('Error deleting file from the cache: %s', thumb_file)
|
||||||
|
|
||||||
ext = os.path.splitext(image_url)[1]
|
ext = os.path.splitext(image_url)[1]
|
||||||
|
|
||||||
thumb_path = os.path.join(self.path_to_art_cache, 'T_' + self.id + '.' + helpers.today() + ext)
|
thumb_path = os.path.join(self.path_to_art_cache,
|
||||||
|
'T_' + self.id + '.' + helpers.today() + ext)
|
||||||
try:
|
try:
|
||||||
with open(thumb_path, 'wb') as f:
|
with open(thumb_path, 'wb') as f:
|
||||||
f.write(artwork)
|
f.write(artwork)
|
||||||
except IOError as e:
|
|
||||||
|
os.chmod(thumb_path, int(headphones.CONFIG.FILE_PERMISSIONS, 8))
|
||||||
|
except (OSError, IOError) as e:
|
||||||
logger.error('Unable to write to the cache dir: %s', e)
|
logger.error('Unable to write to the cache dir: %s', e)
|
||||||
self.thumb_errors = True
|
self.thumb_errors = True
|
||||||
self.thumb_url = image_url
|
self.thumb_url = image_url
|
||||||
|
|
||||||
|
|
||||||
def getArtwork(ArtistID=None, AlbumID=None):
|
def getArtwork(ArtistID=None, AlbumID=None):
|
||||||
|
|
||||||
c = Cache()
|
c = Cache()
|
||||||
artwork_path = c.get_artwork_from_cache(ArtistID, AlbumID)
|
artwork_path = c.get_artwork_from_cache(ArtistID, AlbumID)
|
||||||
|
|
||||||
@@ -446,7 +473,6 @@ def getArtwork(ArtistID=None, AlbumID=None):
|
|||||||
|
|
||||||
|
|
||||||
def getThumb(ArtistID=None, AlbumID=None):
|
def getThumb(ArtistID=None, AlbumID=None):
|
||||||
|
|
||||||
c = Cache()
|
c = Cache()
|
||||||
artwork_path = c.get_thumb_from_cache(ArtistID, AlbumID)
|
artwork_path = c.get_thumb_from_cache(ArtistID, AlbumID)
|
||||||
|
|
||||||
@@ -461,16 +487,13 @@ def getThumb(ArtistID=None, AlbumID=None):
|
|||||||
|
|
||||||
|
|
||||||
def getInfo(ArtistID=None, AlbumID=None):
|
def getInfo(ArtistID=None, AlbumID=None):
|
||||||
|
|
||||||
c = Cache()
|
c = Cache()
|
||||||
|
|
||||||
info_dict = c.get_info_from_cache(ArtistID, AlbumID)
|
info_dict = c.get_info_from_cache(ArtistID, AlbumID)
|
||||||
|
|
||||||
return info_dict
|
return info_dict
|
||||||
|
|
||||||
|
|
||||||
def getImageLinks(ArtistID=None, AlbumID=None):
|
def getImageLinks(ArtistID=None, AlbumID=None):
|
||||||
|
|
||||||
c = Cache()
|
c = Cache()
|
||||||
image_links = c.get_image_links(ArtistID, AlbumID)
|
image_links = c.get_image_links(ArtistID, AlbumID)
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
#########################################
|
#######################################
|
||||||
## Stolen from Sick-Beard's classes.py ##
|
# Stolen from Sick-Beard's classes.py #
|
||||||
#########################################
|
#######################################
|
||||||
|
|
||||||
|
|
||||||
import urllib
|
import urllib
|
||||||
@@ -133,4 +133,5 @@ class Proper:
|
|||||||
self.episode = -1
|
self.episode = -1
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return str(self.date) + " " + self.name + " " + str(self.season) + "x" + str(self.episode) + " of " + str(self.tvdbid)
|
return str(self.date) + " " + self.name + " " + str(self.season) + "x" + str(
|
||||||
|
self.episode) + " of " + str(self.tvdbid)
|
||||||
|
|||||||
+14
-10
@@ -20,15 +20,16 @@ Created on Aug 1, 2011
|
|||||||
'''
|
'''
|
||||||
import platform
|
import platform
|
||||||
import operator
|
import operator
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from headphones import version
|
from headphones import version
|
||||||
|
|
||||||
|
|
||||||
# Identify Our Application
|
# Identify Our Application
|
||||||
USER_AGENT = 'Headphones/-' + version.HEADPHONES_VERSION + ' (' + platform.system() + ' ' + platform.release() + ')'
|
USER_AGENT = 'Headphones/-' + version.HEADPHONES_VERSION + ' (' + platform.system() + ' ' + platform.release() + ')'
|
||||||
|
|
||||||
### Notification Types
|
# Notification Types
|
||||||
NOTIFY_SNATCH = 1
|
NOTIFY_SNATCH = 1
|
||||||
NOTIFY_DOWNLOAD = 2
|
NOTIFY_DOWNLOAD = 2
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ notifyStrings = {}
|
|||||||
notifyStrings[NOTIFY_SNATCH] = "Started Download"
|
notifyStrings[NOTIFY_SNATCH] = "Started Download"
|
||||||
notifyStrings[NOTIFY_DOWNLOAD] = "Download Finished"
|
notifyStrings[NOTIFY_DOWNLOAD] = "Download Finished"
|
||||||
|
|
||||||
### Release statuses
|
# Release statuses
|
||||||
UNKNOWN = -1 # should never happen
|
UNKNOWN = -1 # should never happen
|
||||||
UNAIRED = 1 # releases that haven't dropped yet
|
UNAIRED = 1 # releases that haven't dropped yet
|
||||||
SNATCHED = 2 # qualified with quality
|
SNATCHED = 2 # qualified with quality
|
||||||
@@ -49,7 +50,6 @@ SNATCHED_PROPER = 9 # qualified with quality
|
|||||||
|
|
||||||
|
|
||||||
class Quality:
|
class Quality:
|
||||||
|
|
||||||
NONE = 0
|
NONE = 0
|
||||||
B192 = 1 << 1 # 2
|
B192 = 1 << 1 # 2
|
||||||
VBR = 1 << 2 # 4
|
VBR = 1 << 2 # 4
|
||||||
@@ -75,7 +75,8 @@ class Quality:
|
|||||||
def _getStatusStrings(status):
|
def _getStatusStrings(status):
|
||||||
toReturn = {}
|
toReturn = {}
|
||||||
for x in Quality.qualityStrings.keys():
|
for x in Quality.qualityStrings.keys():
|
||||||
toReturn[Quality.compositeStatus(status, x)] = Quality.statusPrefixes[status] + " (" + Quality.qualityStrings[x] + ")"
|
toReturn[Quality.compositeStatus(status, x)] = Quality.statusPrefixes[status] + " (" + \
|
||||||
|
Quality.qualityStrings[x] + ")"
|
||||||
return toReturn
|
return toReturn
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -103,6 +104,9 @@ class Quality:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def nameQuality(name):
|
def nameQuality(name):
|
||||||
|
|
||||||
|
def checkName(list, func):
|
||||||
|
return func([re.search(x, name, re.I) for x in list])
|
||||||
|
|
||||||
name = os.path.basename(name)
|
name = os.path.basename(name)
|
||||||
|
|
||||||
# if we have our exact text then assume we put it there
|
# if we have our exact text then assume we put it there
|
||||||
@@ -115,8 +119,6 @@ class Quality:
|
|||||||
if regex_match:
|
if regex_match:
|
||||||
return x
|
return x
|
||||||
|
|
||||||
checkName = lambda list, func: func([re.search(x, name, re.I) for x in list])
|
|
||||||
|
|
||||||
# TODO: fix quality checking here
|
# TODO: fix quality checking here
|
||||||
if checkName(["mp3", "192"], any) and not checkName(["flac"], all):
|
if checkName(["mp3", "192"], any) and not checkName(["flac"], all):
|
||||||
return Quality.B192
|
return Quality.B192
|
||||||
@@ -131,7 +133,6 @@ class Quality:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def assumeQuality(name):
|
def assumeQuality(name):
|
||||||
|
|
||||||
if name.lower().endswith(".mp3"):
|
if name.lower().endswith(".mp3"):
|
||||||
return Quality.MP3
|
return Quality.MP3
|
||||||
elif name.lower().endswith(".flac"):
|
elif name.lower().endswith(".flac"):
|
||||||
@@ -167,13 +168,16 @@ class Quality:
|
|||||||
SNATCHED = None
|
SNATCHED = None
|
||||||
SNATCHED_PROPER = None
|
SNATCHED_PROPER = None
|
||||||
|
|
||||||
|
|
||||||
Quality.DOWNLOADED = [Quality.compositeStatus(DOWNLOADED, x) for x in Quality.qualityStrings.keys()]
|
Quality.DOWNLOADED = [Quality.compositeStatus(DOWNLOADED, x) for x in Quality.qualityStrings.keys()]
|
||||||
Quality.SNATCHED = [Quality.compositeStatus(SNATCHED, x) for x in Quality.qualityStrings.keys()]
|
Quality.SNATCHED = [Quality.compositeStatus(SNATCHED, x) for x in Quality.qualityStrings.keys()]
|
||||||
Quality.SNATCHED_PROPER = [Quality.compositeStatus(SNATCHED_PROPER, x) for x in Quality.qualityStrings.keys()]
|
Quality.SNATCHED_PROPER = [Quality.compositeStatus(SNATCHED_PROPER, x) for x in
|
||||||
|
Quality.qualityStrings.keys()]
|
||||||
|
|
||||||
MP3 = Quality.combineQualities([Quality.B192, Quality.B256, Quality.B320, Quality.VBR], [])
|
MP3 = Quality.combineQualities([Quality.B192, Quality.B256, Quality.B320, Quality.VBR], [])
|
||||||
LOSSLESS = Quality.combineQualities([Quality.FLAC], [])
|
LOSSLESS = Quality.combineQualities([Quality.FLAC], [])
|
||||||
ANY = Quality.combineQualities([Quality.B192, Quality.B256, Quality.B320, Quality.VBR, Quality.FLAC], [])
|
ANY = Quality.combineQualities(
|
||||||
|
[Quality.B192, Quality.B256, Quality.B320, Quality.VBR, Quality.FLAC], [])
|
||||||
|
|
||||||
qualityPresets = (MP3, LOSSLESS, ANY)
|
qualityPresets = (MP3, LOSSLESS, ANY)
|
||||||
qualityPresetStrings = {MP3: "MP3 (All bitrates 192+)",
|
qualityPresetStrings = {MP3: "MP3 (All bitrates 192+)",
|
||||||
|
|||||||
+100
-16
@@ -1,7 +1,8 @@
|
|||||||
import headphones.logger
|
|
||||||
import itertools
|
import itertools
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import headphones.logger
|
||||||
from configobj import ConfigObj
|
from configobj import ConfigObj
|
||||||
|
|
||||||
|
|
||||||
@@ -14,6 +15,21 @@ def bool_int(value):
|
|||||||
value = 0
|
value = 0
|
||||||
return int(bool(value))
|
return int(bool(value))
|
||||||
|
|
||||||
|
|
||||||
|
class path(str):
|
||||||
|
"""Internal 'marker' type for paths in config."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __call__(val):
|
||||||
|
return path(val)
|
||||||
|
|
||||||
|
def __new__(cls, *args, **kw):
|
||||||
|
hstr = str.__new__(cls, *args, **kw)
|
||||||
|
return hstr
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return 'headphones.config.path(%s)' % self
|
||||||
|
|
||||||
_CONFIG_DEFINITIONS = {
|
_CONFIG_DEFINITIONS = {
|
||||||
'ADD_ALBUM_ART': (int, 'General', 0),
|
'ADD_ALBUM_ART': (int, 'General', 0),
|
||||||
'ADVANCEDENCODER': (str, 'General', ''),
|
'ADVANCEDENCODER': (str, 'General', ''),
|
||||||
@@ -29,11 +45,11 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'AUTO_ADD_ARTISTS': (int, 'General', 1),
|
'AUTO_ADD_ARTISTS': (int, 'General', 1),
|
||||||
'BITRATE': (int, 'General', 192),
|
'BITRATE': (int, 'General', 192),
|
||||||
'BLACKHOLE': (int, 'General', 0),
|
'BLACKHOLE': (int, 'General', 0),
|
||||||
'BLACKHOLE_DIR': (str, 'General', ''),
|
'BLACKHOLE_DIR': (path, 'General', ''),
|
||||||
'BOXCAR_ENABLED': (int, 'Boxcar', 0),
|
'BOXCAR_ENABLED': (int, 'Boxcar', 0),
|
||||||
'BOXCAR_ONSNATCH': (int, 'Boxcar', 0),
|
'BOXCAR_ONSNATCH': (int, 'Boxcar', 0),
|
||||||
'BOXCAR_TOKEN': (str, 'Boxcar', ''),
|
'BOXCAR_TOKEN': (str, 'Boxcar', ''),
|
||||||
'CACHE_DIR': (str, 'General', ''),
|
'CACHE_DIR': (path, 'General', ''),
|
||||||
'CACHE_SIZEMB': (int, 'Advanced', 32),
|
'CACHE_SIZEMB': (int, 'Advanced', 32),
|
||||||
'CHECK_GITHUB': (int, 'General', 1),
|
'CHECK_GITHUB': (int, 'General', 1),
|
||||||
'CHECK_GITHUB_INTERVAL': (int, 'General', 360),
|
'CHECK_GITHUB_INTERVAL': (int, 'General', 360),
|
||||||
@@ -42,38 +58,62 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'CONFIG_VERSION': (str, 'General', '0'),
|
'CONFIG_VERSION': (str, 'General', '0'),
|
||||||
'CORRECT_METADATA': (int, 'General', 0),
|
'CORRECT_METADATA': (int, 'General', 0),
|
||||||
'CUE_SPLIT': (int, 'General', 1),
|
'CUE_SPLIT': (int, 'General', 1),
|
||||||
|
'CUE_SPLIT_FLAC_PATH': (path, 'General', ''),
|
||||||
|
'CUE_SPLIT_SHNTOOL_PATH': (path, 'General', ''),
|
||||||
|
'CUSTOMAUTH': (int, 'General', 0),
|
||||||
'CUSTOMHOST': (str, 'General', 'localhost'),
|
'CUSTOMHOST': (str, 'General', 'localhost'),
|
||||||
|
'CUSTOMPASS': (str, 'General', ''),
|
||||||
'CUSTOMPORT': (int, 'General', 5000),
|
'CUSTOMPORT': (int, 'General', 5000),
|
||||||
'CUSTOMSLEEP': (int, 'General', 1),
|
'CUSTOMSLEEP': (int, 'General', 1),
|
||||||
|
'CUSTOMUSER': (str, 'General', ''),
|
||||||
'DELETE_LOSSLESS_FILES': (int, 'General', 1),
|
'DELETE_LOSSLESS_FILES': (int, 'General', 1),
|
||||||
|
'DELUGE_HOST': (str, 'Deluge', ''),
|
||||||
|
'DELUGE_PASSWORD': (str, 'Deluge', ''),
|
||||||
|
'DELUGE_LABEL': (str, 'Deluge', ''),
|
||||||
|
'DELUGE_DONE_DIRECTORY': (str, 'Deluge', ''),
|
||||||
|
'DELUGE_PAUSED': (int, 'Deluge', 0),
|
||||||
'DESTINATION_DIR': (str, 'General', ''),
|
'DESTINATION_DIR': (str, 'General', ''),
|
||||||
'DETECT_BITRATE': (int, 'General', 0),
|
'DETECT_BITRATE': (int, 'General', 0),
|
||||||
'DOWNLOAD_DIR': (str, 'General', ''),
|
'DO_NOT_PROCESS_UNMATCHED': (int, 'General', 0),
|
||||||
|
'DOWNLOAD_DIR': (path, 'General', ''),
|
||||||
'DOWNLOAD_SCAN_INTERVAL': (int, 'General', 5),
|
'DOWNLOAD_SCAN_INTERVAL': (int, 'General', 5),
|
||||||
'DOWNLOAD_TORRENT_DIR': (str, 'General', ''),
|
'DOWNLOAD_TORRENT_DIR': (path, 'General', ''),
|
||||||
'DO_NOT_OVERRIDE_GIT_BRANCH': (int, 'General', 0),
|
'DO_NOT_OVERRIDE_GIT_BRANCH': (int, 'General', 0),
|
||||||
|
'EMAIL_ENABLED': (int, 'Email', 0),
|
||||||
|
'EMAIL_FROM': (str, 'Email', ''),
|
||||||
|
'EMAIL_TO': (str, 'Email', ''),
|
||||||
|
'EMAIL_SMTP_SERVER': (str, 'Email', ''),
|
||||||
|
'EMAIL_SMTP_USER': (str, 'Email', ''),
|
||||||
|
'EMAIL_SMTP_PASSWORD': (str, 'Email', ''),
|
||||||
|
'EMAIL_SMTP_PORT': (int, 'Email', 25),
|
||||||
|
'EMAIL_SSL': (int, 'Email', 0),
|
||||||
|
'EMAIL_TLS': (int, 'Email', 0),
|
||||||
|
'EMAIL_ONSNATCH': (int, 'Email', 0),
|
||||||
'EMBED_ALBUM_ART': (int, 'General', 0),
|
'EMBED_ALBUM_ART': (int, 'General', 0),
|
||||||
'EMBED_LYRICS': (int, 'General', 0),
|
'EMBED_LYRICS': (int, 'General', 0),
|
||||||
'ENABLE_HTTPS': (int, 'General', 0),
|
'ENABLE_HTTPS': (int, 'General', 0),
|
||||||
'ENCODER': (str, 'General', 'ffmpeg'),
|
'ENCODER': (str, 'General', 'ffmpeg'),
|
||||||
'ENCODERFOLDER': (str, 'General', ''),
|
'ENCODERFOLDER': (path, 'General', ''),
|
||||||
'ENCODERLOSSLESS': (int, 'General', 1),
|
'ENCODERLOSSLESS': (int, 'General', 1),
|
||||||
'ENCODEROUTPUTFORMAT': (str, 'General', 'mp3'),
|
'ENCODEROUTPUTFORMAT': (str, 'General', 'mp3'),
|
||||||
'ENCODERQUALITY': (int, 'General', 2),
|
'ENCODERQUALITY': (int, 'General', 2),
|
||||||
'ENCODERVBRCBR': (str, 'General', 'cbr'),
|
'ENCODERVBRCBR': (str, 'General', 'cbr'),
|
||||||
'ENCODER_MULTICORE': (int, 'General', 0),
|
'ENCODER_MULTICORE': (int, 'General', 0),
|
||||||
'ENCODER_MULTICORE_COUNT': (int, 'General', 0),
|
'ENCODER_MULTICORE_COUNT': (int, 'General', 0),
|
||||||
'ENCODER_PATH': (str, 'General', ''),
|
'ENCODER_PATH': (path, 'General', ''),
|
||||||
'EXTRAS': (str, 'General', ''),
|
'EXTRAS': (str, 'General', ''),
|
||||||
'EXTRA_NEWZNABS': (list, 'Newznab', ''),
|
'EXTRA_NEWZNABS': (list, 'Newznab', ''),
|
||||||
|
'EXTRA_TORZNABS': (list, 'Torznab', ''),
|
||||||
'FILE_FORMAT': (str, 'General', 'Track Artist - Album [Year] - Title'),
|
'FILE_FORMAT': (str, 'General', 'Track Artist - Album [Year] - Title'),
|
||||||
'FILE_PERMISSIONS': (str, 'General', '0644'),
|
'FILE_PERMISSIONS': (str, 'General', '0644'),
|
||||||
|
'FILE_PERMISSIONS_ENABLED': (bool_int, 'General', True),
|
||||||
'FILE_UNDERSCORES': (int, 'General', 0),
|
'FILE_UNDERSCORES': (int, 'General', 0),
|
||||||
'FOLDER_FORMAT': (str, 'General', 'Artist/Album [Year]'),
|
'FOLDER_FORMAT': (str, 'General', 'Artist/Album [Year]'),
|
||||||
|
'FOLDER_PERMISSIONS_ENABLED': (bool_int, 'General', True),
|
||||||
'FOLDER_PERMISSIONS': (str, 'General', '0755'),
|
'FOLDER_PERMISSIONS': (str, 'General', '0755'),
|
||||||
'FREEZE_DB': (int, 'General', 0),
|
'FREEZE_DB': (int, 'General', 0),
|
||||||
'GIT_BRANCH': (str, 'General', 'master'),
|
'GIT_BRANCH': (str, 'General', 'master'),
|
||||||
'GIT_PATH': (str, 'General', ''),
|
'GIT_PATH': (path, 'General', ''),
|
||||||
'GIT_USER': (str, 'General', 'rembo10'),
|
'GIT_USER': (str, 'General', 'rembo10'),
|
||||||
'GROWL_ENABLED': (int, 'Growl', 0),
|
'GROWL_ENABLED': (int, 'Growl', 0),
|
||||||
'GROWL_HOST': (str, 'Growl', ''),
|
'GROWL_HOST': (str, 'Growl', ''),
|
||||||
@@ -82,15 +122,19 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'HEADPHONES_INDEXER': (bool_int, 'General', False),
|
'HEADPHONES_INDEXER': (bool_int, 'General', False),
|
||||||
'HPPASS': (str, 'General', ''),
|
'HPPASS': (str, 'General', ''),
|
||||||
'HPUSER': (str, 'General', ''),
|
'HPUSER': (str, 'General', ''),
|
||||||
'HTTPS_CERT': (str, 'General', ''),
|
'HTTPS_CERT': (path, 'General', ''),
|
||||||
'HTTPS_KEY': (str, 'General', ''),
|
'HTTPS_KEY': (path, 'General', ''),
|
||||||
'HTTP_HOST': (str, 'General', '0.0.0.0'),
|
'HTTP_HOST': (str, 'General', 'localhost'),
|
||||||
'HTTP_PASSWORD': (str, 'General', ''),
|
'HTTP_PASSWORD': (str, 'General', ''),
|
||||||
'HTTP_PORT': (int, 'General', 8181),
|
'HTTP_PORT': (int, 'General', 8181),
|
||||||
'HTTP_PROXY': (int, 'General', 0),
|
'HTTP_PROXY': (int, 'General', 0),
|
||||||
'HTTP_ROOT': (str, 'General', '/'),
|
'HTTP_ROOT': (str, 'General', '/'),
|
||||||
'HTTP_USERNAME': (str, 'General', ''),
|
'HTTP_USERNAME': (str, 'General', ''),
|
||||||
|
'IDTAG': (int, 'Beets', 0),
|
||||||
|
'IGNORE_CLEAN_RELEASES': (int, 'General', 0),
|
||||||
'IGNORED_WORDS': (str, 'General', ''),
|
'IGNORED_WORDS': (str, 'General', ''),
|
||||||
|
'IGNORED_FOLDERS': (list, 'Advanced', []), # path
|
||||||
|
'IGNORED_FILES': (list, 'Advanced', []), # path
|
||||||
'INCLUDE_EXTRAS': (int, 'General', 0),
|
'INCLUDE_EXTRAS': (int, 'General', 0),
|
||||||
'INTERFACE': (str, 'General', 'default'),
|
'INTERFACE': (str, 'General', 'default'),
|
||||||
'JOURNAL_MODE': (str, 'Advanced', 'wal'),
|
'JOURNAL_MODE': (str, 'Advanced', 'wal'),
|
||||||
@@ -105,17 +149,17 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'LIBRARYSCAN_INTERVAL': (int, 'General', 300),
|
'LIBRARYSCAN_INTERVAL': (int, 'General', 300),
|
||||||
'LMS_ENABLED': (int, 'LMS', 0),
|
'LMS_ENABLED': (int, 'LMS', 0),
|
||||||
'LMS_HOST': (str, 'LMS', ''),
|
'LMS_HOST': (str, 'LMS', ''),
|
||||||
'LOG_DIR': (str, 'General', ''),
|
'LOG_DIR': (path, 'General', ''),
|
||||||
'LOSSLESS_BITRATE_FROM': (int, 'General', 0),
|
'LOSSLESS_BITRATE_FROM': (int, 'General', 0),
|
||||||
'LOSSLESS_BITRATE_TO': (int, 'General', 0),
|
'LOSSLESS_BITRATE_TO': (int, 'General', 0),
|
||||||
'LOSSLESS_DESTINATION_DIR': (str, 'General', ''),
|
'LOSSLESS_DESTINATION_DIR': (path, 'General', ''),
|
||||||
'MB_IGNORE_AGE': (int, 'General', 365),
|
'MB_IGNORE_AGE': (int, 'General', 365),
|
||||||
'MININOVA': (int, 'Mininova', 0),
|
'MININOVA': (int, 'Mininova', 0),
|
||||||
'MININOVA_RATIO': (str, 'Mininova', ''),
|
'MININOVA_RATIO': (str, 'Mininova', ''),
|
||||||
'MIRROR': (str, 'General', 'musicbrainz.org'),
|
'MIRROR': (str, 'General', 'musicbrainz.org'),
|
||||||
'MOVE_FILES': (int, 'General', 0),
|
'MOVE_FILES': (int, 'General', 0),
|
||||||
'MPC_ENABLED': (bool_int, 'MPC', False),
|
'MPC_ENABLED': (bool_int, 'MPC', False),
|
||||||
'MUSIC_DIR': (str, 'General', ''),
|
'MUSIC_DIR': (path, 'General', ''),
|
||||||
'MUSIC_ENCODER': (int, 'General', 0),
|
'MUSIC_ENCODER': (int, 'General', 0),
|
||||||
'NEWZNAB': (int, 'Newznab', 0),
|
'NEWZNAB': (int, 'Newznab', 0),
|
||||||
'NEWZNAB_APIKEY': (str, 'Newznab', ''),
|
'NEWZNAB_APIKEY': (str, 'Newznab', ''),
|
||||||
@@ -135,10 +179,11 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'NZBSORG_HASH': (str, 'NZBsorg', ''),
|
'NZBSORG_HASH': (str, 'NZBsorg', ''),
|
||||||
'NZBSORG_UID': (str, 'NZBsorg', ''),
|
'NZBSORG_UID': (str, 'NZBsorg', ''),
|
||||||
'NZB_DOWNLOADER': (int, 'General', 0),
|
'NZB_DOWNLOADER': (int, 'General', 0),
|
||||||
|
'OFFICIAL_RELEASES_ONLY': (int, 'General', 0),
|
||||||
'OMGWTFNZBS': (int, 'omgwtfnzbs', 0),
|
'OMGWTFNZBS': (int, 'omgwtfnzbs', 0),
|
||||||
'OMGWTFNZBS_APIKEY': (str, 'omgwtfnzbs', ''),
|
'OMGWTFNZBS_APIKEY': (str, 'omgwtfnzbs', ''),
|
||||||
'OMGWTFNZBS_UID': (str, 'omgwtfnzbs', ''),
|
'OMGWTFNZBS_UID': (str, 'omgwtfnzbs', ''),
|
||||||
'OPEN_MAGNET_LINKS': (int, 'General', 0), # 0: Ignore, 1: Open, 2: Convert
|
'OPEN_MAGNET_LINKS': (int, 'General', 0), # 0: Ignore, 1: Open, 2: Convert, 3: Embed (rtorrent)
|
||||||
'MAGNET_LINKS': (int, 'General', 0),
|
'MAGNET_LINKS': (int, 'General', 0),
|
||||||
'OSX_NOTIFY_APP': (str, 'OSX_Notify', '/Applications/Headphones'),
|
'OSX_NOTIFY_APP': (str, 'OSX_Notify', '/Applications/Headphones'),
|
||||||
'OSX_NOTIFY_ENABLED': (int, 'OSX_Notify', 0),
|
'OSX_NOTIFY_ENABLED': (int, 'OSX_Notify', 0),
|
||||||
@@ -146,6 +191,9 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'PIRATEBAY': (int, 'Piratebay', 0),
|
'PIRATEBAY': (int, 'Piratebay', 0),
|
||||||
'PIRATEBAY_PROXY_URL': (str, 'Piratebay', ''),
|
'PIRATEBAY_PROXY_URL': (str, 'Piratebay', ''),
|
||||||
'PIRATEBAY_RATIO': (str, 'Piratebay', ''),
|
'PIRATEBAY_RATIO': (str, 'Piratebay', ''),
|
||||||
|
'OLDPIRATEBAY': (int, 'Old Piratebay', 0),
|
||||||
|
'OLDPIRATEBAY_URL': (str, 'Old Piratebay', ''),
|
||||||
|
'OLDPIRATEBAY_RATIO': (str, 'Old Piratebay', ''),
|
||||||
'PLEX_CLIENT_HOST': (str, 'Plex', ''),
|
'PLEX_CLIENT_HOST': (str, 'Plex', ''),
|
||||||
'PLEX_ENABLED': (int, 'Plex', 0),
|
'PLEX_ENABLED': (int, 'Plex', 0),
|
||||||
'PLEX_NOTIFY': (int, 'Plex', 0),
|
'PLEX_NOTIFY': (int, 'Plex', 0),
|
||||||
@@ -153,6 +201,7 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'PLEX_SERVER_HOST': (str, 'Plex', ''),
|
'PLEX_SERVER_HOST': (str, 'Plex', ''),
|
||||||
'PLEX_UPDATE': (int, 'Plex', 0),
|
'PLEX_UPDATE': (int, 'Plex', 0),
|
||||||
'PLEX_USERNAME': (str, 'Plex', ''),
|
'PLEX_USERNAME': (str, 'Plex', ''),
|
||||||
|
'PLEX_TOKEN': (str, 'Plex', ''),
|
||||||
'PREFERRED_BITRATE': (str, 'General', ''),
|
'PREFERRED_BITRATE': (str, 'General', ''),
|
||||||
'PREFERRED_BITRATE_ALLOW_LOSSLESS': (int, 'General', 0),
|
'PREFERRED_BITRATE_ALLOW_LOSSLESS': (int, 'General', 0),
|
||||||
'PREFERRED_BITRATE_HIGH_BUFFER': (int, 'General', 0),
|
'PREFERRED_BITRATE_HIGH_BUFFER': (int, 'General', 0),
|
||||||
@@ -177,7 +226,10 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'PUSHOVER_ONSNATCH': (int, 'Pushover', 0),
|
'PUSHOVER_ONSNATCH': (int, 'Pushover', 0),
|
||||||
'PUSHOVER_PRIORITY': (int, 'Pushover', 0),
|
'PUSHOVER_PRIORITY': (int, 'Pushover', 0),
|
||||||
'RENAME_FILES': (int, 'General', 0),
|
'RENAME_FILES': (int, 'General', 0),
|
||||||
|
'RENAME_UNPROCESSED': (bool_int, 'General', 1),
|
||||||
|
'RENAME_FROZEN': (bool_int, 'General', 1),
|
||||||
'REPLACE_EXISTING_FOLDERS': (int, 'General', 0),
|
'REPLACE_EXISTING_FOLDERS': (int, 'General', 0),
|
||||||
|
'KEEP_ORIGINAL_FOLDER': (int, 'General', 0),
|
||||||
'REQUIRED_WORDS': (str, 'General', ''),
|
'REQUIRED_WORDS': (str, 'General', ''),
|
||||||
'RUTRACKER': (int, 'Rutracker', 0),
|
'RUTRACKER': (int, 'Rutracker', 0),
|
||||||
'RUTRACKER_PASSWORD': (str, 'Rutracker', ''),
|
'RUTRACKER_PASSWORD': (str, 'Rutracker', ''),
|
||||||
@@ -190,18 +242,29 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'SAB_USERNAME': (str, 'SABnzbd', ''),
|
'SAB_USERNAME': (str, 'SABnzbd', ''),
|
||||||
'SAMPLINGFREQUENCY': (int, 'General', 44100),
|
'SAMPLINGFREQUENCY': (int, 'General', 44100),
|
||||||
'SEARCH_INTERVAL': (int, 'General', 1440),
|
'SEARCH_INTERVAL': (int, 'General', 1440),
|
||||||
|
'SOFT_CHROOT': (path, 'General', ''),
|
||||||
'SONGKICK_APIKEY': (str, 'Songkick', 'nd1We7dFW2RqxPw8'),
|
'SONGKICK_APIKEY': (str, 'Songkick', 'nd1We7dFW2RqxPw8'),
|
||||||
'SONGKICK_ENABLED': (int, 'Songkick', 1),
|
'SONGKICK_ENABLED': (int, 'Songkick', 1),
|
||||||
'SONGKICK_FILTER_ENABLED': (int, 'Songkick', 0),
|
'SONGKICK_FILTER_ENABLED': (int, 'Songkick', 0),
|
||||||
'SONGKICK_LOCATION': (str, 'Songkick', ''),
|
'SONGKICK_LOCATION': (str, 'Songkick', ''),
|
||||||
|
'STRIKE': (int, 'Strike', 0),
|
||||||
|
'STRIKE_RATIO': (str, 'Strike', ''),
|
||||||
'SUBSONIC_ENABLED': (int, 'Subsonic', 0),
|
'SUBSONIC_ENABLED': (int, 'Subsonic', 0),
|
||||||
'SUBSONIC_HOST': (str, 'Subsonic', ''),
|
'SUBSONIC_HOST': (str, 'Subsonic', ''),
|
||||||
'SUBSONIC_PASSWORD': (str, 'Subsonic', ''),
|
'SUBSONIC_PASSWORD': (str, 'Subsonic', ''),
|
||||||
'SUBSONIC_USERNAME': (str, 'Subsonic', ''),
|
'SUBSONIC_USERNAME': (str, 'Subsonic', ''),
|
||||||
'SYNOINDEX_ENABLED': (int, 'Synoindex', 0),
|
'SYNOINDEX_ENABLED': (int, 'Synoindex', 0),
|
||||||
|
'TELEGRAM_TOKEN': (str, 'Telegram', ''),
|
||||||
|
'TELEGRAM_USERID': (str, 'Telegram', ''),
|
||||||
|
'TELEGRAM_ENABLED': (int, 'Telegram', 0),
|
||||||
|
'TELEGRAM_ONSNATCH': (int, 'Telegram', 0),
|
||||||
'TORRENTBLACKHOLE_DIR': (str, 'General', ''),
|
'TORRENTBLACKHOLE_DIR': (str, 'General', ''),
|
||||||
'TORRENT_DOWNLOADER': (int, 'General', 0),
|
'TORRENT_DOWNLOADER': (int, 'General', 0),
|
||||||
'TORRENT_REMOVAL_INTERVAL': (int, 'General', 720),
|
'TORRENT_REMOVAL_INTERVAL': (int, 'General', 720),
|
||||||
|
'TORZNAB': (int, 'Torznab', 0),
|
||||||
|
'TORZNAB_APIKEY': (str, 'Torznab', ''),
|
||||||
|
'TORZNAB_ENABLED': (int, 'Torznab', 1),
|
||||||
|
'TORZNAB_HOST': (str, 'Torznab', ''),
|
||||||
'TRANSMISSION_HOST': (str, 'Transmission', ''),
|
'TRANSMISSION_HOST': (str, 'Transmission', ''),
|
||||||
'TRANSMISSION_PASSWORD': (str, 'Transmission', ''),
|
'TRANSMISSION_PASSWORD': (str, 'Transmission', ''),
|
||||||
'TRANSMISSION_USERNAME': (str, 'Transmission', ''),
|
'TRANSMISSION_USERNAME': (str, 'Transmission', ''),
|
||||||
@@ -217,6 +280,7 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'UTORRENT_PASSWORD': (str, 'uTorrent', ''),
|
'UTORRENT_PASSWORD': (str, 'uTorrent', ''),
|
||||||
'UTORRENT_USERNAME': (str, 'uTorrent', ''),
|
'UTORRENT_USERNAME': (str, 'uTorrent', ''),
|
||||||
'VERIFY_SSL_CERT': (bool_int, 'Advanced', 1),
|
'VERIFY_SSL_CERT': (bool_int, 'Advanced', 1),
|
||||||
|
'WAIT_UNTIL_RELEASE_DATE': (int, 'General', 0),
|
||||||
'WAFFLES': (int, 'Waffles', 0),
|
'WAFFLES': (int, 'Waffles', 0),
|
||||||
'WAFFLES_PASSKEY': (str, 'Waffles', ''),
|
'WAFFLES_PASSKEY': (str, 'Waffles', ''),
|
||||||
'WAFFLES_RATIO': (str, 'Waffles', ''),
|
'WAFFLES_RATIO': (str, 'Waffles', ''),
|
||||||
@@ -234,6 +298,7 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'XLDPROFILE': (str, 'General', '')
|
'XLDPROFILE': (str, 'General', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# pylint:disable=R0902
|
# pylint:disable=R0902
|
||||||
# it might be nice to refactor for fewer instance variables
|
# it might be nice to refactor for fewer instance variables
|
||||||
class Config(object):
|
class Config(object):
|
||||||
@@ -254,7 +319,7 @@ class Config(object):
|
|||||||
definition = _CONFIG_DEFINITIONS[key]
|
definition = _CONFIG_DEFINITIONS[key]
|
||||||
if len(definition) == 3:
|
if len(definition) == 3:
|
||||||
definition_type, section, default = definition
|
definition_type, section, default = definition
|
||||||
else:
|
elif len(definition) == 4:
|
||||||
definition_type, section, _, default = definition
|
definition_type, section, _, default = definition
|
||||||
return key, definition_type, section, ini_key, default
|
return key, definition_type, section, ini_key, default
|
||||||
|
|
||||||
@@ -325,6 +390,25 @@ class Config(object):
|
|||||||
extra_newznabs.append(item)
|
extra_newznabs.append(item)
|
||||||
self.EXTRA_NEWZNABS = extra_newznabs
|
self.EXTRA_NEWZNABS = extra_newznabs
|
||||||
|
|
||||||
|
def get_extra_torznabs(self):
|
||||||
|
""" Return the extra torznab tuples """
|
||||||
|
extra_torznabs = list(
|
||||||
|
itertools.izip(*[itertools.islice(self.EXTRA_TORZNABS, i, None, 3)
|
||||||
|
for i in range(3)])
|
||||||
|
)
|
||||||
|
return extra_torznabs
|
||||||
|
|
||||||
|
def clear_extra_torznabs(self):
|
||||||
|
""" Forget about the configured extra torznabs """
|
||||||
|
self.EXTRA_TORZNABS = []
|
||||||
|
|
||||||
|
def add_extra_torznab(self, torznab):
|
||||||
|
""" Add a new extra torznab """
|
||||||
|
extra_torznabs = self.EXTRA_TORZNABS
|
||||||
|
for item in torznab:
|
||||||
|
extra_torznabs.append(item)
|
||||||
|
self.EXTRA_TORZNABS = extra_torznabs
|
||||||
|
|
||||||
def __getattr__(self, name):
|
def __getattr__(self, name):
|
||||||
"""
|
"""
|
||||||
Returns something from the ini unless it is a real property
|
Returns something from the ini unless it is a real property
|
||||||
|
|||||||
@@ -0,0 +1,440 @@
|
|||||||
|
import mock
|
||||||
|
from mock import MagicMock
|
||||||
|
import headphones.config
|
||||||
|
import re
|
||||||
|
import unittestcompat
|
||||||
|
from unittestcompat import TestCase, TestArgs
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigApiTest(TestCase):
|
||||||
|
""" Common tests for headphones.Config
|
||||||
|
|
||||||
|
Common tests for headphones.Config This test suite guarantees, that external
|
||||||
|
API of the Config class conforms all expectations of other modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _setUpConfigMock(self, mock, sections):
|
||||||
|
# every constructor `xx = ConfigObj()` in headphones.config will return
|
||||||
|
# this mock:
|
||||||
|
self.config_mock = self.config_module_mock.return_value = mock
|
||||||
|
|
||||||
|
if sections:
|
||||||
|
mock.__contains__.side_effect = sections.__contains__
|
||||||
|
mock.__getitem__.side_effect = sections.__getitem__
|
||||||
|
mock.__setitem__.side_effect = sections.__setitem__
|
||||||
|
mock.items.side_effect = sections.items
|
||||||
|
|
||||||
|
return mock
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# patch for low-level ConfigObj for entire test class
|
||||||
|
# result - each test_* method will get one additional
|
||||||
|
# argument during testing
|
||||||
|
self.config_module_mock_patcher = mock.patch('headphones.config.ConfigObj', name='ConfigObjModuleMock')
|
||||||
|
self.config_module_mock = self.config_module_mock_patcher.start()
|
||||||
|
|
||||||
|
existing_sections = {'General': {}, 'Email': {}}
|
||||||
|
# every constructor `xx = ConfigObj()` in headphones.config will return
|
||||||
|
# this mock:
|
||||||
|
self._setUpConfigMock(MagicMock(), existing_sections)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.config_module_mock_patcher.stop()
|
||||||
|
|
||||||
|
def test_constructor(self):
|
||||||
|
""" Config : creating """
|
||||||
|
|
||||||
|
cf = headphones.config.Config('/tmp/notexist')
|
||||||
|
self.assertIsInstance(cf, headphones.config.Config)
|
||||||
|
|
||||||
|
@TestArgs(
|
||||||
|
# this sections are explicitly added in the test body:
|
||||||
|
('General', False),
|
||||||
|
('Email', False),
|
||||||
|
|
||||||
|
# this sections will not be created nor in the test, either in the
|
||||||
|
# Config module
|
||||||
|
('some_new_section_never_defined', True),
|
||||||
|
('another_new_section_never_defined', True),
|
||||||
|
)
|
||||||
|
def test_check_section(self, section_name, expected_return):
|
||||||
|
""" Config : check_section """
|
||||||
|
path = '/tmp/notexist'
|
||||||
|
|
||||||
|
# call methods
|
||||||
|
c = headphones.config.Config(path)
|
||||||
|
res = c.check_section(section_name)
|
||||||
|
res2 = c.check_section(section_name)
|
||||||
|
|
||||||
|
# assertions:
|
||||||
|
self.assertEqual(res, expected_return)
|
||||||
|
self.assertFalse(res2)
|
||||||
|
|
||||||
|
@TestArgs(
|
||||||
|
('api_enabled', 0, int),
|
||||||
|
('Api_Key', '', str),
|
||||||
|
)
|
||||||
|
def test_check_setting(self, setting_name, expected_return, expected_instance):
|
||||||
|
""" Config: check_setting , basic cases """
|
||||||
|
path = '/tmp/notexist'
|
||||||
|
|
||||||
|
# call methods
|
||||||
|
c = headphones.config.Config(path)
|
||||||
|
res = c.check_setting(setting_name)
|
||||||
|
res2 = c.check_setting(setting_name)
|
||||||
|
|
||||||
|
# assertions:
|
||||||
|
self.assertIsInstance(res, expected_instance)
|
||||||
|
self.assertEqual(res, expected_return)
|
||||||
|
self.assertEqual(res, res2)
|
||||||
|
|
||||||
|
@TestArgs(
|
||||||
|
(''),
|
||||||
|
('This_IsNew_Name'),
|
||||||
|
)
|
||||||
|
def test_check_setting_raise_on_unknown_settings(self, setting_name):
|
||||||
|
""" Config: check_setting should raise on unknown """
|
||||||
|
path = '/tmp/notexist'
|
||||||
|
|
||||||
|
exc_regex = re.compile(setting_name, re.IGNORECASE)
|
||||||
|
|
||||||
|
# call methods
|
||||||
|
c = headphones.config.Config(path)
|
||||||
|
# assertions:
|
||||||
|
with self.assertRaisesRegexp(KeyError, exc_regex):
|
||||||
|
c.check_setting(setting_name)
|
||||||
|
pass
|
||||||
|
|
||||||
|
@TestArgs(
|
||||||
|
(None)
|
||||||
|
)
|
||||||
|
def test_check_setting_raise_on_none(self, setting_name):
|
||||||
|
""" Config: check_setting shoud raise on None name """
|
||||||
|
path = '/tmp/notexist'
|
||||||
|
|
||||||
|
# call methods
|
||||||
|
c = headphones.config.Config(path)
|
||||||
|
# assertions:
|
||||||
|
with self.assertRaises(AttributeError):
|
||||||
|
c.check_setting(setting_name)
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_write(self):
|
||||||
|
""" Config : write """
|
||||||
|
path = '/tmp/notexist'
|
||||||
|
|
||||||
|
# overload mocks, defined in setUp:
|
||||||
|
old_conf_mock = self._setUpConfigMock(MagicMock(), {'a': {}})
|
||||||
|
|
||||||
|
option_name_not_from_definitions = 'some_invalid_option_with_super_uniq1_name'
|
||||||
|
option_name_not_from_definitions_value = 1
|
||||||
|
old_conf_mock['asdf'] = {option_name_not_from_definitions: option_name_not_from_definitions_value}
|
||||||
|
|
||||||
|
# call methods
|
||||||
|
cf = headphones.config.Config(path)
|
||||||
|
|
||||||
|
# overload mock-patching for NEW CONFIG
|
||||||
|
new_patcher = mock.patch('headphones.config.ConfigObj', name='NEW_ConfigObjModuleMock_FOR_WRITE')
|
||||||
|
|
||||||
|
new_conf_module_mock = new_patcher.start()
|
||||||
|
new_conf_mock = \
|
||||||
|
new_conf_module_mock.return_value = \
|
||||||
|
MagicMock()
|
||||||
|
cf.write()
|
||||||
|
new_patcher.stop()
|
||||||
|
|
||||||
|
# assertions:
|
||||||
|
self.assertFalse(old_conf_mock.write.called, 'write not called for old config')
|
||||||
|
self.assertTrue(new_conf_mock.write.called, 'write called for new config')
|
||||||
|
self.assertEqual(new_conf_mock.filename, path)
|
||||||
|
|
||||||
|
new_conf_mock['General'].__setitem__.assert_any_call('download_dir', '')
|
||||||
|
# from 3.5... new_conf_mock['asdf'].__setitem__.assert_not_called('download_dir', '')
|
||||||
|
new_conf_mock['asdf'].__setitem__.assert_any_call(option_name_not_from_definitions, option_name_not_from_definitions_value)
|
||||||
|
|
||||||
|
@unittestcompat.skip("process_kwargs should be removed")
|
||||||
|
def test_process_kwargs(self):
|
||||||
|
self.assertTrue(True)
|
||||||
|
|
||||||
|
# ===========================================================
|
||||||
|
# GET ATTR
|
||||||
|
# ===========================================================
|
||||||
|
|
||||||
|
@TestArgs(
|
||||||
|
('ADD_ALBUM_ART', True),
|
||||||
|
('ALBUM_ART_FORMAT', 'shmolder'),
|
||||||
|
('API_ENABLED', 1),
|
||||||
|
('API_KEY', 'Hello'),
|
||||||
|
)
|
||||||
|
def test__getattr__ConfValues(self, name, value):
|
||||||
|
""" Config: __getattr__ with setting value explicit """
|
||||||
|
path = '/tmp/notexist'
|
||||||
|
|
||||||
|
self.config_mock["General"] = {name.lower(): value}
|
||||||
|
|
||||||
|
# call methods
|
||||||
|
c = headphones.config.Config(path)
|
||||||
|
act = c.__getattr__(name)
|
||||||
|
|
||||||
|
# assertions:
|
||||||
|
self.assertEqual(act, value)
|
||||||
|
|
||||||
|
@TestArgs(
|
||||||
|
('ADD_ALBUM_ART', 0),
|
||||||
|
('ALBUM_ART_FORMAT', 'folder'),
|
||||||
|
('API_ENABLED', 0),
|
||||||
|
('API_KEY', ''),
|
||||||
|
)
|
||||||
|
def test__getattr__ConfValuesDefault(self, name, value):
|
||||||
|
""" Config: __getattr__ from config(by braces), default values """
|
||||||
|
path = '/tmp/notexist'
|
||||||
|
|
||||||
|
# call methods
|
||||||
|
c = headphones.config.Config(path)
|
||||||
|
res = c.__getattr__(name)
|
||||||
|
|
||||||
|
# assertions:
|
||||||
|
self.assertEqual(res, value)
|
||||||
|
|
||||||
|
def test__getattr__ConfValuesDefaultUsingDotNotation(self):
|
||||||
|
""" Config: __getattr__ from config (by dot), default values """
|
||||||
|
path = '/tmp/notexist'
|
||||||
|
|
||||||
|
# call methods
|
||||||
|
c = headphones.config.Config(path)
|
||||||
|
|
||||||
|
# assertions:
|
||||||
|
self.assertEqual(c.ALBUM_ART_FORMAT, 'folder')
|
||||||
|
self.assertEqual(c.API_ENABLED, 0)
|
||||||
|
self.assertEqual(c.API_KEY, '')
|
||||||
|
|
||||||
|
def test__getattr__OwnAttributes(self):
|
||||||
|
""" Config: __getattr__ access own attrs """
|
||||||
|
path = '/tmp/notexist'
|
||||||
|
|
||||||
|
# call methods
|
||||||
|
c = headphones.config.Config(path)
|
||||||
|
|
||||||
|
# assertions:
|
||||||
|
self.assertIsNotNone(c)
|
||||||
|
self.assertIn('<headphones.config.Config', c.__str__())
|
||||||
|
|
||||||
|
# ===========================================================
|
||||||
|
# SET ATTR
|
||||||
|
# ===========================================================
|
||||||
|
|
||||||
|
@TestArgs(
|
||||||
|
('ADD_ALBUM_ART', True),
|
||||||
|
('ALBUM_ART_FORMAT', 'shmolder'),
|
||||||
|
('API_ENABLED', 1),
|
||||||
|
('API_KEY', 'Hello'),
|
||||||
|
)
|
||||||
|
def test__setattr__ConfValuesDefault(self, name, value):
|
||||||
|
""" Config: __setattr__ with setting value explicit """
|
||||||
|
path = '/tmp/notexist'
|
||||||
|
|
||||||
|
# call methods
|
||||||
|
c = headphones.config.Config(path)
|
||||||
|
act = c.__setattr__(name, value)
|
||||||
|
|
||||||
|
# assertions:
|
||||||
|
self.assertEqual(self.config_mock["General"][name.lower()], value)
|
||||||
|
self.assertEqual(act, value)
|
||||||
|
|
||||||
|
def test__setattr__ExplicitSetUsingDotNotation(self):
|
||||||
|
""" Config: __setattr__ with setting values using dot notation """
|
||||||
|
path = '/tmp/notexist'
|
||||||
|
|
||||||
|
# call methods
|
||||||
|
c = headphones.config.Config(path)
|
||||||
|
act1 = c.ALBUM_ART_FORMAT = 'Apple'
|
||||||
|
act2 = c.API_ENABLED = True
|
||||||
|
act3 = c.API_KEY = 123
|
||||||
|
|
||||||
|
# assertions:
|
||||||
|
self.assertEqual(self.config_mock["General"]['album_art_format'], 'Apple')
|
||||||
|
self.assertEqual(self.config_mock["General"]['api_enabled'], 1)
|
||||||
|
self.assertEqual(self.config_mock["General"]['api_key'], '123')
|
||||||
|
|
||||||
|
self.assertEqual(act1, 'Apple')
|
||||||
|
self.assertEqual(act2, 1)
|
||||||
|
|
||||||
|
# TODO : check this trange behaviour. I have expected to see here '123', not 123.
|
||||||
|
self.assertEqual(act3, 123)
|
||||||
|
|
||||||
|
# ===========================================================
|
||||||
|
# NEWZNABS
|
||||||
|
#
|
||||||
|
|
||||||
|
@TestArgs(
|
||||||
|
('', []),
|
||||||
|
('ABCDEF', [('A', 'B', 'C'), ('D', 'E', 'F')]),
|
||||||
|
(['ABC', 'DEF'], []),
|
||||||
|
([1], []),
|
||||||
|
([1, 2], []),
|
||||||
|
([1, 2, 3], [(1, 2, 3)]),
|
||||||
|
|
||||||
|
([1, 2, 3, 'Aaa'], [(1, 2, 3)]),
|
||||||
|
([1, 2, 3, 'Aaa', 'Bbba'], [(1, 2, 3)]),
|
||||||
|
([1, 2, 3, 'Aaa', 'Bbba', 'Ccccc'], [(1, 2, 3), ('Aaa', 'Bbba', 'Ccccc')]),
|
||||||
|
([1, 2, 3, 'Aaa', 'Bbba', 'Ccccc', 'Ddddda'], [(1, 2, 3), ('Aaa', 'Bbba', 'Ccccc')]),
|
||||||
|
)
|
||||||
|
def test_get_extra_newznabs(self, conf_value, expected):
|
||||||
|
""" Config: get_extra_newznabs """
|
||||||
|
path = '/tmp/notexist'
|
||||||
|
|
||||||
|
#itertools.izip(*[itertools.islice('', i, None, 3) for i in range(3)])
|
||||||
|
# set up mocks:
|
||||||
|
# 'EXTRA_NEWZNABS': (list, 'Newznab', ''),
|
||||||
|
# 'EXTRA_TORZNABS': (list, 'Torznab', ''),
|
||||||
|
self.config_mock["Newznab"] = {"extra_newznabs": conf_value}
|
||||||
|
|
||||||
|
# call methods
|
||||||
|
c = headphones.config.Config(path)
|
||||||
|
res = c.get_extra_newznabs()
|
||||||
|
|
||||||
|
# assertions:
|
||||||
|
self.assertEqual(res, expected)
|
||||||
|
|
||||||
|
def test_clear_extra_newznabs(self):
|
||||||
|
""" Config: clear_extra_newznabs """
|
||||||
|
path = '/tmp/notexist'
|
||||||
|
|
||||||
|
random_value = 1827746
|
||||||
|
self.config_mock["Newznab"] = {"extra_newznabs": [1, 2, 3]}
|
||||||
|
self.config_mock["Newznab"] = {"do_not_touch": random_value}
|
||||||
|
|
||||||
|
# call methods
|
||||||
|
c = headphones.config.Config(path)
|
||||||
|
res = c.clear_extra_newznabs()
|
||||||
|
|
||||||
|
# assertions:
|
||||||
|
self.assertIsNone(res)
|
||||||
|
self.assertEqual(self.config_mock["Newznab"]["extra_newznabs"], [])
|
||||||
|
self.assertEqual(self.config_mock["Newznab"]["do_not_touch"], random_value)
|
||||||
|
|
||||||
|
@TestArgs(
|
||||||
|
([], [''], ['']),
|
||||||
|
([], 'ABCDEF', ['A', 'B', 'C', 'D', 'E', 'F']),
|
||||||
|
|
||||||
|
([1, 2, [False, True]], ['3', [0, 0]], [1, 2, [False, True], '3', [0, 0]]),
|
||||||
|
)
|
||||||
|
def test_add_extra_newznab(self, initial, added, expected):
|
||||||
|
""" Config: add_extra_newznab """
|
||||||
|
path = '/tmp/notexist'
|
||||||
|
|
||||||
|
self.config_mock["Newznab"] = {"extra_newznabs": initial}
|
||||||
|
|
||||||
|
# call methods
|
||||||
|
c = headphones.config.Config(path)
|
||||||
|
c.add_extra_newznab(added)
|
||||||
|
act = self.config_mock["Newznab"]["extra_newznabs"]
|
||||||
|
|
||||||
|
# assertions:
|
||||||
|
self.assertEqual(act, expected)
|
||||||
|
|
||||||
|
@TestArgs(
|
||||||
|
(None),
|
||||||
|
([]),
|
||||||
|
([1, 2, 3]),
|
||||||
|
([True]),
|
||||||
|
)
|
||||||
|
def test_add_extra_newznab_raise_on_none(self, initial):
|
||||||
|
""" Config: add_extra_newznab should raise on None adding"""
|
||||||
|
path = '/tmp/notexist'
|
||||||
|
|
||||||
|
self.config_mock["Newznab"] = {"extra_newznabs": initial}
|
||||||
|
|
||||||
|
# call methods
|
||||||
|
c = headphones.config.Config(path)
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
c.add_extra_newznab(None)
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ===========================================================
|
||||||
|
# TORZNABS
|
||||||
|
# TODO : here is copypaste from of NEZNABS tests. Make tests better, plz refactor them
|
||||||
|
#
|
||||||
|
@TestArgs(
|
||||||
|
('', []),
|
||||||
|
('ABCDEF', [('A', 'B', 'C'), ('D', 'E', 'F')]),
|
||||||
|
(['ABC', 'DEF'], []),
|
||||||
|
([1], []),
|
||||||
|
([1, 2], []),
|
||||||
|
([1, 2, 3], [(1, 2, 3)]),
|
||||||
|
|
||||||
|
([1, 2, 3, 'Aaa'], [(1, 2, 3)]),
|
||||||
|
([1, 2, 3, 'Aaa', 'Bbba'], [(1, 2, 3)]),
|
||||||
|
([1, 2, 3, 'Aaa', 'Bbba', 'Ccccc'], [(1, 2, 3), ('Aaa', 'Bbba', 'Ccccc')]),
|
||||||
|
([1, 2, 3, 'Aaa', 'Bbba', 'Ccccc', 'Ddddda'], [(1, 2, 3), ('Aaa', 'Bbba', 'Ccccc')]),
|
||||||
|
)
|
||||||
|
def test_get_extra_torznabs(self, conf_value, expected):
|
||||||
|
""" Config: get_extra_torznabs """
|
||||||
|
path = '/tmp/notexist'
|
||||||
|
|
||||||
|
#itertools.izip(*[itertools.islice('', i, None, 3) for i in range(3)])
|
||||||
|
# set up mocks:
|
||||||
|
# 'EXTRA_TORZNABS': (list, '', ''),
|
||||||
|
self.config_mock["Torznab"] = {"extra_torznabs": conf_value}
|
||||||
|
|
||||||
|
# call methods
|
||||||
|
c = headphones.config.Config(path)
|
||||||
|
res = c.get_extra_torznabs()
|
||||||
|
|
||||||
|
# assertions:
|
||||||
|
self.assertEqual(res, expected)
|
||||||
|
|
||||||
|
def test_clear_extra_torznabs(self):
|
||||||
|
""" Config: clear_extra_torznabs """
|
||||||
|
path = '/tmp/notexist'
|
||||||
|
|
||||||
|
random_value = -1292721
|
||||||
|
self.config_mock["Torznab"] = {"extra_torznabs": [1, 2, 3]}
|
||||||
|
self.config_mock["Torznab"] = {"do_not_touch": random_value}
|
||||||
|
|
||||||
|
# call methods
|
||||||
|
c = headphones.config.Config(path)
|
||||||
|
res = c.clear_extra_torznabs()
|
||||||
|
|
||||||
|
# assertions:
|
||||||
|
self.assertIsNone(res)
|
||||||
|
self.assertEqual(self.config_mock["Torznab"]["extra_torznabs"], [])
|
||||||
|
self.assertEqual(self.config_mock["Torznab"]["do_not_touch"], random_value)
|
||||||
|
|
||||||
|
@TestArgs(
|
||||||
|
([], [''], ['']),
|
||||||
|
([], 'ABCDEF', ['A', 'B', 'C', 'D', 'E', 'F']),
|
||||||
|
|
||||||
|
([1, 2, [False, True]], ['3', [0, 0]], [1, 2, [False, True], '3', [0, 0]]),
|
||||||
|
)
|
||||||
|
def test_add_extra_torznab(self, initial, added, expected):
|
||||||
|
""" Config: add_extra_torznab """
|
||||||
|
path = '/tmp/notexist'
|
||||||
|
|
||||||
|
self.config_mock["Torznab"] = {"extra_torznabs": initial}
|
||||||
|
|
||||||
|
# call methods
|
||||||
|
c = headphones.config.Config(path)
|
||||||
|
c.add_extra_torznab(added)
|
||||||
|
act = self.config_mock["Torznab"]["extra_torznabs"]
|
||||||
|
|
||||||
|
# assertions:
|
||||||
|
self.assertEqual(act, expected)
|
||||||
|
|
||||||
|
@TestArgs(
|
||||||
|
(None),
|
||||||
|
([]),
|
||||||
|
([1, 2, 3]),
|
||||||
|
([True]),
|
||||||
|
)
|
||||||
|
def test_add_extra_torznab_raise_on_none(self, initial):
|
||||||
|
""" Config: add_extra_torznab should raise on None adding"""
|
||||||
|
path = '/tmp/notexist'
|
||||||
|
|
||||||
|
self.config_mock["Torznab"] = {"extra_torznabs": initial}
|
||||||
|
|
||||||
|
# call methods
|
||||||
|
c = headphones.config.Config(path)
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
c.add_extra_torznab(None)
|
||||||
|
pass
|
||||||
+43
-30
@@ -15,13 +15,13 @@
|
|||||||
|
|
||||||
# Most of this lifted from here: https://github.com/SzieberthAdam/gneposis-cdgrab
|
# Most of this lifted from here: https://github.com/SzieberthAdam/gneposis-cdgrab
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import re
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import copy
|
import copy
|
||||||
import glob
|
import glob
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
import headphones
|
import headphones
|
||||||
from headphones import logger
|
from headphones import logger
|
||||||
from mutagen.flac import FLAC
|
from mutagen.flac import FLAC
|
||||||
@@ -62,9 +62,7 @@ WAVE_FILE_TYPE_BY_EXTENSION = {
|
|||||||
'.flac': 'Free Lossless Audio Codec'
|
'.flac': 'Free Lossless Audio Codec'
|
||||||
}
|
}
|
||||||
|
|
||||||
# TODO: Only alow flac for now
|
# SHNTOOL_COMPATIBLE = ("Free Lossless Audio Codec", "Waveform Audio", "Monkey's Audio")
|
||||||
#SHNTOOL_COMPATIBLE = ('Waveform Audio', 'WavPack', 'Free Lossless Audio Codec')
|
|
||||||
SHNTOOL_COMPATIBLE = ('Free Lossless Audio Codec')
|
|
||||||
|
|
||||||
# TODO: Make this better!
|
# TODO: Make this better!
|
||||||
# this module-level variable is bad. :(
|
# this module-level variable is bad. :(
|
||||||
@@ -72,11 +70,13 @@ CUE_META = None
|
|||||||
|
|
||||||
|
|
||||||
def check_splitter(command):
|
def check_splitter(command):
|
||||||
'''Check xld or shntools installed'''
|
'''Check xld or shntool installed'''
|
||||||
try:
|
try:
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
if 'xld' in command:
|
if 'xld' in command:
|
||||||
env['PATH'] += os.pathsep + '/Applications'
|
env['PATH'] += os.pathsep + '/Applications'
|
||||||
|
elif headphones.CONFIG.CUE_SPLIT_FLAC_PATH:
|
||||||
|
command = os.path.join(headphones.CONFIG.CUE_SPLIT_SHNTOOL_PATH, 'shntool')
|
||||||
devnull = open(os.devnull)
|
devnull = open(os.devnull)
|
||||||
subprocess.Popen([command], stdout=devnull, stderr=devnull, env=env).communicate()
|
subprocess.Popen([command], stdout=devnull, stderr=devnull, env=env).communicate()
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
@@ -103,12 +103,14 @@ def split_baby(split_file, split_cmd):
|
|||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
if 'xld' in split_cmd:
|
if 'xld' in split_cmd:
|
||||||
env['PATH'] += os.pathsep + '/Applications'
|
env['PATH'] += os.pathsep + '/Applications'
|
||||||
|
elif headphones.CONFIG.CUE_SPLIT_FLAC_PATH:
|
||||||
|
env['PATH'] += os.pathsep + headphones.CONFIG.CUE_SPLIT_FLAC_PATH
|
||||||
|
|
||||||
process = subprocess.Popen(split_cmd, startupinfo=startupinfo,
|
process = subprocess.Popen(split_cmd, startupinfo=startupinfo,
|
||||||
|
|
||||||
stdin=open(os.devnull, 'rb'), stdout=subprocess.PIPE,
|
stdin=open(os.devnull, 'rb'), stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE, env=env)
|
stderr=subprocess.PIPE, env=env)
|
||||||
stdout, stderr = process.communicate()
|
stdout, stderr = process.communicate()
|
||||||
|
|
||||||
if process.returncode:
|
if process.returncode:
|
||||||
logger.error('Split failed for %s', split_file.decode(headphones.SYS_ENCODING, 'replace'))
|
logger.error('Split failed for %s', split_file.decode(headphones.SYS_ENCODING, 'replace'))
|
||||||
out = stdout if stdout else stderr
|
out = stdout if stdout else stderr
|
||||||
@@ -312,7 +314,8 @@ class CueFile(File):
|
|||||||
line_content = c[line_index]
|
line_content = c[line_index]
|
||||||
search_result = re.search(CUE_TRACK, line_content, re.I)
|
search_result = re.search(CUE_TRACK, line_content, re.I)
|
||||||
if not search_result:
|
if not search_result:
|
||||||
raise ValueError('inconsistent CUE sheet, TRACK expected at line {0}'.format(line_index + 1))
|
raise ValueError(
|
||||||
|
'inconsistent CUE sheet, TRACK expected at line {0}'.format(line_index + 1))
|
||||||
track_nr = int(search_result.group(1))
|
track_nr = int(search_result.group(1))
|
||||||
line_index += 1
|
line_index += 1
|
||||||
next_track = False
|
next_track = False
|
||||||
@@ -351,7 +354,8 @@ class CueFile(File):
|
|||||||
track_meta['dcpflag'] = True
|
track_meta['dcpflag'] = True
|
||||||
line_index += 1
|
line_index += 1
|
||||||
else:
|
else:
|
||||||
raise ValueError('unknown entry in track error, line {0}'.format(line_index + 1))
|
raise ValueError(
|
||||||
|
'unknown entry in track error, line {0}'.format(line_index + 1))
|
||||||
else:
|
else:
|
||||||
next_track = True
|
next_track = True
|
||||||
|
|
||||||
@@ -404,9 +408,11 @@ class CueFile(File):
|
|||||||
for i in range(len(self.tracks)):
|
for i in range(len(self.tracks)):
|
||||||
if self.tracks[i]:
|
if self.tracks[i]:
|
||||||
if self.tracks[i].get('artist'):
|
if self.tracks[i].get('artist'):
|
||||||
content += 'track' + int_to_str(i) + 'artist' + '\t' + self.tracks[i].get('artist') + '\n'
|
content += 'track' + int_to_str(i) + 'artist' + '\t' + self.tracks[i].get(
|
||||||
|
'artist') + '\n'
|
||||||
if self.tracks[i].get('title'):
|
if self.tracks[i].get('title'):
|
||||||
content += 'track' + int_to_str(i) + 'title' + '\t' + self.tracks[i].get('title') + '\n'
|
content += 'track' + int_to_str(i) + 'title' + '\t' + self.tracks[i].get(
|
||||||
|
'title') + '\n'
|
||||||
return content
|
return content
|
||||||
|
|
||||||
def htoa(self):
|
def htoa(self):
|
||||||
@@ -424,7 +430,7 @@ class CueFile(File):
|
|||||||
elif t >= 1:
|
elif t >= 1:
|
||||||
t_index = self.tracks[t]['index']
|
t_index = self.tracks[t]['index']
|
||||||
content += t_index[1]
|
content += t_index[1]
|
||||||
if (t < len(self.tracks) - 1):
|
if t < (len(self.tracks) - 1):
|
||||||
content += '\n'
|
content += '\n'
|
||||||
return content
|
return content
|
||||||
|
|
||||||
@@ -447,7 +453,8 @@ class MetaFile(File):
|
|||||||
raise ValueError('Syntax error in album meta file')
|
raise ValueError('Syntax error in album meta file')
|
||||||
if not content['tracks'][int(parsed_track.group(1))]:
|
if not content['tracks'][int(parsed_track.group(1))]:
|
||||||
content['tracks'][int(parsed_track.group(1))] = dict()
|
content['tracks'][int(parsed_track.group(1))] = dict()
|
||||||
content['tracks'][int(parsed_track.group(1))][parsed_track.group(2)] = parsed_line.group(2)
|
content['tracks'][int(parsed_track.group(1))][
|
||||||
|
parsed_track.group(2)] = parsed_line.group(2)
|
||||||
else:
|
else:
|
||||||
content[parsed_line.group(1)] = parsed_line.group(2)
|
content[parsed_line.group(1)] = parsed_line.group(2)
|
||||||
|
|
||||||
@@ -478,7 +485,8 @@ class MetaFile(File):
|
|||||||
|
|
||||||
def folders(self):
|
def folders(self):
|
||||||
artist = self.content['artist']
|
artist = self.content['artist']
|
||||||
album = self.content['date'] + ' - ' + self.content['title'] + ' (' + self.content['label'] + ' - ' + self.content['catalog'] + ')'
|
album = self.content['date'] + ' - ' + self.content['title'] + ' (' + self.content[
|
||||||
|
'label'] + ' - ' + self.content['catalog'] + ')'
|
||||||
return artist, album
|
return artist, album
|
||||||
|
|
||||||
def complete(self):
|
def complete(self):
|
||||||
@@ -533,6 +541,7 @@ class WaveFile(File):
|
|||||||
if self.type == 'Free Lossless Audio Codec':
|
if self.type == 'Free Lossless Audio Codec':
|
||||||
return FLAC(self.name)
|
return FLAC(self.name)
|
||||||
|
|
||||||
|
|
||||||
def split(albumpath):
|
def split(albumpath):
|
||||||
global CUE_META
|
global CUE_META
|
||||||
os.chdir(albumpath)
|
os.chdir(albumpath)
|
||||||
@@ -546,8 +555,8 @@ def split(albumpath):
|
|||||||
for _cue in base_dir.filter('CueFile'):
|
for _cue in base_dir.filter('CueFile'):
|
||||||
for _wave in base_dir.filter('WaveFile'):
|
for _wave in base_dir.filter('WaveFile'):
|
||||||
if _cue.header['file'] == _wave.name:
|
if _cue.header['file'] == _wave.name:
|
||||||
logger.info('CUE Sheet found: {0}'.format(_cue.name))
|
logger.info('CUE Sheet found: %s', _cue.name)
|
||||||
logger.info('Music file found: {0}'.format(_wave.name))
|
logger.info('Music file found: %s', _wave.name)
|
||||||
cue = _cue
|
cue = _cue
|
||||||
wave = _wave
|
wave = _wave
|
||||||
# if no perfect match found then try without extensions
|
# if no perfect match found then try without extensions
|
||||||
@@ -556,9 +565,9 @@ def split(albumpath):
|
|||||||
for _cue in base_dir.filter('CueFile'):
|
for _cue in base_dir.filter('CueFile'):
|
||||||
for _wave in base_dir.filter('WaveFile'):
|
for _wave in base_dir.filter('WaveFile'):
|
||||||
if ''.join(os.path.splitext(_cue.header['file'])[:-1]) == _wave.name_name:
|
if ''.join(os.path.splitext(_cue.header['file'])[:-1]) == _wave.name_name:
|
||||||
logger.info('Possible CUE Sheet found: {0}'.format(_cue.name))
|
logger.info('Possible CUE Sheet found: %s', _cue.name)
|
||||||
logger.info('CUE Sheet refers music file: {0}'.format(_cue.header['file']))
|
logger.info('CUE Sheet refers music file: %s', _cue.header['file'])
|
||||||
logger.info('Possible Music file found: {0}'.format(_wave.name))
|
logger.info('Possible Music file found: %s', _wave.name)
|
||||||
cue = _cue
|
cue = _cue
|
||||||
wave = _wave
|
wave = _wave
|
||||||
cue.header['file'] = wave.name
|
cue.header['file'] = wave.name
|
||||||
@@ -575,7 +584,8 @@ def split(albumpath):
|
|||||||
import getXldProfile
|
import getXldProfile
|
||||||
xldprofile, xldformat, _ = getXldProfile.getXldProfile(headphones.CONFIG.XLDPROFILE)
|
xldprofile, xldformat, _ = getXldProfile.getXldProfile(headphones.CONFIG.XLDPROFILE)
|
||||||
if not xldformat:
|
if not xldformat:
|
||||||
raise ValueError('Details for xld profile "%s" not found, cannot split cue' % (xldprofile))
|
raise ValueError(
|
||||||
|
'Details for xld profile "%s" not found, cannot split cue' % (xldprofile))
|
||||||
else:
|
else:
|
||||||
if headphones.CONFIG.ENCODERFOLDER:
|
if headphones.CONFIG.ENCODERFOLDER:
|
||||||
splitter = os.path.join(headphones.CONFIG.ENCODERFOLDER, 'xld')
|
splitter = os.path.join(headphones.CONFIG.ENCODERFOLDER, 'xld')
|
||||||
@@ -588,11 +598,10 @@ def split(albumpath):
|
|||||||
splitter = 'shntool'
|
splitter = 'shntool'
|
||||||
|
|
||||||
if splitter == 'shntool' and not check_splitter(splitter):
|
if splitter == 'shntool' and not check_splitter(splitter):
|
||||||
raise ValueError('Command not found, ensure shntools with FLAC or xld (OS X) installed')
|
raise ValueError('Command not found, ensure shntool or xld installed')
|
||||||
|
|
||||||
# Determine if file can be split (only flac allowed for shntools)
|
# Determine if file can be split
|
||||||
if 'xld' in splitter and wave.name_ext not in WAVE_FILE_TYPE_BY_EXTENSION.keys() or \
|
if wave.name_ext not in WAVE_FILE_TYPE_BY_EXTENSION.keys():
|
||||||
wave.type not in SHNTOOL_COMPATIBLE:
|
|
||||||
raise ValueError('Cannot split, audio file has unsupported extension')
|
raise ValueError('Cannot split, audio file has unsupported extension')
|
||||||
|
|
||||||
# Split with xld
|
# Split with xld
|
||||||
@@ -627,12 +636,16 @@ def split(albumpath):
|
|||||||
with open(SPLIT_FILE_NAME, mode='w') as split_file:
|
with open(SPLIT_FILE_NAME, mode='w') as split_file:
|
||||||
split_file.write(cue.breakpoints())
|
split_file.write(cue.breakpoints())
|
||||||
|
|
||||||
|
if headphones.CONFIG.CUE_SPLIT_SHNTOOL_PATH:
|
||||||
|
cmd = [os.path.join(headphones.CONFIG.CUE_SPLIT_SHNTOOL_PATH, 'shntool')]
|
||||||
|
else:
|
||||||
cmd = ['shntool']
|
cmd = ['shntool']
|
||||||
|
|
||||||
cmd.extend(['split'])
|
cmd.extend(['split'])
|
||||||
cmd.extend(['-f'])
|
cmd.extend(['-f'])
|
||||||
cmd.extend([SPLIT_FILE_NAME])
|
cmd.extend([SPLIT_FILE_NAME])
|
||||||
cmd.extend(['-o'])
|
cmd.extend(['-o'])
|
||||||
cmd.extend(['flac'])
|
cmd.extend([wave.name_ext.lstrip('.')])
|
||||||
cmd.extend([wave.name])
|
cmd.extend([wave.name])
|
||||||
split = split_baby(wave.name, cmd)
|
split = split_baby(wave.name, cmd)
|
||||||
os.remove(SPLIT_FILE_NAME)
|
os.remove(SPLIT_FILE_NAME)
|
||||||
@@ -641,14 +654,14 @@ def split(albumpath):
|
|||||||
# tag FLAC files
|
# tag FLAC files
|
||||||
if split and CUE_META.count_tracks() == len(base_dir.tracks(ext='.flac', split=True)):
|
if split and CUE_META.count_tracks() == len(base_dir.tracks(ext='.flac', split=True)):
|
||||||
for t in base_dir.tracks(ext='.flac', split=True):
|
for t in base_dir.tracks(ext='.flac', split=True):
|
||||||
logger.info('Tagging {0}...'.format(t.name))
|
logger.info('Tagging %s...', t.name)
|
||||||
t.tag()
|
t.tag()
|
||||||
|
|
||||||
# rename FLAC files
|
# rename files
|
||||||
if split and CUE_META.count_tracks() == len(base_dir.tracks(ext='.flac', split=True)):
|
if split and CUE_META.count_tracks() == len(base_dir.tracks(ext=wave.name_ext, split=True)):
|
||||||
for t in base_dir.tracks(ext='.flac', split=True):
|
for t in base_dir.tracks(ext=wave.name_ext, split=True):
|
||||||
if t.name != t.filename():
|
if t.name != t.filename():
|
||||||
logger.info('Renaming {0} to {1}...'.format(t.name, t.filename()))
|
logger.info('Renaming %s to %s...', t.name, t.filename())
|
||||||
os.rename(t.name, t.filename())
|
os.rename(t.name, t.filename())
|
||||||
|
|
||||||
os.remove(ALBUM_META_FILE_NAME)
|
os.remove(ALBUM_META_FILE_NAME)
|
||||||
|
|||||||
+18
-13
@@ -13,22 +13,20 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
#####################################
|
###################################
|
||||||
## Stolen from Sick-Beard's db.py ##
|
# Stolen from Sick-Beard's db.py #
|
||||||
#####################################
|
###################################
|
||||||
|
|
||||||
from __future__ import with_statement
|
from __future__ import with_statement
|
||||||
|
|
||||||
import os
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
|
import os
|
||||||
import headphones
|
import headphones
|
||||||
|
|
||||||
from headphones import logger
|
from headphones import logger
|
||||||
|
|
||||||
|
|
||||||
def dbFilename(filename="headphones.db"):
|
def dbFilename(filename="headphones.db"):
|
||||||
|
|
||||||
return os.path.join(headphones.DATA_DIR, filename)
|
return os.path.join(headphones.DATA_DIR, filename)
|
||||||
|
|
||||||
|
|
||||||
@@ -41,7 +39,6 @@ def getCacheSize():
|
|||||||
|
|
||||||
|
|
||||||
class DBConnection:
|
class DBConnection:
|
||||||
|
|
||||||
def __init__(self, filename="headphones.db"):
|
def __init__(self, filename="headphones.db"):
|
||||||
|
|
||||||
self.filename = filename
|
self.filename = filename
|
||||||
@@ -92,15 +89,23 @@ class DBConnection:
|
|||||||
|
|
||||||
def upsert(self, tableName, valueDict, keyDict):
|
def upsert(self, tableName, valueDict, keyDict):
|
||||||
|
|
||||||
|
def genParams(myDict):
|
||||||
|
return [x + " = ?" for x in myDict.keys()]
|
||||||
|
|
||||||
changesBefore = self.connection.total_changes
|
changesBefore = self.connection.total_changes
|
||||||
|
|
||||||
genParams = lambda myDict: [x + " = ?" for x in myDict.keys()]
|
update_query = "UPDATE " + tableName + " SET " + ", ".join(
|
||||||
|
genParams(valueDict)) + " WHERE " + " AND ".join(genParams(keyDict))
|
||||||
|
|
||||||
query = "UPDATE " + tableName + " SET " + ", ".join(genParams(valueDict)) + " WHERE " + " AND ".join(genParams(keyDict))
|
self.action(update_query, valueDict.values() + keyDict.values())
|
||||||
|
|
||||||
self.action(query, valueDict.values() + keyDict.values())
|
|
||||||
|
|
||||||
if self.connection.total_changes == changesBefore:
|
if self.connection.total_changes == changesBefore:
|
||||||
query = "INSERT INTO " + tableName + " (" + ", ".join(valueDict.keys() + keyDict.keys()) + ")" + \
|
insert_query = (
|
||||||
|
"INSERT INTO " + tableName + " (" + ", ".join(
|
||||||
|
valueDict.keys() + keyDict.keys()) + ")" +
|
||||||
" VALUES (" + ", ".join(["?"] * len(valueDict.keys() + keyDict.keys())) + ")"
|
" VALUES (" + ", ".join(["?"] * len(valueDict.keys() + keyDict.keys())) + ")"
|
||||||
self.action(query, valueDict.values() + keyDict.values())
|
)
|
||||||
|
try:
|
||||||
|
self.action(insert_query, valueDict.values() + keyDict.values())
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
logger.info('Queries failed: %s and %s', update_query, insert_query)
|
||||||
|
|||||||
@@ -0,0 +1,460 @@
|
|||||||
|
# This file is part of Headphones.
|
||||||
|
#
|
||||||
|
# Headphones is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Headphones is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# Parts of this file are a part of SickRage.
|
||||||
|
# Author: Mr_Orange <mr_orange@hotmail.it>
|
||||||
|
# URL: http://code.google.com/p/sickbeard/
|
||||||
|
# Adapted for Headphones by <noamgit@gmail.com>
|
||||||
|
# URL: https://github.com/noam09
|
||||||
|
#
|
||||||
|
# SickRage is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# SickRage is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with SickRage. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from headphones import logger
|
||||||
|
#from headphones import request
|
||||||
|
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import headphones
|
||||||
|
import requests
|
||||||
|
from base64 import b64encode
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
delugeweb_auth = {}
|
||||||
|
delugeweb_url = ''
|
||||||
|
|
||||||
|
|
||||||
|
def addTorrent(link, data=None):
|
||||||
|
try:
|
||||||
|
result = {}
|
||||||
|
retid = False
|
||||||
|
|
||||||
|
if link.startswith('magnet:'):
|
||||||
|
logger.debug('Deluge: Got a magnet link: %s' % link)
|
||||||
|
result = {'type': 'magnet',
|
||||||
|
'url': link}
|
||||||
|
retid = _add_torrent_magnet(result)
|
||||||
|
|
||||||
|
elif link.startswith('http://') or link.startswith('https://'):
|
||||||
|
logger.debug('Deluge: Got a URL: %s' % link)
|
||||||
|
user_agent = 'Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2243.2 Safari/537.36'
|
||||||
|
headers = {'User-Agent': user_agent}
|
||||||
|
torrentfile = ''
|
||||||
|
logger.debug('Deluge: Trying to download (GET)')
|
||||||
|
try:
|
||||||
|
r = requests.get(link, headers=headers)
|
||||||
|
if r.status_code == 200:
|
||||||
|
logger.debug('Deluge: 200 OK')
|
||||||
|
torrentfile = r.text
|
||||||
|
#for chunk in r.iter_content(chunk_size=1024):
|
||||||
|
# if chunk: # filter out keep-alive new chunks
|
||||||
|
# torrentfile = torrentfile + chunk
|
||||||
|
else:
|
||||||
|
logger.debug('Deluge: Trying to GET %s returned status %d' % (link, r.status_code))
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug('Deluge: Download failed: %s' % str(e))
|
||||||
|
if 'announce' not in torrentfile[:40]:
|
||||||
|
logger.debug('Deluge: Contents of %s doesn\'t look like a torrent file' % link)
|
||||||
|
return False
|
||||||
|
# Extract torrent name from .torrent
|
||||||
|
try:
|
||||||
|
logger.debug('Deluge: Getting torrent name length')
|
||||||
|
name_length = int(re.findall('name([0-9]*)\:.*?\:', torrentfile)[0])
|
||||||
|
logger.debug('Deluge: Getting torrent name')
|
||||||
|
name = re.findall('name[0-9]*\:(.*?)\:', torrentfile)[0][:name_length]
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug('Deluge: Could not get torrent name, getting file name')
|
||||||
|
# get last part of link/path (name only)
|
||||||
|
name = link.split('\\')[-1].split('/')[-1]
|
||||||
|
# remove '.torrent' suffix
|
||||||
|
if name[-len('.torrent'):] == '.torrent':
|
||||||
|
name = name[:-len('.torrent')]
|
||||||
|
logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name, torrentfile[:40]))
|
||||||
|
result = {'type': 'torrent',
|
||||||
|
'name': name,
|
||||||
|
'content': torrentfile}
|
||||||
|
retid = _add_torrent_file(result)
|
||||||
|
|
||||||
|
# elif link.endswith('.torrent') or data:
|
||||||
|
elif not (link.startswith('http://') or link.startswith('https://')):
|
||||||
|
if data:
|
||||||
|
logger.debug('Deluge: Getting .torrent data')
|
||||||
|
torrentfile = data
|
||||||
|
else:
|
||||||
|
logger.debug('Deluge: Getting .torrent file')
|
||||||
|
with open(link, 'rb') as f:
|
||||||
|
torrentfile = f.read()
|
||||||
|
# Extract torrent name from .torrent
|
||||||
|
try:
|
||||||
|
logger.debug('Deluge: Getting torrent name length')
|
||||||
|
name_length = int(re.findall('name([0-9]*)\:.*?\:', torrentfile)[0])
|
||||||
|
logger.debug('Deluge: Getting torrent name')
|
||||||
|
name = re.findall('name[0-9]*\:(.*?)\:', torrentfile)[0][:name_length]
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug('Deluge: Could not get torrent name, getting file name')
|
||||||
|
# get last part of link/path (name only)
|
||||||
|
name = link.split('\\')[-1].split('/')[-1]
|
||||||
|
# remove '.torrent' suffix
|
||||||
|
if name[-len('.torrent'):] == '.torrent':
|
||||||
|
name = name[:-len('.torrent')]
|
||||||
|
logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name, torrentfile[:40]))
|
||||||
|
result = {'type': 'torrent',
|
||||||
|
'name': name,
|
||||||
|
'content': torrentfile}
|
||||||
|
retid = _add_torrent_file(result)
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.error('Deluge: Unknown file type: %s' % link)
|
||||||
|
|
||||||
|
if retid:
|
||||||
|
logger.info('Deluge: Torrent sent to Deluge successfully (%s)' % retid)
|
||||||
|
return retid
|
||||||
|
else:
|
||||||
|
logger.info('Deluge returned status %s' % retid)
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
formatted_lines = traceback.format_exc().splitlines()
|
||||||
|
logger.error('; '.join(formatted_lines))
|
||||||
|
|
||||||
|
|
||||||
|
def getTorrentFolder(result):
|
||||||
|
logger.debug('Deluge: Get torrent folder name')
|
||||||
|
if not any(delugeweb_auth):
|
||||||
|
_get_auth()
|
||||||
|
|
||||||
|
try:
|
||||||
|
post_data = json.dumps({"method": "web.get_torrent_status",
|
||||||
|
"params": [
|
||||||
|
result['hash'],
|
||||||
|
["total_done"]
|
||||||
|
],
|
||||||
|
"id": 22})
|
||||||
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth)
|
||||||
|
result['total_done'] = json.loads(response.text)['result']['total_done']
|
||||||
|
|
||||||
|
tries = 0
|
||||||
|
while result['total_done'] == 0 and tries < 10:
|
||||||
|
tries += 1
|
||||||
|
time.sleep(5)
|
||||||
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth)
|
||||||
|
result['total_done'] = json.loads(response.text)['result']['total_done']
|
||||||
|
|
||||||
|
post_data = json.dumps({"method": "web.get_torrent_status",
|
||||||
|
"params": [
|
||||||
|
result['hash'],
|
||||||
|
[
|
||||||
|
"name",
|
||||||
|
"save_path",
|
||||||
|
"total_size",
|
||||||
|
"num_files",
|
||||||
|
"message",
|
||||||
|
"tracker",
|
||||||
|
"comment"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"id": 23})
|
||||||
|
|
||||||
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth)
|
||||||
|
|
||||||
|
result['save_path'] = json.loads(response.text)['result']['save_path']
|
||||||
|
result['name'] = json.loads(response.text)['result']['name']
|
||||||
|
|
||||||
|
return json.loads(response.text)['result']['name']
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug('Deluge: Could not get torrent folder name: %s' % str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def removeTorrent(torrentid, remove_data=False):
|
||||||
|
|
||||||
|
if not any(delugeweb_auth):
|
||||||
|
_get_auth()
|
||||||
|
|
||||||
|
result = False
|
||||||
|
post_data = json.dumps({"method": "core.remove_torrent",
|
||||||
|
"params": [
|
||||||
|
torrentid,
|
||||||
|
remove_data
|
||||||
|
],
|
||||||
|
"id": 25})
|
||||||
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth)
|
||||||
|
result = json.loads(response.text)['result']
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _get_auth():
|
||||||
|
logger.debug('Deluge: Authenticating...')
|
||||||
|
global delugeweb_auth, delugeweb_url
|
||||||
|
delugeweb_auth = {}
|
||||||
|
|
||||||
|
delugeweb_host = headphones.CONFIG.DELUGE_HOST
|
||||||
|
delugeweb_password = headphones.CONFIG.DELUGE_PASSWORD
|
||||||
|
|
||||||
|
if not delugeweb_host.startswith('http'):
|
||||||
|
delugeweb_host = 'http://%s' % delugeweb_host
|
||||||
|
|
||||||
|
if delugeweb_host.endswith('/'):
|
||||||
|
delugeweb_host = delugeweb_host[:-1]
|
||||||
|
|
||||||
|
delugeweb_url = delugeweb_host + '/json'
|
||||||
|
|
||||||
|
post_data = json.dumps({"method": "auth.login",
|
||||||
|
"params": [delugeweb_password],
|
||||||
|
"id": 1})
|
||||||
|
try:
|
||||||
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth)
|
||||||
|
# , verify=TORRENT_VERIFY_CERT)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
auth = json.loads(response.text)["result"]
|
||||||
|
delugeweb_auth = response.cookies
|
||||||
|
|
||||||
|
post_data = json.dumps({"method": "web.connected",
|
||||||
|
"params": [],
|
||||||
|
"id": 10})
|
||||||
|
try:
|
||||||
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth)
|
||||||
|
# , verify=TORRENT_VERIFY_CERT)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
connected = json.loads(response.text)['result']
|
||||||
|
|
||||||
|
if not connected:
|
||||||
|
post_data = json.dumps({"method": "web.get_hosts",
|
||||||
|
"params": [],
|
||||||
|
"id": 11})
|
||||||
|
try:
|
||||||
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth)
|
||||||
|
# , verify=TORRENT_VERIFY_CERT)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
delugeweb_hosts = json.loads(response.text)['result']
|
||||||
|
if len(delugeweb_hosts) == 0:
|
||||||
|
logger.error('Deluge: WebUI does not contain daemons')
|
||||||
|
return None
|
||||||
|
|
||||||
|
post_data = json.dumps({"method": "web.connect",
|
||||||
|
"params": [delugeweb_hosts[0][0]],
|
||||||
|
"id": 11})
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth)
|
||||||
|
# , verify=TORRENT_VERIFY_CERT)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
post_data = json.dumps({"method": "web.connected",
|
||||||
|
"params": [],
|
||||||
|
"id": 10})
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth)
|
||||||
|
# , verify=TORRENT_VERIFY_CERT)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
connected = json.loads(response.text)['result']
|
||||||
|
|
||||||
|
if not connected:
|
||||||
|
logger.error('Deluge: WebUI could not connect to daemon')
|
||||||
|
return None
|
||||||
|
|
||||||
|
return auth
|
||||||
|
|
||||||
|
|
||||||
|
def _add_torrent_magnet(result):
|
||||||
|
logger.debug('Deluge: Adding magnet')
|
||||||
|
if not any(delugeweb_auth):
|
||||||
|
_get_auth()
|
||||||
|
try:
|
||||||
|
post_data = json.dumps({"method": "core.add_torrent_magnet",
|
||||||
|
"params": [result['url'], {}],
|
||||||
|
"id": 2})
|
||||||
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth)
|
||||||
|
result['hash'] = json.loads(response.text)['result']
|
||||||
|
logger.debug('Deluge: Response was %s' % str(json.loads(response.text)['result']))
|
||||||
|
return json.loads(response.text)['result']
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Deluge: Adding torrent magnet failed: %s' % str(e))
|
||||||
|
|
||||||
|
'''
|
||||||
|
def _add_torrent_url(result):
|
||||||
|
logger.debug('Deluge: Adding URL')
|
||||||
|
if not any(delugeweb_auth):
|
||||||
|
_get_auth()
|
||||||
|
try:
|
||||||
|
post_data = json.dumps({"method": "web.download_torrent_from_url",
|
||||||
|
"params": [result['url'], {}],
|
||||||
|
"id": 2})
|
||||||
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth)
|
||||||
|
result['hash'] = json.loads(response.text)['result']
|
||||||
|
logger.debug('Deluge: Response was %s' % str(json.loads(response.text)['result']))
|
||||||
|
return json.loads(response.text)['result']
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Deluge: Adding torrent URL failed: %s' % str(e))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def _add_torrent_file(result):
|
||||||
|
logger.debug('Deluge: Adding file')
|
||||||
|
if not any(delugeweb_auth):
|
||||||
|
_get_auth()
|
||||||
|
try:
|
||||||
|
# content is torrent file contents that needs to be encoded to base64
|
||||||
|
post_data = json.dumps({"method": "core.add_torrent_file",
|
||||||
|
"params": [result['name'] + '.torrent', b64encode(result['content'].encode('utf8')), {}],
|
||||||
|
"id": 2})
|
||||||
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth)
|
||||||
|
result['hash'] = json.loads(response.text)['result']
|
||||||
|
logger.debug('Deluge: Response was %s' % str(json.loads(response.text)['result']))
|
||||||
|
return json.loads(response.text)['result']
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Deluge: Adding torrent file failed: %s' % str(e))
|
||||||
|
formatted_lines = traceback.format_exc().splitlines()
|
||||||
|
logger.error('; '.join(formatted_lines))
|
||||||
|
|
||||||
|
|
||||||
|
def setTorrentLabel(result):
|
||||||
|
logger.debug('Deluge: Setting label')
|
||||||
|
label = headphones.CONFIG.DELUGE_LABEL
|
||||||
|
|
||||||
|
if not any(delugeweb_auth):
|
||||||
|
_get_auth()
|
||||||
|
|
||||||
|
if ' ' in label:
|
||||||
|
logger.error('Deluge: Invalid label. Label can\'t contain spaces - replacing with underscores')
|
||||||
|
label = label.replace(' ', '_')
|
||||||
|
if label:
|
||||||
|
# check if label already exists and create it if not
|
||||||
|
post_data = json.dumps({"method": 'label.get_labels',
|
||||||
|
"params": [],
|
||||||
|
"id": 3})
|
||||||
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth)
|
||||||
|
labels = json.loads(response.text)['result']
|
||||||
|
|
||||||
|
if labels is not None:
|
||||||
|
if label not in labels:
|
||||||
|
try:
|
||||||
|
logger.debug('Deluge: %s label doesn\'t exist in Deluge, let\'s add it' % label)
|
||||||
|
post_data = json.dumps({"method": 'label.add',
|
||||||
|
"params": [label],
|
||||||
|
"id": 4})
|
||||||
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth)
|
||||||
|
logger.debug('Deluge: %s label added to Deluge' % label)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Deluge: Setting label failed: %s' % str(e))
|
||||||
|
formatted_lines = traceback.format_exc().splitlines()
|
||||||
|
logger.error('; '.join(formatted_lines))
|
||||||
|
|
||||||
|
# add label to torrent
|
||||||
|
post_data = json.dumps({"method": 'label.set_torrent',
|
||||||
|
"params": [result['hash'], label],
|
||||||
|
"id": 5})
|
||||||
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth)
|
||||||
|
logger.debug('Deluge: %s label added to torrent' % label)
|
||||||
|
else:
|
||||||
|
logger.debug('Deluge: Label plugin not detected')
|
||||||
|
return False
|
||||||
|
|
||||||
|
return not json.loads(response.text)['error']
|
||||||
|
|
||||||
|
|
||||||
|
def setSeedRatio(result):
|
||||||
|
logger.debug('Deluge: Setting seed ratio')
|
||||||
|
if not any(delugeweb_auth):
|
||||||
|
_get_auth()
|
||||||
|
|
||||||
|
ratio = None
|
||||||
|
if result['ratio']:
|
||||||
|
ratio = result['ratio']
|
||||||
|
|
||||||
|
if ratio:
|
||||||
|
post_data = json.dumps({"method": "core.set_torrent_stop_at_ratio",
|
||||||
|
"params": [result['hash'], True],
|
||||||
|
"id": 5})
|
||||||
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth)
|
||||||
|
post_data = json.dumps({"method": "core.set_torrent_stop_ratio",
|
||||||
|
"params": [result['hash'], float(ratio)],
|
||||||
|
"id": 6})
|
||||||
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth)
|
||||||
|
|
||||||
|
return not json.loads(response.text)['error']
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def setTorrentPath(result):
|
||||||
|
logger.debug('Deluge: Setting download path')
|
||||||
|
if not any(delugeweb_auth):
|
||||||
|
_get_auth()
|
||||||
|
|
||||||
|
if headphones.CONFIG.DELUGE_DONE_DIRECTORY or headphones.CONFIG.DOWNLOAD_TORRENT_DIR:
|
||||||
|
post_data = json.dumps({"method": "core.set_torrent_move_completed",
|
||||||
|
"params": [result['hash'], True],
|
||||||
|
"id": 7})
|
||||||
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth)
|
||||||
|
|
||||||
|
if headphones.CONFIG.DELUGE_DONE_DIRECTORY:
|
||||||
|
move_to = headphones.CONFIG.DELUGE_DONE_DIRECTORY
|
||||||
|
else:
|
||||||
|
move_to = headphones.CONFIG.DOWNLOAD_TORRENT_DIR
|
||||||
|
|
||||||
|
if not os.path.exists(move_to):
|
||||||
|
logger.debug('Deluge: %s directory doesn\'t exist, let\'s create it' % move_to)
|
||||||
|
os.makedirs(move_to)
|
||||||
|
post_data = json.dumps({"method": "core.set_torrent_move_completed_path",
|
||||||
|
"params": [result['hash'], move_to],
|
||||||
|
"id": 8})
|
||||||
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth)
|
||||||
|
|
||||||
|
return not json.loads(response.text)['error']
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def setTorrentPause(result):
|
||||||
|
logger.debug('Deluge: Pausing torrent')
|
||||||
|
if not any(delugeweb_auth):
|
||||||
|
_get_auth()
|
||||||
|
|
||||||
|
if headphones.CONFIG.DELUGE_PAUSED:
|
||||||
|
post_data = json.dumps({"method": "core.pause_torrent",
|
||||||
|
"params": [[result['hash']]],
|
||||||
|
"id": 9})
|
||||||
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth)
|
||||||
|
|
||||||
|
return not json.loads(response.text)['error']
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -24,3 +24,10 @@ class NewzbinAPIThrottled(HeadphonesException):
|
|||||||
"""
|
"""
|
||||||
Newzbin has throttled us, deal with it
|
Newzbin has throttled us, deal with it
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class SoftChrootError(HeadphonesException):
|
||||||
|
"""
|
||||||
|
Fatal errors in SoftChroot module
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|||||||
+18
-19
@@ -1,32 +1,31 @@
|
|||||||
import os.path
|
import os.path
|
||||||
import plistlib
|
|
||||||
import sys
|
import biplist
|
||||||
import xml.parsers.expat as expat
|
|
||||||
from headphones import logger
|
from headphones import logger
|
||||||
|
|
||||||
|
|
||||||
def getXldProfile(xldProfile):
|
def getXldProfile(xldProfile):
|
||||||
xldProfileNotFound = xldProfile
|
xldProfileNotFound = xldProfile
|
||||||
expandedPath = os.path.expanduser('~/Library/Preferences/jp.tmkk.XLD.plist')
|
|
||||||
try:
|
expanded = os.path.expanduser('~/Library/Preferences/jp.tmkk.XLD.plist')
|
||||||
preferences = plistlib.Plist.fromFile(expandedPath)
|
if not os.path.isfile(expanded):
|
||||||
except (expat.ExpatError):
|
logger.warn("Could not find xld preferences at: %s", expanded)
|
||||||
os.system("/usr/bin/plutil -convert xml1 %s" % expandedPath)
|
|
||||||
try:
|
|
||||||
preferences = plistlib.Plist.fromFile(expandedPath)
|
|
||||||
except (ImportError):
|
|
||||||
os.system("/usr/bin/plutil -convert binary1 %s" % expandedPath)
|
|
||||||
logger.info('The plist at "%s" has a date in it, and therefore is not useable.', expandedPath)
|
|
||||||
return (xldProfileNotFound, None, None)
|
return (xldProfileNotFound, None, None)
|
||||||
except (ImportError):
|
|
||||||
logger.info('The plist at "%s" has a date in it, and therefore is not useable.', expandedPath)
|
# Get xld preferences plist
|
||||||
except:
|
try:
|
||||||
logger.info('Unexpected error: %s', sys.exc_info()[0])
|
preferences = biplist.readPlist(expanded)
|
||||||
|
except (biplist.InvalidPlistException, biplist.NotBinaryPlistException), e:
|
||||||
|
logger.error("Error reading xld preferences plist: %s", e)
|
||||||
return (xldProfileNotFound, None, None)
|
return (xldProfileNotFound, None, None)
|
||||||
|
|
||||||
|
if not isinstance(preferences, dict):
|
||||||
|
logger.error("Error reading xld preferences plist, not a dict: %r", preferences)
|
||||||
|
return (xldProfileNotFound, None, None)
|
||||||
|
|
||||||
|
profiles = preferences.get('Profiles', []) # pylint:disable=E1103
|
||||||
|
|
||||||
xldProfile = xldProfile.lower()
|
xldProfile = xldProfile.lower()
|
||||||
profiles = preferences.get('Profiles')
|
|
||||||
|
|
||||||
for profile in profiles:
|
for profile in profiles:
|
||||||
|
|
||||||
profilename = profile.get('XLDProfileManager_ProfileName')
|
profilename = profile.get('XLDProfileManager_ProfileName')
|
||||||
|
|||||||
+159
-60
@@ -13,17 +13,19 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import os
|
from operator import itemgetter
|
||||||
import re
|
|
||||||
import time
|
|
||||||
import shutil
|
|
||||||
import datetime
|
|
||||||
import headphones
|
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
import datetime
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from operator import itemgetter
|
import fnmatch
|
||||||
|
import re
|
||||||
|
import os
|
||||||
from beets.mediafile import MediaFile, FileTypeError, UnreadableFileError
|
from beets.mediafile import MediaFile, FileTypeError, UnreadableFileError
|
||||||
|
import headphones
|
||||||
|
|
||||||
|
|
||||||
# Modified from https://github.com/Verrus/beets-plugin-featInTitle
|
# Modified from https://github.com/Verrus/beets-plugin-featInTitle
|
||||||
RE_FEATURING = re.compile(r"[fF]t\.|[fF]eaturing|[fF]eat\.|\b[wW]ith\b|&|vs\.")
|
RE_FEATURING = re.compile(r"[fF]t\.|[fF]eaturing|[fF]eat\.|\b[wW]ith\b|&|vs\.")
|
||||||
@@ -33,7 +35,9 @@ RE_CD = re.compile(r"^(CD|dics)\s*[0-9]+$", re.I)
|
|||||||
|
|
||||||
|
|
||||||
def multikeysort(items, columns):
|
def multikeysort(items, columns):
|
||||||
comparers = [((itemgetter(col[1:].strip()), -1) if col.startswith('-') else (itemgetter(col.strip()), 1)) for col in columns]
|
comparers = [
|
||||||
|
((itemgetter(col[1:].strip()), -1) if col.startswith('-') else (itemgetter(col.strip()), 1))
|
||||||
|
for col in columns]
|
||||||
|
|
||||||
def comparer(left, right):
|
def comparer(left, right):
|
||||||
for fn, mult in comparers:
|
for fn, mult in comparers:
|
||||||
@@ -54,7 +58,6 @@ def checked(variable):
|
|||||||
|
|
||||||
|
|
||||||
def radio(variable, pos):
|
def radio(variable, pos):
|
||||||
|
|
||||||
if variable == pos:
|
if variable == pos:
|
||||||
return 'Checked'
|
return 'Checked'
|
||||||
else:
|
else:
|
||||||
@@ -65,9 +68,10 @@ def latinToAscii(unicrap):
|
|||||||
"""
|
"""
|
||||||
From couch potato
|
From couch potato
|
||||||
"""
|
"""
|
||||||
xlate = {0xc0: 'A', 0xc1: 'A', 0xc2: 'A', 0xc3: 'A', 0xc4: 'A', 0xc5: 'A',
|
xlate = {
|
||||||
|
0xc0: 'A', 0xc1: 'A', 0xc2: 'A', 0xc3: 'A', 0xc4: 'A', 0xc5: 'A',
|
||||||
0xc6: 'Ae', 0xc7: 'C',
|
0xc6: 'Ae', 0xc7: 'C',
|
||||||
0xc8: 'E', 0xc9: 'E', 0xca: 'E', 0xcb: 'E', 0x86: 'e',
|
0xc8: 'E', 0xc9: 'E', 0xca: 'E', 0xcb: 'E', 0x86: 'e', 0x39e: 'E',
|
||||||
0xcc: 'I', 0xcd: 'I', 0xce: 'I', 0xcf: 'I',
|
0xcc: 'I', 0xcd: 'I', 0xce: 'I', 0xcf: 'I',
|
||||||
0xd0: 'Th', 0xd1: 'N',
|
0xd0: 'Th', 0xd1: 'N',
|
||||||
0xd2: 'O', 0xd3: 'O', 0xd4: 'O', 0xd5: 'O', 0xd6: 'O', 0xd8: 'O',
|
0xd2: 'O', 0xd3: 'O', 0xd4: 'O', 0xd5: 'O', 0xd6: 'O', 0xd8: 'O',
|
||||||
@@ -104,7 +108,6 @@ def latinToAscii(unicrap):
|
|||||||
|
|
||||||
|
|
||||||
def convert_milliseconds(ms):
|
def convert_milliseconds(ms):
|
||||||
|
|
||||||
seconds = ms / 1000
|
seconds = ms / 1000
|
||||||
gmtime = time.gmtime(seconds)
|
gmtime = time.gmtime(seconds)
|
||||||
if seconds > 3600:
|
if seconds > 3600:
|
||||||
@@ -116,7 +119,6 @@ def convert_milliseconds(ms):
|
|||||||
|
|
||||||
|
|
||||||
def convert_seconds(s):
|
def convert_seconds(s):
|
||||||
|
|
||||||
gmtime = time.gmtime(s)
|
gmtime = time.gmtime(s)
|
||||||
if s > 3600:
|
if s > 3600:
|
||||||
minutes = time.strftime("%H:%M:%S", gmtime)
|
minutes = time.strftime("%H:%M:%S", gmtime)
|
||||||
@@ -138,7 +140,6 @@ def now():
|
|||||||
|
|
||||||
|
|
||||||
def get_age(date):
|
def get_age(date):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
split_date = date.split('-')
|
split_date = date.split('-')
|
||||||
except:
|
except:
|
||||||
@@ -146,14 +147,13 @@ def get_age(date):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
days_old = int(split_date[0]) * 365 + int(split_date[1]) * 30 + int(split_date[2])
|
days_old = int(split_date[0]) * 365 + int(split_date[1]) * 30 + int(split_date[2])
|
||||||
except IndexError:
|
except (IndexError, ValueError):
|
||||||
days_old = False
|
days_old = False
|
||||||
|
|
||||||
return days_old
|
return days_old
|
||||||
|
|
||||||
|
|
||||||
def bytes_to_mb(bytes):
|
def bytes_to_mb(bytes):
|
||||||
|
|
||||||
mb = int(bytes) / 1048576
|
mb = int(bytes) / 1048576
|
||||||
size = '%.1f MB' % mb
|
size = '%.1f MB' % mb
|
||||||
return size
|
return size
|
||||||
@@ -168,13 +168,20 @@ def mb_to_bytes(mb_str):
|
|||||||
def piratesize(size):
|
def piratesize(size):
|
||||||
split = size.split(" ")
|
split = size.split(" ")
|
||||||
factor = float(split[0])
|
factor = float(split[0])
|
||||||
unit = split[1]
|
unit = split[1].upper()
|
||||||
if unit == 'MiB':
|
|
||||||
|
if unit == 'MIB':
|
||||||
size = factor * 1048576
|
size = factor * 1048576
|
||||||
elif unit == 'GiB':
|
elif unit == 'MB':
|
||||||
|
size = factor * 1000000
|
||||||
|
elif unit == 'GIB':
|
||||||
size = factor * 1073741824
|
size = factor * 1073741824
|
||||||
elif unit == 'KiB':
|
elif unit == 'GB':
|
||||||
|
size = factor * 1000000000
|
||||||
|
elif unit == 'KIB':
|
||||||
size = factor * 1024
|
size = factor * 1024
|
||||||
|
elif unit == 'KB':
|
||||||
|
size = factor * 1000
|
||||||
elif unit == "B":
|
elif unit == "B":
|
||||||
size = factor
|
size = factor
|
||||||
else:
|
else:
|
||||||
@@ -184,12 +191,13 @@ def piratesize(size):
|
|||||||
|
|
||||||
|
|
||||||
def replace_all(text, dic, normalize=False):
|
def replace_all(text, dic, normalize=False):
|
||||||
|
from headphones import pathrender
|
||||||
if not text:
|
if not text:
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
for i, j in dic.iteritems():
|
|
||||||
if normalize:
|
if normalize:
|
||||||
|
new_dic = {}
|
||||||
|
for i, j in dic.iteritems():
|
||||||
try:
|
try:
|
||||||
if sys.platform == 'darwin':
|
if sys.platform == 'darwin':
|
||||||
j = unicodedata.normalize('NFD', j)
|
j = unicodedata.normalize('NFD', j)
|
||||||
@@ -197,8 +205,9 @@ def replace_all(text, dic, normalize=False):
|
|||||||
j = unicodedata.normalize('NFC', j)
|
j = unicodedata.normalize('NFC', j)
|
||||||
except TypeError:
|
except TypeError:
|
||||||
j = unicodedata.normalize('NFC', j.decode(headphones.SYS_ENCODING, 'replace'))
|
j = unicodedata.normalize('NFC', j.decode(headphones.SYS_ENCODING, 'replace'))
|
||||||
text = text.replace(i, j)
|
new_dic[i] = j
|
||||||
return text
|
dic = new_dic
|
||||||
|
return pathrender.render(text, dic)[0]
|
||||||
|
|
||||||
|
|
||||||
def replace_illegal_chars(string, type="file"):
|
def replace_illegal_chars(string, type="file"):
|
||||||
@@ -211,15 +220,14 @@ def replace_illegal_chars(string, type="file"):
|
|||||||
|
|
||||||
|
|
||||||
def cleanName(string):
|
def cleanName(string):
|
||||||
|
|
||||||
pass1 = latinToAscii(string).lower()
|
pass1 = latinToAscii(string).lower()
|
||||||
out_string = re.sub('[\.\-\/\!\@\#\$\%\^\&\*\(\)\+\-\"\'\,\;\:\[\]\{\}\<\>\=\_]', '', pass1).encode('utf-8')
|
out_string = re.sub('[\.\-\/\!\@\#\$\%\^\&\*\(\)\+\-\"\'\,\;\:\[\]\{\}\<\>\=\_]', '',
|
||||||
|
pass1).encode('utf-8')
|
||||||
|
|
||||||
return out_string
|
return out_string
|
||||||
|
|
||||||
|
|
||||||
def cleanTitle(title):
|
def cleanTitle(title):
|
||||||
|
|
||||||
title = re.sub('[\.\-\/\_]', ' ', title).lower()
|
title = re.sub('[\.\-\/\_]', ' ', title).lower()
|
||||||
|
|
||||||
# Strip out extra whitespace
|
# Strip out extra whitespace
|
||||||
@@ -302,16 +310,22 @@ def expand_subfolders(f):
|
|||||||
difference = max(path_depths) - min(path_depths)
|
difference = max(path_depths) - min(path_depths)
|
||||||
|
|
||||||
if difference > 0:
|
if difference > 0:
|
||||||
logger.info("Found %d media folders, but depth difference between lowest and deepest media folder is %d (expected zero). If this is a discography or a collection of albums, make sure albums are per folder.", len(media_folders), difference)
|
logger.info(
|
||||||
|
"Found %d media folders, but depth difference between lowest and deepest media folder is %d (expected zero). If this is a discography or a collection of albums, make sure albums are per folder.",
|
||||||
|
len(media_folders), difference)
|
||||||
|
|
||||||
# While already failed, advice the user what he could try. We assume the
|
# While already failed, advice the user what he could try. We assume the
|
||||||
# directory may contain separate CD's and maybe some extra's. The
|
# directory may contain separate CD's and maybe some extra's. The
|
||||||
# structure may look like X albums at same depth, and (one or more)
|
# structure may look like X albums at same depth, and (one or more)
|
||||||
# extra folders with a higher depth.
|
# extra folders with a higher depth.
|
||||||
extra_media_folders = [media_folder[:min(path_depths)] for media_folder in media_folders if len(media_folder) > min(path_depths)]
|
extra_media_folders = [media_folder[:min(path_depths)] for media_folder in media_folders if
|
||||||
extra_media_folders = list(set([os.path.join(*media_folder) for media_folder in extra_media_folders]))
|
len(media_folder) > min(path_depths)]
|
||||||
|
extra_media_folders = list(
|
||||||
|
set([os.path.join(*media_folder) for media_folder in extra_media_folders]))
|
||||||
|
|
||||||
logger.info("Please look at the following folder(s), since they cause the depth difference: %s", extra_media_folders)
|
logger.info(
|
||||||
|
"Please look at the following folder(s), since they cause the depth difference: %s",
|
||||||
|
extra_media_folders)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Convert back to paths and remove duplicates, which may be there after
|
# Convert back to paths and remove duplicates, which may be there after
|
||||||
@@ -329,12 +343,50 @@ def expand_subfolders(f):
|
|||||||
return media_folders
|
return media_folders
|
||||||
|
|
||||||
|
|
||||||
def extract_data(s):
|
def path_match_patterns(path, patterns):
|
||||||
|
"""
|
||||||
|
Check if a path matches one or more patterns. The whole path will be
|
||||||
|
matched be matched against the patterns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
for pattern in patterns:
|
||||||
|
if fnmatch.fnmatch(path, pattern):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# No match
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def path_filter_patterns(paths, patterns, root=None):
|
||||||
|
"""
|
||||||
|
Scan for ignored paths based on glob patterns. Note that the whole path
|
||||||
|
will be matched, therefore paths should only contain the relative paths.
|
||||||
|
|
||||||
|
The root is optional, and only used for producing meaningful debug info.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from headphones import logger
|
||||||
|
|
||||||
|
ignored = 0
|
||||||
|
|
||||||
|
for path in paths[:]:
|
||||||
|
if path_match_patterns(path, patterns):
|
||||||
|
logger.debug("Path ignored by pattern: %s",
|
||||||
|
os.path.join(root or "", path))
|
||||||
|
|
||||||
|
ignored += 1
|
||||||
|
paths.remove(path)
|
||||||
|
|
||||||
|
# Return number of ignored paths
|
||||||
|
return ignored
|
||||||
|
|
||||||
|
|
||||||
|
def extract_data(s):
|
||||||
s = s.replace('_', ' ')
|
s = s.replace('_', ' ')
|
||||||
|
|
||||||
# headphones default format
|
# headphones default format
|
||||||
pattern = re.compile(r'(?P<name>.*?)\s\-\s(?P<album>.*?)\s[\[\(](?P<year>.*?)[\]\)]', re.VERBOSE)
|
pattern = re.compile(r'(?P<name>.*?)\s\-\s(?P<album>.*?)\s[\[\(](?P<year>.*?)[\]\)]',
|
||||||
|
re.VERBOSE)
|
||||||
match = pattern.match(s)
|
match = pattern.match(s)
|
||||||
|
|
||||||
if match:
|
if match:
|
||||||
@@ -420,7 +472,8 @@ def extract_metadata(f):
|
|||||||
old_album = new_albums[index]
|
old_album = new_albums[index]
|
||||||
new_albums[index] = RE_CD_ALBUM.sub("", album).strip()
|
new_albums[index] = RE_CD_ALBUM.sub("", album).strip()
|
||||||
|
|
||||||
logger.debug("Stripped albumd number identifier: %s -> %s", old_album, new_albums[index])
|
logger.debug("Stripped albumd number identifier: %s -> %s", old_album,
|
||||||
|
new_albums[index])
|
||||||
|
|
||||||
# Remove duplicates
|
# Remove duplicates
|
||||||
new_albums = list(set(new_albums))
|
new_albums = list(set(new_albums))
|
||||||
@@ -450,7 +503,8 @@ def extract_metadata(f):
|
|||||||
return (artist, albums[0], years[0])
|
return (artist, albums[0], years[0])
|
||||||
|
|
||||||
# Not sure what to do here.
|
# Not sure what to do here.
|
||||||
logger.info("Found %d artists, %d albums and %d years in metadata, so ignoring", len(artists), len(albums), len(years))
|
logger.info("Found %d artists, %d albums and %d years in metadata, so ignoring", len(artists),
|
||||||
|
len(albums), len(years))
|
||||||
logger.debug("Artists: %s, Albums: %s, Years: %s", artists, albums, years)
|
logger.debug("Artists: %s, Albums: %s, Years: %s", artists, albums, years)
|
||||||
|
|
||||||
return (None, None, None)
|
return (None, None, None)
|
||||||
@@ -471,13 +525,15 @@ def get_downloaded_track_list(albumpath):
|
|||||||
return downloaded_track_list
|
return downloaded_track_list
|
||||||
|
|
||||||
|
|
||||||
def preserve_torrent_direcory(albumpath):
|
def preserve_torrent_directory(albumpath):
|
||||||
"""
|
"""
|
||||||
Copy torrent directory to headphones-modified to keep files for seeding.
|
Copy torrent directory to headphones-modified to keep files for seeding.
|
||||||
"""
|
"""
|
||||||
from headphones import logger
|
from headphones import logger
|
||||||
new_folder = os.path.join(albumpath, 'headphones-modified'.encode(headphones.SYS_ENCODING, 'replace'))
|
new_folder = os.path.join(albumpath,
|
||||||
logger.info("Copying files to 'headphones-modified' subfolder to preserve downloaded files for seeding")
|
'headphones-modified'.encode(headphones.SYS_ENCODING, 'replace'))
|
||||||
|
logger.info(
|
||||||
|
"Copying files to 'headphones-modified' subfolder to preserve downloaded files for seeding")
|
||||||
try:
|
try:
|
||||||
shutil.copytree(albumpath, new_folder)
|
shutil.copytree(albumpath, new_folder)
|
||||||
return new_folder
|
return new_folder
|
||||||
@@ -530,7 +586,9 @@ def cue_split(albumpath):
|
|||||||
|
|
||||||
def extract_logline(s):
|
def extract_logline(s):
|
||||||
# Default log format
|
# Default log format
|
||||||
pattern = re.compile(r'(?P<timestamp>.*?)\s\-\s(?P<level>.*?)\s*\:\:\s(?P<thread>.*?)\s\:\s(?P<message>.*)', re.VERBOSE)
|
pattern = re.compile(
|
||||||
|
r'(?P<timestamp>.*?)\s\-\s(?P<level>.*?)\s*\:\:\s(?P<thread>.*?)\s\:\s(?P<message>.*)',
|
||||||
|
re.VERBOSE)
|
||||||
match = pattern.match(s)
|
match = pattern.match(s)
|
||||||
if match:
|
if match:
|
||||||
timestamp = match.group("timestamp")
|
timestamp = match.group("timestamp")
|
||||||
@@ -571,7 +629,6 @@ def extract_song_data(s):
|
|||||||
|
|
||||||
|
|
||||||
def smartMove(src, dest, delete=True):
|
def smartMove(src, dest, delete=True):
|
||||||
|
|
||||||
from headphones import logger
|
from headphones import logger
|
||||||
|
|
||||||
source_dir = os.path.dirname(src)
|
source_dir = os.path.dirname(src)
|
||||||
@@ -592,7 +649,8 @@ def smartMove(src, dest, delete=True):
|
|||||||
os.rename(src, os.path.join(source_dir, newfile))
|
os.rename(src, os.path.join(source_dir, newfile))
|
||||||
filename = newfile
|
filename = newfile
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warn('Error renaming %s: %s', src.decode(headphones.SYS_ENCODING, 'replace'), e)
|
logger.warn('Error renaming %s: %s',
|
||||||
|
src.decode(headphones.SYS_ENCODING, 'replace'), e)
|
||||||
break
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -602,7 +660,47 @@ def smartMove(src, dest, delete=True):
|
|||||||
shutil.copy(os.path.join(source_dir, filename), os.path.join(dest, filename))
|
shutil.copy(os.path.join(source_dir, filename), os.path.join(dest, filename))
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warn('Error moving file %s: %s', filename.decode(headphones.SYS_ENCODING, 'replace'), e)
|
logger.warn('Error moving file %s: %s', filename.decode(headphones.SYS_ENCODING, 'replace'),
|
||||||
|
e)
|
||||||
|
|
||||||
|
|
||||||
|
def walk_directory(basedir, followlinks=True):
|
||||||
|
"""
|
||||||
|
Enhanced version of 'os.walk' where symlink directores are traversed, but
|
||||||
|
with care. In case a folder is already processed, don't traverse it again.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logger
|
||||||
|
|
||||||
|
# Add the base path, because symlinks poiting to the basedir should not be
|
||||||
|
# traversed again.
|
||||||
|
traversed = [os.path.abspath(basedir)]
|
||||||
|
|
||||||
|
def _inner(root, directories, files):
|
||||||
|
for directory in directories:
|
||||||
|
path = os.path.join(root, directory)
|
||||||
|
|
||||||
|
if followlinks and os.path.islink(path):
|
||||||
|
real_path = os.path.abspath(os.readlink(path))
|
||||||
|
|
||||||
|
if real_path in traversed:
|
||||||
|
logger.debug("Skipping '%s' since it is a symlink to " \
|
||||||
|
"'%s', which is already visited.", path, real_path)
|
||||||
|
else:
|
||||||
|
traversed.append(real_path)
|
||||||
|
|
||||||
|
for args in os.walk(real_path):
|
||||||
|
for result in _inner(*args):
|
||||||
|
yield result
|
||||||
|
|
||||||
|
# Pass on actual result
|
||||||
|
yield root, directories, files
|
||||||
|
|
||||||
|
# Start traversing
|
||||||
|
for args in os.walk(basedir):
|
||||||
|
for result in _inner(*args):
|
||||||
|
yield result
|
||||||
|
|
||||||
|
|
||||||
#########################
|
#########################
|
||||||
# Sab renaming functions #
|
# Sab renaming functions #
|
||||||
@@ -670,34 +768,35 @@ def split_string(mystring, splitvar=','):
|
|||||||
|
|
||||||
def create_https_certificates(ssl_cert, ssl_key):
|
def create_https_certificates(ssl_cert, ssl_key):
|
||||||
"""
|
"""
|
||||||
Stolen from SickBeard (http://github.com/midgetspy/Sick-Beard):
|
Create a pair of self-signed HTTPS certificares and store in them in
|
||||||
Create self-signed HTTPS certificares and store in paths 'ssl_cert' and 'ssl_key'
|
'ssl_cert' and 'ssl_key'. Method assumes pyOpenSSL is installed.
|
||||||
|
|
||||||
|
This code is stolen from SickBeard (http://github.com/midgetspy/Sick-Beard).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from headphones import logger
|
from headphones import logger
|
||||||
|
|
||||||
try:
|
|
||||||
from OpenSSL import crypto
|
from OpenSSL import crypto
|
||||||
from certgen import createKeyPair, createCertRequest, createCertificate, TYPE_RSA, serial
|
from certgen import createKeyPair, createCertRequest, createCertificate, \
|
||||||
except:
|
TYPE_RSA, serial
|
||||||
logger.warn("pyOpenSSL module missing, please install to enable HTTPS")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Create the CA Certificate
|
# Create the CA Certificate
|
||||||
cakey = createKeyPair(TYPE_RSA, 1024)
|
cakey = createKeyPair(TYPE_RSA, 2048)
|
||||||
careq = createCertRequest(cakey, CN='Certificate Authority')
|
careq = createCertRequest(cakey, CN="Certificate Authority")
|
||||||
cacert = createCertificate(careq, (careq, cakey), serial, (0, 60 * 60 * 24 * 365 * 10)) # ten years
|
cacert = createCertificate(careq, (careq, cakey), serial,
|
||||||
|
(0, 60 * 60 * 24 * 365 * 10)) # ten years
|
||||||
|
|
||||||
cname = 'Headphones'
|
pkey = createKeyPair(TYPE_RSA, 2048)
|
||||||
pkey = createKeyPair(TYPE_RSA, 1024)
|
req = createCertRequest(pkey, CN="Headphones")
|
||||||
req = createCertRequest(pkey, CN=cname)
|
cert = createCertificate(req, (cacert, cakey), serial,
|
||||||
cert = createCertificate(req, (cacert, cakey), serial, (0, 60 * 60 * 24 * 365 * 10)) # ten years
|
(0, 60 * 60 * 24 * 365 * 10)) # ten years
|
||||||
|
|
||||||
# Save the key and certificate to disk
|
# Save the key and certificate to disk
|
||||||
try:
|
try:
|
||||||
with open(ssl_key, 'w') as f:
|
with open(ssl_key, "w") as fp:
|
||||||
f.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey))
|
fp.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey))
|
||||||
with open(ssl_cert, 'w') as f:
|
with open(ssl_cert, "w") as fp:
|
||||||
f.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert))
|
fp.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert))
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
logger.error("Error creating SSL key and certificate: %s", e)
|
logger.error("Error creating SSL key and certificate: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|||||||
+147
-60
@@ -13,11 +13,10 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from headphones import logger, helpers, db, mb, lastfm
|
|
||||||
|
|
||||||
from beets.mediafile import MediaFile
|
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from headphones import logger, helpers, db, mb, lastfm, metacritic
|
||||||
|
from beets.mediafile import MediaFile
|
||||||
import headphones
|
import headphones
|
||||||
|
|
||||||
blacklisted_special_artist_names = ['[anonymous]', '[data]', '[no artist]',
|
blacklisted_special_artist_names = ['[anonymous]', '[data]', '[no artist]',
|
||||||
@@ -35,20 +34,21 @@ def is_exists(artistid):
|
|||||||
myDB = db.DBConnection()
|
myDB = db.DBConnection()
|
||||||
|
|
||||||
# See if the artist is already in the database
|
# See if the artist is already in the database
|
||||||
artistlist = myDB.select('SELECT ArtistID, ArtistName from artists WHERE ArtistID=?', [artistid])
|
artistlist = myDB.select('SELECT ArtistID, ArtistName from artists WHERE ArtistID=?',
|
||||||
|
[artistid])
|
||||||
|
|
||||||
if any(artistid in x for x in artistlist):
|
if any(artistid in x for x in artistlist):
|
||||||
logger.info(artistlist[0][1] + u" is already in the database. Updating 'have tracks', but not artist information")
|
logger.info(artistlist[0][
|
||||||
|
1] + u" is already in the database. Updating 'have tracks', but not artist information")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def artistlist_to_mbids(artistlist, forced=False):
|
def artistlist_to_mbids(artistlist, forced=False):
|
||||||
|
|
||||||
for artist in artistlist:
|
for artist in artistlist:
|
||||||
|
|
||||||
if not artist and not (artist == ' '):
|
if not artist and artist != ' ':
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# If adding artists through Manage New Artists, they're coming through as non-unicode (utf-8?)
|
# If adding artists through Manage New Artists, they're coming through as non-unicode (utf-8?)
|
||||||
@@ -77,9 +77,12 @@ def artistlist_to_mbids(artistlist, forced=False):
|
|||||||
myDB = db.DBConnection()
|
myDB = db.DBConnection()
|
||||||
|
|
||||||
if not forced:
|
if not forced:
|
||||||
bl_artist = myDB.action('SELECT * FROM blacklist WHERE ArtistID=?', [artistid]).fetchone()
|
bl_artist = myDB.action('SELECT * FROM blacklist WHERE ArtistID=?',
|
||||||
|
[artistid]).fetchone()
|
||||||
if bl_artist or artistid in blacklisted_special_artists:
|
if bl_artist or artistid in blacklisted_special_artists:
|
||||||
logger.info("Artist ID for '%s' is either blacklisted or Various Artists. To add artist, you must do it manually (Artist ID: %s)" % (artist, artistid))
|
logger.info(
|
||||||
|
"Artist ID for '%s' is either blacklisted or Various Artists. To add artist, you must do it manually (Artist ID: %s)" % (
|
||||||
|
artist, artistid))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Add to database if it doesn't exist
|
# Add to database if it doesn't exist
|
||||||
@@ -88,7 +91,9 @@ def artistlist_to_mbids(artistlist, forced=False):
|
|||||||
|
|
||||||
# Just update the tracks if it does
|
# Just update the tracks if it does
|
||||||
else:
|
else:
|
||||||
havetracks = len(myDB.select('SELECT TrackTitle from tracks WHERE ArtistID=?', [artistid])) + len(myDB.select('SELECT TrackTitle from have WHERE ArtistName like ?', [artist]))
|
havetracks = len(
|
||||||
|
myDB.select('SELECT TrackTitle from tracks WHERE ArtistID=?', [artistid])) + len(
|
||||||
|
myDB.select('SELECT TrackTitle from have WHERE ArtistName like ?', [artist]))
|
||||||
myDB.action('UPDATE artists SET HaveTracks=? WHERE ArtistID=?', [havetracks, artistid])
|
myDB.action('UPDATE artists SET HaveTracks=? WHERE ArtistID=?', [havetracks, artistid])
|
||||||
|
|
||||||
# Delete it from the New Artists if the request came from there
|
# Delete it from the New Artists if the request came from there
|
||||||
@@ -111,8 +116,7 @@ def addArtistIDListToDB(artistidlist):
|
|||||||
addArtisttoDB(artistid)
|
addArtisttoDB(artistid)
|
||||||
|
|
||||||
|
|
||||||
def addArtisttoDB(artistid, extrasonly=False, forcefull=False):
|
def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
|
||||||
|
|
||||||
# Putting this here to get around the circular import. We're using this to update thumbnails for artist/albums
|
# Putting this here to get around the circular import. We're using this to update thumbnails for artist/albums
|
||||||
from headphones import cache
|
from headphones import cache
|
||||||
|
|
||||||
@@ -142,11 +146,18 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False):
|
|||||||
"Status": "Loading",
|
"Status": "Loading",
|
||||||
"IncludeExtras": headphones.CONFIG.INCLUDE_EXTRAS,
|
"IncludeExtras": headphones.CONFIG.INCLUDE_EXTRAS,
|
||||||
"Extras": headphones.CONFIG.EXTRAS}
|
"Extras": headphones.CONFIG.EXTRAS}
|
||||||
|
if type == "series":
|
||||||
|
newValueDict['Type'] = "series"
|
||||||
else:
|
else:
|
||||||
newValueDict = {"Status": "Loading"}
|
newValueDict = {"Status": "Loading"}
|
||||||
|
if dbartist["Type"] == "series":
|
||||||
|
type = "series"
|
||||||
|
|
||||||
myDB.upsert("artists", newValueDict, controlValueDict)
|
myDB.upsert("artists", newValueDict, controlValueDict)
|
||||||
|
|
||||||
|
if type == "series":
|
||||||
|
artist = mb.getSeries(artistid)
|
||||||
|
else:
|
||||||
artist = mb.getArtist(artistid, extrasonly)
|
artist = mb.getArtist(artistid, extrasonly)
|
||||||
|
|
||||||
if artist and artist.get('artist_name') in blacklisted_special_artist_names:
|
if artist and artist.get('artist_name') in blacklisted_special_artist_names:
|
||||||
@@ -184,7 +195,8 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False):
|
|||||||
# See if we need to grab extras. Artist specific extras take precedence
|
# See if we need to grab extras. Artist specific extras take precedence
|
||||||
# over global option. Global options are set when adding a new artist
|
# over global option. Global options are set when adding a new artist
|
||||||
try:
|
try:
|
||||||
db_artist = myDB.action('SELECT IncludeExtras, Extras from artists WHERE ArtistID=?', [artistid]).fetchone()
|
db_artist = myDB.action('SELECT IncludeExtras, Extras from artists WHERE ArtistID=?',
|
||||||
|
[artistid]).fetchone()
|
||||||
includeExtras = db_artist['IncludeExtras']
|
includeExtras = db_artist['IncludeExtras']
|
||||||
except IndexError:
|
except IndexError:
|
||||||
includeExtras = False
|
includeExtras = False
|
||||||
@@ -199,9 +211,12 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False):
|
|||||||
for groups in artist['releasegroups']:
|
for groups in artist['releasegroups']:
|
||||||
group_list.append(groups['id'])
|
group_list.append(groups['id'])
|
||||||
if not extrasonly:
|
if not extrasonly:
|
||||||
remove_missing_groups_from_albums = myDB.select("SELECT AlbumID FROM albums WHERE ArtistID=?", [artistid])
|
remove_missing_groups_from_albums = myDB.select(
|
||||||
|
"SELECT AlbumID FROM albums WHERE ArtistID=?", [artistid])
|
||||||
else:
|
else:
|
||||||
remove_missing_groups_from_albums = myDB.select('SELECT AlbumID FROM albums WHERE ArtistID=? AND Status="Skipped" AND Type!="Album"', [artistid])
|
remove_missing_groups_from_albums = myDB.select(
|
||||||
|
'SELECT AlbumID FROM albums WHERE ArtistID=? AND Status="Skipped" AND Type!="Album"',
|
||||||
|
[artistid])
|
||||||
for items in remove_missing_groups_from_albums:
|
for items in remove_missing_groups_from_albums:
|
||||||
if items['AlbumID'] not in group_list:
|
if items['AlbumID'] not in group_list:
|
||||||
# Remove all from albums/tracks that aren't in release groups
|
# Remove all from albums/tracks that aren't in release groups
|
||||||
@@ -210,12 +225,16 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False):
|
|||||||
myDB.action("DELETE FROM tracks WHERE AlbumID=?", [items['AlbumID']])
|
myDB.action("DELETE FROM tracks WHERE AlbumID=?", [items['AlbumID']])
|
||||||
myDB.action("DELETE FROM alltracks WHERE AlbumID=?", [items['AlbumID']])
|
myDB.action("DELETE FROM alltracks WHERE AlbumID=?", [items['AlbumID']])
|
||||||
myDB.action('DELETE from releases WHERE ReleaseGroupID=?', [items['AlbumID']])
|
myDB.action('DELETE from releases WHERE ReleaseGroupID=?', [items['AlbumID']])
|
||||||
logger.info("[%s] Removing all references to release group %s to reflect MusicBrainz refresh" % (artist['artist_name'], items['AlbumID']))
|
logger.info(
|
||||||
|
"[%s] Removing all references to release group %s to reflect MusicBrainz refresh" % (
|
||||||
|
artist['artist_name'], items['AlbumID']))
|
||||||
if not extrasonly:
|
if not extrasonly:
|
||||||
force_repackage = 1
|
force_repackage = 1
|
||||||
else:
|
else:
|
||||||
if not extrasonly:
|
if not extrasonly:
|
||||||
logger.info("[%s] There was either an error pulling data from MusicBrainz or there might not be any releases for this category" % artist['artist_name'])
|
logger.info(
|
||||||
|
"[%s] There was either an error pulling data from MusicBrainz or there might not be any releases for this category" %
|
||||||
|
artist['artist_name'])
|
||||||
|
|
||||||
# Then search for releases within releasegroups, if releases don't exist, then remove from allalbums/alltracks
|
# Then search for releases within releasegroups, if releases don't exist, then remove from allalbums/alltracks
|
||||||
album_searches = []
|
album_searches = []
|
||||||
@@ -240,12 +259,14 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False):
|
|||||||
new_release_group = True
|
new_release_group = True
|
||||||
|
|
||||||
if new_release_group:
|
if new_release_group:
|
||||||
logger.info("[%s] Now adding: %s (New Release Group)" % (artist['artist_name'], rg['title']))
|
logger.info("[%s] Now adding: %s (New Release Group)" % (
|
||||||
|
artist['artist_name'], rg['title']))
|
||||||
new_releases = mb.get_new_releases(rgid, includeExtras)
|
new_releases = mb.get_new_releases(rgid, includeExtras)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if check_release_date is None or check_release_date == u"None":
|
if check_release_date is None or check_release_date == u"None":
|
||||||
logger.info("[%s] Now updating: %s (No Release Date)" % (artist['artist_name'], rg['title']))
|
logger.info("[%s] Now updating: %s (No Release Date)" % (
|
||||||
|
artist['artist_name'], rg['title']))
|
||||||
new_releases = mb.get_new_releases(rgid, includeExtras, True)
|
new_releases = mb.get_new_releases(rgid, includeExtras, True)
|
||||||
else:
|
else:
|
||||||
if len(check_release_date) == 10:
|
if len(check_release_date) == 10:
|
||||||
@@ -257,20 +278,24 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False):
|
|||||||
else:
|
else:
|
||||||
release_date = today
|
release_date = today
|
||||||
if helpers.get_age(today) - helpers.get_age(release_date) < pause_delta:
|
if helpers.get_age(today) - helpers.get_age(release_date) < pause_delta:
|
||||||
logger.info("[%s] Now updating: %s (Release Date <%s Days)", artist['artist_name'], rg['title'], pause_delta)
|
logger.info("[%s] Now updating: %s (Release Date <%s Days)",
|
||||||
|
artist['artist_name'], rg['title'], pause_delta)
|
||||||
new_releases = mb.get_new_releases(rgid, includeExtras, True)
|
new_releases = mb.get_new_releases(rgid, includeExtras, True)
|
||||||
else:
|
else:
|
||||||
logger.info("[%s] Skipping: %s (Release Date >%s Days)", artist['artist_name'], rg['title'], pause_delta)
|
logger.info("[%s] Skipping: %s (Release Date >%s Days)",
|
||||||
|
artist['artist_name'], rg['title'], pause_delta)
|
||||||
skip_log = 1
|
skip_log = 1
|
||||||
new_releases = 0
|
new_releases = 0
|
||||||
|
|
||||||
if force_repackage == 1:
|
if force_repackage == 1:
|
||||||
new_releases = -1
|
new_releases = -1
|
||||||
logger.info('[%s] Forcing repackage of %s (Release Group Removed)', artist['artist_name'], al_title)
|
logger.info('[%s] Forcing repackage of %s (Release Group Removed)',
|
||||||
|
artist['artist_name'], al_title)
|
||||||
else:
|
else:
|
||||||
new_releases = new_releases
|
new_releases = new_releases
|
||||||
else:
|
else:
|
||||||
logger.info("[%s] Now adding/updating: %s (Comprehensive Force)", artist['artist_name'], rg['title'])
|
logger.info("[%s] Now adding/updating: %s (Comprehensive Force)", artist['artist_name'],
|
||||||
|
rg['title'])
|
||||||
new_releases = mb.get_new_releases(rgid, includeExtras, forcefull)
|
new_releases = mb.get_new_releases(rgid, includeExtras, forcefull)
|
||||||
|
|
||||||
if new_releases != 0:
|
if new_releases != 0:
|
||||||
@@ -284,11 +309,13 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False):
|
|||||||
# This will be used later to build a hybrid release
|
# This will be used later to build a hybrid release
|
||||||
fullreleaselist = []
|
fullreleaselist = []
|
||||||
# Search for releases within a release group
|
# Search for releases within a release group
|
||||||
find_hybrid_releases = myDB.action("SELECT * from allalbums WHERE AlbumID=?", [rg['id']])
|
find_hybrid_releases = myDB.action("SELECT * from allalbums WHERE AlbumID=?",
|
||||||
|
[rg['id']])
|
||||||
|
|
||||||
# Build the dictionary for the fullreleaselist
|
# Build the dictionary for the fullreleaselist
|
||||||
for items in find_hybrid_releases:
|
for items in find_hybrid_releases:
|
||||||
if items['ReleaseID'] != rg['id']: #don't include hybrid information, since that's what we're replacing
|
if items['ReleaseID'] != rg[
|
||||||
|
'id']: # don't include hybrid information, since that's what we're replacing
|
||||||
hybrid_release_id = items['ReleaseID']
|
hybrid_release_id = items['ReleaseID']
|
||||||
newValueDict = {"ArtistID": items['ArtistID'],
|
newValueDict = {"ArtistID": items['ArtistID'],
|
||||||
"ArtistName": items['ArtistName'],
|
"ArtistName": items['ArtistName'],
|
||||||
@@ -300,7 +327,8 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False):
|
|||||||
"ReleaseCountry": items['ReleaseCountry'],
|
"ReleaseCountry": items['ReleaseCountry'],
|
||||||
"ReleaseFormat": items['ReleaseFormat']
|
"ReleaseFormat": items['ReleaseFormat']
|
||||||
}
|
}
|
||||||
find_hybrid_tracks = myDB.action("SELECT * from alltracks WHERE ReleaseID=?", [hybrid_release_id])
|
find_hybrid_tracks = myDB.action("SELECT * from alltracks WHERE ReleaseID=?",
|
||||||
|
[hybrid_release_id])
|
||||||
totalTracks = 1
|
totalTracks = 1
|
||||||
hybrid_track_array = []
|
hybrid_track_array = []
|
||||||
for hybrid_tracks in find_hybrid_tracks:
|
for hybrid_tracks in find_hybrid_tracks:
|
||||||
@@ -320,10 +348,12 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False):
|
|||||||
# This may end up being called with an empty fullreleaselist
|
# This may end up being called with an empty fullreleaselist
|
||||||
try:
|
try:
|
||||||
hybridrelease = getHybridRelease(fullreleaselist)
|
hybridrelease = getHybridRelease(fullreleaselist)
|
||||||
logger.info('[%s] Packaging %s releases into hybrid title' % (artist['artist_name'], rg['title']))
|
logger.info('[%s] Packaging %s releases into hybrid title' % (
|
||||||
|
artist['artist_name'], rg['title']))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors = True
|
errors = True
|
||||||
logger.warn('[%s] Unable to get hybrid release information for %s: %s' % (artist['artist_name'], rg['title'], e))
|
logger.warn('[%s] Unable to get hybrid release information for %s: %s' % (
|
||||||
|
artist['artist_name'], rg['title'], e))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Use the ReleaseGroupID as the ReleaseID for the hybrid release to differentiate it
|
# Use the ReleaseGroupID as the ReleaseID for the hybrid release to differentiate it
|
||||||
@@ -344,7 +374,8 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False):
|
|||||||
|
|
||||||
for track in hybridrelease['Tracks']:
|
for track in hybridrelease['Tracks']:
|
||||||
|
|
||||||
cleanname = helpers.cleanName(artist['artist_name'] + ' ' + rg['title'] + ' ' + track['title'])
|
cleanname = helpers.cleanName(
|
||||||
|
artist['artist_name'] + ' ' + rg['title'] + ' ' + track['title'])
|
||||||
|
|
||||||
controlValueDict = {"TrackID": track['id'],
|
controlValueDict = {"TrackID": track['id'],
|
||||||
"ReleaseID": rg['id']}
|
"ReleaseID": rg['id']}
|
||||||
@@ -360,10 +391,13 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False):
|
|||||||
"CleanName": cleanname
|
"CleanName": cleanname
|
||||||
}
|
}
|
||||||
|
|
||||||
match = myDB.action('SELECT Location, BitRate, Format from have WHERE CleanName=?', [cleanname]).fetchone()
|
match = myDB.action('SELECT Location, BitRate, Format from have WHERE CleanName=?',
|
||||||
|
[cleanname]).fetchone()
|
||||||
|
|
||||||
if not match:
|
if not match:
|
||||||
match = myDB.action('SELECT Location, BitRate, Format from have WHERE ArtistName LIKE ? AND AlbumTitle LIKE ? AND TrackTitle LIKE ?', [artist['artist_name'], rg['title'], track['title']]).fetchone()
|
match = myDB.action(
|
||||||
|
'SELECT Location, BitRate, Format from have WHERE ArtistName LIKE ? AND AlbumTitle LIKE ? AND TrackTitle LIKE ?',
|
||||||
|
[artist['artist_name'], rg['title'], track['title']]).fetchone()
|
||||||
# if not match:
|
# if not match:
|
||||||
# match = myDB.action('SELECT Location, BitRate, Format from have WHERE TrackID=?', [track['id']]).fetchone()
|
# match = myDB.action('SELECT Location, BitRate, Format from have WHERE TrackID=?', [track['id']]).fetchone()
|
||||||
if match:
|
if match:
|
||||||
@@ -371,7 +405,8 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False):
|
|||||||
newValueDict['BitRate'] = match['BitRate']
|
newValueDict['BitRate'] = match['BitRate']
|
||||||
newValueDict['Format'] = match['Format']
|
newValueDict['Format'] = match['Format']
|
||||||
# myDB.action('UPDATE have SET Matched="True" WHERE Location=?', [match['Location']])
|
# myDB.action('UPDATE have SET Matched="True" WHERE Location=?', [match['Location']])
|
||||||
myDB.action('UPDATE have SET Matched=? WHERE Location=?', (rg['id'], match['Location']))
|
myDB.action('UPDATE have SET Matched=? WHERE Location=?',
|
||||||
|
(rg['id'], match['Location']))
|
||||||
|
|
||||||
myDB.upsert("alltracks", newValueDict, controlValueDict)
|
myDB.upsert("alltracks", newValueDict, controlValueDict)
|
||||||
|
|
||||||
@@ -418,14 +453,17 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False):
|
|||||||
newValueDict['Status'] = "Wanted"
|
newValueDict['Status'] = "Wanted"
|
||||||
# Sometimes "new" albums are added to musicbrainz after their release date, so let's try to catch these
|
# Sometimes "new" albums are added to musicbrainz after their release date, so let's try to catch these
|
||||||
# The first test just makes sure we have year-month-day
|
# The first test just makes sure we have year-month-day
|
||||||
elif helpers.get_age(album['ReleaseDate']) and helpers.get_age(today) - helpers.get_age(album['ReleaseDate']) < 21 and headphones.CONFIG.AUTOWANT_UPCOMING:
|
elif helpers.get_age(album['ReleaseDate']) and helpers.get_age(
|
||||||
|
today) - helpers.get_age(
|
||||||
|
album['ReleaseDate']) < 21 and headphones.CONFIG.AUTOWANT_UPCOMING:
|
||||||
newValueDict['Status'] = "Wanted"
|
newValueDict['Status'] = "Wanted"
|
||||||
else:
|
else:
|
||||||
newValueDict['Status'] = "Skipped"
|
newValueDict['Status'] = "Skipped"
|
||||||
|
|
||||||
myDB.upsert("albums", newValueDict, controlValueDict)
|
myDB.upsert("albums", newValueDict, controlValueDict)
|
||||||
|
|
||||||
tracks = myDB.action('SELECT * from alltracks WHERE ReleaseID=?', [releaseid]).fetchall()
|
tracks = myDB.action('SELECT * from alltracks WHERE ReleaseID=?',
|
||||||
|
[releaseid]).fetchall()
|
||||||
|
|
||||||
# This is used to see how many tracks you have from an album - to
|
# This is used to see how many tracks you have from an album - to
|
||||||
# mark it as downloaded. Default is 80%, can be set in config as
|
# mark it as downloaded. Default is 80%, can be set in config as
|
||||||
@@ -458,19 +496,27 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False):
|
|||||||
myDB.upsert("tracks", newValueDict, controlValueDict)
|
myDB.upsert("tracks", newValueDict, controlValueDict)
|
||||||
|
|
||||||
# Mark albums as downloaded if they have at least 80% (by default, configurable) of the album
|
# Mark albums as downloaded if they have at least 80% (by default, configurable) of the album
|
||||||
have_track_count = len(myDB.select('SELECT * from tracks WHERE AlbumID=? AND Location IS NOT NULL', [rg['id']]))
|
have_track_count = len(
|
||||||
|
myDB.select('SELECT * from tracks WHERE AlbumID=? AND Location IS NOT NULL',
|
||||||
|
[rg['id']]))
|
||||||
marked_as_downloaded = False
|
marked_as_downloaded = False
|
||||||
|
|
||||||
if rg_exists:
|
if rg_exists:
|
||||||
if rg_exists['Status'] == 'Skipped' and ((have_track_count / float(total_track_count)) >= (headphones.CONFIG.ALBUM_COMPLETION_PCT / 100.0)):
|
if rg_exists['Status'] == 'Skipped' and (
|
||||||
myDB.action('UPDATE albums SET Status=? WHERE AlbumID=?', ['Downloaded', rg['id']])
|
(have_track_count / float(total_track_count)) >= (
|
||||||
|
headphones.CONFIG.ALBUM_COMPLETION_PCT / 100.0)):
|
||||||
|
myDB.action('UPDATE albums SET Status=? WHERE AlbumID=?',
|
||||||
|
['Downloaded', rg['id']])
|
||||||
marked_as_downloaded = True
|
marked_as_downloaded = True
|
||||||
else:
|
else:
|
||||||
if ((have_track_count / float(total_track_count)) >= (headphones.CONFIG.ALBUM_COMPLETION_PCT / 100.0)):
|
if (have_track_count / float(total_track_count)) >= (
|
||||||
myDB.action('UPDATE albums SET Status=? WHERE AlbumID=?', ['Downloaded', rg['id']])
|
headphones.CONFIG.ALBUM_COMPLETION_PCT / 100.0):
|
||||||
|
myDB.action('UPDATE albums SET Status=? WHERE AlbumID=?',
|
||||||
|
['Downloaded', rg['id']])
|
||||||
marked_as_downloaded = True
|
marked_as_downloaded = True
|
||||||
|
|
||||||
logger.info(u"[%s] Seeing if we need album art for %s" % (artist['artist_name'], rg['title']))
|
logger.info(
|
||||||
|
u"[%s] Seeing if we need album art for %s" % (artist['artist_name'], rg['title']))
|
||||||
cache.getThumb(AlbumID=rg['id'])
|
cache.getThumb(AlbumID=rg['id'])
|
||||||
|
|
||||||
# Start a search for the album if it's new, hasn't been marked as
|
# Start a search for the album if it's new, hasn't been marked as
|
||||||
@@ -480,7 +526,8 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False):
|
|||||||
album_searches.append(rg['id'])
|
album_searches.append(rg['id'])
|
||||||
else:
|
else:
|
||||||
if skip_log == 0:
|
if skip_log == 0:
|
||||||
logger.info(u"[%s] No new releases, so no changes made to %s" % (artist['artist_name'], rg['title']))
|
logger.info(u"[%s] No new releases, so no changes made to %s" % (
|
||||||
|
artist['artist_name'], rg['title']))
|
||||||
|
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
finalize_update(artistid, artist['artist_name'], errors)
|
finalize_update(artistid, artist['artist_name'], errors)
|
||||||
@@ -488,8 +535,13 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False):
|
|||||||
logger.info(u"Seeing if we need album art for: %s" % artist['artist_name'])
|
logger.info(u"Seeing if we need album art for: %s" % artist['artist_name'])
|
||||||
cache.getThumb(ArtistID=artistid)
|
cache.getThumb(ArtistID=artistid)
|
||||||
|
|
||||||
|
logger.info(u"Fetching Metacritic reviews for: %s" % artist['artist_name'])
|
||||||
|
metacritic.update(artistid, artist['artist_name'], artist['releasegroups'])
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
logger.info("[%s] Finished updating artist: %s but with errors, so not marking it as updated in the database" % (artist['artist_name'], artist['artist_name']))
|
logger.info(
|
||||||
|
"[%s] Finished updating artist: %s but with errors, so not marking it as updated in the database" % (
|
||||||
|
artist['artist_name'], artist['artist_name']))
|
||||||
else:
|
else:
|
||||||
myDB.action('DELETE FROM newartists WHERE ArtistName = ?', [artist['artist_name']])
|
myDB.action('DELETE FROM newartists WHERE ArtistName = ?', [artist['artist_name']])
|
||||||
logger.info(u"Updating complete for: %s" % artist['artist_name'])
|
logger.info(u"Updating complete for: %s" % artist['artist_name'])
|
||||||
@@ -508,10 +560,18 @@ def finalize_update(artistid, artistname, errors=False):
|
|||||||
|
|
||||||
myDB = db.DBConnection()
|
myDB = db.DBConnection()
|
||||||
|
|
||||||
latestalbum = myDB.action('SELECT AlbumTitle, ReleaseDate, AlbumID from albums WHERE ArtistID=? order by ReleaseDate DESC', [artistid]).fetchone()
|
latestalbum = myDB.action(
|
||||||
totaltracks = len(myDB.select('SELECT TrackTitle from tracks WHERE ArtistID=? AND AlbumID IN (SELECT AlbumID FROM albums WHERE Status != "Ignored")', [artistid]))
|
'SELECT AlbumTitle, ReleaseDate, AlbumID from albums WHERE ArtistID=? order by ReleaseDate DESC',
|
||||||
|
[artistid]).fetchone()
|
||||||
|
totaltracks = len(myDB.select(
|
||||||
|
'SELECT TrackTitle from tracks WHERE ArtistID=? AND AlbumID IN (SELECT AlbumID FROM albums WHERE Status != "Ignored")',
|
||||||
|
[artistid]))
|
||||||
# havetracks = len(myDB.select('SELECT TrackTitle from tracks WHERE ArtistID=? AND Location IS NOT NULL', [artistid])) + len(myDB.select('SELECT TrackTitle from have WHERE ArtistName like ?', [artist['artist_name']]))
|
# havetracks = len(myDB.select('SELECT TrackTitle from tracks WHERE ArtistID=? AND Location IS NOT NULL', [artistid])) + len(myDB.select('SELECT TrackTitle from have WHERE ArtistName like ?', [artist['artist_name']]))
|
||||||
havetracks = len(myDB.select('SELECT TrackTitle from tracks WHERE ArtistID=? AND Location IS NOT NULL', [artistid])) + len(myDB.select('SELECT TrackTitle from have WHERE ArtistName like ? AND Matched = "Failed"', [artistname]))
|
havetracks = len(
|
||||||
|
myDB.select('SELECT TrackTitle from tracks WHERE ArtistID=? AND Location IS NOT NULL',
|
||||||
|
[artistid])) + len(
|
||||||
|
myDB.select('SELECT TrackTitle from have WHERE ArtistName like ? AND Matched = "Failed"',
|
||||||
|
[artistname]))
|
||||||
|
|
||||||
controlValueDict = {"ArtistID": artistid}
|
controlValueDict = {"ArtistID": artistid}
|
||||||
|
|
||||||
@@ -534,7 +594,6 @@ def finalize_update(artistid, artistname, errors=False):
|
|||||||
|
|
||||||
|
|
||||||
def addReleaseById(rid, rgid=None):
|
def addReleaseById(rid, rgid=None):
|
||||||
|
|
||||||
myDB = db.DBConnection()
|
myDB = db.DBConnection()
|
||||||
|
|
||||||
# Create minimum info upfront if added from searchresults
|
# Create minimum info upfront if added from searchresults
|
||||||
@@ -553,14 +612,18 @@ def addReleaseById(rid, rgid=None):
|
|||||||
rgid = None
|
rgid = None
|
||||||
artistid = None
|
artistid = None
|
||||||
release_dict = None
|
release_dict = None
|
||||||
results = myDB.select("SELECT albums.ArtistID, releases.ReleaseGroupID from releases, albums WHERE releases.ReleaseID=? and releases.ReleaseGroupID=albums.AlbumID LIMIT 1", [rid])
|
results = myDB.select(
|
||||||
|
"SELECT albums.ArtistID, releases.ReleaseGroupID from releases, albums WHERE releases.ReleaseID=? and releases.ReleaseGroupID=albums.AlbumID LIMIT 1",
|
||||||
|
[rid])
|
||||||
for result in results:
|
for result in results:
|
||||||
rgid = result['ReleaseGroupID']
|
rgid = result['ReleaseGroupID']
|
||||||
artistid = result['ArtistID']
|
artistid = result['ArtistID']
|
||||||
logger.debug("Found a cached releaseid : releasegroupid relationship: " + rid + " : " + rgid)
|
logger.debug(
|
||||||
|
"Found a cached releaseid : releasegroupid relationship: " + rid + " : " + rgid)
|
||||||
if not rgid:
|
if not rgid:
|
||||||
# didn't find it in the cache, get the information from MB
|
# didn't find it in the cache, get the information from MB
|
||||||
logger.debug("Didn't find releaseID " + rid + " in the cache. Looking up its ReleaseGroupID")
|
logger.debug(
|
||||||
|
"Didn't find releaseID " + rid + " in the cache. Looking up its ReleaseGroupID")
|
||||||
try:
|
try:
|
||||||
release_dict = mb.getRelease(rid)
|
release_dict = mb.getRelease(rid)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -589,7 +652,8 @@ def addReleaseById(rid, rgid=None):
|
|||||||
else:
|
else:
|
||||||
sortname = release_dict['artist_name']
|
sortname = release_dict['artist_name']
|
||||||
|
|
||||||
logger.info(u"Now manually adding: " + release_dict['artist_name'] + " - with status Paused")
|
logger.info(
|
||||||
|
u"Now manually adding: " + release_dict['artist_name'] + " - with status Paused")
|
||||||
controlValueDict = {"ArtistID": release_dict['artist_id']}
|
controlValueDict = {"ArtistID": release_dict['artist_id']}
|
||||||
newValueDict = {"ArtistName": release_dict['artist_name'],
|
newValueDict = {"ArtistName": release_dict['artist_name'],
|
||||||
"ArtistSortName": sortname,
|
"ArtistSortName": sortname,
|
||||||
@@ -600,10 +664,22 @@ def addReleaseById(rid, rgid=None):
|
|||||||
newValueDict['IncludeExtras'] = 1
|
newValueDict['IncludeExtras'] = 1
|
||||||
newValueDict['Extras'] = headphones.CONFIG.EXTRAS
|
newValueDict['Extras'] = headphones.CONFIG.EXTRAS
|
||||||
|
|
||||||
|
if 'title' in release_dict:
|
||||||
|
newValueDict['LatestAlbum'] = release_dict['title']
|
||||||
|
elif 'rg_title' in release_dict:
|
||||||
|
newValueDict['LatestAlbum'] = release_dict['rg_title']
|
||||||
|
|
||||||
|
if 'date' in release_dict:
|
||||||
|
newValueDict['ReleaseDate'] = release_dict['date']
|
||||||
|
|
||||||
|
if rgid:
|
||||||
|
newValueDict['AlbumID'] = rgid
|
||||||
|
|
||||||
myDB.upsert("artists", newValueDict, controlValueDict)
|
myDB.upsert("artists", newValueDict, controlValueDict)
|
||||||
|
|
||||||
elif not artist_exists and not release_dict:
|
elif not artist_exists and not release_dict:
|
||||||
logger.error("Artist does not exist in the database and did not get a valid response from MB. Skipping release.")
|
logger.error(
|
||||||
|
"Artist does not exist in the database and did not get a valid response from MB. Skipping release.")
|
||||||
if status == 'Loading':
|
if status == 'Loading':
|
||||||
myDB.action("DELETE FROM albums WHERE AlbumID=?", [rgid])
|
myDB.action("DELETE FROM albums WHERE AlbumID=?", [rgid])
|
||||||
return
|
return
|
||||||
@@ -618,7 +694,8 @@ def addReleaseById(rid, rgid=None):
|
|||||||
newValueDict = {"ArtistID": release_dict['artist_id'],
|
newValueDict = {"ArtistID": release_dict['artist_id'],
|
||||||
"ReleaseID": rgid,
|
"ReleaseID": rgid,
|
||||||
"ArtistName": release_dict['artist_name'],
|
"ArtistName": release_dict['artist_name'],
|
||||||
"AlbumTitle": release_dict['title'] if 'title' in release_dict else release_dict['rg_title'],
|
"AlbumTitle": release_dict['title'] if 'title' in release_dict else
|
||||||
|
release_dict['rg_title'],
|
||||||
"AlbumASIN": release_dict['asin'],
|
"AlbumASIN": release_dict['asin'],
|
||||||
"ReleaseDate": release_dict['date'],
|
"ReleaseDate": release_dict['date'],
|
||||||
"DateAdded": helpers.today(),
|
"DateAdded": helpers.today(),
|
||||||
@@ -633,7 +710,8 @@ def addReleaseById(rid, rgid=None):
|
|||||||
myDB.action('INSERT INTO releases VALUES( ?, ?)', [rid, release_dict['rgid']])
|
myDB.action('INSERT INTO releases VALUES( ?, ?)', [rid, release_dict['rgid']])
|
||||||
|
|
||||||
for track in release_dict['tracks']:
|
for track in release_dict['tracks']:
|
||||||
cleanname = helpers.cleanName(release_dict['artist_name'] + ' ' + release_dict['rg_title'] + ' ' + track['title'])
|
cleanname = helpers.cleanName(
|
||||||
|
release_dict['artist_name'] + ' ' + release_dict['rg_title'] + ' ' + track['title'])
|
||||||
|
|
||||||
controlValueDict = {"TrackID": track['id'],
|
controlValueDict = {"TrackID": track['id'],
|
||||||
"AlbumID": rgid}
|
"AlbumID": rgid}
|
||||||
@@ -647,10 +725,15 @@ def addReleaseById(rid, rgid=None):
|
|||||||
"CleanName": cleanname
|
"CleanName": cleanname
|
||||||
}
|
}
|
||||||
|
|
||||||
match = myDB.action('SELECT Location, BitRate, Format, Matched from have WHERE CleanName=?', [cleanname]).fetchone()
|
match = myDB.action(
|
||||||
|
'SELECT Location, BitRate, Format, Matched from have WHERE CleanName=?',
|
||||||
|
[cleanname]).fetchone()
|
||||||
|
|
||||||
if not match:
|
if not match:
|
||||||
match = myDB.action('SELECT Location, BitRate, Format, Matched from have WHERE ArtistName LIKE ? AND AlbumTitle LIKE ? AND TrackTitle LIKE ?', [release_dict['artist_name'], release_dict['rg_title'], track['title']]).fetchone()
|
match = myDB.action(
|
||||||
|
'SELECT Location, BitRate, Format, Matched from have WHERE ArtistName LIKE ? AND AlbumTitle LIKE ? AND TrackTitle LIKE ?',
|
||||||
|
[release_dict['artist_name'], release_dict['rg_title'],
|
||||||
|
track['title']]).fetchone()
|
||||||
|
|
||||||
# if not match:
|
# if not match:
|
||||||
# match = myDB.action('SELECT Location, BitRate, Format from have WHERE TrackID=?', [track['id']]).fetchone()
|
# match = myDB.action('SELECT Location, BitRate, Format from have WHERE TrackID=?', [track['id']]).fetchone()
|
||||||
@@ -663,7 +746,8 @@ def addReleaseById(rid, rgid=None):
|
|||||||
|
|
||||||
# If the album has been scanned before adding the release it will be unmatched, update to matched
|
# If the album has been scanned before adding the release it will be unmatched, update to matched
|
||||||
if match['Matched'] == 'Failed':
|
if match['Matched'] == 'Failed':
|
||||||
myDB.action('UPDATE have SET Matched=? WHERE Location=?', (release_dict['rgid'], match['Location']))
|
myDB.action('UPDATE have SET Matched=? WHERE Location=?',
|
||||||
|
(release_dict['rgid'], match['Location']))
|
||||||
|
|
||||||
myDB.upsert("tracks", newValueDict, controlValueDict)
|
myDB.upsert("tracks", newValueDict, controlValueDict)
|
||||||
|
|
||||||
@@ -682,7 +766,8 @@ def addReleaseById(rid, rgid=None):
|
|||||||
searcher.searchforalbum(rgid, False)
|
searcher.searchforalbum(rgid, False)
|
||||||
|
|
||||||
elif not rg_exists and not release_dict:
|
elif not rg_exists and not release_dict:
|
||||||
logger.error("ReleaseGroup does not exist in the database and did not get a valid response from MB. Skipping release.")
|
logger.error(
|
||||||
|
"ReleaseGroup does not exist in the database and did not get a valid response from MB. Skipping release.")
|
||||||
if status == 'Loading':
|
if status == 'Loading':
|
||||||
myDB.action("DELETE FROM albums WHERE AlbumID=?", [rgid])
|
myDB.action("DELETE FROM albums WHERE AlbumID=?", [rgid])
|
||||||
return
|
return
|
||||||
@@ -790,11 +875,13 @@ def getHybridRelease(fullreleaselist):
|
|||||||
|
|
||||||
sortable_release_list.sort(key=lambda x: getSortableReleaseDate(x['releasedate']))
|
sortable_release_list.sort(key=lambda x: getSortableReleaseDate(x['releasedate']))
|
||||||
|
|
||||||
average_tracks = sum(x['trackscount'] for x in sortable_release_list) / float(len(sortable_release_list))
|
average_tracks = sum(x['trackscount'] for x in sortable_release_list) / float(
|
||||||
|
len(sortable_release_list))
|
||||||
for item in sortable_release_list:
|
for item in sortable_release_list:
|
||||||
item['trackscount_delta'] = abs(average_tracks - item['trackscount'])
|
item['trackscount_delta'] = abs(average_tracks - item['trackscount'])
|
||||||
|
|
||||||
a = helpers.multikeysort(sortable_release_list, ['-hasasin', 'country', 'format', 'trackscount_delta'])
|
a = helpers.multikeysort(sortable_release_list,
|
||||||
|
['-hasasin', 'country', 'format', 'trackscount_delta'])
|
||||||
|
|
||||||
release_dict = {'ReleaseDate': sortable_release_list[0]['releasedate'],
|
release_dict = {'ReleaseDate': sortable_release_list[0]['releasedate'],
|
||||||
'Tracks': a[0]['tracks'],
|
'Tracks': a[0]['tracks'],
|
||||||
|
|||||||
+8
-12
@@ -13,22 +13,20 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import time
|
|
||||||
import random
|
import random
|
||||||
import threading
|
|
||||||
import headphones
|
|
||||||
|
|
||||||
from headphones import db, logger, request
|
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
|
import headphones
|
||||||
|
import headphones.lock
|
||||||
|
from headphones import db, logger, request
|
||||||
|
|
||||||
TIMEOUT = 60.0 # seconds
|
TIMEOUT = 60.0 # seconds
|
||||||
REQUEST_LIMIT = 1.0 / 5 # seconds
|
REQUEST_LIMIT = 1.0 / 5 # seconds
|
||||||
ENTRY_POINT = "http://ws.audioscrobbler.com/2.0/"
|
ENTRY_POINT = "http://ws.audioscrobbler.com/2.0/"
|
||||||
API_KEY = "395e6ec6bb557382fc41fde867bce66f"
|
API_KEY = "395e6ec6bb557382fc41fde867bce66f"
|
||||||
|
|
||||||
# Required for API request limit
|
# Required for API request limit
|
||||||
lock = threading.Lock()
|
lastfm_lock = headphones.lock.TimedLock(REQUEST_LIMIT)
|
||||||
|
|
||||||
|
|
||||||
def request_lastfm(method, **kwargs):
|
def request_lastfm(method, **kwargs):
|
||||||
@@ -49,8 +47,7 @@ def request_lastfm(method, **kwargs):
|
|||||||
logger.debug("Calling Last.FM method: %s", method)
|
logger.debug("Calling Last.FM method: %s", method)
|
||||||
logger.debug("Last.FM call parameters: %s", kwargs)
|
logger.debug("Last.FM call parameters: %s", kwargs)
|
||||||
|
|
||||||
data = request.request_json(ENTRY_POINT, timeout=TIMEOUT, params=kwargs,
|
data = request.request_json(ENTRY_POINT, timeout=TIMEOUT, params=kwargs, lock=lastfm_lock)
|
||||||
rate_limit=(lock, REQUEST_LIMIT))
|
|
||||||
|
|
||||||
# Parse response and check for errors.
|
# Parse response and check for errors.
|
||||||
if not data:
|
if not data:
|
||||||
@@ -73,7 +70,6 @@ def getSimilar():
|
|||||||
|
|
||||||
for result in results[:12]:
|
for result in results[:12]:
|
||||||
data = request_lastfm("artist.getsimilar", mbid=result["ArtistId"])
|
data = request_lastfm("artist.getsimilar", mbid=result["ArtistId"])
|
||||||
time.sleep(10)
|
|
||||||
|
|
||||||
if data and "similarartists" in data:
|
if data and "similarartists" in data:
|
||||||
artists = data["similarartists"]["artist"]
|
artists = data["similarartists"]["artist"]
|
||||||
@@ -82,7 +78,7 @@ def getSimilar():
|
|||||||
try:
|
try:
|
||||||
artist_mbid = artist["mbid"]
|
artist_mbid = artist["mbid"]
|
||||||
artist_name = artist["name"]
|
artist_name = artist["name"]
|
||||||
except TypeError:
|
except KeyError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not any(artist_mbid in x for x in results):
|
if not any(artist_mbid in x for x in results):
|
||||||
@@ -119,7 +115,7 @@ def getArtists():
|
|||||||
return
|
return
|
||||||
|
|
||||||
logger.info("Fetching artists from Last.FM for username: %s", headphones.CONFIG.LASTFM_USERNAME)
|
logger.info("Fetching artists from Last.FM for username: %s", headphones.CONFIG.LASTFM_USERNAME)
|
||||||
data = request_lastfm("library.getartists", limit=10000, user=headphones.CONFIG.LASTFM_USERNAME)
|
data = request_lastfm("library.getartists", limit=1000, user=headphones.CONFIG.LASTFM_USERNAME)
|
||||||
|
|
||||||
if data and "artists" in data:
|
if data and "artists" in data:
|
||||||
artistlist = []
|
artistlist = []
|
||||||
|
|||||||
+99
-57
@@ -14,17 +14,17 @@
|
|||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import math
|
||||||
|
|
||||||
import headphones
|
import headphones
|
||||||
|
|
||||||
from beets.mediafile import MediaFile, FileTypeError, UnreadableFileError
|
from beets.mediafile import MediaFile, FileTypeError, UnreadableFileError
|
||||||
|
|
||||||
from headphones import db, logger, helpers, importer, lastfm
|
from headphones import db, logger, helpers, importer, lastfm
|
||||||
|
|
||||||
# You can scan a single directory and append it to the current library by specifying append=True, ArtistID & ArtistName
|
|
||||||
|
|
||||||
|
|
||||||
def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None, cron=False):
|
|
||||||
|
|
||||||
|
# You can scan a single directory and append it to the current library by
|
||||||
|
# specifying append=True, ArtistID and ArtistName.
|
||||||
|
def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
|
||||||
|
cron=False, artistScan=False):
|
||||||
if cron and not headphones.CONFIG.LIBRARYSCAN:
|
if cron and not headphones.CONFIG.LIBRARYSCAN:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -36,11 +36,12 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None, cron=Fal
|
|||||||
|
|
||||||
# If we're appending a dir, it's coming from the post processor which is
|
# If we're appending a dir, it's coming from the post processor which is
|
||||||
# already bytestring
|
# already bytestring
|
||||||
if not append:
|
if not append or artistScan:
|
||||||
dir = dir.encode(headphones.SYS_ENCODING)
|
dir = dir.encode(headphones.SYS_ENCODING)
|
||||||
|
|
||||||
if not os.path.isdir(dir):
|
if not os.path.isdir(dir):
|
||||||
logger.warn('Cannot find directory: %s. Not scanning' % dir.decode(headphones.SYS_ENCODING, 'replace'))
|
logger.warn('Cannot find directory: %s. Not scanning' % dir.decode(headphones.SYS_ENCODING,
|
||||||
|
'replace'))
|
||||||
return
|
return
|
||||||
|
|
||||||
myDB = db.DBConnection()
|
myDB = db.DBConnection()
|
||||||
@@ -50,13 +51,16 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None, cron=Fal
|
|||||||
|
|
||||||
if not append:
|
if not append:
|
||||||
# Clean up bad filepaths
|
# Clean up bad filepaths
|
||||||
tracks = myDB.select('SELECT Location from alltracks WHERE Location IS NOT NULL UNION SELECT Location from tracks WHERE Location IS NOT NULL')
|
tracks = myDB.select(
|
||||||
|
'SELECT Location from alltracks WHERE Location IS NOT NULL UNION SELECT Location from tracks WHERE Location IS NOT NULL')
|
||||||
|
|
||||||
for track in tracks:
|
for track in tracks:
|
||||||
encoded_track_string = track['Location'].encode(headphones.SYS_ENCODING)
|
encoded_track_string = track['Location'].encode(headphones.SYS_ENCODING, 'replace')
|
||||||
if not os.path.isfile(encoded_track_string):
|
if not os.path.isfile(encoded_track_string):
|
||||||
myDB.action('UPDATE tracks SET Location=?, BitRate=?, Format=? WHERE Location=?', [None, None, None, track['Location']])
|
myDB.action('UPDATE tracks SET Location=?, BitRate=?, Format=? WHERE Location=?',
|
||||||
myDB.action('UPDATE alltracks SET Location=?, BitRate=?, Format=? WHERE Location=?', [None, None, None, track['Location']])
|
[None, None, None, track['Location']])
|
||||||
|
myDB.action('UPDATE alltracks SET Location=?, BitRate=?, Format=? WHERE Location=?',
|
||||||
|
[None, None, None, track['Location']])
|
||||||
|
|
||||||
del_have_tracks = myDB.select('SELECT Location, Matched, ArtistName from have')
|
del_have_tracks = myDB.select('SELECT Location, Matched, ArtistName from have')
|
||||||
|
|
||||||
@@ -67,36 +71,38 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None, cron=Fal
|
|||||||
# Make sure deleted files get accounted for when updating artist track counts
|
# Make sure deleted files get accounted for when updating artist track counts
|
||||||
new_artists.append(track['ArtistName'])
|
new_artists.append(track['ArtistName'])
|
||||||
myDB.action('DELETE FROM have WHERE Location=?', [track['Location']])
|
myDB.action('DELETE FROM have WHERE Location=?', [track['Location']])
|
||||||
logger.info('File %s removed from Headphones, as it is no longer on disk' % encoded_track_string.decode(headphones.SYS_ENCODING, 'replace'))
|
logger.info(
|
||||||
###############myDB.action('DELETE from have')
|
'File %s removed from Headphones, as it is no longer on disk' % encoded_track_string.decode(
|
||||||
|
headphones.SYS_ENCODING, 'replace'))
|
||||||
|
|
||||||
bitrates = []
|
bitrates = []
|
||||||
|
|
||||||
song_list = []
|
song_list = []
|
||||||
|
latest_subdirectory = []
|
||||||
|
|
||||||
new_song_count = 0
|
new_song_count = 0
|
||||||
file_count = 0
|
file_count = 0
|
||||||
|
|
||||||
latest_subdirectory = []
|
for r, d, f in helpers.walk_directory(dir):
|
||||||
|
# Filter paths based on config. Note that these methods work directly
|
||||||
for r, d, f in os.walk(dir, followlinks=True):
|
# on the inputs
|
||||||
# Need to abuse slicing to get a copy of the list, doing it directly
|
helpers.path_filter_patterns(d, headphones.CONFIG.IGNORED_FOLDERS, r)
|
||||||
# will skip the element after a deleted one using a list comprehension
|
helpers.path_filter_patterns(f, headphones.CONFIG.IGNORED_FILES, r)
|
||||||
# will not work correctly for nested subdirectories (os.walk keeps its
|
|
||||||
# original list)
|
|
||||||
for directory in d[:]:
|
|
||||||
if directory.startswith("."):
|
|
||||||
d.remove(directory)
|
|
||||||
|
|
||||||
for files in f:
|
for files in f:
|
||||||
# MEDIA_FORMATS = music file extensions, e.g. mp3, flac, etc
|
# MEDIA_FORMATS = music file extensions, e.g. mp3, flac, etc
|
||||||
if any(files.lower().endswith('.' + x.lower()) for x in headphones.MEDIA_FORMATS):
|
if any(files.lower().endswith('.' + x.lower()) for x in headphones.MEDIA_FORMATS):
|
||||||
|
|
||||||
subdirectory = r.replace(dir, '')
|
subdirectory = r.replace(dir, '')
|
||||||
latest_subdirectory.append(subdirectory)
|
latest_subdirectory.append(subdirectory)
|
||||||
|
|
||||||
if file_count == 0 and r.replace(dir, '') != '':
|
if file_count == 0 and r.replace(dir, '') != '':
|
||||||
logger.info("[%s] Now scanning subdirectory %s" % (dir.decode(headphones.SYS_ENCODING, 'replace'), subdirectory.decode(headphones.SYS_ENCODING, 'replace')))
|
logger.info("[%s] Now scanning subdirectory %s" % (
|
||||||
elif latest_subdirectory[file_count] != latest_subdirectory[file_count - 1] and file_count != 0:
|
dir.decode(headphones.SYS_ENCODING, 'replace'),
|
||||||
logger.info("[%s] Now scanning subdirectory %s" % (dir.decode(headphones.SYS_ENCODING, 'replace'), subdirectory.decode(headphones.SYS_ENCODING, 'replace')))
|
subdirectory.decode(headphones.SYS_ENCODING, 'replace')))
|
||||||
|
elif latest_subdirectory[file_count] != latest_subdirectory[
|
||||||
|
file_count - 1] and file_count != 0:
|
||||||
|
logger.info("[%s] Now scanning subdirectory %s" % (
|
||||||
|
dir.decode(headphones.SYS_ENCODING, 'replace'),
|
||||||
|
subdirectory.decode(headphones.SYS_ENCODING, 'replace')))
|
||||||
|
|
||||||
song = os.path.join(r, files)
|
song = os.path.join(r, files)
|
||||||
|
|
||||||
@@ -107,10 +113,13 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None, cron=Fal
|
|||||||
try:
|
try:
|
||||||
f = MediaFile(song)
|
f = MediaFile(song)
|
||||||
except (FileTypeError, UnreadableFileError):
|
except (FileTypeError, UnreadableFileError):
|
||||||
logger.warning("Cannot read media file '%s', skipping. It may be corrupted or not a media file.", unicode_song_path)
|
logger.warning(
|
||||||
|
"Cannot read media file '%s', skipping. It may be corrupted or not a media file.",
|
||||||
|
unicode_song_path)
|
||||||
continue
|
continue
|
||||||
except IOError:
|
except IOError:
|
||||||
logger.warning("Cannnot read media file '%s', skipping. Does the file exists?", unicode_song_path)
|
logger.warning("Cannnot read media file '%s', skipping. Does the file exists?",
|
||||||
|
unicode_song_path)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Grab the bitrates for the auto detect bit rate option
|
# Grab the bitrates for the auto detect bit rate option
|
||||||
@@ -150,7 +159,8 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None, cron=Fal
|
|||||||
}
|
}
|
||||||
|
|
||||||
# song_list.append(song_dict)
|
# song_list.append(song_dict)
|
||||||
check_exist_song = myDB.action("SELECT * FROM have WHERE Location=?", [unicode_song_path]).fetchone()
|
check_exist_song = myDB.action("SELECT * FROM have WHERE Location=?",
|
||||||
|
[unicode_song_path]).fetchone()
|
||||||
# Only attempt to match songs that are new, haven't yet been matched, or metadata has changed.
|
# Only attempt to match songs that are new, haven't yet been matched, or metadata has changed.
|
||||||
if not check_exist_song:
|
if not check_exist_song:
|
||||||
# This is a new track
|
# This is a new track
|
||||||
@@ -159,19 +169,25 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None, cron=Fal
|
|||||||
myDB.upsert("have", newValueDict, controlValueDict)
|
myDB.upsert("have", newValueDict, controlValueDict)
|
||||||
new_song_count += 1
|
new_song_count += 1
|
||||||
else:
|
else:
|
||||||
if check_exist_song['ArtistName'] != f_artist or check_exist_song['AlbumTitle'] != f.album or check_exist_song['TrackTitle'] != f.title:
|
if check_exist_song['ArtistName'] != f_artist or check_exist_song[
|
||||||
|
'AlbumTitle'] != f.album or check_exist_song['TrackTitle'] != f.title:
|
||||||
# Important track metadata has been modified, need to run matcher again
|
# Important track metadata has been modified, need to run matcher again
|
||||||
if f_artist and f_artist != check_exist_song['ArtistName']:
|
if f_artist and f_artist != check_exist_song['ArtistName']:
|
||||||
new_artists.append(f_artist)
|
new_artists.append(f_artist)
|
||||||
elif f_artist and f_artist == check_exist_song['ArtistName'] and check_exist_song['Matched'] != "Ignored":
|
elif f_artist and f_artist == check_exist_song['ArtistName'] and \
|
||||||
|
check_exist_song['Matched'] != "Ignored":
|
||||||
new_artists.append(f_artist)
|
new_artists.append(f_artist)
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
newValueDict['Matched'] = None
|
newValueDict['Matched'] = None
|
||||||
myDB.upsert("have", newValueDict, controlValueDict)
|
myDB.upsert("have", newValueDict, controlValueDict)
|
||||||
myDB.action('UPDATE tracks SET Location=?, BitRate=?, Format=? WHERE Location=?', [None, None, None, unicode_song_path])
|
myDB.action(
|
||||||
myDB.action('UPDATE alltracks SET Location=?, BitRate=?, Format=? WHERE Location=?', [None, None, None, unicode_song_path])
|
'UPDATE tracks SET Location=?, BitRate=?, Format=? WHERE Location=?',
|
||||||
|
[None, None, None, unicode_song_path])
|
||||||
|
myDB.action(
|
||||||
|
'UPDATE alltracks SET Location=?, BitRate=?, Format=? WHERE Location=?',
|
||||||
|
[None, None, None, unicode_song_path])
|
||||||
new_song_count += 1
|
new_song_count += 1
|
||||||
else:
|
else:
|
||||||
# This track information hasn't changed
|
# This track information hasn't changed
|
||||||
@@ -182,9 +198,13 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None, cron=Fal
|
|||||||
|
|
||||||
# Now we start track matching
|
# Now we start track matching
|
||||||
logger.info("%s new/modified songs found and added to the database" % new_song_count)
|
logger.info("%s new/modified songs found and added to the database" % new_song_count)
|
||||||
song_list = myDB.action("SELECT * FROM have WHERE Matched IS NULL AND LOCATION LIKE ?", [dir.decode(headphones.SYS_ENCODING, 'replace') + "%"])
|
song_list = myDB.action("SELECT * FROM have WHERE Matched IS NULL AND LOCATION LIKE ?",
|
||||||
total_number_of_songs = myDB.action("SELECT COUNT(*) FROM have WHERE Matched IS NULL AND LOCATION LIKE ?", [dir.decode(headphones.SYS_ENCODING, 'replace') + "%"]).fetchone()[0]
|
[dir.decode(headphones.SYS_ENCODING, 'replace') + "%"])
|
||||||
logger.info("Found " + str(total_number_of_songs) + " new/modified tracks in: '" + dir.decode(headphones.SYS_ENCODING, 'replace') + "'. Matching tracks to the appropriate releases....")
|
total_number_of_songs = \
|
||||||
|
myDB.action("SELECT COUNT(*) FROM have WHERE Matched IS NULL AND LOCATION LIKE ?",
|
||||||
|
[dir.decode(headphones.SYS_ENCODING, 'replace') + "%"]).fetchone()[0]
|
||||||
|
logger.info("Found " + str(total_number_of_songs) + " new/modified tracks in: '" + dir.decode(
|
||||||
|
headphones.SYS_ENCODING, 'replace') + "'. Matching tracks to the appropriate releases....")
|
||||||
|
|
||||||
# Sort the song_list by most vague (e.g. no trackid or releaseid) to most specific (both trackid & releaseid)
|
# Sort the song_list by most vague (e.g. no trackid or releaseid) to most specific (both trackid & releaseid)
|
||||||
# When we insert into the database, the tracks with the most specific information will overwrite the more general matches
|
# When we insert into the database, the tracks with the most specific information will overwrite the more general matches
|
||||||
@@ -195,6 +215,7 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None, cron=Fal
|
|||||||
# We'll use this to give a % completion, just because the track matching might take a while
|
# We'll use this to give a % completion, just because the track matching might take a while
|
||||||
song_count = 0
|
song_count = 0
|
||||||
latest_artist = []
|
latest_artist = []
|
||||||
|
last_completion_percentage = 0
|
||||||
|
|
||||||
for song in song_list:
|
for song in song_list:
|
||||||
|
|
||||||
@@ -205,10 +226,11 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None, cron=Fal
|
|||||||
logger.info("Now matching songs by %s" % song['ArtistName'])
|
logger.info("Now matching songs by %s" % song['ArtistName'])
|
||||||
|
|
||||||
song_count += 1
|
song_count += 1
|
||||||
completion_percentage = float(song_count) / total_number_of_songs * 100
|
completion_percentage = math.floor(float(song_count) / total_number_of_songs * 1000) / 10
|
||||||
|
|
||||||
if completion_percentage % 10 == 0:
|
if completion_percentage >= (last_completion_percentage + 10):
|
||||||
logger.info("Track matching is " + str(completion_percentage) + "% complete")
|
logger.info("Track matching is " + str(completion_percentage) + "% complete")
|
||||||
|
last_completion_percentage = completion_percentage
|
||||||
|
|
||||||
# THE "MORE-SPECIFIC" CLAUSES HERE HAVE ALL BEEN REMOVED. WHEN RUNNING A LIBRARY SCAN, THE ONLY CLAUSES THAT
|
# THE "MORE-SPECIFIC" CLAUSES HERE HAVE ALL BEEN REMOVED. WHEN RUNNING A LIBRARY SCAN, THE ONLY CLAUSES THAT
|
||||||
# EVER GOT HIT WERE [ARTIST/ALBUM/TRACK] OR CLEANNAME. ARTISTID & RELEASEID ARE NEVER PASSED TO THIS FUNCTION,
|
# EVER GOT HIT WERE [ARTIST/ALBUM/TRACK] OR CLEANNAME. ARTISTID & RELEASEID ARE NEVER PASSED TO THIS FUNCTION,
|
||||||
@@ -217,7 +239,9 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None, cron=Fal
|
|||||||
|
|
||||||
if song['ArtistName'] and song['AlbumTitle'] and song['TrackTitle']:
|
if song['ArtistName'] and song['AlbumTitle'] and song['TrackTitle']:
|
||||||
|
|
||||||
track = myDB.action('SELECT ArtistName, AlbumTitle, TrackTitle, AlbumID from tracks WHERE ArtistName LIKE ? AND AlbumTitle LIKE ? AND TrackTitle LIKE ?', [song['ArtistName'], song['AlbumTitle'], song['TrackTitle']]).fetchone()
|
track = myDB.action(
|
||||||
|
'SELECT ArtistName, AlbumTitle, TrackTitle, AlbumID from tracks WHERE ArtistName LIKE ? AND AlbumTitle LIKE ? AND TrackTitle LIKE ?',
|
||||||
|
[song['ArtistName'], song['AlbumTitle'], song['TrackTitle']]).fetchone()
|
||||||
have_updated = False
|
have_updated = False
|
||||||
if track:
|
if track:
|
||||||
controlValueDict = {'ArtistName': track['ArtistName'],
|
controlValueDict = {'ArtistName': track['ArtistName'],
|
||||||
@@ -233,7 +257,8 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None, cron=Fal
|
|||||||
myDB.upsert("have", newValueDict2, controlValueDict2)
|
myDB.upsert("have", newValueDict2, controlValueDict2)
|
||||||
have_updated = True
|
have_updated = True
|
||||||
else:
|
else:
|
||||||
track = myDB.action('SELECT CleanName, AlbumID from tracks WHERE CleanName LIKE ?', [song['CleanName']]).fetchone()
|
track = myDB.action('SELECT CleanName, AlbumID from tracks WHERE CleanName LIKE ?',
|
||||||
|
[song['CleanName']]).fetchone()
|
||||||
if track:
|
if track:
|
||||||
controlValueDict = {'CleanName': track['CleanName']}
|
controlValueDict = {'CleanName': track['CleanName']}
|
||||||
newValueDict = {'Location': song['Location'],
|
newValueDict = {'Location': song['Location'],
|
||||||
@@ -251,7 +276,9 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None, cron=Fal
|
|||||||
myDB.upsert("have", newValueDict2, controlValueDict2)
|
myDB.upsert("have", newValueDict2, controlValueDict2)
|
||||||
have_updated = True
|
have_updated = True
|
||||||
|
|
||||||
alltrack = myDB.action('SELECT ArtistName, AlbumTitle, TrackTitle, AlbumID from alltracks WHERE ArtistName LIKE ? AND AlbumTitle LIKE ? AND TrackTitle LIKE ?', [song['ArtistName'], song['AlbumTitle'], song['TrackTitle']]).fetchone()
|
alltrack = myDB.action(
|
||||||
|
'SELECT ArtistName, AlbumTitle, TrackTitle, AlbumID from alltracks WHERE ArtistName LIKE ? AND AlbumTitle LIKE ? AND TrackTitle LIKE ?',
|
||||||
|
[song['ArtistName'], song['AlbumTitle'], song['TrackTitle']]).fetchone()
|
||||||
if alltrack:
|
if alltrack:
|
||||||
controlValueDict = {'ArtistName': alltrack['ArtistName'],
|
controlValueDict = {'ArtistName': alltrack['ArtistName'],
|
||||||
'AlbumTitle': alltrack['AlbumTitle'],
|
'AlbumTitle': alltrack['AlbumTitle'],
|
||||||
@@ -265,7 +292,9 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None, cron=Fal
|
|||||||
newValueDict2 = {'Matched': alltrack['AlbumID']}
|
newValueDict2 = {'Matched': alltrack['AlbumID']}
|
||||||
myDB.upsert("have", newValueDict2, controlValueDict2)
|
myDB.upsert("have", newValueDict2, controlValueDict2)
|
||||||
else:
|
else:
|
||||||
alltrack = myDB.action('SELECT CleanName, AlbumID from alltracks WHERE CleanName LIKE ?', [song['CleanName']]).fetchone()
|
alltrack = myDB.action(
|
||||||
|
'SELECT CleanName, AlbumID from alltracks WHERE CleanName LIKE ?',
|
||||||
|
[song['CleanName']]).fetchone()
|
||||||
if alltrack:
|
if alltrack:
|
||||||
controlValueDict = {'CleanName': alltrack['CleanName']}
|
controlValueDict = {'CleanName': alltrack['CleanName']}
|
||||||
newValueDict = {'Location': song['Location'],
|
newValueDict = {'Location': song['Location'],
|
||||||
@@ -290,9 +319,10 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None, cron=Fal
|
|||||||
|
|
||||||
#######myDB.action('INSERT INTO have (ArtistName, AlbumTitle, TrackNumber, TrackTitle, TrackLength, BitRate, Genre, Date, TrackID, Location, CleanName, Format) VALUES( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [song['ArtistName'], song['AlbumTitle'], song['TrackNumber'], song['TrackTitle'], song['TrackLength'], song['BitRate'], song['Genre'], song['Date'], song['TrackID'], song['Location'], CleanName, song['Format']])
|
#######myDB.action('INSERT INTO have (ArtistName, AlbumTitle, TrackNumber, TrackTitle, TrackLength, BitRate, Genre, Date, TrackID, Location, CleanName, Format) VALUES( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [song['ArtistName'], song['AlbumTitle'], song['TrackNumber'], song['TrackTitle'], song['TrackLength'], song['BitRate'], song['Genre'], song['Date'], song['TrackID'], song['Location'], CleanName, song['Format']])
|
||||||
|
|
||||||
logger.info('Completed matching tracks from directory: %s' % dir.decode(headphones.SYS_ENCODING, 'replace'))
|
logger.info('Completed matching tracks from directory: %s' % dir.decode(headphones.SYS_ENCODING,
|
||||||
|
'replace'))
|
||||||
|
|
||||||
if not append:
|
if not append or artistScan:
|
||||||
logger.info('Updating scanned artist track counts')
|
logger.info('Updating scanned artist track counts')
|
||||||
|
|
||||||
# Clean up the new artist list
|
# Clean up the new artist list
|
||||||
@@ -316,21 +346,23 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None, cron=Fal
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Update track counts
|
# Update track counts
|
||||||
|
|
||||||
for artist in artists_checked:
|
for artist in artists_checked:
|
||||||
# Have tracks are selected from tracks table and not all tracks because of duplicates
|
# Have tracks are selected from tracks table and not all tracks because of duplicates
|
||||||
# We update the track count upon an album switch to compliment this
|
# We update the track count upon an album switch to compliment this
|
||||||
havetracks = (
|
havetracks = (
|
||||||
len(myDB.select('SELECT TrackTitle from tracks WHERE ArtistName like ? AND Location IS NOT NULL', [artist]))
|
len(myDB.select(
|
||||||
+ len(myDB.select('SELECT TrackTitle from have WHERE ArtistName like ? AND Matched = "Failed"', [artist]))
|
'SELECT TrackTitle from tracks WHERE ArtistName like ? AND Location IS NOT NULL',
|
||||||
|
[artist])) + len(myDB.select(
|
||||||
|
'SELECT TrackTitle from have WHERE ArtistName like ? AND Matched = "Failed"',
|
||||||
|
[artist]))
|
||||||
)
|
)
|
||||||
#Note, some people complain about having "artist have tracks" > # of tracks total in artist official releases
|
# Note: some people complain about having "artist have tracks" > # of tracks total in artist official releases
|
||||||
# (can fix by getting rid of second len statement)
|
# (can fix by getting rid of second len statement)
|
||||||
myDB.action('UPDATE artists SET HaveTracks=? WHERE ArtistName=?', [havetracks, artist])
|
myDB.action('UPDATE artists SET HaveTracks=? WHERE ArtistName=?', [havetracks, artist])
|
||||||
|
|
||||||
logger.info('Found %i new artists' % len(artist_list))
|
logger.info('Found %i new artists' % len(artist_list))
|
||||||
|
|
||||||
if len(artist_list):
|
if artist_list:
|
||||||
if headphones.CONFIG.AUTO_ADD_ARTISTS:
|
if headphones.CONFIG.AUTO_ADD_ARTISTS:
|
||||||
logger.info('Importing %i new artists' % len(artist_list))
|
logger.info('Importing %i new artists' % len(artist_list))
|
||||||
importer.artistlist_to_mbids(artist_list)
|
importer.artistlist_to_mbids(artist_list)
|
||||||
@@ -340,21 +372,29 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None, cron=Fal
|
|||||||
for artist in artist_list:
|
for artist in artist_list:
|
||||||
myDB.action('INSERT OR IGNORE INTO newartists VALUES (?)', [artist])
|
myDB.action('INSERT OR IGNORE INTO newartists VALUES (?)', [artist])
|
||||||
|
|
||||||
if headphones.CONFIG.DETECT_BITRATE:
|
if headphones.CONFIG.DETECT_BITRATE and bitrates:
|
||||||
headphones.CONFIG.PREFERRED_BITRATE = sum(bitrates) / len(bitrates) / 1000
|
headphones.CONFIG.PREFERRED_BITRATE = sum(bitrates) / len(bitrates) / 1000
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# If we're appending a new album to the database, update the artists total track counts
|
# If we're appending a new album to the database, update the artists total track counts
|
||||||
logger.info('Updating artist track counts')
|
logger.info('Updating artist track counts')
|
||||||
|
|
||||||
havetracks = len(myDB.select('SELECT TrackTitle from tracks WHERE ArtistID=? AND Location IS NOT NULL', [ArtistID])) + len(myDB.select('SELECT TrackTitle from have WHERE ArtistName like ? AND Matched = "Failed"', [ArtistName]))
|
havetracks = len(
|
||||||
|
myDB.select('SELECT TrackTitle from tracks WHERE ArtistID=? AND Location IS NOT NULL',
|
||||||
|
[ArtistID])) + len(myDB.select(
|
||||||
|
'SELECT TrackTitle from have WHERE ArtistName like ? AND Matched = "Failed"',
|
||||||
|
[ArtistName]))
|
||||||
myDB.action('UPDATE artists SET HaveTracks=? WHERE ArtistID=?', [havetracks, ArtistID])
|
myDB.action('UPDATE artists SET HaveTracks=? WHERE ArtistID=?', [havetracks, ArtistID])
|
||||||
|
|
||||||
if not append:
|
if not append:
|
||||||
update_album_status()
|
update_album_status()
|
||||||
|
|
||||||
|
if not append and not artistScan:
|
||||||
lastfm.getSimilar()
|
lastfm.getSimilar()
|
||||||
|
|
||||||
logger.info('Library scan complete')
|
logger.info('Library scan complete')
|
||||||
|
|
||||||
|
|
||||||
# ADDED THIS SECTION TO MARK ALBUMS AS DOWNLOADED IF ARTISTS ARE ADDED EN MASSE BEFORE LIBRARY IS SCANNED
|
# ADDED THIS SECTION TO MARK ALBUMS AS DOWNLOADED IF ARTISTS ARE ADDED EN MASSE BEFORE LIBRARY IS SCANNED
|
||||||
|
|
||||||
|
|
||||||
@@ -362,11 +402,13 @@ def update_album_status(AlbumID=None):
|
|||||||
myDB = db.DBConnection()
|
myDB = db.DBConnection()
|
||||||
logger.info('Counting matched tracks to mark albums as skipped/downloaded')
|
logger.info('Counting matched tracks to mark albums as skipped/downloaded')
|
||||||
if AlbumID:
|
if AlbumID:
|
||||||
album_status_updater = myDB.action('SELECT AlbumID, AlbumTitle, Status from albums WHERE AlbumID=?', [AlbumID])
|
album_status_updater = myDB.action(
|
||||||
|
'SELECT AlbumID, AlbumTitle, Status from albums WHERE AlbumID=?', [AlbumID])
|
||||||
else:
|
else:
|
||||||
album_status_updater = myDB.action('SELECT AlbumID, AlbumTitle, Status from albums')
|
album_status_updater = myDB.action('SELECT AlbumID, AlbumTitle, Status from albums')
|
||||||
for album in album_status_updater:
|
for album in album_status_updater:
|
||||||
track_counter = myDB.action('SELECT Location from tracks where AlbumID=?', [album['AlbumID']])
|
track_counter = myDB.action('SELECT Location from tracks where AlbumID=?',
|
||||||
|
[album['AlbumID']])
|
||||||
total_tracks = 0
|
total_tracks = 0
|
||||||
have_tracks = 0
|
have_tracks = 0
|
||||||
for track in track_counter:
|
for track in track_counter:
|
||||||
@@ -379,7 +421,7 @@ def update_album_status(AlbumID=None):
|
|||||||
album_completion = 0
|
album_completion = 0
|
||||||
logger.info('Album %s does not have any tracks in database' % album['AlbumTitle'])
|
logger.info('Album %s does not have any tracks in database' % album['AlbumTitle'])
|
||||||
|
|
||||||
if album_completion >= headphones.CONFIG.ALBUM_COMPLETION_PCT and album['Status'] == 'Skipped':
|
if album_completion >= headphones.CONFIG.ALBUM_COMPLETION_PCT:
|
||||||
new_album_status = "Downloaded"
|
new_album_status = "Downloaded"
|
||||||
|
|
||||||
# I don't think we want to change Downloaded->Skipped.....
|
# I don't think we want to change Downloaded->Skipped.....
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
Locking-related classes
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import Queue
|
||||||
|
|
||||||
|
import headphones.logger
|
||||||
|
|
||||||
|
|
||||||
|
class TimedLock(object):
|
||||||
|
"""
|
||||||
|
Enforce request rate limit if applicable. This uses the lock so there
|
||||||
|
is synchronized access to the API. When N threads enter this method, the
|
||||||
|
first will pass trough, since there there was no last request recorded.
|
||||||
|
The last request time will be set. Then, the second thread will unlock,
|
||||||
|
and see that the last request was X seconds ago. It will sleep
|
||||||
|
(request_limit - X) seconds, and then continue. Then the third one will
|
||||||
|
unblock, and so on. After all threads finish, the total time will at
|
||||||
|
least be (N * request_limit) seconds. If some request takes longer than
|
||||||
|
request_limit seconds, the next unblocked thread will wait less.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, minimum_delta=0):
|
||||||
|
"""
|
||||||
|
Set up the lock
|
||||||
|
"""
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.last_used = 0
|
||||||
|
self.minimum_delta = minimum_delta
|
||||||
|
self.queue = Queue.Queue()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""
|
||||||
|
Called when with lock: is invoked
|
||||||
|
"""
|
||||||
|
self.lock.acquire()
|
||||||
|
delta = time.time() - self.last_used
|
||||||
|
sleep_amount = self.minimum_delta - delta
|
||||||
|
if sleep_amount >= 0:
|
||||||
|
# zero sleeps give the cpu a chance to task-switch
|
||||||
|
headphones.logger.debug('Sleeping %s (interval)', sleep_amount)
|
||||||
|
time.sleep(sleep_amount)
|
||||||
|
while not self.queue.empty():
|
||||||
|
try:
|
||||||
|
seconds = self.queue.get(False)
|
||||||
|
headphones.logger.debug('Sleeping %s (queued)', seconds)
|
||||||
|
time.sleep(seconds)
|
||||||
|
except Queue.Empty:
|
||||||
|
continue
|
||||||
|
self.queue.task_done()
|
||||||
|
|
||||||
|
def __exit__(self, type, value, traceback):
|
||||||
|
"""
|
||||||
|
Called when exiting the with block.
|
||||||
|
"""
|
||||||
|
self.last_used = time.time()
|
||||||
|
self.lock.release()
|
||||||
|
|
||||||
|
def snooze(self, seconds):
|
||||||
|
"""
|
||||||
|
Asynchronously add time to the next request. Can be called outside
|
||||||
|
of the lock context, but it is possible for the next lock holder
|
||||||
|
to not check the queue until after something adds time to it.
|
||||||
|
"""
|
||||||
|
# We use a queue so that we don't have to synchronize
|
||||||
|
# across threads and with or without locks
|
||||||
|
headphones.logger.info('Adding %s to queue', seconds)
|
||||||
|
self.queue.put(seconds)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLock(object):
|
||||||
|
"""
|
||||||
|
If no locking or request throttling is needed, use this
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""
|
||||||
|
Do nothing on enter
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __exit__(self, type, value, traceback):
|
||||||
|
"""
|
||||||
|
Do nothing on exit
|
||||||
|
"""
|
||||||
|
pass
|
||||||
+25
-18
@@ -13,20 +13,20 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from headphones import helpers
|
|
||||||
|
|
||||||
from logutils.queue import QueueHandler, QueueListener
|
|
||||||
from logging import handlers
|
from logging import handlers
|
||||||
|
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import contextlib
|
import contextlib
|
||||||
import headphones
|
|
||||||
import threading
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
import logging
|
import logging
|
||||||
import errno
|
import errno
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from headphones import helpers
|
||||||
|
from logutils.queue import QueueHandler, QueueListener
|
||||||
|
import headphones
|
||||||
|
|
||||||
|
|
||||||
# These settings are for file logging only
|
# These settings are for file logging only
|
||||||
FILENAME = "headphones.log"
|
FILENAME = "headphones.log"
|
||||||
@@ -112,7 +112,7 @@ def initMultiprocessing():
|
|||||||
threading.current_thread().name = multiprocessing.current_process().name
|
threading.current_thread().name = multiprocessing.current_process().name
|
||||||
|
|
||||||
|
|
||||||
def initLogger(console=False, verbose=False):
|
def initLogger(console=False, log_dir=False, verbose=False):
|
||||||
"""
|
"""
|
||||||
Setup logging for Headphones. It uses the logger instance with the name
|
Setup logging for Headphones. It uses the logger instance with the name
|
||||||
'headphones'. Three log handlers are added:
|
'headphones'. Three log handlers are added:
|
||||||
@@ -121,7 +121,8 @@ def initLogger(console=False, verbose=False):
|
|||||||
* LogListHandler: for Web UI
|
* LogListHandler: for Web UI
|
||||||
* StreamHandler: for console (if console)
|
* StreamHandler: for console (if console)
|
||||||
|
|
||||||
Console logging is only enabled if console is set to True.
|
Console logging is only enabled if console is set to True. This method can
|
||||||
|
be invoked multiple times, during different stages of Headphones.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Close and remove old handlers. This is required to reinit the loggers
|
# Close and remove old handlers. This is required to reinit the loggers
|
||||||
@@ -139,25 +140,29 @@ def initLogger(console=False, verbose=False):
|
|||||||
logger.propagate = False
|
logger.propagate = False
|
||||||
logger.setLevel(logging.DEBUG if verbose else logging.INFO)
|
logger.setLevel(logging.DEBUG if verbose else logging.INFO)
|
||||||
|
|
||||||
# Setup file logger
|
|
||||||
filename = os.path.join(headphones.CONFIG.LOG_DIR, FILENAME)
|
|
||||||
|
|
||||||
file_formatter = logging.Formatter('%(asctime)s - %(levelname)-7s :: %(threadName)s : %(message)s', '%d-%b-%Y %H:%M:%S')
|
|
||||||
file_handler = handlers.RotatingFileHandler(filename, maxBytes=MAX_SIZE, backupCount=MAX_FILES)
|
|
||||||
file_handler.setLevel(logging.DEBUG)
|
|
||||||
file_handler.setFormatter(file_formatter)
|
|
||||||
|
|
||||||
logger.addHandler(file_handler)
|
|
||||||
|
|
||||||
# Add list logger
|
# Add list logger
|
||||||
loglist_handler = LogListHandler()
|
loglist_handler = LogListHandler()
|
||||||
loglist_handler.setLevel(logging.DEBUG)
|
loglist_handler.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
logger.addHandler(loglist_handler)
|
logger.addHandler(loglist_handler)
|
||||||
|
|
||||||
|
# Setup file logger
|
||||||
|
if log_dir:
|
||||||
|
filename = os.path.join(log_dir, FILENAME)
|
||||||
|
|
||||||
|
file_formatter = logging.Formatter(
|
||||||
|
'%(asctime)s - %(levelname)-7s :: %(threadName)s : %(message)s', '%d-%b-%Y %H:%M:%S')
|
||||||
|
file_handler = handlers.RotatingFileHandler(filename, maxBytes=MAX_SIZE,
|
||||||
|
backupCount=MAX_FILES)
|
||||||
|
file_handler.setLevel(logging.DEBUG)
|
||||||
|
file_handler.setFormatter(file_formatter)
|
||||||
|
|
||||||
|
logger.addHandler(file_handler)
|
||||||
|
|
||||||
# Setup console logger
|
# Setup console logger
|
||||||
if console:
|
if console:
|
||||||
console_formatter = logging.Formatter('%(asctime)s - %(levelname)s :: %(threadName)s : %(message)s', '%d-%b-%Y %H:%M:%S')
|
console_formatter = logging.Formatter(
|
||||||
|
'%(asctime)s - %(levelname)s :: %(threadName)s : %(message)s', '%d-%b-%Y %H:%M:%S')
|
||||||
console_handler = logging.StreamHandler()
|
console_handler = logging.StreamHandler()
|
||||||
console_handler.setFormatter(console_formatter)
|
console_handler.setFormatter(console_formatter)
|
||||||
console_handler.setLevel(logging.DEBUG)
|
console_handler.setLevel(logging.DEBUG)
|
||||||
@@ -211,11 +216,13 @@ def initHooks(global_exceptions=True, thread_exceptions=True, pass_original=True
|
|||||||
raise
|
raise
|
||||||
except:
|
except:
|
||||||
excepthook(*sys.exc_info())
|
excepthook(*sys.exc_info())
|
||||||
|
|
||||||
self.run = new_run
|
self.run = new_run
|
||||||
|
|
||||||
# Monkey patch the run() by monkey patching the __init__ method
|
# Monkey patch the run() by monkey patching the __init__ method
|
||||||
threading.Thread.__init__ = new_init
|
threading.Thread.__init__ = new_init
|
||||||
|
|
||||||
|
|
||||||
# Expose logger methods
|
# Expose logger methods
|
||||||
info = logger.info
|
info = logger.info
|
||||||
warn = logger.warn
|
warn = logger.warn
|
||||||
|
|||||||
@@ -13,14 +13,13 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import re
|
|
||||||
import htmlentitydefs
|
import htmlentitydefs
|
||||||
|
|
||||||
|
import re
|
||||||
from headphones import logger, request
|
from headphones import logger, request
|
||||||
|
|
||||||
|
|
||||||
def getLyrics(artist, song):
|
def getLyrics(artist, song):
|
||||||
|
|
||||||
params = {"artist": artist.encode('utf-8'),
|
params = {"artist": artist.encode('utf-8'),
|
||||||
"song": song.encode('utf-8'),
|
"song": song.encode('utf-8'),
|
||||||
"fmt": 'xml'
|
"fmt": 'xml'
|
||||||
@@ -46,10 +45,13 @@ def getLyrics(artist, song):
|
|||||||
logger.warn('Error fetching lyrics from: %s' % lyricsurl)
|
logger.warn('Error fetching lyrics from: %s' % lyricsurl)
|
||||||
return
|
return
|
||||||
|
|
||||||
m = re.compile('''<div class='lyricbox'><div class='rtMatcher'>.*?</div>(.*?)<!--''').search(lyricspage)
|
m = re.compile('''<div class='lyricbox'><div class='rtMatcher'>.*?</div>(.*?)<!--''').search(
|
||||||
|
lyricspage)
|
||||||
|
|
||||||
if not m:
|
if not m:
|
||||||
m = re.compile('''<div class='lyricbox'><span style="padding:1em"><a href="/Category:Instrumental" title="Instrumental">''').search(lyricspage)
|
m = re.compile(
|
||||||
|
'''<div class='lyricbox'><span style="padding:1em"><a href="/Category:Instrumental" title="Instrumental">''').search(
|
||||||
|
lyricspage)
|
||||||
if m:
|
if m:
|
||||||
return u'(Instrumental)'
|
return u'(Instrumental)'
|
||||||
else:
|
else:
|
||||||
|
|||||||
+220
-101
@@ -16,10 +16,9 @@
|
|||||||
|
|
||||||
from headphones import logger, db, helpers
|
from headphones import logger, db, helpers
|
||||||
|
|
||||||
import time
|
|
||||||
import threading
|
|
||||||
import headphones
|
import headphones
|
||||||
import musicbrainzngs
|
import musicbrainzngs
|
||||||
|
import headphones.lock
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# pylint:disable=E0611
|
# pylint:disable=E0611
|
||||||
@@ -30,14 +29,14 @@ except ImportError:
|
|||||||
# Python 2.6.x fallback, from libs
|
# Python 2.6.x fallback, from libs
|
||||||
from ordereddict import OrderedDict
|
from ordereddict import OrderedDict
|
||||||
|
|
||||||
mb_lock = threading.Lock()
|
mb_lock = headphones.lock.TimedLock(0)
|
||||||
|
|
||||||
|
|
||||||
# Quick fix to add mirror switching on the fly. Need to probably return the mbhost & mbport that's
|
# Quick fix to add mirror switching on the fly. Need to probably return the mbhost & mbport that's
|
||||||
# being used, so we can send those values to the log
|
# being used, so we can send those values to the log
|
||||||
|
|
||||||
|
|
||||||
def startmb():
|
def startmb():
|
||||||
|
|
||||||
mbuser = None
|
mbuser = None
|
||||||
mbpass = None
|
mbpass = None
|
||||||
|
|
||||||
@@ -48,9 +47,11 @@ def startmb():
|
|||||||
elif headphones.CONFIG.MIRROR == "custom":
|
elif headphones.CONFIG.MIRROR == "custom":
|
||||||
mbhost = headphones.CONFIG.CUSTOMHOST
|
mbhost = headphones.CONFIG.CUSTOMHOST
|
||||||
mbport = int(headphones.CONFIG.CUSTOMPORT)
|
mbport = int(headphones.CONFIG.CUSTOMPORT)
|
||||||
|
mbuser = headphones.CONFIG.CUSTOMUSER
|
||||||
|
mbpass = headphones.CONFIG.CUSTOMPASS
|
||||||
sleepytime = int(headphones.CONFIG.CUSTOMSLEEP)
|
sleepytime = int(headphones.CONFIG.CUSTOMSLEEP)
|
||||||
elif headphones.CONFIG.MIRROR == "headphones":
|
elif headphones.CONFIG.MIRROR == "headphones":
|
||||||
mbhost = "144.76.94.239"
|
mbhost = "codeshy.com"
|
||||||
mbport = 8181
|
mbport = 8181
|
||||||
mbuser = headphones.CONFIG.HPUSER
|
mbuser = headphones.CONFIG.HPUSER
|
||||||
mbpass = headphones.CONFIG.HPPASS
|
mbpass = headphones.CONFIG.HPPASS
|
||||||
@@ -60,27 +61,33 @@ def startmb():
|
|||||||
|
|
||||||
musicbrainzngs.set_useragent("headphones", "0.0", "https://github.com/rembo10/headphones")
|
musicbrainzngs.set_useragent("headphones", "0.0", "https://github.com/rembo10/headphones")
|
||||||
musicbrainzngs.set_hostname(mbhost + ":" + str(mbport))
|
musicbrainzngs.set_hostname(mbhost + ":" + str(mbport))
|
||||||
|
|
||||||
|
# Their rate limiting should be redundant to our lock
|
||||||
if sleepytime == 0:
|
if sleepytime == 0:
|
||||||
musicbrainzngs.set_rate_limit(False)
|
musicbrainzngs.set_rate_limit(False)
|
||||||
else:
|
else:
|
||||||
# calling it with an it ends up blocking all requests after the first
|
# calling it with an it ends up blocking all requests after the first
|
||||||
musicbrainzngs.set_rate_limit(limit_or_interval=float(sleepytime))
|
musicbrainzngs.set_rate_limit(limit_or_interval=float(sleepytime))
|
||||||
|
mb_lock.minimum_delta = sleepytime
|
||||||
|
|
||||||
# Add headphones credentials
|
# Add headphones credentials
|
||||||
if headphones.CONFIG.MIRROR == "headphones":
|
if headphones.CONFIG.MIRROR == "headphones" or headphones.CONFIG.CUSTOMAUTH:
|
||||||
if not mbuser and mbpass:
|
if not mbuser or not mbpass:
|
||||||
logger.warn("No username or password set for VIP server")
|
logger.warn("No username or password set for MusicBrainz server")
|
||||||
else:
|
else:
|
||||||
musicbrainzngs.hpauth(mbuser, mbpass)
|
musicbrainzngs.hpauth(mbuser, mbpass)
|
||||||
|
|
||||||
logger.debug('Using the following server values: MBHost: %s, MBPort: %i, Sleep Interval: %i', mbhost, mbport, sleepytime)
|
# Let us know if we disable custom authentication
|
||||||
|
if not headphones.CONFIG.CUSTOMAUTH and headphones.CONFIG.MIRROR == "custom":
|
||||||
|
musicbrainzngs.disable_hpauth()
|
||||||
|
|
||||||
|
logger.debug('Using the following server values: MBHost: %s, MBPort: %i, Sleep Interval: %i',
|
||||||
|
mbhost, mbport, sleepytime)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def findArtist(name, limit=1):
|
def findArtist(name, limit=1):
|
||||||
|
|
||||||
with mb_lock:
|
|
||||||
artistlist = []
|
artistlist = []
|
||||||
artistResults = None
|
artistResults = None
|
||||||
|
|
||||||
@@ -90,11 +97,20 @@ def findArtist(name, limit=1):
|
|||||||
|
|
||||||
criteria = {'artist': name.lower()}
|
criteria = {'artist': name.lower()}
|
||||||
|
|
||||||
|
with mb_lock:
|
||||||
try:
|
try:
|
||||||
artistResults = musicbrainzngs.search_artists(limit=limit, **criteria)['artist-list']
|
artistResults = musicbrainzngs.search_artists(limit=limit, **criteria)['artist-list']
|
||||||
|
except ValueError as e:
|
||||||
|
if "at least one query term is required" in e.message:
|
||||||
|
logger.error(
|
||||||
|
"Tried to search without a term, or an empty one. Provided artist (probably emtpy): %s",
|
||||||
|
name)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
raise
|
||||||
except musicbrainzngs.WebServiceError as e:
|
except musicbrainzngs.WebServiceError as e:
|
||||||
logger.warn('Attempt to query MusicBrainz for %s failed (%s)' % (name, str(e)))
|
logger.warn('Attempt to query MusicBrainz for %s failed (%s)' % (name, str(e)))
|
||||||
time.sleep(5)
|
mb_lock.snooze(5)
|
||||||
|
|
||||||
if not artistResults:
|
if not artistResults:
|
||||||
return False
|
return False
|
||||||
@@ -104,10 +120,12 @@ def findArtist(name, limit=1):
|
|||||||
else:
|
else:
|
||||||
uniquename = unicode(result['sort-name'])
|
uniquename = unicode(result['sort-name'])
|
||||||
if result['name'] != uniquename and limit == 1:
|
if result['name'] != uniquename and limit == 1:
|
||||||
logger.info('Found an artist with a disambiguation: %s - doing an album based search' % name)
|
logger.info(
|
||||||
|
'Found an artist with a disambiguation: %s - doing an album based search' % name)
|
||||||
artistdict = findArtistbyAlbum(name)
|
artistdict = findArtistbyAlbum(name)
|
||||||
if not artistdict:
|
if not artistdict:
|
||||||
logger.info('Cannot determine the best match from an artist/album search. Using top match instead')
|
logger.info(
|
||||||
|
'Cannot determine the best match from an artist/album search. Using top match instead')
|
||||||
artistlist.append({
|
artistlist.append({
|
||||||
# Just need the artist id if the limit is 1
|
# Just need the artist id if the limit is 1
|
||||||
# 'name': unicode(result['sort-name']),
|
# 'name': unicode(result['sort-name']),
|
||||||
@@ -123,15 +141,14 @@ def findArtist(name, limit=1):
|
|||||||
'name': unicode(result['sort-name']),
|
'name': unicode(result['sort-name']),
|
||||||
'uniquename': uniquename,
|
'uniquename': uniquename,
|
||||||
'id': unicode(result['id']),
|
'id': unicode(result['id']),
|
||||||
'url': unicode("http://musicbrainz.org/artist/" + result['id']),#probably needs to be changed
|
'url': unicode("http://musicbrainz.org/artist/" + result['id']),
|
||||||
|
# probably needs to be changed
|
||||||
'score': int(result['ext:score'])
|
'score': int(result['ext:score'])
|
||||||
})
|
})
|
||||||
return artistlist
|
return artistlist
|
||||||
|
|
||||||
|
|
||||||
def findRelease(name, limit=1, artist=None):
|
def findRelease(name, limit=1, artist=None):
|
||||||
|
|
||||||
with mb_lock:
|
|
||||||
releaselist = []
|
releaselist = []
|
||||||
releaseResults = None
|
releaseResults = None
|
||||||
|
|
||||||
@@ -145,11 +162,13 @@ def findRelease(name, limit=1, artist=None):
|
|||||||
if artist and any((c in chars) for c in artist):
|
if artist and any((c in chars) for c in artist):
|
||||||
artist = '"' + artist + '"'
|
artist = '"' + artist + '"'
|
||||||
|
|
||||||
|
with mb_lock:
|
||||||
try:
|
try:
|
||||||
releaseResults = musicbrainzngs.search_releases(query=name, limit=limit, artist=artist)['release-list']
|
releaseResults = musicbrainzngs.search_releases(query=name, limit=limit, artist=artist)[
|
||||||
|
'release-list']
|
||||||
except musicbrainzngs.WebServiceError as e: # need to update exceptions
|
except musicbrainzngs.WebServiceError as e: # need to update exceptions
|
||||||
logger.warn('Attempt to query MusicBrainz for "%s" failed: %s' % (name, str(e)))
|
logger.warn('Attempt to query MusicBrainz for "%s" failed: %s' % (name, str(e)))
|
||||||
time.sleep(5)
|
mb_lock.snooze(5)
|
||||||
|
|
||||||
if not releaseResults:
|
if not releaseResults:
|
||||||
return False
|
return False
|
||||||
@@ -195,8 +214,11 @@ def findRelease(name, limit=1, artist=None):
|
|||||||
'title': unicode(title),
|
'title': unicode(title),
|
||||||
'id': unicode(result['artist-credit'][0]['artist']['id']),
|
'id': unicode(result['artist-credit'][0]['artist']['id']),
|
||||||
'albumid': unicode(result['id']),
|
'albumid': unicode(result['id']),
|
||||||
'url': unicode("http://musicbrainz.org/artist/" + result['artist-credit'][0]['artist']['id']),#probably needs to be changed
|
'url': unicode(
|
||||||
'albumurl': unicode("http://musicbrainz.org/release/" + result['id']),#probably needs to be changed
|
"http://musicbrainz.org/artist/" + result['artist-credit'][0]['artist']['id']),
|
||||||
|
# probably needs to be changed
|
||||||
|
'albumurl': unicode("http://musicbrainz.org/release/" + result['id']),
|
||||||
|
# probably needs to be changed
|
||||||
'score': int(result['ext:score']),
|
'score': int(result['ext:score']),
|
||||||
'date': unicode(result['date']) if 'date' in result else '',
|
'date': unicode(result['date']) if 'date' in result else '',
|
||||||
'country': unicode(result['country']) if 'country' in result else '',
|
'country': unicode(result['country']) if 'country' in result else '',
|
||||||
@@ -208,52 +230,73 @@ def findRelease(name, limit=1, artist=None):
|
|||||||
return releaselist
|
return releaselist
|
||||||
|
|
||||||
|
|
||||||
def getArtist(artistid, extrasonly=False):
|
def findSeries(name, limit=1):
|
||||||
|
serieslist = []
|
||||||
|
seriesResults = None
|
||||||
|
|
||||||
|
chars = set('!?*-')
|
||||||
|
if any((c in chars) for c in name):
|
||||||
|
name = '"' + name + '"'
|
||||||
|
|
||||||
|
criteria = {'series': name.lower()}
|
||||||
|
|
||||||
with mb_lock:
|
with mb_lock:
|
||||||
artist_dict = {}
|
|
||||||
|
|
||||||
artist = None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
limit = 200
|
seriesResults = musicbrainzngs.search_series(limit=limit, **criteria)['series-list']
|
||||||
|
except musicbrainzngs.WebServiceError as e:
|
||||||
|
logger.warn('Attempt to query MusicBrainz for %s failed (%s)' % (name, str(e)))
|
||||||
|
mb_lock.snooze(5)
|
||||||
|
|
||||||
|
if not seriesResults:
|
||||||
|
return False
|
||||||
|
for result in seriesResults:
|
||||||
|
if 'disambiguation' in result:
|
||||||
|
uniquename = unicode(result['name'] + " (" + result['disambiguation'] + ")")
|
||||||
|
else:
|
||||||
|
uniquename = unicode(result['name'])
|
||||||
|
serieslist.append({
|
||||||
|
'uniquename': uniquename,
|
||||||
|
'name': unicode(result['name']),
|
||||||
|
'type': unicode(result['type']),
|
||||||
|
'id': unicode(result['id']),
|
||||||
|
'url': unicode("http://musicbrainz.org/series/" + result['id']),
|
||||||
|
# probably needs to be changed
|
||||||
|
'score': int(result['ext:score'])
|
||||||
|
})
|
||||||
|
return serieslist
|
||||||
|
|
||||||
|
|
||||||
|
def getArtist(artistid, extrasonly=False):
|
||||||
|
artist_dict = {}
|
||||||
|
artist = None
|
||||||
|
try:
|
||||||
|
limit = 100
|
||||||
|
with mb_lock:
|
||||||
artist = musicbrainzngs.get_artist_by_id(artistid)['artist']
|
artist = musicbrainzngs.get_artist_by_id(artistid)['artist']
|
||||||
newRgs = None
|
newRgs = None
|
||||||
artist['release-group-list'] = []
|
artist['release-group-list'] = []
|
||||||
while newRgs is None or len(newRgs) >= limit:
|
while newRgs is None or len(newRgs) >= limit:
|
||||||
newRgs = musicbrainzngs.browse_release_groups(artistid, release_type="album", offset=len(artist['release-group-list']), limit=limit)['release-group-list']
|
with mb_lock:
|
||||||
|
newRgs = musicbrainzngs.browse_release_groups(
|
||||||
|
artistid,
|
||||||
|
release_type="album",
|
||||||
|
offset=len(artist['release-group-list']),
|
||||||
|
limit=limit)
|
||||||
|
newRgs = newRgs['release-group-list']
|
||||||
artist['release-group-list'] += newRgs
|
artist['release-group-list'] += newRgs
|
||||||
except musicbrainzngs.WebServiceError as e:
|
except musicbrainzngs.WebServiceError as e:
|
||||||
logger.warn('Attempt to retrieve artist information from MusicBrainz failed for artistid: %s (%s)' % (artistid, str(e)))
|
logger.warn(
|
||||||
time.sleep(5)
|
'Attempt to retrieve artist information from MusicBrainz failed for artistid: %s (%s)' % (
|
||||||
|
artistid, str(e)))
|
||||||
|
mb_lock.snooze(5)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if not artist:
|
if not artist:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
#if 'disambiguation' in artist:
|
|
||||||
# uniquename = unicode(artist['sort-name'] + " (" + artist['disambiguation'] + ")")
|
|
||||||
#else:
|
|
||||||
# uniquename = unicode(artist['sort-name'])
|
|
||||||
|
|
||||||
artist_dict['artist_name'] = unicode(artist['name'])
|
artist_dict['artist_name'] = unicode(artist['name'])
|
||||||
|
|
||||||
# Not using the following values anywhere yet so we don't need to grab them.
|
|
||||||
# Was causing an exception to be raised if they didn't exist.
|
|
||||||
#
|
|
||||||
#artist_dict['artist_sortname'] = unicode(artist['sort-name'])
|
|
||||||
#artist_dict['artist_uniquename'] = uniquename
|
|
||||||
#artist_dict['artist_type'] = unicode(artist['type'])
|
|
||||||
|
|
||||||
#artist_dict['artist_begindate'] = None
|
|
||||||
#artist_dict['artist_enddate'] = None
|
|
||||||
#if 'life-span' in artist:
|
|
||||||
# if 'begin' in artist['life-span']:
|
|
||||||
# artist_dict['artist_begindate'] = unicode(artist['life-span']['begin'])
|
|
||||||
# if 'end' in artist['life-span']:
|
|
||||||
# artist_dict['artist_enddate'] = unicode(artist['life-span']['end'])
|
|
||||||
|
|
||||||
releasegroups = []
|
releasegroups = []
|
||||||
|
|
||||||
if not extrasonly:
|
if not extrasonly:
|
||||||
@@ -272,7 +315,8 @@ def getArtist(artistid, extrasonly=False):
|
|||||||
myDB = db.DBConnection()
|
myDB = db.DBConnection()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db_artist = myDB.action('SELECT IncludeExtras, Extras from artists WHERE ArtistID=?', [artistid]).fetchone()
|
db_artist = myDB.action('SELECT IncludeExtras, Extras from artists WHERE ArtistID=?',
|
||||||
|
[artistid]).fetchone()
|
||||||
includeExtras = db_artist['IncludeExtras']
|
includeExtras = db_artist['IncludeExtras']
|
||||||
except IndexError:
|
except IndexError:
|
||||||
includeExtras = False
|
includeExtras = False
|
||||||
@@ -299,17 +343,21 @@ def getArtist(artistid, extrasonly=False):
|
|||||||
mb_extras_list = []
|
mb_extras_list = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
limit = 200
|
limit = 100
|
||||||
newRgs = None
|
newRgs = None
|
||||||
while newRgs is None or len(newRgs) >= limit:
|
while newRgs is None or len(newRgs) >= limit:
|
||||||
newRgs = musicbrainzngs.browse_release_groups(artistid, release_type=include, offset=len(mb_extras_list), limit=limit)['release-group-list']
|
with mb_lock:
|
||||||
|
newRgs = musicbrainzngs.browse_release_groups(
|
||||||
|
artistid, release_type=include, offset=len(mb_extras_list), limit=limit)
|
||||||
|
newRgs = newRgs['release-group-list']
|
||||||
mb_extras_list += newRgs
|
mb_extras_list += newRgs
|
||||||
except musicbrainzngs.WebServiceError as e:
|
except musicbrainzngs.WebServiceError as e:
|
||||||
logger.warn('Attempt to retrieve artist information from MusicBrainz failed for artistid: %s (%s)' % (artistid, str(e)))
|
logger.warn(
|
||||||
time.sleep(5)
|
'Attempt to retrieve artist information from MusicBrainz failed for artistid: %s (%s)' % (
|
||||||
|
artistid, str(e)))
|
||||||
|
mb_lock.snooze(5)
|
||||||
|
|
||||||
for rg in mb_extras_list:
|
for rg in mb_extras_list:
|
||||||
|
|
||||||
rg_type = rg['type']
|
rg_type = rg['type']
|
||||||
if rg_type == 'Album' and 'secondary-type-list' in rg:
|
if rg_type == 'Album' and 'secondary-type-list' in rg:
|
||||||
secondary_type = rg['secondary-type-list'][0]
|
secondary_type = rg['secondary-type-list'][0]
|
||||||
@@ -322,25 +370,63 @@ def getArtist(artistid, extrasonly=False):
|
|||||||
'url': u"http://musicbrainz.org/release-group/" + rg['id'],
|
'url': u"http://musicbrainz.org/release-group/" + rg['id'],
|
||||||
'type': unicode(rg_type)
|
'type': unicode(rg_type)
|
||||||
})
|
})
|
||||||
|
|
||||||
artist_dict['releasegroups'] = releasegroups
|
artist_dict['releasegroups'] = releasegroups
|
||||||
|
|
||||||
return artist_dict
|
return artist_dict
|
||||||
|
|
||||||
|
|
||||||
|
def getSeries(seriesid):
|
||||||
|
series_dict = {}
|
||||||
|
series = None
|
||||||
|
try:
|
||||||
|
with mb_lock:
|
||||||
|
series = musicbrainzngs.get_series_by_id(seriesid, includes=['release-group-rels'])[
|
||||||
|
'series']
|
||||||
|
except musicbrainzngs.WebServiceError as e:
|
||||||
|
logger.warn(
|
||||||
|
'Attempt to retrieve series information from MusicBrainz failed for seriesid: %s (%s)' % (
|
||||||
|
seriesid, str(e)))
|
||||||
|
mb_lock.snooze(5)
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not series:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if 'disambiguation' in series:
|
||||||
|
series_dict['artist_name'] = unicode(
|
||||||
|
series['name'] + " (" + unicode(series['disambiguation']) + ")")
|
||||||
|
else:
|
||||||
|
series_dict['artist_name'] = unicode(series['name'])
|
||||||
|
|
||||||
|
releasegroups = []
|
||||||
|
|
||||||
|
for rg in series['release_group-relation-list']:
|
||||||
|
releasegroup = rg['release-group']
|
||||||
|
releasegroups.append({
|
||||||
|
'title': releasegroup['title'],
|
||||||
|
'date': releasegroup['first-release-date'],
|
||||||
|
'id': releasegroup['id'],
|
||||||
|
'type': rg['type']
|
||||||
|
})
|
||||||
|
series_dict['releasegroups'] = releasegroups
|
||||||
|
return series_dict
|
||||||
|
|
||||||
|
|
||||||
def getReleaseGroup(rgid):
|
def getReleaseGroup(rgid):
|
||||||
"""
|
"""
|
||||||
Returns a list of releases in a release group
|
Returns a list of releases in a release group
|
||||||
"""
|
"""
|
||||||
with mb_lock:
|
|
||||||
|
|
||||||
releaseGroup = None
|
releaseGroup = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
releaseGroup = musicbrainzngs.get_release_group_by_id(rgid, ["artists", "releases", "media", "discids", ])['release-group']
|
with mb_lock:
|
||||||
|
releaseGroup = musicbrainzngs.get_release_group_by_id(
|
||||||
|
rgid, ["artists", "releases", "media", "discids", ])
|
||||||
|
releaseGroup = releaseGroup['release-group']
|
||||||
except musicbrainzngs.WebServiceError as e:
|
except musicbrainzngs.WebServiceError as e:
|
||||||
logger.warn('Attempt to retrieve information from MusicBrainz for release group "%s" failed (%s)' % (rgid, str(e)))
|
logger.warn(
|
||||||
time.sleep(5)
|
'Attempt to retrieve information from MusicBrainz for release group "%s" failed (%s)' % (
|
||||||
|
rgid, str(e)))
|
||||||
|
mb_lock.snooze(5)
|
||||||
|
|
||||||
if not releaseGroup:
|
if not releaseGroup:
|
||||||
return False
|
return False
|
||||||
@@ -352,19 +438,23 @@ def getRelease(releaseid, include_artist_info=True):
|
|||||||
"""
|
"""
|
||||||
Deep release search to get track info
|
Deep release search to get track info
|
||||||
"""
|
"""
|
||||||
with mb_lock:
|
|
||||||
|
|
||||||
release = {}
|
release = {}
|
||||||
results = None
|
results = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
with mb_lock:
|
||||||
if include_artist_info:
|
if include_artist_info:
|
||||||
results = musicbrainzngs.get_release_by_id(releaseid, ["artists", "release-groups", "media", "recordings"]).get('release')
|
results = musicbrainzngs.get_release_by_id(releaseid,
|
||||||
|
["artists", "release-groups", "media",
|
||||||
|
"recordings"]).get('release')
|
||||||
else:
|
else:
|
||||||
results = musicbrainzngs.get_release_by_id(releaseid, ["media", "recordings"]).get('release')
|
results = musicbrainzngs.get_release_by_id(releaseid, ["media", "recordings"]).get(
|
||||||
|
'release')
|
||||||
except musicbrainzngs.WebServiceError as e:
|
except musicbrainzngs.WebServiceError as e:
|
||||||
logger.warn('Attempt to retrieve information from MusicBrainz for release "%s" failed (%s)' % (releaseid, str(e)))
|
logger.warn(
|
||||||
time.sleep(5)
|
'Attempt to retrieve information from MusicBrainz for release "%s" failed (%s)' % (
|
||||||
|
releaseid, str(e)))
|
||||||
|
mb_lock.snooze(5)
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
return False
|
return False
|
||||||
@@ -391,7 +481,8 @@ def getRelease(releaseid, include_artist_info=True):
|
|||||||
try:
|
try:
|
||||||
release['rg_type'] = unicode(results['release-group']['type'])
|
release['rg_type'] = unicode(results['release-group']['type'])
|
||||||
|
|
||||||
if release['rg_type'] == 'Album' and 'secondary-type-list' in results['release-group']:
|
if release['rg_type'] == 'Album' and 'secondary-type-list' in results[
|
||||||
|
'release-group']:
|
||||||
secondary_type = unicode(results['release-group']['secondary-type-list'][0])
|
secondary_type = unicode(results['release-group']['secondary-type-list'][0])
|
||||||
if secondary_type != release['rg_type']:
|
if secondary_type != release['rg_type']:
|
||||||
release['rg_type'] = secondary_type
|
release['rg_type'] = secondary_type
|
||||||
@@ -411,22 +502,34 @@ def getRelease(releaseid, include_artist_info=True):
|
|||||||
|
|
||||||
|
|
||||||
def get_new_releases(rgid, includeExtras=False, forcefull=False):
|
def get_new_releases(rgid, includeExtras=False, forcefull=False):
|
||||||
|
|
||||||
myDB = db.DBConnection()
|
myDB = db.DBConnection()
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
|
release_status = "official"
|
||||||
|
if includeExtras and not headphones.CONFIG.OFFICIAL_RELEASES_ONLY:
|
||||||
|
release_status = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
limit = 100
|
limit = 100
|
||||||
newResults = None
|
newResults = None
|
||||||
while newResults is None or len(newResults) >= limit:
|
while newResults is None or len(newResults) >= limit:
|
||||||
newResults = musicbrainzngs.browse_releases(release_group=rgid, includes=['artist-credits', 'labels', 'recordings', 'release-groups', 'media'], limit=limit, offset=len(results))
|
with mb_lock:
|
||||||
|
newResults = musicbrainzngs.browse_releases(
|
||||||
|
release_group=rgid,
|
||||||
|
includes=['artist-credits', 'labels', 'recordings', 'release-groups', 'media'],
|
||||||
|
release_status=release_status,
|
||||||
|
limit=limit,
|
||||||
|
offset=len(results))
|
||||||
if 'release-list' not in newResults:
|
if 'release-list' not in newResults:
|
||||||
break # may want to raise an exception here instead ?
|
break # may want to raise an exception here instead ?
|
||||||
newResults = newResults['release-list']
|
newResults = newResults['release-list']
|
||||||
results += newResults
|
results += newResults
|
||||||
|
|
||||||
except musicbrainzngs.WebServiceError as e:
|
except musicbrainzngs.WebServiceError as e:
|
||||||
logger.warn('Attempt to retrieve information from MusicBrainz for release group "%s" failed (%s)' % (rgid, str(e)))
|
logger.warn(
|
||||||
time.sleep(5)
|
'Attempt to retrieve information from MusicBrainz for release group "%s" failed (%s)' % (
|
||||||
|
rgid, str(e)))
|
||||||
|
mb_lock.snooze(5)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not results or len(results) == 0:
|
if not results or len(results) == 0:
|
||||||
@@ -439,7 +542,8 @@ def get_new_releases(rgid, includeExtras=False, forcefull=False):
|
|||||||
for release_mark in results:
|
for release_mark in results:
|
||||||
release_list.append(unicode(release_mark['id']))
|
release_list.append(unicode(release_mark['id']))
|
||||||
release_title = release_mark['title']
|
release_title = release_mark['title']
|
||||||
remove_missing_releases = myDB.action("SELECT ReleaseID FROM allalbums WHERE AlbumID=?", [rgid])
|
remove_missing_releases = myDB.action("SELECT ReleaseID FROM allalbums WHERE AlbumID=?",
|
||||||
|
[rgid])
|
||||||
if remove_missing_releases:
|
if remove_missing_releases:
|
||||||
for items in remove_missing_releases:
|
for items in remove_missing_releases:
|
||||||
if items['ReleaseID'] not in release_list and items['ReleaseID'] != rgid:
|
if items['ReleaseID'] not in release_list and items['ReleaseID'] != rgid:
|
||||||
@@ -448,22 +552,22 @@ def get_new_releases(rgid, includeExtras=False, forcefull=False):
|
|||||||
myDB.action("DELETE FROM tracks WHERE ReleaseID=?", [items['ReleaseID']])
|
myDB.action("DELETE FROM tracks WHERE ReleaseID=?", [items['ReleaseID']])
|
||||||
myDB.action("DELETE FROM allalbums WHERE ReleaseID=?", [items['ReleaseID']])
|
myDB.action("DELETE FROM allalbums WHERE ReleaseID=?", [items['ReleaseID']])
|
||||||
myDB.action("DELETE FROM alltracks WHERE ReleaseID=?", [items['ReleaseID']])
|
myDB.action("DELETE FROM alltracks WHERE ReleaseID=?", [items['ReleaseID']])
|
||||||
logger.info("Removing all references to release %s to reflect MusicBrainz" % items['ReleaseID'])
|
logger.info(
|
||||||
|
"Removing all references to release %s to reflect MusicBrainz" % items[
|
||||||
|
'ReleaseID'])
|
||||||
force_repackage1 = 1
|
force_repackage1 = 1
|
||||||
else:
|
else:
|
||||||
logger.info("There was either an error pulling data from MusicBrainz or there might not be any releases for this category")
|
logger.info(
|
||||||
|
"There was either an error pulling data from MusicBrainz or there might not be any releases for this category")
|
||||||
|
|
||||||
num_new_releases = 0
|
num_new_releases = 0
|
||||||
|
|
||||||
for releasedata in results:
|
for releasedata in results:
|
||||||
#releasedata.get will return None if it doesn't have a status
|
|
||||||
#all official releases should have the Official status included
|
|
||||||
if not includeExtras and releasedata.get('status') != 'Official':
|
|
||||||
continue
|
|
||||||
|
|
||||||
release = {}
|
release = {}
|
||||||
rel_id_check = releasedata['id']
|
rel_id_check = releasedata['id']
|
||||||
album_checker = myDB.action('SELECT * from allalbums WHERE ReleaseID=?', [rel_id_check]).fetchone()
|
album_checker = myDB.action('SELECT * from allalbums WHERE ReleaseID=?',
|
||||||
|
[rel_id_check]).fetchone()
|
||||||
if not album_checker or forcefull:
|
if not album_checker or forcefull:
|
||||||
# DELETE all references to this release since we're updating it anyway.
|
# DELETE all references to this release since we're updating it anyway.
|
||||||
myDB.action('DELETE from allalbums WHERE ReleaseID=?', [rel_id_check])
|
myDB.action('DELETE from allalbums WHERE ReleaseID=?', [rel_id_check])
|
||||||
@@ -474,7 +578,8 @@ def get_new_releases(rgid, includeExtras=False, forcefull=False):
|
|||||||
release['ReleaseDate'] = unicode(releasedata['date']) if 'date' in releasedata else None
|
release['ReleaseDate'] = unicode(releasedata['date']) if 'date' in releasedata else None
|
||||||
release['ReleaseID'] = releasedata['id']
|
release['ReleaseID'] = releasedata['id']
|
||||||
if 'release-group' not in releasedata:
|
if 'release-group' not in releasedata:
|
||||||
raise Exception('No release group associated with release id ' + releasedata['id'] + ' album id' + rgid)
|
raise Exception('No release group associated with release id ' + releasedata[
|
||||||
|
'id'] + ' album id' + rgid)
|
||||||
release['Type'] = unicode(releasedata['release-group']['type'])
|
release['Type'] = unicode(releasedata['release-group']['type'])
|
||||||
|
|
||||||
if release['Type'] == 'Album' and 'secondary-type-list' in releasedata['release-group']:
|
if release['Type'] == 'Album' and 'secondary-type-list' in releasedata['release-group']:
|
||||||
@@ -490,7 +595,8 @@ def get_new_releases(rgid, includeExtras=False, forcefull=False):
|
|||||||
logger.warn('Release ' + releasedata['id'] + ' has no Artists associated.')
|
logger.warn('Release ' + releasedata['id'] + ' has no Artists associated.')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
release['ReleaseCountry'] = unicode(releasedata['country']) if 'country' in releasedata else u'Unknown'
|
release['ReleaseCountry'] = unicode(
|
||||||
|
releasedata['country']) if 'country' in releasedata else u'Unknown'
|
||||||
# assuming that the list will contain media and that the format will be consistent
|
# assuming that the list will contain media and that the format will be consistent
|
||||||
try:
|
try:
|
||||||
additional_medium = ''
|
additional_medium = ''
|
||||||
@@ -503,7 +609,8 @@ def get_new_releases(rgid, includeExtras=False, forcefull=False):
|
|||||||
disc_number = ''
|
disc_number = ''
|
||||||
else:
|
else:
|
||||||
disc_number = str(medium_count) + 'x'
|
disc_number = str(medium_count) + 'x'
|
||||||
packaged_medium = disc_number + releasedata['medium-list'][0]['format'] + additional_medium
|
packaged_medium = disc_number + releasedata['medium-list'][0][
|
||||||
|
'format'] + additional_medium
|
||||||
release['ReleaseFormat'] = unicode(packaged_medium)
|
release['ReleaseFormat'] = unicode(packaged_medium)
|
||||||
except:
|
except:
|
||||||
release['ReleaseFormat'] = u'Unknown'
|
release['ReleaseFormat'] = u'Unknown'
|
||||||
@@ -530,7 +637,8 @@ def get_new_releases(rgid, includeExtras=False, forcefull=False):
|
|||||||
|
|
||||||
for track in release['Tracks']:
|
for track in release['Tracks']:
|
||||||
|
|
||||||
cleanname = helpers.cleanName(release['ArtistName'] + ' ' + release['AlbumTitle'] + ' ' + track['title'])
|
cleanname = helpers.cleanName(
|
||||||
|
release['ArtistName'] + ' ' + release['AlbumTitle'] + ' ' + track['title'])
|
||||||
|
|
||||||
controlValueDict = {"TrackID": track['id'],
|
controlValueDict = {"TrackID": track['id'],
|
||||||
"ReleaseID": release['ReleaseID']}
|
"ReleaseID": release['ReleaseID']}
|
||||||
@@ -546,10 +654,13 @@ def get_new_releases(rgid, includeExtras=False, forcefull=False):
|
|||||||
"CleanName": cleanname
|
"CleanName": cleanname
|
||||||
}
|
}
|
||||||
|
|
||||||
match = myDB.action('SELECT Location, BitRate, Format from have WHERE CleanName=?', [cleanname]).fetchone()
|
match = myDB.action('SELECT Location, BitRate, Format from have WHERE CleanName=?',
|
||||||
|
[cleanname]).fetchone()
|
||||||
|
|
||||||
if not match:
|
if not match:
|
||||||
match = myDB.action('SELECT Location, BitRate, Format from have WHERE ArtistName LIKE ? AND AlbumTitle LIKE ? AND TrackTitle LIKE ?', [release['ArtistName'], release['AlbumTitle'], track['title']]).fetchone()
|
match = myDB.action(
|
||||||
|
'SELECT Location, BitRate, Format from have WHERE ArtistName LIKE ? AND AlbumTitle LIKE ? AND TrackTitle LIKE ?',
|
||||||
|
[release['ArtistName'], release['AlbumTitle'], track['title']]).fetchone()
|
||||||
# if not match:
|
# if not match:
|
||||||
# match = myDB.action('SELECT Location, BitRate, Format from have WHERE TrackID=?', [track['id']]).fetchone()
|
# match = myDB.action('SELECT Location, BitRate, Format from have WHERE TrackID=?', [track['id']]).fetchone()
|
||||||
if match:
|
if match:
|
||||||
@@ -557,17 +668,21 @@ def get_new_releases(rgid, includeExtras=False, forcefull=False):
|
|||||||
newValueDict['BitRate'] = match['BitRate']
|
newValueDict['BitRate'] = match['BitRate']
|
||||||
newValueDict['Format'] = match['Format']
|
newValueDict['Format'] = match['Format']
|
||||||
# myDB.action('UPDATE have SET Matched="True" WHERE Location=?', [match['Location']])
|
# myDB.action('UPDATE have SET Matched="True" WHERE Location=?', [match['Location']])
|
||||||
myDB.action('UPDATE have SET Matched=? WHERE Location=?', (release['AlbumID'], match['Location']))
|
myDB.action('UPDATE have SET Matched=? WHERE Location=?',
|
||||||
|
(release['AlbumID'], match['Location']))
|
||||||
|
|
||||||
myDB.upsert("alltracks", newValueDict, controlValueDict)
|
myDB.upsert("alltracks", newValueDict, controlValueDict)
|
||||||
num_new_releases = num_new_releases + 1
|
num_new_releases = num_new_releases + 1
|
||||||
if album_checker:
|
if album_checker:
|
||||||
logger.info('[%s] Existing release %s (%s) updated' % (release['ArtistName'], release['AlbumTitle'], rel_id_check))
|
logger.info('[%s] Existing release %s (%s) updated' % (
|
||||||
|
release['ArtistName'], release['AlbumTitle'], rel_id_check))
|
||||||
else:
|
else:
|
||||||
logger.info('[%s] New release %s (%s) added' % (release['ArtistName'], release['AlbumTitle'], rel_id_check))
|
logger.info('[%s] New release %s (%s) added' % (
|
||||||
|
release['ArtistName'], release['AlbumTitle'], rel_id_check))
|
||||||
if force_repackage1 == 1:
|
if force_repackage1 == 1:
|
||||||
num_new_releases = -1
|
num_new_releases = -1
|
||||||
logger.info('[%s] Forcing repackage of %s, since dB releases have been removed' % (release['ArtistName'], release_title))
|
logger.info('[%s] Forcing repackage of %s, since dB releases have been removed' % (
|
||||||
|
release['ArtistName'], release_title))
|
||||||
else:
|
else:
|
||||||
num_new_releases = num_new_releases
|
num_new_releases = num_new_releases
|
||||||
|
|
||||||
@@ -593,14 +708,16 @@ def getTracksFromRelease(release):
|
|||||||
totalTracks += 1
|
totalTracks += 1
|
||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
|
|
||||||
# Used when there is a disambiguation
|
# Used when there is a disambiguation
|
||||||
|
|
||||||
|
|
||||||
def findArtistbyAlbum(name):
|
def findArtistbyAlbum(name):
|
||||||
|
|
||||||
myDB = db.DBConnection()
|
myDB = db.DBConnection()
|
||||||
|
|
||||||
artist = myDB.action('SELECT AlbumTitle from have WHERE ArtistName=? AND AlbumTitle IS NOT NULL ORDER BY RANDOM()', [name]).fetchone()
|
artist = myDB.action(
|
||||||
|
'SELECT AlbumTitle from have WHERE ArtistName=? AND AlbumTitle IS NOT NULL ORDER BY RANDOM()',
|
||||||
|
[name]).fetchone()
|
||||||
|
|
||||||
if not artist:
|
if not artist:
|
||||||
return False
|
return False
|
||||||
@@ -614,10 +731,11 @@ def findArtistbyAlbum(name):
|
|||||||
results = None
|
results = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
with mb_lock:
|
||||||
results = musicbrainzngs.search_release_groups(term).get('release-group-list')
|
results = musicbrainzngs.search_release_groups(term).get('release-group-list')
|
||||||
except musicbrainzngs.WebServiceError as e:
|
except musicbrainzngs.WebServiceError as e:
|
||||||
logger.warn('Attempt to query MusicBrainz for %s failed (%s)' % (name, str(e)))
|
logger.warn('Attempt to query MusicBrainz for %s failed (%s)' % (name, str(e)))
|
||||||
time.sleep(5)
|
mb_lock.snooze(5)
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
return False
|
return False
|
||||||
@@ -640,7 +758,6 @@ def findArtistbyAlbum(name):
|
|||||||
|
|
||||||
|
|
||||||
def findAlbumID(artist=None, album=None):
|
def findAlbumID(artist=None, album=None):
|
||||||
|
|
||||||
results = None
|
results = None
|
||||||
chars = set('!?*-')
|
chars = set('!?*-')
|
||||||
|
|
||||||
@@ -656,11 +773,13 @@ def findAlbumID(artist=None, album=None):
|
|||||||
if any((c in chars) for c in album):
|
if any((c in chars) for c in album):
|
||||||
album = '"' + album + '"'
|
album = '"' + album + '"'
|
||||||
criteria = {'release': album.lower()}
|
criteria = {'release': album.lower()}
|
||||||
|
with mb_lock:
|
||||||
results = musicbrainzngs.search_release_groups(limit=1, **criteria).get('release-group-list')
|
results = musicbrainzngs.search_release_groups(limit=1, **criteria).get(
|
||||||
|
'release-group-list')
|
||||||
except musicbrainzngs.WebServiceError as e:
|
except musicbrainzngs.WebServiceError as e:
|
||||||
logger.warn('Attempt to query MusicBrainz for %s - %s failed (%s)' % (artist, album, str(e)))
|
logger.warn(
|
||||||
time.sleep(5)
|
'Attempt to query MusicBrainz for %s - %s failed (%s)' % (artist, album, str(e)))
|
||||||
|
mb_lock.snooze(5)
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# This file is part of Headphones.
|
||||||
|
#
|
||||||
|
# Headphones is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Headphones is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from headphones import db, helpers, logger, request
|
||||||
|
|
||||||
|
|
||||||
|
def update(artistid, artist_name, release_groups):
|
||||||
|
""" Pretty simple and crude function to find the artist page on metacritic,
|
||||||
|
then parse that page to get critic & user scores for albums"""
|
||||||
|
|
||||||
|
# First let's modify the artist name to fit the metacritic convention.
|
||||||
|
# We could just do a search, then take the top result, but at least this will
|
||||||
|
# cut down on api calls. If it's ineffective then we'll switch to search
|
||||||
|
|
||||||
|
replacements = {" & ": " ", ".": ""}
|
||||||
|
mc_artist_name = helpers.replace_all(artist_name.lower(), replacements)
|
||||||
|
|
||||||
|
mc_artist_name = mc_artist_name.replace(" ", "-")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2243.2 Safari/537.36'}
|
||||||
|
|
||||||
|
url = "http://www.metacritic.com/person/" + mc_artist_name + "?filter-options=music&sort_options=date&num_items=100"
|
||||||
|
|
||||||
|
res = request.request_soup(url, headers=headers)
|
||||||
|
|
||||||
|
rows = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
table = res.find("table", class_="credits person_credits")
|
||||||
|
rows = table.tbody.find_all('tr')
|
||||||
|
except:
|
||||||
|
logger.info("Unable to get metacritic scores for: %s" % artist_name)
|
||||||
|
|
||||||
|
myDB = db.DBConnection()
|
||||||
|
artist = myDB.action('SELECT * FROM artists WHERE ArtistID=?', [artistid]).fetchone()
|
||||||
|
|
||||||
|
score_list = []
|
||||||
|
|
||||||
|
# If we couldn't get anything from MetaCritic for whatever reason,
|
||||||
|
# let's try to load scores from the db
|
||||||
|
if not rows:
|
||||||
|
if artist['MetaCritic']:
|
||||||
|
score_list = json.loads(artist['MetaCritic'])
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
# If we did get scores, let's update the db with them
|
||||||
|
else:
|
||||||
|
for row in rows:
|
||||||
|
title = row.a.string
|
||||||
|
scores = row.find_all("span")
|
||||||
|
critic_score = scores[0].string
|
||||||
|
user_score = scores[1].string
|
||||||
|
score_dict = {'title': title, 'critic_score': critic_score, 'user_score': user_score}
|
||||||
|
score_list.append(score_dict)
|
||||||
|
|
||||||
|
# Save scores to the database
|
||||||
|
controlValueDict = {"ArtistID": artistid}
|
||||||
|
newValueDict = {'MetaCritic': json.dumps(score_list)}
|
||||||
|
myDB.upsert("artists", newValueDict, controlValueDict)
|
||||||
|
|
||||||
|
for score in score_list:
|
||||||
|
title = score['title']
|
||||||
|
# Iterate through the release groups we got passed to see if we can find
|
||||||
|
# a match
|
||||||
|
for rg in release_groups:
|
||||||
|
if rg['title'].lower() == title.lower():
|
||||||
|
critic_score = score['critic_score']
|
||||||
|
user_score = score['user_score']
|
||||||
|
controlValueDict = {"AlbumID": rg['id']}
|
||||||
|
newValueDict = {'CriticScore': critic_score, 'UserScore': user_score}
|
||||||
|
myDB.upsert("albums", newValueDict, controlValueDict)
|
||||||
+50
-34
@@ -13,16 +13,17 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import os
|
|
||||||
import headphones
|
|
||||||
import shutil
|
|
||||||
import time
|
import time
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
|
|
||||||
import subprocess
|
import os
|
||||||
|
import headphones
|
||||||
from headphones import logger
|
from headphones import logger
|
||||||
from beets.mediafile import MediaFile
|
from beets.mediafile import MediaFile
|
||||||
|
|
||||||
|
|
||||||
# xld
|
# xld
|
||||||
import getXldProfile
|
import getXldProfile
|
||||||
|
|
||||||
@@ -32,10 +33,14 @@ def encode(albumPath):
|
|||||||
|
|
||||||
# Return if xld details not found
|
# Return if xld details not found
|
||||||
if use_xld:
|
if use_xld:
|
||||||
(xldProfile, xldFormat, xldBitrate) = getXldProfile.getXldProfile(headphones.CONFIG.XLDPROFILE)
|
(xldProfile, xldFormat, xldBitrate) = getXldProfile.getXldProfile(
|
||||||
|
headphones.CONFIG.XLDPROFILE)
|
||||||
if not xldFormat:
|
if not xldFormat:
|
||||||
logger.error('Details for xld profile \'%s\' not found, files will not be re-encoded', xldProfile)
|
logger.error('Details for xld profile \'%s\' not found, files will not be re-encoded',
|
||||||
|
xldProfile)
|
||||||
return None
|
return None
|
||||||
|
else:
|
||||||
|
xldProfile = None
|
||||||
|
|
||||||
tempDirEncode = os.path.join(albumPath, "temp")
|
tempDirEncode = os.path.join(albumPath, "temp")
|
||||||
musicFiles = []
|
musicFiles = []
|
||||||
@@ -58,17 +63,20 @@ def encode(albumPath):
|
|||||||
for music in f:
|
for music in f:
|
||||||
if any(music.lower().endswith('.' + x.lower()) for x in headphones.MEDIA_FORMATS):
|
if any(music.lower().endswith('.' + x.lower()) for x in headphones.MEDIA_FORMATS):
|
||||||
if not use_xld:
|
if not use_xld:
|
||||||
encoderFormat = headphones.CONFIG.ENCODEROUTPUTFORMAT.encode(headphones.SYS_ENCODING)
|
encoderFormat = headphones.CONFIG.ENCODEROUTPUTFORMAT.encode(
|
||||||
|
headphones.SYS_ENCODING)
|
||||||
else:
|
else:
|
||||||
xldMusicFile = os.path.join(r, music)
|
xldMusicFile = os.path.join(r, music)
|
||||||
xldInfoMusic = MediaFile(xldMusicFile)
|
xldInfoMusic = MediaFile(xldMusicFile)
|
||||||
encoderFormat = xldFormat
|
encoderFormat = xldFormat
|
||||||
|
|
||||||
if (headphones.CONFIG.ENCODERLOSSLESS):
|
if headphones.CONFIG.ENCODERLOSSLESS:
|
||||||
ext = os.path.normpath(os.path.splitext(music)[1].lstrip(".")).lower()
|
ext = os.path.normpath(os.path.splitext(music)[1].lstrip(".")).lower()
|
||||||
if not use_xld and ext == 'flac' or use_xld and (ext != xldFormat and (xldInfoMusic.bitrate / 1000 > 400)):
|
if not use_xld and ext == 'flac' or use_xld and (
|
||||||
|
ext != xldFormat and (xldInfoMusic.bitrate / 1000 > 400)):
|
||||||
musicFiles.append(os.path.join(r, music))
|
musicFiles.append(os.path.join(r, music))
|
||||||
musicTemp = os.path.normpath(os.path.splitext(music)[0] + '.' + encoderFormat)
|
musicTemp = os.path.normpath(
|
||||||
|
os.path.splitext(music)[0] + '.' + encoderFormat)
|
||||||
musicTempFiles.append(os.path.join(tempDirEncode, musicTemp))
|
musicTempFiles.append(os.path.join(tempDirEncode, musicTemp))
|
||||||
else:
|
else:
|
||||||
logger.debug('%s is already encoded', music)
|
logger.debug('%s is already encoded', music)
|
||||||
@@ -84,7 +92,7 @@ def encode(albumPath):
|
|||||||
encoder = os.path.join('/Applications', 'xld')
|
encoder = os.path.join('/Applications', 'xld')
|
||||||
elif headphones.CONFIG.ENCODER == 'lame':
|
elif headphones.CONFIG.ENCODER == 'lame':
|
||||||
if headphones.SYS_PLATFORM == "win32":
|
if headphones.SYS_PLATFORM == "win32":
|
||||||
## NEED THE DEFAULT LAME INSTALL ON WIN!
|
# NEED THE DEFAULT LAME INSTALL ON WIN!
|
||||||
encoder = "C:/Program Files/lame/lame.exe"
|
encoder = "C:/Program Files/lame/lame.exe"
|
||||||
else:
|
else:
|
||||||
encoder = "lame"
|
encoder = "lame"
|
||||||
@@ -109,31 +117,38 @@ def encode(albumPath):
|
|||||||
|
|
||||||
if use_xld:
|
if use_xld:
|
||||||
if xldBitrate and (infoMusic.bitrate / 1000 <= xldBitrate):
|
if xldBitrate and (infoMusic.bitrate / 1000 <= xldBitrate):
|
||||||
logger.info('%s has bitrate <= %skb, will not be re-encoded', music.decode(headphones.SYS_ENCODING, 'replace'), xldBitrate)
|
logger.info('%s has bitrate <= %skb, will not be re-encoded',
|
||||||
|
music.decode(headphones.SYS_ENCODING, 'replace'), xldBitrate)
|
||||||
else:
|
else:
|
||||||
encode = True
|
encode = True
|
||||||
elif headphones.CONFIG.ENCODER == 'lame':
|
elif headphones.CONFIG.ENCODER == 'lame':
|
||||||
if not any(music.decode(headphones.SYS_ENCODING, 'replace').lower().endswith('.' + x) for x in ["mp3", "wav"]):
|
if not any(
|
||||||
logger.warn('Lame cannot encode %s format for %s, use ffmpeg', os.path.splitext(music)[1], music)
|
music.decode(headphones.SYS_ENCODING, 'replace').lower().endswith('.' + x) for x
|
||||||
|
in ["mp3", "wav"]):
|
||||||
|
logger.warn('Lame cannot encode %s format for %s, use ffmpeg',
|
||||||
|
os.path.splitext(music)[1], music)
|
||||||
else:
|
else:
|
||||||
if (music.decode(headphones.SYS_ENCODING, 'replace').lower().endswith('.mp3') and (int(infoMusic.bitrate / 1000) <= headphones.CONFIG.BITRATE)):
|
if music.decode(headphones.SYS_ENCODING, 'replace').lower().endswith('.mp3') and (
|
||||||
logger.info('%s has bitrate <= %skb, will not be re-encoded', music, headphones.CONFIG.BITRATE)
|
int(infoMusic.bitrate / 1000) <= headphones.CONFIG.BITRATE):
|
||||||
|
logger.info('%s has bitrate <= %skb, will not be re-encoded', music,
|
||||||
|
headphones.CONFIG.BITRATE)
|
||||||
else:
|
else:
|
||||||
encode = True
|
encode = True
|
||||||
else:
|
else:
|
||||||
if headphones.CONFIG.ENCODEROUTPUTFORMAT == 'ogg':
|
if headphones.CONFIG.ENCODEROUTPUTFORMAT == 'ogg':
|
||||||
if music.decode(headphones.SYS_ENCODING, 'replace').lower().endswith('.ogg'):
|
if music.decode(headphones.SYS_ENCODING, 'replace').lower().endswith('.ogg'):
|
||||||
logger.warn('Cannot re-encode .ogg %s', music.decode(headphones.SYS_ENCODING, 'replace'))
|
logger.warn('Cannot re-encode .ogg %s',
|
||||||
|
music.decode(headphones.SYS_ENCODING, 'replace'))
|
||||||
else:
|
else:
|
||||||
encode = True
|
encode = True
|
||||||
elif (headphones.CONFIG.ENCODEROUTPUTFORMAT == 'mp3' or headphones.CONFIG.ENCODEROUTPUTFORMAT == 'm4a'):
|
else:
|
||||||
if (music.decode(headphones.SYS_ENCODING, 'replace').lower().endswith('.' + headphones.CONFIG.ENCODEROUTPUTFORMAT) and (int(infoMusic.bitrate / 1000) <= headphones.CONFIG.BITRATE)):
|
if music.decode(headphones.SYS_ENCODING, 'replace').lower().endswith('.' + headphones.CONFIG.ENCODEROUTPUTFORMAT) and (int(infoMusic.bitrate / 1000) <= headphones.CONFIG.BITRATE):
|
||||||
logger.info('%s has bitrate <= %skb, will not be re-encoded', music, headphones.CONFIG.BITRATE)
|
logger.info('%s has bitrate <= %skb, will not be re-encoded', music, headphones.CONFIG.BITRATE)
|
||||||
else:
|
else:
|
||||||
encode = True
|
encode = True
|
||||||
# encode
|
# encode
|
||||||
if encode:
|
if encode:
|
||||||
job = (encoder, music, musicTempFiles[i], albumPath)
|
job = (encoder, music, musicTempFiles[i], albumPath, xldProfile)
|
||||||
jobs.append(job)
|
jobs.append(job)
|
||||||
else:
|
else:
|
||||||
musicFiles[i] = None
|
musicFiles[i] = None
|
||||||
@@ -183,7 +198,8 @@ def encode(albumPath):
|
|||||||
for dest in musicTempFiles:
|
for dest in musicTempFiles:
|
||||||
if not os.path.exists(dest):
|
if not os.path.exists(dest):
|
||||||
encoder_failed = True
|
encoder_failed = True
|
||||||
logger.error("Encoded file '%s' does not exist in the destination temp directory", dest)
|
logger.error("Encoded file '%s' does not exist in the destination temp directory",
|
||||||
|
dest)
|
||||||
|
|
||||||
# No errors, move from temp to parent
|
# No errors, move from temp to parent
|
||||||
if not encoder_failed and musicTempFiles:
|
if not encoder_failed and musicTempFiles:
|
||||||
@@ -209,7 +225,9 @@ def encode(albumPath):
|
|||||||
|
|
||||||
# Return with error if any encoding errors
|
# Return with error if any encoding errors
|
||||||
if encoder_failed:
|
if encoder_failed:
|
||||||
logger.error("One or more files failed to encode. Ensure you have the latest version of %s installed.", headphones.CONFIG.ENCODER)
|
logger.error(
|
||||||
|
"One or more files failed to encode. Ensure you have the latest version of %s installed.",
|
||||||
|
headphones.CONFIG.ENCODER)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
@@ -242,22 +260,16 @@ def command_map(args):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def command(encoder, musicSource, musicDest, albumPath):
|
def command(encoder, musicSource, musicDest, albumPath, xldProfile):
|
||||||
"""
|
"""
|
||||||
Encode a given music file with a certain encoder. Returns True on success,
|
Encode a given music file with a certain encoder. Returns True on success,
|
||||||
or False otherwise.
|
or False otherwise.
|
||||||
"""
|
"""
|
||||||
use_xld = headphones.CONFIG.ENCODER == 'xld'
|
|
||||||
|
|
||||||
startMusicTime = time.time()
|
startMusicTime = time.time()
|
||||||
cmd = []
|
cmd = []
|
||||||
|
|
||||||
# Return if xld details not found
|
if xldProfile:
|
||||||
if use_xld:
|
|
||||||
(xldProfile, xldFormat, xldBitrate) = getXldProfile.getXldProfile(headphones.CONFIG.XLDPROFILE)
|
|
||||||
if not xldFormat:
|
|
||||||
logger.error('Details for xld profile \'%s\' not found, files will not be re-encoded', xldProfile)
|
|
||||||
return None
|
|
||||||
xldDestDir = os.path.split(musicDest)[0]
|
xldDestDir = os.path.split(musicDest)[0]
|
||||||
cmd = [encoder]
|
cmd = [encoder]
|
||||||
cmd.extend([musicSource])
|
cmd.extend([musicSource])
|
||||||
@@ -273,7 +285,8 @@ def command(encoder, musicSource, musicDest, albumPath):
|
|||||||
if not headphones.CONFIG.ADVANCEDENCODER:
|
if not headphones.CONFIG.ADVANCEDENCODER:
|
||||||
opts.extend(['-h'])
|
opts.extend(['-h'])
|
||||||
if headphones.CONFIG.ENCODERVBRCBR == 'cbr':
|
if headphones.CONFIG.ENCODERVBRCBR == 'cbr':
|
||||||
opts.extend(['--resample', str(headphones.CONFIG.SAMPLINGFREQUENCY), '-b', str(headphones.CONFIG.BITRATE)])
|
opts.extend(['--resample', str(headphones.CONFIG.SAMPLINGFREQUENCY), '-b',
|
||||||
|
str(headphones.CONFIG.BITRATE)])
|
||||||
elif headphones.CONFIG.ENCODERVBRCBR == 'vbr':
|
elif headphones.CONFIG.ENCODERVBRCBR == 'vbr':
|
||||||
opts.extend(['-v', str(headphones.CONFIG.ENCODERQUALITY)])
|
opts.extend(['-v', str(headphones.CONFIG.ENCODERQUALITY)])
|
||||||
else:
|
else:
|
||||||
@@ -294,7 +307,8 @@ def command(encoder, musicSource, musicDest, albumPath):
|
|||||||
if headphones.CONFIG.ENCODEROUTPUTFORMAT == 'm4a':
|
if headphones.CONFIG.ENCODEROUTPUTFORMAT == 'm4a':
|
||||||
opts.extend(['-strict', 'experimental'])
|
opts.extend(['-strict', 'experimental'])
|
||||||
if headphones.CONFIG.ENCODERVBRCBR == 'cbr':
|
if headphones.CONFIG.ENCODERVBRCBR == 'cbr':
|
||||||
opts.extend(['-ar', str(headphones.CONFIG.SAMPLINGFREQUENCY), '-ab', str(headphones.CONFIG.BITRATE) + 'k'])
|
opts.extend(['-ar', str(headphones.CONFIG.SAMPLINGFREQUENCY), '-ab',
|
||||||
|
str(headphones.CONFIG.BITRATE) + 'k'])
|
||||||
elif headphones.CONFIG.ENCODERVBRCBR == 'vbr':
|
elif headphones.CONFIG.ENCODERVBRCBR == 'vbr':
|
||||||
opts.extend(['-aq', str(headphones.CONFIG.ENCODERQUALITY)])
|
opts.extend(['-aq', str(headphones.CONFIG.ENCODERQUALITY)])
|
||||||
opts.extend(['-y', '-ac', '2', '-vn'])
|
opts.extend(['-y', '-ac', '2', '-vn'])
|
||||||
@@ -315,7 +329,8 @@ def command(encoder, musicSource, musicDest, albumPath):
|
|||||||
if headphones.CONFIG.ENCODEROUTPUTFORMAT == 'm4a':
|
if headphones.CONFIG.ENCODEROUTPUTFORMAT == 'm4a':
|
||||||
opts.extend(['-strict', 'experimental'])
|
opts.extend(['-strict', 'experimental'])
|
||||||
if headphones.CONFIG.ENCODERVBRCBR == 'cbr':
|
if headphones.CONFIG.ENCODERVBRCBR == 'cbr':
|
||||||
opts.extend(['-ar', str(headphones.CONFIG.SAMPLINGFREQUENCY), '-ab', str(headphones.CONFIG.BITRATE) + 'k'])
|
opts.extend(['-ar', str(headphones.CONFIG.SAMPLINGFREQUENCY), '-ab',
|
||||||
|
str(headphones.CONFIG.BITRATE) + 'k'])
|
||||||
elif headphones.CONFIG.ENCODERVBRCBR == 'vbr':
|
elif headphones.CONFIG.ENCODERVBRCBR == 'vbr':
|
||||||
opts.extend(['-aq', str(headphones.CONFIG.ENCODERQUALITY)])
|
opts.extend(['-aq', str(headphones.CONFIG.ENCODERQUALITY)])
|
||||||
opts.extend(['-y', '-ac', '2', '-vn'])
|
opts.extend(['-y', '-ac', '2', '-vn'])
|
||||||
@@ -347,7 +362,8 @@ def command(encoder, musicSource, musicDest, albumPath):
|
|||||||
|
|
||||||
# Error if return code not zero
|
# Error if return code not zero
|
||||||
if process.returncode:
|
if process.returncode:
|
||||||
logger.error('Encoding failed for %s' % (musicSource.decode(headphones.SYS_ENCODING, 'replace')))
|
logger.error(
|
||||||
|
'Encoding failed for %s' % (musicSource.decode(headphones.SYS_ENCODING, 'replace')))
|
||||||
out = stdout if stdout else stderr
|
out = stdout if stdout else stderr
|
||||||
out = out.decode(headphones.SYS_ENCODING, 'replace')
|
out = out.decode(headphones.SYS_ENCODING, 'replace')
|
||||||
outlast2lines = '\n'.join(out.splitlines()[-2:])
|
outlast2lines = '\n'.join(out.splitlines()[-2:])
|
||||||
|
|||||||
+194
-133
@@ -13,31 +13,26 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from headphones import logger, helpers, common, request
|
|
||||||
|
|
||||||
from xml.dom import minidom
|
|
||||||
from httplib import HTTPSConnection
|
|
||||||
from urllib import urlencode
|
from urllib import urlencode
|
||||||
from pynma import pynma
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import cherrypy
|
|
||||||
import urllib
|
import urllib
|
||||||
import urllib2
|
|
||||||
import headphones
|
|
||||||
import os.path
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import gntp.notifier
|
|
||||||
import json
|
import json
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
import smtplib
|
||||||
|
import email.utils
|
||||||
|
from httplib import HTTPSConnection
|
||||||
|
from urlparse import parse_qsl
|
||||||
|
import urllib2
|
||||||
|
|
||||||
|
import os.path
|
||||||
|
from headphones import logger, helpers, common, request
|
||||||
|
from pynma import pynma
|
||||||
|
import cherrypy
|
||||||
|
import headphones
|
||||||
|
import gntp.notifier
|
||||||
import oauth2 as oauth
|
import oauth2 as oauth
|
||||||
import pythontwitter as twitter
|
import pythontwitter as twitter
|
||||||
|
|
||||||
try:
|
|
||||||
from urlparse import parse_qsl
|
|
||||||
except ImportError:
|
|
||||||
from cgi import parse_qsl
|
|
||||||
|
|
||||||
|
|
||||||
class GROWL(object):
|
class GROWL(object):
|
||||||
"""
|
"""
|
||||||
@@ -185,7 +180,6 @@ class MPC(object):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def notify(self):
|
def notify(self):
|
||||||
@@ -218,9 +212,11 @@ class XBMC(object):
|
|||||||
url = host + '/jsonrpc'
|
url = host + '/jsonrpc'
|
||||||
|
|
||||||
if self.password:
|
if self.password:
|
||||||
response = request.request_json(url, method="post", data=json.dumps(data), headers=headers, auth=(self.username, self.password))
|
response = request.request_json(url, method="post", data=json.dumps(data),
|
||||||
|
headers=headers, auth=(self.username, self.password))
|
||||||
else:
|
else:
|
||||||
response = request.request_json(url, method="post", data=json.dumps(data), headers=headers)
|
response = request.request_json(url, method="post", data=json.dumps(data),
|
||||||
|
headers=headers)
|
||||||
|
|
||||||
if response:
|
if response:
|
||||||
return response[0]['result']
|
return response[0]['result']
|
||||||
@@ -249,15 +245,19 @@ class XBMC(object):
|
|||||||
for host in hosts:
|
for host in hosts:
|
||||||
logger.info('Sending notification command to XMBC @ ' + host)
|
logger.info('Sending notification command to XMBC @ ' + host)
|
||||||
try:
|
try:
|
||||||
version = self._sendjson(host, 'Application.GetProperties', {'properties': ['version']})['version']['major']
|
version = \
|
||||||
|
self._sendjson(host, 'Application.GetProperties', {'properties': ['version']})[
|
||||||
|
'version']['major']
|
||||||
|
|
||||||
if version < 12: # Eden
|
if version < 12: # Eden
|
||||||
notification = header + "," + message + "," + time + "," + albumartpath
|
notification = header + "," + message + "," + time + "," + albumartpath
|
||||||
notifycommand = {'command': 'ExecBuiltIn', 'parameter': 'Notification(' + notification + ')'}
|
notifycommand = {'command': 'ExecBuiltIn',
|
||||||
|
'parameter': 'Notification(' + notification + ')'}
|
||||||
request = self._sendhttp(host, notifycommand)
|
request = self._sendhttp(host, notifycommand)
|
||||||
|
|
||||||
else: # Frodo
|
else: # Frodo
|
||||||
params = {'title': header, 'message': message, 'displaytime': int(time), 'image': albumartpath}
|
params = {'title': header, 'message': message, 'displaytime': int(time),
|
||||||
|
'image': albumartpath}
|
||||||
request = self._sendjson(host, 'GUI.ShowNotification', params)
|
request = self._sendjson(host, 'GUI.ShowNotification', params)
|
||||||
|
|
||||||
if not request:
|
if not request:
|
||||||
@@ -295,7 +295,7 @@ class LMS(object):
|
|||||||
return response['result']
|
return response['result']
|
||||||
except:
|
except:
|
||||||
logger.warn('LMS returned error: %s' % response['error'])
|
logger.warn('LMS returned error: %s' % response['error'])
|
||||||
return
|
return response['error']
|
||||||
|
|
||||||
def update(self):
|
def update(self):
|
||||||
|
|
||||||
@@ -305,7 +305,7 @@ class LMS(object):
|
|||||||
logger.info('Sending library rescan command to LMS @ ' + host)
|
logger.info('Sending library rescan command to LMS @ ' + host)
|
||||||
request = self._sendjson(host)
|
request = self._sendjson(host)
|
||||||
|
|
||||||
if not request:
|
if request:
|
||||||
logger.warn('Error sending rescan request to LMS')
|
logger.warn('Error sending rescan request to LMS')
|
||||||
|
|
||||||
|
|
||||||
@@ -316,34 +316,34 @@ class Plex(object):
|
|||||||
self.client_hosts = headphones.CONFIG.PLEX_CLIENT_HOST
|
self.client_hosts = headphones.CONFIG.PLEX_CLIENT_HOST
|
||||||
self.username = headphones.CONFIG.PLEX_USERNAME
|
self.username = headphones.CONFIG.PLEX_USERNAME
|
||||||
self.password = headphones.CONFIG.PLEX_PASSWORD
|
self.password = headphones.CONFIG.PLEX_PASSWORD
|
||||||
|
self.token = headphones.CONFIG.PLEX_TOKEN
|
||||||
|
|
||||||
def _sendhttp(self, host, command):
|
def _sendhttp(self, host, command):
|
||||||
|
|
||||||
username = self.username
|
url = host + '/xbmcCmds/xbmcHttp/?' + command
|
||||||
password = self.password
|
|
||||||
|
|
||||||
url_command = urllib.urlencode(command)
|
if self.password:
|
||||||
|
response = request.request_response(url, auth=(self.username, self.password))
|
||||||
url = host + '/xbmcCmds/xbmcHttp/?' + url_command
|
else:
|
||||||
|
response = request.request_response(url)
|
||||||
req = urllib2.Request(url)
|
|
||||||
|
|
||||||
if password:
|
|
||||||
base64string = base64.encodestring('%s:%s' % (username, password)).replace('\n', '')
|
|
||||||
req.add_header("Authorization", "Basic %s" % base64string)
|
|
||||||
|
|
||||||
logger.info('Plex url: %s' % url)
|
|
||||||
|
|
||||||
try:
|
|
||||||
handle = urllib2.urlopen(req)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warn('Error opening Plex url: %s' % e)
|
|
||||||
return
|
|
||||||
|
|
||||||
response = handle.read().decode(headphones.SYS_ENCODING)
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
def _sendjson(self, host, method, params={}):
|
||||||
|
data = [{'id': 0, 'jsonrpc': '2.0', 'method': method, 'params': params}]
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
url = host + '/jsonrpc'
|
||||||
|
|
||||||
|
if self.password:
|
||||||
|
response = request.request_json(url, method="post", data=json.dumps(data),
|
||||||
|
headers=headers, auth=(self.username, self.password))
|
||||||
|
else:
|
||||||
|
response = request.request_json(url, method="post", data=json.dumps(data),
|
||||||
|
headers=headers)
|
||||||
|
|
||||||
|
if response:
|
||||||
|
return response[0]['result']
|
||||||
|
|
||||||
def update(self):
|
def update(self):
|
||||||
|
|
||||||
# From what I read you can't update the music library on a per directory or per path basis
|
# From what I read you can't update the music library on a per directory or per path basis
|
||||||
@@ -354,13 +354,15 @@ class Plex(object):
|
|||||||
for host in hosts:
|
for host in hosts:
|
||||||
logger.info('Sending library update command to Plex Media Server@ ' + host)
|
logger.info('Sending library update command to Plex Media Server@ ' + host)
|
||||||
url = "%s/library/sections" % host
|
url = "%s/library/sections" % host
|
||||||
try:
|
if self.token:
|
||||||
xml_sections = minidom.parse(urllib.urlopen(url))
|
params = {'X-Plex-Token': self.token}
|
||||||
except IOError, e:
|
else:
|
||||||
logger.warn("Error while trying to contact Plex Media Server: %s" % e)
|
params = False
|
||||||
return False
|
|
||||||
|
r = request.request_minidom(url, params=params)
|
||||||
|
|
||||||
|
sections = r.getElementsByTagName('Directory')
|
||||||
|
|
||||||
sections = xml_sections.getElementsByTagName('Directory')
|
|
||||||
if not sections:
|
if not sections:
|
||||||
logger.info(u"Plex Media Server not running on: " + host)
|
logger.info(u"Plex Media Server not running on: " + host)
|
||||||
return False
|
return False
|
||||||
@@ -368,11 +370,7 @@ class Plex(object):
|
|||||||
for s in sections:
|
for s in sections:
|
||||||
if s.getAttribute('type') == "artist":
|
if s.getAttribute('type') == "artist":
|
||||||
url = "%s/library/sections/%s/refresh" % (host, s.getAttribute('key'))
|
url = "%s/library/sections/%s/refresh" % (host, s.getAttribute('key'))
|
||||||
try:
|
request.request_response(url, params=params)
|
||||||
urllib.urlopen(url)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warn("Error updating library section for Plex Media Server: %s" % e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def notify(self, artist, album, albumartpath):
|
def notify(self, artist, album, albumartpath):
|
||||||
|
|
||||||
@@ -383,17 +381,28 @@ class Plex(object):
|
|||||||
time = "3000" # in ms
|
time = "3000" # in ms
|
||||||
|
|
||||||
for host in hosts:
|
for host in hosts:
|
||||||
logger.info('Sending notification command to Plex Media Server @ ' + host)
|
logger.info('Sending notification command to Plex client @ ' + host)
|
||||||
try:
|
try:
|
||||||
|
version = \
|
||||||
|
self._sendjson(host, 'Application.GetProperties', {'properties': ['version']})[
|
||||||
|
'version']['major']
|
||||||
|
|
||||||
|
if version < 12: # Eden
|
||||||
notification = header + "," + message + "," + time + "," + albumartpath
|
notification = header + "," + message + "," + time + "," + albumartpath
|
||||||
notifycommand = {'command': 'ExecBuiltIn', 'parameter': 'Notification(' + notification + ')'}
|
notifycommand = {'command': 'ExecBuiltIn',
|
||||||
|
'parameter': 'Notification(' + notification + ')'}
|
||||||
request = self._sendhttp(host, notifycommand)
|
request = self._sendhttp(host, notifycommand)
|
||||||
|
|
||||||
|
else: # Frodo
|
||||||
|
params = {'title': header, 'message': message, 'displaytime': int(time),
|
||||||
|
'image': albumartpath}
|
||||||
|
request = self._sendjson(host, 'GUI.ShowNotification', params)
|
||||||
|
|
||||||
if not request:
|
if not request:
|
||||||
raise Exception
|
raise Exception
|
||||||
|
|
||||||
except:
|
except Exception:
|
||||||
logger.warn('Error sending notification request to Plex Media Server')
|
logger.error('Error sending notification request to Plex client @ ' + host)
|
||||||
|
|
||||||
|
|
||||||
class NMA(object):
|
class NMA(object):
|
||||||
@@ -435,61 +444,37 @@ class NMA(object):
|
|||||||
|
|
||||||
|
|
||||||
class PUSHBULLET(object):
|
class PUSHBULLET(object):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.apikey = headphones.CONFIG.PUSHBULLET_APIKEY
|
self.apikey = headphones.CONFIG.PUSHBULLET_APIKEY
|
||||||
self.deviceid = headphones.CONFIG.PUSHBULLET_DEVICEID
|
self.deviceid = headphones.CONFIG.PUSHBULLET_DEVICEID
|
||||||
|
|
||||||
def conf(self, options):
|
def notify(self, message, status):
|
||||||
return cherrypy.config['config'].get('PUSHBULLET', options)
|
|
||||||
|
|
||||||
def notify(self, message, event):
|
|
||||||
if not headphones.CONFIG.PUSHBULLET_ENABLED:
|
if not headphones.CONFIG.PUSHBULLET_ENABLED:
|
||||||
return
|
return
|
||||||
|
|
||||||
http_handler = HTTPSConnection("api.pushbullet.com")
|
url = "https://api.pushbullet.com/v2/pushes"
|
||||||
|
|
||||||
data = {'device_iden': headphones.CONFIG.PUSHBULLET_DEVICEID,
|
data = {'type': "note",
|
||||||
'type': "note",
|
|
||||||
'title': "Headphones",
|
'title': "Headphones",
|
||||||
'body': message.encode("utf-8")}
|
'body': message + ': ' + status}
|
||||||
|
|
||||||
http_handler.request("POST",
|
if self.deviceid:
|
||||||
"/api/pushes",
|
data['device_iden'] = self.deviceid
|
||||||
headers={'Content-type': "application/x-www-form-urlencoded",
|
|
||||||
'Authorization': 'Basic %s' % base64.b64encode(headphones.CONFIG.PUSHBULLET_APIKEY + ":")},
|
|
||||||
body=urlencode(data))
|
|
||||||
response = http_handler.getresponse()
|
|
||||||
request_status = response.status
|
|
||||||
logger.debug(u"PushBullet response status: %r" % request_status)
|
|
||||||
logger.debug(u"PushBullet response headers: %r" % response.getheaders())
|
|
||||||
logger.debug(u"PushBullet response body: %r" % response.read())
|
|
||||||
|
|
||||||
if request_status == 200:
|
headers = {'Content-type': "application/json",
|
||||||
|
'Authorization': 'Bearer ' + headphones.CONFIG.PUSHBULLET_APIKEY}
|
||||||
|
|
||||||
|
response = request.request_json(url, method="post", headers=headers, data=json.dumps(data))
|
||||||
|
|
||||||
|
if response:
|
||||||
logger.info(u"PushBullet notifications sent.")
|
logger.info(u"PushBullet notifications sent.")
|
||||||
return True
|
return True
|
||||||
elif request_status >= 400 and request_status < 500:
|
|
||||||
logger.info(u"PushBullet request failed: %s" % response.reason)
|
|
||||||
return False
|
|
||||||
else:
|
else:
|
||||||
logger.info(u"PushBullet notification failed serverside.")
|
logger.info(u"PushBullet notification failed.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def updateLibrary(self):
|
|
||||||
#For uniformity reasons not removed
|
|
||||||
return
|
|
||||||
|
|
||||||
def test(self, apikey, deviceid):
|
|
||||||
|
|
||||||
self.enabled = True
|
|
||||||
self.apikey = apikey
|
|
||||||
self.deviceid = deviceid
|
|
||||||
|
|
||||||
self.notify('Main Screen Activate', 'Test Message')
|
|
||||||
|
|
||||||
|
|
||||||
class PUSHALOT(object):
|
class PUSHALOT(object):
|
||||||
|
|
||||||
def notify(self, message, event):
|
def notify(self, message, event):
|
||||||
if not headphones.CONFIG.PUSHALOT_ENABLED:
|
if not headphones.CONFIG.PUSHALOT_ENABLED:
|
||||||
return
|
return
|
||||||
@@ -539,7 +524,8 @@ class Synoindex(object):
|
|||||||
path = os.path.abspath(path)
|
path = os.path.abspath(path)
|
||||||
|
|
||||||
if not self.util_exists():
|
if not self.util_exists():
|
||||||
logger.warn("Error sending notification: synoindex utility not found at %s" % self.util_loc)
|
logger.warn(
|
||||||
|
"Error sending notification: synoindex utility not found at %s" % self.util_loc)
|
||||||
return
|
return
|
||||||
|
|
||||||
if os.path.isfile(path):
|
if os.path.isfile(path):
|
||||||
@@ -547,13 +533,15 @@ class Synoindex(object):
|
|||||||
elif os.path.isdir(path):
|
elif os.path.isdir(path):
|
||||||
cmd_arg = '-A'
|
cmd_arg = '-A'
|
||||||
else:
|
else:
|
||||||
logger.warn("Error sending notification: Path passed to synoindex was not a file or folder.")
|
logger.warn(
|
||||||
|
"Error sending notification: Path passed to synoindex was not a file or folder.")
|
||||||
return
|
return
|
||||||
|
|
||||||
cmd = [self.util_loc, cmd_arg, path]
|
cmd = [self.util_loc, cmd_arg, path]
|
||||||
logger.info("Calling synoindex command: %s" % str(cmd))
|
logger.info("Calling synoindex command: %s" % str(cmd))
|
||||||
try:
|
try:
|
||||||
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=headphones.PROG_DIR)
|
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
|
cwd=headphones.PROG_DIR)
|
||||||
out, error = p.communicate()
|
out, error = p.communicate()
|
||||||
# synoindex never returns any codes other than '0', highly irritating
|
# synoindex never returns any codes other than '0', highly irritating
|
||||||
except OSError, e:
|
except OSError, e:
|
||||||
@@ -566,7 +554,6 @@ class Synoindex(object):
|
|||||||
|
|
||||||
|
|
||||||
class PUSHOVER(object):
|
class PUSHOVER(object):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.enabled = headphones.CONFIG.PUSHOVER_ENABLED
|
self.enabled = headphones.CONFIG.PUSHOVER_ENABLED
|
||||||
self.keys = headphones.CONFIG.PUSHOVER_KEYS
|
self.keys = headphones.CONFIG.PUSHOVER_KEYS
|
||||||
@@ -584,7 +571,7 @@ class PUSHOVER(object):
|
|||||||
if not headphones.CONFIG.PUSHOVER_ENABLED:
|
if not headphones.CONFIG.PUSHOVER_ENABLED:
|
||||||
return
|
return
|
||||||
|
|
||||||
http_handler = HTTPSConnection("api.pushover.net")
|
url = "https://api.pushover.net/1/messages.json"
|
||||||
|
|
||||||
data = {'token': self.application_token,
|
data = {'token': self.application_token,
|
||||||
'user': headphones.CONFIG.PUSHOVER_KEYS,
|
'user': headphones.CONFIG.PUSHOVER_KEYS,
|
||||||
@@ -592,24 +579,15 @@ class PUSHOVER(object):
|
|||||||
'message': message.encode("utf-8"),
|
'message': message.encode("utf-8"),
|
||||||
'priority': headphones.CONFIG.PUSHOVER_PRIORITY}
|
'priority': headphones.CONFIG.PUSHOVER_PRIORITY}
|
||||||
|
|
||||||
http_handler.request("POST",
|
headers = {'Content-type': "application/x-www-form-urlencoded"}
|
||||||
"/1/messages.json",
|
|
||||||
headers={'Content-type': "application/x-www-form-urlencoded"},
|
|
||||||
body=urlencode(data))
|
|
||||||
response = http_handler.getresponse()
|
|
||||||
request_status = response.status
|
|
||||||
logger.debug(u"Pushover response status: %r" % request_status)
|
|
||||||
logger.debug(u"Pushover response headers: %r" % response.getheaders())
|
|
||||||
logger.debug(u"Pushover response body: %r" % response.read())
|
|
||||||
|
|
||||||
if request_status == 200:
|
response = request.request_response(url, method="POST", headers=headers, data=data)
|
||||||
|
|
||||||
|
if response:
|
||||||
logger.info(u"Pushover notifications sent.")
|
logger.info(u"Pushover notifications sent.")
|
||||||
return True
|
return True
|
||||||
elif request_status >= 400 and request_status < 500:
|
|
||||||
logger.info(u"Pushover request failed: %s" % response.reason)
|
|
||||||
return False
|
|
||||||
else:
|
else:
|
||||||
logger.info(u"Pushover notification failed.")
|
logger.error(u"Pushover notification failed.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def updateLibrary(self):
|
def updateLibrary(self):
|
||||||
@@ -625,7 +603,6 @@ class PUSHOVER(object):
|
|||||||
|
|
||||||
|
|
||||||
class TwitterNotifier(object):
|
class TwitterNotifier(object):
|
||||||
|
|
||||||
REQUEST_TOKEN_URL = 'https://api.twitter.com/oauth/request_token'
|
REQUEST_TOKEN_URL = 'https://api.twitter.com/oauth/request_token'
|
||||||
ACCESS_TOKEN_URL = 'https://api.twitter.com/oauth/access_token'
|
ACCESS_TOKEN_URL = 'https://api.twitter.com/oauth/access_token'
|
||||||
AUTHORIZATION_URL = 'https://api.twitter.com/oauth/authorize'
|
AUTHORIZATION_URL = 'https://api.twitter.com/oauth/authorize'
|
||||||
@@ -637,14 +614,17 @@ class TwitterNotifier(object):
|
|||||||
|
|
||||||
def notify_snatch(self, title):
|
def notify_snatch(self, title):
|
||||||
if headphones.CONFIG.TWITTER_ONSNATCH:
|
if headphones.CONFIG.TWITTER_ONSNATCH:
|
||||||
self._notifyTwitter(common.notifyStrings[common.NOTIFY_SNATCH] + ': ' + title + ' at ' + helpers.now())
|
self._notifyTwitter(
|
||||||
|
common.notifyStrings[common.NOTIFY_SNATCH] + ': ' + title + ' at ' + helpers.now())
|
||||||
|
|
||||||
def notify_download(self, title):
|
def notify_download(self, title):
|
||||||
if headphones.CONFIG.TWITTER_ENABLED:
|
if headphones.CONFIG.TWITTER_ENABLED:
|
||||||
self._notifyTwitter(common.notifyStrings[common.NOTIFY_DOWNLOAD] + ': ' + title + ' at ' + helpers.now())
|
self._notifyTwitter(common.notifyStrings[
|
||||||
|
common.NOTIFY_DOWNLOAD] + ': ' + title + ' at ' + helpers.now())
|
||||||
|
|
||||||
def test_notify(self):
|
def test_notify(self):
|
||||||
return self._notifyTwitter("This is a test notification from Headphones at " + helpers.now(), force=True)
|
return self._notifyTwitter(
|
||||||
|
"This is a test notification from Headphones at " + helpers.now(), force=True)
|
||||||
|
|
||||||
def _get_authorization(self):
|
def _get_authorization(self):
|
||||||
|
|
||||||
@@ -681,7 +661,8 @@ class TwitterNotifier(object):
|
|||||||
logger.info('oauth_consumer: ' + str(oauth_consumer))
|
logger.info('oauth_consumer: ' + str(oauth_consumer))
|
||||||
oauth_client = oauth.Client(oauth_consumer, token)
|
oauth_client = oauth.Client(oauth_consumer, token)
|
||||||
logger.info('oauth_client: ' + str(oauth_client))
|
logger.info('oauth_client: ' + str(oauth_client))
|
||||||
resp, content = oauth_client.request(self.ACCESS_TOKEN_URL, method='POST', body='oauth_verifier=%s' % key)
|
resp, content = oauth_client.request(self.ACCESS_TOKEN_URL, method='POST',
|
||||||
|
body='oauth_verifier=%s' % key)
|
||||||
logger.info('resp, content: ' + str(resp) + ',' + str(content))
|
logger.info('resp, content: ' + str(resp) + ',' + str(content))
|
||||||
|
|
||||||
access_token = dict(parse_qsl(content))
|
access_token = dict(parse_qsl(content))
|
||||||
@@ -689,7 +670,8 @@ class TwitterNotifier(object):
|
|||||||
|
|
||||||
logger.info('resp[status] = ' + str(resp['status']))
|
logger.info('resp[status] = ' + str(resp['status']))
|
||||||
if resp['status'] != '200':
|
if resp['status'] != '200':
|
||||||
logger.info('The request for a token with did not succeed: ' + str(resp['status']), logger.ERROR)
|
logger.info('The request for a token with did not succeed: ' + str(resp['status']),
|
||||||
|
logger.ERROR)
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
logger.info('Your Twitter Access Token key: %s' % access_token['oauth_token'])
|
logger.info('Your Twitter Access Token key: %s' % access_token['oauth_token'])
|
||||||
@@ -727,28 +709,37 @@ class TwitterNotifier(object):
|
|||||||
|
|
||||||
|
|
||||||
class OSX_NOTIFY(object):
|
class OSX_NOTIFY(object):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
try:
|
try:
|
||||||
self.objc = __import__("objc")
|
self.objc = __import__("objc")
|
||||||
|
self.AppKit = __import__("AppKit")
|
||||||
except:
|
except:
|
||||||
|
logger.warn('OS X Notification: Cannot import objc or AppKit')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def swizzle(self, cls, SEL, func):
|
def swizzle(self, cls, SEL, func):
|
||||||
|
old_IMP = getattr(cls, SEL, None)
|
||||||
|
if old_IMP is None:
|
||||||
old_IMP = cls.instanceMethodForSelector_(SEL)
|
old_IMP = cls.instanceMethodForSelector_(SEL)
|
||||||
|
|
||||||
def wrapper(self, *args, **kwargs):
|
def wrapper(self, *args, **kwargs):
|
||||||
return func(self, old_IMP, *args, **kwargs)
|
return func(self, old_IMP, *args, **kwargs)
|
||||||
new_IMP = self.objc.selector(wrapper, selector=old_IMP.selector,
|
|
||||||
signature=old_IMP.signature)
|
|
||||||
self.objc.classAddMethod(cls, SEL, new_IMP)
|
|
||||||
|
|
||||||
def notify(self, title, subtitle=None, text=None, sound=True):
|
new_IMP = self.objc.selector(
|
||||||
|
wrapper,
|
||||||
|
selector=old_IMP.selector,
|
||||||
|
signature=old_IMP.signature
|
||||||
|
)
|
||||||
|
self.objc.classAddMethod(cls, SEL.encode(), new_IMP)
|
||||||
|
|
||||||
|
def notify(self, title, subtitle=None, text=None, sound=True, image=None):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.swizzle(self.objc.lookUpClass('NSBundle'),
|
self.swizzle(
|
||||||
b'bundleIdentifier',
|
self.objc.lookUpClass('NSBundle'),
|
||||||
self.swizzled_bundleIdentifier)
|
'bundleIdentifier',
|
||||||
|
self.swizzled_bundleIdentifier
|
||||||
|
)
|
||||||
|
|
||||||
NSUserNotification = self.objc.lookUpClass('NSUserNotification')
|
NSUserNotification = self.objc.lookUpClass('NSUserNotification')
|
||||||
NSUserNotificationCenter = self.objc.lookUpClass('NSUserNotificationCenter')
|
NSUserNotificationCenter = self.objc.lookUpClass('NSUserNotificationCenter')
|
||||||
@@ -767,6 +758,10 @@ class OSX_NOTIFY(object):
|
|||||||
notification.setInformativeText_(text)
|
notification.setInformativeText_(text)
|
||||||
if sound:
|
if sound:
|
||||||
notification.setSoundName_("NSUserNotificationDefaultSoundName")
|
notification.setSoundName_("NSUserNotificationDefaultSoundName")
|
||||||
|
if image:
|
||||||
|
source_img = self.AppKit.NSImage.alloc().initByReferencingFile_(image)
|
||||||
|
notification.setContentImage_(source_img)
|
||||||
|
# notification.set_identityImage_(source_img)
|
||||||
notification.setHasActionButton_(False)
|
notification.setHasActionButton_(False)
|
||||||
|
|
||||||
notification_center = NSUserNotificationCenter.defaultUserNotificationCenter()
|
notification_center = NSUserNotificationCenter.defaultUserNotificationCenter()
|
||||||
@@ -784,7 +779,6 @@ class OSX_NOTIFY(object):
|
|||||||
|
|
||||||
|
|
||||||
class BOXCAR(object):
|
class BOXCAR(object):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.url = 'https://new.boxcar.io/api/notifications'
|
self.url = 'https://new.boxcar.io/api/notifications'
|
||||||
|
|
||||||
@@ -811,7 +805,6 @@ class BOXCAR(object):
|
|||||||
|
|
||||||
|
|
||||||
class SubSonicNotifier(object):
|
class SubSonicNotifier(object):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.host = headphones.CONFIG.SUBSONIC_HOST
|
self.host = headphones.CONFIG.SUBSONIC_HOST
|
||||||
self.username = headphones.CONFIG.SUBSONIC_USERNAME
|
self.username = headphones.CONFIG.SUBSONIC_USERNAME
|
||||||
@@ -828,3 +821,71 @@ class SubSonicNotifier(object):
|
|||||||
# Invoke request
|
# Invoke request
|
||||||
request.request_response(self.host + "musicFolderSettings.view?scanNow",
|
request.request_response(self.host + "musicFolderSettings.view?scanNow",
|
||||||
auth=(self.username, self.password))
|
auth=(self.username, self.password))
|
||||||
|
|
||||||
|
|
||||||
|
class Email(object):
|
||||||
|
def notify(self, subject, message):
|
||||||
|
|
||||||
|
message = MIMEText(message, 'plain', "utf-8")
|
||||||
|
message['Subject'] = subject
|
||||||
|
message['From'] = email.utils.formataddr(('Headphones', headphones.CONFIG.EMAIL_FROM))
|
||||||
|
message['To'] = headphones.CONFIG.EMAIL_TO
|
||||||
|
|
||||||
|
try:
|
||||||
|
if headphones.CONFIG.EMAIL_SSL:
|
||||||
|
mailserver = smtplib.SMTP_SSL(headphones.CONFIG.EMAIL_SMTP_SERVER,
|
||||||
|
headphones.CONFIG.EMAIL_SMTP_PORT)
|
||||||
|
else:
|
||||||
|
mailserver = smtplib.SMTP(headphones.CONFIG.EMAIL_SMTP_SERVER,
|
||||||
|
headphones.CONFIG.EMAIL_SMTP_PORT)
|
||||||
|
|
||||||
|
if headphones.CONFIG.EMAIL_TLS:
|
||||||
|
mailserver.starttls()
|
||||||
|
|
||||||
|
mailserver.ehlo()
|
||||||
|
|
||||||
|
if headphones.CONFIG.EMAIL_SMTP_USER:
|
||||||
|
mailserver.login(headphones.CONFIG.EMAIL_SMTP_USER,
|
||||||
|
headphones.CONFIG.EMAIL_SMTP_PASSWORD)
|
||||||
|
|
||||||
|
mailserver.sendmail(headphones.CONFIG.EMAIL_FROM, headphones.CONFIG.EMAIL_TO,
|
||||||
|
message.as_string())
|
||||||
|
mailserver.quit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception, e:
|
||||||
|
logger.warn('Error sending Email: %s' % e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class TELEGRAM(object):
|
||||||
|
|
||||||
|
def notify(self, message, status):
|
||||||
|
if not headphones.CONFIG.TELEGRAM_ENABLED:
|
||||||
|
return
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
TELEGRAM_API = "https://api.telegram.org/bot%s/%s"
|
||||||
|
|
||||||
|
# Get configuration data
|
||||||
|
token = headphones.CONFIG.TELEGRAM_TOKEN
|
||||||
|
userid = headphones.CONFIG.TELEGRAM_USERID
|
||||||
|
|
||||||
|
# Construct message
|
||||||
|
payload = {'chat_id': userid, 'text': status + ': ' + message}
|
||||||
|
|
||||||
|
# Send message to user using Telegram's Bot API
|
||||||
|
try:
|
||||||
|
response = requests.post(TELEGRAM_API % (token, "sendMessage"), data=payload)
|
||||||
|
except Exception, e:
|
||||||
|
logger.info(u'Telegram notify failed: ' + str(e))
|
||||||
|
|
||||||
|
# Error logging
|
||||||
|
sent_successfuly = True
|
||||||
|
if not response.status_code == 200:
|
||||||
|
logger.info(u'Could not send notification to TelegramBot (token=%s). Response: [%s]', (token, response.text))
|
||||||
|
sent_successfuly = False
|
||||||
|
|
||||||
|
logger.info(u"Telegram notifications sent.")
|
||||||
|
return sent_successfuly
|
||||||
|
|||||||
+40
-24
@@ -19,47 +19,49 @@
|
|||||||
# along with Sick Beard. If not, see <http://www.gnu.org/licenses/>.
|
# along with Sick Beard. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
import httplib
|
|
||||||
|
|
||||||
import headphones
|
|
||||||
|
|
||||||
from base64 import standard_b64encode
|
from base64 import standard_b64encode
|
||||||
|
import httplib
|
||||||
import xmlrpclib
|
import xmlrpclib
|
||||||
|
|
||||||
|
import headphones
|
||||||
from headphones import logger
|
from headphones import logger
|
||||||
|
|
||||||
|
|
||||||
def sendNZB(nzb):
|
def sendNZB(nzb):
|
||||||
|
|
||||||
addToTop = False
|
addToTop = False
|
||||||
nzbgetXMLrpc = "%(username)s:%(password)s@%(host)s/xmlrpc"
|
nzbgetXMLrpc = "%(protocol)s://%(username)s:%(password)s@%(host)s/xmlrpc"
|
||||||
|
|
||||||
if headphones.CONFIG.NZBGET_HOST is None:
|
if not headphones.CONFIG.NZBGET_HOST:
|
||||||
logger.error(u"No NZBget host found in configuration. Please configure it.")
|
logger.error(u"No NZBget host found in configuration. Please configure it.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if headphones.CONFIG.NZBGET_HOST.startswith('https://'):
|
if headphones.CONFIG.NZBGET_HOST.startswith('https://'):
|
||||||
nzbgetXMLrpc = 'https://' + nzbgetXMLrpc
|
protocol = 'https'
|
||||||
headphones.CONFIG.NZBGET_HOST.replace('https://', '', 1)
|
host = headphones.CONFIG.NZBGET_HOST.replace('https://', '', 1)
|
||||||
else:
|
else:
|
||||||
nzbgetXMLrpc = 'http://' + nzbgetXMLrpc
|
protocol = 'http'
|
||||||
headphones.CONFIG.NZBGET_HOST.replace('http://', '', 1)
|
host = headphones.CONFIG.NZBGET_HOST.replace('http://', '', 1)
|
||||||
|
|
||||||
url = nzbgetXMLrpc % {"host": headphones.CONFIG.NZBGET_HOST, "username": headphones.CONFIG.NZBGET_USERNAME, "password": headphones.CONFIG.NZBGET_PASSWORD}
|
url = nzbgetXMLrpc % {"protocol": protocol, "host": host,
|
||||||
|
"username": headphones.CONFIG.NZBGET_USERNAME,
|
||||||
|
"password": headphones.CONFIG.NZBGET_PASSWORD}
|
||||||
|
|
||||||
nzbGetRPC = xmlrpclib.ServerProxy(url)
|
nzbGetRPC = xmlrpclib.ServerProxy(url)
|
||||||
try:
|
try:
|
||||||
if nzbGetRPC.writelog("INFO", "headphones connected to drop of %s any moment now." % (nzb.name + ".nzb")):
|
if nzbGetRPC.writelog("INFO", "headphones connected to drop of %s any moment now." % (
|
||||||
|
nzb.name + ".nzb")):
|
||||||
logger.debug(u"Successfully connected to NZBget")
|
logger.debug(u"Successfully connected to NZBget")
|
||||||
else:
|
else:
|
||||||
logger.info(u"Successfully connected to NZBget, but unable to send a message" % (nzb.name + ".nzb"))
|
logger.info(u"Successfully connected to NZBget, but unable to send a message" % (
|
||||||
|
nzb.name + ".nzb"))
|
||||||
|
|
||||||
except httplib.socket.error:
|
except httplib.socket.error:
|
||||||
logger.error(u"Please check your NZBget host and port (if it is running). NZBget is not responding to this combination")
|
logger.error(
|
||||||
|
u"Please check your NZBget host and port (if it is running). NZBget is not responding to this combination")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except xmlrpclib.ProtocolError, e:
|
except xmlrpclib.ProtocolError, e:
|
||||||
if (e.errmsg == "Unauthorized"):
|
if e.errmsg == "Unauthorized":
|
||||||
logger.error(u"NZBget password is incorrect.")
|
logger.error(u"NZBget password is incorrect.")
|
||||||
else:
|
else:
|
||||||
logger.error(u"Protocol Error: " + e.errmsg)
|
logger.error(u"Protocol Error: " + e.errmsg)
|
||||||
@@ -82,7 +84,9 @@ def sendNZB(nzb):
|
|||||||
nzbget_version = int(nzbget_version_str[:nzbget_version_str.find(".")])
|
nzbget_version = int(nzbget_version_str[:nzbget_version_str.find(".")])
|
||||||
if nzbget_version == 0:
|
if nzbget_version == 0:
|
||||||
if nzbcontent64 is not None:
|
if nzbcontent64 is not None:
|
||||||
nzbget_result = nzbGetRPC.append(nzb.name + ".nzb", headphones.CONFIG.NZBGET_CATEGORY, addToTop, nzbcontent64)
|
nzbget_result = nzbGetRPC.append(nzb.name + ".nzb",
|
||||||
|
headphones.CONFIG.NZBGET_CATEGORY, addToTop,
|
||||||
|
nzbcontent64)
|
||||||
else:
|
else:
|
||||||
# from headphones.common.providers.generic import GenericProvider
|
# from headphones.common.providers.generic import GenericProvider
|
||||||
# if nzb.resultType == "nzb":
|
# if nzb.resultType == "nzb":
|
||||||
@@ -95,24 +99,35 @@ def sendNZB(nzb):
|
|||||||
return False
|
return False
|
||||||
elif nzbget_version == 12:
|
elif nzbget_version == 12:
|
||||||
if nzbcontent64 is not None:
|
if nzbcontent64 is not None:
|
||||||
nzbget_result = nzbGetRPC.append(nzb.name + ".nzb", headphones.CONFIG.NZBGET_CATEGORY, headphones.CONFIG.NZBGET_PRIORITY, False,
|
nzbget_result = nzbGetRPC.append(nzb.name + ".nzb",
|
||||||
|
headphones.CONFIG.NZBGET_CATEGORY,
|
||||||
|
headphones.CONFIG.NZBGET_PRIORITY, False,
|
||||||
nzbcontent64, False, dupekey, dupescore, "score")
|
nzbcontent64, False, dupekey, dupescore, "score")
|
||||||
else:
|
else:
|
||||||
nzbget_result = nzbGetRPC.appendurl(nzb.name + ".nzb", headphones.CONFIG.NZBGET_CATEGORY, headphones.CONFIG.NZBGET_PRIORITY, False,
|
nzbget_result = nzbGetRPC.appendurl(nzb.name + ".nzb",
|
||||||
|
headphones.CONFIG.NZBGET_CATEGORY,
|
||||||
|
headphones.CONFIG.NZBGET_PRIORITY, False,
|
||||||
nzb.url, False, dupekey, dupescore, "score")
|
nzb.url, False, dupekey, dupescore, "score")
|
||||||
# v13+ has a new combined append method that accepts both (url and content)
|
# v13+ has a new combined append method that accepts both (url and content)
|
||||||
# also the return value has changed from boolean to integer
|
# also the return value has changed from boolean to integer
|
||||||
# (Positive number representing NZBID of the queue item. 0 and negative numbers represent error codes.)
|
# (Positive number representing NZBID of the queue item. 0 and negative numbers represent error codes.)
|
||||||
elif nzbget_version >= 13:
|
elif nzbget_version >= 13:
|
||||||
nzbget_result = True if nzbGetRPC.append(nzb.name + ".nzb", nzbcontent64 if nzbcontent64 is not None else nzb.url,
|
nzbget_result = True if nzbGetRPC.append(nzb.name + ".nzb",
|
||||||
headphones.CONFIG.NZBGET_CATEGORY, headphones.CONFIG.NZBGET_PRIORITY, False, False, dupekey, dupescore,
|
nzbcontent64 if nzbcontent64 is not None else nzb.url,
|
||||||
|
headphones.CONFIG.NZBGET_CATEGORY,
|
||||||
|
headphones.CONFIG.NZBGET_PRIORITY, False,
|
||||||
|
False, dupekey, dupescore,
|
||||||
"score") > 0 else False
|
"score") > 0 else False
|
||||||
else:
|
else:
|
||||||
if nzbcontent64 is not None:
|
if nzbcontent64 is not None:
|
||||||
nzbget_result = nzbGetRPC.append(nzb.name + ".nzb", headphones.CONFIG.NZBGET_CATEGORY, headphones.CONFIG.NZBGET_PRIORITY, False,
|
nzbget_result = nzbGetRPC.append(nzb.name + ".nzb",
|
||||||
|
headphones.CONFIG.NZBGET_CATEGORY,
|
||||||
|
headphones.CONFIG.NZBGET_PRIORITY, False,
|
||||||
nzbcontent64)
|
nzbcontent64)
|
||||||
else:
|
else:
|
||||||
nzbget_result = nzbGetRPC.appendurl(nzb.name + ".nzb", headphones.CONFIG.NZBGET_CATEGORY, headphones.CONFIG.NZBGET_PRIORITY, False,
|
nzbget_result = nzbGetRPC.appendurl(nzb.name + ".nzb",
|
||||||
|
headphones.CONFIG.NZBGET_CATEGORY,
|
||||||
|
headphones.CONFIG.NZBGET_PRIORITY, False,
|
||||||
nzb.url)
|
nzb.url)
|
||||||
|
|
||||||
if nzbget_result:
|
if nzbget_result:
|
||||||
@@ -122,5 +137,6 @@ def sendNZB(nzb):
|
|||||||
logger.error(u"NZBget could not add %s to the queue" % (nzb.name + ".nzb"))
|
logger.error(u"NZBget could not add %s to the queue" % (nzb.name + ".nzb"))
|
||||||
return False
|
return False
|
||||||
except:
|
except:
|
||||||
logger.error(u"Connect Error to NZBget: could not add %s to the queue" % (nzb.name + ".nzb"))
|
logger.error(
|
||||||
|
u"Connect Error to NZBget: could not add %s to the queue" % (nzb.name + ".nzb"))
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
# encoding=utf8
|
||||||
|
# This file is part of Headphones.
|
||||||
|
#
|
||||||
|
# Headphones is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Headphones is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
'''Path pattern substitution module, see details below for syntax.
|
||||||
|
|
||||||
|
The pattern matching is loosely based on foobar2000 pattern syntax,
|
||||||
|
i.e. the notion of escaping characters with \' and optional elements
|
||||||
|
enclosed in square brackets [] is taken from there while the
|
||||||
|
substitution variable names are Perl-ish or sh-ish. The following
|
||||||
|
syntax elements are supported:
|
||||||
|
* escaped literal strings, that is everything that is enclosed
|
||||||
|
within single quotes (like \'this\');
|
||||||
|
* substitution variables, which start with dollar sign ($) and
|
||||||
|
extend until next non-alphanumeric+underscore character
|
||||||
|
(like $This and $5_that).
|
||||||
|
* optional elements enclosed in curly braces, which render
|
||||||
|
nonempty value only if any variable or optional inside returned
|
||||||
|
nonempty value, ignoring literals (like {\'[\'$That\']\'}).
|
||||||
|
'''
|
||||||
|
from __future__ import print_function
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
__author__ = "Andrzej Ciarkowski <andrzej.ciarkowski@gmail.com>"
|
||||||
|
|
||||||
|
|
||||||
|
class _PatternElement(object):
|
||||||
|
'''ABC for hierarchy of path name renderer pattern elements.'''
|
||||||
|
def render(self, replacement):
|
||||||
|
# type: (Mapping[str,str]) -> str
|
||||||
|
'''Format this _PatternElement into string using provided substitution dictionary.'''
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
|
class _Generator(_PatternElement):
|
||||||
|
# pylint: disable=abstract-method
|
||||||
|
'''Tagging interface for "content-generating" elements like replacement or optional block.'''
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _Replacement(_Generator):
|
||||||
|
'''Replacement variable, eg. $title.'''
|
||||||
|
def __init__(self, pattern):
|
||||||
|
# type: (str)
|
||||||
|
self._pattern = pattern
|
||||||
|
|
||||||
|
def render(self, replacement):
|
||||||
|
# type: (Mapping[str,str]) -> str
|
||||||
|
return replacement.get(self._pattern, self._pattern)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self._pattern
|
||||||
|
|
||||||
|
|
||||||
|
class _LiteralText(_PatternElement):
|
||||||
|
'''Just a plain piece of text to be rendered "as is".'''
|
||||||
|
def __init__(self, text):
|
||||||
|
# type: (str)
|
||||||
|
self._text = text
|
||||||
|
|
||||||
|
def render(self, replacement):
|
||||||
|
# type: (Mapping[str,str]) -> str
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
|
||||||
|
class _OptionalBlock(_Generator):
|
||||||
|
'''Optional block will render its contents only if any _Generator in its scope did return non-empty result.'''
|
||||||
|
|
||||||
|
def __init__(self, scope):
|
||||||
|
# type: ([_PatternElement])
|
||||||
|
self._scope = scope
|
||||||
|
|
||||||
|
def render(self, replacement):
|
||||||
|
# type: (Mapping[str,str]) -> str
|
||||||
|
res = [(isinstance(x, _Generator), x.render(replacement)) for x in self._scope]
|
||||||
|
if any((t[0] and len(t[1]) != 0) for t in res):
|
||||||
|
return u"".join(t[1] for t in res)
|
||||||
|
else:
|
||||||
|
return u""
|
||||||
|
|
||||||
|
|
||||||
|
_OPTIONAL_START = u'{'
|
||||||
|
_OPTIONAL_END = u'}'
|
||||||
|
_ESCAPE_CHAR = u'\''
|
||||||
|
_REPLACEMENT_START = u'$'
|
||||||
|
|
||||||
|
|
||||||
|
def _is_replacement_valid(c):
|
||||||
|
# type: (str) -> bool
|
||||||
|
return c.isalnum() or c == u'_'
|
||||||
|
|
||||||
|
|
||||||
|
class _State(Enum):
|
||||||
|
LITERAL = 0
|
||||||
|
ESCAPE = 1
|
||||||
|
REPLACEMENT = 2
|
||||||
|
|
||||||
|
|
||||||
|
def _append_literal(scope, text):
|
||||||
|
# type: ([_PatternElement], str) -> None
|
||||||
|
'''Append literal text to the scope BUT ONLY if it's not an empty string.'''
|
||||||
|
if len(text) == 0:
|
||||||
|
return
|
||||||
|
scope.append(_LiteralText(text))
|
||||||
|
|
||||||
|
|
||||||
|
class Warnings(Enum):
|
||||||
|
'''Pattern parsing warnings, as stored withing warnings property of Pattern object after parsing.'''
|
||||||
|
UNCLOSED_ESCAPE = 'Warnings.UNCLOSED_ESCAPE'
|
||||||
|
UNCLOSED_OPTIONAL = 'Warnings.UNCLOSED_OPTIONAL'
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_pattern(pattern, warnings):
|
||||||
|
# type: (str,MutableSet[Warnings]) -> [_PatternElement]
|
||||||
|
'''Parse path pattern text into list of _PatternElements, put warnings into the provided set.'''
|
||||||
|
start = 0 # index of current state start char
|
||||||
|
root_scope = [] # here our _PatternElements will reside
|
||||||
|
scope_stack = [root_scope] # stack so that we can return to the outer scope
|
||||||
|
scope = root_scope # pointer to the current list for _OptionalBlock
|
||||||
|
inside_optional = 0 # nesting level of _OptionalBlocks
|
||||||
|
state = _State.LITERAL # current state
|
||||||
|
for i, c in enumerate(pattern):
|
||||||
|
if state is _State.ESCAPE:
|
||||||
|
if c != _ESCAPE_CHAR:
|
||||||
|
# only escape char can get us out of _State.ESCAPE
|
||||||
|
continue
|
||||||
|
_append_literal(scope, pattern[start + 1:i])
|
||||||
|
state = _State.LITERAL
|
||||||
|
start = i + 1
|
||||||
|
# after exiting _State.ESCAPE on escape char no more processing of c
|
||||||
|
continue
|
||||||
|
if state is _State.REPLACEMENT:
|
||||||
|
if _is_replacement_valid(c):
|
||||||
|
# only replacement invalid can get us out _State.REPLACEMENT
|
||||||
|
continue
|
||||||
|
scope.append(_Replacement(pattern[start:i]))
|
||||||
|
state = _State.LITERAL
|
||||||
|
start = i
|
||||||
|
# intentional fall-through to _State.LITERAL
|
||||||
|
assert state is _State.LITERAL
|
||||||
|
if c == _ESCAPE_CHAR:
|
||||||
|
_append_literal(scope, pattern[start:i])
|
||||||
|
state = _State.ESCAPE
|
||||||
|
start = i
|
||||||
|
# no more processing to escape char c
|
||||||
|
continue
|
||||||
|
if c == _REPLACEMENT_START:
|
||||||
|
_append_literal(scope, pattern[start:i])
|
||||||
|
state = _State.REPLACEMENT
|
||||||
|
start = i
|
||||||
|
# no more processing to replacement char c
|
||||||
|
continue
|
||||||
|
if c == _OPTIONAL_START:
|
||||||
|
_append_literal(scope, pattern[start:i])
|
||||||
|
inside_optional += 1
|
||||||
|
new_scope = []
|
||||||
|
scope_stack.append(new_scope)
|
||||||
|
scope = new_scope
|
||||||
|
start = i + 1
|
||||||
|
continue
|
||||||
|
if c == _OPTIONAL_END:
|
||||||
|
if inside_optional == 0:
|
||||||
|
# no optional block to end, just treat as literal text
|
||||||
|
continue
|
||||||
|
inside_optional -= 1
|
||||||
|
_append_literal(scope, pattern[start:i])
|
||||||
|
scope_stack.pop()
|
||||||
|
prev_scope = scope_stack[-1]
|
||||||
|
prev_scope.append(_OptionalBlock(scope))
|
||||||
|
scope = prev_scope
|
||||||
|
start = i + 1
|
||||||
|
# fi
|
||||||
|
# done
|
||||||
|
if state is _State.ESCAPE:
|
||||||
|
warnings.add(Warnings.UNCLOSED_ESCAPE)
|
||||||
|
if inside_optional != 0:
|
||||||
|
warnings.add(Warnings.UNCLOSED_OPTIONAL)
|
||||||
|
if state is _State.REPLACEMENT:
|
||||||
|
root_scope.append(_Replacement(pattern[start:]))
|
||||||
|
else:
|
||||||
|
# don't care about unclosed elements :P
|
||||||
|
_append_literal(root_scope, pattern[start:])
|
||||||
|
return root_scope
|
||||||
|
|
||||||
|
|
||||||
|
class Pattern(object):
|
||||||
|
'''Stores preparsed rename pattern for repeated use.
|
||||||
|
|
||||||
|
If using the same pattern repeatedly it is much more effective
|
||||||
|
to parse the pattern into Pattern object and use it instead of
|
||||||
|
parsing the textual pattern on each substitution. To use Pattern
|
||||||
|
object for substitution simply call it as it was function
|
||||||
|
providing dictionary as an argument (see __call__()).'''
|
||||||
|
|
||||||
|
def __init__(self, pattern):
|
||||||
|
# type: (str)
|
||||||
|
self._warnings = set()
|
||||||
|
self._pattern = _parse_pattern(pattern, self._warnings)
|
||||||
|
|
||||||
|
def __call__(self, replacement):
|
||||||
|
# type: (Mapping[str,str]) -> str
|
||||||
|
'''Execute path rendering/substitution based on replacement dictionary.'''
|
||||||
|
return u"".join(p.render(replacement) for p in self._pattern)
|
||||||
|
|
||||||
|
def _get_warnings(self):
|
||||||
|
# type: () -> str
|
||||||
|
'''Getter for warnings property.'''
|
||||||
|
return self._warnings
|
||||||
|
|
||||||
|
warnings = property(_get_warnings, doc="Access warnings raised during pattern parsing")
|
||||||
|
|
||||||
|
|
||||||
|
def render(pattern, replacement):
|
||||||
|
# type: (str, Mapping[str,str]) -> (str, AbstractSet[Warnings])
|
||||||
|
'''Render path name based on replacement pattern and dictionary.'''
|
||||||
|
p = Pattern(pattern)
|
||||||
|
return p(replacement), p.warnings
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# primitive test ;)
|
||||||
|
p = Pattern(u"[$Disc.]$Track - $Artist - $Title[ '['$Year']'")
|
||||||
|
d = {'$Disc': '', '$Track': '05', '$Artist': u'Grzegżółka', '$Title': u'Błona kapłona', '$Year': '2019'}
|
||||||
|
print(p(d).encode('utf8'), p.warnings)
|
||||||
+358
-156
File diff suppressed because it is too large
Load Diff
+53
-47
@@ -13,26 +13,31 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from headphones import logger
|
|
||||||
|
|
||||||
from xml.dom import minidom
|
from xml.dom import minidom
|
||||||
from bs4 import BeautifulSoup
|
import collections
|
||||||
|
|
||||||
import time
|
import sys
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
import requests
|
import requests
|
||||||
|
from headphones import logger
|
||||||
import feedparser
|
import feedparser
|
||||||
import headphones
|
import headphones
|
||||||
import collections
|
import headphones.lock
|
||||||
|
|
||||||
|
|
||||||
|
# Disable SSL certificate warnings. We have our own handling
|
||||||
|
requests.packages.urllib3.disable_warnings()
|
||||||
|
|
||||||
# Dictionary with last request times, for rate limiting.
|
# Dictionary with last request times, for rate limiting.
|
||||||
last_requests = collections.defaultdict(int)
|
last_requests = collections.defaultdict(int)
|
||||||
|
fake_lock = headphones.lock.FakeLock()
|
||||||
|
|
||||||
|
|
||||||
def request_response(url, method="get", auto_raise=True,
|
def request_response(url, method="get", auto_raise=True,
|
||||||
whitelist_status_code=None, rate_limit=None, **kwargs):
|
whitelist_status_code=None, lock=fake_lock, **kwargs):
|
||||||
"""
|
"""
|
||||||
Convenient wrapper for `requests.get', which will capture the exceptions and
|
Convenient wrapper for `requests.get', which will capture the exceptions
|
||||||
log them. On success, the Response object is returned. In case of a
|
and log them. On success, the Response object is returned. In case of a
|
||||||
exception, None is returned.
|
exception, None is returned.
|
||||||
|
|
||||||
Additionally, there is support for rate limiting. To use this feature,
|
Additionally, there is support for rate limiting. To use this feature,
|
||||||
@@ -50,39 +55,23 @@ def request_response(url, method="get", auto_raise=True,
|
|||||||
# pose a security issue!
|
# pose a security issue!
|
||||||
kwargs["verify"] = bool(headphones.CONFIG.VERIFY_SSL_CERT)
|
kwargs["verify"] = bool(headphones.CONFIG.VERIFY_SSL_CERT)
|
||||||
|
|
||||||
# Map method to the request.XXX method. This is a simple hack, but it allows
|
#This fix is put in place for systems with broken SSL (like QNAP)
|
||||||
# requests to apply more magic per method. See lib/requests/api.py.
|
if not headphones.CONFIG.VERIFY_SSL_CERT and sys.version_info >= (2, 7, 9):
|
||||||
|
try:
|
||||||
|
import ssl
|
||||||
|
ssl._create_default_https_context = ssl._create_unverified_context
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Map method to the request.XXX method. This is a simple hack, but it
|
||||||
|
# allows requests to apply more magic per method. See lib/requests/api.py.
|
||||||
request_method = getattr(requests, method.lower())
|
request_method = getattr(requests, method.lower())
|
||||||
|
|
||||||
# Enfore request rate limit if applicable. This uses the lock so there
|
|
||||||
# is synchronized access to the API. When N threads enter this method, the
|
|
||||||
# first will pass trough, since there there was no last request recorded.
|
|
||||||
# The last request time will be set. Then, the second thread will unlock,
|
|
||||||
# and see that the last request was X seconds ago. It will sleep
|
|
||||||
# (request_limit - X) seconds, and then continue. Then the third one will
|
|
||||||
# unblock, and so on. After all threads finished, the total time will at
|
|
||||||
# least be (N * request_limit) seconds. If some request takes longer than
|
|
||||||
# request_limit seconds, the next unblocked thread will wait less.
|
|
||||||
if rate_limit:
|
|
||||||
lock, request_limit = rate_limit
|
|
||||||
|
|
||||||
with lock:
|
|
||||||
delta = time.time() - last_requests[lock]
|
|
||||||
limit = int(1.0 / request_limit)
|
|
||||||
|
|
||||||
if delta < request_limit:
|
|
||||||
logger.debug("Sleeping %.2f seconds for request, limit " \
|
|
||||||
"is %d req/sec.", request_limit - delta, limit)
|
|
||||||
|
|
||||||
# Sleep the remaining time
|
|
||||||
time.sleep(request_limit - delta)
|
|
||||||
|
|
||||||
# Update last request time.
|
|
||||||
last_requests[lock] = time.time()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Request URL and wait for response
|
# Request URL and wait for response
|
||||||
logger.debug("Requesting URL via %s method: %s", method.upper(), url)
|
with lock:
|
||||||
|
logger.debug(
|
||||||
|
"Requesting URL via %s method: %s", method.upper(), url)
|
||||||
response = request_method(url, **kwargs)
|
response = request_method(url, **kwargs)
|
||||||
|
|
||||||
# If status code != OK, then raise exception, except if the status code
|
# If status code != OK, then raise exception, except if the status code
|
||||||
@@ -92,19 +81,34 @@ def request_response(url, method="get", auto_raise=True,
|
|||||||
try:
|
try:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except:
|
except:
|
||||||
logger.debug("Response status code %d is not white " \
|
logger.debug(
|
||||||
|
"Response status code %d is not white "
|
||||||
"listed, raised exception", response.status_code)
|
"listed, raised exception", response.status_code)
|
||||||
raise
|
raise
|
||||||
elif auto_raise:
|
elif auto_raise:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
except requests.exceptions.SSLError as e:
|
||||||
|
if kwargs["verify"]:
|
||||||
|
logger.error(
|
||||||
|
"Unable to connect to remote host because of a SSL error. "
|
||||||
|
"It is likely that your system cannot verify the validity"
|
||||||
|
"of the certificate. The remote certificate is either "
|
||||||
|
"self-signed, or the remote server uses SNI. See the wiki for "
|
||||||
|
"more information on this topic.")
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"SSL error raised during connection, with certificate "
|
||||||
|
"verification turned off: %s", e)
|
||||||
except requests.ConnectionError:
|
except requests.ConnectionError:
|
||||||
logger.error("Unable to connect to remote host. Check if the remote " \
|
logger.error(
|
||||||
|
"Unable to connect to remote host. Check if the remote "
|
||||||
"host is up and running.")
|
"host is up and running.")
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
logger.error("Request timed out. The remote host did not respeond " \
|
logger.error(
|
||||||
"timely.")
|
"Request timed out. The remote host did not respond in a timely "
|
||||||
|
"manner.")
|
||||||
except requests.HTTPError as e:
|
except requests.HTTPError as e:
|
||||||
if e.response is not None:
|
if e.response is not None:
|
||||||
if e.response.status_code >= 500:
|
if e.response.status_code >= 500:
|
||||||
@@ -115,7 +119,8 @@ def request_response(url, method="get", auto_raise=True,
|
|||||||
# I don't think we will end up here, but for completeness
|
# I don't think we will end up here, but for completeness
|
||||||
cause = "unknown"
|
cause = "unknown"
|
||||||
|
|
||||||
logger.error("Request raise HTTP error with status code %d (%s).",
|
logger.error(
|
||||||
|
"Request raise HTTP error with status code %d (%s).",
|
||||||
e.response.status_code, cause)
|
e.response.status_code, cause)
|
||||||
|
|
||||||
# Debug response
|
# Debug response
|
||||||
@@ -157,8 +162,8 @@ def request_json(url, **kwargs):
|
|||||||
Wrapper for `request_response', which will decode the response as JSON
|
Wrapper for `request_response', which will decode the response as JSON
|
||||||
object and return the result, if no exceptions are raised.
|
object and return the result, if no exceptions are raised.
|
||||||
|
|
||||||
As an option, a validator callback can be given, which should return True if
|
As an option, a validator callback can be given, which should return True
|
||||||
the result is valid.
|
if the result is valid.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
validator = kwargs.pop("validator", None)
|
validator = kwargs.pop("validator", None)
|
||||||
@@ -214,7 +219,8 @@ def server_message(response):
|
|||||||
message = None
|
message = None
|
||||||
|
|
||||||
# First attempt is to 'read' the response as HTML
|
# First attempt is to 'read' the response as HTML
|
||||||
if "text/html" in response.headers.get("content-type"):
|
if response.headers.get("content-type") and \
|
||||||
|
"text/html" in response.headers.get("content-type"):
|
||||||
try:
|
try:
|
||||||
soup = BeautifulSoup(response.content, "html5lib")
|
soup = BeautifulSoup(response.content, "html5lib")
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -223,9 +229,9 @@ def server_message(response):
|
|||||||
# Find body and cleanup common tags to grab content, which probably
|
# Find body and cleanup common tags to grab content, which probably
|
||||||
# contains the message.
|
# contains the message.
|
||||||
message = soup.find("body")
|
message = soup.find("body")
|
||||||
|
elements = ("header", "script", "footer", "nav", "input", "textarea")
|
||||||
|
|
||||||
for element in ["header", "script", "footer", "nav", "input",
|
for element in elements:
|
||||||
"textarea"]:
|
|
||||||
|
|
||||||
for tag in soup.find_all(element):
|
for tag in soup.find_all(element):
|
||||||
tag.replaceWith("")
|
tag.replaceWith("")
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
import urllib
|
||||||
|
import time
|
||||||
|
from urlparse import urlparse
|
||||||
|
|
||||||
|
import re
|
||||||
|
import requests as requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
import headphones
|
||||||
|
from headphones import logger
|
||||||
|
|
||||||
|
|
||||||
|
class Rutracker(object):
|
||||||
|
def __init__(self):
|
||||||
|
self.session = requests.session()
|
||||||
|
self.timeout = 60
|
||||||
|
self.loggedin = False
|
||||||
|
self.maxsize = 0
|
||||||
|
self.search_referer = 'http://rutracker.org/forum/tracker.php'
|
||||||
|
|
||||||
|
def logged_in(self):
|
||||||
|
return self.loggedin
|
||||||
|
|
||||||
|
def still_logged_in(self, html):
|
||||||
|
if not html or "action=\"http://login.rutracker.org/forum/login.php\">" in html:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def login(self):
|
||||||
|
"""
|
||||||
|
Logs in user
|
||||||
|
"""
|
||||||
|
|
||||||
|
loginpage = 'http://login.rutracker.org/forum/login.php'
|
||||||
|
post_params = {
|
||||||
|
'login_username': headphones.CONFIG.RUTRACKER_USER,
|
||||||
|
'login_password': headphones.CONFIG.RUTRACKER_PASSWORD,
|
||||||
|
'login': b'\xc2\xf5\xee\xe4' # '%C2%F5%EE%E4'
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Attempting to log in to rutracker...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = self.session.post(loginpage, data=post_params, timeout=self.timeout)
|
||||||
|
# try again
|
||||||
|
if 'bb_data' not in r.cookies.keys():
|
||||||
|
time.sleep(10)
|
||||||
|
r = self.session.post(loginpage, data=post_params, timeout=self.timeout)
|
||||||
|
if r.status_code != 200:
|
||||||
|
logger.error("rutracker login returned status code %s" % r.status_code)
|
||||||
|
self.loggedin = False
|
||||||
|
else:
|
||||||
|
if 'bb_data' in r.cookies.keys():
|
||||||
|
self.loggedin = True
|
||||||
|
logger.info("Successfully logged in to rutracker")
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"Could not login to rutracker, credentials maybe incorrect, site is down or too many attempts. Try again later")
|
||||||
|
self.loggedin = False
|
||||||
|
return self.loggedin
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Unknown error logging in to rutracker: %s" % e)
|
||||||
|
self.loggedin = False
|
||||||
|
return self.loggedin
|
||||||
|
|
||||||
|
def searchurl(self, artist, album, year, format):
|
||||||
|
"""
|
||||||
|
Return the search url
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Build search url
|
||||||
|
searchterm = ''
|
||||||
|
if artist != 'Various Artists':
|
||||||
|
searchterm = artist
|
||||||
|
searchterm = searchterm + ' '
|
||||||
|
searchterm = searchterm + album
|
||||||
|
searchterm = searchterm + ' '
|
||||||
|
searchterm = searchterm + year
|
||||||
|
|
||||||
|
if format == 'lossless':
|
||||||
|
format = '+lossless'
|
||||||
|
self.maxsize = 10000000000
|
||||||
|
elif format == 'lossless+mp3':
|
||||||
|
format = '+lossless||mp3||aac'
|
||||||
|
self.maxsize = 10000000000
|
||||||
|
else:
|
||||||
|
format = '+mp3||aac'
|
||||||
|
self.maxsize = 300000000
|
||||||
|
|
||||||
|
# sort by size, descending.
|
||||||
|
sort = '&o=7&s=2'
|
||||||
|
|
||||||
|
searchurl = "%s?nm=%s%s%s" % (self.search_referer, urllib.quote(searchterm), format, sort)
|
||||||
|
|
||||||
|
logger.info("Searching rutracker using term: %s", searchterm)
|
||||||
|
|
||||||
|
return searchurl
|
||||||
|
|
||||||
|
def search(self, searchurl):
|
||||||
|
"""
|
||||||
|
Parse the search results and return valid torrent list
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
headers = {'Referer': self.search_referer}
|
||||||
|
r = self.session.get(url=searchurl, headers=headers, timeout=self.timeout)
|
||||||
|
|
||||||
|
soup = BeautifulSoup(r.content, 'html5lib')
|
||||||
|
|
||||||
|
# Debug
|
||||||
|
# logger.debug (soup.prettify())
|
||||||
|
|
||||||
|
# Check if still logged in
|
||||||
|
if not self.still_logged_in(soup):
|
||||||
|
self.login()
|
||||||
|
r = self.session.get(url=searchurl, timeout=self.timeout)
|
||||||
|
soup = BeautifulSoup(r.content, 'html5lib')
|
||||||
|
if not self.still_logged_in(soup):
|
||||||
|
logger.error("Error getting rutracker data")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Process
|
||||||
|
rulist = []
|
||||||
|
i = soup.find('table', id='tor-tbl')
|
||||||
|
if not i:
|
||||||
|
logger.info("No valid results found from rutracker")
|
||||||
|
return None
|
||||||
|
minimumseeders = int(headphones.CONFIG.NUMBEROFSEEDERS) - 1
|
||||||
|
|
||||||
|
for item in zip(i.find_all(class_='hl-tags'), i.find_all(class_='dl-stub'),
|
||||||
|
i.find_all(class_='seedmed')):
|
||||||
|
title = item[0].get_text()
|
||||||
|
url = item[1].get('href')
|
||||||
|
size_formatted = item[1].get_text()[:-2]
|
||||||
|
seeds = item[2].get_text()
|
||||||
|
size_parts = size_formatted.split()
|
||||||
|
size = float(size_parts[0])
|
||||||
|
|
||||||
|
if size_parts[1] == 'KB':
|
||||||
|
size *= 1024
|
||||||
|
if size_parts[1] == 'MB':
|
||||||
|
size *= 1024 ** 2
|
||||||
|
if size_parts[1] == 'GB':
|
||||||
|
size *= 1024 ** 3
|
||||||
|
if size_parts[1] == 'TB':
|
||||||
|
size *= 1024 ** 4
|
||||||
|
|
||||||
|
if size < self.maxsize and minimumseeders < int(seeds):
|
||||||
|
logger.info('Found %s. Size: %s' % (title, size_formatted))
|
||||||
|
# Torrent topic page
|
||||||
|
torrent_id = dict([part.split('=') for part in urlparse(url)[4].split('&')])[
|
||||||
|
't']
|
||||||
|
topicurl = 'http://rutracker.org/forum/viewtopic.php?t=' + torrent_id
|
||||||
|
rulist.append((title, size, topicurl, 'rutracker.org', 'torrent', True))
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"%s is larger than the maxsize or has too little seeders for this category, skipping. (Size: %i bytes, Seeders: %i)" % (
|
||||||
|
title, size, int(seeds)))
|
||||||
|
|
||||||
|
if not rulist:
|
||||||
|
logger.info("No valid results found from rutracker")
|
||||||
|
|
||||||
|
return rulist
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("An unknown error occurred in the rutracker parser: %s" % e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_torrent_data(self, url):
|
||||||
|
"""
|
||||||
|
return the .torrent data
|
||||||
|
"""
|
||||||
|
|
||||||
|
torrent_id = dict([part.split('=') for part in urlparse(url)[4].split('&')])['t']
|
||||||
|
downloadurl = 'http://dl.rutracker.org/forum/dl.php?t=' + torrent_id
|
||||||
|
cookie = {'bb_dl': torrent_id}
|
||||||
|
try:
|
||||||
|
headers = {'Referer': url}
|
||||||
|
r = self.session.post(url=downloadurl, cookies=cookie, headers=headers,
|
||||||
|
timeout=self.timeout)
|
||||||
|
return r.content
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error getting torrent: %s', e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# TODO get this working in utorrent.py
|
||||||
|
def utorrent_add_file(self, data):
|
||||||
|
|
||||||
|
host = headphones.CONFIG.UTORRENT_HOST
|
||||||
|
if not host.startswith('http'):
|
||||||
|
host = 'http://' + host
|
||||||
|
if host.endswith('/'):
|
||||||
|
host = host[:-1]
|
||||||
|
if host.endswith('/gui'):
|
||||||
|
host = host[:-4]
|
||||||
|
|
||||||
|
base_url = host
|
||||||
|
|
||||||
|
url = base_url + '/gui/'
|
||||||
|
self.session.auth = (
|
||||||
|
headphones.CONFIG.UTORRENT_USERNAME, headphones.CONFIG.UTORRENT_PASSWORD)
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = self.session.get(url + 'token.html')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error getting token: %s', e)
|
||||||
|
return
|
||||||
|
|
||||||
|
if r.status_code == 401:
|
||||||
|
logger.debug('Error reaching utorrent')
|
||||||
|
return
|
||||||
|
|
||||||
|
regex = re.search(r'.+>([^<]+)</div></html>', r.text)
|
||||||
|
if regex is None:
|
||||||
|
logger.debug('Error reading token')
|
||||||
|
return
|
||||||
|
|
||||||
|
self.session.params = {'token': regex.group(1)}
|
||||||
|
files = {'torrent_file': ("", data)}
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.session.post(url, params={'action': 'add-file'}, files=files)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('Error adding file to utorrent %s', e)
|
||||||
+46
-91
@@ -13,26 +13,26 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
#####################################
|
###################################
|
||||||
## Stolen from Sick-Beard's sab.py ##
|
# Stolen from Sick-Beard's sab.py #
|
||||||
#####################################
|
###################################
|
||||||
|
|
||||||
import MultipartPostHandler
|
|
||||||
import headphones
|
|
||||||
import cookielib
|
import cookielib
|
||||||
import urllib2
|
|
||||||
import httplib
|
|
||||||
import urllib
|
|
||||||
import ast
|
|
||||||
|
|
||||||
|
import headphones
|
||||||
from headphones.common import USER_AGENT
|
from headphones.common import USER_AGENT
|
||||||
from headphones import logger
|
from headphones import logger, helpers, request
|
||||||
from headphones import helpers
|
|
||||||
|
|
||||||
|
|
||||||
def sendNZB(nzb):
|
def sab_api_call(request_type=None, params={}, **kwargs):
|
||||||
|
if not headphones.CONFIG.SAB_HOST.startswith('http'):
|
||||||
|
headphones.CONFIG.SAB_HOST = 'http://' + headphones.CONFIG.SAB_HOST
|
||||||
|
|
||||||
params = {}
|
if headphones.CONFIG.SAB_HOST.endswith('/'):
|
||||||
|
headphones.CONFIG.SAB_HOST = headphones.CONFIG.SAB_HOST[
|
||||||
|
0:len(headphones.CONFIG.SAB_HOST) - 1]
|
||||||
|
|
||||||
|
url = headphones.CONFIG.SAB_HOST + "/" + "api?"
|
||||||
|
|
||||||
if headphones.CONFIG.SAB_USERNAME:
|
if headphones.CONFIG.SAB_USERNAME:
|
||||||
params['ma_username'] = headphones.CONFIG.SAB_USERNAME
|
params['ma_username'] = headphones.CONFIG.SAB_USERNAME
|
||||||
@@ -40,9 +40,24 @@ def sendNZB(nzb):
|
|||||||
params['ma_password'] = headphones.CONFIG.SAB_PASSWORD
|
params['ma_password'] = headphones.CONFIG.SAB_PASSWORD
|
||||||
if headphones.CONFIG.SAB_APIKEY:
|
if headphones.CONFIG.SAB_APIKEY:
|
||||||
params['apikey'] = headphones.CONFIG.SAB_APIKEY
|
params['apikey'] = headphones.CONFIG.SAB_APIKEY
|
||||||
if headphones.CONFIG.SAB_CATEGORY:
|
|
||||||
|
if request_type == 'send_nzb' and headphones.CONFIG.SAB_CATEGORY:
|
||||||
params['cat'] = headphones.CONFIG.SAB_CATEGORY
|
params['cat'] = headphones.CONFIG.SAB_CATEGORY
|
||||||
|
|
||||||
|
params['output'] = 'json'
|
||||||
|
|
||||||
|
response = request.request_json(url, params=params, **kwargs)
|
||||||
|
|
||||||
|
if not response:
|
||||||
|
logger.error("Error connecting to SABnzbd on url: %s" % headphones.CONFIG.SAB_HOST)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.debug("Successfully connected to SABnzbd on url: %s" % headphones.CONFIG.SAB_HOST)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def sendNZB(nzb):
|
||||||
|
params = {}
|
||||||
# if it's a normal result we just pass SAB the URL
|
# if it's a normal result we just pass SAB the URL
|
||||||
if nzb.resultType == "nzb":
|
if nzb.resultType == "nzb":
|
||||||
# for newzbin results send the ID to sab specifically
|
# for newzbin results send the ID to sab specifically
|
||||||
@@ -62,102 +77,42 @@ def sendNZB(nzb):
|
|||||||
# Sanitize the file a bit, since we can only use ascii chars with MultiPartPostHandler
|
# Sanitize the file a bit, since we can only use ascii chars with MultiPartPostHandler
|
||||||
nzbdata = helpers.latinToAscii(nzb.extraInfo[0])
|
nzbdata = helpers.latinToAscii(nzb.extraInfo[0])
|
||||||
params['mode'] = 'addfile'
|
params['mode'] = 'addfile'
|
||||||
multiPartParams = {"nzbfile": (helpers.latinToAscii(nzb.name) + ".nzb", nzbdata)}
|
files = {"nzbfile": (helpers.latinToAscii(nzb.name) + ".nzb", nzbdata)}
|
||||||
|
headers = {'User-Agent': USER_AGENT}
|
||||||
if not headphones.CONFIG.SAB_HOST.startswith('http'):
|
|
||||||
headphones.CONFIG.SAB_HOST = 'http://' + headphones.CONFIG.SAB_HOST
|
|
||||||
|
|
||||||
if headphones.CONFIG.SAB_HOST.endswith('/'):
|
|
||||||
headphones.CONFIG.SAB_HOST = headphones.CONFIG.SAB_HOST[0:len(headphones.CONFIG.SAB_HOST) - 1]
|
|
||||||
|
|
||||||
url = headphones.CONFIG.SAB_HOST + "/" + "api?" + urllib.urlencode(params)
|
|
||||||
|
|
||||||
try:
|
|
||||||
|
|
||||||
|
logger.info("Attempting to connect to SABnzbd on url: %s" % headphones.CONFIG.SAB_HOST)
|
||||||
if nzb.resultType == "nzb":
|
if nzb.resultType == "nzb":
|
||||||
f = urllib.urlopen(url)
|
response = sab_api_call('send_nzb', params=params)
|
||||||
elif nzb.resultType == "nzbdata":
|
elif nzb.resultType == "nzbdata":
|
||||||
cookies = cookielib.CookieJar()
|
cookies = cookielib.CookieJar()
|
||||||
opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cookies),
|
response = sab_api_call('send_nzb', params=params, method="post", files=files,
|
||||||
MultipartPostHandler.MultipartPostHandler)
|
cookies=cookies, headers=headers)
|
||||||
|
|
||||||
req = urllib2.Request(url,
|
if not response:
|
||||||
multiPartParams,
|
|
||||||
headers={'User-Agent': USER_AGENT})
|
|
||||||
|
|
||||||
f = opener.open(req)
|
|
||||||
|
|
||||||
except (EOFError, IOError) as e:
|
|
||||||
logger.error(u"Unable to connect to SAB with URL: %s" % url)
|
|
||||||
return False
|
|
||||||
|
|
||||||
except httplib.InvalidURL as e:
|
|
||||||
logger.error(u"Invalid SAB host, check your config. Current host: %s" % headphones.CONFIG.SAB_HOST)
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(u"Error: " + str(e))
|
|
||||||
return False
|
|
||||||
|
|
||||||
if f is None:
|
|
||||||
logger.info(u"No data returned from SABnzbd, NZB not sent")
|
logger.info(u"No data returned from SABnzbd, NZB not sent")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
if response['status']:
|
||||||
result = f.readlines()
|
logger.info(u"NZB sent to SABnzbd successfully")
|
||||||
except Exception as e:
|
|
||||||
logger.info(u"Error trying to get result from SAB, NZB not sent: ")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if len(result) == 0:
|
|
||||||
logger.info(u"No data returned from SABnzbd, NZB not sent")
|
|
||||||
return False
|
|
||||||
|
|
||||||
sabText = result[0].strip()
|
|
||||||
|
|
||||||
logger.info(u"Result text from SAB: " + sabText)
|
|
||||||
|
|
||||||
if sabText == "ok":
|
|
||||||
logger.info(u"NZB sent to SAB successfully")
|
|
||||||
return True
|
return True
|
||||||
elif sabText == "Missing authentication":
|
|
||||||
logger.info(u"Incorrect username/password sent to SAB, NZB not sent")
|
|
||||||
return False
|
|
||||||
else:
|
else:
|
||||||
logger.info(u"Unknown failure sending NZB to sab. Return text is: " + sabText)
|
logger.error(u"Error sending NZB to SABnzbd: %s" % response['error'])
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def checkConfig():
|
def checkConfig():
|
||||||
|
|
||||||
params = {'mode': 'get_config',
|
params = {'mode': 'get_config',
|
||||||
'section': 'misc'
|
'section': 'misc',
|
||||||
}
|
}
|
||||||
|
|
||||||
if headphones.CONFIG.SAB_USERNAME:
|
config_options = sab_api_call(params=params)
|
||||||
params['ma_username'] = headphones.CONFIG.SAB_USERNAME
|
|
||||||
if headphones.CONFIG.SAB_PASSWORD:
|
|
||||||
params['ma_password'] = headphones.CONFIG.SAB_PASSWORD
|
|
||||||
if headphones.CONFIG.SAB_APIKEY:
|
|
||||||
params['apikey'] = headphones.CONFIG.SAB_APIKEY
|
|
||||||
|
|
||||||
if not headphones.CONFIG.SAB_HOST.startswith('http'):
|
if not config_options:
|
||||||
headphones.CONFIG.SAB_HOST = 'http://' + headphones.CONFIG.SAB_HOST
|
logger.warn(
|
||||||
|
"Unable to read SABnzbd config file - cannot determine renaming options (might affect auto & forced post processing)")
|
||||||
if headphones.CONFIG.SAB_HOST.endswith('/'):
|
|
||||||
headphones.CONFIG.SAB_HOST = headphones.CONFIG.SAB_HOST[0:len(headphones.CONFIG.SAB_HOST) - 1]
|
|
||||||
|
|
||||||
url = headphones.CONFIG.SAB_HOST + "/" + "api?" + urllib.urlencode(params)
|
|
||||||
|
|
||||||
try:
|
|
||||||
f = urllib.urlopen(url).read()
|
|
||||||
except Exception:
|
|
||||||
logger.warn("Unable to read SABnzbd config file - cannot determine renaming options (might affect auto & forced post processing)")
|
|
||||||
return (0, 0)
|
return (0, 0)
|
||||||
|
|
||||||
config_options = ast.literal_eval(f)
|
replace_spaces = config_options['config']['misc']['replace_spaces']
|
||||||
|
replace_dots = config_options['config']['misc']['replace_dots']
|
||||||
replace_spaces = config_options['misc']['replace_spaces']
|
|
||||||
replace_dots = config_options['misc']['replace_dots']
|
|
||||||
|
|
||||||
return (replace_spaces, replace_dots)
|
return (replace_spaces, replace_dots)
|
||||||
|
|||||||
+556
-231
File diff suppressed because it is too large
Load Diff
@@ -1,349 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# coding=utf-8
|
|
||||||
|
|
||||||
# Headphones rutracker.org search
|
|
||||||
# Functions called from searcher.py
|
|
||||||
|
|
||||||
from bencode import bencode as bencode, bdecode
|
|
||||||
from urlparse import urlparse
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from tempfile import mkdtemp
|
|
||||||
from hashlib import sha1
|
|
||||||
|
|
||||||
import headphones
|
|
||||||
import requests
|
|
||||||
import cookielib
|
|
||||||
import urllib2
|
|
||||||
import urllib
|
|
||||||
import re
|
|
||||||
import os
|
|
||||||
|
|
||||||
from headphones import db, logger
|
|
||||||
|
|
||||||
|
|
||||||
class Rutracker():
|
|
||||||
|
|
||||||
logged_in = False
|
|
||||||
|
|
||||||
# Stores a number of login attempts to prevent recursion.
|
|
||||||
#login_counter = 0
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
|
|
||||||
self.cookiejar = cookielib.CookieJar()
|
|
||||||
self.opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(self.cookiejar))
|
|
||||||
urllib2.install_opener(self.opener)
|
|
||||||
|
|
||||||
def login(self, login, password):
|
|
||||||
"""Implements tracker login procedure."""
|
|
||||||
|
|
||||||
self.logged_in = False
|
|
||||||
|
|
||||||
if login is None or password is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
#self.login_counter += 1
|
|
||||||
|
|
||||||
# No recursion wanted.
|
|
||||||
#if self.login_counter > 1:
|
|
||||||
# return False
|
|
||||||
|
|
||||||
params = urllib.urlencode({"login_username": login,
|
|
||||||
"login_password": password,
|
|
||||||
"login": "Вход"})
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.opener.open("http://login.rutracker.org/forum/login.php", params)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check if we're logged in
|
|
||||||
for cookie in self.cookiejar:
|
|
||||||
if cookie.name == 'bb_data':
|
|
||||||
self.logged_in = True
|
|
||||||
|
|
||||||
return self.logged_in
|
|
||||||
|
|
||||||
def searchurl(self, artist, album, year, format):
|
|
||||||
"""
|
|
||||||
Return the search url
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Build search url
|
|
||||||
searchterm = ''
|
|
||||||
if artist != 'Various Artists':
|
|
||||||
searchterm = artist
|
|
||||||
searchterm = searchterm + ' '
|
|
||||||
searchterm = searchterm + album
|
|
||||||
searchterm = searchterm + ' '
|
|
||||||
searchterm = searchterm + year
|
|
||||||
|
|
||||||
providerurl = "http://rutracker.org/forum/tracker.php"
|
|
||||||
|
|
||||||
if format == 'lossless':
|
|
||||||
format = '+lossless'
|
|
||||||
elif format == 'lossless+mp3':
|
|
||||||
format = '+lossless||mp3||aac'
|
|
||||||
else:
|
|
||||||
format = '+mp3||aac'
|
|
||||||
|
|
||||||
# sort by size, descending.
|
|
||||||
sort = '&o=7&s=2'
|
|
||||||
|
|
||||||
searchurl = "%s?nm=%s%s%s" % (providerurl, urllib.quote(searchterm), format, sort)
|
|
||||||
|
|
||||||
return searchurl
|
|
||||||
|
|
||||||
def search(self, searchurl, maxsize, minseeders, albumid):
|
|
||||||
"""
|
|
||||||
Parse the search results and return valid torrent list
|
|
||||||
"""
|
|
||||||
|
|
||||||
titles = []
|
|
||||||
urls = []
|
|
||||||
seeders = []
|
|
||||||
sizes = []
|
|
||||||
torrentlist = []
|
|
||||||
rulist = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
|
|
||||||
page = self.opener.open(searchurl, timeout=60)
|
|
||||||
soup = BeautifulSoup(page.read())
|
|
||||||
|
|
||||||
# Debug
|
|
||||||
#logger.debug (soup.prettify())
|
|
||||||
|
|
||||||
# Title
|
|
||||||
for link in soup.find_all('a', attrs={'class': 'med tLink hl-tags bold'}):
|
|
||||||
title = link.get_text()
|
|
||||||
titles.append(title)
|
|
||||||
|
|
||||||
# Download URL
|
|
||||||
for link in soup.find_all('a', attrs={'class': 'small tr-dl dl-stub'}):
|
|
||||||
url = link.get('href')
|
|
||||||
urls.append(url)
|
|
||||||
|
|
||||||
# Seeders
|
|
||||||
for link in soup.find_all('b', attrs={'class': 'seedmed'}):
|
|
||||||
seeder = link.get_text()
|
|
||||||
seeders.append(seeder)
|
|
||||||
|
|
||||||
# Size
|
|
||||||
for link in soup.find_all('td', attrs={'class': 'row4 small nowrap tor-size'}):
|
|
||||||
size = link.u.string
|
|
||||||
sizes.append(size)
|
|
||||||
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Combine lists
|
|
||||||
torrentlist = zip(titles, urls, seeders, sizes)
|
|
||||||
|
|
||||||
# return if nothing found
|
|
||||||
if not torrentlist:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# don't bother checking track counts anymore, let searcher filter instead
|
|
||||||
# leave code in just in case
|
|
||||||
check_track_count = False
|
|
||||||
|
|
||||||
if check_track_count:
|
|
||||||
|
|
||||||
# get headphones track count for album, return if not found
|
|
||||||
myDB = db.DBConnection()
|
|
||||||
tracks = myDB.select('SELECT * from tracks WHERE AlbumID=?', [albumid])
|
|
||||||
hptrackcount = len(tracks)
|
|
||||||
|
|
||||||
if not hptrackcount:
|
|
||||||
logger.info('headphones track info not found, cannot compare to torrent')
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Return all valid entries, ignored, required words now checked in searcher.py
|
|
||||||
|
|
||||||
#unwantedlist = ['promo', 'vinyl', '[lp]', 'songbook', 'tvrip', 'hdtv', 'dvd']
|
|
||||||
|
|
||||||
formatlist = ['ape', 'flac', 'ogg', 'm4a', 'aac', 'mp3', 'wav', 'aif']
|
|
||||||
deluxelist = ['deluxe', 'edition', 'japanese', 'exclusive']
|
|
||||||
|
|
||||||
for torrent in torrentlist:
|
|
||||||
|
|
||||||
returntitle = torrent[0].encode('utf-8')
|
|
||||||
url = torrent[1]
|
|
||||||
seeders = torrent[2]
|
|
||||||
size = torrent[3]
|
|
||||||
|
|
||||||
if int(size) <= maxsize and int(seeders) >= minseeders:
|
|
||||||
|
|
||||||
#Torrent topic page
|
|
||||||
torrent_id = dict([part.split('=') for part in urlparse(url)[4].split('&')])['t']
|
|
||||||
topicurl = 'http://rutracker.org/forum/viewtopic.php?t=' + torrent_id
|
|
||||||
|
|
||||||
# add to list
|
|
||||||
if not check_track_count:
|
|
||||||
valid = True
|
|
||||||
else:
|
|
||||||
|
|
||||||
# Check torrent info
|
|
||||||
self.cookiejar.set_cookie(cookielib.Cookie(version=0, name='bb_dl', value=torrent_id, port=None, port_specified=False, domain='.rutracker.org', domain_specified=False, domain_initial_dot=False, path='/', path_specified=True, secure=False, expires=None, discard=True, comment=None, comment_url=None, rest={'HttpOnly': None}, rfc2109=False))
|
|
||||||
|
|
||||||
# Debug
|
|
||||||
#for cookie in self.cookiejar:
|
|
||||||
# logger.debug ('Cookie: %s' % cookie)
|
|
||||||
|
|
||||||
try:
|
|
||||||
page = self.opener.open(url)
|
|
||||||
torrent = page.read()
|
|
||||||
if torrent:
|
|
||||||
decoded = bdecode(torrent)
|
|
||||||
metainfo = decoded['info']
|
|
||||||
page.close()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Error getting torrent: %s' % e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# get torrent track count and check for cue
|
|
||||||
trackcount = 0
|
|
||||||
cuecount = 0
|
|
||||||
|
|
||||||
if 'files' in metainfo: # multi
|
|
||||||
for pathfile in metainfo['files']:
|
|
||||||
path = pathfile['path']
|
|
||||||
for file in path:
|
|
||||||
if any(file.lower().endswith('.' + x.lower()) for x in formatlist):
|
|
||||||
trackcount += 1
|
|
||||||
if '.cue' in file:
|
|
||||||
cuecount += 1
|
|
||||||
|
|
||||||
title = returntitle.lower()
|
|
||||||
logger.debug('torrent title: %s' % title)
|
|
||||||
logger.debug('headphones trackcount: %s' % hptrackcount)
|
|
||||||
logger.debug('rutracker trackcount: %s' % trackcount)
|
|
||||||
|
|
||||||
# If torrent track count less than headphones track count, and there's a cue, then attempt to get track count from log(s)
|
|
||||||
# This is for the case where we have a single .flac/.wav which can be split by cue
|
|
||||||
# Not great, but shouldn't be doing this too often
|
|
||||||
totallogcount = 0
|
|
||||||
if trackcount < hptrackcount and cuecount > 0 and cuecount < hptrackcount:
|
|
||||||
page = self.opener.open(topicurl, timeout=60)
|
|
||||||
soup = BeautifulSoup(page.read())
|
|
||||||
findtoc = soup.find_all(text='TOC of the extracted CD')
|
|
||||||
if not findtoc:
|
|
||||||
findtoc = soup.find_all(text='TOC извлечённого CD')
|
|
||||||
for toc in findtoc:
|
|
||||||
logcount = 0
|
|
||||||
for toccontent in toc.find_all_next(text=True):
|
|
||||||
cut_string = toccontent.split('|')
|
|
||||||
new_string = cut_string[0].lstrip().rstrip()
|
|
||||||
if new_string == '1' or new_string == '01':
|
|
||||||
logcount = 1
|
|
||||||
elif logcount > 0:
|
|
||||||
if new_string.isdigit():
|
|
||||||
logcount += 1
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
totallogcount = totallogcount + logcount
|
|
||||||
|
|
||||||
if totallogcount > 0:
|
|
||||||
trackcount = totallogcount
|
|
||||||
logger.debug('rutracker logtrackcount: %s' % totallogcount)
|
|
||||||
|
|
||||||
# If torrent track count = hp track count then return torrent,
|
|
||||||
# if greater, check for deluxe/special/foreign editions
|
|
||||||
# if less, then allow if it's a single track with a cue
|
|
||||||
valid = False
|
|
||||||
|
|
||||||
if trackcount == hptrackcount:
|
|
||||||
valid = True
|
|
||||||
elif trackcount > hptrackcount:
|
|
||||||
if any(deluxe in title for deluxe in deluxelist):
|
|
||||||
valid = True
|
|
||||||
|
|
||||||
# Add to list
|
|
||||||
if valid:
|
|
||||||
rulist.append((returntitle, size, topicurl))
|
|
||||||
else:
|
|
||||||
if topicurl:
|
|
||||||
logger.info(u'<a href="%s">Torrent</a> found with %s tracks but the selected headphones release has %s tracks, skipping for rutracker.org' % (topicurl, trackcount, hptrackcount))
|
|
||||||
else:
|
|
||||||
logger.info('%s is larger than the maxsize or has too little seeders for this category, skipping. (Size: %i bytes, Seeders: %i)' % (returntitle, int(size), int(seeders)))
|
|
||||||
|
|
||||||
return rulist
|
|
||||||
|
|
||||||
def get_torrent(self, url, savelocation=None):
|
|
||||||
|
|
||||||
torrent_id = dict([part.split('=') for part in urlparse(url)[4].split('&')])['t']
|
|
||||||
self.cookiejar.set_cookie(cookielib.Cookie(version=0, name='bb_dl', value=torrent_id, port=None, port_specified=False, domain='.rutracker.org', domain_specified=False, domain_initial_dot=False, path='/', path_specified=True, secure=False, expires=None, discard=True, comment=None, comment_url=None, rest={'HttpOnly': None}, rfc2109=False))
|
|
||||||
downloadurl = 'http://dl.rutracker.org/forum/dl.php?t=' + torrent_id
|
|
||||||
torrent_name = torrent_id + '.torrent'
|
|
||||||
|
|
||||||
try:
|
|
||||||
prev = os.umask(headphones.UMASK)
|
|
||||||
page = self.opener.open(downloadurl)
|
|
||||||
torrent = page.read()
|
|
||||||
decoded = bdecode(torrent)
|
|
||||||
metainfo = decoded['info']
|
|
||||||
tor_hash = sha1(bencode(metainfo)).hexdigest()
|
|
||||||
if savelocation:
|
|
||||||
download_path = os.path.join(savelocation, torrent_name)
|
|
||||||
else:
|
|
||||||
tempdir = mkdtemp(suffix='_rutracker_torrents')
|
|
||||||
download_path = os.path.join(tempdir, torrent_name)
|
|
||||||
|
|
||||||
with open(download_path, 'wb') as f:
|
|
||||||
f.write(torrent)
|
|
||||||
os.umask(prev)
|
|
||||||
|
|
||||||
# Add file to utorrent
|
|
||||||
if headphones.CONFIG.TORRENT_DOWNLOADER == 2:
|
|
||||||
self.utorrent_add_file(download_path)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Error getting torrent: %s', e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
return download_path, tor_hash
|
|
||||||
|
|
||||||
#TODO get this working in utorrent.py
|
|
||||||
def utorrent_add_file(self, filename):
|
|
||||||
|
|
||||||
host = headphones.CONFIG.UTORRENT_HOST
|
|
||||||
if not host.startswith('http'):
|
|
||||||
host = 'http://' + host
|
|
||||||
if host.endswith('/'):
|
|
||||||
host = host[:-1]
|
|
||||||
if host.endswith('/gui'):
|
|
||||||
host = host[:-4]
|
|
||||||
|
|
||||||
base_url = host
|
|
||||||
username = headphones.CONFIG.UTORRENT_USERNAME
|
|
||||||
password = headphones.CONFIG.UTORRENT_PASSWORD
|
|
||||||
|
|
||||||
session = requests.Session()
|
|
||||||
url = base_url + '/gui/'
|
|
||||||
session.auth = (username, password)
|
|
||||||
|
|
||||||
try:
|
|
||||||
r = session.get(url + 'token.html')
|
|
||||||
except Exception:
|
|
||||||
logger.exception('Error getting token')
|
|
||||||
return
|
|
||||||
|
|
||||||
if r.status_code == '401':
|
|
||||||
logger.debug('Error reaching utorrent')
|
|
||||||
return
|
|
||||||
|
|
||||||
regex = re.search(r'.+>([^<]+)</div></html>', r.text)
|
|
||||||
if regex is None:
|
|
||||||
logger.debug('Error reading token')
|
|
||||||
return
|
|
||||||
|
|
||||||
session.params = {'token': regex.group(1)}
|
|
||||||
|
|
||||||
with open(filename, 'rb') as f:
|
|
||||||
try:
|
|
||||||
session.post(url, params={'action': 'add-file'},
|
|
||||||
files={'torrent_file': f})
|
|
||||||
except Exception:
|
|
||||||
logger.exception('Error adding file to utorrent')
|
|
||||||
return
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import os
|
||||||
|
from headphones.exceptions import SoftChrootError
|
||||||
|
|
||||||
|
|
||||||
|
class SoftChroot(object):
|
||||||
|
""" SoftChroot provides SOFT chrooting for UI
|
||||||
|
|
||||||
|
IMPORTANT: call methods of this class just in modules, which generates data for client UI. Try to avoid unnecessary usage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
enabled = False
|
||||||
|
chroot = None
|
||||||
|
|
||||||
|
def __init__(self, path):
|
||||||
|
if not path:
|
||||||
|
#disabled
|
||||||
|
return
|
||||||
|
|
||||||
|
path = path.strip()
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
|
||||||
|
if (not os.path.exists(path) or
|
||||||
|
not os.path.isdir(path)):
|
||||||
|
raise SoftChrootError('No such directory: %s' % path)
|
||||||
|
|
||||||
|
path = path.rstrip(os.path.sep) + os.path.sep
|
||||||
|
|
||||||
|
self.enabled = True
|
||||||
|
self.chroot = path
|
||||||
|
|
||||||
|
def isEnabled(self):
|
||||||
|
return self.enabled
|
||||||
|
|
||||||
|
def getRoot(self):
|
||||||
|
return self.chroot
|
||||||
|
|
||||||
|
def apply(self, path):
|
||||||
|
if not self.enabled:
|
||||||
|
return path
|
||||||
|
|
||||||
|
if not path:
|
||||||
|
return path
|
||||||
|
|
||||||
|
p = path.strip()
|
||||||
|
if not p:
|
||||||
|
return path
|
||||||
|
|
||||||
|
if path.startswith(self.chroot):
|
||||||
|
p = os.path.sep + path[len(self.chroot):]
|
||||||
|
else:
|
||||||
|
p = os.path.sep
|
||||||
|
|
||||||
|
return p
|
||||||
|
|
||||||
|
def revoke(self, path):
|
||||||
|
if not self.enabled:
|
||||||
|
return path
|
||||||
|
|
||||||
|
if not path:
|
||||||
|
return path
|
||||||
|
|
||||||
|
p = path.strip()
|
||||||
|
if not p:
|
||||||
|
return path
|
||||||
|
|
||||||
|
if os.path.sep == p[0]:
|
||||||
|
p = p[1:]
|
||||||
|
|
||||||
|
p = self.chroot + p
|
||||||
|
return p
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import os
|
||||||
|
import mock
|
||||||
|
from headphones.unittestcompat import TestCase, TestArgs
|
||||||
|
#from mock import MagicMock
|
||||||
|
|
||||||
|
from headphones.softchroot import SoftChroot
|
||||||
|
from headphones.exceptions import SoftChrootError
|
||||||
|
|
||||||
|
|
||||||
|
class SoftChrootTest(TestCase):
|
||||||
|
def test_create(self):
|
||||||
|
""" create headphones.SoftChroot """
|
||||||
|
|
||||||
|
cf = SoftChroot('/tmp/')
|
||||||
|
self.assertIsInstance(cf, SoftChroot)
|
||||||
|
self.assertTrue(cf.isEnabled())
|
||||||
|
self.assertEqual(cf.getRoot(), '/tmp/')
|
||||||
|
|
||||||
|
@TestArgs(
|
||||||
|
(None),
|
||||||
|
(''),
|
||||||
|
(' '),
|
||||||
|
)
|
||||||
|
def test_create_disabled(self, empty_path):
|
||||||
|
""" create DISABLED SoftChroot """
|
||||||
|
|
||||||
|
cf = SoftChroot(empty_path)
|
||||||
|
self.assertIsInstance(cf, SoftChroot)
|
||||||
|
self.assertFalse(cf.isEnabled())
|
||||||
|
self.assertIsNone(cf.getRoot())
|
||||||
|
|
||||||
|
def test_create_on_not_exists_dir(self):
|
||||||
|
""" create SoftChroot on non existent dir """
|
||||||
|
|
||||||
|
path = os.path.join('/tmp', 'notexist', 'asdf', '11', '12', 'np', 'itsssss')
|
||||||
|
|
||||||
|
cf = None
|
||||||
|
with self.assertRaises(SoftChrootError) as exc:
|
||||||
|
cf = SoftChroot(path)
|
||||||
|
self.assertIsNone(cf)
|
||||||
|
|
||||||
|
self.assertRegexpMatches(str(exc.exception), r'No such directory')
|
||||||
|
self.assertRegexpMatches(str(exc.exception), path)
|
||||||
|
|
||||||
|
@mock.patch('headphones.softchroot.os', wrap=os, name='OsMock')
|
||||||
|
def test_create_on_file(self, os_mock):
|
||||||
|
""" create SoftChroot on file, not a directory """
|
||||||
|
|
||||||
|
path = os.path.join('/tmp', 'notexist', 'asdf', '11', '12', 'np', 'itsssss')
|
||||||
|
|
||||||
|
os_mock.path.sep = os.path.sep
|
||||||
|
os_mock.path.isdir.side_effect = lambda x: x != path
|
||||||
|
|
||||||
|
cf = None
|
||||||
|
with self.assertRaises(SoftChrootError) as exc:
|
||||||
|
cf = SoftChroot(path)
|
||||||
|
self.assertIsNone(cf)
|
||||||
|
|
||||||
|
self.assertTrue(os_mock.path.isdir.called)
|
||||||
|
|
||||||
|
self.assertRegexpMatches(str(exc.exception), r'No such directory')
|
||||||
|
self.assertRegexpMatches(str(exc.exception), path)
|
||||||
|
|
||||||
|
@TestArgs(
|
||||||
|
(None, None),
|
||||||
|
('', ''),
|
||||||
|
(' ', ' '),
|
||||||
|
('/tmp/', '/'),
|
||||||
|
('/tmp/asdf', '/asdf'),
|
||||||
|
)
|
||||||
|
def test_apply(self, p, e):
|
||||||
|
""" apply SoftChroot """
|
||||||
|
sc = SoftChroot('/tmp/')
|
||||||
|
a = sc.apply(p)
|
||||||
|
self.assertEqual(a, e)
|
||||||
|
|
||||||
|
@TestArgs(
|
||||||
|
('/'),
|
||||||
|
('/nonch/path/asdf'),
|
||||||
|
('tmp/asdf'),
|
||||||
|
)
|
||||||
|
def test_apply_out_of_root(self, p):
|
||||||
|
""" apply SoftChroot to paths outside of the chroot """
|
||||||
|
sc = SoftChroot('/tmp/')
|
||||||
|
a = sc.apply(p)
|
||||||
|
self.assertEqual(a, '/')
|
||||||
|
|
||||||
|
@TestArgs(
|
||||||
|
(None, None),
|
||||||
|
('', ''),
|
||||||
|
(' ', ' '),
|
||||||
|
('/', '/tmp/'),
|
||||||
|
('/asdf', '/tmp/asdf'),
|
||||||
|
('/asdf/', '/tmp/asdf/'),
|
||||||
|
('localdir/adf', '/tmp/localdir/adf'),
|
||||||
|
('localdir/adf/', '/tmp/localdir/adf/'),
|
||||||
|
)
|
||||||
|
def test_revoke(self, p, e):
|
||||||
|
""" revoke SoftChroot """
|
||||||
|
sc = SoftChroot('/tmp/')
|
||||||
|
a = sc.revoke(p)
|
||||||
|
self.assertEqual(a, e)
|
||||||
|
|
||||||
|
@TestArgs(
|
||||||
|
(None),
|
||||||
|
(''),
|
||||||
|
(' '),
|
||||||
|
('/tmp'),
|
||||||
|
('/tmp/'),
|
||||||
|
('/tmp/asdf'),
|
||||||
|
('/tmp/localdir/adf'),
|
||||||
|
('localdir/adf'),
|
||||||
|
('localdir/adf/'),
|
||||||
|
)
|
||||||
|
def test_actions_on_disabled(self, p):
|
||||||
|
""" disabled SoftChroot should not change args on apply and revoke """
|
||||||
|
sc = SoftChroot(None)
|
||||||
|
a = sc.apply(p)
|
||||||
|
self.assertEqual(a, p)
|
||||||
|
|
||||||
|
r = sc.revoke(p)
|
||||||
|
self.assertEqual(r, p)
|
||||||
@@ -14,20 +14,21 @@
|
|||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
import headphones
|
|
||||||
from headphones import db, utorrent, transmission, logger
|
from headphones import db, utorrent, transmission, logger
|
||||||
|
import headphones
|
||||||
|
|
||||||
postprocessor_lock = threading.Lock()
|
postprocessor_lock = threading.Lock()
|
||||||
|
|
||||||
# Remove Torrent + data if Post Processed and finished Seeding
|
|
||||||
|
|
||||||
|
|
||||||
def checkTorrentFinished():
|
def checkTorrentFinished():
|
||||||
|
"""
|
||||||
|
Remove Torrent + data if Post Processed and finished Seeding
|
||||||
|
"""
|
||||||
|
|
||||||
logger.info("Checking if any torrents have finished seeding and can be removed")
|
logger.info("Checking if any torrents have finished seeding and can be removed")
|
||||||
|
|
||||||
with postprocessor_lock:
|
with postprocessor_lock:
|
||||||
|
|
||||||
myDB = db.DBConnection()
|
myDB = db.DBConnection()
|
||||||
results = myDB.select('SELECT * from snatched WHERE Status="Seed_Processed"')
|
results = myDB.select('SELECT * from snatched WHERE Status="Seed_Processed"')
|
||||||
|
|
||||||
@@ -41,4 +42,7 @@ def checkTorrentFinished():
|
|||||||
torrent_removed = utorrent.removeTorrent(hash, True)
|
torrent_removed = utorrent.removeTorrent(hash, True)
|
||||||
|
|
||||||
if torrent_removed:
|
if torrent_removed:
|
||||||
myDB.action('DELETE from snatched WHERE status = "Seed_Processed" and AlbumID=?', [albumid])
|
myDB.action('DELETE from snatched WHERE status = "Seed_Processed" and AlbumID=?',
|
||||||
|
[albumid])
|
||||||
|
|
||||||
|
logger.info("Checking finished torrents completed")
|
||||||
|
|||||||
+47
-55
@@ -13,24 +13,30 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from headphones import logger, request
|
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
import base64
|
import base64
|
||||||
|
import urlparse
|
||||||
|
|
||||||
|
from headphones import logger, request
|
||||||
import headphones
|
import headphones
|
||||||
|
|
||||||
|
|
||||||
# This is just a simple script to send torrents to transmission. The
|
# This is just a simple script to send torrents to transmission. The
|
||||||
# intention is to turn this into a class where we can check the state
|
# intention is to turn this into a class where we can check the state
|
||||||
# of the download, set the download dir, etc.
|
# of the download, set the download dir, etc.
|
||||||
# TODO: Store the session id so we don't need to make 2 calls
|
# TODO: Store torrent id so we can check up on it
|
||||||
# Store torrent id so we can check up on it
|
|
||||||
|
_session_id = None
|
||||||
|
|
||||||
|
|
||||||
def addTorrent(link):
|
def addTorrent(link, data=None):
|
||||||
method = 'torrent-add'
|
method = 'torrent-add'
|
||||||
|
|
||||||
if link.endswith('.torrent'):
|
if link.endswith('.torrent') and not link.startswith('http') or data:
|
||||||
|
if data:
|
||||||
|
metainfo = str(base64.b64encode(data))
|
||||||
|
else:
|
||||||
with open(link, 'rb') as f:
|
with open(link, 'rb') as f:
|
||||||
metainfo = str(base64.b64encode(f.read()))
|
metainfo = str(base64.b64encode(f.read()))
|
||||||
arguments = {'metainfo': metainfo, 'download-dir': headphones.CONFIG.DOWNLOAD_TORRENT_DIR}
|
arguments = {'metainfo': metainfo, 'download-dir': headphones.CONFIG.DOWNLOAD_TORRENT_DIR}
|
||||||
@@ -92,7 +98,6 @@ def setSeedRatio(torrentid, ratio):
|
|||||||
|
|
||||||
|
|
||||||
def removeTorrent(torrentid, remove_data=False):
|
def removeTorrent(torrentid, remove_data=False):
|
||||||
|
|
||||||
method = 'torrent-get'
|
method = 'torrent-get'
|
||||||
arguments = {'ids': torrentid, 'fields': ['isFinished', 'name']}
|
arguments = {'ids': torrentid, 'fields': ['isFinished', 'name']}
|
||||||
|
|
||||||
@@ -114,7 +119,8 @@ def removeTorrent(torrentid, remove_data=False):
|
|||||||
response = torrentAction(method, arguments)
|
response = torrentAction(method, arguments)
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.info('%s has not finished seeding yet, torrent will not be removed, will try again on next run' % name)
|
logger.info(
|
||||||
|
'%s has not finished seeding yet, torrent will not be removed, will try again on next run' % name)
|
||||||
except:
|
except:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -122,11 +128,10 @@ def removeTorrent(torrentid, remove_data=False):
|
|||||||
|
|
||||||
|
|
||||||
def torrentAction(method, arguments):
|
def torrentAction(method, arguments):
|
||||||
|
global _session_id
|
||||||
host = headphones.CONFIG.TRANSMISSION_HOST
|
host = headphones.CONFIG.TRANSMISSION_HOST
|
||||||
username = headphones.CONFIG.TRANSMISSION_USERNAME
|
username = headphones.CONFIG.TRANSMISSION_USERNAME
|
||||||
password = headphones.CONFIG.TRANSMISSION_PASSWORD
|
password = headphones.CONFIG.TRANSMISSION_PASSWORD
|
||||||
sessionid = None
|
|
||||||
|
|
||||||
if not host.startswith('http'):
|
if not host.startswith('http'):
|
||||||
host = 'http://' + host
|
host = 'http://' + host
|
||||||
@@ -134,58 +139,45 @@ def torrentAction(method, arguments):
|
|||||||
if host.endswith('/'):
|
if host.endswith('/'):
|
||||||
host = host[:-1]
|
host = host[:-1]
|
||||||
|
|
||||||
# Either the host ends with a port, or some directory, or rpc
|
# Fix the URL. We assume that the user does not point to the RPC endpoint,
|
||||||
# If it ends with /rpc we don't have to do anything
|
# so add it if it is missing.
|
||||||
# If it ends with a port we add /transmission/rpc
|
parts = list(urlparse.urlparse(host))
|
||||||
# anything else we just add rpc
|
|
||||||
if not host.endswith('/rpc'):
|
|
||||||
# Check if it ends in a port number
|
|
||||||
i = host.rfind(':')
|
|
||||||
if i >= 0:
|
|
||||||
possible_port = host[i + 1:]
|
|
||||||
host = host + "/rpc"
|
|
||||||
try:
|
|
||||||
port = int(possible_port)
|
|
||||||
if port:
|
|
||||||
host = host + "/transmission/rpc"
|
|
||||||
except ValueError:
|
|
||||||
logger.debug('No port, assuming not transmission')
|
|
||||||
else:
|
|
||||||
logger.error('Transmission port missing')
|
|
||||||
return
|
|
||||||
|
|
||||||
# Retrieve session id
|
if not parts[0] in ("http", "https"):
|
||||||
|
parts[0] = "http"
|
||||||
|
|
||||||
|
if not parts[2].endswith("/rpc"):
|
||||||
|
parts[2] += "/transmission/rpc"
|
||||||
|
|
||||||
|
host = urlparse.urlunparse(parts)
|
||||||
|
data = {'method': method, 'arguments': arguments}
|
||||||
|
data_json = json.dumps(data)
|
||||||
auth = (username, password) if username and password else None
|
auth = (username, password) if username and password else None
|
||||||
|
for retry in range(2):
|
||||||
response = request.request_response(host, auth=auth, whitelist_status_code=[401, 409])
|
if _session_id is not None:
|
||||||
|
headers = {'x-transmission-session-id': _session_id}
|
||||||
if response is None:
|
response = request.request_response(host, method="POST",
|
||||||
logger.error("Error gettings Transmission session ID")
|
data=data_json, headers=headers, auth=auth,
|
||||||
return
|
whitelist_status_code=[200, 401, 409])
|
||||||
|
else:
|
||||||
# Parse response
|
response = request.request_response(host, auth=auth,
|
||||||
|
whitelist_status_code=[401, 409])
|
||||||
if response.status_code == 401:
|
if response.status_code == 401:
|
||||||
if auth:
|
if auth:
|
||||||
logger.error("Username and/or password not accepted by Transmission")
|
logger.error("Username and/or password not accepted by " \
|
||||||
|
"Transmission")
|
||||||
else:
|
else:
|
||||||
logger.error("Transmission authorization required")
|
logger.error("Transmission authorization required")
|
||||||
|
|
||||||
return
|
return
|
||||||
elif response.status_code == 409:
|
elif response.status_code == 409:
|
||||||
sessionid = response.headers['x-transmission-session-id']
|
_session_id = response.headers['x-transmission-session-id']
|
||||||
|
if _session_id is None:
|
||||||
if not sessionid:
|
logger.error("Expected a Session ID from Transmission, got None")
|
||||||
logger.error("Error getting Session ID from Transmission")
|
|
||||||
return
|
return
|
||||||
|
# retry request with new session id
|
||||||
|
logger.debug("Retrying Transmission request with new session id")
|
||||||
|
continue
|
||||||
|
|
||||||
# Prepare next request
|
resp_json = response.json()
|
||||||
headers = {'x-transmission-session-id': sessionid}
|
print resp_json
|
||||||
data = {'method': method, 'arguments': arguments}
|
return resp_json
|
||||||
|
|
||||||
response = request.request_json(host, method="post", data=json.dumps(data), headers=headers, auth=auth)
|
|
||||||
|
|
||||||
if not response:
|
|
||||||
logger.error("Error sending torrent to Transmission")
|
|
||||||
return
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import sys
|
||||||
|
if sys.version_info < (2, 7):
|
||||||
|
import unittest2 as unittest
|
||||||
|
from unittest2 import TestCase as TC
|
||||||
|
else:
|
||||||
|
import unittest
|
||||||
|
from unittest import TestCase as TC
|
||||||
|
|
||||||
|
skip = unittest.skip
|
||||||
|
|
||||||
|
_dummy = False
|
||||||
|
|
||||||
|
# less than 2.6 ...
|
||||||
|
if sys.version_info[0] == 2 and sys.version_info[1] <= 6:
|
||||||
|
_dummy = True
|
||||||
|
|
||||||
|
|
||||||
|
def _d(f):
|
||||||
|
def decorate(self, *args, **kw):
|
||||||
|
if not _dummy:
|
||||||
|
return f(self, *args, **kw)
|
||||||
|
return self.assertTrue(True)
|
||||||
|
return decorate
|
||||||
|
|
||||||
|
|
||||||
|
class TestCase(TC):
|
||||||
|
"""
|
||||||
|
Wrapper for python 2.6 stubs
|
||||||
|
"""
|
||||||
|
|
||||||
|
def assertIsInstance(self, obj, cls, msg=None):
|
||||||
|
if not _dummy:
|
||||||
|
return super(TestCase, self).assertIsInstance(obj, cls, msg)
|
||||||
|
tst = isinstance(obj, cls)
|
||||||
|
return self.assertTrue(tst, msg)
|
||||||
|
|
||||||
|
@_d
|
||||||
|
def assertNotIsInstance(self, *args, **kw):
|
||||||
|
return super(TestCase, self).assertNotIsInstance(*args, **kw)
|
||||||
|
|
||||||
|
@_d
|
||||||
|
def assertIn(self, *args, **kw):
|
||||||
|
return super(TestCase, self).assertIn(*args, **kw)
|
||||||
|
|
||||||
|
@_d
|
||||||
|
def assertRegexpMatches(self, *args, **kw):
|
||||||
|
return super(TestCase, self).assertRegexpMatches(*args, **kw)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# NOT DUMMY ASSERTIONS
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
def assertIsNone(self, val, msg=None):
|
||||||
|
if not _dummy:
|
||||||
|
return super(TestCase, self).assertIsNone(val, msg)
|
||||||
|
tst = val is None
|
||||||
|
return super(TestCase, self).assertTrue(tst, msg)
|
||||||
|
|
||||||
|
def assertIsNotNone(self, val, msg=None):
|
||||||
|
if not _dummy:
|
||||||
|
return super(TestCase, self).assertIsNotNone(val, msg)
|
||||||
|
tst = val is not None
|
||||||
|
return super(TestCase, self).assertTrue(tst, msg)
|
||||||
|
|
||||||
|
def assertRaises(self, exc, msg=None):
|
||||||
|
if not _dummy:
|
||||||
|
return super(TestCase, self).assertRaises(exc, msg)
|
||||||
|
return TestCase._TestCaseRaiseStub(self, exc, msg=msg)
|
||||||
|
|
||||||
|
def assertRaisesRegexp(self, exc, regex, msg=None):
|
||||||
|
if not _dummy:
|
||||||
|
return super(TestCase, self).assertRaises(exc, msg)
|
||||||
|
return TestCase._TestCaseRaiseStub(self, exc, regex=regex, msg=msg)
|
||||||
|
|
||||||
|
class _TestCaseRaiseStub:
|
||||||
|
""" Internal stuff for stubbing `assertRaises*` """
|
||||||
|
|
||||||
|
def __init__(self, test_case, exc, regex=None, msg=None):
|
||||||
|
self.exc = exc
|
||||||
|
self.test_case = test_case
|
||||||
|
self.regex = regex
|
||||||
|
self.msg = msg
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, tp, value, traceback):
|
||||||
|
tst = tp is self.exc
|
||||||
|
self.test_case.assertTrue(tst, msg=self.msg)
|
||||||
|
self.exception = value
|
||||||
|
|
||||||
|
# TODO: implement self.regex checking
|
||||||
|
|
||||||
|
# True indicates, that exception is handled
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def TestArgs(*parameters):
|
||||||
|
def tuplify(x):
|
||||||
|
if not isinstance(x, tuple):
|
||||||
|
return (x,)
|
||||||
|
return x
|
||||||
|
|
||||||
|
def decorator(method, parameters=parameters):
|
||||||
|
for parameter in (tuplify(x) for x in parameters):
|
||||||
|
|
||||||
|
def method_for_parameter(self, method=method, parameter=parameter):
|
||||||
|
method(self, *parameter)
|
||||||
|
args_for_parameter = ",".join(repr(v) for v in parameter)
|
||||||
|
name_for_parameter = method.__name__ + "(" + args_for_parameter + ")"
|
||||||
|
frame = sys._getframe(1) # pylint: disable-msg=W0212
|
||||||
|
frame.f_locals[name_for_parameter] = method_for_parameter
|
||||||
|
frame.f_locals[name_for_parameter].__doc__ = method.__doc__ + '(' + args_for_parameter + ')'
|
||||||
|
method_for_parameter.__name__ = name_for_parameter + '(' + args_for_parameter + ')'
|
||||||
|
return None
|
||||||
|
return decorator
|
||||||
@@ -17,14 +17,13 @@ from headphones import logger, db, importer
|
|||||||
|
|
||||||
|
|
||||||
def dbUpdate(forcefull=False):
|
def dbUpdate(forcefull=False):
|
||||||
|
|
||||||
myDB = db.DBConnection()
|
myDB = db.DBConnection()
|
||||||
|
|
||||||
activeartists = myDB.select('SELECT ArtistID, ArtistName from artists WHERE Status="Active" or Status="Loading" order by LastUpdated ASC')
|
active_artists = myDB.select(
|
||||||
logger.info('Starting update for %i active artists' % len(activeartists))
|
'SELECT ArtistID, ArtistName from artists WHERE Status="Active" or Status="Loading" order by LastUpdated ASC')
|
||||||
|
logger.info('Starting update for %i active artists', len(active_artists))
|
||||||
for artist in activeartists:
|
|
||||||
|
|
||||||
|
for artist in active_artists:
|
||||||
artistid = artist[0]
|
artistid = artist[0]
|
||||||
importer.addArtisttoDB(artistid=artistid, extrasonly=False, forcefull=forcefull)
|
importer.addArtisttoDB(artistid=artistid, extrasonly=False, forcefull=forcefull)
|
||||||
|
|
||||||
|
|||||||
+21
-16
@@ -14,22 +14,20 @@
|
|||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import urllib
|
import urllib
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from collections import namedtuple
|
||||||
import urllib2
|
import urllib2
|
||||||
import urlparse
|
import urlparse
|
||||||
import cookielib
|
import cookielib
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
|
|
||||||
import headphones
|
import headphones
|
||||||
|
|
||||||
from headphones import logger
|
from headphones import logger
|
||||||
from collections import namedtuple
|
|
||||||
|
|
||||||
|
|
||||||
class utorrentclient(object):
|
class utorrentclient(object):
|
||||||
|
|
||||||
TOKEN_REGEX = "<div id='token' style='display:none;'>([^<>]+)</div>"
|
TOKEN_REGEX = "<div id='token' style='display:none;'>([^<>]+)</div>"
|
||||||
UTSetting = namedtuple("UTSetting", ["name", "int", "str", "access"])
|
UTSetting = namedtuple("UTSetting", ["name", "int", "str", "access"])
|
||||||
|
|
||||||
@@ -73,6 +71,7 @@ class utorrentclient(object):
|
|||||||
except urllib2.HTTPError as err:
|
except urllib2.HTTPError as err:
|
||||||
logger.debug('URL: ' + str(url))
|
logger.debug('URL: ' + str(url))
|
||||||
logger.debug('Error getting Token. uTorrent responded with error: ' + str(err))
|
logger.debug('Error getting Token. uTorrent responded with error: ' + str(err))
|
||||||
|
return
|
||||||
match = re.search(utorrentclient.TOKEN_REGEX, response.read())
|
match = re.search(utorrentclient.TOKEN_REGEX, response.read())
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
|
|
||||||
@@ -147,6 +146,10 @@ class utorrentclient(object):
|
|||||||
return self._action(params)
|
return self._action(params)
|
||||||
|
|
||||||
def _action(self, params, body=None, content_type=None):
|
def _action(self, params, body=None, content_type=None):
|
||||||
|
|
||||||
|
if not self.token:
|
||||||
|
return
|
||||||
|
|
||||||
url = self.base_url + '/gui/' + '?token=' + self.token + '&' + urllib.urlencode(params)
|
url = self.base_url + '/gui/' + '?token=' + self.token + '&' + urllib.urlencode(params)
|
||||||
request = urllib2.Request(url)
|
request = urllib2.Request(url)
|
||||||
|
|
||||||
@@ -176,13 +179,15 @@ def removeTorrent(hash, remove_data=False):
|
|||||||
status, torrentList = uTorrentClient.list()
|
status, torrentList = uTorrentClient.list()
|
||||||
torrents = torrentList['torrents']
|
torrents = torrentList['torrents']
|
||||||
for torrent in torrents:
|
for torrent in torrents:
|
||||||
if torrent[0].lower() == hash:
|
if torrent[0].upper() == hash.upper():
|
||||||
if torrent[21] == 'Finished':
|
if torrent[21] == 'Finished':
|
||||||
logger.info('%s has finished seeding, removing torrent and data' % torrent[2])
|
logger.info('%s has finished seeding, removing torrent and data' % torrent[2])
|
||||||
uTorrentClient.remove(hash, remove_data)
|
uTorrentClient.remove(hash, remove_data)
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.info('%s has not finished seeding yet, torrent will not be removed, will try again on next run' % torrent[2])
|
logger.info(
|
||||||
|
'%s has not finished seeding yet, torrent will not be removed, will try again on next run' %
|
||||||
|
torrent[2])
|
||||||
return False
|
return False
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -198,7 +203,6 @@ def setSeedRatio(hash, ratio):
|
|||||||
|
|
||||||
|
|
||||||
def dirTorrent(hash, cacheid=None, return_name=None):
|
def dirTorrent(hash, cacheid=None, return_name=None):
|
||||||
|
|
||||||
uTorrentClient = utorrentclient()
|
uTorrentClient = utorrentclient()
|
||||||
|
|
||||||
if not cacheid:
|
if not cacheid:
|
||||||
@@ -215,7 +219,7 @@ def dirTorrent(hash, cacheid=None, return_name=None):
|
|||||||
cacheid = torrentList['torrentc']
|
cacheid = torrentList['torrentc']
|
||||||
|
|
||||||
for torrent in torrents:
|
for torrent in torrents:
|
||||||
if (torrent[0].lower() == hash):
|
if torrent[0].upper() == hash.upper():
|
||||||
if not return_name:
|
if not return_name:
|
||||||
return torrent[26], cacheid
|
return torrent[26], cacheid
|
||||||
else:
|
else:
|
||||||
@@ -224,18 +228,21 @@ def dirTorrent(hash, cacheid=None, return_name=None):
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def addTorrent(link, hash):
|
def addTorrent(link):
|
||||||
uTorrentClient = utorrentclient()
|
uTorrentClient = utorrentclient()
|
||||||
|
uTorrentClient.add_url(link)
|
||||||
|
|
||||||
|
|
||||||
|
def getFolder(hash):
|
||||||
|
|
||||||
# Get Active Directory from settings
|
# Get Active Directory from settings
|
||||||
active_dir, completed_dir = getSettingsDirectories()
|
active_dir, completed_dir = getSettingsDirectories()
|
||||||
|
|
||||||
if not active_dir:
|
if not active_dir:
|
||||||
logger.error('Could not get "Put new downloads in:" directory from uTorrent settings, please ensure it is set')
|
logger.error(
|
||||||
|
'Could not get "Put new downloads in:" directory from uTorrent settings, please ensure it is set')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
uTorrentClient.add_url(link)
|
|
||||||
|
|
||||||
# Get Torrent Folder Name
|
# Get Torrent Folder Name
|
||||||
torrent_folder, cacheid = dirTorrent(hash)
|
torrent_folder, cacheid = dirTorrent(hash)
|
||||||
|
|
||||||
@@ -249,10 +256,8 @@ def addTorrent(link, hash):
|
|||||||
|
|
||||||
if torrent_folder == active_dir or not torrent_folder:
|
if torrent_folder == active_dir or not torrent_folder:
|
||||||
torrent_folder, cacheid = dirTorrent(hash, cacheid, return_name=True)
|
torrent_folder, cacheid = dirTorrent(hash, cacheid, return_name=True)
|
||||||
labelTorrent(hash)
|
|
||||||
return torrent_folder
|
return torrent_folder
|
||||||
else:
|
else:
|
||||||
labelTorrent(hash)
|
|
||||||
if headphones.SYS_PLATFORM != "win32":
|
if headphones.SYS_PLATFORM != "win32":
|
||||||
torrent_folder = torrent_folder.replace('\\', '/')
|
torrent_folder = torrent_folder.replace('\\', '/')
|
||||||
return os.path.basename(os.path.normpath(torrent_folder))
|
return os.path.basename(os.path.normpath(torrent_folder))
|
||||||
|
|||||||
+23
-15
@@ -13,18 +13,17 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import re
|
|
||||||
import os
|
|
||||||
import tarfile
|
import tarfile
|
||||||
import platform
|
import platform
|
||||||
import headphones
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import headphones
|
||||||
from headphones import logger, version, request
|
from headphones import logger, version, request
|
||||||
|
|
||||||
|
|
||||||
def runGit(args):
|
def runGit(args):
|
||||||
|
|
||||||
if headphones.CONFIG.GIT_PATH:
|
if headphones.CONFIG.GIT_PATH:
|
||||||
git_locations = ['"' + headphones.CONFIG.GIT_PATH + '"']
|
git_locations = ['"' + headphones.CONFIG.GIT_PATH + '"']
|
||||||
else:
|
else:
|
||||||
@@ -40,7 +39,8 @@ def runGit(args):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
logger.debug('Trying to execute: "' + cmd + '" with shell in ' + headphones.PROG_DIR)
|
logger.debug('Trying to execute: "' + cmd + '" with shell in ' + headphones.PROG_DIR)
|
||||||
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True, cwd=headphones.PROG_DIR)
|
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True,
|
||||||
|
cwd=headphones.PROG_DIR)
|
||||||
output, err = p.communicate()
|
output, err = p.communicate()
|
||||||
output = output.strip()
|
output = output.strip()
|
||||||
|
|
||||||
@@ -62,7 +62,6 @@ def runGit(args):
|
|||||||
|
|
||||||
|
|
||||||
def getVersion():
|
def getVersion():
|
||||||
|
|
||||||
if version.HEADPHONES_VERSION.startswith('win32build'):
|
if version.HEADPHONES_VERSION.startswith('win32build'):
|
||||||
headphones.INSTALL_TYPE = 'win'
|
headphones.INSTALL_TYPE = 'win'
|
||||||
|
|
||||||
@@ -92,7 +91,8 @@ def getVersion():
|
|||||||
branch_name = branch_name
|
branch_name = branch_name
|
||||||
|
|
||||||
if not branch_name and headphones.CONFIG.GIT_BRANCH:
|
if not branch_name and headphones.CONFIG.GIT_BRANCH:
|
||||||
logger.error('Could not retrieve branch name from git. Falling back to %s' % headphones.CONFIG.GIT_BRANCH)
|
logger.error(
|
||||||
|
'Could not retrieve branch name from git. Falling back to %s' % headphones.CONFIG.GIT_BRANCH)
|
||||||
branch_name = headphones.CONFIG.GIT_BRANCH
|
branch_name = headphones.CONFIG.GIT_BRANCH
|
||||||
if not branch_name:
|
if not branch_name:
|
||||||
logger.error('Could not retrieve branch name from git. Defaulting to master')
|
logger.error('Could not retrieve branch name from git. Defaulting to master')
|
||||||
@@ -123,11 +123,13 @@ def checkGithub():
|
|||||||
|
|
||||||
# Get the latest version available from github
|
# Get the latest version available from github
|
||||||
logger.info('Retrieving latest version information from GitHub')
|
logger.info('Retrieving latest version information from GitHub')
|
||||||
url = 'https://api.github.com/repos/%s/headphones/commits/%s' % (headphones.CONFIG.GIT_USER, headphones.CONFIG.GIT_BRANCH)
|
url = 'https://api.github.com/repos/%s/headphones/commits/%s' % (
|
||||||
|
headphones.CONFIG.GIT_USER, headphones.CONFIG.GIT_BRANCH)
|
||||||
version = request.request_json(url, timeout=20, validator=lambda x: type(x) == dict)
|
version = request.request_json(url, timeout=20, validator=lambda x: type(x) == dict)
|
||||||
|
|
||||||
if version is None:
|
if version is None:
|
||||||
logger.warn('Could not get the latest version from GitHub. Are you running a local development version?')
|
logger.warn(
|
||||||
|
'Could not get the latest version from GitHub. Are you running a local development version?')
|
||||||
return headphones.CURRENT_VERSION
|
return headphones.CURRENT_VERSION
|
||||||
|
|
||||||
headphones.LATEST_VERSION = version['sha']
|
headphones.LATEST_VERSION = version['sha']
|
||||||
@@ -135,7 +137,8 @@ def checkGithub():
|
|||||||
|
|
||||||
# See how many commits behind we are
|
# See how many commits behind we are
|
||||||
if not headphones.CURRENT_VERSION:
|
if not headphones.CURRENT_VERSION:
|
||||||
logger.info('You are running an unknown version of Headphones. Run the updater to identify your version')
|
logger.info(
|
||||||
|
'You are running an unknown version of Headphones. Run the updater to identify your version')
|
||||||
return headphones.LATEST_VERSION
|
return headphones.LATEST_VERSION
|
||||||
|
|
||||||
if headphones.LATEST_VERSION == headphones.CURRENT_VERSION:
|
if headphones.LATEST_VERSION == headphones.CURRENT_VERSION:
|
||||||
@@ -143,8 +146,10 @@ def checkGithub():
|
|||||||
return headphones.LATEST_VERSION
|
return headphones.LATEST_VERSION
|
||||||
|
|
||||||
logger.info('Comparing currently installed version with latest GitHub version')
|
logger.info('Comparing currently installed version with latest GitHub version')
|
||||||
url = 'https://api.github.com/repos/%s/headphones/compare/%s...%s' % (headphones.CONFIG.GIT_USER, headphones.LATEST_VERSION, headphones.CURRENT_VERSION)
|
url = 'https://api.github.com/repos/%s/headphones/compare/%s...%s' % (
|
||||||
commits = request.request_json(url, timeout=20, whitelist_status_code=404, validator=lambda x: type(x) == dict)
|
headphones.CONFIG.GIT_USER, headphones.LATEST_VERSION, headphones.CURRENT_VERSION)
|
||||||
|
commits = request.request_json(url, timeout=20, whitelist_status_code=404,
|
||||||
|
validator=lambda x: type(x) == dict)
|
||||||
|
|
||||||
if commits is None:
|
if commits is None:
|
||||||
logger.warn('Could not get commits behind from GitHub.')
|
logger.warn('Could not get commits behind from GitHub.')
|
||||||
@@ -158,7 +163,8 @@ def checkGithub():
|
|||||||
headphones.COMMITS_BEHIND = 0
|
headphones.COMMITS_BEHIND = 0
|
||||||
|
|
||||||
if headphones.COMMITS_BEHIND > 0:
|
if headphones.COMMITS_BEHIND > 0:
|
||||||
logger.info('New version is available. You are %s commits behind' % headphones.COMMITS_BEHIND)
|
logger.info(
|
||||||
|
'New version is available. You are %s commits behind' % headphones.COMMITS_BEHIND)
|
||||||
elif headphones.COMMITS_BEHIND == 0:
|
elif headphones.COMMITS_BEHIND == 0:
|
||||||
logger.info('Headphones is up to date')
|
logger.info('Headphones is up to date')
|
||||||
|
|
||||||
@@ -185,7 +191,8 @@ def update():
|
|||||||
logger.info('Output: ' + str(output))
|
logger.info('Output: ' + str(output))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
tar_download_url = 'https://github.com/%s/headphones/tarball/%s' % (headphones.CONFIG.GIT_USER, headphones.CONFIG.GIT_BRANCH)
|
tar_download_url = 'https://github.com/%s/headphones/tarball/%s' % (
|
||||||
|
headphones.CONFIG.GIT_USER, headphones.CONFIG.GIT_BRANCH)
|
||||||
update_dir = os.path.join(headphones.PROG_DIR, 'update')
|
update_dir = os.path.join(headphones.PROG_DIR, 'update')
|
||||||
version_path = os.path.join(headphones.PROG_DIR, 'version.txt')
|
version_path = os.path.join(headphones.PROG_DIR, 'version.txt')
|
||||||
|
|
||||||
@@ -214,7 +221,8 @@ def update():
|
|||||||
os.remove(tar_download_path)
|
os.remove(tar_download_path)
|
||||||
|
|
||||||
# Find update dir name
|
# Find update dir name
|
||||||
update_dir_contents = [x for x in os.listdir(update_dir) if os.path.isdir(os.path.join(update_dir, x))]
|
update_dir_contents = [x for x in os.listdir(update_dir) if
|
||||||
|
os.path.isdir(os.path.join(update_dir, x))]
|
||||||
if len(update_dir_contents) != 1:
|
if len(update_dir_contents) != 1:
|
||||||
logger.error("Invalid update data, update failed: " + str(update_dir_contents))
|
logger.error("Invalid update data, update failed: " + str(update_dir_contents))
|
||||||
return
|
return
|
||||||
|
|||||||
+559
-241
File diff suppressed because it is too large
Load Diff
+15
-14
@@ -13,36 +13,35 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
import os
|
||||||
import cherrypy
|
import cherrypy
|
||||||
import headphones
|
import headphones
|
||||||
|
|
||||||
from headphones import logger
|
from headphones import logger
|
||||||
from headphones.webserve import WebInterface
|
from headphones.webserve import WebInterface
|
||||||
from headphones.helpers import create_https_certificates
|
from headphones.helpers import create_https_certificates
|
||||||
|
|
||||||
|
|
||||||
def initialize(options=None):
|
def initialize(options):
|
||||||
if options is None:
|
|
||||||
options = {}
|
|
||||||
|
|
||||||
# HTTPS stuff stolen from sickbeard
|
# HTTPS stuff stolen from sickbeard
|
||||||
enable_https = options['enable_https']
|
enable_https = options['enable_https']
|
||||||
https_cert = options['https_cert']
|
https_cert = options['https_cert']
|
||||||
https_key = options['https_key']
|
https_key = options['https_key']
|
||||||
|
|
||||||
if enable_https:
|
if enable_https:
|
||||||
# If either the HTTPS certificate or key do not exist, make some self-signed ones.
|
# If either the HTTPS certificate or key do not exist, try to make
|
||||||
if not (https_cert and os.path.exists(https_cert)) or not (https_key and os.path.exists(https_key)):
|
# self-signed ones.
|
||||||
|
if not (https_cert and os.path.exists(https_cert)) or not (
|
||||||
|
https_key and os.path.exists(https_key)):
|
||||||
if not create_https_certificates(https_cert, https_key):
|
if not create_https_certificates(https_cert, https_key):
|
||||||
logger.warn(u"Unable to create cert/key files, disabling HTTPS")
|
logger.warn("Unable to create certificate and key. Disabling " \
|
||||||
headphones.CONFIG.ENABLE_HTTPS = False
|
"HTTPS")
|
||||||
enable_https = False
|
enable_https = False
|
||||||
|
|
||||||
if not (os.path.exists(https_cert) and os.path.exists(https_key)):
|
if not (os.path.exists(https_cert) and os.path.exists(https_key)):
|
||||||
logger.warn(u"Disabled HTTPS because of missing CERT and KEY files")
|
logger.warn("Disabled HTTPS because of missing certificate and " \
|
||||||
headphones.CONFIG.ENABLE_HTTPS = False
|
"key.")
|
||||||
enable_https = False
|
enable_https = False
|
||||||
|
|
||||||
options_dict = {
|
options_dict = {
|
||||||
@@ -100,7 +99,8 @@ def initialize(options=None):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if options['http_password']:
|
if options['http_password']:
|
||||||
logger.info("Web server authentication is enabled, username is '%s'", options['http_username'])
|
logger.info("Web server authentication is enabled, username is '%s'",
|
||||||
|
options['http_username'])
|
||||||
|
|
||||||
conf['/'].update({
|
conf['/'].update({
|
||||||
'tools.auth_basic.on': True,
|
'tools.auth_basic.on': True,
|
||||||
@@ -119,7 +119,8 @@ def initialize(options=None):
|
|||||||
cherrypy.process.servers.check_port(str(options['http_host']), options['http_port'])
|
cherrypy.process.servers.check_port(str(options['http_host']), options['http_port'])
|
||||||
cherrypy.server.start()
|
cherrypy.server.start()
|
||||||
except IOError:
|
except IOError:
|
||||||
sys.stderr.write('Failed to start on port: %i. Is something else running?\n' % (options['http_port']))
|
sys.stderr.write(
|
||||||
|
'Failed to start on port: %i. Is something else running?\n' % (options['http_port']))
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
cherrypy.server.wait()
|
cherrypy.server.wait()
|
||||||
|
|||||||
@@ -27,17 +27,17 @@ rcvar=${name}_enable
|
|||||||
|
|
||||||
load_rc_config ${name}
|
load_rc_config ${name}
|
||||||
|
|
||||||
: ${headphones_enable:="NO"}
|
: "${headphones_enable:="NO"}"
|
||||||
: ${headphones_user:="_sabnzbd"}
|
: "${headphones_user:="_sabnzbd"}"
|
||||||
: ${headphones_dir:="/usr/local/headphones"}
|
: "${headphones_dir:="/usr/local/headphones"}"
|
||||||
: ${headphones_chdir:="${headphones_dir}"}
|
: "${headphones_chdir:="${headphones_dir}"}"
|
||||||
: ${headphones_pid:="${headphones_dir}/headphones.pid"}
|
: "${headphones_pid:="${headphones_dir}/headphones.pid"}"
|
||||||
: ${headphones_conf:="${headphones_dir}/config.ini"}
|
: "${headphones_conf:="${headphones_dir}/config.ini"}"
|
||||||
|
|
||||||
WGET="/usr/local/bin/wget" # You need wget for this script to safely shutdown Headphones.
|
WGET="/usr/local/bin/wget" # You need wget for this script to safely shutdown Headphones.
|
||||||
if [ -e "${headphones_conf}" ]; then
|
if [ -e "${headphones_conf}" ]; then
|
||||||
HOST=`grep -A64 "\[General\]" "${headphones_conf}"|egrep "^http_host"|perl -wple 's/^http_host = (.*)$/$1/'`
|
HOST=$(grep -A64 "\[General\]" "${headphones_conf}"|egrep "^http_host"|perl -wple 's/^http_host = (.*)$/$1/')
|
||||||
PORT=`grep -A64 "\[General\]" "${headphones_conf}"|egrep "^http_port"|perl -wple 's/^http_port = (.*)$/$1/'`
|
PORT=$(grep -A64 "\[General\]" "${headphones_conf}"|egrep "^http_port"|perl -wple 's/^http_port = (.*)$/$1/')
|
||||||
fi
|
fi
|
||||||
|
|
||||||
status_cmd="${name}_status"
|
status_cmd="${name}_status"
|
||||||
@@ -53,15 +53,15 @@ if [ ! -x "${WGET}" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure user is root when running this script.
|
# Ensure user is root when running this script.
|
||||||
if [ `id -u` != "0" ]; then
|
if [ "$(id -u)" != "0" ]; then
|
||||||
echo "Oops, you should be root before running this!"
|
echo "Oops, you should be root before running this!"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
verify_headphones_pid() {
|
verify_headphones_pid() {
|
||||||
# Make sure the pid corresponds to the Headphones process.
|
# Make sure the pid corresponds to the Headphones process.
|
||||||
pid=`cat ${headphones_pid} 2>/dev/null`
|
pid=$(cat "${headphones_pid}" 2>/dev/null)
|
||||||
ps -p ${pid} | grep -q "python ${headphones_dir}/Headphones.py"
|
pgrep -F "${headphones_pid}" -q "python ${headphones_dir}/Headphones.py"
|
||||||
return $?
|
return $?
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,10 +73,10 @@ headphones_stop() {
|
|||||||
fi
|
fi
|
||||||
echo "Stopping $name"
|
echo "Stopping $name"
|
||||||
verify_headphones_pid
|
verify_headphones_pid
|
||||||
${WGET} -O - -q --user=${SBUSR} --password=${SBPWD} "http://${HOST}:${PORT}/shutdown/" >/dev/null
|
${WGET} -O - -q --user="${SBUSR}" --password="${SBPWD}" "http://${HOST}:${PORT}/shutdown/" >/dev/null
|
||||||
|
|
||||||
if [ -n "${pid}" ]; then
|
if [ -n "${pid}" ]; then
|
||||||
wait_for_pids ${pid}
|
wait_for_pids "${pid}"
|
||||||
echo "Stopped $name"
|
echo "Stopped $name"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ rcvar=${name}_enable
|
|||||||
|
|
||||||
load_rc_config ${name}
|
load_rc_config ${name}
|
||||||
|
|
||||||
: ${headphones_enable:="NO"}
|
: "${headphones_enable:="NO"}"
|
||||||
: ${headphones_user:="_sabnzbd"}
|
: "${headphones_user:="_sabnzbd"}"
|
||||||
: ${headphones_dir:="/usr/local/headphones"}
|
: "${headphones_dir:="/usr/local/headphones"}"
|
||||||
: ${headphones_chdir:="${headphones_dir}"}
|
: "${headphones_chdir:="${headphones_dir}"}"
|
||||||
: ${headphones_pid:="${headphones_dir}/headphones.pid"}
|
: "${headphones_pid:="${headphones_dir}/headphones.pid"}"
|
||||||
|
|
||||||
status_cmd="${name}_status"
|
status_cmd="${name}_status"
|
||||||
stop_cmd="${name}_stop"
|
stop_cmd="${name}_stop"
|
||||||
@@ -41,15 +41,15 @@ command="/usr/sbin/daemon"
|
|||||||
command_args="-f -p ${headphones_pid} python ${headphones_dir}/Headphones.py ${headphones_flags} --quiet --nolaunch"
|
command_args="-f -p ${headphones_pid} python ${headphones_dir}/Headphones.py ${headphones_flags} --quiet --nolaunch"
|
||||||
|
|
||||||
# Ensure user is root when running this script.
|
# Ensure user is root when running this script.
|
||||||
if [ `id -u` != "0" ]; then
|
if [ "$(id -u)" != "0" ]; then
|
||||||
echo "Oops, you should be root before running this!"
|
echo "Oops, you should be root before running this!"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
verify_headphones_pid() {
|
verify_headphones_pid() {
|
||||||
# Make sure the pid corresponds to the Headphones process.
|
# Make sure the pid corresponds to the Headphones process.
|
||||||
pid=`cat ${headphones_pid} 2>/dev/null`
|
pid=$(cat "${headphones_pid}" 2>/dev/null)
|
||||||
ps -p ${pid} | grep -q "python ${headphones_dir}/Headphones.py"
|
pgrep -F "${headphones_pid}" -q "python ${headphones_dir}/Headphones.py"
|
||||||
return $?
|
return $?
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ headphones_stop() {
|
|||||||
echo "Stopping $name"
|
echo "Stopping $name"
|
||||||
verify_headphones_pid
|
verify_headphones_pid
|
||||||
if [ -n "${pid}" ]; then
|
if [ -n "${pid}" ]; then
|
||||||
wait_for_pids ${pid}
|
wait_for_pids "${pid}"
|
||||||
echo "Stopped"
|
echo "Stopped"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>Label</key>
|
<key>Label</key>
|
||||||
<string>com.headphones.headphones</string>
|
<string>headphones</string>
|
||||||
<key>ProgramArguments</key>
|
<key>ProgramArguments</key>
|
||||||
<array>
|
<array>
|
||||||
|
<!-- Modify these two lines if you need to to reflect your python location and Headphones install location -->
|
||||||
<string>/usr/bin/python</string>
|
<string>/usr/bin/python</string>
|
||||||
<string>/Applications/Headphones/headphones.py</string>
|
<string>/Applications/Headphones/Headphones.py</string>
|
||||||
<string>--quiet</string>
|
|
||||||
<string>--daemon</string>
|
|
||||||
<string>--nolaunch</string>
|
|
||||||
</array>
|
</array>
|
||||||
<key>RunAtLoad</key>
|
<key>RunAtLoad</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
+12
-16
@@ -32,7 +32,6 @@
|
|||||||
## HP_PIDFILE= #$PID_FILE, the location of headphones.pid, the default is /var/run/headphones/headphones.pid
|
## HP_PIDFILE= #$PID_FILE, the location of headphones.pid, the default is /var/run/headphones/headphones.pid
|
||||||
## PYTHON_BIN= #$DAEMON, the location of the python binary, the default is /usr/bin/python
|
## PYTHON_BIN= #$DAEMON, the location of the python binary, the default is /usr/bin/python
|
||||||
## HP_OPTS= #$EXTRA_DAEMON_OPTS, extra cli option for headphones, i.e. " --config=/home/headphones/config.ini"
|
## HP_OPTS= #$EXTRA_DAEMON_OPTS, extra cli option for headphones, i.e. " --config=/home/headphones/config.ini"
|
||||||
## SSD_OPTS= #$EXTRA_SSD_OPTS, extra start-stop-daemon option like " --group=users"
|
|
||||||
## HP_PORT= #$PORT_OPTS, hardcoded port for the webserver, overrides value in config.ini
|
## HP_PORT= #$PORT_OPTS, hardcoded port for the webserver, overrides value in config.ini
|
||||||
##
|
##
|
||||||
## EXAMPLE if want to run as different user
|
## EXAMPLE if want to run as different user
|
||||||
@@ -101,9 +100,6 @@ load_settings() {
|
|||||||
# Extra daemon option like: HP_OPTS=" --config=/home/headphones/config.ini"
|
# Extra daemon option like: HP_OPTS=" --config=/home/headphones/config.ini"
|
||||||
EXTRA_DAEMON_OPTS=${HP_OPTS-}
|
EXTRA_DAEMON_OPTS=${HP_OPTS-}
|
||||||
|
|
||||||
# Extra start-stop-daemon option like START_OPTS=" --group=users"
|
|
||||||
EXTRA_SSD_OPTS=${SSD_OPTS-}
|
|
||||||
|
|
||||||
# Hardcoded port to run on, overrides config.ini settings
|
# Hardcoded port to run on, overrides config.ini settings
|
||||||
[ -n "$HP_PORT" ] && {
|
[ -n "$HP_PORT" ] && {
|
||||||
PORT_OPTS=" --port=${HP_PORT} "
|
PORT_OPTS=" --port=${HP_PORT} "
|
||||||
@@ -114,7 +110,7 @@ load_settings() {
|
|||||||
SETTINGS_LOADED=TRUE
|
SETTINGS_LOADED=TRUE
|
||||||
fi
|
fi
|
||||||
|
|
||||||
[ -x $DAEMON ] || {
|
[ -x "$DAEMON" ] || {
|
||||||
log_warning_msg "$DESC: Can't execute daemon, aborting. See $DAEMON";
|
log_warning_msg "$DESC: Can't execute daemon, aborting. See $DAEMON";
|
||||||
return 1;}
|
return 1;}
|
||||||
|
|
||||||
@@ -125,8 +121,8 @@ load_settings || exit 0
|
|||||||
|
|
||||||
is_running () {
|
is_running () {
|
||||||
# returns 1 when running, else 0.
|
# returns 1 when running, else 0.
|
||||||
if [ -e $PID_FILE ]; then
|
if [ -e "$PID_FILE" ]; then
|
||||||
PID=`cat $PID_FILE`
|
PID=$(cat "$PID_FILE")
|
||||||
|
|
||||||
RET=$?
|
RET=$?
|
||||||
[ $RET -gt 1 ] && exit 1 || return $RET
|
[ $RET -gt 1 ] && exit 1 || return $RET
|
||||||
@@ -136,28 +132,28 @@ is_running () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handle_pid () {
|
handle_pid () {
|
||||||
PID_PATH=`dirname $PID_FILE`
|
PID_PATH=$(dirname "$PID_FILE")
|
||||||
[ -d $PID_PATH ] || mkdir -p $PID_PATH && chown -R $RUN_AS $PID_PATH > /dev/null || {
|
[ -d "$PID_PATH" ] || mkdir -p "$PID_PATH" && chown -R "$RUN_AS" "$PID_PATH" > /dev/null || {
|
||||||
log_warning_msg "$DESC: Could not create $PID_FILE, See $SETTINGS, aborting.";
|
log_warning_msg "$DESC: Could not create $PID_FILE, See $SETTINGS, aborting.";
|
||||||
return 1;}
|
return 1;}
|
||||||
|
|
||||||
if [ -e $PID_FILE ]; then
|
if [ -e "$PID_FILE" ]; then
|
||||||
PID=`cat $PID_FILE`
|
PID=$(cat "$PID_FILE")
|
||||||
if ! kill -0 $PID > /dev/null 2>&1; then
|
if ! kill -0 "$PID" > /dev/null 2>&1; then
|
||||||
log_warning_msg "Removing stale $PID_FILE"
|
log_warning_msg "Removing stale $PID_FILE"
|
||||||
rm $PID_FILE
|
rm "$PID_FILE"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
handle_datadir () {
|
handle_datadir () {
|
||||||
[ -d $DATA_DIR ] || mkdir -p $DATA_DIR && chown -R $RUN_AS $DATA_DIR > /dev/null || {
|
[ -d "$DATA_DIR" ] || mkdir -p "$DATA_DIR" && chown -R "$RUN_AS" "$DATA_DIR" > /dev/null || {
|
||||||
log_warning_msg "$DESC: Could not create $DATA_DIR, See $SETTINGS, aborting.";
|
log_warning_msg "$DESC: Could not create $DATA_DIR, See $SETTINGS, aborting.";
|
||||||
return 1;}
|
return 1;}
|
||||||
}
|
}
|
||||||
|
|
||||||
handle_updates () {
|
handle_updates () {
|
||||||
chown -R $RUN_AS $APP_PATH > /dev/null || {
|
chown -R "$RUN_AS" "$APP_PATH" > /dev/null || {
|
||||||
log_warning_msg "$DESC: $APP_PATH not writable by $RUN_AS for web-updates";
|
log_warning_msg "$DESC: $APP_PATH not writable by $RUN_AS for web-updates";
|
||||||
return 0; }
|
return 0; }
|
||||||
}
|
}
|
||||||
@@ -178,7 +174,7 @@ start_headphones () {
|
|||||||
stop_headphones () {
|
stop_headphones () {
|
||||||
if is_running; then
|
if is_running; then
|
||||||
log_daemon_msg "Stopping $DESC"
|
log_daemon_msg "Stopping $DESC"
|
||||||
start-stop-daemon -o --stop --pidfile $PID_FILE --retry 15
|
start-stop-daemon -o --stop --pidfile "$PID_FILE" --retry 15
|
||||||
check_retval
|
check_retval
|
||||||
else
|
else
|
||||||
log_success_msg "$DESC: not running"
|
log_success_msg "$DESC: not running"
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2010-2014 Adrian Sampson
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
.. image:: https://travis-ci.org/sampsyo/beets.svg?branch=master
|
||||||
|
:target: https://travis-ci.org/sampsyo/beets
|
||||||
|
|
||||||
|
.. image:: http://img.shields.io/coveralls/sampsyo/beets.svg
|
||||||
|
:target: https://coveralls.io/r/sampsyo/beets
|
||||||
|
|
||||||
|
.. image:: http://img.shields.io/pypi/v/beets.svg
|
||||||
|
:target: https://pypi.python.org/pypi/beets
|
||||||
|
|
||||||
|
Beets is the media library management system for obsessive-compulsive music
|
||||||
|
geeks.
|
||||||
|
|
||||||
|
The purpose of beets is to get your music collection right once and for all.
|
||||||
|
It catalogs your collection, automatically improving its metadata as it goes.
|
||||||
|
It then provides a bouquet of tools for manipulating and accessing your music.
|
||||||
|
|
||||||
|
Here's an example of beets' brainy tag corrector doing its thing::
|
||||||
|
|
||||||
|
$ beet import ~/music/ladytron
|
||||||
|
Tagging:
|
||||||
|
Ladytron - Witching Hour
|
||||||
|
(Similarity: 98.4%)
|
||||||
|
* Last One Standing -> The Last One Standing
|
||||||
|
* Beauty -> Beauty*2
|
||||||
|
* White Light Generation -> Whitelightgenerator
|
||||||
|
* All the Way -> All the Way...
|
||||||
|
|
||||||
|
Because beets is designed as a library, it can do almost anything you can
|
||||||
|
imagine for your music collection. Via `plugins`_, beets becomes a panacea:
|
||||||
|
|
||||||
|
- Fetch or calculate all the metadata you could possibly need: `album art`_,
|
||||||
|
`lyrics`_, `genres`_, `tempos`_, `ReplayGain`_ levels, or `acoustic
|
||||||
|
fingerprints`_.
|
||||||
|
- Get metadata from `MusicBrainz`_, `Discogs`_, or `Beatport`_. Or guess
|
||||||
|
metadata using songs' filenames or their acoustic fingerprints.
|
||||||
|
- `Transcode audio`_ to any format you like.
|
||||||
|
- Check your library for `duplicate tracks and albums`_ or for `albums that
|
||||||
|
are missing tracks`_.
|
||||||
|
- Clean up crufty tags left behind by other, less-awesome tools.
|
||||||
|
- Embed and extract album art from files' metadata.
|
||||||
|
- Browse your music library graphically through a Web browser and play it in any
|
||||||
|
browser that supports `HTML5 Audio`_.
|
||||||
|
- Analyze music files' metadata from the command line.
|
||||||
|
- Listen to your library with a music player that speaks the `MPD`_ protocol
|
||||||
|
and works with a staggering variety of interfaces.
|
||||||
|
|
||||||
|
If beets doesn't do what you want yet, `writing your own plugin`_ is
|
||||||
|
shockingly simple if you know a little Python.
|
||||||
|
|
||||||
|
.. _plugins: http://beets.readthedocs.org/page/plugins/
|
||||||
|
.. _MPD: http://www.musicpd.org/
|
||||||
|
.. _MusicBrainz music collection: http://musicbrainz.org/doc/Collections/
|
||||||
|
.. _writing your own plugin:
|
||||||
|
http://beets.readthedocs.org/page/dev/plugins.html
|
||||||
|
.. _HTML5 Audio:
|
||||||
|
http://www.w3.org/TR/html-markup/audio.html
|
||||||
|
.. _albums that are missing tracks:
|
||||||
|
http://beets.readthedocs.org/page/plugins/missing.html
|
||||||
|
.. _duplicate tracks and albums:
|
||||||
|
http://beets.readthedocs.org/page/plugins/duplicates.html
|
||||||
|
.. _Transcode audio:
|
||||||
|
http://beets.readthedocs.org/page/plugins/convert.html
|
||||||
|
.. _Beatport: http://www.beatport.com/
|
||||||
|
.. _Discogs: http://www.discogs.com/
|
||||||
|
.. _acoustic fingerprints:
|
||||||
|
http://beets.readthedocs.org/page/plugins/chroma.html
|
||||||
|
.. _ReplayGain: http://beets.readthedocs.org/page/plugins/replaygain.html
|
||||||
|
.. _tempos: http://beets.readthedocs.org/page/plugins/echonest.html
|
||||||
|
.. _genres: http://beets.readthedocs.org/page/plugins/lastgenre.html
|
||||||
|
.. _album art: http://beets.readthedocs.org/page/plugins/fetchart.html
|
||||||
|
.. _lyrics: http://beets.readthedocs.org/page/plugins/lyrics.html
|
||||||
|
.. _MusicBrainz: http://musicbrainz.org/
|
||||||
|
|
||||||
|
Read More
|
||||||
|
---------
|
||||||
|
|
||||||
|
Learn more about beets at `its Web site`_. Follow `@b33ts`_ on Twitter for
|
||||||
|
news and updates.
|
||||||
|
|
||||||
|
You can install beets by typing ``pip install beets``. Then check out the
|
||||||
|
`Getting Started`_ guide.
|
||||||
|
|
||||||
|
.. _its Web site: http://beets.radbox.org/
|
||||||
|
.. _Getting Started: http://beets.readthedocs.org/page/guides/main.html
|
||||||
|
.. _@b33ts: http://twitter.com/b33ts/
|
||||||
|
|
||||||
|
Authors
|
||||||
|
-------
|
||||||
|
|
||||||
|
Beets is by `Adrian Sampson`_ with a supporting cast of thousands. For help,
|
||||||
|
please contact the `mailing list`_.
|
||||||
|
|
||||||
|
.. _mailing list: https://groups.google.com/forum/#!forum/beets-users
|
||||||
|
.. _Adrian Sampson: http://homes.cs.washington.edu/~asampson/
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# This file is part of beets.
|
# This file is part of beets.
|
||||||
# Copyright 2013, Adrian Sampson.
|
# Copyright 2014, Adrian Sampson.
|
||||||
#
|
#
|
||||||
# Permission is hereby granted, free of charge, to any person obtaining
|
# Permission is hereby granted, free of charge, to any person obtaining
|
||||||
# a copy of this software and associated documentation files (the
|
# a copy of this software and associated documentation files (the
|
||||||
@@ -12,13 +12,14 @@
|
|||||||
# The above copyright notice and this permission notice shall be
|
# The above copyright notice and this permission notice shall be
|
||||||
# included in all copies or substantial portions of the Software.
|
# included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
# This particular version has been slightly modified to work with headphones
|
# This particular version has been slightly modified to work with Headphones
|
||||||
# https://github.com/rembo10/headphones
|
# https://github.com/rembo10/headphones
|
||||||
import os
|
|
||||||
|
|
||||||
__version__ = '1.3.4'
|
__version__ = '1.3.10-headphones'
|
||||||
__author__ = 'Adrian Sampson <adrian@radbox.org>'
|
__author__ = 'Adrian Sampson <adrian@radbox.org>'
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
import beets.library
|
import beets.library
|
||||||
from beets.util import confit
|
from beets.util import confit
|
||||||
|
|
||||||
|
|||||||
+10
-121
@@ -14,135 +14,21 @@
|
|||||||
|
|
||||||
"""Facilities for automatically determining files' correct metadata.
|
"""Facilities for automatically determining files' correct metadata.
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
|
|
||||||
from beets import library, mediafile, config
|
from beets import config
|
||||||
from beets.util import sorted_walk, ancestry, displayable_path
|
|
||||||
|
|
||||||
# Parts of external interface.
|
# Parts of external interface.
|
||||||
from .hooks import AlbumInfo, TrackInfo, AlbumMatch, TrackMatch
|
from .hooks import AlbumInfo, TrackInfo, AlbumMatch, TrackMatch # noqa
|
||||||
from .match import tag_item, tag_album
|
from .match import tag_item, tag_album # noqa
|
||||||
from .match import recommendation
|
from .match import Recommendation # noqa
|
||||||
|
|
||||||
# Global logger.
|
# Global logger.
|
||||||
log = logging.getLogger('beets')
|
log = logging.getLogger('beets')
|
||||||
|
|
||||||
# Constants for directory walker.
|
|
||||||
MULTIDISC_MARKERS = (r'dis[ck]', r'cd')
|
|
||||||
MULTIDISC_PAT_FMT = r'^(.*%s[\W_]*)\d'
|
|
||||||
|
|
||||||
|
|
||||||
# Additional utilities for the main interface.
|
# Additional utilities for the main interface.
|
||||||
|
|
||||||
def albums_in_dir(path):
|
|
||||||
"""Recursively searches the given directory and returns an iterable
|
|
||||||
of (paths, items) where paths is a list of directories and items is
|
|
||||||
a list of Items that is probably an album. Specifically, any folder
|
|
||||||
containing any media files is an album.
|
|
||||||
"""
|
|
||||||
collapse_pat = collapse_paths = collapse_items = None
|
|
||||||
|
|
||||||
for root, dirs, files in sorted_walk(path,
|
|
||||||
ignore=config['ignore'].as_str_seq(),
|
|
||||||
logger=log):
|
|
||||||
# Get a list of items in the directory.
|
|
||||||
items = []
|
|
||||||
for filename in files:
|
|
||||||
try:
|
|
||||||
i = library.Item.from_path(os.path.join(root, filename))
|
|
||||||
except mediafile.FileTypeError:
|
|
||||||
pass
|
|
||||||
except mediafile.UnreadableFileError:
|
|
||||||
log.warn(u'unreadable file: {0}'.format(
|
|
||||||
displayable_path(filename))
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
items.append(i)
|
|
||||||
|
|
||||||
# If we're currently collapsing the constituent directories in a
|
|
||||||
# multi-disc album, check whether we should continue collapsing
|
|
||||||
# and add the current directory. If so, just add the directory
|
|
||||||
# and move on to the next directory. If not, stop collapsing.
|
|
||||||
if collapse_paths:
|
|
||||||
if (not collapse_pat and collapse_paths[0] in ancestry(root)) or \
|
|
||||||
(collapse_pat and
|
|
||||||
collapse_pat.match(os.path.basename(root))):
|
|
||||||
# Still collapsing.
|
|
||||||
collapse_paths.append(root)
|
|
||||||
collapse_items += items
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
# Collapse finished. Yield the collapsed directory and
|
|
||||||
# proceed to process the current one.
|
|
||||||
if collapse_items:
|
|
||||||
yield collapse_paths, collapse_items
|
|
||||||
collapse_pat = collapse_paths = collapse_items = None
|
|
||||||
|
|
||||||
# Check whether this directory looks like the *first* directory
|
|
||||||
# in a multi-disc sequence. There are two indicators: the file
|
|
||||||
# is named like part of a multi-disc sequence (e.g., "Title Disc
|
|
||||||
# 1") or it contains no items but only directories that are
|
|
||||||
# named in this way.
|
|
||||||
start_collapsing = False
|
|
||||||
for marker in MULTIDISC_MARKERS:
|
|
||||||
marker_pat = re.compile(MULTIDISC_PAT_FMT % marker, re.I)
|
|
||||||
match = marker_pat.match(os.path.basename(root))
|
|
||||||
|
|
||||||
# Is this directory the root of a nested multi-disc album?
|
|
||||||
if dirs and not items:
|
|
||||||
# Check whether all subdirectories have the same prefix.
|
|
||||||
start_collapsing = True
|
|
||||||
subdir_pat = None
|
|
||||||
for subdir in dirs:
|
|
||||||
# The first directory dictates the pattern for
|
|
||||||
# the remaining directories.
|
|
||||||
if not subdir_pat:
|
|
||||||
match = marker_pat.match(subdir)
|
|
||||||
if match:
|
|
||||||
subdir_pat = re.compile(r'^%s\d' %
|
|
||||||
re.escape(match.group(1)), re.I)
|
|
||||||
else:
|
|
||||||
start_collapsing = False
|
|
||||||
break
|
|
||||||
|
|
||||||
# Subsequent directories must match the pattern.
|
|
||||||
elif not subdir_pat.match(subdir):
|
|
||||||
start_collapsing = False
|
|
||||||
break
|
|
||||||
|
|
||||||
# If all subdirectories match, don't check other
|
|
||||||
# markers.
|
|
||||||
if start_collapsing:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Is this directory the first in a flattened multi-disc album?
|
|
||||||
elif match:
|
|
||||||
start_collapsing = True
|
|
||||||
# Set the current pattern to match directories with the same
|
|
||||||
# prefix as this one, followed by a digit.
|
|
||||||
collapse_pat = re.compile(r'^%s\d' %
|
|
||||||
re.escape(match.group(1)), re.I)
|
|
||||||
break
|
|
||||||
|
|
||||||
# If either of the above heuristics indicated that this is the
|
|
||||||
# beginning of a multi-disc album, initialize the collapsed
|
|
||||||
# directory and item lists and check the next directory.
|
|
||||||
if start_collapsing:
|
|
||||||
# Start collapsing; continue to the next iteration.
|
|
||||||
collapse_paths = [root]
|
|
||||||
collapse_items = items
|
|
||||||
continue
|
|
||||||
|
|
||||||
# If it's nonempty, yield it.
|
|
||||||
if items:
|
|
||||||
yield [root], items
|
|
||||||
|
|
||||||
# Clear out any unfinished collapse.
|
|
||||||
if collapse_paths and collapse_items:
|
|
||||||
yield collapse_paths, collapse_items
|
|
||||||
|
|
||||||
def apply_item_metadata(item, track_info):
|
def apply_item_metadata(item, track_info):
|
||||||
"""Set an item's metadata from its matched TrackInfo object.
|
"""Set an item's metadata from its matched TrackInfo object.
|
||||||
"""
|
"""
|
||||||
@@ -156,6 +42,7 @@ def apply_item_metadata(item, track_info):
|
|||||||
# At the moment, the other metadata is left intact (including album
|
# At the moment, the other metadata is left intact (including album
|
||||||
# and track number). Perhaps these should be emptied?
|
# and track number). Perhaps these should be emptied?
|
||||||
|
|
||||||
|
|
||||||
def apply_metadata(album_info, mapping):
|
def apply_metadata(album_info, mapping):
|
||||||
"""Set the items' metadata to match an AlbumInfo object using a
|
"""Set the items' metadata to match an AlbumInfo object using a
|
||||||
mapping from Items to TrackInfo objects.
|
mapping from Items to TrackInfo objects.
|
||||||
@@ -171,8 +58,8 @@ def apply_metadata(album_info, mapping):
|
|||||||
|
|
||||||
# Artist sort and credit names.
|
# Artist sort and credit names.
|
||||||
item.artist_sort = track_info.artist_sort or album_info.artist_sort
|
item.artist_sort = track_info.artist_sort or album_info.artist_sort
|
||||||
item.artist_credit = track_info.artist_credit or \
|
item.artist_credit = (track_info.artist_credit or
|
||||||
album_info.artist_credit
|
album_info.artist_credit)
|
||||||
item.albumartist_sort = album_info.artist_sort
|
item.albumartist_sort = album_info.artist_sort
|
||||||
item.albumartist_credit = album_info.artist_credit
|
item.albumartist_credit = album_info.artist_credit
|
||||||
|
|
||||||
@@ -235,7 +122,6 @@ def apply_metadata(album_info, mapping):
|
|||||||
'language',
|
'language',
|
||||||
'country',
|
'country',
|
||||||
'albumstatus',
|
'albumstatus',
|
||||||
'media',
|
|
||||||
'albumdisambig'):
|
'albumdisambig'):
|
||||||
value = getattr(album_info, field)
|
value = getattr(album_info, field)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
@@ -243,5 +129,8 @@ def apply_metadata(album_info, mapping):
|
|||||||
if track_info.disctitle is not None:
|
if track_info.disctitle is not None:
|
||||||
item.disctitle = track_info.disctitle
|
item.disctitle = track_info.disctitle
|
||||||
|
|
||||||
|
if track_info.media is not None:
|
||||||
|
item.media = track_info.media
|
||||||
|
|
||||||
# Headphones seal of approval
|
# Headphones seal of approval
|
||||||
item.comments = 'tagged by headphones/beets'
|
item.comments = 'tagged by headphones/beets'
|
||||||
+46
-12
@@ -116,6 +116,7 @@ class AlbumInfo(object):
|
|||||||
for track in self.tracks:
|
for track in self.tracks:
|
||||||
track.decode(codec)
|
track.decode(codec)
|
||||||
|
|
||||||
|
|
||||||
class TrackInfo(object):
|
class TrackInfo(object):
|
||||||
"""Describes a canonical track present on a release. Appears as part
|
"""Describes a canonical track present on a release. Appears as part
|
||||||
of an AlbumInfo's ``tracks`` list. Consists of these data members:
|
of an AlbumInfo's ``tracks`` list. Consists of these data members:
|
||||||
@@ -126,6 +127,7 @@ class TrackInfo(object):
|
|||||||
- ``artist_id``
|
- ``artist_id``
|
||||||
- ``length``: float: duration of the track in seconds
|
- ``length``: float: duration of the track in seconds
|
||||||
- ``index``: position on the entire release
|
- ``index``: position on the entire release
|
||||||
|
- ``media``: delivery mechanism (Vinyl, etc.)
|
||||||
- ``medium``: the disc number this track appears on in the album
|
- ``medium``: the disc number this track appears on in the album
|
||||||
- ``medium_index``: the track's position on the disc
|
- ``medium_index``: the track's position on the disc
|
||||||
- ``medium_total``: the number of tracks on the item's disc
|
- ``medium_total``: the number of tracks on the item's disc
|
||||||
@@ -140,13 +142,15 @@ class TrackInfo(object):
|
|||||||
def __init__(self, title, track_id, artist=None, artist_id=None,
|
def __init__(self, title, track_id, artist=None, artist_id=None,
|
||||||
length=None, index=None, medium=None, medium_index=None,
|
length=None, index=None, medium=None, medium_index=None,
|
||||||
medium_total=None, artist_sort=None, disctitle=None,
|
medium_total=None, artist_sort=None, disctitle=None,
|
||||||
artist_credit=None, data_source=None, data_url=None):
|
artist_credit=None, data_source=None, data_url=None,
|
||||||
|
media=None):
|
||||||
self.title = title
|
self.title = title
|
||||||
self.track_id = track_id
|
self.track_id = track_id
|
||||||
self.artist = artist
|
self.artist = artist
|
||||||
self.artist_id = artist_id
|
self.artist_id = artist_id
|
||||||
self.length = length
|
self.length = length
|
||||||
self.index = index
|
self.index = index
|
||||||
|
self.media = media
|
||||||
self.medium = medium
|
self.medium = medium
|
||||||
self.medium_index = medium_index
|
self.medium_index = medium_index
|
||||||
self.medium_total = medium_total
|
self.medium_total = medium_total
|
||||||
@@ -162,7 +166,7 @@ class TrackInfo(object):
|
|||||||
to Unicode.
|
to Unicode.
|
||||||
"""
|
"""
|
||||||
for fld in ['title', 'artist', 'medium', 'artist_sort', 'disctitle',
|
for fld in ['title', 'artist', 'medium', 'artist_sort', 'disctitle',
|
||||||
'artist_credit']:
|
'artist_credit', 'media']:
|
||||||
value = getattr(self, fld)
|
value = getattr(self, fld)
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
setattr(self, fld, value.decode(codec, 'ignore'))
|
setattr(self, fld, value.decode(codec, 'ignore'))
|
||||||
@@ -187,6 +191,7 @@ SD_REPLACE = [
|
|||||||
(r'&', 'and'),
|
(r'&', 'and'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _string_dist_basic(str1, str2):
|
def _string_dist_basic(str1, str2):
|
||||||
"""Basic edit distance between two strings, ignoring
|
"""Basic edit distance between two strings, ignoring
|
||||||
non-alphanumeric characters and case. Comparisons are based on a
|
non-alphanumeric characters and case. Comparisons are based on a
|
||||||
@@ -201,13 +206,16 @@ def _string_dist_basic(str1, str2):
|
|||||||
return 0.0
|
return 0.0
|
||||||
return levenshtein(str1, str2) / float(max(len(str1), len(str2)))
|
return levenshtein(str1, str2) / float(max(len(str1), len(str2)))
|
||||||
|
|
||||||
|
|
||||||
def string_dist(str1, str2):
|
def string_dist(str1, str2):
|
||||||
"""Gives an "intuitive" edit distance between two strings. This is
|
"""Gives an "intuitive" edit distance between two strings. This is
|
||||||
an edit distance, normalized by the string length, with a number of
|
an edit distance, normalized by the string length, with a number of
|
||||||
tweaks that reflect intuition about text.
|
tweaks that reflect intuition about text.
|
||||||
"""
|
"""
|
||||||
if str1 is None and str2 is None: return 0.0
|
if str1 is None and str2 is None:
|
||||||
if str1 is None or str2 is None: return 1.0
|
return 0.0
|
||||||
|
if str1 is None or str2 is None:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
str1 = str1.lower()
|
str1 = str1.lower()
|
||||||
str2 = str2.lower()
|
str2 = str2.lower()
|
||||||
@@ -256,6 +264,23 @@ def string_dist(str1, str2):
|
|||||||
|
|
||||||
return base_dist + penalty
|
return base_dist + penalty
|
||||||
|
|
||||||
|
|
||||||
|
class LazyClassProperty(object):
|
||||||
|
"""A decorator implementing a read-only property that is *lazy* in
|
||||||
|
the sense that the getter is only invoked once. Subsequent accesses
|
||||||
|
through *any* instance use the cached result.
|
||||||
|
"""
|
||||||
|
def __init__(self, getter):
|
||||||
|
self.getter = getter
|
||||||
|
self.computed = False
|
||||||
|
|
||||||
|
def __get__(self, obj, owner):
|
||||||
|
if not self.computed:
|
||||||
|
self.value = self.getter(owner)
|
||||||
|
self.computed = True
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
class Distance(object):
|
class Distance(object):
|
||||||
"""Keeps track of multiple distance penalties. Provides a single
|
"""Keeps track of multiple distance penalties. Provides a single
|
||||||
weighted distance for all penalties as well as a weighted distance
|
weighted distance for all penalties as well as a weighted distance
|
||||||
@@ -264,11 +289,15 @@ class Distance(object):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._penalties = {}
|
self._penalties = {}
|
||||||
|
|
||||||
|
@LazyClassProperty
|
||||||
|
def _weights(cls):
|
||||||
|
"""A dictionary from keys to floating-point weights.
|
||||||
|
"""
|
||||||
weights_view = config['match']['distance_weights']
|
weights_view = config['match']['distance_weights']
|
||||||
self._weights = {}
|
weights = {}
|
||||||
for key in weights_view.keys():
|
for key in weights_view.keys():
|
||||||
self._weights[key] = weights_view[key].as_number()
|
weights[key] = weights_view[key].as_number()
|
||||||
|
return weights
|
||||||
|
|
||||||
# Access the components and their aggregates.
|
# Access the components and their aggregates.
|
||||||
|
|
||||||
@@ -315,7 +344,6 @@ class Distance(object):
|
|||||||
# still get the items with the biggest distance first.
|
# still get the items with the biggest distance first.
|
||||||
return sorted(list_, key=lambda (key, dist): (0 - dist, key))
|
return sorted(list_, key=lambda (key, dist): (0 - dist, key))
|
||||||
|
|
||||||
|
|
||||||
# Behave like a float.
|
# Behave like a float.
|
||||||
|
|
||||||
def __cmp__(self, other):
|
def __cmp__(self, other):
|
||||||
@@ -323,13 +351,13 @@ class Distance(object):
|
|||||||
|
|
||||||
def __float__(self):
|
def __float__(self):
|
||||||
return self.distance
|
return self.distance
|
||||||
|
|
||||||
def __sub__(self, other):
|
def __sub__(self, other):
|
||||||
return self.distance - other
|
return self.distance - other
|
||||||
|
|
||||||
def __rsub__(self, other):
|
def __rsub__(self, other):
|
||||||
return other - self.distance
|
return other - self.distance
|
||||||
|
|
||||||
|
|
||||||
# Behave like a dict.
|
# Behave like a dict.
|
||||||
|
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
@@ -355,11 +383,11 @@ class Distance(object):
|
|||||||
"""
|
"""
|
||||||
if not isinstance(dist, Distance):
|
if not isinstance(dist, Distance):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
'`dist` must be a Distance object. It is: %r' % dist)
|
'`dist` must be a Distance object, not {0}'.format(type(dist))
|
||||||
|
)
|
||||||
for key, penalties in dist._penalties.iteritems():
|
for key, penalties in dist._penalties.iteritems():
|
||||||
self._penalties.setdefault(key, []).extend(penalties)
|
self._penalties.setdefault(key, []).extend(penalties)
|
||||||
|
|
||||||
|
|
||||||
# Adding components.
|
# Adding components.
|
||||||
|
|
||||||
def _eq(self, value1, value2):
|
def _eq(self, value1, value2):
|
||||||
@@ -379,7 +407,8 @@ class Distance(object):
|
|||||||
"""
|
"""
|
||||||
if not 0.0 <= dist <= 1.0:
|
if not 0.0 <= dist <= 1.0:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
'`dist` must be between 0.0 and 1.0. It is: %r' % dist)
|
'`dist` must be between 0.0 and 1.0, not {0}'.format(dist)
|
||||||
|
)
|
||||||
self._penalties.setdefault(key, []).append(dist)
|
self._penalties.setdefault(key, []).append(dist)
|
||||||
|
|
||||||
def add_equality(self, key, value, options):
|
def add_equality(self, key, value, options):
|
||||||
@@ -476,6 +505,7 @@ def album_for_mbid(release_id):
|
|||||||
except mb.MusicBrainzAPIError as exc:
|
except mb.MusicBrainzAPIError as exc:
|
||||||
exc.log(log)
|
exc.log(log)
|
||||||
|
|
||||||
|
|
||||||
def track_for_mbid(recording_id):
|
def track_for_mbid(recording_id):
|
||||||
"""Get a TrackInfo object for a MusicBrainz recording ID. Return None
|
"""Get a TrackInfo object for a MusicBrainz recording ID. Return None
|
||||||
if the ID is not found.
|
if the ID is not found.
|
||||||
@@ -485,18 +515,21 @@ def track_for_mbid(recording_id):
|
|||||||
except mb.MusicBrainzAPIError as exc:
|
except mb.MusicBrainzAPIError as exc:
|
||||||
exc.log(log)
|
exc.log(log)
|
||||||
|
|
||||||
|
|
||||||
def albums_for_id(album_id):
|
def albums_for_id(album_id):
|
||||||
"""Get a list of albums for an ID."""
|
"""Get a list of albums for an ID."""
|
||||||
candidates = [album_for_mbid(album_id)]
|
candidates = [album_for_mbid(album_id)]
|
||||||
candidates.extend(plugins.album_for_id(album_id))
|
candidates.extend(plugins.album_for_id(album_id))
|
||||||
return filter(None, candidates)
|
return filter(None, candidates)
|
||||||
|
|
||||||
|
|
||||||
def tracks_for_id(track_id):
|
def tracks_for_id(track_id):
|
||||||
"""Get a list of tracks for an ID."""
|
"""Get a list of tracks for an ID."""
|
||||||
candidates = [track_for_mbid(track_id)]
|
candidates = [track_for_mbid(track_id)]
|
||||||
candidates.extend(plugins.track_for_id(track_id))
|
candidates.extend(plugins.track_for_id(track_id))
|
||||||
return filter(None, candidates)
|
return filter(None, candidates)
|
||||||
|
|
||||||
|
|
||||||
def album_candidates(items, artist, album, va_likely):
|
def album_candidates(items, artist, album, va_likely):
|
||||||
"""Search for album matches. ``items`` is a list of Item objects
|
"""Search for album matches. ``items`` is a list of Item objects
|
||||||
that make up the album. ``artist`` and ``album`` are the respective
|
that make up the album. ``artist`` and ``album`` are the respective
|
||||||
@@ -525,6 +558,7 @@ def album_candidates(items, artist, album, va_likely):
|
|||||||
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def item_candidates(item, artist, title):
|
def item_candidates(item, artist, title):
|
||||||
"""Search for item matches. ``item`` is the Item to be matched.
|
"""Search for item matches. ``item`` is the Item to be matched.
|
||||||
``artist`` and ``title`` are strings and either reflect the item or
|
``artist`` and ``title`` are strings and either reflect the item or
|
||||||
|
|||||||
+84
-48
@@ -25,11 +25,8 @@ from munkres import Munkres
|
|||||||
from beets import plugins
|
from beets import plugins
|
||||||
from beets import config
|
from beets import config
|
||||||
from beets.util import plurality
|
from beets.util import plurality
|
||||||
from beets.util.enumeration import enum
|
|
||||||
from beets.autotag import hooks
|
from beets.autotag import hooks
|
||||||
|
from beets.util.enumeration import OrderedEnum
|
||||||
# Recommendation enumeration.
|
|
||||||
recommendation = enum('none', 'low', 'medium', 'strong', name='recommendation')
|
|
||||||
|
|
||||||
# Artist signals that indicate "various artists". These are used at the
|
# Artist signals that indicate "various artists". These are used at the
|
||||||
# album level to determine whether a given release is likely a VA
|
# album level to determine whether a given release is likely a VA
|
||||||
@@ -41,6 +38,18 @@ VA_ARTISTS = (u'', u'various artists', u'various', u'va', u'unknown')
|
|||||||
log = logging.getLogger('beets')
|
log = logging.getLogger('beets')
|
||||||
|
|
||||||
|
|
||||||
|
# Recommendation enumeration.
|
||||||
|
|
||||||
|
class Recommendation(OrderedEnum):
|
||||||
|
"""Indicates a qualitative suggestion to the user about what should
|
||||||
|
be done with a given match.
|
||||||
|
"""
|
||||||
|
none = 0
|
||||||
|
low = 1
|
||||||
|
medium = 2
|
||||||
|
strong = 3
|
||||||
|
|
||||||
|
|
||||||
# Primary matching functionality.
|
# Primary matching functionality.
|
||||||
|
|
||||||
def current_metadata(items):
|
def current_metadata(items):
|
||||||
@@ -56,10 +65,10 @@ def current_metadata(items):
|
|||||||
fields = ['artist', 'album', 'albumartist', 'year', 'disctotal',
|
fields = ['artist', 'album', 'albumartist', 'year', 'disctotal',
|
||||||
'mb_albumid', 'label', 'catalognum', 'country', 'media',
|
'mb_albumid', 'label', 'catalognum', 'country', 'media',
|
||||||
'albumdisambig']
|
'albumdisambig']
|
||||||
for key in fields:
|
for field in fields:
|
||||||
values = [getattr(item, key) for item in items if item]
|
values = [item[field] for item in items if item]
|
||||||
likelies[key], freq = plurality(values)
|
likelies[field], freq = plurality(values)
|
||||||
consensus[key] = (freq == len(values))
|
consensus[field] = (freq == len(values))
|
||||||
|
|
||||||
# If there's an album artist consensus, use this for the artist.
|
# If there's an album artist consensus, use this for the artist.
|
||||||
if consensus['albumartist'] and likelies['albumartist']:
|
if consensus['albumartist'] and likelies['albumartist']:
|
||||||
@@ -67,6 +76,7 @@ def current_metadata(items):
|
|||||||
|
|
||||||
return likelies, consensus
|
return likelies, consensus
|
||||||
|
|
||||||
|
|
||||||
def assign_items(items, tracks):
|
def assign_items(items, tracks):
|
||||||
"""Given a list of Items and a list of TrackInfo objects, find the
|
"""Given a list of Items and a list of TrackInfo objects, find the
|
||||||
best mapping between them. Returns a mapping from Items to TrackInfo
|
best mapping between them. Returns a mapping from Items to TrackInfo
|
||||||
@@ -93,12 +103,14 @@ def assign_items(items, tracks):
|
|||||||
extra_tracks.sort(key=lambda t: (t.index, t.title))
|
extra_tracks.sort(key=lambda t: (t.index, t.title))
|
||||||
return mapping, extra_items, extra_tracks
|
return mapping, extra_items, extra_tracks
|
||||||
|
|
||||||
|
|
||||||
def track_index_changed(item, track_info):
|
def track_index_changed(item, track_info):
|
||||||
"""Returns True if the item and track info index is different. Tolerates
|
"""Returns True if the item and track info index is different. Tolerates
|
||||||
per disc and per release numbering.
|
per disc and per release numbering.
|
||||||
"""
|
"""
|
||||||
return item.track not in (track_info.medium_index, track_info.index)
|
return item.track not in (track_info.medium_index, track_info.index)
|
||||||
|
|
||||||
|
|
||||||
def track_distance(item, track_info, incl_artist=False):
|
def track_distance(item, track_info, incl_artist=False):
|
||||||
"""Determines the significance of a track metadata change. Returns a
|
"""Determines the significance of a track metadata change. Returns a
|
||||||
Distance object. `incl_artist` indicates that a distance component should
|
Distance object. `incl_artist` indicates that a distance component should
|
||||||
@@ -134,6 +146,7 @@ def track_distance(item, track_info, incl_artist=False):
|
|||||||
|
|
||||||
return dist
|
return dist
|
||||||
|
|
||||||
|
|
||||||
def distance(items, album_info, mapping):
|
def distance(items, album_info, mapping):
|
||||||
"""Determines how "significant" an album metadata change would be.
|
"""Determines how "significant" an album metadata change would be.
|
||||||
Returns a Distance object. `album_info` is an AlbumInfo object
|
Returns a Distance object. `album_info` is an AlbumInfo object
|
||||||
@@ -239,6 +252,7 @@ def distance(items, album_info, mapping):
|
|||||||
|
|
||||||
return dist
|
return dist
|
||||||
|
|
||||||
|
|
||||||
def match_by_id(items):
|
def match_by_id(items):
|
||||||
"""If the items are tagged with a MusicBrainz album ID, returns an
|
"""If the items are tagged with a MusicBrainz album ID, returns an
|
||||||
AlbumInfo object for the corresponding album. Otherwise, returns
|
AlbumInfo object for the corresponding album. Otherwise, returns
|
||||||
@@ -247,16 +261,17 @@ def match_by_id(items):
|
|||||||
# Is there a consensus on the MB album ID?
|
# Is there a consensus on the MB album ID?
|
||||||
albumids = [item.mb_albumid for item in items if item.mb_albumid]
|
albumids = [item.mb_albumid for item in items if item.mb_albumid]
|
||||||
if not albumids:
|
if not albumids:
|
||||||
log.debug('No album IDs found.')
|
log.debug(u'No album IDs found.')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# If all album IDs are equal, look up the album.
|
# If all album IDs are equal, look up the album.
|
||||||
if bool(reduce(lambda x, y: x if x == y else (), albumids)):
|
if bool(reduce(lambda x, y: x if x == y else (), albumids)):
|
||||||
albumid = albumids[0]
|
albumid = albumids[0]
|
||||||
log.debug('Searching for discovered album ID: ' + albumid)
|
log.debug(u'Searching for discovered album ID: {0}'.format(albumid))
|
||||||
return hooks.album_for_mbid(albumid)
|
return hooks.album_for_mbid(albumid)
|
||||||
else:
|
else:
|
||||||
log.debug('No album ID consensus.')
|
log.debug(u'No album ID consensus.')
|
||||||
|
|
||||||
|
|
||||||
def _recommendation(results):
|
def _recommendation(results):
|
||||||
"""Given a sorted list of AlbumMatch or TrackMatch objects, return a
|
"""Given a sorted list of AlbumMatch or TrackMatch objects, return a
|
||||||
@@ -268,26 +283,26 @@ def _recommendation(results):
|
|||||||
"""
|
"""
|
||||||
if not results:
|
if not results:
|
||||||
# No candidates: no recommendation.
|
# No candidates: no recommendation.
|
||||||
return recommendation.none
|
return Recommendation.none
|
||||||
|
|
||||||
# Basic distance thresholding.
|
# Basic distance thresholding.
|
||||||
min_dist = results[0].distance
|
min_dist = results[0].distance
|
||||||
if min_dist < config['match']['strong_rec_thresh'].as_number():
|
if min_dist < config['match']['strong_rec_thresh'].as_number():
|
||||||
# Strong recommendation level.
|
# Strong recommendation level.
|
||||||
rec = recommendation.strong
|
rec = Recommendation.strong
|
||||||
elif min_dist <= config['match']['medium_rec_thresh'].as_number():
|
elif min_dist <= config['match']['medium_rec_thresh'].as_number():
|
||||||
# Medium recommendation level.
|
# Medium recommendation level.
|
||||||
rec = recommendation.medium
|
rec = Recommendation.medium
|
||||||
elif len(results) == 1:
|
elif len(results) == 1:
|
||||||
# Only a single candidate.
|
# Only a single candidate.
|
||||||
rec = recommendation.low
|
rec = Recommendation.low
|
||||||
elif results[1].distance - min_dist >= \
|
elif results[1].distance - min_dist >= \
|
||||||
config['match']['rec_gap_thresh'].as_number():
|
config['match']['rec_gap_thresh'].as_number():
|
||||||
# Gap between first two candidates is large.
|
# Gap between first two candidates is large.
|
||||||
rec = recommendation.low
|
rec = Recommendation.low
|
||||||
else:
|
else:
|
||||||
# No conclusion. Return immediately. Can't be downgraded any further.
|
# No conclusion. Return immediately. Can't be downgraded any further.
|
||||||
return recommendation.none
|
return Recommendation.none
|
||||||
|
|
||||||
# Downgrade to the max rec if it is lower than the current rec for an
|
# Downgrade to the max rec if it is lower than the current rec for an
|
||||||
# applied penalty.
|
# applied penalty.
|
||||||
@@ -299,26 +314,38 @@ def _recommendation(results):
|
|||||||
for key in keys:
|
for key in keys:
|
||||||
if key in max_rec_view.keys():
|
if key in max_rec_view.keys():
|
||||||
max_rec = max_rec_view[key].as_choice({
|
max_rec = max_rec_view[key].as_choice({
|
||||||
'strong': recommendation.strong,
|
'strong': Recommendation.strong,
|
||||||
'medium': recommendation.medium,
|
'medium': Recommendation.medium,
|
||||||
'low': recommendation.low,
|
'low': Recommendation.low,
|
||||||
'none': recommendation.none,
|
'none': Recommendation.none,
|
||||||
})
|
})
|
||||||
rec = min(rec, max_rec)
|
rec = min(rec, max_rec)
|
||||||
|
|
||||||
return rec
|
return rec
|
||||||
|
|
||||||
|
|
||||||
def _add_candidate(items, results, info):
|
def _add_candidate(items, results, info):
|
||||||
"""Given a candidate AlbumInfo object, attempt to add the candidate
|
"""Given a candidate AlbumInfo object, attempt to add the candidate
|
||||||
to the output dictionary of AlbumMatch objects. This involves
|
to the output dictionary of AlbumMatch objects. This involves
|
||||||
checking the track count, ordering the items, checking for
|
checking the track count, ordering the items, checking for
|
||||||
duplicates, and calculating the distance.
|
duplicates, and calculating the distance.
|
||||||
"""
|
"""
|
||||||
log.debug('Candidate: %s - %s' % (info.artist, info.album))
|
log.debug(u'Candidate: {0} - {1}'.format(info.artist, info.album))
|
||||||
|
|
||||||
|
# Discard albums with zero tracks.
|
||||||
|
if not info.tracks:
|
||||||
|
log.debug('No tracks.')
|
||||||
|
return
|
||||||
|
|
||||||
# Don't duplicate.
|
# Don't duplicate.
|
||||||
if info.album_id in results:
|
if info.album_id in results:
|
||||||
log.debug('Duplicate.')
|
log.debug(u'Duplicate.')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Discard matches without required tags.
|
||||||
|
for req_tag in config['match']['required'].as_str_seq():
|
||||||
|
if getattr(info, req_tag) is None:
|
||||||
|
log.debug(u'Ignored. Missing required tag: {0}'.format(req_tag))
|
||||||
return
|
return
|
||||||
|
|
||||||
# Find mapping between the items and the track info.
|
# Find mapping between the items and the track info.
|
||||||
@@ -331,30 +358,36 @@ def _add_candidate(items, results, info):
|
|||||||
penalties = [key for _, key in dist]
|
penalties = [key for _, key in dist]
|
||||||
for penalty in config['match']['ignored'].as_str_seq():
|
for penalty in config['match']['ignored'].as_str_seq():
|
||||||
if penalty in penalties:
|
if penalty in penalties:
|
||||||
log.debug('Ignored. Penalty: %s' % penalty)
|
log.debug(u'Ignored. Penalty: {0}'.format(penalty))
|
||||||
return
|
return
|
||||||
|
|
||||||
log.debug('Success. Distance: %f' % dist)
|
log.debug(u'Success. Distance: {0}'.format(dist))
|
||||||
results[info.album_id] = hooks.AlbumMatch(dist, info, mapping,
|
results[info.album_id] = hooks.AlbumMatch(dist, info, mapping,
|
||||||
extra_items, extra_tracks)
|
extra_items, extra_tracks)
|
||||||
|
|
||||||
|
|
||||||
def tag_album(items, search_artist=None, search_album=None,
|
def tag_album(items, search_artist=None, search_album=None,
|
||||||
search_id=None):
|
search_id=None):
|
||||||
"""Bundles together the functionality used to infer tags for a
|
"""Return a tuple of a artist name, an album name, a list of
|
||||||
set of items comprised by an album. Returns everything relevant:
|
`AlbumMatch` candidates from the metadata backend, and a
|
||||||
- The current artist.
|
`Recommendation`.
|
||||||
- The current album.
|
|
||||||
- A list of AlbumMatch objects. The candidates are sorted by
|
The artist and album are the most common values of these fields
|
||||||
distance (i.e., best match first).
|
among `items`.
|
||||||
- A recommendation.
|
|
||||||
If search_artist and search_album or search_id are provided, then
|
The `AlbumMatch` objects are generated by searching the metadata
|
||||||
they are used as search terms in place of the current metadata.
|
backends. By default, the metadata of the items is used for the
|
||||||
|
search. This can be customized by setting the parameters. The
|
||||||
|
`mapping` field of the album has the matched `items` as keys.
|
||||||
|
|
||||||
|
The recommendation is calculated from the match qualitiy of the
|
||||||
|
candidates.
|
||||||
"""
|
"""
|
||||||
# Get current metadata.
|
# Get current metadata.
|
||||||
likelies, consensus = current_metadata(items)
|
likelies, consensus = current_metadata(items)
|
||||||
cur_artist = likelies['artist']
|
cur_artist = likelies['artist']
|
||||||
cur_album = likelies['album']
|
cur_album = likelies['album']
|
||||||
log.debug('Tagging %s - %s' % (cur_artist, cur_album))
|
log.debug(u'Tagging {0} - {1}'.format(cur_artist, cur_album))
|
||||||
|
|
||||||
# The output result (distance, AlbumInfo) tuples (keyed by MB album
|
# The output result (distance, AlbumInfo) tuples (keyed by MB album
|
||||||
# ID).
|
# ID).
|
||||||
@@ -362,7 +395,7 @@ def tag_album(items, search_artist=None, search_album=None,
|
|||||||
|
|
||||||
# Search by explicit ID.
|
# Search by explicit ID.
|
||||||
if search_id is not None:
|
if search_id is not None:
|
||||||
log.debug('Searching for album ID: ' + search_id)
|
log.debug(u'Searching for album ID: {0}'.format(search_id))
|
||||||
search_cands = hooks.albums_for_id(search_id)
|
search_cands = hooks.albums_for_id(search_id)
|
||||||
|
|
||||||
# Use existing metadata or text search.
|
# Use existing metadata or text search.
|
||||||
@@ -372,32 +405,33 @@ def tag_album(items, search_artist=None, search_album=None,
|
|||||||
if id_info:
|
if id_info:
|
||||||
_add_candidate(items, candidates, id_info)
|
_add_candidate(items, candidates, id_info)
|
||||||
rec = _recommendation(candidates.values())
|
rec = _recommendation(candidates.values())
|
||||||
log.debug('Album ID match recommendation is ' + str(rec))
|
log.debug(u'Album ID match recommendation is {0}'.format(str(rec)))
|
||||||
if candidates and not config['import']['timid']:
|
if candidates and not config['import']['timid']:
|
||||||
# If we have a very good MBID match, return immediately.
|
# If we have a very good MBID match, return immediately.
|
||||||
# Otherwise, this match will compete against metadata-based
|
# Otherwise, this match will compete against metadata-based
|
||||||
# matches.
|
# matches.
|
||||||
if rec == recommendation.strong:
|
if rec == Recommendation.strong:
|
||||||
log.debug('ID match.')
|
log.debug(u'ID match.')
|
||||||
return cur_artist, cur_album, candidates.values(), rec
|
return cur_artist, cur_album, candidates.values(), rec
|
||||||
|
|
||||||
# Search terms.
|
# Search terms.
|
||||||
if not (search_artist and search_album):
|
if not (search_artist and search_album):
|
||||||
# No explicit search terms -- use current metadata.
|
# No explicit search terms -- use current metadata.
|
||||||
search_artist, search_album = cur_artist, cur_album
|
search_artist, search_album = cur_artist, cur_album
|
||||||
log.debug(u'Search terms: %s - %s' % (search_artist, search_album))
|
log.debug(u'Search terms: {0} - {1}'.format(search_artist,
|
||||||
|
search_album))
|
||||||
|
|
||||||
# Is this album likely to be a "various artist" release?
|
# Is this album likely to be a "various artist" release?
|
||||||
va_likely = ((not consensus['artist']) or
|
va_likely = ((not consensus['artist']) or
|
||||||
(search_artist.lower() in VA_ARTISTS) or
|
(search_artist.lower() in VA_ARTISTS) or
|
||||||
any(item.comp for item in items))
|
any(item.comp for item in items))
|
||||||
log.debug(u'Album might be VA: %s' % str(va_likely))
|
log.debug(u'Album might be VA: {0}'.format(str(va_likely)))
|
||||||
|
|
||||||
# Get the results from the data sources.
|
# Get the results from the data sources.
|
||||||
search_cands = hooks.album_candidates(items, search_artist,
|
search_cands = hooks.album_candidates(items, search_artist,
|
||||||
search_album, va_likely)
|
search_album, va_likely)
|
||||||
|
|
||||||
log.debug(u'Evaluating %i candidates.' % len(search_cands))
|
log.debug(u'Evaluating {0} candidates.'.format(len(search_cands)))
|
||||||
for info in search_cands:
|
for info in search_cands:
|
||||||
_add_candidate(items, candidates, info)
|
_add_candidate(items, candidates, info)
|
||||||
|
|
||||||
@@ -406,6 +440,7 @@ def tag_album(items, search_artist=None, search_album=None,
|
|||||||
rec = _recommendation(candidates)
|
rec = _recommendation(candidates)
|
||||||
return cur_artist, cur_album, candidates, rec
|
return cur_artist, cur_album, candidates, rec
|
||||||
|
|
||||||
|
|
||||||
def tag_item(item, search_artist=None, search_title=None,
|
def tag_item(item, search_artist=None, search_title=None,
|
||||||
search_id=None):
|
search_id=None):
|
||||||
"""Attempts to find metadata for a single track. Returns a
|
"""Attempts to find metadata for a single track. Returns a
|
||||||
@@ -421,15 +456,15 @@ def tag_item(item, search_artist=None, search_title=None,
|
|||||||
# First, try matching by MusicBrainz ID.
|
# First, try matching by MusicBrainz ID.
|
||||||
trackid = search_id or item.mb_trackid
|
trackid = search_id or item.mb_trackid
|
||||||
if trackid:
|
if trackid:
|
||||||
log.debug('Searching for track ID: ' + trackid)
|
log.debug(u'Searching for track ID: {0}'.format(trackid))
|
||||||
for track_info in hooks.tracks_for_id(trackid):
|
for track_info in hooks.tracks_for_id(trackid):
|
||||||
dist = track_distance(item, track_info, incl_artist=True)
|
dist = track_distance(item, track_info, incl_artist=True)
|
||||||
candidates[track_info.track_id] = \
|
candidates[track_info.track_id] = \
|
||||||
hooks.TrackMatch(dist, track_info)
|
hooks.TrackMatch(dist, track_info)
|
||||||
# If this is a good match, then don't keep searching.
|
# If this is a good match, then don't keep searching.
|
||||||
rec = _recommendation(candidates.values())
|
rec = _recommendation(candidates.values())
|
||||||
if rec == recommendation.strong and not config['import']['timid']:
|
if rec == Recommendation.strong and not config['import']['timid']:
|
||||||
log.debug('Track ID match.')
|
log.debug(u'Track ID match.')
|
||||||
return candidates.values(), rec
|
return candidates.values(), rec
|
||||||
|
|
||||||
# If we're searching by ID, don't proceed.
|
# If we're searching by ID, don't proceed.
|
||||||
@@ -437,12 +472,13 @@ def tag_item(item, search_artist=None, search_title=None,
|
|||||||
if candidates:
|
if candidates:
|
||||||
return candidates.values(), rec
|
return candidates.values(), rec
|
||||||
else:
|
else:
|
||||||
return [], recommendation.none
|
return [], Recommendation.none
|
||||||
|
|
||||||
# Search terms.
|
# Search terms.
|
||||||
if not (search_artist and search_title):
|
if not (search_artist and search_title):
|
||||||
search_artist, search_title = item.artist, item.title
|
search_artist, search_title = item.artist, item.title
|
||||||
log.debug(u'Item search terms: %s - %s' % (search_artist, search_title))
|
log.debug(u'Item search terms: {0} - {1}'.format(search_artist,
|
||||||
|
search_title))
|
||||||
|
|
||||||
# Get and evaluate candidate metadata.
|
# Get and evaluate candidate metadata.
|
||||||
for track_info in hooks.item_candidates(item, search_artist, search_title):
|
for track_info in hooks.item_candidates(item, search_artist, search_title):
|
||||||
@@ -450,7 +486,7 @@ def tag_item(item, search_artist=None, search_title=None,
|
|||||||
candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info)
|
candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info)
|
||||||
|
|
||||||
# Sort by distance and return with recommendation.
|
# Sort by distance and return with recommendation.
|
||||||
log.debug('Found %i candidates.' % len(candidates))
|
log.debug(u'Found {0} candidates.'.format(len(candidates)))
|
||||||
candidates = sorted(candidates.itervalues())
|
candidates = sorted(candidates.itervalues())
|
||||||
rec = _recommendation(candidates)
|
rec = _recommendation(candidates)
|
||||||
return candidates, rec
|
return candidates, rec
|
||||||
|
|||||||
+31
-14
@@ -32,6 +32,7 @@ BASE_URL = 'http://musicbrainz.org/'
|
|||||||
musicbrainzngs.set_useragent('beets', beets.__version__,
|
musicbrainzngs.set_useragent('beets', beets.__version__,
|
||||||
'http://beets.radbox.org/')
|
'http://beets.radbox.org/')
|
||||||
|
|
||||||
|
|
||||||
class MusicBrainzAPIError(util.HumanReadableException):
|
class MusicBrainzAPIError(util.HumanReadableException):
|
||||||
"""An error while talking to MusicBrainz. The `query` field is the
|
"""An error while talking to MusicBrainz. The `query` field is the
|
||||||
parameter to the action and may have any type.
|
parameter to the action and may have any type.
|
||||||
@@ -41,7 +42,7 @@ class MusicBrainzAPIError(util.HumanReadableException):
|
|||||||
super(MusicBrainzAPIError, self).__init__(reason, verb, tb)
|
super(MusicBrainzAPIError, self).__init__(reason, verb, tb)
|
||||||
|
|
||||||
def get_message(self):
|
def get_message(self):
|
||||||
return u'"{0}" in {1} with query {2}'.format(
|
return u'{0} in {1} with query {2}'.format(
|
||||||
self._reasonstr(), self.verb, repr(self.query)
|
self._reasonstr(), self.verb, repr(self.query)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,12 +52,15 @@ RELEASE_INCLUDES = ['artists', 'media', 'recordings', 'release-groups',
|
|||||||
'labels', 'artist-credits', 'aliases']
|
'labels', 'artist-credits', 'aliases']
|
||||||
TRACK_INCLUDES = ['artists', 'aliases']
|
TRACK_INCLUDES = ['artists', 'aliases']
|
||||||
|
|
||||||
|
|
||||||
def track_url(trackid):
|
def track_url(trackid):
|
||||||
return urljoin(BASE_URL, 'recording/' + trackid)
|
return urljoin(BASE_URL, 'recording/' + trackid)
|
||||||
|
|
||||||
|
|
||||||
def album_url(albumid):
|
def album_url(albumid):
|
||||||
return urljoin(BASE_URL, 'release/' + albumid)
|
return urljoin(BASE_URL, 'release/' + albumid)
|
||||||
|
|
||||||
|
|
||||||
def configure():
|
def configure():
|
||||||
"""Set up the python-musicbrainz-ngs module according to settings
|
"""Set up the python-musicbrainz-ngs module according to settings
|
||||||
from the beets configuration. This should be called at startup.
|
from the beets configuration. This should be called at startup.
|
||||||
@@ -67,6 +71,7 @@ def configure():
|
|||||||
config['musicbrainz']['ratelimit'].get(int),
|
config['musicbrainz']['ratelimit'].get(int),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _preferred_alias(aliases):
|
def _preferred_alias(aliases):
|
||||||
"""Given an list of alias structures for an artist credit, select
|
"""Given an list of alias structures for an artist credit, select
|
||||||
and return the user's preferred alias alias or None if no matching
|
and return the user's preferred alias alias or None if no matching
|
||||||
@@ -81,13 +86,15 @@ def _preferred_alias(aliases):
|
|||||||
# Search configured locales in order.
|
# Search configured locales in order.
|
||||||
for locale in config['import']['languages'].as_str_seq():
|
for locale in config['import']['languages'].as_str_seq():
|
||||||
# Find matching primary aliases for this locale.
|
# Find matching primary aliases for this locale.
|
||||||
matches = [a for a in aliases if a['locale'] == locale and 'primary' in a]
|
matches = [a for a in aliases
|
||||||
|
if a['locale'] == locale and 'primary' in a]
|
||||||
# Skip to the next locale if we have no matches
|
# Skip to the next locale if we have no matches
|
||||||
if not matches:
|
if not matches:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return matches[0]
|
return matches[0]
|
||||||
|
|
||||||
|
|
||||||
def _flatten_artist_credit(credit):
|
def _flatten_artist_credit(credit):
|
||||||
"""Given a list representing an ``artist-credit`` block, flatten the
|
"""Given a list representing an ``artist-credit`` block, flatten the
|
||||||
data into a triple of joined artist name strings: canonical, sort, and
|
data into a triple of joined artist name strings: canonical, sort, and
|
||||||
@@ -133,6 +140,7 @@ def _flatten_artist_credit(credit):
|
|||||||
''.join(artist_credit_parts),
|
''.join(artist_credit_parts),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def track_info(recording, index=None, medium=None, medium_index=None,
|
def track_info(recording, index=None, medium=None, medium_index=None,
|
||||||
medium_total=None):
|
medium_total=None):
|
||||||
"""Translates a MusicBrainz recording result dictionary into a beets
|
"""Translates a MusicBrainz recording result dictionary into a beets
|
||||||
@@ -167,6 +175,7 @@ def track_info(recording, index=None, medium=None, medium_index=None,
|
|||||||
info.decode()
|
info.decode()
|
||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
def _set_date_str(info, date_str, original=False):
|
def _set_date_str(info, date_str, original=False):
|
||||||
"""Given a (possibly partial) YYYY-MM-DD string and an AlbumInfo
|
"""Given a (possibly partial) YYYY-MM-DD string and an AlbumInfo
|
||||||
object, set the object's release date fields appropriately. If
|
object, set the object's release date fields appropriately. If
|
||||||
@@ -186,6 +195,7 @@ def _set_date_str(info, date_str, original=False):
|
|||||||
key = 'original_' + key
|
key = 'original_' + key
|
||||||
setattr(info, key, date_num)
|
setattr(info, key, date_num)
|
||||||
|
|
||||||
|
|
||||||
def album_info(release):
|
def album_info(release):
|
||||||
"""Takes a MusicBrainz release result dictionary and returns a beets
|
"""Takes a MusicBrainz release result dictionary and returns a beets
|
||||||
AlbumInfo object containing the interesting data about that release.
|
AlbumInfo object containing the interesting data about that release.
|
||||||
@@ -199,6 +209,7 @@ def album_info(release):
|
|||||||
index = 0
|
index = 0
|
||||||
for medium in release['medium-list']:
|
for medium in release['medium-list']:
|
||||||
disctitle = medium.get('title')
|
disctitle = medium.get('title')
|
||||||
|
format = medium.get('format')
|
||||||
for track in medium['track-list']:
|
for track in medium['track-list']:
|
||||||
# Basic information from the recording.
|
# Basic information from the recording.
|
||||||
index += 1
|
index += 1
|
||||||
@@ -210,6 +221,7 @@ def album_info(release):
|
|||||||
len(medium['track-list']),
|
len(medium['track-list']),
|
||||||
)
|
)
|
||||||
ti.disctitle = disctitle
|
ti.disctitle = disctitle
|
||||||
|
ti.media = format
|
||||||
|
|
||||||
# Prefer track data, where present, over recording data.
|
# Prefer track data, where present, over recording data.
|
||||||
if track.get('title'):
|
if track.get('title'):
|
||||||
@@ -288,6 +300,7 @@ def album_info(release):
|
|||||||
info.decode()
|
info.decode()
|
||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
def match_album(artist, album, tracks=None, limit=SEARCH_LIMIT):
|
def match_album(artist, album, tracks=None, limit=SEARCH_LIMIT):
|
||||||
"""Searches for a single album ("release" in MusicBrainz parlance)
|
"""Searches for a single album ("release" in MusicBrainz parlance)
|
||||||
and returns an iterator over AlbumInfo objects. May raise a
|
and returns an iterator over AlbumInfo objects. May raise a
|
||||||
@@ -297,9 +310,9 @@ def match_album(artist, album, tracks=None, limit=SEARCH_LIMIT):
|
|||||||
optionally, a number of tracks on the album.
|
optionally, a number of tracks on the album.
|
||||||
"""
|
"""
|
||||||
# Build search criteria.
|
# Build search criteria.
|
||||||
criteria = {'release': album.lower()}
|
criteria = {'release': album.lower().strip()}
|
||||||
if artist is not None:
|
if artist is not None:
|
||||||
criteria['artist'] = artist.lower()
|
criteria['artist'] = artist.lower().strip()
|
||||||
else:
|
else:
|
||||||
# Various Artists search.
|
# Various Artists search.
|
||||||
criteria['arid'] = VARIOUS_ARTISTS_ID
|
criteria['arid'] = VARIOUS_ARTISTS_ID
|
||||||
@@ -322,13 +335,14 @@ def match_album(artist, album, tracks=None, limit=SEARCH_LIMIT):
|
|||||||
if albuminfo is not None:
|
if albuminfo is not None:
|
||||||
yield albuminfo
|
yield albuminfo
|
||||||
|
|
||||||
|
|
||||||
def match_track(artist, title, limit=SEARCH_LIMIT):
|
def match_track(artist, title, limit=SEARCH_LIMIT):
|
||||||
"""Searches for a single track and returns an iterable of TrackInfo
|
"""Searches for a single track and returns an iterable of TrackInfo
|
||||||
objects. May raise a MusicBrainzAPIError.
|
objects. May raise a MusicBrainzAPIError.
|
||||||
"""
|
"""
|
||||||
criteria = {
|
criteria = {
|
||||||
'artist': artist.lower(),
|
'artist': artist.lower().strip(),
|
||||||
'recording': title.lower(),
|
'recording': title.lower().strip(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if not any(criteria.itervalues()):
|
if not any(criteria.itervalues()):
|
||||||
@@ -342,6 +356,7 @@ def match_track(artist, title, limit=SEARCH_LIMIT):
|
|||||||
for recording in res['recording-list']:
|
for recording in res['recording-list']:
|
||||||
yield track_info(recording)
|
yield track_info(recording)
|
||||||
|
|
||||||
|
|
||||||
def _parse_id(s):
|
def _parse_id(s):
|
||||||
"""Search for a MusicBrainz ID in the given string and return it. If
|
"""Search for a MusicBrainz ID in the given string and return it. If
|
||||||
no ID can be found, return None.
|
no ID can be found, return None.
|
||||||
@@ -351,38 +366,40 @@ def _parse_id(s):
|
|||||||
if match:
|
if match:
|
||||||
return match.group()
|
return match.group()
|
||||||
|
|
||||||
def album_for_id(albumid):
|
|
||||||
|
def album_for_id(releaseid):
|
||||||
"""Fetches an album by its MusicBrainz ID and returns an AlbumInfo
|
"""Fetches an album by its MusicBrainz ID and returns an AlbumInfo
|
||||||
object or None if the album is not found. May raise a
|
object or None if the album is not found. May raise a
|
||||||
MusicBrainzAPIError.
|
MusicBrainzAPIError.
|
||||||
"""
|
"""
|
||||||
albumid = _parse_id(albumid)
|
albumid = _parse_id(releaseid)
|
||||||
if not albumid:
|
if not albumid:
|
||||||
log.error('Invalid MBID.')
|
log.debug(u'Invalid MBID ({0}).'.format(releaseid))
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
res = musicbrainzngs.get_release_by_id(albumid,
|
res = musicbrainzngs.get_release_by_id(albumid,
|
||||||
RELEASE_INCLUDES)
|
RELEASE_INCLUDES)
|
||||||
except musicbrainzngs.ResponseError:
|
except musicbrainzngs.ResponseError:
|
||||||
log.debug('Album ID match failed.')
|
log.debug(u'Album ID match failed.')
|
||||||
return None
|
return None
|
||||||
except musicbrainzngs.MusicBrainzError as exc:
|
except musicbrainzngs.MusicBrainzError as exc:
|
||||||
raise MusicBrainzAPIError(exc, 'get release by ID', albumid,
|
raise MusicBrainzAPIError(exc, 'get release by ID', albumid,
|
||||||
traceback.format_exc())
|
traceback.format_exc())
|
||||||
return album_info(res['release'])
|
return album_info(res['release'])
|
||||||
|
|
||||||
def track_for_id(trackid):
|
|
||||||
|
def track_for_id(releaseid):
|
||||||
"""Fetches a track by its MusicBrainz ID. Returns a TrackInfo object
|
"""Fetches a track by its MusicBrainz ID. Returns a TrackInfo object
|
||||||
or None if no track is found. May raise a MusicBrainzAPIError.
|
or None if no track is found. May raise a MusicBrainzAPIError.
|
||||||
"""
|
"""
|
||||||
trackid = _parse_id(trackid)
|
trackid = _parse_id(releaseid)
|
||||||
if not trackid:
|
if not trackid:
|
||||||
log.error('Invalid MBID.')
|
log.debug(u'Invalid MBID ({0}).'.format(releaseid))
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
res = musicbrainzngs.get_recording_by_id(trackid, TRACK_INCLUDES)
|
res = musicbrainzngs.get_recording_by_id(trackid, TRACK_INCLUDES)
|
||||||
except musicbrainzngs.ResponseError:
|
except musicbrainzngs.ResponseError:
|
||||||
log.debug('Track ID match failed.')
|
log.debug(u'Track ID match failed.')
|
||||||
return None
|
return None
|
||||||
except musicbrainzngs.MusicBrainzError as exc:
|
except musicbrainzngs.MusicBrainzError as exc:
|
||||||
raise MusicBrainzAPIError(exc, 'get recording by ID', trackid,
|
raise MusicBrainzAPIError(exc, 'get recording by ID', trackid,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import:
|
|||||||
write: yes
|
write: yes
|
||||||
copy: yes
|
copy: yes
|
||||||
move: no
|
move: no
|
||||||
|
link: no
|
||||||
delete: no
|
delete: no
|
||||||
resume: ask
|
resume: ask
|
||||||
incremental: no
|
incremental: no
|
||||||
@@ -20,6 +21,7 @@ import:
|
|||||||
detail: no
|
detail: no
|
||||||
flat: no
|
flat: no
|
||||||
group_albums: no
|
group_albums: no
|
||||||
|
pretend: false
|
||||||
|
|
||||||
clutter: ["Thumbs.DB", ".DS_Store"]
|
clutter: ["Thumbs.DB", ".DS_Store"]
|
||||||
ignore: [".*", "*~", "System Volume Information"]
|
ignore: [".*", "*~", "System Volume Information"]
|
||||||
@@ -32,6 +34,7 @@ replace:
|
|||||||
'\s+$': ''
|
'\s+$': ''
|
||||||
'^\s+': ''
|
'^\s+': ''
|
||||||
path_sep_replace: _
|
path_sep_replace: _
|
||||||
|
asciify_paths: false
|
||||||
art_filename: cover
|
art_filename: cover
|
||||||
max_filename_length: 0
|
max_filename_length: 0
|
||||||
|
|
||||||
@@ -54,6 +57,9 @@ list_format_item: $artist - $album - $title
|
|||||||
list_format_album: $albumartist - $album
|
list_format_album: $albumartist - $album
|
||||||
time_format: '%Y-%m-%d %H:%M:%S'
|
time_format: '%Y-%m-%d %H:%M:%S'
|
||||||
|
|
||||||
|
sort_album: albumartist+ album+
|
||||||
|
sort_item: artist+ album+ disc+ track+
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
default: $albumartist/$album%aunique{}/$track $title
|
default: $albumartist/$album%aunique{}/$track $title
|
||||||
singleton: Non-Album/$artist/$title
|
singleton: Non-Album/$artist/$title
|
||||||
@@ -98,5 +104,6 @@ match:
|
|||||||
media: []
|
media: []
|
||||||
original_year: no
|
original_year: no
|
||||||
ignored: []
|
ignored: []
|
||||||
|
required: []
|
||||||
track_length_grace: 10
|
track_length_grace: 10
|
||||||
track_length_max: 30
|
track_length_max: 30
|
||||||
|
|||||||
@@ -18,3 +18,8 @@ Library.
|
|||||||
from .db import Model, Database
|
from .db import Model, Database
|
||||||
from .query import Query, FieldQuery, MatchQuery, AndQuery, OrQuery
|
from .query import Query, FieldQuery, MatchQuery, AndQuery, OrQuery
|
||||||
from .types import Type
|
from .types import Type
|
||||||
|
from .queryparse import query_from_strings
|
||||||
|
from .queryparse import sort_from_strings
|
||||||
|
from .queryparse import parse_sorted_query
|
||||||
|
|
||||||
|
# flake8: noqa
|
||||||
|
|||||||
+211
-118
@@ -20,16 +20,62 @@ from collections import defaultdict
|
|||||||
import threading
|
import threading
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import collections
|
||||||
|
|
||||||
import beets
|
import beets
|
||||||
from beets.util.functemplate import Template
|
from beets.util.functemplate import Template
|
||||||
from .query import MatchQuery
|
from beets.dbcore import types
|
||||||
|
from .query import MatchQuery, NullSort, TrueQuery
|
||||||
|
|
||||||
|
|
||||||
|
class FormattedMapping(collections.Mapping):
|
||||||
|
"""A `dict`-like formatted view of a model.
|
||||||
|
|
||||||
|
The accessor `mapping[key]` returns the formated version of
|
||||||
|
`model[key]` as a unicode string.
|
||||||
|
|
||||||
|
If `for_path` is true, all path separators in the formatted values
|
||||||
|
are replaced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, model, for_path=False):
|
||||||
|
self.for_path = for_path
|
||||||
|
self.model = model
|
||||||
|
self.model_keys = model.keys(True)
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
if key in self.model_keys:
|
||||||
|
return self._get_formatted(self.model, key)
|
||||||
|
else:
|
||||||
|
raise KeyError(key)
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return iter(self.model_keys)
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self.model_keys)
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
if default is None:
|
||||||
|
default = self.model._type(key).format(None)
|
||||||
|
return super(FormattedMapping, self).get(key, default)
|
||||||
|
|
||||||
|
def _get_formatted(self, model, key):
|
||||||
|
value = model._type(key).format(model.get(key))
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
value = value.decode('utf8', 'ignore')
|
||||||
|
|
||||||
|
if self.for_path:
|
||||||
|
sep_repl = beets.config['path_sep_replace'].get(unicode)
|
||||||
|
for sep in (os.path.sep, os.path.altsep):
|
||||||
|
if sep:
|
||||||
|
value = value.replace(sep, sep_repl)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
# Abstract base for model classes.
|
# Abstract base for model classes.
|
||||||
|
|
||||||
|
|
||||||
class Model(object):
|
class Model(object):
|
||||||
"""An abstract object representing an object in the database. Model
|
"""An abstract object representing an object in the database. Model
|
||||||
objects act like dictionaries (i.e., the allow subscript access like
|
objects act like dictionaries (i.e., the allow subscript access like
|
||||||
@@ -66,12 +112,7 @@ class Model(object):
|
|||||||
|
|
||||||
_fields = {}
|
_fields = {}
|
||||||
"""A mapping indicating available "fixed" fields on this type. The
|
"""A mapping indicating available "fixed" fields on this type. The
|
||||||
keys are field names and the values are Type objects.
|
keys are field names and the values are `Type` objects.
|
||||||
"""
|
|
||||||
|
|
||||||
_bytes_keys = ()
|
|
||||||
"""Keys whose values should be stored as raw bytes blobs rather than
|
|
||||||
strings.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_search_fields = ()
|
_search_fields = ()
|
||||||
@@ -79,6 +120,21 @@ class Model(object):
|
|||||||
terms.
|
terms.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_types = {}
|
||||||
|
"""Optional Types for non-fixed (i.e., flexible and computed) fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_sorts = {}
|
||||||
|
"""Optional named sort criteria. The keys are strings and the values
|
||||||
|
are subclasses of `Sort`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_always_dirty = False
|
||||||
|
"""By default, fields only become "dirty" when their value actually
|
||||||
|
changes. Enabling this flag marks fields as dirty even when the new
|
||||||
|
value is the same as the old value (e.g., `o.f = o.f`).
|
||||||
|
"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _getters(cls):
|
def _getters(cls):
|
||||||
"""Return a mapping from field names to getter functions.
|
"""Return a mapping from field names to getter functions.
|
||||||
@@ -94,7 +150,6 @@ class Model(object):
|
|||||||
# As above: we could consider caching this result.
|
# As above: we could consider caching this result.
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
# Basic operation.
|
# Basic operation.
|
||||||
|
|
||||||
def __init__(self, db=None, **values):
|
def __init__(self, db=None, **values):
|
||||||
@@ -110,6 +165,20 @@ class Model(object):
|
|||||||
self.update(values)
|
self.update(values)
|
||||||
self.clear_dirty()
|
self.clear_dirty()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _awaken(cls, db=None, fixed_values={}, flex_values={}):
|
||||||
|
"""Create an object with values drawn from the database.
|
||||||
|
|
||||||
|
This is a performance optimization: the checks involved with
|
||||||
|
ordinary construction are bypassed.
|
||||||
|
"""
|
||||||
|
obj = cls(db)
|
||||||
|
for key, value in fixed_values.iteritems():
|
||||||
|
obj._values_fixed[key] = cls._type(key).from_sql(value)
|
||||||
|
for key, value in flex_values.iteritems():
|
||||||
|
obj._values_flex[key] = cls._type(key).from_sql(value)
|
||||||
|
return obj
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return '{0}({1})'.format(
|
return '{0}({1})'.format(
|
||||||
type(self).__name__,
|
type(self).__name__,
|
||||||
@@ -132,9 +201,17 @@ class Model(object):
|
|||||||
if need_id and not self.id:
|
if need_id and not self.id:
|
||||||
raise ValueError('{0} has no id'.format(type(self).__name__))
|
raise ValueError('{0} has no id'.format(type(self).__name__))
|
||||||
|
|
||||||
|
|
||||||
# Essential field accessors.
|
# Essential field accessors.
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _type(self, key):
|
||||||
|
"""Get the type of a field, a `Type` instance.
|
||||||
|
|
||||||
|
If the field has no explicit type, it is given the base `Type`,
|
||||||
|
which does no conversion.
|
||||||
|
"""
|
||||||
|
return self._fields.get(key) or self._types.get(key) or types.DEFAULT
|
||||||
|
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
"""Get the value for a field. Raise a KeyError if the field is
|
"""Get the value for a field. Raise a KeyError if the field is
|
||||||
not available.
|
not available.
|
||||||
@@ -152,11 +229,19 @@ class Model(object):
|
|||||||
def __setitem__(self, key, value):
|
def __setitem__(self, key, value):
|
||||||
"""Assign the value for a field.
|
"""Assign the value for a field.
|
||||||
"""
|
"""
|
||||||
source = self._values_fixed if key in self._fields \
|
# Choose where to place the value.
|
||||||
else self._values_flex
|
if key in self._fields:
|
||||||
|
source = self._values_fixed
|
||||||
|
else:
|
||||||
|
source = self._values_flex
|
||||||
|
|
||||||
|
# If the field has a type, filter the value.
|
||||||
|
value = self._type(key).normalize(value)
|
||||||
|
|
||||||
|
# Assign value and possibly mark as dirty.
|
||||||
old_value = source.get(key)
|
old_value = source.get(key)
|
||||||
source[key] = value
|
source[key] = value
|
||||||
if old_value != value:
|
if self._always_dirty or old_value != value:
|
||||||
self._dirty.add(key)
|
self._dirty.add(key)
|
||||||
|
|
||||||
def __delitem__(self, key):
|
def __delitem__(self, key):
|
||||||
@@ -183,7 +268,6 @@ class Model(object):
|
|||||||
else:
|
else:
|
||||||
return base_keys
|
return base_keys
|
||||||
|
|
||||||
|
|
||||||
# Act like a dictionary.
|
# Act like a dictionary.
|
||||||
|
|
||||||
def update(self, values):
|
def update(self, values):
|
||||||
@@ -219,7 +303,6 @@ class Model(object):
|
|||||||
"""
|
"""
|
||||||
return iter(self.keys())
|
return iter(self.keys())
|
||||||
|
|
||||||
|
|
||||||
# Convenient attribute access.
|
# Convenient attribute access.
|
||||||
|
|
||||||
def __getattr__(self, key):
|
def __getattr__(self, key):
|
||||||
@@ -243,7 +326,6 @@ class Model(object):
|
|||||||
else:
|
else:
|
||||||
del self[key]
|
del self[key]
|
||||||
|
|
||||||
|
|
||||||
# Database interaction (CRUD methods).
|
# Database interaction (CRUD methods).
|
||||||
|
|
||||||
def store(self):
|
def store(self):
|
||||||
@@ -252,19 +334,15 @@ class Model(object):
|
|||||||
self._check_db()
|
self._check_db()
|
||||||
|
|
||||||
# Build assignments for query.
|
# Build assignments for query.
|
||||||
assignments = ''
|
assignments = []
|
||||||
subvars = []
|
subvars = []
|
||||||
for key in self._fields:
|
for key in self._fields:
|
||||||
if key != 'id' and key in self._dirty:
|
if key != 'id' and key in self._dirty:
|
||||||
self._dirty.remove(key)
|
self._dirty.remove(key)
|
||||||
assignments += key + '=?,'
|
assignments.append(key + '=?')
|
||||||
value = self[key]
|
value = self._type(key).to_sql(self[key])
|
||||||
# Wrap path strings in buffers so they get stored
|
|
||||||
# "in the raw".
|
|
||||||
if key in self._bytes_keys and isinstance(value, str):
|
|
||||||
value = buffer(value)
|
|
||||||
subvars.append(value)
|
subvars.append(value)
|
||||||
assignments = assignments[:-1] # Knock off last ,
|
assignments = ','.join(assignments)
|
||||||
|
|
||||||
with self._db.transaction() as tx:
|
with self._db.transaction() as tx:
|
||||||
# Main table update.
|
# Main table update.
|
||||||
@@ -302,6 +380,8 @@ class Model(object):
|
|||||||
self._check_db()
|
self._check_db()
|
||||||
stored_obj = self._db._get(type(self), self.id)
|
stored_obj = self._db._get(type(self), self.id)
|
||||||
assert stored_obj is not None, "object {0} not in DB".format(self.id)
|
assert stored_obj is not None, "object {0} not in DB".format(self.id)
|
||||||
|
self._values_fixed = {}
|
||||||
|
self._values_flex = {}
|
||||||
self.update(dict(stored_obj))
|
self.update(dict(stored_obj))
|
||||||
self.clear_dirty()
|
self.clear_dirty()
|
||||||
|
|
||||||
@@ -344,76 +424,26 @@ class Model(object):
|
|||||||
self._dirty.add(key)
|
self._dirty.add(key)
|
||||||
self.store()
|
self.store()
|
||||||
|
|
||||||
|
|
||||||
# Formatting and templating.
|
# Formatting and templating.
|
||||||
|
|
||||||
@classmethod
|
_formatter = FormattedMapping
|
||||||
def _format(cls, key, value, for_path=False):
|
|
||||||
"""Format a value as the given field for this model.
|
|
||||||
"""
|
|
||||||
# Format the value as a string according to its type, if any.
|
|
||||||
if key in cls._fields:
|
|
||||||
value = cls._fields[key].format(value)
|
|
||||||
# Formatting must result in a string. To deal with
|
|
||||||
# Python2isms, implicitly convert ASCII strings.
|
|
||||||
assert isinstance(value, basestring), \
|
|
||||||
u'field formatter must produce strings'
|
|
||||||
if isinstance(value, bytes):
|
|
||||||
value = value.decode('utf8', 'ignore')
|
|
||||||
|
|
||||||
elif not isinstance(value, unicode):
|
def formatted(self, for_path=False):
|
||||||
# Fallback formatter. Convert to unicode at all cost.
|
|
||||||
if value is None:
|
|
||||||
value = u''
|
|
||||||
elif isinstance(value, basestring):
|
|
||||||
if isinstance(value, bytes):
|
|
||||||
value = value.decode('utf8', 'ignore')
|
|
||||||
else:
|
|
||||||
value = unicode(value)
|
|
||||||
|
|
||||||
if for_path:
|
|
||||||
sep_repl = beets.config['path_sep_replace'].get(unicode)
|
|
||||||
for sep in (os.path.sep, os.path.altsep):
|
|
||||||
if sep:
|
|
||||||
value = value.replace(sep, sep_repl)
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
def _get_formatted(self, key, for_path=False):
|
|
||||||
"""Get a field value formatted as a string (`unicode` object)
|
|
||||||
for display to the user. If `for_path` is true, then the value
|
|
||||||
will be sanitized for inclusion in a pathname (i.e., path
|
|
||||||
separators will be removed from the value).
|
|
||||||
"""
|
|
||||||
return self._format(key, self.get(key), for_path)
|
|
||||||
|
|
||||||
def _formatted_mapping(self, for_path=False):
|
|
||||||
"""Get a mapping containing all values on this object formatted
|
"""Get a mapping containing all values on this object formatted
|
||||||
as human-readable strings.
|
as human-readable unicode strings.
|
||||||
"""
|
"""
|
||||||
# In the future, this could be made "lazy" to avoid computing
|
return self._formatter(self, for_path)
|
||||||
# fields unnecessarily.
|
|
||||||
out = {}
|
|
||||||
for key in self.keys(True):
|
|
||||||
out[key] = self._get_formatted(key, for_path)
|
|
||||||
return out
|
|
||||||
|
|
||||||
def evaluate_template(self, template, for_path=False):
|
def evaluate_template(self, template, for_path=False):
|
||||||
"""Evaluate a template (a string or a `Template` object) using
|
"""Evaluate a template (a string or a `Template` object) using
|
||||||
the object's fields. If `for_path` is true, then no new path
|
the object's fields. If `for_path` is true, then no new path
|
||||||
separators will be added to the template.
|
separators will be added to the template.
|
||||||
"""
|
"""
|
||||||
# Build value mapping.
|
|
||||||
mapping = self._formatted_mapping(for_path)
|
|
||||||
|
|
||||||
# Get template functions.
|
|
||||||
funcs = self._template_funcs()
|
|
||||||
|
|
||||||
# Perform substitution.
|
# Perform substitution.
|
||||||
if isinstance(template, basestring):
|
if isinstance(template, basestring):
|
||||||
template = Template(template)
|
template = Template(template)
|
||||||
return template.substitute(mapping, funcs)
|
return template.substitute(self.formatted(for_path),
|
||||||
|
self._template_funcs())
|
||||||
|
|
||||||
# Parsing.
|
# Parsing.
|
||||||
|
|
||||||
@@ -424,40 +454,91 @@ class Model(object):
|
|||||||
if not isinstance(string, basestring):
|
if not isinstance(string, basestring):
|
||||||
raise TypeError("_parse() argument must be a string")
|
raise TypeError("_parse() argument must be a string")
|
||||||
|
|
||||||
typ = cls._fields.get(key)
|
return cls._type(key).parse(string)
|
||||||
if typ:
|
|
||||||
return typ.parse(string)
|
|
||||||
else:
|
|
||||||
# Fall back to unparsed string.
|
|
||||||
return string
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Database controller and supporting interfaces.
|
# Database controller and supporting interfaces.
|
||||||
|
|
||||||
|
|
||||||
class Results(object):
|
class Results(object):
|
||||||
"""An item query result set. Iterating over the collection lazily
|
"""An item query result set. Iterating over the collection lazily
|
||||||
constructs LibModel objects that reflect database rows.
|
constructs LibModel objects that reflect database rows.
|
||||||
"""
|
"""
|
||||||
def __init__(self, model_class, rows, db, query=None):
|
def __init__(self, model_class, rows, db, query=None, sort=None):
|
||||||
"""Create a result set that will construct objects of type
|
"""Create a result set that will construct objects of type
|
||||||
`model_class`, which should be a subclass of `LibModel`, out of
|
`model_class`.
|
||||||
the query result mapping in `rows`. The new objects are
|
|
||||||
associated with the database `db`. If `query` is provided, it is
|
`model_class` is a subclass of `LibModel` that will be
|
||||||
used as a predicate to filter the results for a "slow query" that
|
constructed. `rows` is a query result: a list of mappings. The
|
||||||
cannot be evaluated by the database directly.
|
new objects will be associated with the database `db`.
|
||||||
|
|
||||||
|
If `query` is provided, it is used as a predicate to filter the
|
||||||
|
results for a "slow query" that cannot be evaluated by the
|
||||||
|
database directly. If `sort` is provided, it is used to sort the
|
||||||
|
full list of results before returning. This means it is a "slow
|
||||||
|
sort" and all objects must be built before returning the first
|
||||||
|
one.
|
||||||
"""
|
"""
|
||||||
self.model_class = model_class
|
self.model_class = model_class
|
||||||
self.rows = rows
|
self.rows = rows
|
||||||
self.db = db
|
self.db = db
|
||||||
self.query = query
|
self.query = query
|
||||||
|
self.sort = sort
|
||||||
|
|
||||||
|
# We keep a queue of rows we haven't yet consumed for
|
||||||
|
# materialization. We preserve the original total number of
|
||||||
|
# rows.
|
||||||
|
self._rows = rows
|
||||||
|
self._row_count = len(rows)
|
||||||
|
|
||||||
|
# The materialized objects corresponding to rows that have been
|
||||||
|
# consumed.
|
||||||
|
self._objects = []
|
||||||
|
|
||||||
|
def _get_objects(self):
|
||||||
|
"""Construct and generate Model objects for they query. The
|
||||||
|
objects are returned in the order emitted from the database; no
|
||||||
|
slow sort is applied.
|
||||||
|
|
||||||
|
For performance, this generator caches materialized objects to
|
||||||
|
avoid constructing them more than once. This way, iterating over
|
||||||
|
a `Results` object a second time should be much faster than the
|
||||||
|
first.
|
||||||
|
"""
|
||||||
|
index = 0 # Position in the materialized objects.
|
||||||
|
while index < len(self._objects) or self._rows:
|
||||||
|
# Are there previously-materialized objects to produce?
|
||||||
|
if index < len(self._objects):
|
||||||
|
yield self._objects[index]
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
# Otherwise, we consume another row, materialize its object
|
||||||
|
# and produce it.
|
||||||
|
else:
|
||||||
|
while self._rows:
|
||||||
|
row = self._rows.pop(0)
|
||||||
|
obj = self._make_model(row)
|
||||||
|
# If there is a slow-query predicate, ensurer that the
|
||||||
|
# object passes it.
|
||||||
|
if not self.query or self.query.match(obj):
|
||||||
|
self._objects.append(obj)
|
||||||
|
index += 1
|
||||||
|
yield obj
|
||||||
|
break
|
||||||
|
|
||||||
def __iter__(self):
|
def __iter__(self):
|
||||||
"""Construct Python objects for all rows that pass the query
|
"""Construct and generate Model objects for all matching
|
||||||
predicate.
|
objects, in sorted order.
|
||||||
"""
|
"""
|
||||||
for row in self.rows:
|
if self.sort:
|
||||||
|
# Slow sort. Must build the full list first.
|
||||||
|
objects = self.sort.sort(list(self._get_objects()))
|
||||||
|
return iter(objects)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Objects are pre-sorted (i.e., by the database).
|
||||||
|
return self._get_objects()
|
||||||
|
|
||||||
|
def _make_model(self, row):
|
||||||
# Get the flexible attributes for the object.
|
# Get the flexible attributes for the object.
|
||||||
with self.db.transaction() as tx:
|
with self.db.transaction() as tx:
|
||||||
flex_rows = tx.query(
|
flex_rows = tx.query(
|
||||||
@@ -466,21 +547,24 @@ class Results(object):
|
|||||||
),
|
),
|
||||||
(row['id'],)
|
(row['id'],)
|
||||||
)
|
)
|
||||||
values = dict(row)
|
|
||||||
values.update(
|
|
||||||
dict((row['key'], row['value']) for row in flex_rows)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Construct the Python object and yield it if it passes the
|
cols = dict(row)
|
||||||
# predicate.
|
values = dict((k, v) for (k, v) in cols.items()
|
||||||
obj = self.model_class(self.db, **values)
|
if not k[:4] == 'flex')
|
||||||
if not self.query or self.query.match(obj):
|
flex_values = dict((row['key'], row['value']) for row in flex_rows)
|
||||||
yield obj
|
|
||||||
|
# Construct the Python object
|
||||||
|
obj = self.model_class._awaken(self.db, values, flex_values)
|
||||||
|
return obj
|
||||||
|
|
||||||
def __len__(self):
|
def __len__(self):
|
||||||
"""Get the number of matching objects.
|
"""Get the number of matching objects.
|
||||||
"""
|
"""
|
||||||
if self.query:
|
if not self._rows:
|
||||||
|
# Fully materialized. Just count the objects.
|
||||||
|
return len(self._objects)
|
||||||
|
|
||||||
|
elif self.query:
|
||||||
# A slow query. Fall back to testing every object.
|
# A slow query. Fall back to testing every object.
|
||||||
count = 0
|
count = 0
|
||||||
for obj in self:
|
for obj in self:
|
||||||
@@ -489,7 +573,7 @@ class Results(object):
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
# A fast query. Just count the rows.
|
# A fast query. Just count the rows.
|
||||||
return len(self.rows)
|
return self._row_count
|
||||||
|
|
||||||
def __nonzero__(self):
|
def __nonzero__(self):
|
||||||
"""Does this result contain any objects?
|
"""Does this result contain any objects?
|
||||||
@@ -500,6 +584,11 @@ class Results(object):
|
|||||||
"""Get the nth item in this result set. This is inefficient: all
|
"""Get the nth item in this result set. This is inefficient: all
|
||||||
items up to n are materialized and thrown away.
|
items up to n are materialized and thrown away.
|
||||||
"""
|
"""
|
||||||
|
if not self._rows and not self.sort:
|
||||||
|
# Fully materialized and already in order. Just look up the
|
||||||
|
# object.
|
||||||
|
return self._objects[n]
|
||||||
|
|
||||||
it = iter(self)
|
it = iter(self)
|
||||||
try:
|
try:
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
@@ -604,7 +693,6 @@ class Database(object):
|
|||||||
self._make_table(model_cls._table, model_cls._fields)
|
self._make_table(model_cls._table, model_cls._fields)
|
||||||
self._make_attribute_table(model_cls._flex_table)
|
self._make_attribute_table(model_cls._flex_table)
|
||||||
|
|
||||||
|
|
||||||
# Primitive access control: connections and transactions.
|
# Primitive access control: connections and transactions.
|
||||||
|
|
||||||
def _connection(self):
|
def _connection(self):
|
||||||
@@ -644,7 +732,6 @@ class Database(object):
|
|||||||
"""
|
"""
|
||||||
return Transaction(self)
|
return Transaction(self)
|
||||||
|
|
||||||
|
|
||||||
# Schema setup and migration.
|
# Schema setup and migration.
|
||||||
|
|
||||||
def _make_table(self, table, fields):
|
def _make_table(self, table, fields):
|
||||||
@@ -698,27 +785,33 @@ class Database(object):
|
|||||||
ON {0} (entity_id);
|
ON {0} (entity_id);
|
||||||
""".format(flex_table))
|
""".format(flex_table))
|
||||||
|
|
||||||
|
|
||||||
# Querying.
|
# Querying.
|
||||||
|
|
||||||
def _fetch(self, model_cls, query, order_by=None):
|
def _fetch(self, model_cls, query=None, sort=None):
|
||||||
"""Fetch the objects of type `model_cls` matching the given
|
"""Fetch the objects of type `model_cls` matching the given
|
||||||
query. The query may be given as a string, string sequence, a
|
query. The query may be given as a string, string sequence, a
|
||||||
Query object, or None (to fetch everything). If provided,
|
Query object, or None (to fetch everything). `sort` is an
|
||||||
`order_by` is a SQLite ORDER BY clause for sorting.
|
`Sort` object.
|
||||||
"""
|
"""
|
||||||
|
query = query or TrueQuery() # A null query.
|
||||||
|
sort = sort or NullSort() # Unsorted.
|
||||||
where, subvals = query.clause()
|
where, subvals = query.clause()
|
||||||
|
order_by = sort.order_clause()
|
||||||
|
|
||||||
sql = "SELECT * FROM {0} WHERE {1}".format(
|
sql = ("SELECT * FROM {0} WHERE {1} {2}").format(
|
||||||
model_cls._table,
|
model_cls._table,
|
||||||
where or '1',
|
where or '1',
|
||||||
|
"ORDER BY {0}".format(order_by) if order_by else '',
|
||||||
)
|
)
|
||||||
if order_by:
|
|
||||||
sql += " ORDER BY {0}".format(order_by)
|
|
||||||
with self.transaction() as tx:
|
with self.transaction() as tx:
|
||||||
rows = tx.query(sql, subvals)
|
rows = tx.query(sql, subvals)
|
||||||
|
|
||||||
return Results(model_cls, rows, self, None if where else query)
|
return Results(
|
||||||
|
model_cls, rows, self,
|
||||||
|
None if where else query, # Slow query component.
|
||||||
|
sort if sort.is_slow() else None, # Slow sort component.
|
||||||
|
)
|
||||||
|
|
||||||
def _get(self, model_cls, id):
|
def _get(self, model_cls, id):
|
||||||
"""Get a Model object by its id or None if the id does not
|
"""Get a Model object by its id or None if the id does not
|
||||||
|
|||||||
+167
-7
@@ -15,6 +15,7 @@
|
|||||||
"""The Query type hierarchy for DBCore.
|
"""The Query type hierarchy for DBCore.
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
|
from operator import attrgetter
|
||||||
from beets import util
|
from beets import util
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
@@ -82,6 +83,23 @@ class MatchQuery(FieldQuery):
|
|||||||
return pattern == value
|
return pattern == value
|
||||||
|
|
||||||
|
|
||||||
|
class NoneQuery(FieldQuery):
|
||||||
|
|
||||||
|
def __init__(self, field, fast=True):
|
||||||
|
self.field = field
|
||||||
|
self.fast = fast
|
||||||
|
|
||||||
|
def col_clause(self):
|
||||||
|
return self.field + " IS NULL", ()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def match(self, item):
|
||||||
|
try:
|
||||||
|
return item[self.field] is None
|
||||||
|
except KeyError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class StringFieldQuery(FieldQuery):
|
class StringFieldQuery(FieldQuery):
|
||||||
"""A FieldQuery that converts values to strings before matching
|
"""A FieldQuery that converts values to strings before matching
|
||||||
them.
|
them.
|
||||||
@@ -104,8 +122,11 @@ class StringFieldQuery(FieldQuery):
|
|||||||
class SubstringQuery(StringFieldQuery):
|
class SubstringQuery(StringFieldQuery):
|
||||||
"""A query that matches a substring in a specific item field."""
|
"""A query that matches a substring in a specific item field."""
|
||||||
def col_clause(self):
|
def col_clause(self):
|
||||||
search = '%' + (self.pattern.replace('\\','\\\\').replace('%','\\%')
|
pattern = (self.pattern
|
||||||
.replace('_','\\_')) + '%'
|
.replace('\\', '\\\\')
|
||||||
|
.replace('%', '\\%')
|
||||||
|
.replace('_', '\\_'))
|
||||||
|
search = '%' + pattern + '%'
|
||||||
clause = self.field + " like ? escape '\\'"
|
clause = self.field + " like ? escape '\\'"
|
||||||
subvals = [search]
|
subvals = [search]
|
||||||
return clause, subvals
|
return clause, subvals
|
||||||
@@ -200,7 +221,9 @@ class NumericQuery(FieldQuery):
|
|||||||
self.rangemax = self._convert(parts[1])
|
self.rangemax = self._convert(parts[1])
|
||||||
|
|
||||||
def match(self, item):
|
def match(self, item):
|
||||||
value = getattr(item, self.field)
|
if self.field not in item:
|
||||||
|
return False
|
||||||
|
value = item[self.field]
|
||||||
if isinstance(value, basestring):
|
if isinstance(value, basestring):
|
||||||
value = self._convert(value)
|
value = self._convert(value)
|
||||||
|
|
||||||
@@ -236,12 +259,16 @@ class CollectionQuery(Query):
|
|||||||
self.subqueries = subqueries
|
self.subqueries = subqueries
|
||||||
|
|
||||||
# Act like a sequence.
|
# Act like a sequence.
|
||||||
|
|
||||||
def __len__(self):
|
def __len__(self):
|
||||||
return len(self.subqueries)
|
return len(self.subqueries)
|
||||||
|
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
return self.subqueries[key]
|
return self.subqueries[key]
|
||||||
|
|
||||||
def __iter__(self):
|
def __iter__(self):
|
||||||
return iter(self.subqueries)
|
return iter(self.subqueries)
|
||||||
|
|
||||||
def __contains__(self, item):
|
def __contains__(self, item):
|
||||||
return item in self.subqueries
|
return item in self.subqueries
|
||||||
|
|
||||||
@@ -334,10 +361,8 @@ class FalseQuery(Query):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Time/date queries.
|
# Time/date queries.
|
||||||
|
|
||||||
|
|
||||||
def _to_epoch_time(date):
|
def _to_epoch_time(date):
|
||||||
"""Convert a `datetime` object to an integer number of seconds since
|
"""Convert a `datetime` object to an integer number of seconds since
|
||||||
the (local) Unix epoch.
|
the (local) Unix epoch.
|
||||||
@@ -393,10 +418,14 @@ class Period(object):
|
|||||||
return None
|
return None
|
||||||
ordinal = string.count('-')
|
ordinal = string.count('-')
|
||||||
if ordinal >= len(cls.date_formats):
|
if ordinal >= len(cls.date_formats):
|
||||||
raise ValueError('date is not in one of the formats '
|
# Too many components.
|
||||||
+ ', '.join(cls.date_formats))
|
return None
|
||||||
date_format = cls.date_formats[ordinal]
|
date_format = cls.date_formats[ordinal]
|
||||||
|
try:
|
||||||
date = datetime.strptime(string, date_format)
|
date = datetime.strptime(string, date_format)
|
||||||
|
except ValueError:
|
||||||
|
# Parsing failed.
|
||||||
|
return None
|
||||||
precision = cls.precisions[ordinal]
|
precision = cls.precisions[ordinal]
|
||||||
return cls(date, precision)
|
return cls(date, precision)
|
||||||
|
|
||||||
@@ -492,3 +521,134 @@ class DateQuery(FieldQuery):
|
|||||||
# Match any date.
|
# Match any date.
|
||||||
clause = '1'
|
clause = '1'
|
||||||
return clause, subvals
|
return clause, subvals
|
||||||
|
|
||||||
|
|
||||||
|
# Sorting.
|
||||||
|
|
||||||
|
class Sort(object):
|
||||||
|
"""An abstract class representing a sort operation for a query into
|
||||||
|
the item database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def order_clause(self):
|
||||||
|
"""Generates a SQL fragment to be used in a ORDER BY clause, or
|
||||||
|
None if no fragment is used (i.e., this is a slow sort).
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def sort(self, items):
|
||||||
|
"""Sort the list of objects and return a list.
|
||||||
|
"""
|
||||||
|
return sorted(items)
|
||||||
|
|
||||||
|
def is_slow(self):
|
||||||
|
"""Indicate whether this query is *slow*, meaning that it cannot
|
||||||
|
be executed in SQL and must be executed in Python.
|
||||||
|
"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class MultipleSort(Sort):
|
||||||
|
"""Sort that encapsulates multiple sub-sorts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, sorts=None):
|
||||||
|
self.sorts = sorts or []
|
||||||
|
|
||||||
|
def add_sort(self, sort):
|
||||||
|
self.sorts.append(sort)
|
||||||
|
|
||||||
|
def _sql_sorts(self):
|
||||||
|
"""Return the list of sub-sorts for which we can be (at least
|
||||||
|
partially) fast.
|
||||||
|
|
||||||
|
A contiguous suffix of fast (SQL-capable) sub-sorts are
|
||||||
|
executable in SQL. The remaining, even if they are fast
|
||||||
|
independently, must be executed slowly.
|
||||||
|
"""
|
||||||
|
sql_sorts = []
|
||||||
|
for sort in reversed(self.sorts):
|
||||||
|
if not sort.order_clause() is None:
|
||||||
|
sql_sorts.append(sort)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
sql_sorts.reverse()
|
||||||
|
return sql_sorts
|
||||||
|
|
||||||
|
def order_clause(self):
|
||||||
|
order_strings = []
|
||||||
|
for sort in self._sql_sorts():
|
||||||
|
order = sort.order_clause()
|
||||||
|
order_strings.append(order)
|
||||||
|
|
||||||
|
return ", ".join(order_strings)
|
||||||
|
|
||||||
|
def is_slow(self):
|
||||||
|
for sort in self.sorts:
|
||||||
|
if sort.is_slow():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def sort(self, items):
|
||||||
|
slow_sorts = []
|
||||||
|
switch_slow = False
|
||||||
|
for sort in reversed(self.sorts):
|
||||||
|
if switch_slow:
|
||||||
|
slow_sorts.append(sort)
|
||||||
|
elif sort.order_clause() is None:
|
||||||
|
switch_slow = True
|
||||||
|
slow_sorts.append(sort)
|
||||||
|
else:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for sort in slow_sorts:
|
||||||
|
items = sort.sort(items)
|
||||||
|
return items
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return u'MultipleSort({0})'.format(repr(self.sorts))
|
||||||
|
|
||||||
|
|
||||||
|
class FieldSort(Sort):
|
||||||
|
"""An abstract sort criterion that orders by a specific field (of
|
||||||
|
any kind).
|
||||||
|
"""
|
||||||
|
def __init__(self, field, ascending=True):
|
||||||
|
self.field = field
|
||||||
|
self.ascending = ascending
|
||||||
|
|
||||||
|
def sort(self, objs):
|
||||||
|
# TODO: Conversion and null-detection here. In Python 3,
|
||||||
|
# comparisons with None fail. We should also support flexible
|
||||||
|
# attributes with different types without falling over.
|
||||||
|
return sorted(objs, key=attrgetter(self.field),
|
||||||
|
reverse=not self.ascending)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return u'<{0}: {1}{2}>'.format(
|
||||||
|
type(self).__name__,
|
||||||
|
self.field,
|
||||||
|
'+' if self.ascending else '-',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FixedFieldSort(FieldSort):
|
||||||
|
"""Sort object to sort on a fixed field.
|
||||||
|
"""
|
||||||
|
def order_clause(self):
|
||||||
|
order = "ASC" if self.ascending else "DESC"
|
||||||
|
return "{0} {1}".format(self.field, order)
|
||||||
|
|
||||||
|
|
||||||
|
class SlowFieldSort(FieldSort):
|
||||||
|
"""A sort criterion by some model field other than a fixed field:
|
||||||
|
i.e., a computed or flexible field.
|
||||||
|
"""
|
||||||
|
def is_slow(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class NullSort(Sort):
|
||||||
|
"""No sorting. Leave results unsorted."""
|
||||||
|
def sort(items):
|
||||||
|
return items
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# This file is part of beets.
|
||||||
|
# Copyright 2014, Adrian Sampson.
|
||||||
|
#
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
# a copy of this software and associated documentation files (the
|
||||||
|
# "Software"), to deal in the Software without restriction, including
|
||||||
|
# without limitation the rights to use, copy, modify, merge, publish,
|
||||||
|
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||||
|
# permit persons to whom the Software is furnished to do so, subject to
|
||||||
|
# the following conditions:
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be
|
||||||
|
# included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
"""Parsing of strings into DBCore queries.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import itertools
|
||||||
|
from . import query
|
||||||
|
|
||||||
|
|
||||||
|
PARSE_QUERY_PART_REGEX = re.compile(
|
||||||
|
# Non-capturing optional segment for the keyword.
|
||||||
|
r'(?:'
|
||||||
|
r'(\S+?)' # The field key.
|
||||||
|
r'(?<!\\):' # Unescaped :
|
||||||
|
r')?'
|
||||||
|
|
||||||
|
r'(.*)', # The term itself.
|
||||||
|
|
||||||
|
re.I # Case-insensitive.
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_query_part(part, query_classes={}, prefixes={},
|
||||||
|
default_class=query.SubstringQuery):
|
||||||
|
"""Take a query in the form of a key/value pair separated by a
|
||||||
|
colon and return a tuple of `(key, value, cls)`. `key` may be None,
|
||||||
|
indicating that any field may be matched. `cls` is a subclass of
|
||||||
|
`FieldQuery`.
|
||||||
|
|
||||||
|
The optional `query_classes` parameter maps field names to default
|
||||||
|
query types; `default_class` is the fallback. `prefixes` is a map
|
||||||
|
from query prefix markers and query types. Prefix-indicated queries
|
||||||
|
take precedence over type-based queries.
|
||||||
|
|
||||||
|
To determine the query class, two factors are used: prefixes and
|
||||||
|
field types. For example, the colon prefix denotes a regular
|
||||||
|
expression query and a type map might provide a special kind of
|
||||||
|
query for numeric values. If neither a prefix nor a specific query
|
||||||
|
class is available, `default_class` is used.
|
||||||
|
|
||||||
|
For instance,
|
||||||
|
'stapler' -> (None, 'stapler', SubstringQuery)
|
||||||
|
'color:red' -> ('color', 'red', SubstringQuery)
|
||||||
|
':^Quiet' -> (None, '^Quiet', RegexpQuery)
|
||||||
|
'color::b..e' -> ('color', 'b..e', RegexpQuery)
|
||||||
|
|
||||||
|
Prefixes may be "escaped" with a backslash to disable the keying
|
||||||
|
behavior.
|
||||||
|
"""
|
||||||
|
part = part.strip()
|
||||||
|
match = PARSE_QUERY_PART_REGEX.match(part)
|
||||||
|
|
||||||
|
assert match # Regex should always match.
|
||||||
|
key = match.group(1)
|
||||||
|
term = match.group(2).replace('\:', ':')
|
||||||
|
|
||||||
|
# Match the search term against the list of prefixes.
|
||||||
|
for pre, query_class in prefixes.items():
|
||||||
|
if term.startswith(pre):
|
||||||
|
return key, term[len(pre):], query_class
|
||||||
|
|
||||||
|
# No matching prefix: use type-based or fallback/default query.
|
||||||
|
query_class = query_classes.get(key, default_class)
|
||||||
|
return key, term, query_class
|
||||||
|
|
||||||
|
|
||||||
|
def construct_query_part(model_cls, prefixes, query_part):
|
||||||
|
"""Create a query from a single query component, `query_part`, for
|
||||||
|
querying instances of `model_cls`. Return a `Query` instance.
|
||||||
|
"""
|
||||||
|
# Shortcut for empty query parts.
|
||||||
|
if not query_part:
|
||||||
|
return query.TrueQuery()
|
||||||
|
|
||||||
|
# Get the query classes for each possible field.
|
||||||
|
query_classes = {}
|
||||||
|
for k, t in itertools.chain(model_cls._fields.items(),
|
||||||
|
model_cls._types.items()):
|
||||||
|
query_classes[k] = t.query
|
||||||
|
|
||||||
|
# Parse the string.
|
||||||
|
key, pattern, query_class = \
|
||||||
|
parse_query_part(query_part, query_classes, prefixes)
|
||||||
|
|
||||||
|
# No key specified.
|
||||||
|
if key is None:
|
||||||
|
if issubclass(query_class, query.FieldQuery):
|
||||||
|
# The query type matches a specific field, but none was
|
||||||
|
# specified. So we use a version of the query that matches
|
||||||
|
# any field.
|
||||||
|
return query.AnyFieldQuery(pattern, model_cls._search_fields,
|
||||||
|
query_class)
|
||||||
|
else:
|
||||||
|
# Other query type.
|
||||||
|
return query_class(pattern)
|
||||||
|
|
||||||
|
key = key.lower()
|
||||||
|
return query_class(key.lower(), pattern, key in model_cls._fields)
|
||||||
|
|
||||||
|
|
||||||
|
def query_from_strings(query_cls, model_cls, prefixes, query_parts):
|
||||||
|
"""Creates a collection query of type `query_cls` from a list of
|
||||||
|
strings in the format used by parse_query_part. `model_cls`
|
||||||
|
determines how queries are constructed from strings.
|
||||||
|
"""
|
||||||
|
subqueries = []
|
||||||
|
for part in query_parts:
|
||||||
|
subqueries.append(construct_query_part(model_cls, prefixes, part))
|
||||||
|
if not subqueries: # No terms in query.
|
||||||
|
subqueries = [query.TrueQuery()]
|
||||||
|
return query_cls(subqueries)
|
||||||
|
|
||||||
|
|
||||||
|
def construct_sort_part(model_cls, part):
|
||||||
|
"""Create a `Sort` from a single string criterion.
|
||||||
|
|
||||||
|
`model_cls` is the `Model` being queried. `part` is a single string
|
||||||
|
ending in ``+`` or ``-`` indicating the sort.
|
||||||
|
"""
|
||||||
|
assert part, "part must be a field name and + or -"
|
||||||
|
field = part[:-1]
|
||||||
|
assert field, "field is missing"
|
||||||
|
direction = part[-1]
|
||||||
|
assert direction in ('+', '-'), "part must end with + or -"
|
||||||
|
is_ascending = direction == '+'
|
||||||
|
|
||||||
|
if field in model_cls._sorts:
|
||||||
|
sort = model_cls._sorts[field](model_cls, is_ascending)
|
||||||
|
elif field in model_cls._fields:
|
||||||
|
sort = query.FixedFieldSort(field, is_ascending)
|
||||||
|
else:
|
||||||
|
# Flexible or computed.
|
||||||
|
sort = query.SlowFieldSort(field, is_ascending)
|
||||||
|
return sort
|
||||||
|
|
||||||
|
|
||||||
|
def sort_from_strings(model_cls, sort_parts):
|
||||||
|
"""Create a `Sort` from a list of sort criteria (strings).
|
||||||
|
"""
|
||||||
|
if not sort_parts:
|
||||||
|
return query.NullSort()
|
||||||
|
else:
|
||||||
|
sort = query.MultipleSort()
|
||||||
|
for part in sort_parts:
|
||||||
|
sort.add_sort(construct_sort_part(model_cls, part))
|
||||||
|
return sort
|
||||||
|
|
||||||
|
|
||||||
|
def parse_sorted_query(model_cls, parts, prefixes={},
|
||||||
|
query_cls=query.AndQuery):
|
||||||
|
"""Given a list of strings, create the `Query` and `Sort` that they
|
||||||
|
represent.
|
||||||
|
"""
|
||||||
|
# Separate query token and sort token.
|
||||||
|
query_parts = []
|
||||||
|
sort_parts = []
|
||||||
|
for part in parts:
|
||||||
|
if part.endswith((u'+', u'-')) and u':' not in part:
|
||||||
|
sort_parts.append(part)
|
||||||
|
else:
|
||||||
|
query_parts.append(part)
|
||||||
|
|
||||||
|
# Parse each.
|
||||||
|
q = query_from_strings(
|
||||||
|
query_cls, model_cls, prefixes, query_parts
|
||||||
|
)
|
||||||
|
s = sort_from_strings(model_cls, sort_parts)
|
||||||
|
return q, s
|
||||||
+98
-30
@@ -18,55 +18,111 @@ from . import query
|
|||||||
from beets.util import str2bool
|
from beets.util import str2bool
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Abstract base.
|
# Abstract base.
|
||||||
|
|
||||||
|
|
||||||
class Type(object):
|
class Type(object):
|
||||||
"""An object encapsulating the type of a model field. Includes
|
"""An object encapsulating the type of a model field. Includes
|
||||||
information about how to store the value in the database, query,
|
information about how to store, query, format, and parse a given
|
||||||
format, and parse a given field.
|
field.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
sql = None
|
sql = u'TEXT'
|
||||||
"""The SQLite column type for the value.
|
"""The SQLite column type for the value.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
query = None
|
query = query.SubstringQuery
|
||||||
"""The `Query` subclass to be used when querying the field.
|
"""The `Query` subclass to be used when querying the field.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
model_type = unicode
|
||||||
|
"""The Python type that is used to represent the value in the model.
|
||||||
|
|
||||||
|
The model is guaranteed to return a value of this type if the field
|
||||||
|
is accessed. To this end, the constructor is used by the `normalize`
|
||||||
|
and `from_sql` methods and the `default` property.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def null(self):
|
||||||
|
"""The value to be exposed when the underlying value is None.
|
||||||
|
"""
|
||||||
|
return self.model_type()
|
||||||
|
|
||||||
def format(self, value):
|
def format(self, value):
|
||||||
"""Given a value of this type, produce a Unicode string
|
"""Given a value of this type, produce a Unicode string
|
||||||
representing the value. This is used in template evaluation.
|
representing the value. This is used in template evaluation.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError()
|
if value is None:
|
||||||
|
value = self.null
|
||||||
|
# `self.null` might be `None`
|
||||||
|
if value is None:
|
||||||
|
value = u''
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
value = value.decode('utf8', 'ignore')
|
||||||
|
|
||||||
|
return unicode(value)
|
||||||
|
|
||||||
def parse(self, string):
|
def parse(self, string):
|
||||||
"""Parse a (possibly human-written) string and return the
|
"""Parse a (possibly human-written) string and return the
|
||||||
indicated value of this type.
|
indicated value of this type.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError()
|
try:
|
||||||
|
return self.model_type(string)
|
||||||
|
except ValueError:
|
||||||
|
return self.null
|
||||||
|
|
||||||
|
def normalize(self, value):
|
||||||
|
"""Given a value that will be assigned into a field of this
|
||||||
|
type, normalize the value to have the appropriate type. This
|
||||||
|
base implementation only reinterprets `None`.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return self.null
|
||||||
|
else:
|
||||||
|
# TODO This should eventually be replaced by
|
||||||
|
# `self.model_type(value)`
|
||||||
|
return value
|
||||||
|
|
||||||
|
def from_sql(self, sql_value):
|
||||||
|
"""Receives the value stored in the SQL backend and return the
|
||||||
|
value to be stored in the model.
|
||||||
|
|
||||||
|
For fixed fields the type of `value` is determined by the column
|
||||||
|
type affinity given in the `sql` property and the SQL to Python
|
||||||
|
mapping of the database adapter. For more information see:
|
||||||
|
http://www.sqlite.org/datatype3.html
|
||||||
|
https://docs.python.org/2/library/sqlite3.html#sqlite-and-python-types
|
||||||
|
|
||||||
|
Flexible fields have the type afinity `TEXT`. This means the
|
||||||
|
`sql_value` is either a `buffer` or a `unicode` object` and the
|
||||||
|
method must handle these in addition.
|
||||||
|
"""
|
||||||
|
if isinstance(sql_value, buffer):
|
||||||
|
sql_value = bytes(sql_value).decode('utf8', 'ignore')
|
||||||
|
if isinstance(sql_value, unicode):
|
||||||
|
return self.parse(sql_value)
|
||||||
|
else:
|
||||||
|
return self.normalize(sql_value)
|
||||||
|
|
||||||
|
def to_sql(self, model_value):
|
||||||
|
"""Convert a value as stored in the model object to a value used
|
||||||
|
by the database adapter.
|
||||||
|
"""
|
||||||
|
return model_value
|
||||||
|
|
||||||
|
|
||||||
# Reusable types.
|
# Reusable types.
|
||||||
|
|
||||||
|
class Default(Type):
|
||||||
|
null = None
|
||||||
|
|
||||||
|
|
||||||
class Integer(Type):
|
class Integer(Type):
|
||||||
"""A basic integer type.
|
"""A basic integer type.
|
||||||
"""
|
"""
|
||||||
sql = u'INTEGER'
|
sql = u'INTEGER'
|
||||||
query = query.NumericQuery
|
query = query.NumericQuery
|
||||||
|
model_type = int
|
||||||
def format(self, value):
|
|
||||||
return unicode(value or 0)
|
|
||||||
|
|
||||||
def parse(self, string):
|
|
||||||
try:
|
|
||||||
return int(string)
|
|
||||||
except ValueError:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
class PaddedInt(Integer):
|
class PaddedInt(Integer):
|
||||||
@@ -93,9 +149,14 @@ class ScaledInt(Integer):
|
|||||||
|
|
||||||
|
|
||||||
class Id(Integer):
|
class Id(Integer):
|
||||||
"""An integer used as the row key for a SQLite table.
|
"""An integer used as the row id or a foreign key in a SQLite table.
|
||||||
|
This type is nullable: None values are not translated to zero.
|
||||||
"""
|
"""
|
||||||
sql = u'INTEGER PRIMARY KEY'
|
null = None
|
||||||
|
|
||||||
|
def __init__(self, primary=True):
|
||||||
|
if primary:
|
||||||
|
self.sql = u'INTEGER PRIMARY KEY'
|
||||||
|
|
||||||
|
|
||||||
class Float(Type):
|
class Float(Type):
|
||||||
@@ -103,15 +164,16 @@ class Float(Type):
|
|||||||
"""
|
"""
|
||||||
sql = u'REAL'
|
sql = u'REAL'
|
||||||
query = query.NumericQuery
|
query = query.NumericQuery
|
||||||
|
model_type = float
|
||||||
|
|
||||||
def format(self, value):
|
def format(self, value):
|
||||||
return u'{0:.1f}'.format(value or 0.0)
|
return u'{0:.1f}'.format(value or 0.0)
|
||||||
|
|
||||||
def parse(self, string):
|
|
||||||
try:
|
class NullFloat(Float):
|
||||||
return float(string)
|
"""Same as `Float`, but does not normalize `None` to `0.0`.
|
||||||
except ValueError:
|
"""
|
||||||
return 0.0
|
null = None
|
||||||
|
|
||||||
|
|
||||||
class String(Type):
|
class String(Type):
|
||||||
@@ -120,21 +182,27 @@ class String(Type):
|
|||||||
sql = u'TEXT'
|
sql = u'TEXT'
|
||||||
query = query.SubstringQuery
|
query = query.SubstringQuery
|
||||||
|
|
||||||
def format(self, value):
|
|
||||||
return unicode(value) if value else u''
|
|
||||||
|
|
||||||
def parse(self, string):
|
|
||||||
return string
|
|
||||||
|
|
||||||
|
|
||||||
class Boolean(Type):
|
class Boolean(Type):
|
||||||
"""A boolean type.
|
"""A boolean type.
|
||||||
"""
|
"""
|
||||||
sql = u'INTEGER'
|
sql = u'INTEGER'
|
||||||
query = query.BooleanQuery
|
query = query.BooleanQuery
|
||||||
|
model_type = bool
|
||||||
|
|
||||||
def format(self, value):
|
def format(self, value):
|
||||||
return unicode(bool(value))
|
return unicode(bool(value))
|
||||||
|
|
||||||
def parse(self, string):
|
def parse(self, string):
|
||||||
return str2bool(string)
|
return str2bool(string)
|
||||||
|
|
||||||
|
|
||||||
|
# Shared instances of common types.
|
||||||
|
DEFAULT = Default()
|
||||||
|
INTEGER = Integer()
|
||||||
|
PRIMARY_ID = Id(True)
|
||||||
|
FOREIGN_ID = Id(False)
|
||||||
|
FLOAT = Float()
|
||||||
|
NULL_FLOAT = NullFloat()
|
||||||
|
STRING = String()
|
||||||
|
BOOLEAN = Boolean()
|
||||||
|
|||||||
+1015
-617
File diff suppressed because it is too large
Load Diff
+475
-392
File diff suppressed because it is too large
Load Diff
+463
-206
File diff suppressed because it is too large
Load Diff
+96
-20
@@ -16,8 +16,11 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
|
|
||||||
import beets
|
import beets
|
||||||
from beets import mediafile
|
from beets import mediafile
|
||||||
|
|
||||||
@@ -30,6 +33,14 @@ LASTFM_KEY = '2dc3914abf35f0d9c92d97d8f8e42b43'
|
|||||||
log = logging.getLogger('beets')
|
log = logging.getLogger('beets')
|
||||||
|
|
||||||
|
|
||||||
|
class PluginConflictException(Exception):
|
||||||
|
"""Indicates that the services provided by one plugin conflict with
|
||||||
|
those of another.
|
||||||
|
|
||||||
|
For example two plugins may define different types for flexible fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# Managing the plugins themselves.
|
# Managing the plugins themselves.
|
||||||
|
|
||||||
class BeetsPlugin(object):
|
class BeetsPlugin(object):
|
||||||
@@ -40,7 +51,6 @@ class BeetsPlugin(object):
|
|||||||
def __init__(self, name=None):
|
def __init__(self, name=None):
|
||||||
"""Perform one-time plugin setup.
|
"""Perform one-time plugin setup.
|
||||||
"""
|
"""
|
||||||
_add_media_fields(self.item_fields())
|
|
||||||
self.import_stages = []
|
self.import_stages = []
|
||||||
self.name = name or self.__module__.split('.')[-1]
|
self.name = name or self.__module__.split('.')[-1]
|
||||||
self.config = beets.config[self.name]
|
self.config = beets.config[self.name]
|
||||||
@@ -86,14 +96,6 @@ class BeetsPlugin(object):
|
|||||||
"""
|
"""
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
def item_fields(self):
|
|
||||||
"""Returns field descriptors to be added to the MediaFile class,
|
|
||||||
in the form of a dictionary whose keys are field names and whose
|
|
||||||
values are descriptor (e.g., MediaField) instances. The Library
|
|
||||||
database schema is not (currently) extended.
|
|
||||||
"""
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def album_for_id(self, album_id):
|
def album_for_id(self, album_id):
|
||||||
"""Return an AlbumInfo object or None if no matching release was
|
"""Return an AlbumInfo object or None if no matching release was
|
||||||
found.
|
found.
|
||||||
@@ -106,6 +108,20 @@ class BeetsPlugin(object):
|
|||||||
"""
|
"""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def add_media_field(self, name, descriptor):
|
||||||
|
"""Add a field that is synchronized between media files and items.
|
||||||
|
|
||||||
|
When a media field is added ``item.write()`` will set the name
|
||||||
|
property of the item's MediaFile to ``item[name]`` and save the
|
||||||
|
changes. Similarly ``item.read()`` will set ``item[name]`` to
|
||||||
|
the value of the name property of the media file.
|
||||||
|
|
||||||
|
``descriptor`` must be an instance of ``mediafile.MediaField``.
|
||||||
|
"""
|
||||||
|
# Defer impor to prevent circular dependency
|
||||||
|
from beets import library
|
||||||
|
mediafile.MediaFile.add_field(name, descriptor)
|
||||||
|
library.Item._media_fields.add(name)
|
||||||
|
|
||||||
listeners = None
|
listeners = None
|
||||||
|
|
||||||
@@ -130,7 +146,7 @@ class BeetsPlugin(object):
|
|||||||
|
|
||||||
>>> @MyPlugin.listen("imported")
|
>>> @MyPlugin.listen("imported")
|
||||||
>>> def importListener(**kwargs):
|
>>> def importListener(**kwargs):
|
||||||
>>> pass
|
... pass
|
||||||
"""
|
"""
|
||||||
def helper(func):
|
def helper(func):
|
||||||
if cls.listeners is None:
|
if cls.listeners is None:
|
||||||
@@ -170,7 +186,10 @@ class BeetsPlugin(object):
|
|||||||
return func
|
return func
|
||||||
return helper
|
return helper
|
||||||
|
|
||||||
|
|
||||||
_classes = set()
|
_classes = set()
|
||||||
|
|
||||||
|
|
||||||
def load_plugins(names=()):
|
def load_plugins(names=()):
|
||||||
"""Imports the modules for a sequence of plugin names. Each name
|
"""Imports the modules for a sequence of plugin names. Each name
|
||||||
must be the name of a Python module under the "beetsplug" namespace
|
must be the name of a Python module under the "beetsplug" namespace
|
||||||
@@ -185,7 +204,7 @@ def load_plugins(names=()):
|
|||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
# Again, this is hacky:
|
# Again, this is hacky:
|
||||||
if exc.args[0].endswith(' ' + name):
|
if exc.args[0].endswith(' ' + name):
|
||||||
log.warn('** plugin %s not found' % name)
|
log.warn(u'** plugin {0} not found'.format(name))
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
@@ -195,10 +214,13 @@ def load_plugins(names=()):
|
|||||||
_classes.add(obj)
|
_classes.add(obj)
|
||||||
|
|
||||||
except:
|
except:
|
||||||
log.warn('** error loading plugin %s' % name)
|
log.warn(u'** error loading plugin {0}'.format(name))
|
||||||
log.warn(traceback.format_exc())
|
log.warn(traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
_instances = {}
|
_instances = {}
|
||||||
|
|
||||||
|
|
||||||
def find_plugins():
|
def find_plugins():
|
||||||
"""Returns a list of BeetsPlugin subclass instances from all
|
"""Returns a list of BeetsPlugin subclass instances from all
|
||||||
currently loaded beets plugins. Loads the default plugin set
|
currently loaded beets plugins. Loads the default plugin set
|
||||||
@@ -224,6 +246,7 @@ def commands():
|
|||||||
out += plugin.commands()
|
out += plugin.commands()
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def queries():
|
def queries():
|
||||||
"""Returns a dict mapping prefix strings to Query subclasses all loaded
|
"""Returns a dict mapping prefix strings to Query subclasses all loaded
|
||||||
plugins.
|
plugins.
|
||||||
@@ -233,6 +256,24 @@ def queries():
|
|||||||
out.update(plugin.queries())
|
out.update(plugin.queries())
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def types(model_cls):
|
||||||
|
# Gives us `item_types` and `album_types`
|
||||||
|
attr_name = '{0}_types'.format(model_cls.__name__.lower())
|
||||||
|
types = {}
|
||||||
|
for plugin in find_plugins():
|
||||||
|
plugin_types = getattr(plugin, attr_name, {})
|
||||||
|
for field in plugin_types:
|
||||||
|
if field in types and plugin_types[field] != types[field]:
|
||||||
|
raise PluginConflictException(
|
||||||
|
u'Plugin {0} defines flexible field {1} '
|
||||||
|
'which has already been defined with '
|
||||||
|
'another type.'.format(plugin.name, field)
|
||||||
|
)
|
||||||
|
types.update(plugin_types)
|
||||||
|
return types
|
||||||
|
|
||||||
|
|
||||||
def track_distance(item, info):
|
def track_distance(item, info):
|
||||||
"""Gets the track distance calculated by all loaded plugins.
|
"""Gets the track distance calculated by all loaded plugins.
|
||||||
Returns a Distance object.
|
Returns a Distance object.
|
||||||
@@ -243,6 +284,7 @@ def track_distance(item, info):
|
|||||||
dist.update(plugin.track_distance(item, info))
|
dist.update(plugin.track_distance(item, info))
|
||||||
return dist
|
return dist
|
||||||
|
|
||||||
|
|
||||||
def album_distance(items, album_info, mapping):
|
def album_distance(items, album_info, mapping):
|
||||||
"""Returns the album distance calculated by plugins."""
|
"""Returns the album distance calculated by plugins."""
|
||||||
from beets.autotag.hooks import Distance
|
from beets.autotag.hooks import Distance
|
||||||
@@ -251,6 +293,7 @@ def album_distance(items, album_info, mapping):
|
|||||||
dist.update(plugin.album_distance(items, album_info, mapping))
|
dist.update(plugin.album_distance(items, album_info, mapping))
|
||||||
return dist
|
return dist
|
||||||
|
|
||||||
|
|
||||||
def candidates(items, artist, album, va_likely):
|
def candidates(items, artist, album, va_likely):
|
||||||
"""Gets MusicBrainz candidates for an album from each plugin.
|
"""Gets MusicBrainz candidates for an album from each plugin.
|
||||||
"""
|
"""
|
||||||
@@ -259,6 +302,7 @@ def candidates(items, artist, album, va_likely):
|
|||||||
out.extend(plugin.candidates(items, artist, album, va_likely))
|
out.extend(plugin.candidates(items, artist, album, va_likely))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def item_candidates(item, artist, title):
|
def item_candidates(item, artist, title):
|
||||||
"""Gets MusicBrainz candidates for an item from the plugins.
|
"""Gets MusicBrainz candidates for an item from the plugins.
|
||||||
"""
|
"""
|
||||||
@@ -267,6 +311,7 @@ def item_candidates(item, artist, title):
|
|||||||
out.extend(plugin.item_candidates(item, artist, title))
|
out.extend(plugin.item_candidates(item, artist, title))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def album_for_id(album_id):
|
def album_for_id(album_id):
|
||||||
"""Get AlbumInfo objects for a given ID string.
|
"""Get AlbumInfo objects for a given ID string.
|
||||||
"""
|
"""
|
||||||
@@ -277,6 +322,7 @@ def album_for_id(album_id):
|
|||||||
out.append(res)
|
out.append(res)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def track_for_id(track_id):
|
def track_for_id(track_id):
|
||||||
"""Get TrackInfo objects for a given ID string.
|
"""Get TrackInfo objects for a given ID string.
|
||||||
"""
|
"""
|
||||||
@@ -287,6 +333,7 @@ def track_for_id(track_id):
|
|||||||
out.append(res)
|
out.append(res)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def template_funcs():
|
def template_funcs():
|
||||||
"""Get all the template functions declared by plugins as a
|
"""Get all the template functions declared by plugins as a
|
||||||
dictionary.
|
dictionary.
|
||||||
@@ -297,12 +344,6 @@ def template_funcs():
|
|||||||
funcs.update(plugin.template_funcs)
|
funcs.update(plugin.template_funcs)
|
||||||
return funcs
|
return funcs
|
||||||
|
|
||||||
def _add_media_fields(fields):
|
|
||||||
"""Adds a {name: descriptor} dictionary of fields to the MediaFile
|
|
||||||
class. Called during the plugin initialization.
|
|
||||||
"""
|
|
||||||
for key, value in fields.iteritems():
|
|
||||||
setattr(mediafile.MediaFile, key, value)
|
|
||||||
|
|
||||||
def import_stages():
|
def import_stages():
|
||||||
"""Get a list of import stage functions defined by plugins."""
|
"""Get a list of import stage functions defined by plugins."""
|
||||||
@@ -325,6 +366,7 @@ def item_field_getters():
|
|||||||
funcs.update(plugin.template_fields)
|
funcs.update(plugin.template_fields)
|
||||||
return funcs
|
return funcs
|
||||||
|
|
||||||
|
|
||||||
def album_field_getters():
|
def album_field_getters():
|
||||||
"""As above, for album fields.
|
"""As above, for album fields.
|
||||||
"""
|
"""
|
||||||
@@ -348,6 +390,7 @@ def event_handlers():
|
|||||||
all_handlers[event] += handlers
|
all_handlers[event] += handlers
|
||||||
return all_handlers
|
return all_handlers
|
||||||
|
|
||||||
|
|
||||||
def send(event, **arguments):
|
def send(event, **arguments):
|
||||||
"""Sends an event to all assigned event listeners. Event is the
|
"""Sends an event to all assigned event listeners. Event is the
|
||||||
name of the event to send, all other named arguments go to the
|
name of the event to send, all other named arguments go to the
|
||||||
@@ -355,5 +398,38 @@ def send(event, **arguments):
|
|||||||
|
|
||||||
Returns a list of return values from the handlers.
|
Returns a list of return values from the handlers.
|
||||||
"""
|
"""
|
||||||
log.debug('Sending event: %s' % event)
|
log.debug(u'Sending event: {0}'.format(event))
|
||||||
return [handler(**arguments) for handler in event_handlers()[event]]
|
for handler in event_handlers()[event]:
|
||||||
|
# Don't break legacy plugins if we want to pass more arguments
|
||||||
|
argspec = inspect.getargspec(handler).args
|
||||||
|
args = dict((k, v) for k, v in arguments.items() if k in argspec)
|
||||||
|
handler(**args)
|
||||||
|
|
||||||
|
|
||||||
|
def feat_tokens(for_artist=True):
|
||||||
|
"""Return a regular expression that matches phrases like "featuring"
|
||||||
|
that separate a main artist or a song title from secondary artists.
|
||||||
|
The `for_artist` option determines whether the regex should be
|
||||||
|
suitable for matching artist fields (the default) or title fields.
|
||||||
|
"""
|
||||||
|
feat_words = ['ft', 'featuring', 'feat', 'feat.', 'ft.']
|
||||||
|
if for_artist:
|
||||||
|
feat_words += ['with', 'vs', 'and', 'con', '&']
|
||||||
|
return '(?<=\s)(?:{0})(?=\s)'.format(
|
||||||
|
'|'.join(re.escape(x) for x in feat_words)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_choices(choices, choices_all):
|
||||||
|
"""Clean up a stringlist configuration attribute: keep only choices
|
||||||
|
elements present in choices_all, remove duplicate elements, expand '*'
|
||||||
|
wildcard while keeping original stringlist order.
|
||||||
|
"""
|
||||||
|
seen = set()
|
||||||
|
others = [x for x in choices_all if x not in choices]
|
||||||
|
res = []
|
||||||
|
for s in choices:
|
||||||
|
if s in list(choices_all) + ['*']:
|
||||||
|
if not (s in seen or seen.add(s)):
|
||||||
|
res.extend(list(others) if s == '*' else [s])
|
||||||
|
return res
|
||||||
|
|||||||
+157
-167
@@ -29,6 +29,7 @@ import errno
|
|||||||
import re
|
import re
|
||||||
import struct
|
import struct
|
||||||
import traceback
|
import traceback
|
||||||
|
import os.path
|
||||||
|
|
||||||
from beets import library
|
from beets import library
|
||||||
from beets import plugins
|
from beets import plugins
|
||||||
@@ -38,9 +39,7 @@ from beets import config
|
|||||||
from beets.util import confit
|
from beets.util import confit
|
||||||
from beets.autotag import mb
|
from beets.autotag import mb
|
||||||
|
|
||||||
|
|
||||||
# On Windows platforms, use colorama to support "ANSI" terminal colors.
|
# On Windows platforms, use colorama to support "ANSI" terminal colors.
|
||||||
|
|
||||||
if sys.platform == 'win32':
|
if sys.platform == 'win32':
|
||||||
try:
|
try:
|
||||||
import colorama
|
import colorama
|
||||||
@@ -50,8 +49,10 @@ if sys.platform == 'win32':
|
|||||||
colorama.init()
|
colorama.init()
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger('beets')
|
||||||
# Constants.
|
if not log.handlers:
|
||||||
|
log.addHandler(logging.StreamHandler())
|
||||||
|
log.propagate = False # Don't propagate to root handler.
|
||||||
|
|
||||||
|
|
||||||
PF_KEY_QUERIES = {
|
PF_KEY_QUERIES = {
|
||||||
@@ -59,19 +60,15 @@ PF_KEY_QUERIES = {
|
|||||||
'singleton': 'singleton:true',
|
'singleton': 'singleton:true',
|
||||||
}
|
}
|
||||||
|
|
||||||
# UI exception. Commands should throw this in order to display
|
|
||||||
# nonrecoverable errors to the user.
|
|
||||||
class UserError(Exception):
|
class UserError(Exception):
|
||||||
pass
|
"""UI exception. Commands should throw this in order to display
|
||||||
|
nonrecoverable errors to the user.
|
||||||
# Main logger.
|
"""
|
||||||
log = logging.getLogger('beets')
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Utilities.
|
# Utilities.
|
||||||
|
|
||||||
|
|
||||||
def _encoding():
|
def _encoding():
|
||||||
"""Tries to guess the encoding used by the terminal."""
|
"""Tries to guess the encoding used by the terminal."""
|
||||||
# Configured override?
|
# Configured override?
|
||||||
@@ -181,7 +178,8 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
|
|||||||
index = option.index(found_letter)
|
index = option.index(found_letter)
|
||||||
|
|
||||||
# Mark the option's shortcut letter for display.
|
# Mark the option's shortcut letter for display.
|
||||||
if not require and ((default is None and not numrange and first) or
|
if not require and (
|
||||||
|
(default is None and not numrange and first) or
|
||||||
(isinstance(default, basestring) and
|
(isinstance(default, basestring) and
|
||||||
found_letter.lower() == default.lower())):
|
found_letter.lower() == default.lower())):
|
||||||
# The first option is the default; mark it.
|
# The first option is the default; mark it.
|
||||||
@@ -357,6 +355,8 @@ DARK_COLORS = ["black", "darkred", "darkgreen", "brown", "darkblue",
|
|||||||
LIGHT_COLORS = ["darkgray", "red", "green", "yellow", "blue",
|
LIGHT_COLORS = ["darkgray", "red", "green", "yellow", "blue",
|
||||||
"fuchsia", "turquoise", "white"]
|
"fuchsia", "turquoise", "white"]
|
||||||
RESET_COLOR = COLOR_ESCAPE + "39;49;00m"
|
RESET_COLOR = COLOR_ESCAPE + "39;49;00m"
|
||||||
|
|
||||||
|
|
||||||
def _colorize(color, text):
|
def _colorize(color, text):
|
||||||
"""Returns a string that prints the given text in the given color
|
"""Returns a string that prints the given text in the given color
|
||||||
in a terminal that is ANSI color-aware. The color must be something
|
in a terminal that is ANSI color-aware. The color must be something
|
||||||
@@ -441,30 +441,6 @@ def colordiff(a, b, highlight='red'):
|
|||||||
return unicode(a), unicode(b)
|
return unicode(a), unicode(b)
|
||||||
|
|
||||||
|
|
||||||
def color_diff_suffix(a, b, highlight='red'):
|
|
||||||
"""Colorize the differing suffix between two strings."""
|
|
||||||
a, b = unicode(a), unicode(b)
|
|
||||||
if not config['color']:
|
|
||||||
return a, b
|
|
||||||
|
|
||||||
# Fast path.
|
|
||||||
if a == b:
|
|
||||||
return a, b
|
|
||||||
|
|
||||||
# Find the longest common prefix.
|
|
||||||
first_diff = None
|
|
||||||
for i in range(min(len(a), len(b))):
|
|
||||||
if a[i] != b[i]:
|
|
||||||
first_diff = i
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
first_diff = min(len(a), len(b))
|
|
||||||
|
|
||||||
# Colorize from the first difference on.
|
|
||||||
return a[:first_diff] + colorize(highlight, a[first_diff:]), \
|
|
||||||
b[:first_diff] + colorize(highlight, b[first_diff:])
|
|
||||||
|
|
||||||
|
|
||||||
def get_path_formats(subview=None):
|
def get_path_formats(subview=None):
|
||||||
"""Get the configuration's path formats as a list of query/template
|
"""Get the configuration's path formats as a list of query/template
|
||||||
pairs.
|
pairs.
|
||||||
@@ -494,21 +470,6 @@ def get_replacements():
|
|||||||
return replacements
|
return replacements
|
||||||
|
|
||||||
|
|
||||||
def get_plugin_paths():
|
|
||||||
"""Get the list of search paths for plugins from the config file.
|
|
||||||
The value for "pluginpath" may be a single string or a list of
|
|
||||||
strings.
|
|
||||||
"""
|
|
||||||
pluginpaths = config['pluginpath'].get()
|
|
||||||
if isinstance(pluginpaths, basestring):
|
|
||||||
pluginpaths = [pluginpaths]
|
|
||||||
if not isinstance(pluginpaths, list):
|
|
||||||
raise confit.ConfigTypeError(
|
|
||||||
u'pluginpath must be string or a list of strings'
|
|
||||||
)
|
|
||||||
return map(util.normpath, pluginpaths)
|
|
||||||
|
|
||||||
|
|
||||||
def _pick_format(album, fmt=None):
|
def _pick_format(album, fmt=None):
|
||||||
"""Pick a format string for printing Album or Item objects,
|
"""Pick a format string for printing Album or Item objects,
|
||||||
falling back to config options and defaults.
|
falling back to config options and defaults.
|
||||||
@@ -558,6 +519,8 @@ def term_width():
|
|||||||
|
|
||||||
|
|
||||||
FLOAT_EPSILON = 0.01
|
FLOAT_EPSILON = 0.01
|
||||||
|
|
||||||
|
|
||||||
def _field_diff(field, old, new):
|
def _field_diff(field, old, new):
|
||||||
"""Given two Model objects, format their values for `field` and
|
"""Given two Model objects, format their values for `field` and
|
||||||
highlight changes among them. Return a human-readable string. If the
|
highlight changes among them. Return a human-readable string. If the
|
||||||
@@ -574,13 +537,13 @@ def _field_diff(field, old, new):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Get formatted values for output.
|
# Get formatted values for output.
|
||||||
oldstr = old._get_formatted(field)
|
oldstr = old.formatted().get(field, u'')
|
||||||
newstr = new._get_formatted(field)
|
newstr = new.formatted().get(field, u'')
|
||||||
|
|
||||||
# For strings, highlight changes. For others, colorize the whole
|
# For strings, highlight changes. For others, colorize the whole
|
||||||
# thing.
|
# thing.
|
||||||
if isinstance(oldval, basestring):
|
if isinstance(oldval, basestring):
|
||||||
oldstr, newstr = colordiff(oldval, newval)
|
oldstr, newstr = colordiff(oldval, newstr)
|
||||||
else:
|
else:
|
||||||
oldstr, newstr = colorize('red', oldstr), colorize('red', newstr)
|
oldstr, newstr = colorize('red', oldstr), colorize('red', newstr)
|
||||||
|
|
||||||
@@ -613,9 +576,12 @@ def show_model_changes(new, old=None, fields=None, always=False):
|
|||||||
|
|
||||||
# New fields.
|
# New fields.
|
||||||
for field in set(new) - set(old):
|
for field in set(new) - set(old):
|
||||||
|
if fields and field not in fields:
|
||||||
|
continue
|
||||||
|
|
||||||
changes.append(u' {0}: {1}'.format(
|
changes.append(u' {0}: {1}'.format(
|
||||||
field,
|
field,
|
||||||
colorize('red', new._get_formatted(field))
|
colorize('red', new.formatted()[field])
|
||||||
))
|
))
|
||||||
|
|
||||||
# Print changes.
|
# Print changes.
|
||||||
@@ -627,10 +593,8 @@ def show_model_changes(new, old=None, fields=None, always=False):
|
|||||||
return bool(changes)
|
return bool(changes)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Subcommand parsing infrastructure.
|
# Subcommand parsing infrastructure.
|
||||||
|
#
|
||||||
|
|
||||||
# This is a fairly generic subcommand parser for optparse. It is
|
# This is a fairly generic subcommand parser for optparse. It is
|
||||||
# maintained externally here:
|
# maintained externally here:
|
||||||
# http://gist.github.com/462717
|
# http://gist.github.com/462717
|
||||||
@@ -653,45 +617,55 @@ class Subcommand(object):
|
|||||||
self.aliases = aliases
|
self.aliases = aliases
|
||||||
self.help = help
|
self.help = help
|
||||||
self.hide = hide
|
self.hide = hide
|
||||||
|
self._root_parser = None
|
||||||
|
|
||||||
|
def print_help(self):
|
||||||
|
self.parser.print_help()
|
||||||
|
|
||||||
|
def parse_args(self, args):
|
||||||
|
return self.parser.parse_args(args)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def root_parser(self):
|
||||||
|
return self._root_parser
|
||||||
|
|
||||||
|
@root_parser.setter
|
||||||
|
def root_parser(self, root_parser):
|
||||||
|
self._root_parser = root_parser
|
||||||
|
self.parser.prog = '{0} {1}'.format(root_parser.get_prog_name(),
|
||||||
|
self.name)
|
||||||
|
|
||||||
|
|
||||||
class SubcommandsOptionParser(optparse.OptionParser):
|
class SubcommandsOptionParser(optparse.OptionParser):
|
||||||
"""A variant of OptionParser that parses subcommands and their
|
"""A variant of OptionParser that parses subcommands and their
|
||||||
arguments.
|
arguments.
|
||||||
"""
|
"""
|
||||||
# A singleton command used to give help on other subcommands.
|
|
||||||
_HelpSubcommand = Subcommand('help', optparse.OptionParser(),
|
|
||||||
help='give detailed help on a specific sub-command',
|
|
||||||
aliases=('?',))
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
"""Create a new subcommand-aware option parser. All of the
|
"""Create a new subcommand-aware option parser. All of the
|
||||||
options to OptionParser.__init__ are supported in addition
|
options to OptionParser.__init__ are supported in addition
|
||||||
to subcommands, a sequence of Subcommand objects.
|
to subcommands, a sequence of Subcommand objects.
|
||||||
"""
|
"""
|
||||||
# The subcommand array, with the help command included.
|
|
||||||
self.subcommands = list(kwargs.pop('subcommands', []))
|
|
||||||
self.subcommands.append(self._HelpSubcommand)
|
|
||||||
|
|
||||||
# A more helpful default usage.
|
# A more helpful default usage.
|
||||||
if 'usage' not in kwargs:
|
if 'usage' not in kwargs:
|
||||||
kwargs['usage'] = """
|
kwargs['usage'] = """
|
||||||
%prog COMMAND [ARGS...]
|
%prog COMMAND [ARGS...]
|
||||||
%prog help COMMAND"""
|
%prog help COMMAND"""
|
||||||
|
kwargs['add_help_option'] = False
|
||||||
|
|
||||||
# Super constructor.
|
# Super constructor.
|
||||||
optparse.OptionParser.__init__(self, *args, **kwargs)
|
optparse.OptionParser.__init__(self, *args, **kwargs)
|
||||||
|
|
||||||
# Adjust the help-visible name of each subcommand.
|
|
||||||
for subcommand in self.subcommands:
|
|
||||||
subcommand.parser.prog = '%s %s' % \
|
|
||||||
(self.get_prog_name(), subcommand.name)
|
|
||||||
|
|
||||||
# Our root parser needs to stop on the first unrecognized argument.
|
# Our root parser needs to stop on the first unrecognized argument.
|
||||||
self.disable_interspersed_args()
|
self.disable_interspersed_args()
|
||||||
|
|
||||||
def add_subcommand(self, cmd):
|
self.subcommands = []
|
||||||
|
|
||||||
|
def add_subcommand(self, *cmds):
|
||||||
"""Adds a Subcommand object to the parser's list of commands.
|
"""Adds a Subcommand object to the parser's list of commands.
|
||||||
"""
|
"""
|
||||||
|
for cmd in cmds:
|
||||||
|
cmd.root_parser = self
|
||||||
self.subcommands.append(cmd)
|
self.subcommands.append(cmd)
|
||||||
|
|
||||||
# Add the list of subcommands to the help message.
|
# Add the list of subcommands to the help message.
|
||||||
@@ -711,6 +685,7 @@ class SubcommandsOptionParser(optparse.OptionParser):
|
|||||||
disp_names = []
|
disp_names = []
|
||||||
help_position = 0
|
help_position = 0
|
||||||
subcommands = [c for c in self.subcommands if not c.hide]
|
subcommands = [c for c in self.subcommands if not c.hide]
|
||||||
|
subcommands.sort(key=lambda c: c.name)
|
||||||
for subcommand in subcommands:
|
for subcommand in subcommands:
|
||||||
name = subcommand.name
|
name = subcommand.name
|
||||||
if subcommand.aliases:
|
if subcommand.aliases:
|
||||||
@@ -756,52 +731,40 @@ class SubcommandsOptionParser(optparse.OptionParser):
|
|||||||
return subcommand
|
return subcommand
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def parse_args(self, a=None, v=None):
|
def parse_global_options(self, args):
|
||||||
"""Like OptionParser.parse_args, but returns these four items:
|
"""Parse options up to the subcommand argument. Returns a tuple
|
||||||
- options: the options passed to the root parser
|
of the options object and the remaining arguments.
|
||||||
- subcommand: the Subcommand object that was invoked
|
|
||||||
- suboptions: the options passed to the subcommand parser
|
|
||||||
- subargs: the positional arguments passed to the subcommand
|
|
||||||
"""
|
"""
|
||||||
options, args = optparse.OptionParser.parse_args(self, a, v)
|
options, subargs = self.parse_args(args)
|
||||||
subcommand, suboptions, subargs = self._parse_sub(args)
|
|
||||||
return options, subcommand, suboptions, subargs
|
|
||||||
|
|
||||||
def _parse_sub(self, args):
|
# Force the help command
|
||||||
"""Given the `args` left unused by a typical OptionParser
|
if options.help:
|
||||||
`parse_args`, return the invoked subcommand, the subcommand
|
subargs = ['help']
|
||||||
options, and the subcommand arguments.
|
elif options.version:
|
||||||
|
subargs = ['version']
|
||||||
|
return options, subargs
|
||||||
|
|
||||||
|
def parse_subcommand(self, args):
|
||||||
|
"""Given the `args` left unused by a `parse_global_options`,
|
||||||
|
return the invoked subcommand, the subcommand options, and the
|
||||||
|
subcommand arguments.
|
||||||
"""
|
"""
|
||||||
|
# Help is default command
|
||||||
if not args:
|
if not args:
|
||||||
# No command given.
|
args = ['help']
|
||||||
self.print_help()
|
|
||||||
self.exit()
|
|
||||||
else:
|
|
||||||
cmdname = args.pop(0)
|
cmdname = args.pop(0)
|
||||||
subcommand = self._subcommand_for_name(cmdname)
|
subcommand = self._subcommand_for_name(cmdname)
|
||||||
if not subcommand:
|
if not subcommand:
|
||||||
self.error('unknown command ' + cmdname)
|
raise UserError("unknown command '{0}'".format(cmdname))
|
||||||
|
|
||||||
suboptions, subargs = subcommand.parser.parse_args(args)
|
|
||||||
|
|
||||||
if subcommand is self._HelpSubcommand:
|
|
||||||
if subargs:
|
|
||||||
# particular
|
|
||||||
cmdname = subargs[0]
|
|
||||||
helpcommand = self._subcommand_for_name(cmdname)
|
|
||||||
if not helpcommand:
|
|
||||||
self.error('no command named {0}'.format(cmdname))
|
|
||||||
helpcommand.parser.print_help()
|
|
||||||
self.exit()
|
|
||||||
else:
|
|
||||||
# general
|
|
||||||
self.print_help()
|
|
||||||
self.exit()
|
|
||||||
|
|
||||||
|
suboptions, subargs = subcommand.parse_args(args)
|
||||||
return subcommand, suboptions, subargs
|
return subcommand, suboptions, subargs
|
||||||
|
|
||||||
|
|
||||||
optparse.Option.ALWAYS_TYPED_ACTIONS += ('callback',)
|
optparse.Option.ALWAYS_TYPED_ACTIONS += ('callback',)
|
||||||
|
|
||||||
|
|
||||||
def vararg_callback(option, opt_str, value, parser):
|
def vararg_callback(option, opt_str, value, parser):
|
||||||
"""Callback for an option with variable arguments.
|
"""Callback for an option with variable arguments.
|
||||||
Manually collect arguments right of a callback-action
|
Manually collect arguments right of a callback-action
|
||||||
@@ -838,53 +801,54 @@ def vararg_callback(option, opt_str, value, parser):
|
|||||||
setattr(parser.values, option.dest, value)
|
setattr(parser.values, option.dest, value)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# The main entry point and bootstrapping.
|
# The main entry point and bootstrapping.
|
||||||
|
|
||||||
|
def _load_plugins(config):
|
||||||
def _load_plugins():
|
|
||||||
"""Load the plugins specified in the configuration.
|
"""Load the plugins specified in the configuration.
|
||||||
"""
|
"""
|
||||||
# Add plugin paths.
|
paths = config['pluginpath'].get(confit.StrSeq(split=False))
|
||||||
|
paths = map(util.normpath, paths)
|
||||||
|
|
||||||
import beetsplug
|
import beetsplug
|
||||||
beetsplug.__path__ = get_plugin_paths() + beetsplug.__path__
|
beetsplug.__path__ = paths + beetsplug.__path__
|
||||||
|
|
||||||
# For backwards compatibility.
|
# For backwards compatibility.
|
||||||
sys.path += get_plugin_paths()
|
sys.path += paths
|
||||||
|
|
||||||
# Load requested plugins.
|
|
||||||
plugins.load_plugins(config['plugins'].as_str_seq())
|
plugins.load_plugins(config['plugins'].as_str_seq())
|
||||||
plugins.send("pluginload")
|
plugins.send("pluginload")
|
||||||
|
return plugins
|
||||||
|
|
||||||
|
|
||||||
def _configure(args):
|
def _setup(options, lib=None):
|
||||||
"""Parse the command line, load configuration files (including
|
"""Prepare and global state and updates it with command line options.
|
||||||
loading any indicated plugins), and return the invoked subcomand,
|
|
||||||
the subcommand options, and the subcommand arguments.
|
Returns a list of subcommands, a list of plugins, and a library instance.
|
||||||
"""
|
"""
|
||||||
# Temporary: Migrate from 1.0-style configuration.
|
# Configure the MusicBrainz API.
|
||||||
from beets.ui import migrate
|
mb.configure()
|
||||||
migrate.automigrate()
|
|
||||||
|
config = _configure(options)
|
||||||
|
|
||||||
|
plugins = _load_plugins(config)
|
||||||
|
|
||||||
# Get the default subcommands.
|
# Get the default subcommands.
|
||||||
from beets.ui.commands import default_commands
|
from beets.ui.commands import default_commands
|
||||||
|
|
||||||
# Construct the root parser.
|
subcommands = list(default_commands)
|
||||||
commands = list(default_commands)
|
subcommands.extend(plugins.commands())
|
||||||
commands.append(migrate.migrate_cmd) # Temporary.
|
|
||||||
parser = SubcommandsOptionParser(subcommands=commands)
|
|
||||||
parser.add_option('-l', '--library', dest='library',
|
|
||||||
help='library database file to use')
|
|
||||||
parser.add_option('-d', '--directory', dest='directory',
|
|
||||||
help="destination music directory")
|
|
||||||
parser.add_option('-v', '--verbose', dest='verbose', action='store_true',
|
|
||||||
help='print debugging information')
|
|
||||||
parser.add_option('-c', '--config', dest='config',
|
|
||||||
help='path to configuration file')
|
|
||||||
|
|
||||||
# Parse the command-line!
|
if lib is None:
|
||||||
options, args = optparse.OptionParser.parse_args(parser, args)
|
lib = _open_library(config)
|
||||||
|
plugins.send("library_opened", lib=lib)
|
||||||
|
library.Item._types = plugins.types(library.Item)
|
||||||
|
library.Album._types = plugins.types(library.Album)
|
||||||
|
|
||||||
|
return subcommands, plugins, lib
|
||||||
|
|
||||||
|
|
||||||
|
def _configure(options):
|
||||||
|
"""Amend the global configuration object with command line options.
|
||||||
|
"""
|
||||||
# Add any additional config files specified with --config. This
|
# Add any additional config files specified with --config. This
|
||||||
# special handling lets specified plugins get loaded before we
|
# special handling lets specified plugins get loaded before we
|
||||||
# finish parsing the command line.
|
# finish parsing the command line.
|
||||||
@@ -894,22 +858,28 @@ def _configure(args):
|
|||||||
config.set_file(config_path)
|
config.set_file(config_path)
|
||||||
config.set_args(options)
|
config.set_args(options)
|
||||||
|
|
||||||
# Now add the plugin commands to the parser.
|
# Configure the logger.
|
||||||
_load_plugins()
|
if config['verbose'].get(bool):
|
||||||
for cmd in plugins.commands():
|
log.setLevel(logging.DEBUG)
|
||||||
parser.add_subcommand(cmd)
|
else:
|
||||||
|
log.setLevel(logging.INFO)
|
||||||
|
|
||||||
# Parse the remainder of the command line with loaded plugins.
|
config_path = config.user_config_path()
|
||||||
return parser._parse_sub(args)
|
if os.path.isfile(config_path):
|
||||||
|
log.debug(u'user configuration: {0}'.format(
|
||||||
|
util.displayable_path(config_path)))
|
||||||
|
else:
|
||||||
|
log.debug(u'no user configuration found at {0}'.format(
|
||||||
|
util.displayable_path(config_path)))
|
||||||
|
|
||||||
|
log.debug(u'data directory: {0}'
|
||||||
|
.format(util.displayable_path(config.config_dir())))
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
def _raw_main(args):
|
def _open_library(config):
|
||||||
"""A helper function for `main` without top-level exception
|
"""Create a new library instance from the configuration.
|
||||||
handling.
|
|
||||||
"""
|
"""
|
||||||
subcommand, suboptions, subargs = _configure(args)
|
|
||||||
|
|
||||||
# Open library file.
|
|
||||||
dbpath = config['library'].as_filename()
|
dbpath = config['library'].as_filename()
|
||||||
try:
|
try:
|
||||||
lib = library.Library(
|
lib = library.Library(
|
||||||
@@ -918,32 +888,52 @@ def _raw_main(args):
|
|||||||
get_path_formats(),
|
get_path_formats(),
|
||||||
get_replacements(),
|
get_replacements(),
|
||||||
)
|
)
|
||||||
except sqlite3.OperationalError:
|
lib.get_item(0) # Test database connection.
|
||||||
|
except (sqlite3.OperationalError, sqlite3.DatabaseError):
|
||||||
|
log.debug(traceback.format_exc())
|
||||||
raise UserError(u"database file {0} could not be opened".format(
|
raise UserError(u"database file {0} could not be opened".format(
|
||||||
util.displayable_path(dbpath)
|
util.displayable_path(dbpath)
|
||||||
))
|
))
|
||||||
plugins.send("library_opened", lib=lib)
|
log.debug(u'library database: {0}\n'
|
||||||
|
u'library directory: {1}'
|
||||||
|
.format(util.displayable_path(lib.path),
|
||||||
|
util.displayable_path(lib.directory)))
|
||||||
|
return lib
|
||||||
|
|
||||||
# Configure the logger.
|
|
||||||
if config['verbose'].get(bool):
|
|
||||||
log.setLevel(logging.DEBUG)
|
|
||||||
else:
|
|
||||||
log.setLevel(logging.INFO)
|
|
||||||
log.debug(u'data directory: {0}\n'
|
|
||||||
u'library database: {1}\n'
|
|
||||||
u'library directory: {2}'
|
|
||||||
.format(
|
|
||||||
util.displayable_path(config.config_dir()),
|
|
||||||
util.displayable_path(lib.path),
|
|
||||||
util.displayable_path(lib.directory),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Configure the MusicBrainz API.
|
def _raw_main(args, lib=None):
|
||||||
mb.configure()
|
"""A helper function for `main` without top-level exception
|
||||||
|
handling.
|
||||||
|
"""
|
||||||
|
parser = SubcommandsOptionParser()
|
||||||
|
parser.add_option('-l', '--library', dest='library',
|
||||||
|
help='library database file to use')
|
||||||
|
parser.add_option('-d', '--directory', dest='directory',
|
||||||
|
help="destination music directory")
|
||||||
|
parser.add_option('-v', '--verbose', dest='verbose', action='store_true',
|
||||||
|
help='print debugging information')
|
||||||
|
parser.add_option('-c', '--config', dest='config',
|
||||||
|
help='path to configuration file')
|
||||||
|
parser.add_option('-h', '--help', dest='help', action='store_true',
|
||||||
|
help='how this help message and exit')
|
||||||
|
parser.add_option('--version', dest='version', action='store_true',
|
||||||
|
help=optparse.SUPPRESS_HELP)
|
||||||
|
|
||||||
# Invoke the subcommand.
|
options, subargs = parser.parse_global_options(args)
|
||||||
|
|
||||||
|
# Special case for the `config --edit` command: bypass _setup so
|
||||||
|
# that an invalid configuration does not prevent the editor from
|
||||||
|
# starting.
|
||||||
|
if subargs[0] == 'config' and ('-e' in subargs or '--edit' in subargs):
|
||||||
|
from beets.ui.commands import config_edit
|
||||||
|
return config_edit()
|
||||||
|
|
||||||
|
subcommands, plugins, lib = _setup(options, lib)
|
||||||
|
parser.add_subcommand(*subcommands)
|
||||||
|
|
||||||
|
subcommand, suboptions, subargs = parser.parse_subcommand(subargs)
|
||||||
subcommand.func(lib, suboptions, subargs)
|
subcommand.func(lib, suboptions, subargs)
|
||||||
|
|
||||||
plugins.send('cli_exit', lib=lib)
|
plugins.send('cli_exit', lib=lib)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+448
-217
File diff suppressed because it is too large
Load Diff
@@ -1,401 +0,0 @@
|
|||||||
# This file is part of beets.
|
|
||||||
# Copyright 2013, Adrian Sampson.
|
|
||||||
#
|
|
||||||
# Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
# a copy of this software and associated documentation files (the
|
|
||||||
# "Software"), to deal in the Software without restriction, including
|
|
||||||
# without limitation the rights to use, copy, modify, merge, publish,
|
|
||||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
|
||||||
# permit persons to whom the Software is furnished to do so, subject to
|
|
||||||
# the following conditions:
|
|
||||||
#
|
|
||||||
# The above copyright notice and this permission notice shall be
|
|
||||||
# included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
"""Conversion from legacy (pre-1.1) configuration to Confit/YAML
|
|
||||||
configuration.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import ConfigParser
|
|
||||||
import codecs
|
|
||||||
import yaml
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
import itertools
|
|
||||||
import re
|
|
||||||
|
|
||||||
import beets
|
|
||||||
from beets import util
|
|
||||||
from beets import ui
|
|
||||||
from beets.util import confit
|
|
||||||
|
|
||||||
CONFIG_PATH_VAR = 'BEETSCONFIG'
|
|
||||||
DEFAULT_CONFIG_FILENAME_UNIX = '.beetsconfig'
|
|
||||||
DEFAULT_CONFIG_FILENAME_WINDOWS = 'beetsconfig.ini'
|
|
||||||
DEFAULT_LIBRARY_FILENAME_UNIX = '.beetsmusic.blb'
|
|
||||||
DEFAULT_LIBRARY_FILENAME_WINDOWS = 'beetsmusic.blb'
|
|
||||||
WINDOWS_BASEDIR = os.environ.get('APPDATA') or '~'
|
|
||||||
|
|
||||||
OLD_CONFIG_SUFFIX = '.old'
|
|
||||||
PLUGIN_NAMES = {
|
|
||||||
'rdm': 'random',
|
|
||||||
'fuzzy_search': 'fuzzy',
|
|
||||||
}
|
|
||||||
AUTO_KEYS = ('automatic', 'autofetch', 'autoembed', 'autoscrub')
|
|
||||||
IMPORTFEEDS_PREFIX = 'feeds_'
|
|
||||||
CONFIG_MIGRATED_MESSAGE = u"""
|
|
||||||
You appear to be upgrading from beets 1.0 (or earlier) to 1.1. Your
|
|
||||||
configuration file has been migrated automatically to:
|
|
||||||
{newconfig}
|
|
||||||
Edit this file to configure beets. You might want to remove your
|
|
||||||
old-style ".beetsconfig" file now. See the documentation for more
|
|
||||||
details on the new configuration system:
|
|
||||||
http://beets.readthedocs.org/page/reference/config.html
|
|
||||||
""".strip()
|
|
||||||
DB_MIGRATED_MESSAGE = u'Your database file has also been copied to:\n{newdb}'
|
|
||||||
YAML_COMMENT = '# Automatically migrated from legacy .beetsconfig.\n\n'
|
|
||||||
|
|
||||||
log = logging.getLogger('beets')
|
|
||||||
|
|
||||||
# An itertools recipe.
|
|
||||||
def grouper(n, iterable):
|
|
||||||
args = [iter(iterable)] * n
|
|
||||||
return itertools.izip_longest(*args)
|
|
||||||
|
|
||||||
def _displace(fn):
|
|
||||||
"""Move a file aside using a timestamp suffix so a new file can be
|
|
||||||
put in its place.
|
|
||||||
"""
|
|
||||||
util.move(
|
|
||||||
fn,
|
|
||||||
u'{0}.old.{1}'.format(fn, int(time.time())),
|
|
||||||
True
|
|
||||||
)
|
|
||||||
|
|
||||||
def default_paths():
|
|
||||||
"""Produces the appropriate default config and library database
|
|
||||||
paths for the current system. On Unix, this is always in ~. On
|
|
||||||
Windows, tries ~ first and then $APPDATA for the config and library
|
|
||||||
files (for backwards compatibility).
|
|
||||||
"""
|
|
||||||
windows = os.path.__name__ == 'ntpath'
|
|
||||||
if windows:
|
|
||||||
windata = os.environ.get('APPDATA') or '~'
|
|
||||||
|
|
||||||
# Shorthand for joining paths.
|
|
||||||
def exp(*vals):
|
|
||||||
return os.path.expanduser(os.path.join(*vals))
|
|
||||||
|
|
||||||
config = exp('~', DEFAULT_CONFIG_FILENAME_UNIX)
|
|
||||||
if windows and not os.path.exists(config):
|
|
||||||
config = exp(windata, DEFAULT_CONFIG_FILENAME_WINDOWS)
|
|
||||||
|
|
||||||
libpath = exp('~', DEFAULT_LIBRARY_FILENAME_UNIX)
|
|
||||||
if windows and not os.path.exists(libpath):
|
|
||||||
libpath = exp(windata, DEFAULT_LIBRARY_FILENAME_WINDOWS)
|
|
||||||
|
|
||||||
return config, libpath
|
|
||||||
|
|
||||||
def get_config():
|
|
||||||
"""Using the same logic as beets 1.0, locate and read the
|
|
||||||
.beetsconfig file. Return a ConfigParser instance or None if no
|
|
||||||
config is found.
|
|
||||||
"""
|
|
||||||
default_config, default_libpath = default_paths()
|
|
||||||
if CONFIG_PATH_VAR in os.environ:
|
|
||||||
configpath = os.path.expanduser(os.environ[CONFIG_PATH_VAR])
|
|
||||||
else:
|
|
||||||
configpath = default_config
|
|
||||||
|
|
||||||
config = ConfigParser.SafeConfigParser()
|
|
||||||
if os.path.exists(util.syspath(configpath)):
|
|
||||||
with codecs.open(configpath, 'r', encoding='utf-8') as f:
|
|
||||||
config.readfp(f)
|
|
||||||
return config, configpath
|
|
||||||
else:
|
|
||||||
return None, configpath
|
|
||||||
|
|
||||||
def flatten_config(config):
|
|
||||||
"""Given a ConfigParser, flatten the values into a dict-of-dicts
|
|
||||||
representation where each section gets its own dictionary of values.
|
|
||||||
"""
|
|
||||||
out = confit.OrderedDict()
|
|
||||||
for section in config.sections():
|
|
||||||
sec_dict = out[section] = confit.OrderedDict()
|
|
||||||
for option in config.options(section):
|
|
||||||
sec_dict[option] = config.get(section, option, True)
|
|
||||||
return out
|
|
||||||
|
|
||||||
def transform_value(value):
|
|
||||||
"""Given a string read as the value of a config option, return a
|
|
||||||
massaged version of that value (possibly with a different type).
|
|
||||||
"""
|
|
||||||
# Booleans.
|
|
||||||
if value.lower() in ('false', 'no', 'off'):
|
|
||||||
return False
|
|
||||||
elif value.lower() in ('true', 'yes', 'on'):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Integers.
|
|
||||||
try:
|
|
||||||
return int(value)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Floats.
|
|
||||||
try:
|
|
||||||
return float(value)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
def transform_data(data):
|
|
||||||
"""Given a dict-of-dicts representation of legacy config data, tweak
|
|
||||||
the data into a new form. This new form is suitable for dumping as
|
|
||||||
YAML.
|
|
||||||
"""
|
|
||||||
out = confit.OrderedDict()
|
|
||||||
|
|
||||||
for section, pairs in data.items():
|
|
||||||
if section == 'beets':
|
|
||||||
# The "main" section. In the new config system, these values
|
|
||||||
# are in the "root": no section at all.
|
|
||||||
for key, value in pairs.items():
|
|
||||||
value = transform_value(value)
|
|
||||||
|
|
||||||
if key.startswith('import_'):
|
|
||||||
# Importer config is now under an "import:" key.
|
|
||||||
if 'import' not in out:
|
|
||||||
out['import'] = confit.OrderedDict()
|
|
||||||
out['import'][key[7:]] = value
|
|
||||||
|
|
||||||
elif key == 'plugins':
|
|
||||||
# Renamed plugins.
|
|
||||||
plugins = value.split()
|
|
||||||
new_plugins = [PLUGIN_NAMES.get(p, p) for p in plugins]
|
|
||||||
out['plugins'] = ' '.join(new_plugins)
|
|
||||||
|
|
||||||
elif key == 'replace':
|
|
||||||
# YAMLy representation for character replacements.
|
|
||||||
replacements = confit.OrderedDict()
|
|
||||||
for pat, repl in grouper(2, value.split()):
|
|
||||||
if repl == '<strip>':
|
|
||||||
repl = ''
|
|
||||||
replacements[pat] = repl
|
|
||||||
out['replace'] = replacements
|
|
||||||
|
|
||||||
elif key == 'pluginpath':
|
|
||||||
# Used to be a colon-separated string. Now a list.
|
|
||||||
out['pluginpath'] = value.split(':')
|
|
||||||
|
|
||||||
else:
|
|
||||||
out[key] = value
|
|
||||||
|
|
||||||
elif pairs:
|
|
||||||
# Other sections (plugins, etc).
|
|
||||||
sec_out = out[section] = confit.OrderedDict()
|
|
||||||
for key, value in pairs.items():
|
|
||||||
|
|
||||||
# Standardized "auto" option.
|
|
||||||
if key in AUTO_KEYS:
|
|
||||||
key = 'auto'
|
|
||||||
|
|
||||||
# Unnecessary : hack in queries.
|
|
||||||
if section == 'paths':
|
|
||||||
key = key.replace('_', ':')
|
|
||||||
|
|
||||||
# Changed option names for importfeeds plugin.
|
|
||||||
if section == 'importfeeds':
|
|
||||||
if key.startswith(IMPORTFEEDS_PREFIX):
|
|
||||||
key = key[len(IMPORTFEEDS_PREFIX):]
|
|
||||||
|
|
||||||
sec_out[key] = transform_value(value)
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
class Dumper(yaml.SafeDumper):
|
|
||||||
"""A PyYAML Dumper that represents OrderedDicts as ordinary mappings
|
|
||||||
(in order, of course).
|
|
||||||
"""
|
|
||||||
# From http://pyyaml.org/attachment/ticket/161/use_ordered_dict.py
|
|
||||||
def represent_mapping(self, tag, mapping, flow_style=None):
|
|
||||||
value = []
|
|
||||||
node = yaml.MappingNode(tag, value, flow_style=flow_style)
|
|
||||||
if self.alias_key is not None:
|
|
||||||
self.represented_objects[self.alias_key] = node
|
|
||||||
best_style = True
|
|
||||||
if hasattr(mapping, 'items'):
|
|
||||||
mapping = list(mapping.items())
|
|
||||||
for item_key, item_value in mapping:
|
|
||||||
node_key = self.represent_data(item_key)
|
|
||||||
node_value = self.represent_data(item_value)
|
|
||||||
if not (isinstance(node_key, yaml.ScalarNode) and \
|
|
||||||
not node_key.style):
|
|
||||||
best_style = False
|
|
||||||
if not (isinstance(node_value, yaml.ScalarNode) and \
|
|
||||||
not node_value.style):
|
|
||||||
best_style = False
|
|
||||||
value.append((node_key, node_value))
|
|
||||||
if flow_style is None:
|
|
||||||
if self.default_flow_style is not None:
|
|
||||||
node.flow_style = self.default_flow_style
|
|
||||||
else:
|
|
||||||
node.flow_style = best_style
|
|
||||||
return node
|
|
||||||
Dumper.add_representer(confit.OrderedDict, Dumper.represent_dict)
|
|
||||||
|
|
||||||
def migrate_config(replace=False):
|
|
||||||
"""Migrate a legacy beetsconfig file to a new-style config.yaml file
|
|
||||||
in an appropriate place. If `replace` is enabled, then any existing
|
|
||||||
config.yaml will be moved aside. Otherwise, the process is aborted
|
|
||||||
when the file exists.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Load legacy configuration data, if any.
|
|
||||||
config, configpath = get_config()
|
|
||||||
if not config:
|
|
||||||
log.debug(u'no config file found at {0}'.format(
|
|
||||||
util.displayable_path(configpath)
|
|
||||||
))
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get the new configuration file path and possibly move it out of
|
|
||||||
# the way.
|
|
||||||
destfn = os.path.join(beets.config.config_dir(), confit.CONFIG_FILENAME)
|
|
||||||
if os.path.exists(destfn):
|
|
||||||
if replace:
|
|
||||||
log.debug(u'moving old config aside: {0}'.format(
|
|
||||||
util.displayable_path(destfn)
|
|
||||||
))
|
|
||||||
_displace(destfn)
|
|
||||||
else:
|
|
||||||
# File exists and we won't replace it. We're done.
|
|
||||||
return
|
|
||||||
|
|
||||||
log.debug(u'migrating config file {0}'.format(
|
|
||||||
util.displayable_path(configpath)
|
|
||||||
))
|
|
||||||
|
|
||||||
# Convert the configuration to a data structure ready to be dumped
|
|
||||||
# as the new Confit file.
|
|
||||||
data = transform_data(flatten_config(config))
|
|
||||||
|
|
||||||
# Encode result as YAML.
|
|
||||||
yaml_out = yaml.dump(
|
|
||||||
data,
|
|
||||||
Dumper=Dumper,
|
|
||||||
default_flow_style=False,
|
|
||||||
indent=4,
|
|
||||||
width=1000,
|
|
||||||
)
|
|
||||||
# A ridiculous little hack to add some whitespace between "sections"
|
|
||||||
# in the YAML output. I hope this doesn't break any YAML syntax.
|
|
||||||
yaml_out = re.sub(r'(\n\w+:\n [^-\s])', '\n\\1', yaml_out)
|
|
||||||
yaml_out = YAML_COMMENT + yaml_out
|
|
||||||
|
|
||||||
# Write the data to the new config destination.
|
|
||||||
log.debug(u'writing migrated config to {0}'.format(
|
|
||||||
util.displayable_path(destfn)
|
|
||||||
))
|
|
||||||
with open(destfn, 'w') as f:
|
|
||||||
f.write(yaml_out)
|
|
||||||
return destfn
|
|
||||||
|
|
||||||
def migrate_db(replace=False):
|
|
||||||
"""Copy the beets library database file to the new location (e.g.,
|
|
||||||
from ~/.beetsmusic.blb to ~/.config/beets/library.db).
|
|
||||||
"""
|
|
||||||
_, srcfn = default_paths()
|
|
||||||
destfn = beets.config['library'].as_filename()
|
|
||||||
|
|
||||||
if not os.path.exists(srcfn) or srcfn == destfn:
|
|
||||||
# Old DB does not exist or we're configured to point to the same
|
|
||||||
# database. Do nothing.
|
|
||||||
return
|
|
||||||
|
|
||||||
if os.path.exists(destfn):
|
|
||||||
if replace:
|
|
||||||
log.debug(u'moving old database aside: {0}'.format(
|
|
||||||
util.displayable_path(destfn)
|
|
||||||
))
|
|
||||||
_displace(destfn)
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
|
|
||||||
log.debug(u'copying database from {0} to {1}'.format(
|
|
||||||
util.displayable_path(srcfn), util.displayable_path(destfn)
|
|
||||||
))
|
|
||||||
util.copy(srcfn, destfn)
|
|
||||||
return destfn
|
|
||||||
|
|
||||||
def migrate_state(replace=False):
|
|
||||||
"""Copy the beets runtime state file from the old path (i.e.,
|
|
||||||
~/.beetsstate) to the new path (i.e., ~/.config/beets/state.pickle).
|
|
||||||
"""
|
|
||||||
srcfn = os.path.expanduser(os.path.join('~', '.beetsstate'))
|
|
||||||
if not os.path.exists(srcfn):
|
|
||||||
return
|
|
||||||
|
|
||||||
destfn = beets.config['statefile'].as_filename()
|
|
||||||
if os.path.exists(destfn):
|
|
||||||
if replace:
|
|
||||||
_displace(destfn)
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
|
|
||||||
log.debug(u'copying state file from {0} to {1}'.format(
|
|
||||||
util.displayable_path(srcfn), util.displayable_path(destfn)
|
|
||||||
))
|
|
||||||
util.copy(srcfn, destfn)
|
|
||||||
return destfn
|
|
||||||
|
|
||||||
|
|
||||||
# Automatic migration when beets starts.
|
|
||||||
|
|
||||||
def automigrate():
|
|
||||||
"""Migrate the configuration, database, and state files. If any
|
|
||||||
migration occurs, print out a notice with some helpful next steps.
|
|
||||||
"""
|
|
||||||
config_fn = migrate_config()
|
|
||||||
db_fn = migrate_db()
|
|
||||||
migrate_state()
|
|
||||||
|
|
||||||
if config_fn:
|
|
||||||
ui.print_(ui.colorize('fuchsia', u'MIGRATED CONFIGURATION'))
|
|
||||||
|
|
||||||
ui.print_(CONFIG_MIGRATED_MESSAGE.format(
|
|
||||||
newconfig=util.displayable_path(config_fn))
|
|
||||||
)
|
|
||||||
if db_fn:
|
|
||||||
ui.print_(DB_MIGRATED_MESSAGE.format(
|
|
||||||
newdb=util.displayable_path(db_fn)
|
|
||||||
))
|
|
||||||
|
|
||||||
ui.input_(ui.colorize('fuchsia', u'Press ENTER to continue:'))
|
|
||||||
ui.print_()
|
|
||||||
|
|
||||||
|
|
||||||
# CLI command for explicit migration.
|
|
||||||
|
|
||||||
migrate_cmd = ui.Subcommand('migrate', help='convert legacy config')
|
|
||||||
def migrate_func(lib, opts, args):
|
|
||||||
"""Explicit command for migrating files. Existing files in each
|
|
||||||
destination are moved aside.
|
|
||||||
"""
|
|
||||||
config_fn = migrate_config(replace=True)
|
|
||||||
if config_fn:
|
|
||||||
log.info(u'Migrated configuration to: {0}'.format(
|
|
||||||
util.displayable_path(config_fn)
|
|
||||||
))
|
|
||||||
db_fn = migrate_db(replace=True)
|
|
||||||
if db_fn:
|
|
||||||
log.info(u'Migrated library database to: {0}'.format(
|
|
||||||
util.displayable_path(db_fn)
|
|
||||||
))
|
|
||||||
state_fn = migrate_state(replace=True)
|
|
||||||
if state_fn:
|
|
||||||
log.info(u'Migrated state file to: {0}'.format(
|
|
||||||
util.displayable_path(state_fn)
|
|
||||||
))
|
|
||||||
migrate_cmd.func = migrate_func
|
|
||||||
+80
-12
@@ -23,10 +23,13 @@ import fnmatch
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
import traceback
|
import traceback
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import platform
|
||||||
|
|
||||||
|
|
||||||
MAX_FILENAME_LENGTH = 200
|
MAX_FILENAME_LENGTH = 200
|
||||||
WINDOWS_MAGIC_PREFIX = u'\\\\?\\'
|
WINDOWS_MAGIC_PREFIX = u'\\\\?\\'
|
||||||
|
|
||||||
|
|
||||||
class HumanReadableException(Exception):
|
class HumanReadableException(Exception):
|
||||||
"""An Exception that can include a human-readable error message to
|
"""An Exception that can include a human-readable error message to
|
||||||
be logged without a traceback. Can preserve a traceback for
|
be logged without a traceback. Can preserve a traceback for
|
||||||
@@ -82,6 +85,7 @@ class HumanReadableException(Exception):
|
|||||||
logger.debug(self.tb)
|
logger.debug(self.tb)
|
||||||
logger.error(u'{0}: {1}'.format(self.error_kind, self.args[0]))
|
logger.error(u'{0}: {1}'.format(self.error_kind, self.args[0]))
|
||||||
|
|
||||||
|
|
||||||
class FilesystemError(HumanReadableException):
|
class FilesystemError(HumanReadableException):
|
||||||
"""An error that occurred while performing a filesystem manipulation
|
"""An error that occurred while performing a filesystem manipulation
|
||||||
via a function in this module. The `paths` field is a sequence of
|
via a function in this module. The `paths` field is a sequence of
|
||||||
@@ -111,6 +115,7 @@ class FilesystemError(HumanReadableException):
|
|||||||
|
|
||||||
return u'{0} {1}'.format(self._reasonstr(), clause)
|
return u'{0} {1}'.format(self._reasonstr(), clause)
|
||||||
|
|
||||||
|
|
||||||
def normpath(path):
|
def normpath(path):
|
||||||
"""Provide the canonical form of the path suitable for storing in
|
"""Provide the canonical form of the path suitable for storing in
|
||||||
the database.
|
the database.
|
||||||
@@ -119,6 +124,7 @@ def normpath(path):
|
|||||||
path = os.path.normpath(os.path.abspath(os.path.expanduser(path)))
|
path = os.path.normpath(os.path.abspath(os.path.expanduser(path)))
|
||||||
return bytestring_path(path)
|
return bytestring_path(path)
|
||||||
|
|
||||||
|
|
||||||
def ancestry(path):
|
def ancestry(path):
|
||||||
"""Return a list consisting of path's parent directory, its
|
"""Return a list consisting of path's parent directory, its
|
||||||
grandparent, and so on. For instance:
|
grandparent, and so on. For instance:
|
||||||
@@ -137,10 +143,12 @@ def ancestry(path):
|
|||||||
break
|
break
|
||||||
last_path = path
|
last_path = path
|
||||||
|
|
||||||
if path: # don't yield ''
|
if path:
|
||||||
|
# don't yield ''
|
||||||
out.insert(0, path)
|
out.insert(0, path)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def sorted_walk(path, ignore=(), logger=None):
|
def sorted_walk(path, ignore=(), logger=None):
|
||||||
"""Like `os.walk`, but yields things in case-insensitive sorted,
|
"""Like `os.walk`, but yields things in case-insensitive sorted,
|
||||||
breadth-first order. Directory and file names matching any glob
|
breadth-first order. Directory and file names matching any glob
|
||||||
@@ -192,6 +200,7 @@ def sorted_walk(path, ignore=(), logger=None):
|
|||||||
for res in sorted_walk(cur, ignore, logger):
|
for res in sorted_walk(cur, ignore, logger):
|
||||||
yield res
|
yield res
|
||||||
|
|
||||||
|
|
||||||
def mkdirall(path):
|
def mkdirall(path):
|
||||||
"""Make all the enclosing directories of path (like mkdir -p on the
|
"""Make all the enclosing directories of path (like mkdir -p on the
|
||||||
parent).
|
parent).
|
||||||
@@ -204,6 +213,7 @@ def mkdirall(path):
|
|||||||
raise FilesystemError(exc, 'create', (ancestor,),
|
raise FilesystemError(exc, 'create', (ancestor,),
|
||||||
traceback.format_exc())
|
traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
def fnmatch_all(names, patterns):
|
def fnmatch_all(names, patterns):
|
||||||
"""Determine whether all strings in `names` match at least one of
|
"""Determine whether all strings in `names` match at least one of
|
||||||
the `patterns`, which should be shell glob expressions.
|
the `patterns`, which should be shell glob expressions.
|
||||||
@@ -218,6 +228,7 @@ def fnmatch_all(names, patterns):
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def prune_dirs(path, root=None, clutter=('.DS_Store', 'Thumbs.db')):
|
def prune_dirs(path, root=None, clutter=('.DS_Store', 'Thumbs.db')):
|
||||||
"""If path is an empty directory, then remove it. Recursively remove
|
"""If path is an empty directory, then remove it. Recursively remove
|
||||||
path's ancestry up to root (which is never removed) where there are
|
path's ancestry up to root (which is never removed) where there are
|
||||||
@@ -258,6 +269,7 @@ def prune_dirs(path, root=None, clutter=('.DS_Store', 'Thumbs.db')):
|
|||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
def components(path):
|
def components(path):
|
||||||
"""Return a list of the path components in path. For instance:
|
"""Return a list of the path components in path. For instance:
|
||||||
|
|
||||||
@@ -281,6 +293,7 @@ def components(path):
|
|||||||
|
|
||||||
return comps
|
return comps
|
||||||
|
|
||||||
|
|
||||||
def _fsencoding():
|
def _fsencoding():
|
||||||
"""Get the system's filesystem encoding. On Windows, this is always
|
"""Get the system's filesystem encoding. On Windows, this is always
|
||||||
UTF-8 (not MBCS).
|
UTF-8 (not MBCS).
|
||||||
@@ -295,6 +308,7 @@ def _fsencoding():
|
|||||||
encoding = 'utf8'
|
encoding = 'utf8'
|
||||||
return encoding
|
return encoding
|
||||||
|
|
||||||
|
|
||||||
def bytestring_path(path):
|
def bytestring_path(path):
|
||||||
"""Given a path, which is either a str or a unicode, returns a str
|
"""Given a path, which is either a str or a unicode, returns a str
|
||||||
path (ensuring that we never deal with Unicode pathnames).
|
path (ensuring that we never deal with Unicode pathnames).
|
||||||
@@ -315,6 +329,7 @@ def bytestring_path(path):
|
|||||||
except (UnicodeError, LookupError):
|
except (UnicodeError, LookupError):
|
||||||
return path.encode('utf8')
|
return path.encode('utf8')
|
||||||
|
|
||||||
|
|
||||||
def displayable_path(path, separator=u'; '):
|
def displayable_path(path, separator=u'; '):
|
||||||
"""Attempts to decode a bytestring path to a unicode object for the
|
"""Attempts to decode a bytestring path to a unicode object for the
|
||||||
purpose of displaying it to the user. If the `path` argument is a
|
purpose of displaying it to the user. If the `path` argument is a
|
||||||
@@ -333,6 +348,7 @@ def displayable_path(path, separator=u'; '):
|
|||||||
except (UnicodeError, LookupError):
|
except (UnicodeError, LookupError):
|
||||||
return path.decode('utf8', 'ignore')
|
return path.decode('utf8', 'ignore')
|
||||||
|
|
||||||
|
|
||||||
def syspath(path, prefix=True):
|
def syspath(path, prefix=True):
|
||||||
"""Convert a path for use by the operating system. In particular,
|
"""Convert a path for use by the operating system. In particular,
|
||||||
paths on Windows must receive a magic prefix and must be converted
|
paths on Windows must receive a magic prefix and must be converted
|
||||||
@@ -356,16 +372,22 @@ def syspath(path, prefix=True):
|
|||||||
encoding = sys.getfilesystemencoding() or sys.getdefaultencoding()
|
encoding = sys.getfilesystemencoding() or sys.getdefaultencoding()
|
||||||
path = path.decode(encoding, 'replace')
|
path = path.decode(encoding, 'replace')
|
||||||
|
|
||||||
# Add the magic prefix if it isn't already there
|
# Add the magic prefix if it isn't already there.
|
||||||
|
# http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx
|
||||||
if prefix and not path.startswith(WINDOWS_MAGIC_PREFIX):
|
if prefix and not path.startswith(WINDOWS_MAGIC_PREFIX):
|
||||||
|
if path.startswith(u'\\\\'):
|
||||||
|
# UNC path. Final path should look like \\?\UNC\...
|
||||||
|
path = u'UNC' + path[1:]
|
||||||
path = WINDOWS_MAGIC_PREFIX + path
|
path = WINDOWS_MAGIC_PREFIX + path
|
||||||
|
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def samefile(p1, p2):
|
def samefile(p1, p2):
|
||||||
"""Safer equality for paths."""
|
"""Safer equality for paths."""
|
||||||
return shutil._samefile(syspath(p1), syspath(p2))
|
return shutil._samefile(syspath(p1), syspath(p2))
|
||||||
|
|
||||||
|
|
||||||
def remove(path, soft=True):
|
def remove(path, soft=True):
|
||||||
"""Remove the file. If `soft`, then no error will be raised if the
|
"""Remove the file. If `soft`, then no error will be raised if the
|
||||||
file does not exist.
|
file does not exist.
|
||||||
@@ -378,6 +400,7 @@ def remove(path, soft=True):
|
|||||||
except (OSError, IOError) as exc:
|
except (OSError, IOError) as exc:
|
||||||
raise FilesystemError(exc, 'delete', (path,), traceback.format_exc())
|
raise FilesystemError(exc, 'delete', (path,), traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
def copy(path, dest, replace=False):
|
def copy(path, dest, replace=False):
|
||||||
"""Copy a plain file. Permissions are not copied. If `dest` already
|
"""Copy a plain file. Permissions are not copied. If `dest` already
|
||||||
exists, raises a FilesystemError unless `replace` is True. Has no
|
exists, raises a FilesystemError unless `replace` is True. Has no
|
||||||
@@ -396,6 +419,7 @@ def copy(path, dest, replace=False):
|
|||||||
raise FilesystemError(exc, 'copy', (path, dest),
|
raise FilesystemError(exc, 'copy', (path, dest),
|
||||||
traceback.format_exc())
|
traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
def move(path, dest, replace=False):
|
def move(path, dest, replace=False):
|
||||||
"""Rename a file. `dest` may not be a directory. If `dest` already
|
"""Rename a file. `dest` may not be a directory. If `dest` already
|
||||||
exists, raises an OSError unless `replace` is True. Has no effect if
|
exists, raises an OSError unless `replace` is True. Has no effect if
|
||||||
@@ -424,6 +448,27 @@ def move(path, dest, replace=False):
|
|||||||
raise FilesystemError(exc, 'move', (path, dest),
|
raise FilesystemError(exc, 'move', (path, dest),
|
||||||
traceback.format_exc())
|
traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
|
def link(path, dest, replace=False):
|
||||||
|
"""Create a symbolic link from path to `dest`. Raises an OSError if
|
||||||
|
`dest` already exists, unless `replace` is True. Does nothing if
|
||||||
|
`path` == `dest`."""
|
||||||
|
if (samefile(path, dest)):
|
||||||
|
return
|
||||||
|
|
||||||
|
path = syspath(path)
|
||||||
|
dest = syspath(dest)
|
||||||
|
if os.path.exists(dest) and not replace:
|
||||||
|
raise FilesystemError('file exists', 'rename', (path, dest),
|
||||||
|
traceback.format_exc())
|
||||||
|
try:
|
||||||
|
os.symlink(path, dest)
|
||||||
|
except OSError:
|
||||||
|
raise FilesystemError('Operating system does not support symbolic '
|
||||||
|
'links.', 'link', (path, dest),
|
||||||
|
traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
def unique_path(path):
|
def unique_path(path):
|
||||||
"""Returns a version of ``path`` that does not exist on the
|
"""Returns a version of ``path`` that does not exist on the
|
||||||
filesystem. Specifically, if ``path` itself already exists, then
|
filesystem. Specifically, if ``path` itself already exists, then
|
||||||
@@ -457,6 +502,8 @@ CHAR_REPLACE = [
|
|||||||
(re.compile(ur'\.$'), u'_'), # Trailing dots.
|
(re.compile(ur'\.$'), u'_'), # Trailing dots.
|
||||||
(re.compile(ur'\s+$'), u''), # Trailing whitespace.
|
(re.compile(ur'\s+$'), u''), # Trailing whitespace.
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def sanitize_path(path, replacements=None):
|
def sanitize_path(path, replacements=None):
|
||||||
"""Takes a path (as a Unicode string) and makes sure that it is
|
"""Takes a path (as a Unicode string) and makes sure that it is
|
||||||
legal. Returns a new path. Only works with fragments; won't work
|
legal. Returns a new path. Only works with fragments; won't work
|
||||||
@@ -477,6 +524,7 @@ def sanitize_path(path, replacements=None):
|
|||||||
comps[i] = comp
|
comps[i] = comp
|
||||||
return os.path.join(*comps)
|
return os.path.join(*comps)
|
||||||
|
|
||||||
|
|
||||||
def truncate_path(path, length=MAX_FILENAME_LENGTH):
|
def truncate_path(path, length=MAX_FILENAME_LENGTH):
|
||||||
"""Given a bytestring path or a Unicode path fragment, truncate the
|
"""Given a bytestring path or a Unicode path fragment, truncate the
|
||||||
components to a legal length. In the last component, the extension
|
components to a legal length. In the last component, the extension
|
||||||
@@ -493,6 +541,7 @@ def truncate_path(path, length=MAX_FILENAME_LENGTH):
|
|||||||
|
|
||||||
return os.path.join(*out)
|
return os.path.join(*out)
|
||||||
|
|
||||||
|
|
||||||
def str2bool(value):
|
def str2bool(value):
|
||||||
"""Returns a boolean reflecting a human-entered string."""
|
"""Returns a boolean reflecting a human-entered string."""
|
||||||
if value.lower() in ('yes', '1', 'true', 't', 'y'):
|
if value.lower() in ('yes', '1', 'true', 't', 'y'):
|
||||||
@@ -500,6 +549,7 @@ def str2bool(value):
|
|||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def as_string(value):
|
def as_string(value):
|
||||||
"""Convert a value to a Unicode object for matching with a query.
|
"""Convert a value to a Unicode object for matching with a query.
|
||||||
None becomes the empty string. Bytestrings are silently decoded.
|
None becomes the empty string. Bytestrings are silently decoded.
|
||||||
@@ -513,6 +563,7 @@ def as_string(value):
|
|||||||
else:
|
else:
|
||||||
return unicode(value)
|
return unicode(value)
|
||||||
|
|
||||||
|
|
||||||
def levenshtein(s1, s2):
|
def levenshtein(s1, s2):
|
||||||
"""A nice DP edit distance implementation from Wikibooks:
|
"""A nice DP edit distance implementation from Wikibooks:
|
||||||
http://en.wikibooks.org/wiki/Algorithm_implementation/Strings/
|
http://en.wikibooks.org/wiki/Algorithm_implementation/Strings/
|
||||||
@@ -535,6 +586,7 @@ def levenshtein(s1, s2):
|
|||||||
|
|
||||||
return previous_row[-1]
|
return previous_row[-1]
|
||||||
|
|
||||||
|
|
||||||
def plurality(objs):
|
def plurality(objs):
|
||||||
"""Given a sequence of comparable objects, returns the object that
|
"""Given a sequence of comparable objects, returns the object that
|
||||||
is most common in the set and the frequency of that object. The
|
is most common in the set and the frequency of that object. The
|
||||||
@@ -558,6 +610,7 @@ def plurality(objs):
|
|||||||
|
|
||||||
return res, max_freq
|
return res, max_freq
|
||||||
|
|
||||||
|
|
||||||
def cpu_count():
|
def cpu_count():
|
||||||
"""Return the number of hardware thread contexts (cores or SMT
|
"""Return the number of hardware thread contexts (cores or SMT
|
||||||
threads) in the system.
|
threads) in the system.
|
||||||
@@ -571,7 +624,7 @@ def cpu_count():
|
|||||||
num = 0
|
num = 0
|
||||||
elif sys.platform == 'darwin':
|
elif sys.platform == 'darwin':
|
||||||
try:
|
try:
|
||||||
num = int(os.popen('sysctl -n hw.ncpu').read())
|
num = int(command_output(['sysctl', '-n', 'hw.ncpu']))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
num = 0
|
num = 0
|
||||||
else:
|
else:
|
||||||
@@ -584,23 +637,38 @@ def cpu_count():
|
|||||||
else:
|
else:
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
def command_output(cmd):
|
|
||||||
"""Wraps the `subprocess` module to invoke a command (given as a
|
def command_output(cmd, shell=False):
|
||||||
list of arguments starting with the command name) and collect
|
"""Runs the command and returns its output after it has exited.
|
||||||
stdout. The stderr stream is ignored. May raise
|
|
||||||
`subprocess.CalledProcessError` or an `OSError`.
|
``cmd`` is a list of arguments starting with the command names. If
|
||||||
|
``shell`` is true, ``cmd`` is assumed to be a string and passed to a
|
||||||
|
shell to execute.
|
||||||
|
|
||||||
|
If the process exits with a non-zero return code
|
||||||
|
``subprocess.CalledProcessError`` is raised. May also raise
|
||||||
|
``OSError``.
|
||||||
|
|
||||||
This replaces `subprocess.check_output`, which isn't available in
|
This replaces `subprocess.check_output`, which isn't available in
|
||||||
Python 2.6 and which can have problems if lots of output is sent to
|
Python 2.6 and which can have problems if lots of output is sent to
|
||||||
stderr.
|
stderr.
|
||||||
"""
|
"""
|
||||||
with open(os.devnull, 'w') as devnull:
|
proc = subprocess.Popen(
|
||||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=devnull)
|
cmd,
|
||||||
stdout, _ = proc.communicate()
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
close_fds=platform.system() != 'Windows',
|
||||||
|
shell=shell
|
||||||
|
)
|
||||||
|
stdout, stderr = proc.communicate()
|
||||||
if proc.returncode:
|
if proc.returncode:
|
||||||
raise subprocess.CalledProcessError(proc.returncode, cmd)
|
raise subprocess.CalledProcessError(
|
||||||
|
returncode=proc.returncode,
|
||||||
|
cmd=' '.join(cmd),
|
||||||
|
)
|
||||||
return stdout
|
return stdout
|
||||||
|
|
||||||
|
|
||||||
def max_filename_length(path, limit=MAX_FILENAME_LENGTH):
|
def max_filename_length(path, limit=MAX_FILENAME_LENGTH):
|
||||||
"""Attempt to determine the maximum filename length for the
|
"""Attempt to determine the maximum filename length for the
|
||||||
filesystem containing `path`. If the value is greater than `limit`,
|
filesystem containing `path`. If the value is greater than `limit`,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# This file is part of beets.
|
# This file is part of beets.
|
||||||
# Copyright 2013, Fabrice Laporte
|
# Copyright 2014, Fabrice Laporte
|
||||||
#
|
#
|
||||||
# Permission is hereby granted, free of charge, to any person obtaining
|
# Permission is hereby granted, free of charge, to any person obtaining
|
||||||
# a copy of this software and associated documentation files (the
|
# a copy of this software and associated documentation files (the
|
||||||
@@ -18,6 +18,7 @@ public resizing proxy if neither is available.
|
|||||||
import urllib
|
import urllib
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from tempfile import NamedTemporaryFile
|
from tempfile import NamedTemporaryFile
|
||||||
import logging
|
import logging
|
||||||
from beets import util
|
from beets import util
|
||||||
@@ -76,7 +77,7 @@ def pil_resize(maxwidth, path_in, path_out=None):
|
|||||||
|
|
||||||
def im_resize(maxwidth, path_in, path_out=None):
|
def im_resize(maxwidth, path_in, path_out=None):
|
||||||
"""Resize using ImageMagick's ``convert`` tool.
|
"""Resize using ImageMagick's ``convert`` tool.
|
||||||
tool. Return the output path of resized image.
|
Return the output path of resized image.
|
||||||
"""
|
"""
|
||||||
path_out = path_out or temp_file_for(path_in)
|
path_out = path_out or temp_file_for(path_in)
|
||||||
log.debug(u'artresizer: ImageMagick resizing {0} to {1}'.format(
|
log.debug(u'artresizer: ImageMagick resizing {0} to {1}'.format(
|
||||||
@@ -132,8 +133,9 @@ class ArtResizer(object):
|
|||||||
"""Create a resizer object for the given method or, if none is
|
"""Create a resizer object for the given method or, if none is
|
||||||
specified, with an inferred method.
|
specified, with an inferred method.
|
||||||
"""
|
"""
|
||||||
self.method = method or self._guess_method()
|
self.method = self._check_method(method)
|
||||||
log.debug(u"artresizer: method is {0}".format(self.method))
|
log.debug(u"artresizer: method is {0}".format(self.method))
|
||||||
|
self.can_compare = self._can_compare()
|
||||||
|
|
||||||
def resize(self, maxwidth, path_in, path_out=None):
|
def resize(self, maxwidth, path_in, path_out=None):
|
||||||
"""Manipulate an image file according to the method, returning a
|
"""Manipulate an image file according to the method, returning a
|
||||||
@@ -141,7 +143,7 @@ class ArtResizer(object):
|
|||||||
temporary file. For WEBPROXY, returns `path_in` unmodified.
|
temporary file. For WEBPROXY, returns `path_in` unmodified.
|
||||||
"""
|
"""
|
||||||
if self.local:
|
if self.local:
|
||||||
func = BACKEND_FUNCS[self.method]
|
func = BACKEND_FUNCS[self.method[0]]
|
||||||
return func(maxwidth, path_in, path_out)
|
return func(maxwidth, path_in, path_out)
|
||||||
else:
|
else:
|
||||||
return path_in
|
return path_in
|
||||||
@@ -159,30 +161,51 @@ class ArtResizer(object):
|
|||||||
@property
|
@property
|
||||||
def local(self):
|
def local(self):
|
||||||
"""A boolean indicating whether the resizing method is performed
|
"""A boolean indicating whether the resizing method is performed
|
||||||
locally (i.e., PIL or IMAGEMAGICK).
|
locally (i.e., PIL or ImageMagick).
|
||||||
"""
|
"""
|
||||||
return self.method in BACKEND_FUNCS
|
return self.method[0] in BACKEND_FUNCS
|
||||||
|
|
||||||
|
def _can_compare(self):
|
||||||
|
"""A boolean indicating whether image comparison is available"""
|
||||||
|
|
||||||
|
return self.method[0] == IMAGEMAGICK and self.method[1] > (6, 8, 7)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _guess_method():
|
def _check_method(method=None):
|
||||||
"""Determine which resizing method to use. Returns PIL,
|
"""A tuple indicating whether current method is available and its
|
||||||
IMAGEMAGICK, or WEBPROXY depending on available dependencies.
|
version. If no method is given, it returns a supported one.
|
||||||
"""
|
"""
|
||||||
# Try importing PIL.
|
# Guess available method
|
||||||
try:
|
if not method:
|
||||||
__import__('PIL', fromlist=['Image'])
|
for m in [IMAGEMAGICK, PIL]:
|
||||||
return PIL
|
_, version = ArtResizer._check_method(m)
|
||||||
except ImportError:
|
if version:
|
||||||
pass
|
return (m, version)
|
||||||
|
return (WEBPROXY, (0))
|
||||||
|
|
||||||
|
if method == IMAGEMAGICK:
|
||||||
|
|
||||||
# Try invoking ImageMagick's "convert".
|
# Try invoking ImageMagick's "convert".
|
||||||
try:
|
try:
|
||||||
out = util.command_output(['convert', '--version'])
|
out = util.command_output(['identify', '--version'])
|
||||||
if 'imagemagick' in out.lower():
|
|
||||||
# system32/convert.exe may be interfering
|
|
||||||
return IMAGEMAGICK
|
|
||||||
except (subprocess.CalledProcessError, OSError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fall back to Web proxy method.
|
if 'imagemagick' in out.lower():
|
||||||
return WEBPROXY
|
pattern = r".+ (\d+)\.(\d+)\.(\d+).*"
|
||||||
|
match = re.search(pattern, out)
|
||||||
|
if match:
|
||||||
|
return (IMAGEMAGICK,
|
||||||
|
(int(match.group(1)),
|
||||||
|
int(match.group(2)),
|
||||||
|
int(match.group(3))))
|
||||||
|
return (IMAGEMAGICK, (0))
|
||||||
|
|
||||||
|
except (subprocess.CalledProcessError, OSError):
|
||||||
|
return (IMAGEMAGICK, None)
|
||||||
|
|
||||||
|
if method == PIL:
|
||||||
|
# Try importing PIL.
|
||||||
|
try:
|
||||||
|
__import__('PIL', fromlist=['Image'])
|
||||||
|
return (PIL, (0))
|
||||||
|
except ImportError:
|
||||||
|
return (PIL, None)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class Event(object):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class WaitableEvent(Event):
|
class WaitableEvent(Event):
|
||||||
"""A waitable event is one encapsulating an action that can be
|
"""A waitable event is one encapsulating an action that can be
|
||||||
waited for using a select() call. That is, it's an event with an
|
waited for using a select() call. That is, it's an event with an
|
||||||
@@ -57,21 +58,25 @@ class WaitableEvent(Event):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ValueEvent(Event):
|
class ValueEvent(Event):
|
||||||
"""An event that does nothing but return a fixed value."""
|
"""An event that does nothing but return a fixed value."""
|
||||||
def __init__(self, value):
|
def __init__(self, value):
|
||||||
self.value = value
|
self.value = value
|
||||||
|
|
||||||
|
|
||||||
class ExceptionEvent(Event):
|
class ExceptionEvent(Event):
|
||||||
"""Raise an exception at the yield point. Used internally."""
|
"""Raise an exception at the yield point. Used internally."""
|
||||||
def __init__(self, exc_info):
|
def __init__(self, exc_info):
|
||||||
self.exc_info = exc_info
|
self.exc_info = exc_info
|
||||||
|
|
||||||
|
|
||||||
class SpawnEvent(Event):
|
class SpawnEvent(Event):
|
||||||
"""Add a new coroutine thread to the scheduler."""
|
"""Add a new coroutine thread to the scheduler."""
|
||||||
def __init__(self, coro):
|
def __init__(self, coro):
|
||||||
self.spawned = coro
|
self.spawned = coro
|
||||||
|
|
||||||
|
|
||||||
class JoinEvent(Event):
|
class JoinEvent(Event):
|
||||||
"""Suspend the thread until the specified child thread has
|
"""Suspend the thread until the specified child thread has
|
||||||
completed.
|
completed.
|
||||||
@@ -79,11 +84,13 @@ class JoinEvent(Event):
|
|||||||
def __init__(self, child):
|
def __init__(self, child):
|
||||||
self.child = child
|
self.child = child
|
||||||
|
|
||||||
|
|
||||||
class KillEvent(Event):
|
class KillEvent(Event):
|
||||||
"""Unschedule a child thread."""
|
"""Unschedule a child thread."""
|
||||||
def __init__(self, child):
|
def __init__(self, child):
|
||||||
self.child = child
|
self.child = child
|
||||||
|
|
||||||
|
|
||||||
class DelegationEvent(Event):
|
class DelegationEvent(Event):
|
||||||
"""Suspend execution of the current thread, start a new thread and,
|
"""Suspend execution of the current thread, start a new thread and,
|
||||||
once the child thread finished, return control to the parent
|
once the child thread finished, return control to the parent
|
||||||
@@ -92,6 +99,7 @@ class DelegationEvent(Event):
|
|||||||
def __init__(self, coro):
|
def __init__(self, coro):
|
||||||
self.spawned = coro
|
self.spawned = coro
|
||||||
|
|
||||||
|
|
||||||
class ReturnEvent(Event):
|
class ReturnEvent(Event):
|
||||||
"""Return a value the current thread's delegator at the point of
|
"""Return a value the current thread's delegator at the point of
|
||||||
delegation. Ends the current (delegate) thread.
|
delegation. Ends the current (delegate) thread.
|
||||||
@@ -99,6 +107,7 @@ class ReturnEvent(Event):
|
|||||||
def __init__(self, value):
|
def __init__(self, value):
|
||||||
self.value = value
|
self.value = value
|
||||||
|
|
||||||
|
|
||||||
class SleepEvent(WaitableEvent):
|
class SleepEvent(WaitableEvent):
|
||||||
"""Suspend the thread for a given duration.
|
"""Suspend the thread for a given duration.
|
||||||
"""
|
"""
|
||||||
@@ -108,6 +117,7 @@ class SleepEvent(WaitableEvent):
|
|||||||
def time_left(self):
|
def time_left(self):
|
||||||
return max(self.wakeup_time - time.time(), 0.0)
|
return max(self.wakeup_time - time.time(), 0.0)
|
||||||
|
|
||||||
|
|
||||||
class ReadEvent(WaitableEvent):
|
class ReadEvent(WaitableEvent):
|
||||||
"""Reads from a file-like object."""
|
"""Reads from a file-like object."""
|
||||||
def __init__(self, fd, bufsize):
|
def __init__(self, fd, bufsize):
|
||||||
@@ -120,6 +130,7 @@ class ReadEvent(WaitableEvent):
|
|||||||
def fire(self):
|
def fire(self):
|
||||||
return self.fd.read(self.bufsize)
|
return self.fd.read(self.bufsize)
|
||||||
|
|
||||||
|
|
||||||
class WriteEvent(WaitableEvent):
|
class WriteEvent(WaitableEvent):
|
||||||
"""Writes to a file-like object."""
|
"""Writes to a file-like object."""
|
||||||
def __init__(self, fd, data):
|
def __init__(self, fd, data):
|
||||||
@@ -192,15 +203,19 @@ def _event_select(events):
|
|||||||
|
|
||||||
return ready_events
|
return ready_events
|
||||||
|
|
||||||
|
|
||||||
class ThreadException(Exception):
|
class ThreadException(Exception):
|
||||||
def __init__(self, coro, exc_info):
|
def __init__(self, coro, exc_info):
|
||||||
self.coro = coro
|
self.coro = coro
|
||||||
self.exc_info = exc_info
|
self.exc_info = exc_info
|
||||||
|
|
||||||
def reraise(self):
|
def reraise(self):
|
||||||
_reraise(self.exc_info[0], self.exc_info[1], self.exc_info[2])
|
_reraise(self.exc_info[0], self.exc_info[1], self.exc_info[2])
|
||||||
|
|
||||||
|
|
||||||
SUSPENDED = Event() # Special sentinel placeholder for suspended threads.
|
SUSPENDED = Event() # Special sentinel placeholder for suspended threads.
|
||||||
|
|
||||||
|
|
||||||
class Delegated(Event):
|
class Delegated(Event):
|
||||||
"""Placeholder indicating that a thread has delegated execution to a
|
"""Placeholder indicating that a thread has delegated execution to a
|
||||||
different thread.
|
different thread.
|
||||||
@@ -208,6 +223,7 @@ class Delegated(Event):
|
|||||||
def __init__(self, child):
|
def __init__(self, child):
|
||||||
self.child = child
|
self.child = child
|
||||||
|
|
||||||
|
|
||||||
def run(root_coro):
|
def run(root_coro):
|
||||||
"""Schedules a coroutine, running it to completion. This
|
"""Schedules a coroutine, running it to completion. This
|
||||||
encapsulates the Bluelet scheduler, which the root coroutine can
|
encapsulates the Bluelet scheduler, which the root coroutine can
|
||||||
@@ -378,6 +394,7 @@ def run(root_coro):
|
|||||||
class SocketClosedError(Exception):
|
class SocketClosedError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Listener(object):
|
class Listener(object):
|
||||||
"""A socket wrapper object for listening sockets.
|
"""A socket wrapper object for listening sockets.
|
||||||
"""
|
"""
|
||||||
@@ -407,6 +424,7 @@ class Listener(object):
|
|||||||
self._closed = True
|
self._closed = True
|
||||||
self.sock.close()
|
self.sock.close()
|
||||||
|
|
||||||
|
|
||||||
class Connection(object):
|
class Connection(object):
|
||||||
"""A socket wrapper object for connected sockets.
|
"""A socket wrapper object for connected sockets.
|
||||||
"""
|
"""
|
||||||
@@ -468,6 +486,7 @@ class Connection(object):
|
|||||||
yield ReturnEvent(line)
|
yield ReturnEvent(line)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
class AcceptEvent(WaitableEvent):
|
class AcceptEvent(WaitableEvent):
|
||||||
"""An event for Listener objects (listening sockets) that suspends
|
"""An event for Listener objects (listening sockets) that suspends
|
||||||
execution until the socket gets a connection.
|
execution until the socket gets a connection.
|
||||||
@@ -482,6 +501,7 @@ class AcceptEvent(WaitableEvent):
|
|||||||
sock, addr = self.listener.sock.accept()
|
sock, addr = self.listener.sock.accept()
|
||||||
return Connection(sock, addr)
|
return Connection(sock, addr)
|
||||||
|
|
||||||
|
|
||||||
class ReceiveEvent(WaitableEvent):
|
class ReceiveEvent(WaitableEvent):
|
||||||
"""An event for Connection objects (connected sockets) for
|
"""An event for Connection objects (connected sockets) for
|
||||||
asynchronously reading data.
|
asynchronously reading data.
|
||||||
@@ -496,6 +516,7 @@ class ReceiveEvent(WaitableEvent):
|
|||||||
def fire(self):
|
def fire(self):
|
||||||
return self.conn.sock.recv(self.bufsize)
|
return self.conn.sock.recv(self.bufsize)
|
||||||
|
|
||||||
|
|
||||||
class SendEvent(WaitableEvent):
|
class SendEvent(WaitableEvent):
|
||||||
"""An event for Connection objects (connected sockets) for
|
"""An event for Connection objects (connected sockets) for
|
||||||
asynchronously writing data.
|
asynchronously writing data.
|
||||||
@@ -523,6 +544,7 @@ def null():
|
|||||||
"""
|
"""
|
||||||
return ValueEvent(None)
|
return ValueEvent(None)
|
||||||
|
|
||||||
|
|
||||||
def spawn(coro):
|
def spawn(coro):
|
||||||
"""Event: add another coroutine to the scheduler. Both the parent
|
"""Event: add another coroutine to the scheduler. Both the parent
|
||||||
and child coroutines run concurrently.
|
and child coroutines run concurrently.
|
||||||
@@ -531,6 +553,7 @@ def spawn(coro):
|
|||||||
raise ValueError('%s is not a coroutine' % str(coro))
|
raise ValueError('%s is not a coroutine' % str(coro))
|
||||||
return SpawnEvent(coro)
|
return SpawnEvent(coro)
|
||||||
|
|
||||||
|
|
||||||
def call(coro):
|
def call(coro):
|
||||||
"""Event: delegate to another coroutine. The current coroutine
|
"""Event: delegate to another coroutine. The current coroutine
|
||||||
is resumed once the sub-coroutine finishes. If the sub-coroutine
|
is resumed once the sub-coroutine finishes. If the sub-coroutine
|
||||||
@@ -540,12 +563,14 @@ def call(coro):
|
|||||||
raise ValueError('%s is not a coroutine' % str(coro))
|
raise ValueError('%s is not a coroutine' % str(coro))
|
||||||
return DelegationEvent(coro)
|
return DelegationEvent(coro)
|
||||||
|
|
||||||
|
|
||||||
def end(value=None):
|
def end(value=None):
|
||||||
"""Event: ends the coroutine and returns a value to its
|
"""Event: ends the coroutine and returns a value to its
|
||||||
delegator.
|
delegator.
|
||||||
"""
|
"""
|
||||||
return ReturnEvent(value)
|
return ReturnEvent(value)
|
||||||
|
|
||||||
|
|
||||||
def read(fd, bufsize=None):
|
def read(fd, bufsize=None):
|
||||||
"""Event: read from a file descriptor asynchronously."""
|
"""Event: read from a file descriptor asynchronously."""
|
||||||
if bufsize is None:
|
if bufsize is None:
|
||||||
@@ -563,10 +588,12 @@ def read(fd, bufsize=None):
|
|||||||
else:
|
else:
|
||||||
return ReadEvent(fd, bufsize)
|
return ReadEvent(fd, bufsize)
|
||||||
|
|
||||||
|
|
||||||
def write(fd, data):
|
def write(fd, data):
|
||||||
"""Event: write to a file descriptor asynchronously."""
|
"""Event: write to a file descriptor asynchronously."""
|
||||||
return WriteEvent(fd, data)
|
return WriteEvent(fd, data)
|
||||||
|
|
||||||
|
|
||||||
def connect(host, port):
|
def connect(host, port):
|
||||||
"""Event: connect to a network address and return a Connection
|
"""Event: connect to a network address and return a Connection
|
||||||
object for communicating on the socket.
|
object for communicating on the socket.
|
||||||
@@ -575,17 +602,20 @@ def connect(host, port):
|
|||||||
sock = socket.create_connection(addr)
|
sock = socket.create_connection(addr)
|
||||||
return ValueEvent(Connection(sock, addr))
|
return ValueEvent(Connection(sock, addr))
|
||||||
|
|
||||||
|
|
||||||
def sleep(duration):
|
def sleep(duration):
|
||||||
"""Event: suspend the thread for ``duration`` seconds.
|
"""Event: suspend the thread for ``duration`` seconds.
|
||||||
"""
|
"""
|
||||||
return SleepEvent(duration)
|
return SleepEvent(duration)
|
||||||
|
|
||||||
|
|
||||||
def join(coro):
|
def join(coro):
|
||||||
"""Suspend the thread until another, previously `spawn`ed thread
|
"""Suspend the thread until another, previously `spawn`ed thread
|
||||||
completes.
|
completes.
|
||||||
"""
|
"""
|
||||||
return JoinEvent(coro)
|
return JoinEvent(coro)
|
||||||
|
|
||||||
|
|
||||||
def kill(coro):
|
def kill(coro):
|
||||||
"""Halt the execution of a different `spawn`ed thread.
|
"""Halt the execution of a different `spawn`ed thread.
|
||||||
"""
|
"""
|
||||||
|
|||||||
+476
-95
@@ -21,6 +21,8 @@ import pkgutil
|
|||||||
import sys
|
import sys
|
||||||
import yaml
|
import yaml
|
||||||
import types
|
import types
|
||||||
|
import collections
|
||||||
|
import re
|
||||||
try:
|
try:
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -47,6 +49,7 @@ BASESTRING = str if PY3 else basestring
|
|||||||
NUMERIC_TYPES = (int, float) if PY3 else (int, float, long)
|
NUMERIC_TYPES = (int, float) if PY3 else (int, float, long)
|
||||||
TYPE_TYPES = (type,) if PY3 else (type, types.ClassType)
|
TYPE_TYPES = (type,) if PY3 else (type, types.ClassType)
|
||||||
|
|
||||||
|
|
||||||
def iter_first(sequence):
|
def iter_first(sequence):
|
||||||
"""Get the first element from an iterable or raise a ValueError if
|
"""Get the first element from an iterable or raise a ValueError if
|
||||||
the iterator generates no values.
|
the iterator generates no values.
|
||||||
@@ -67,16 +70,25 @@ class ConfigError(Exception):
|
|||||||
"""Base class for exceptions raised when querying a configuration.
|
"""Base class for exceptions raised when querying a configuration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class NotFoundError(ConfigError):
|
class NotFoundError(ConfigError):
|
||||||
"""A requested value could not be found in the configuration trees.
|
"""A requested value could not be found in the configuration trees.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class ConfigTypeError(ConfigError, TypeError):
|
|
||||||
|
class ConfigValueError(ConfigError):
|
||||||
|
"""The value in the configuration is illegal."""
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigTypeError(ConfigValueError):
|
||||||
"""The value in the configuration did not match the expected type.
|
"""The value in the configuration did not match the expected type.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class ConfigValueError(ConfigError, ValueError):
|
|
||||||
"""The value in the configuration is illegal."""
|
class ConfigTemplateError(ConfigError):
|
||||||
|
"""Base class for exceptions raised because of an invalid template.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class ConfigReadError(ConfigError):
|
class ConfigReadError(ConfigError):
|
||||||
"""A configuration file could not be read."""
|
"""A configuration file could not be read."""
|
||||||
@@ -132,6 +144,7 @@ class ConfigSource(dict):
|
|||||||
else:
|
else:
|
||||||
raise TypeError('source value must be a dict')
|
raise TypeError('source value must be a dict')
|
||||||
|
|
||||||
|
|
||||||
class ConfigView(object):
|
class ConfigView(object):
|
||||||
"""A configuration "view" is a query into a program's configuration
|
"""A configuration "view" is a query into a program's configuration
|
||||||
data. A view represents a hypothetical location in the configuration
|
data. A view represents a hypothetical location in the configuration
|
||||||
@@ -207,6 +220,9 @@ class ConfigView(object):
|
|||||||
"""
|
"""
|
||||||
self.set({key: value})
|
self.set({key: value})
|
||||||
|
|
||||||
|
def __contains__(self, key):
|
||||||
|
return self[key].exists()
|
||||||
|
|
||||||
def set_args(self, namespace):
|
def set_args(self, namespace):
|
||||||
"""Overlay parsed command-line arguments, generated by a library
|
"""Overlay parsed command-line arguments, generated by a library
|
||||||
like argparse or optparse, onto this view's value.
|
like argparse or optparse, onto this view's value.
|
||||||
@@ -310,98 +326,6 @@ class ConfigView(object):
|
|||||||
|
|
||||||
# Validation and conversion.
|
# Validation and conversion.
|
||||||
|
|
||||||
def get(self, typ=None):
|
|
||||||
"""Returns the canonical value for the view, checked against the
|
|
||||||
passed-in type. If the value is not an instance of the given
|
|
||||||
type, a ConfigTypeError is raised. May also raise a
|
|
||||||
NotFoundError.
|
|
||||||
"""
|
|
||||||
value, _ = self.first()
|
|
||||||
|
|
||||||
if typ is not None:
|
|
||||||
if not isinstance(typ, TYPE_TYPES):
|
|
||||||
raise TypeError('argument to get() must be a type')
|
|
||||||
|
|
||||||
if not isinstance(value, typ):
|
|
||||||
raise ConfigTypeError(
|
|
||||||
"{0} must be of type {1}, not {2}".format(
|
|
||||||
self.name, typ.__name__, type(value).__name__
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
def as_filename(self):
|
|
||||||
"""Get a string as a normalized as an absolute, tilde-free path.
|
|
||||||
|
|
||||||
Relative paths are relative to the configuration directory (see
|
|
||||||
the `config_dir` method) if they come from a file. Otherwise,
|
|
||||||
they are relative to the current working directory. This helps
|
|
||||||
attain the expected behavior when using command-line options.
|
|
||||||
"""
|
|
||||||
path, source = self.first()
|
|
||||||
if not isinstance(path, BASESTRING):
|
|
||||||
raise ConfigTypeError('{0} must be a filename, not {1}'.format(
|
|
||||||
self.name, type(path).__name__
|
|
||||||
))
|
|
||||||
path = os.path.expanduser(STRING(path))
|
|
||||||
|
|
||||||
if not os.path.isabs(path) and source.filename:
|
|
||||||
# From defaults: relative to the app's directory.
|
|
||||||
path = os.path.join(self.root().config_dir(), path)
|
|
||||||
|
|
||||||
return os.path.abspath(path)
|
|
||||||
|
|
||||||
def as_choice(self, choices):
|
|
||||||
"""Ensure that the value is among a collection of choices and
|
|
||||||
return it. If `choices` is a dictionary, then return the
|
|
||||||
corresponding value rather than the value itself (the key).
|
|
||||||
"""
|
|
||||||
value = self.get()
|
|
||||||
|
|
||||||
if value not in choices:
|
|
||||||
raise ConfigValueError(
|
|
||||||
'{0} must be one of {1}, not {2}'.format(
|
|
||||||
self.name, repr(list(choices)), repr(value)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if isinstance(choices, dict):
|
|
||||||
return choices[value]
|
|
||||||
else:
|
|
||||||
return value
|
|
||||||
|
|
||||||
def as_number(self):
|
|
||||||
"""Ensure that a value is of numeric type."""
|
|
||||||
value = self.get()
|
|
||||||
if isinstance(value, NUMERIC_TYPES):
|
|
||||||
return value
|
|
||||||
raise ConfigTypeError(
|
|
||||||
'{0} must be numeric, not {1}'.format(
|
|
||||||
self.name, type(value).__name__
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def as_str_seq(self):
|
|
||||||
"""Get the value as a list of strings. The underlying configured
|
|
||||||
value can be a sequence or a single string. In the latter case,
|
|
||||||
the string is treated as a white-space separated list of words.
|
|
||||||
"""
|
|
||||||
value = self.get()
|
|
||||||
if isinstance(value, bytes):
|
|
||||||
value = value.decode('utf8', 'ignore')
|
|
||||||
|
|
||||||
if isinstance(value, STRING):
|
|
||||||
return value.split()
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
return list(value)
|
|
||||||
except TypeError:
|
|
||||||
raise ConfigTypeError(
|
|
||||||
'{0} must be a whitespace-separated string or '
|
|
||||||
'a list'.format(self.name)
|
|
||||||
)
|
|
||||||
|
|
||||||
def flatten(self):
|
def flatten(self):
|
||||||
"""Create a hierarchy of OrderedDicts containing the data from
|
"""Create a hierarchy of OrderedDicts containing the data from
|
||||||
this view, recursively reifying all views to get their
|
this view, recursively reifying all views to get their
|
||||||
@@ -415,6 +339,35 @@ class ConfigView(object):
|
|||||||
od[key] = view.get()
|
od[key] = view.get()
|
||||||
return od
|
return od
|
||||||
|
|
||||||
|
def get(self, template=None):
|
||||||
|
"""Retrieve the value for this view according to the template.
|
||||||
|
|
||||||
|
The `template` against which the values are checked can be
|
||||||
|
anything convertible to a `Template` using `as_template`. This
|
||||||
|
means you can pass in a default integer or string value, for
|
||||||
|
example, or a type to just check that something matches the type
|
||||||
|
you expect.
|
||||||
|
|
||||||
|
May raise a `ConfigValueError` (or its subclass,
|
||||||
|
`ConfigTypeError`) or a `NotFoundError` when the configuration
|
||||||
|
doesn't satisfy the template.
|
||||||
|
"""
|
||||||
|
return as_template(template).value(self, template)
|
||||||
|
|
||||||
|
# Old validation methods (deprecated).
|
||||||
|
|
||||||
|
def as_filename(self):
|
||||||
|
return self.get(Filename())
|
||||||
|
|
||||||
|
def as_choice(self, choices):
|
||||||
|
return self.get(Choice(choices))
|
||||||
|
|
||||||
|
def as_number(self):
|
||||||
|
return self.get(Number())
|
||||||
|
|
||||||
|
def as_str_seq(self):
|
||||||
|
return self.get(StrSeq())
|
||||||
|
|
||||||
|
|
||||||
class RootView(ConfigView):
|
class RootView(ConfigView):
|
||||||
"""The base of a view hierarchy. This view keeps track of the
|
"""The base of a view hierarchy. This view keeps track of the
|
||||||
@@ -518,6 +471,7 @@ def _package_path(name):
|
|||||||
|
|
||||||
return os.path.dirname(os.path.abspath(filepath))
|
return os.path.dirname(os.path.abspath(filepath))
|
||||||
|
|
||||||
|
|
||||||
def config_dirs():
|
def config_dirs():
|
||||||
"""Return a platform-specific list of candidates for user
|
"""Return a platform-specific list of candidates for user
|
||||||
configuration directories on the system.
|
configuration directories on the system.
|
||||||
@@ -606,10 +560,12 @@ class Loader(yaml.SafeLoader):
|
|||||||
plain = super(Loader, self).check_plain()
|
plain = super(Loader, self).check_plain()
|
||||||
return plain or self.peek() == '%'
|
return plain or self.peek() == '%'
|
||||||
|
|
||||||
|
|
||||||
Loader.add_constructor('tag:yaml.org,2002:str', Loader._construct_unicode)
|
Loader.add_constructor('tag:yaml.org,2002:str', Loader._construct_unicode)
|
||||||
Loader.add_constructor('tag:yaml.org,2002:map', Loader.construct_yaml_map)
|
Loader.add_constructor('tag:yaml.org,2002:map', Loader.construct_yaml_map)
|
||||||
Loader.add_constructor('tag:yaml.org,2002:omap', Loader.construct_yaml_map)
|
Loader.add_constructor('tag:yaml.org,2002:omap', Loader.construct_yaml_map)
|
||||||
|
|
||||||
|
|
||||||
def load_yaml(filename):
|
def load_yaml(filename):
|
||||||
"""Read a YAML document from a file. If the file cannot be read or
|
"""Read a YAML document from a file. If the file cannot be read or
|
||||||
parsed, a ConfigReadError is raised.
|
parsed, a ConfigReadError is raised.
|
||||||
@@ -679,11 +635,13 @@ class Dumper(yaml.SafeDumper):
|
|||||||
"""
|
"""
|
||||||
return self.represent_scalar('tag:yaml.org,2002:null', '')
|
return self.represent_scalar('tag:yaml.org,2002:null', '')
|
||||||
|
|
||||||
|
|
||||||
Dumper.add_representer(OrderedDict, Dumper.represent_dict)
|
Dumper.add_representer(OrderedDict, Dumper.represent_dict)
|
||||||
Dumper.add_representer(bool, Dumper.represent_bool)
|
Dumper.add_representer(bool, Dumper.represent_bool)
|
||||||
Dumper.add_representer(type(None), Dumper.represent_none)
|
Dumper.add_representer(type(None), Dumper.represent_none)
|
||||||
Dumper.add_representer(list, Dumper.represent_list)
|
Dumper.add_representer(list, Dumper.represent_list)
|
||||||
|
|
||||||
|
|
||||||
def restore_yaml_comments(data, default_data):
|
def restore_yaml_comments(data, default_data):
|
||||||
"""Scan default_data for comments (we include empty lines in our
|
"""Scan default_data for comments (we include empty lines in our
|
||||||
definition of comments) and place them before the same keys in data.
|
definition of comments) and place them before the same keys in data.
|
||||||
@@ -898,3 +856,426 @@ class LazyConfig(Configuration):
|
|||||||
del self.sources[:]
|
del self.sources[:]
|
||||||
self._lazy_suffix = []
|
self._lazy_suffix = []
|
||||||
self._lazy_prefix = []
|
self._lazy_prefix = []
|
||||||
|
|
||||||
|
|
||||||
|
# "Validated" configuration views: experimental!
|
||||||
|
|
||||||
|
|
||||||
|
REQUIRED = object()
|
||||||
|
"""A sentinel indicating that there is no default value and an exception
|
||||||
|
should be raised when the value is missing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Template(object):
|
||||||
|
"""A value template for configuration fields.
|
||||||
|
|
||||||
|
The template works like a type and instructs Confit about how to
|
||||||
|
interpret a deserialized YAML value. This includes type conversions,
|
||||||
|
providing a default value, and validating for errors. For example, a
|
||||||
|
filepath type might expand tildes and check that the file exists.
|
||||||
|
"""
|
||||||
|
def __init__(self, default=REQUIRED):
|
||||||
|
"""Create a template with a given default value.
|
||||||
|
|
||||||
|
If `default` is the sentinel `REQUIRED` (as it is by default),
|
||||||
|
then an error will be raised when a value is missing. Otherwise,
|
||||||
|
missing values will instead return `default`.
|
||||||
|
"""
|
||||||
|
self.default = default
|
||||||
|
|
||||||
|
def __call__(self, view):
|
||||||
|
"""Invoking a template on a view gets the view's value according
|
||||||
|
to the template.
|
||||||
|
"""
|
||||||
|
return self.value(view, self)
|
||||||
|
|
||||||
|
def value(self, view, template=None):
|
||||||
|
"""Get the value for a `ConfigView`.
|
||||||
|
|
||||||
|
May raise a `NotFoundError` if the value is missing (and the
|
||||||
|
template requires it) or a `ConfigValueError` for invalid values.
|
||||||
|
"""
|
||||||
|
if view.exists():
|
||||||
|
value, _ = view.first()
|
||||||
|
return self.convert(value, view)
|
||||||
|
elif self.default is REQUIRED:
|
||||||
|
# Missing required value. This is an error.
|
||||||
|
raise NotFoundError("{0} not found".format(view.name))
|
||||||
|
else:
|
||||||
|
# Missing value, but not required.
|
||||||
|
return self.default
|
||||||
|
|
||||||
|
def convert(self, value, view):
|
||||||
|
"""Convert the YAML-deserialized value to a value of the desired
|
||||||
|
type.
|
||||||
|
|
||||||
|
Subclasses should override this to provide useful conversions.
|
||||||
|
May raise a `ConfigValueError` when the configuration is wrong.
|
||||||
|
"""
|
||||||
|
# Default implementation does no conversion.
|
||||||
|
return value
|
||||||
|
|
||||||
|
def fail(self, message, view, type_error=False):
|
||||||
|
"""Raise an exception indicating that a value cannot be
|
||||||
|
accepted.
|
||||||
|
|
||||||
|
`type_error` indicates whether the error is due to a type
|
||||||
|
mismatch rather than a malformed value. In this case, a more
|
||||||
|
specific exception is raised.
|
||||||
|
"""
|
||||||
|
exc_class = ConfigTypeError if type_error else ConfigValueError
|
||||||
|
raise exc_class(
|
||||||
|
'{0}: {1}'.format(view.name, message)
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '{0}({1})'.format(
|
||||||
|
type(self).__name__,
|
||||||
|
'' if self.default is REQUIRED else repr(self.default),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Integer(Template):
|
||||||
|
"""An integer configuration value template.
|
||||||
|
"""
|
||||||
|
def convert(self, value, view):
|
||||||
|
"""Check that the value is an integer. Floats are rounded.
|
||||||
|
"""
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
elif isinstance(value, float):
|
||||||
|
return int(value)
|
||||||
|
else:
|
||||||
|
self.fail('must be a number', view, True)
|
||||||
|
|
||||||
|
|
||||||
|
class Number(Template):
|
||||||
|
"""A numeric type: either an integer or a floating-point number.
|
||||||
|
"""
|
||||||
|
def convert(self, value, view):
|
||||||
|
"""Check that the value is an int or a float.
|
||||||
|
"""
|
||||||
|
if isinstance(value, NUMERIC_TYPES):
|
||||||
|
return value
|
||||||
|
else:
|
||||||
|
self.fail(
|
||||||
|
'must be numeric, not {0}'.format(type(value).__name__),
|
||||||
|
view,
|
||||||
|
True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MappingTemplate(Template):
|
||||||
|
"""A template that uses a dictionary to specify other types for the
|
||||||
|
values for a set of keys and produce a validated `AttrDict`.
|
||||||
|
"""
|
||||||
|
def __init__(self, mapping):
|
||||||
|
"""Create a template according to a dict (mapping). The
|
||||||
|
mapping's values should themselves either be Types or
|
||||||
|
convertible to Types.
|
||||||
|
"""
|
||||||
|
subtemplates = {}
|
||||||
|
for key, typ in mapping.items():
|
||||||
|
subtemplates[key] = as_template(typ)
|
||||||
|
self.subtemplates = subtemplates
|
||||||
|
|
||||||
|
def value(self, view, template=None):
|
||||||
|
"""Get a dict with the same keys as the template and values
|
||||||
|
validated according to the value types.
|
||||||
|
"""
|
||||||
|
out = AttrDict()
|
||||||
|
for key, typ in self.subtemplates.items():
|
||||||
|
out[key] = typ.value(view[key], self)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return 'MappingTemplate({0})'.format(repr(self.subtemplates))
|
||||||
|
|
||||||
|
|
||||||
|
class String(Template):
|
||||||
|
"""A string configuration value template.
|
||||||
|
"""
|
||||||
|
def __init__(self, default=REQUIRED, pattern=None):
|
||||||
|
"""Create a template with the added optional `pattern` argument,
|
||||||
|
a regular expression string that the value should match.
|
||||||
|
"""
|
||||||
|
super(String, self).__init__(default)
|
||||||
|
self.pattern = pattern
|
||||||
|
if pattern:
|
||||||
|
self.regex = re.compile(pattern)
|
||||||
|
|
||||||
|
def convert(self, value, view):
|
||||||
|
"""Check that the value is a string and matches the pattern.
|
||||||
|
"""
|
||||||
|
if isinstance(value, BASESTRING):
|
||||||
|
if self.pattern and not self.regex.match(value):
|
||||||
|
self.fail(
|
||||||
|
"must match the pattern {0}".format(self.pattern),
|
||||||
|
view
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
else:
|
||||||
|
self.fail('must be a string', view, True)
|
||||||
|
|
||||||
|
|
||||||
|
class Choice(Template):
|
||||||
|
"""A template that permits values from a sequence of choices.
|
||||||
|
"""
|
||||||
|
def __init__(self, choices):
|
||||||
|
"""Create a template that validates any of the values from the
|
||||||
|
iterable `choices`.
|
||||||
|
|
||||||
|
If `choices` is a map, then the corresponding value is emitted.
|
||||||
|
Otherwise, the value itself is emitted.
|
||||||
|
"""
|
||||||
|
self.choices = choices
|
||||||
|
|
||||||
|
def convert(self, value, view):
|
||||||
|
"""Ensure that the value is among the choices (and remap if the
|
||||||
|
choices are a mapping).
|
||||||
|
"""
|
||||||
|
if value not in self.choices:
|
||||||
|
self.fail(
|
||||||
|
'must be one of {0}, not {1}'.format(
|
||||||
|
repr(list(self.choices)), repr(value)
|
||||||
|
),
|
||||||
|
view
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(self.choices, collections.Mapping):
|
||||||
|
return self.choices[value]
|
||||||
|
else:
|
||||||
|
return value
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return 'Choice({0!r})'.format(self.choices)
|
||||||
|
|
||||||
|
|
||||||
|
class StrSeq(Template):
|
||||||
|
"""A template for values that are lists of strings.
|
||||||
|
|
||||||
|
Validates both actual YAML string lists and single strings. Strings
|
||||||
|
can optionally be split on whitespace.
|
||||||
|
"""
|
||||||
|
def __init__(self, split=True):
|
||||||
|
"""Create a new template.
|
||||||
|
|
||||||
|
`split` indicates whether, when the underlying value is a single
|
||||||
|
string, it should be split on whitespace. Otherwise, the
|
||||||
|
resulting value is a list containing a single string.
|
||||||
|
"""
|
||||||
|
super(StrSeq, self).__init__()
|
||||||
|
self.split = split
|
||||||
|
|
||||||
|
def convert(self, value, view):
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
value = value.decode('utf8', 'ignore')
|
||||||
|
|
||||||
|
if isinstance(value, STRING):
|
||||||
|
if self.split:
|
||||||
|
return value.split()
|
||||||
|
else:
|
||||||
|
return [value]
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = list(value)
|
||||||
|
except TypeError:
|
||||||
|
self.fail('must be a whitespace-separated string or a list',
|
||||||
|
view, True)
|
||||||
|
|
||||||
|
def convert(x):
|
||||||
|
if isinstance(x, unicode):
|
||||||
|
return x
|
||||||
|
elif isinstance(x, BASESTRING):
|
||||||
|
return x.decode('utf8', 'ignore')
|
||||||
|
else:
|
||||||
|
self.fail('must be a list of strings', view, True)
|
||||||
|
return map(convert, value)
|
||||||
|
|
||||||
|
|
||||||
|
class Filename(Template):
|
||||||
|
"""A template that validates strings as filenames.
|
||||||
|
|
||||||
|
Filenames are returned as absolute, tilde-free paths.
|
||||||
|
|
||||||
|
Relative paths are relative to the template's `cwd` argument
|
||||||
|
when it is specified, then the configuration directory (see
|
||||||
|
the `config_dir` method) if they come from a file. Otherwise,
|
||||||
|
they are relative to the current working directory. This helps
|
||||||
|
attain the expected behavior when using command-line options.
|
||||||
|
"""
|
||||||
|
def __init__(self, default=REQUIRED, cwd=None, relative_to=None,
|
||||||
|
in_app_dir=False):
|
||||||
|
""" `relative_to` is the name of a sibling value that is
|
||||||
|
being validated at the same time.
|
||||||
|
|
||||||
|
`in_app_dir` indicates whether the path should be resolved
|
||||||
|
inside the application's config directory (even when the setting
|
||||||
|
does not come from a file).
|
||||||
|
"""
|
||||||
|
super(Filename, self).__init__(default)
|
||||||
|
self.cwd = cwd
|
||||||
|
self.relative_to = relative_to
|
||||||
|
self.in_app_dir = in_app_dir
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
args = []
|
||||||
|
|
||||||
|
if self.default is not REQUIRED:
|
||||||
|
args.append(repr(self.default))
|
||||||
|
|
||||||
|
if self.cwd is not None:
|
||||||
|
args.append('cwd=' + repr(self.cwd))
|
||||||
|
|
||||||
|
if self.relative_to is not None:
|
||||||
|
args.append('relative_to=' + repr(self.relative_to))
|
||||||
|
|
||||||
|
if self.in_app_dir:
|
||||||
|
args.append('in_app_dir=True')
|
||||||
|
|
||||||
|
return 'Filename({0})'.format(', '.join(args))
|
||||||
|
|
||||||
|
def resolve_relative_to(self, view, template):
|
||||||
|
if not isinstance(template, (collections.Mapping, MappingTemplate)):
|
||||||
|
# disallow config.get(Filename(relative_to='foo'))
|
||||||
|
raise ConfigTemplateError(
|
||||||
|
'relative_to may only be used when getting multiple values.'
|
||||||
|
)
|
||||||
|
|
||||||
|
elif self.relative_to == view.key:
|
||||||
|
raise ConfigTemplateError(
|
||||||
|
'{0} is relative to itself'.format(view.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif self.relative_to not in view.parent.keys():
|
||||||
|
# self.relative_to is not in the config
|
||||||
|
self.fail(
|
||||||
|
(
|
||||||
|
'needs sibling value "{0}" to expand relative path'
|
||||||
|
).format(self.relative_to),
|
||||||
|
view
|
||||||
|
)
|
||||||
|
|
||||||
|
old_template = {}
|
||||||
|
old_template.update(template.subtemplates)
|
||||||
|
|
||||||
|
# save time by skipping MappingTemplate's init loop
|
||||||
|
next_template = MappingTemplate({})
|
||||||
|
next_relative = self.relative_to
|
||||||
|
|
||||||
|
# gather all the needed templates and nothing else
|
||||||
|
while next_relative is not None:
|
||||||
|
try:
|
||||||
|
# pop to avoid infinite loop because of recursive
|
||||||
|
# relative paths
|
||||||
|
rel_to_template = old_template.pop(next_relative)
|
||||||
|
except KeyError:
|
||||||
|
if next_relative in template.subtemplates:
|
||||||
|
# we encountered this config key previously
|
||||||
|
raise ConfigTemplateError((
|
||||||
|
'{0} and {1} are recursively relative'
|
||||||
|
).format(view.name, self.relative_to))
|
||||||
|
else:
|
||||||
|
raise ConfigTemplateError((
|
||||||
|
'missing template for {0}, needed to expand {1}\'s' +
|
||||||
|
'relative path'
|
||||||
|
).format(self.relative_to, view.name))
|
||||||
|
|
||||||
|
next_template.subtemplates[next_relative] = rel_to_template
|
||||||
|
next_relative = rel_to_template.relative_to
|
||||||
|
|
||||||
|
return view.parent.get(next_template)[self.relative_to]
|
||||||
|
|
||||||
|
def value(self, view, template=None):
|
||||||
|
path, source = view.first()
|
||||||
|
if not isinstance(path, BASESTRING):
|
||||||
|
self.fail(
|
||||||
|
'must be a filename, not {0}'.format(type(path).__name__),
|
||||||
|
view,
|
||||||
|
True
|
||||||
|
)
|
||||||
|
path = os.path.expanduser(STRING(path))
|
||||||
|
|
||||||
|
if not os.path.isabs(path):
|
||||||
|
if self.cwd is not None:
|
||||||
|
# relative to the template's argument
|
||||||
|
path = os.path.join(self.cwd, path)
|
||||||
|
|
||||||
|
elif self.relative_to is not None:
|
||||||
|
path = os.path.join(
|
||||||
|
self.resolve_relative_to(view, template),
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif source.filename or self.in_app_dir:
|
||||||
|
# From defaults: relative to the app's directory.
|
||||||
|
path = os.path.join(view.root().config_dir(), path)
|
||||||
|
|
||||||
|
return os.path.abspath(path)
|
||||||
|
|
||||||
|
|
||||||
|
class TypeTemplate(Template):
|
||||||
|
"""A simple template that checks that a value is an instance of a
|
||||||
|
desired Python type.
|
||||||
|
"""
|
||||||
|
def __init__(self, typ, default=REQUIRED):
|
||||||
|
"""Create a template that checks that the value is an instance
|
||||||
|
of `typ`.
|
||||||
|
"""
|
||||||
|
super(TypeTemplate, self).__init__(default)
|
||||||
|
self.typ = typ
|
||||||
|
|
||||||
|
def convert(self, value, view):
|
||||||
|
if not isinstance(value, self.typ):
|
||||||
|
self.fail(
|
||||||
|
'must be a {0}, not {1}'.format(
|
||||||
|
self.typ.__name__,
|
||||||
|
type(value).__name__,
|
||||||
|
),
|
||||||
|
view,
|
||||||
|
True
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class AttrDict(dict):
|
||||||
|
"""A `dict` subclass that can be accessed via attributes (dot
|
||||||
|
notation) for convenience.
|
||||||
|
"""
|
||||||
|
def __getattr__(self, key):
|
||||||
|
if key in self:
|
||||||
|
return self[key]
|
||||||
|
else:
|
||||||
|
raise AttributeError(key)
|
||||||
|
|
||||||
|
|
||||||
|
def as_template(value):
|
||||||
|
"""Convert a simple "shorthand" Python value to a `Template`.
|
||||||
|
"""
|
||||||
|
if isinstance(value, Template):
|
||||||
|
# If it's already a Template, pass it through.
|
||||||
|
return value
|
||||||
|
elif isinstance(value, collections.Mapping):
|
||||||
|
# Dictionaries work as templates.
|
||||||
|
return MappingTemplate(value)
|
||||||
|
elif value is int:
|
||||||
|
return Integer()
|
||||||
|
elif isinstance(value, int):
|
||||||
|
return Integer(value)
|
||||||
|
elif isinstance(value, type) and issubclass(value, BASESTRING):
|
||||||
|
return String()
|
||||||
|
elif isinstance(value, BASESTRING):
|
||||||
|
return String(value)
|
||||||
|
elif value is float:
|
||||||
|
return Number()
|
||||||
|
elif value is None:
|
||||||
|
return Template()
|
||||||
|
elif value is dict:
|
||||||
|
return TypeTemplate(collections.Mapping)
|
||||||
|
elif value is list:
|
||||||
|
return TypeTemplate(collections.Sequence)
|
||||||
|
elif isinstance(value, type):
|
||||||
|
return TypeTemplate(value)
|
||||||
|
else:
|
||||||
|
raise ValueError('cannot convert to template: {0!r}'.format(value))
|
||||||
|
|||||||
+18
-156
@@ -12,167 +12,29 @@
|
|||||||
# The above copyright notice and this permission notice shall be
|
# The above copyright notice and this permission notice shall be
|
||||||
# included in all copies or substantial portions of the Software.
|
# included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
"""A metaclass for enumerated types that really are types.
|
from enum import Enum
|
||||||
|
|
||||||
You can create enumerations with `enum(values, [name])` and they work
|
|
||||||
how you would expect them to.
|
|
||||||
|
|
||||||
>>> from enumeration import enum
|
class OrderedEnum(Enum):
|
||||||
>>> Direction = enum('north east south west', name='Direction')
|
|
||||||
>>> Direction.west
|
|
||||||
Direction.west
|
|
||||||
>>> Direction.west == Direction.west
|
|
||||||
True
|
|
||||||
>>> Direction.west == Direction.east
|
|
||||||
False
|
|
||||||
>>> isinstance(Direction.west, Direction)
|
|
||||||
True
|
|
||||||
>>> Direction[3]
|
|
||||||
Direction.west
|
|
||||||
>>> Direction['west']
|
|
||||||
Direction.west
|
|
||||||
>>> Direction.west.name
|
|
||||||
'west'
|
|
||||||
>>> Direction.north < Direction.west
|
|
||||||
True
|
|
||||||
|
|
||||||
Enumerations are classes; their instances represent the possible values
|
|
||||||
of the enumeration. Because Python classes must have names, you may
|
|
||||||
provide a `name` parameter to `enum`; if you don't, a meaningless one
|
|
||||||
will be chosen for you.
|
|
||||||
"""
|
"""
|
||||||
import random
|
An Enum subclass that allows comparison of members.
|
||||||
|
|
||||||
class Enumeration(type):
|
|
||||||
"""A metaclass whose classes are enumerations.
|
|
||||||
|
|
||||||
The `values` attribute of the class is used to populate the
|
|
||||||
enumeration. Values may either be a list of enumerated names or a
|
|
||||||
string containing a space-separated list of names. When the class
|
|
||||||
is created, it is instantiated for each name value in `values`.
|
|
||||||
Each such instance is the name of the enumerated item as the sole
|
|
||||||
argument.
|
|
||||||
|
|
||||||
The `Enumerated` class is a good choice for a superclass.
|
|
||||||
"""
|
"""
|
||||||
|
def __ge__(self, other):
|
||||||
def __init__(cls, name, bases, dic):
|
if self.__class__ is other.__class__:
|
||||||
super(Enumeration, cls).__init__(name, bases, dic)
|
return self.value >= other.value
|
||||||
|
|
||||||
if 'values' not in dic:
|
|
||||||
# Do nothing if no values are provided (i.e., with
|
|
||||||
# Enumerated itself).
|
|
||||||
return
|
|
||||||
|
|
||||||
# May be called with a single string, in which case we split on
|
|
||||||
# whitespace for convenience.
|
|
||||||
values = dic['values']
|
|
||||||
if isinstance(values, basestring):
|
|
||||||
values = values.split()
|
|
||||||
|
|
||||||
# Create the Enumerated instances for each value. We have to use
|
|
||||||
# super's __setattr__ here because we disallow setattr below.
|
|
||||||
super(Enumeration, cls).__setattr__('_items_dict', {})
|
|
||||||
super(Enumeration, cls).__setattr__('_items_list', [])
|
|
||||||
for value in values:
|
|
||||||
item = cls(value, len(cls._items_list))
|
|
||||||
cls._items_dict[value] = item
|
|
||||||
cls._items_list.append(item)
|
|
||||||
|
|
||||||
def __getattr__(cls, key):
|
|
||||||
try:
|
|
||||||
return cls._items_dict[key]
|
|
||||||
except KeyError:
|
|
||||||
raise AttributeError("enumeration '" + cls.__name__ +
|
|
||||||
"' has no item '" + key + "'")
|
|
||||||
|
|
||||||
def __setattr__(cls, key, val):
|
|
||||||
raise TypeError("enumerations do not support attribute assignment")
|
|
||||||
|
|
||||||
def __getitem__(cls, key):
|
|
||||||
if isinstance(key, int):
|
|
||||||
return cls._items_list[key]
|
|
||||||
else:
|
|
||||||
return getattr(cls, key)
|
|
||||||
|
|
||||||
def __len__(cls):
|
|
||||||
return len(cls._items_list)
|
|
||||||
|
|
||||||
def __iter__(cls):
|
|
||||||
return iter(cls._items_list)
|
|
||||||
|
|
||||||
def __nonzero__(cls):
|
|
||||||
# Ensures that __len__ doesn't get called before __init__ by
|
|
||||||
# pydoc.
|
|
||||||
return True
|
|
||||||
|
|
||||||
class Enumerated(object):
|
|
||||||
"""An item in an enumeration.
|
|
||||||
|
|
||||||
Contains instance methods inherited by enumerated objects. The
|
|
||||||
metaclass is preset to `Enumeration` for your convenience.
|
|
||||||
|
|
||||||
Instance attributes:
|
|
||||||
name -- The name of the item.
|
|
||||||
index -- The index of the item in its enumeration.
|
|
||||||
|
|
||||||
>>> from enumeration import Enumerated
|
|
||||||
>>> class Garment(Enumerated):
|
|
||||||
... values = 'hat glove belt poncho lederhosen suspenders'
|
|
||||||
... def wear(self):
|
|
||||||
... print('now wearing a ' + self.name)
|
|
||||||
...
|
|
||||||
>>> Garment.poncho.wear()
|
|
||||||
now wearing a poncho
|
|
||||||
"""
|
|
||||||
|
|
||||||
__metaclass__ = Enumeration
|
|
||||||
|
|
||||||
def __init__(self, name, index):
|
|
||||||
self.name = name
|
|
||||||
self.index = index
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return type(self).__name__ + '.' + self.name
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return str(self)
|
|
||||||
|
|
||||||
def __cmp__(self, other):
|
|
||||||
if type(self) is type(other):
|
|
||||||
# Note that we're assuming that the items are direct
|
|
||||||
# instances of the same Enumeration (i.e., no fancy
|
|
||||||
# subclassing), which is probably okay.
|
|
||||||
return cmp(self.index, other.index)
|
|
||||||
else:
|
|
||||||
return NotImplemented
|
return NotImplemented
|
||||||
|
|
||||||
def enum(*values, **kwargs):
|
def __gt__(self, other):
|
||||||
"""Shorthand for creating a new Enumeration class.
|
if self.__class__ is other.__class__:
|
||||||
|
return self.value > other.value
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
Call with enumeration values as a list, a space-delimited string, or
|
def __le__(self, other):
|
||||||
just an argument list. To give the class a name, pass it as the
|
if self.__class__ is other.__class__:
|
||||||
`name` keyword argument. Otherwise, a name will be chosen for you.
|
return self.value <= other.value
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
The following are all equivalent:
|
def __lt__(self, other):
|
||||||
|
if self.__class__ is other.__class__:
|
||||||
enum('pinkie ring middle index thumb')
|
return self.value < other.value
|
||||||
enum('pinkie', 'ring', 'middle', 'index', 'thumb')
|
return NotImplemented
|
||||||
enum(['pinkie', 'ring', 'middle', 'index', 'thumb'])
|
|
||||||
"""
|
|
||||||
|
|
||||||
if ('name' not in kwargs) or kwargs['name'] is None:
|
|
||||||
# Create a probably-unique name. It doesn't really have to be
|
|
||||||
# unique, but getting distinct names each time helps with
|
|
||||||
# identification in debugging.
|
|
||||||
name = 'Enumeration' + hex(random.randint(0,0xfffffff))[2:].upper()
|
|
||||||
else:
|
|
||||||
name = kwargs['name']
|
|
||||||
|
|
||||||
if len(values) == 1:
|
|
||||||
# If there's only one value, we have a couple of alternate calling
|
|
||||||
# styles.
|
|
||||||
if isinstance(values[0], basestring) or hasattr(values[0], '__iter__'):
|
|
||||||
values = values[0]
|
|
||||||
|
|
||||||
return type(name, (Enumerated,), {'values': values})
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ ESCAPE_CHAR = u'$'
|
|||||||
VARIABLE_PREFIX = '__var_'
|
VARIABLE_PREFIX = '__var_'
|
||||||
FUNCTION_PREFIX = '__func_'
|
FUNCTION_PREFIX = '__func_'
|
||||||
|
|
||||||
|
|
||||||
class Environment(object):
|
class Environment(object):
|
||||||
"""Contains the values and functions to be substituted into a
|
"""Contains the values and functions to be substituted into a
|
||||||
template.
|
template.
|
||||||
@@ -57,10 +58,12 @@ def ex_lvalue(name):
|
|||||||
"""A variable load expression."""
|
"""A variable load expression."""
|
||||||
return ast.Name(name, ast.Store())
|
return ast.Name(name, ast.Store())
|
||||||
|
|
||||||
|
|
||||||
def ex_rvalue(name):
|
def ex_rvalue(name):
|
||||||
"""A variable store expression."""
|
"""A variable store expression."""
|
||||||
return ast.Name(name, ast.Load())
|
return ast.Name(name, ast.Load())
|
||||||
|
|
||||||
|
|
||||||
def ex_literal(val):
|
def ex_literal(val):
|
||||||
"""An int, float, long, bool, string, or None literal with the given
|
"""An int, float, long, bool, string, or None literal with the given
|
||||||
value.
|
value.
|
||||||
@@ -75,6 +78,7 @@ def ex_literal(val):
|
|||||||
return ast.Str(val)
|
return ast.Str(val)
|
||||||
raise TypeError('no literal for {0}'.format(type(val)))
|
raise TypeError('no literal for {0}'.format(type(val)))
|
||||||
|
|
||||||
|
|
||||||
def ex_varassign(name, expr):
|
def ex_varassign(name, expr):
|
||||||
"""Assign an expression into a single variable. The expression may
|
"""Assign an expression into a single variable. The expression may
|
||||||
either be an `ast.expr` object or a value to be used as a literal.
|
either be an `ast.expr` object or a value to be used as a literal.
|
||||||
@@ -83,6 +87,7 @@ def ex_varassign(name, expr):
|
|||||||
expr = ex_literal(expr)
|
expr = ex_literal(expr)
|
||||||
return ast.Assign([ex_lvalue(name)], expr)
|
return ast.Assign([ex_lvalue(name)], expr)
|
||||||
|
|
||||||
|
|
||||||
def ex_call(func, args):
|
def ex_call(func, args):
|
||||||
"""A function-call expression with only positional parameters. The
|
"""A function-call expression with only positional parameters. The
|
||||||
function may be an expression or the name of a function. Each
|
function may be an expression or the name of a function. Each
|
||||||
@@ -98,6 +103,7 @@ def ex_call(func, args):
|
|||||||
|
|
||||||
return ast.Call(func, args, [], None, None)
|
return ast.Call(func, args, [], None, None)
|
||||||
|
|
||||||
|
|
||||||
def compile_func(arg_names, statements, name='_the_func', debug=False):
|
def compile_func(arg_names, statements, name='_the_func', debug=False):
|
||||||
"""Compile a list of statements as the body of a function and return
|
"""Compile a list of statements as the body of a function and return
|
||||||
the resulting Python function. If `debug`, then print out the
|
the resulting Python function. If `debug`, then print out the
|
||||||
@@ -157,6 +163,7 @@ class Symbol(object):
|
|||||||
expr = ex_rvalue(VARIABLE_PREFIX + self.ident.encode('utf8'))
|
expr = ex_rvalue(VARIABLE_PREFIX + self.ident.encode('utf8'))
|
||||||
return [expr], set([self.ident.encode('utf8')]), set()
|
return [expr], set([self.ident.encode('utf8')]), set()
|
||||||
|
|
||||||
|
|
||||||
class Call(object):
|
class Call(object):
|
||||||
"""A function call in a template."""
|
"""A function call in a template."""
|
||||||
def __init__(self, ident, args, original):
|
def __init__(self, ident, args, original):
|
||||||
@@ -214,6 +221,7 @@ class Call(object):
|
|||||||
)
|
)
|
||||||
return [subexpr_call], varnames, funcnames
|
return [subexpr_call], varnames, funcnames
|
||||||
|
|
||||||
|
|
||||||
class Expression(object):
|
class Expression(object):
|
||||||
"""Top-level template construct: contains a list of text blobs,
|
"""Top-level template construct: contains a list of text blobs,
|
||||||
Symbols, and Calls.
|
Symbols, and Calls.
|
||||||
@@ -259,6 +267,7 @@ class Expression(object):
|
|||||||
class ParseError(Exception):
|
class ParseError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Parser(object):
|
class Parser(object):
|
||||||
"""Parses a template expression string. Instantiate the class with
|
"""Parses a template expression string. Instantiate the class with
|
||||||
the template source and call ``parse_expression``. The ``pos`` field
|
the template source and call ``parse_expression``. The ``pos`` field
|
||||||
@@ -472,6 +481,7 @@ class Parser(object):
|
|||||||
self.pos += len(ident)
|
self.pos += len(ident)
|
||||||
return ident
|
return ident
|
||||||
|
|
||||||
|
|
||||||
def _parse(template):
|
def _parse(template):
|
||||||
"""Parse a top-level template string Expression. Any extraneous text
|
"""Parse a top-level template string Expression. Any extraneous text
|
||||||
is considered literal text.
|
is considered literal text.
|
||||||
|
|||||||
@@ -35,13 +35,13 @@ from __future__ import print_function
|
|||||||
import Queue
|
import Queue
|
||||||
from threading import Thread, Lock
|
from threading import Thread, Lock
|
||||||
import sys
|
import sys
|
||||||
import types
|
|
||||||
|
|
||||||
BUBBLE = '__PIPELINE_BUBBLE__'
|
BUBBLE = '__PIPELINE_BUBBLE__'
|
||||||
POISON = '__PIPELINE_POISON__'
|
POISON = '__PIPELINE_POISON__'
|
||||||
|
|
||||||
DEFAULT_QUEUE_SIZE = 16
|
DEFAULT_QUEUE_SIZE = 16
|
||||||
|
|
||||||
|
|
||||||
def _invalidate_queue(q, val=None, sync=True):
|
def _invalidate_queue(q, val=None, sync=True):
|
||||||
"""Breaks a Queue such that it never blocks, always has size 1,
|
"""Breaks a Queue such that it never blocks, always has size 1,
|
||||||
and has no maximum size. get()ing from the queue returns `val`,
|
and has no maximum size. get()ing from the queue returns `val`,
|
||||||
@@ -50,8 +50,10 @@ def _invalidate_queue(q, val=None, sync=True):
|
|||||||
"""
|
"""
|
||||||
def _qsize(len=len):
|
def _qsize(len=len):
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
def _put(item):
|
def _put(item):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _get():
|
def _get():
|
||||||
return val
|
return val
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ def _invalidate_queue(q, val=None, sync=True):
|
|||||||
if sync:
|
if sync:
|
||||||
q.mutex.release()
|
q.mutex.release()
|
||||||
|
|
||||||
|
|
||||||
class CountedQueue(Queue.Queue):
|
class CountedQueue(Queue.Queue):
|
||||||
"""A queue that keeps track of the number of threads that are
|
"""A queue that keeps track of the number of threads that are
|
||||||
still feeding into it. The queue is poisoned when all threads are
|
still feeding into it. The queue is poisoned when all threads are
|
||||||
@@ -104,6 +107,7 @@ class CountedQueue(Queue.Queue):
|
|||||||
|
|
||||||
# Replacement _get invalidates when no items remain.
|
# Replacement _get invalidates when no items remain.
|
||||||
_old_get = self._get
|
_old_get = self._get
|
||||||
|
|
||||||
def _get():
|
def _get():
|
||||||
out = _old_get()
|
out = _old_get()
|
||||||
if not self.queue:
|
if not self.queue:
|
||||||
@@ -117,18 +121,67 @@ class CountedQueue(Queue.Queue):
|
|||||||
# No items. Invalidate immediately.
|
# No items. Invalidate immediately.
|
||||||
_invalidate_queue(self, POISON, False)
|
_invalidate_queue(self, POISON, False)
|
||||||
|
|
||||||
|
|
||||||
class MultiMessage(object):
|
class MultiMessage(object):
|
||||||
"""A message yielded by a pipeline stage encapsulating multiple
|
"""A message yielded by a pipeline stage encapsulating multiple
|
||||||
values to be sent to the next stage.
|
values to be sent to the next stage.
|
||||||
"""
|
"""
|
||||||
def __init__(self, messages):
|
def __init__(self, messages):
|
||||||
self.messages = messages
|
self.messages = messages
|
||||||
|
|
||||||
|
|
||||||
def multiple(messages):
|
def multiple(messages):
|
||||||
"""Yield multiple([message, ..]) from a pipeline stage to send
|
"""Yield multiple([message, ..]) from a pipeline stage to send
|
||||||
multiple values to the next pipeline stage.
|
multiple values to the next pipeline stage.
|
||||||
"""
|
"""
|
||||||
return MultiMessage(messages)
|
return MultiMessage(messages)
|
||||||
|
|
||||||
|
|
||||||
|
def stage(func):
|
||||||
|
"""Decorate a function to become a simple stage.
|
||||||
|
|
||||||
|
>>> @stage
|
||||||
|
... def add(n, i):
|
||||||
|
... return i + n
|
||||||
|
>>> pipe = Pipeline([
|
||||||
|
... iter([1, 2, 3]),
|
||||||
|
... add(2),
|
||||||
|
... ])
|
||||||
|
>>> list(pipe.pull())
|
||||||
|
[3, 4, 5]
|
||||||
|
"""
|
||||||
|
|
||||||
|
def coro(*args):
|
||||||
|
task = None
|
||||||
|
while True:
|
||||||
|
task = yield task
|
||||||
|
task = func(*(args + (task,)))
|
||||||
|
return coro
|
||||||
|
|
||||||
|
|
||||||
|
def mutator_stage(func):
|
||||||
|
"""Decorate a function that manipulates items in a coroutine to
|
||||||
|
become a simple stage.
|
||||||
|
|
||||||
|
>>> @mutator_stage
|
||||||
|
... def setkey(key, item):
|
||||||
|
... item[key] = True
|
||||||
|
>>> pipe = Pipeline([
|
||||||
|
... iter([{'x': False}, {'a': False}]),
|
||||||
|
... setkey('x'),
|
||||||
|
... ])
|
||||||
|
>>> list(pipe.pull())
|
||||||
|
[{'x': True}, {'a': False, 'x': True}]
|
||||||
|
"""
|
||||||
|
|
||||||
|
def coro(*args):
|
||||||
|
task = None
|
||||||
|
while True:
|
||||||
|
task = yield task
|
||||||
|
func(*(args + (task,)))
|
||||||
|
return coro
|
||||||
|
|
||||||
|
|
||||||
def _allmsgs(obj):
|
def _allmsgs(obj):
|
||||||
"""Returns a list of all the messages encapsulated in obj. If obj
|
"""Returns a list of all the messages encapsulated in obj. If obj
|
||||||
is a MultiMessage, returns its enclosed messages. If obj is BUBBLE,
|
is a MultiMessage, returns its enclosed messages. If obj is BUBBLE,
|
||||||
@@ -141,6 +194,7 @@ def _allmsgs(obj):
|
|||||||
else:
|
else:
|
||||||
return [obj]
|
return [obj]
|
||||||
|
|
||||||
|
|
||||||
class PipelineThread(Thread):
|
class PipelineThread(Thread):
|
||||||
"""Abstract base class for pipeline-stage threads."""
|
"""Abstract base class for pipeline-stage threads."""
|
||||||
def __init__(self, all_threads):
|
def __init__(self, all_threads):
|
||||||
@@ -169,6 +223,7 @@ class PipelineThread(Thread):
|
|||||||
for thread in self.all_threads:
|
for thread in self.all_threads:
|
||||||
thread.abort()
|
thread.abort()
|
||||||
|
|
||||||
|
|
||||||
class FirstPipelineThread(PipelineThread):
|
class FirstPipelineThread(PipelineThread):
|
||||||
"""The thread running the first stage in a parallel pipeline setup.
|
"""The thread running the first stage in a parallel pipeline setup.
|
||||||
The coroutine should just be a generator.
|
The coroutine should just be a generator.
|
||||||
@@ -209,6 +264,7 @@ class FirstPipelineThread(PipelineThread):
|
|||||||
# Generator finished; shut down the pipeline.
|
# Generator finished; shut down the pipeline.
|
||||||
self.out_queue.release()
|
self.out_queue.release()
|
||||||
|
|
||||||
|
|
||||||
class MiddlePipelineThread(PipelineThread):
|
class MiddlePipelineThread(PipelineThread):
|
||||||
"""A thread running any stage in the pipeline except the first or
|
"""A thread running any stage in the pipeline except the first or
|
||||||
last.
|
last.
|
||||||
@@ -256,6 +312,7 @@ class MiddlePipelineThread(PipelineThread):
|
|||||||
# Pipeline is shutting down normally.
|
# Pipeline is shutting down normally.
|
||||||
self.out_queue.release()
|
self.out_queue.release()
|
||||||
|
|
||||||
|
|
||||||
class LastPipelineThread(PipelineThread):
|
class LastPipelineThread(PipelineThread):
|
||||||
"""A thread running the last stage in a pipeline. The coroutine
|
"""A thread running the last stage in a pipeline. The coroutine
|
||||||
should yield nothing.
|
should yield nothing.
|
||||||
@@ -291,6 +348,7 @@ class LastPipelineThread(PipelineThread):
|
|||||||
self.abort_all(sys.exc_info())
|
self.abort_all(sys.exc_info())
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
class Pipeline(object):
|
class Pipeline(object):
|
||||||
"""Represents a staged pattern of work. Each stage in the pipeline
|
"""Represents a staged pattern of work. Each stage in the pipeline
|
||||||
is a coroutine that receives messages from the previous stage and
|
is a coroutine that receives messages from the previous stage and
|
||||||
@@ -322,7 +380,8 @@ class Pipeline(object):
|
|||||||
messages between the stages are stored in queues of the given
|
messages between the stages are stored in queues of the given
|
||||||
size.
|
size.
|
||||||
"""
|
"""
|
||||||
queues = [CountedQueue(queue_size) for i in range(len(self.stages)-1)]
|
queue_count = len(self.stages) - 1
|
||||||
|
queues = [CountedQueue(queue_size) for i in range(queue_count)]
|
||||||
threads = []
|
threads = []
|
||||||
|
|
||||||
# Set up first stage.
|
# Set up first stage.
|
||||||
@@ -330,7 +389,7 @@ class Pipeline(object):
|
|||||||
threads.append(FirstPipelineThread(coro, queues[0], threads))
|
threads.append(FirstPipelineThread(coro, queues[0], threads))
|
||||||
|
|
||||||
# Middle stages.
|
# Middle stages.
|
||||||
for i in range(1, len(self.stages)-1):
|
for i in range(1, queue_count):
|
||||||
for coro in self.stages[i]:
|
for coro in self.stages[i]:
|
||||||
threads.append(MiddlePipelineThread(
|
threads.append(MiddlePipelineThread(
|
||||||
coro, queues[i - 1], queues[i], threads
|
coro, queues[i - 1], queues[i], threads
|
||||||
@@ -408,17 +467,20 @@ if __name__ == '__main__':
|
|||||||
print('generating %i' % i)
|
print('generating %i' % i)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
yield i
|
yield i
|
||||||
|
|
||||||
def work():
|
def work():
|
||||||
num = yield
|
num = yield
|
||||||
while True:
|
while True:
|
||||||
print('processing %i' % num)
|
print('processing %i' % num)
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
num = yield num * 2
|
num = yield num * 2
|
||||||
|
|
||||||
def consume():
|
def consume():
|
||||||
while True:
|
while True:
|
||||||
num = yield
|
num = yield
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
print('received %i' % num)
|
print('received %i' % num)
|
||||||
|
|
||||||
ts_start = time.time()
|
ts_start = time.time()
|
||||||
Pipeline([produce(), work(), consume()]).run_sequential()
|
Pipeline([produce(), work(), consume()]).run_sequential()
|
||||||
ts_seq = time.time()
|
ts_seq = time.time()
|
||||||
@@ -437,6 +499,7 @@ if __name__ == '__main__':
|
|||||||
print('generating %i' % i)
|
print('generating %i' % i)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
yield i
|
yield i
|
||||||
|
|
||||||
def exc_work():
|
def exc_work():
|
||||||
num = yield
|
num = yield
|
||||||
while True:
|
while True:
|
||||||
@@ -445,10 +508,10 @@ if __name__ == '__main__':
|
|||||||
if num == 3:
|
if num == 3:
|
||||||
raise Exception()
|
raise Exception()
|
||||||
num = yield num * 2
|
num = yield num * 2
|
||||||
|
|
||||||
def exc_consume():
|
def exc_consume():
|
||||||
while True:
|
while True:
|
||||||
num = yield
|
num = yield
|
||||||
#if num == 4:
|
|
||||||
# raise Exception()
|
|
||||||
print('received %i' % num)
|
print('received %i' % num)
|
||||||
|
|
||||||
Pipeline([exc_produce(), exc_work(), exc_consume()]).run_parallel(1)
|
Pipeline([exc_produce(), exc_work(), exc_consume()]).run_parallel(1)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from beets import util
|
|||||||
|
|
||||||
Node = namedtuple('Node', ['files', 'dirs'])
|
Node = namedtuple('Node', ['files', 'dirs'])
|
||||||
|
|
||||||
|
|
||||||
def _insert(node, path, itemid):
|
def _insert(node, path, itemid):
|
||||||
"""Insert an item into a virtual filesystem node."""
|
"""Insert an item into a virtual filesystem node."""
|
||||||
if len(path) == 1:
|
if len(path) == 1:
|
||||||
@@ -33,6 +34,7 @@ def _insert(node, path, itemid):
|
|||||||
node.dirs[dirname] = Node({}, {})
|
node.dirs[dirname] = Node({}, {})
|
||||||
_insert(node.dirs[dirname], rest, itemid)
|
_insert(node.dirs[dirname], rest, itemid)
|
||||||
|
|
||||||
|
|
||||||
def libtree(lib):
|
def libtree(lib):
|
||||||
"""Generates a filesystem-like directory tree for the files
|
"""Generates a filesystem-like directory tree for the files
|
||||||
contained in `lib`. Filesystem nodes are (files, dirs) named
|
contained in `lib`. Filesystem nodes are (files, dirs) named
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user