Compare commits

...
417 Commits
Author SHA1 Message Date
AdeHub 62757c0fcf Merge pull request #3367 from confor/fix-deps
Add imghdr and cgi
2025-06-02 08:39:31 +12:00
AdeHub 63c992a4e5 Merge pull request #3363 from hypsometric/deluge_fix
Fix deluge torrent upload
2025-05-31 12:17:20 +12:00
AdeHub 63549b2471 fix weserv blocking 2025-05-31 12:05:49 +12:00
confor 3ff431da4b add cgi (py 3.13+ support)
sourced from youknowone/python-deadlib
2025-05-22 22:04:07 -04:00
confor 8ccc3cb146 add imghdr (py 3.13+ support)
this package was deprecated and removed in python 3.13+.

sourced from youknowone/python-deadlib
2025-05-22 21:56:10 -04:00
hypsometric 6764783aa7 Fix deluge torrent upload
Fix torrent file encoding for deluge
- received content is bytes
- we need to convert this content into base64
- we then need to convert base64 bytes into base64 string to include in
  the json data
binary
Fixes #3362
2025-04-17 12:33:43 +02:00
AdeHub 828fc0ccda Merge pull request #3359 from hypsometric/gazelle_improvements
Gazelle improvements
2025-04-12 15:28:44 +12:00
AdeHub 68a495127e Potential fix for #3355
Potential fix for #3355
2025-04-12 15:26:21 +12:00
Hypsometric 26a6095851 Make Gazelle torrents sorting clearer
Sort by format and most seeders if `search_formats` has data.
Sort only by most seeders else.
2025-03-19 17:20:46 +01:00
Hypsometric 95bd0a57ff Refactor common Orpheus & Redacted code
Orpheus & Redacted being based on Gazelle API, their code was the same
(with the exception of release type filtering).

Merging both will make maintenance easy and brings Redacted on par with
Orpheus regarding release type filtering.
2025-03-19 17:20:46 +01:00
Hypsometric 5efbd561d6 Allow the use of apikey with GazelleAPI & Redacted 2025-03-19 17:16:55 +01:00
AdeHub b690850019 torznab enclosure url 2024-12-07 19:58:57 +13:00
AdeHub a09e91ff8a cherrypy update
fixes #3348
2024-12-07 19:56:21 +13:00
AdeHub 94d62430a0 Ignore bs4 warning 2024-12-07 09:55:00 +13:00
AdeHub 2d5102d29c Remove unused directory 2024-08-24 17:02:22 +12:00
AdeHub 046d4d82b4 update Beets 2024-08-24 16:44:41 +12:00
AdeHub a63098a919 update urllib3 2024-08-24 16:42:31 +12:00
AdeHub 38d8e13e4e update Soupsieve 2024-08-24 16:36:55 +12:00
AdeHub b3edfa0d87 update requests_oauthlib 2024-08-24 16:33:52 +12:00
AdeHub aeebc67365 update requests 2024-08-24 16:25:17 +12:00
AdeHub d6f22a2326 Update Beautiful Soup 2024-08-24 16:18:38 +12:00
AdeHub 5b1d9c0750 update mutagen 2024-08-24 16:00:16 +12:00
AdeHub ba666f68b8 tpb tidy up 2024-08-16 12:44:17 +12:00
AdeHub 000509353a qBitTorrent Seed Ratio fix
- added missing api param
2024-08-11 07:53:58 +12:00
AdeHub c264c75282 Removed Waffles
😧
2024-08-10 11:34:30 +12:00
AdeHub 4c15b33c58 TPB fixes
- changed default to use apibay.org

- removed 'Old Pirate Bay'
2024-08-10 11:20:23 +12:00
AdeHub 167b5817e7 forcePostProcess more encoding
- encode all download dirs so subsequent decode will work
2024-07-23 21:11:05 +12:00
AdeHub 46c7582fb1 Merge pull request #3342 from dsm1212/issue3341
decode paths before using them to fix manual post process
2024-07-23 20:17:10 +12:00
dsm1212 7b9e32f525 fix duplicate file rename 2024-07-14 20:55:05 -04:00
AdeHub 37c4e19663 Torznab changes
-Search using parent category 3000

some providers lump everything in to the parent Music Category 3000 instead of the Sub Category (e.g. 3040 lossless).

search using 3000 then filter with Category (if returned) or Headphones filtering

overall this should return more results across indexers

-Allow Prowlarr indexers to be manually entered

-Searcher fixups
2024-07-08 21:18:19 -04:00
AdeHub e5beb5291d Soulseek tweaks # 2
push results through filters
2024-07-08 21:18:19 -04:00
AdeHub ebe8a60ca5 Soulseek tweaks
-include soulseek results in overall search results

-fixed windows paths not quite working on macOS

-allow user search term

-tighten search for self titled, Various Artists

-postprocess by user/folder

-bit more logging
2024-07-08 21:18:19 -04:00
AdeHub 040de81d3c Torznab changes
-Search using parent category 3000

some providers lump everything in to the parent Music Category 3000 instead of the Sub Category (e.g. 3040 lossless).

search using 3000 then filter with Category (if returned) or Headphones filtering

overall this should return more results across indexers

-Allow Prowlarr indexers to be manually entered

-Searcher fixups
2024-07-05 19:26:30 +12:00
Steve McAfee 1bf8c35154 decode paths before using them to fix manual post process 2024-07-03 21:22:55 -04:00
AdeHub dae4d80490 Soulseek tweaks # 2
push results through filters
2024-06-19 20:33:40 +12:00
AdeHub acf73368c9 Soulseek tweaks
-include soulseek results in overall search results

-fixed windows paths not quite working on macOS

-allow user search term

-tighten search for self titled, Various Artists

-postprocess by user/folder

-bit more logging
2024-06-16 20:01:35 +12:00
rembo10 abac9b5a15 Merge branch 'develop' 2024-05-26 21:12:22 +05:30
rembo10 ace2b4f26a Update changelog for v0.6.3 2024-05-26 21:12:09 +05:30
rembo10 ebba115443 Fix for searcher not returning results 2024-05-26 19:58:45 +05:30
rembo10 b54218abbd Merge branch 'develop' 2024-05-26 09:52:28 +05:30
rembo10 3e354ff128 Update changelog for v0.6.2 2024-05-26 09:52:17 +05:30
rembo10 b8168ec8eb add slskd_api @ v0.1.5 2024-05-26 09:47:43 +05:30
rembo10 c0c636d545 Merge remote-tracking branch 'el133/slskd-python-api_support' into develop 2024-05-26 09:44:54 +05:30
rembo10 33d1d17c97 Merge remote-tracking branch 'nopoz/deluge' into develop 2024-05-26 09:42:23 +05:30
rembo10 8fd13621f5 Fix some invalid escape sequences in helpers.py 2024-05-26 09:37:05 +05:30
rembo10 56674adfea Merge branch 'python312' into develop 2024-05-26 09:31:16 +05:30
Elmer 489c6cbe71 Basic soulseek support 2024-02-28 12:53:55 +01:00
AdeHub 6afe31bffc bandcamp tweaks 2024-01-26 21:11:29 +13:00
rembo10 742529a92d munkres: 1.0.6 -> 1.1.4 2024-01-18 16:12:15 +05:30
rembo10 944d066903 Fix regex warnings in postprocessor.py 2024-01-18 16:10:33 +05:30
rembo10 87819a3c74 Remove redundant bencode import from searcher.py 2024-01-18 16:09:22 +05:30
rembo10 c9fbe29c90 Fix regex warnings in deluge.py 2024-01-18 16:06:51 +05:30
rembo10 d78cb7d14e Import mediafile from bandcamp.py 2024-01-18 16:04:42 +05:30
rembo10 e8c392824f Fix regex warnings in pygazelle 2024-01-18 16:02:59 +05:30
rembo10 9811df2779 Slightly clean up searcher.py, fix regex warnings 2024-01-18 16:01:26 +05:30
rembo10 1a4865ed38 Remove unused functions (nameQuality, statusFromName) from common.py 2024-01-18 15:17:37 +05:30
rembo10 a06fb40f50 tzlocal: 1.1.2 -> 5.2 2024-01-18 14:59:28 +05:30
rembo10 ad6a4f570e use pkg_resources from setuptools-69.0.3 2024-01-18 14:56:54 +05:30
rembo10 3685d32a7d cherrypy: 18.8.0 -> 6387a2b 2024-01-18 14:55:28 +05:30
rembo10 152f5daa8c Sync up master and develop branches 2023-11-27 13:40:34 +05:30
Mikaël Hautinandrembo10 39054a04df Update parsing logic for updated TPB proxy html structure (#3318)
* fix: Update parsing logic for updated TPB proxy html structure

---------

Co-authored-by: rembo10 <rembo10@users.noreply.github.com>
2023-11-27 10:04:10 +05:30
kirill9617 1c4b9c10f0 Fix for 503 error from CloudFlare. (#3316)
CloudFlare seems to block request without User-Agent header. This fix should be enough to bypass that.
2023-11-26 15:15:36 +05:30
Menno Blom 73ca787cf1 Bandcamp support (#3252)
* Add configuration options for bandcamp

* Add bandcamp config options

* Add bandcamp config options

* Initial crude bandcamp search and download support

* Add bandcamp search support

* Better utf-8 handling and tagging of the downloads

* Post-process bandcamp dodwnload directory

* Tweak the order of downloads (prevent querying bandcamp too often)

* Show [bandcamp] link in the history page

* pep8

* Use more sane loglevels

* Oops.

* Patch regexp to support new bandcamp page structure

* Make sure the file-key exists, is not None and contains data
2023-11-26 15:13:13 +05:30
rembo10 c7bc852868 Update changelog for v0.6.1 2023-11-26 14:30:29 +05:30
rembo10 391b0cc465 Merge branch 'develop' 2023-11-26 14:27:56 +05:30
rembo10 4aaeaa704f apscheduler: 3.0.1 -> 3.10.1 2023-06-09 07:26:41 +05:30
rembo10 4d14b028ff update cherrypy to v18.8.0 2023-04-20 15:06:17 +05:30
rembo10 a78f38c174 Update changelog for v0.6.0 2022-11-13 09:39:20 +05:30
rembo10 14f2a6d22c Merge branch 'develop' 2022-11-13 09:34:39 +05:30
rembo10 2e4299efa7 Merge branch 'lastfm-api-key' into develop 2022-11-13 09:31:24 +05:30
rembo10 0610c2fa93 Fix formatting of Last.fm in log messages 2022-11-12 16:45:43 +05:30
rembo10 9add571886 Re-enable Last.fm similar tag cloud 2022-11-12 16:44:01 +05:30
rembo10 fcf59a9b38 add an option for a personal last.fm api key 2022-11-12 15:48:31 +05:30
AdeHub 74f9e91afc Various
xld - use native plistlib instead of biplist

rutracker - use new Result object

seacher - small tweaks

transmission - str no longer required
2022-03-05 19:08:12 +13:00
rembo10 83398cb102 metadata: Fix typo in DISC_TOTAL 2022-02-18 15:04:42 +05:30
rembo10 61c2e1f821 Add option to ignore disc# for single disc albums (#3297) 2022-02-18 11:59:15 +05:30
rembo10 3e3047aef2 Use str(e) instead of e.message in searcher.py 2022-02-15 15:11:27 +05:30
rembo10 fff44e4631 Various fixes from last commit, fixes an issue where the hash couldn't be calculated from the torrent data 2022-02-15 15:06:26 +05:30
rembo10 0964371de8 Require python 3.7+ for dataclasses 2022-02-15 14:28:44 +05:30
rembo10 654f923a8d A little cleanup in searcher.py 2022-02-15 14:14:06 +05:30
rembo10 b91206c64a Remove tests from libs 2022-02-14 13:43:44 +05:30
rembo10 c9ba59ee9a Add zipp lib 2022-02-14 13:42:40 +05:30
rembo10 b7e35d5ff0 Decode the b64 data to utf-8 for nzbget (#3294) 2022-02-14 10:14:44 +05:30
rembo10 9d82143abe Remove errant print statements 2022-02-14 08:51:39 +05:30
rembo10 eaf2db6c59 Open blackhole nzb file as binary 2022-02-14 08:12:47 +05:30
rembo10 586b9ed3c8 Add importlib_resources to lib 2022-02-14 07:45:09 +05:30
rembo10 d89f4171da Disable interpolation in ConfigParser 2022-02-13 10:13:47 +05:30
rembo10 9f7be5348b Prevent accessing error.message in db.py action 2022-02-11 10:42:11 +05:30
rembo10 9c254ff222 Fix for trying to access e.message in findArtist 2022-02-11 10:34:44 +05:30
rembo10 ba969fd3b8 Fix for sending invalid dates to helpers.age 2022-02-10 07:37:49 +05:30
rembo10 c851d5ed1a Remove ordereddict 2022-02-09 21:24:20 +05:30
rembo10 2223928958 Fix sort by have in web ui, a little import cleanup in webserve.py 2022-02-09 21:10:14 +05:30
rembo10 164c3cacbc Disable last.fm getSimilar in importer 2022-02-09 17:51:12 +05:30
rembo10 16d4ac8895 Fixed some imports in helpers.py 2022-02-09 02:48:50 +05:30
rembo10 f4d60226b3 Disable last.fm getSimilar 2022-02-08 19:37:33 +05:30
rembo10 9ca87e23b2 A less insane library scan interval 2022-02-08 19:27:44 +05:30
rembo10 d934c865c6 Fix for adding albums with no release date 2022-02-08 18:41:24 +05:30
rembo10 de74cd2502 Fix typo in librarysync: track['Location'] 2022-02-08 16:24:11 +05:30
rembo10 f41db714a9 Remove any quotes from str/path values in the config 2022-02-08 16:14:06 +05:30
rembo10 f03b82e5f6 Update mutagen to 1.45.1 2022-02-07 09:30:17 +05:30
rembo10 e2db680b9e Catch NoneType in helpers.py/multikeysort 2022-02-07 08:07:18 +05:30
rembo10 a3db89c11d Update minimum version 2022-02-04 07:42:57 +05:30
rembo10 517d0eb327 Merge branch 'develop' 2022-02-01 20:11:24 +05:30
rembo10 2bacd5a0fc Merge branch 'https' into develop 2022-01-23 14:23:42 +05:30
rembo10 5a559c526d http -> https for album art with small refactor 2022-01-23 14:23:27 +05:30
rembo10 095cee9368 http -> https for internet calls 2022-01-23 14:08:36 +05:30
rembo10 79cb133d1d Remove non-ascii chars in nzb folder name 2022-01-22 08:34:47 +05:30
rembo10 3a9b749017 Merge branch 'develop' 2022-01-22 08:11:01 +05:30
rembo10 0182be2f27 Merge pull request #3284 from hypsometric/py3
Fixes for Python 3.10
2022-01-22 08:01:58 +05:30
hypsometric b6388f7daa Fixes for Python 3.10
- deluge: b64encode returns bytes, transform into string before
  serializing to JSON.
- deluge: no more need to try different encodings for the torrent
  filename or the base64-encoded content, should all be unicode strings
  with Python 3
- collections.abc: Iterable, Mapping, MutableMapping, MutableSequence
  moved from collections to collections.abc since Python 3.3
- pygazelle: html.parser.HTMLParser().unescape() moved to
  html.unescape() in Python 3.4
2022-01-19 18:21:16 +01:00
rembo10 0b3d0242fc headphones/__init__.py: file() -> open() 2022-01-15 21:47:45 +05:30
rembo10 9551e1b04a Update pytz lib 2022-01-15 21:46:34 +05:30
rembo10 ab4dd18be4 Initial python3 changes
Mostly just updating libraries, removing string encoding/decoding,
fixing some edge cases. No new functionality was added in this
commit.
2022-01-14 10:38:06 +05:30
rembo10 b3199605be v0.5.20 2021-10-15 09:56:59 +05:30
rembo10 9a7006ad14 Update changelog for v0.5.20 release 2021-10-15 09:56:49 +05:30
rembo10 38197a5871 Merge branch 'ssl-fix' into develop 2021-10-15 08:14:19 +05:30
rembo10 ee58f62b2b Update root certificates for the requests lib 2021-10-15 08:13:49 +05:30
AdeHub 58edc604b3 Merge branch 'develop' 2021-07-08 19:57:08 +12:00
AdeHub ba33a5e655 last.fm api key 2021-07-08 19:52:46 +12:00
AdeHub 379fd3d0b8 Merge branch 'develop' 2020-10-17 11:06:38 +13:00
AdeHub 374b106522 last.fm api
last.fm api
2020-10-17 11:02:00 +13:00
AdeHub bf74f57535 Merge branch 'develop' 2020-05-28 10:49:51 +12:00
AdeHub f1a03e5fb6 pep 2020-04-15 07:22:31 +12:00
AdeHub 50cbe2e943 qBit logging 2020-04-15 07:10:51 +12:00
AdeHub 219f855dfc qBittorrent v2 api 2020-04-13 09:50:40 +12:00
AdeHub f18334d87c Merge branch 'develop' 2020-03-07 10:30:57 +13:00
AdeHub 72fce3cdeb last.fm api key
new key
2020-03-07 10:25:45 +13:00
AdeHub 5283b48736 Merge branch 'develop' 2019-09-08 17:38:17 +12:00
AdeHub 507d8aa7d2 Telegram 2019-09-08 17:27:51 +12:00
AdeHub 4bce833c1e pep 2019-08-17 11:48:50 +12:00
AdeHub f7a23bb4d0 Fix/Enhance Telegram 2019-08-17 11:36:55 +12:00
AdeHub 5d145b6f8a revert to older requests version 2019-07-15 13:54:34 +12:00
AdeHub 3ab31f1da7 urllib3
requests supported version
2019-07-14 21:02:40 +12:00
AdeHub cf85d94a37 requests packages 2019-07-14 20:46:19 +12:00
AdeHub a8a53353ee pep 2019-07-14 20:13:00 +12:00
AdeHub 476ee7b312 mb artist relationships
Get Artist Relationship urls including Discogs, Wikepedia etc.

Currently not used
2019-07-14 19:59:48 +12:00
AdeHub 14bef1e63d Use fanart.tv for artist image
last.fm no longer allows access to artist images. Get artist image from fanart.tv  instead
2019-07-14 19:57:40 +12:00
doucheymcdoucherson 138d01db4a deluge udpate 2019-06-15 00:03:03 -07:00
Ade dc22bb006d Hotfix index creation from develop
Fixes #3175
2019-01-08 21:09:41 +13:00
Ade b49cd8a813 Attempt to fix Index creation error 2019-01-06 16:52:39 +13:00
Ade d7275e5a99 Update Requests lib 2019-01-06 12:12:32 +13:00
Ade aa1867cf06 More Jackett redirect stuff 2019-01-01 12:45:59 +13:00
Ade 19b766a7ce Jackett redirect update
Moved the redirect for Transmission, Deluge
2018-12-31 10:22:37 +13:00
Ade b45f3565da Orpheus.network issues 2018-12-21 18:51:48 +13:00
Ade 22525668fd Remove Mininova 2018-12-21 18:21:33 +13:00
Ade af79bc47e8 Escape sql artist string 2018-12-19 20:25:49 +13:00
AdeHub 6208fe0e69 Merge pull request #3165 from hypsometric/gazelle_freeleech_tokens
Redacted: use a FL token when available
2018-12-14 08:38:25 +13:00
Hypsometric f66a37abbd Redacted: use a FL token when available
- Use a FL token when available
- Added a configuration (default off) to enable the feature
- Skip prefetching the data with Deluge as with Transmission in
`preprocess()`

- Note: because QBittorrent & uTorrent need the data to compute the
torrenthash but still require the url to download the file, it will
result in 2 FL tokens used instead of one because the .torrent will be
downloaded twice from the server.

Fixes #3163
2018-12-13 16:41:22 +01:00
AdeHub 4b52ae445d Merge pull request #3160 from doucheymcdoucherson/develop1
Replace apollo.rip with orpheus.network
2018-12-08 17:52:11 +13:00
doucheymcdoucherson 8a2c3169e2 fix whitespace on blank line 2018-11-08 12:12:03 -08:00
doucheymcdoucherson 805eb7dd13 tabs to spaces 2018-11-08 12:08:41 -08:00
doucheymcdoucherson 81486d2cd2 fix for seeder sort with Orpheus 2018-11-08 11:41:33 -08:00
doucheymcdoucherson d1237b052a replace apollo.rip with orpheus.network 2018-11-08 09:58:09 -08:00
AdeHub a4ef757d12 Merge pull request #3144 from cheese1/develop
fix typo
2018-10-14 17:15:03 +13:00
AdeHub bb8eb19dbf Merge pull request #3151 from permster/develop
Workaround for issue #2674
2018-10-14 17:14:33 +13:00
Ade 602e115a29 Disable torznab tests 2018-10-14 15:28:39 +13:00
Ade e2c335db2d more pep 2018-10-13 18:22:54 +13:00
Ade 44583d42dd pep 2018-10-13 18:18:01 +13:00
Ade 43d465ca22 OSX notify import fix 2018-10-13 18:13:53 +13:00
Ade bd60935493 Torrent folder fix 2018-10-13 12:17:58 +13:00
Travis Golliher 0264b1fb0d Workaround for issue #2674 2018-10-06 02:46:13 -07:00
Ade 24f22834ff Torznab/Jackett Seed Ratio 2018-09-07 18:41:47 +12:00
cheese1 aed2e63984 fix missing space 2018-09-03 11:31:39 +02:00
cheese1 694c17a40b fix typo 2018-09-02 20:38:54 +02:00
Ade 6c34bc46e9 Fix Ignore Undated MB Releases not saving 2018-08-25 17:39:30 +12:00
Ade 0b2600b783 Fix helpers.replace_all
- Renamed post processing replace_all to pattern_substitute
- Resurrected replace_all
2018-08-17 18:43:43 +12:00
Ade 693e5b1624 Improve database locking issues
- Added new indexes
- Retry if locked
- Fix upsert race condition
- Various changes to track matching
2018-08-15 14:51:27 +12:00
Ade 287278c049 Merge branch 'develop' of https://github.com/rembo10/headphones into develop 2018-08-14 20:08:59 +12:00
AdeHub 496560909f Merge pull request #3128 from doucheymcdoucherson/develop1
fixes #3110
2018-08-14 20:05:39 +12:00
AdeHub 22b1bf36ab Merge pull request #3124 from doucheymcdoucherson/develop
sort Apollo.rip results by number of seeders
2018-08-14 20:04:40 +12:00
Ade b506a1797d Merge branch 'master' of https://github.com/rembo10/headphones into develop 2018-07-12 12:51:20 +12:00
Ade ff8417b410 Merge branch 'master' into develop 2018-07-12 12:45:19 +12:00
doucheymcdoucherson 741cb466ef fixes #3110 2018-07-06 14:09:12 -07:00
doucheymcdoucherson a99a6abde2 sort Apollo.rip results by number of seeders 2018-07-03 11:46:51 -07:00
Ade 4631b3cbbf Plex update fix 2018-06-14 10:53:24 +12:00
Ade 7230428a31 Speed up albumPage 2018-06-10 10:37:59 +12:00
Ade 2b5b2bd506 rutracker hires
get more lossless results
2018-06-06 18:56:18 +12:00
Ade 99c3e8498d More Jackett magnet 2018-06-03 15:22:16 +12:00
Ade 472f657c88 Jackett magnet links 2018-05-28 20:58:27 +12:00
Ade 368e30067b Remove t411, kick ass 2018-05-10 10:33:52 +12:00
Ade 053fd40e76 pep 2018-04-25 21:03:16 +12:00
Ade 7af7f6be1e Remove Kick Ass 2018-04-25 12:05:21 +12:00
Ade 5fd20e50fd Remove Strike Search 2018-04-25 11:55:14 +12:00
Ade 43df2956fb Remove t411 pt2 2018-04-25 11:48:54 +12:00
Ade 0c96997005 Remove t411 2018-04-25 11:33:39 +12:00
Ade 81651b119a Stop Metacritic 404
Fixes #2715
2018-04-24 20:14:11 +12:00
Ade ecad4d39eb Get Original Folder from Temp directory
Fixes #3109
2018-04-21 11:02:21 +12:00
Ade 0d3be4866f Email date
Fixes #2703
2018-04-14 13:59:18 +12:00
AdeHub eeed89811b Merge pull request #3104 from mirwan/torznab_size_fix
Items from torznab are not returned when size is defined as torznab:attr
2018-04-09 19:58:17 +12:00
Erwan Miran d9bff5dffe size may be set as torznab:attr 2018-04-08 23:43:42 +02:00
rembo10 3b48f22bd9 v0.5.19 2018-03-27 10:14:08 -05:00
rembo10 79d508fb07 Merge branch 'develop' 2018-03-27 10:13:51 -05:00
rembo10 ae8fadd587 Updated changelog for release 2018-03-27 10:13:16 -05:00
Ade 5d7fdc7a82 Speed up Choose Alternate Release
Possible fix for #3078
2018-03-06 16:48:19 +13:00
AdeHub 1ded166765 Merge pull request #3082 from YipYup/master
Add Feature: Ignore Releases Missing a Release Date
2018-03-03 22:07:57 +01:00
Ade 22eb21a4ef Jackett seeders
Use Soup to get seeders returned from Jackett

Fixes #3081
2018-03-03 20:02:14 +13:00
Get your own 'tots. Geez! 4283b381b6 Fix indentation. 2018-02-22 19:17:23 -07:00
Get your own 'tots. Geez! 2cdc6669da Merge branch 'develop' into master 2018-02-22 19:02:29 -07:00
Ade e3983d615d Merge branches 'develop' and 'master' of https://github.com/rembo10/headphones 2017-10-22 09:51:39 +13:00
AdeHub de8fac347d Merge pull request #3037 from Lartza/fix-win-encoding
Fix SYS_ENCODING selection on Windows
2017-10-22 09:49:48 +13:00
Ade da9287d7cf Merge branch 'develop' 2017-10-22 09:45:15 +13:00
Ade f9d73da31b Remove unidecode for windows file names 2017-10-22 09:43:55 +13:00
Lartza 3cc280666e Fix encoding selection on Windows 2017-10-18 22:44:37 +03:00
Ade 0828643c09 Replace windows file/folder problem characters 2017-09-26 11:06:34 +13:00
AdeHub 9b3d7f3bbb Merge pull request #3023 from noam09/develop
Deluge encoding issues with unicode torrent names
2017-09-22 08:54:58 +12:00
Noam 68c551c828 Try DEcoding torrent name utf-8 in params 2017-09-17 02:40:09 +03:00
Noam b7af246340 Try encoding torrent name utf-8 in params 2017-09-17 02:10:30 +03:00
Noam 4c3b60be68 UnicodeDecodeError printing torrent name containing unicode 2017-09-17 01:40:04 +03:00
AdeHub 539db608e2 Merge pull request #3018 from noam09/develop
Added UTF-8 header for encoding issues
2017-09-14 20:01:28 +12:00
Noam 0b6f263985 Added UTF-8 header for encoding issues 2017-09-11 19:38:01 +03:00
AdeHub a10ec8dce4 Merge pull request #3016 from noam09/develop
Deluge WebUI daemons - fix #3010
2017-09-10 12:08:22 +12:00
Noam 867f75a681 Check _if_ WebUI daemons exist before checking _how many_ exist, fix #3010 2017-09-10 02:15:36 +03:00
Ade c8057676db pep 2017-09-07 22:48:54 +12:00
Ade 9197f27a50 Jackett provider formatting 2017-09-07 22:26:30 +12:00
Ade e7062a6710 Jackett/Torznab v2 api
Fixes #2999
2017-09-02 19:14:37 +12:00
Ade 9c2bfcf7d8 Scan Artist exception handling 2017-08-28 20:54:19 +12:00
Ade 4d575787cd last.fm import fix
Fixes #2753
2017-08-24 20:48:15 +12:00
Ade bdf55f8662 replace torcache 2017-08-19 08:42:04 +12:00
Ade cd57668b92 allow .aif 2017-08-15 17:25:37 +12:00
Ade f20520541d Merge branch 'develop' 2017-08-09 12:52:43 +12:00
Ade 48dd832d4e pep 2017-08-09 12:52:11 +12:00
Ade af10088dc8 Merge branch 'develop' 2017-08-09 12:20:53 +12:00
AdeHub cf9737e804 Merge pull request #2958 from Fish2/master
lossless compression of images saved 93KB
2017-07-28 19:05:56 +12:00
AdeHub 4f118eaaef Merge pull request #2957 from permster/develop
Join notification support
2017-07-27 20:41:30 +12:00
AdeHub 79277ddc33 Merge pull request #2969 from doucheymcdoucherson/develop
Make apollo.rip searches specific to the album type
2017-07-27 20:38:27 +12:00
AdeHub 3ee2598f4c Merge pull request #2989 from djl/sort-wanted
Sorted wanted albums by release date.
2017-07-27 20:07:32 +12:00
AdeHub 1e1b8a9351 Merge pull request #2992 from djl/fix-typeerror
Fix TypeError in dbcheck()
2017-07-27 20:06:38 +12:00
David Logie 0cc2dd7b31 Fix TypeError in dbcheck().
Fixes #2987
2017-07-26 12:17:31 +01:00
David Logie 80fe81e345 Sorted wanted albums by release date.
Without this SQLite will order the results by whichever column happens
to be the fastest for it. This will usually result in the rows being
sorted by the primary key (or rowid).

Sorting by release date also matches the sort order of the "upcoming"
albums list.
2017-07-24 12:54:42 +01:00
Ade 0c98085018 Merge branch 'develop' 2017-07-03 11:44:45 +12:00
Ade e96a6fc7c8 Transmission magnet issue
Fixes #2972
2017-06-24 09:27:51 +12:00
Ade ee7254ddbe Version check logging 2017-06-24 09:12:17 +12:00
Ade 3e44baaded Boxcar icon url 2017-06-17 21:36:17 +12:00
doucheymcdoucherson 914ab65577 Make apollo.rip searches specific to the album type 2017-06-16 22:52:47 -07:00
Ade 4d674689b1 rutracker search term fix 2017-06-16 13:02:13 +12:00
Fish2 ae328a15aa lossless compression of images saved 93KB 2017-06-04 11:41:07 +01:00
Travis Golliher c612a228b6 Fix pep8 error 2017-05-31 21:59:48 -07:00
Travis Golliher 334493d81e Join notification support
Fix pep8 errors.
2017-05-31 21:40:01 -07:00
Travis Golliher 50de38ce82 Join notification support
Initial Join by Joaoapps API Notification Support.  See issue #2712.
2017-05-31 21:04:30 -07:00
AdeHub 36434b57ee Merge pull request #2952 from noam09/develop
rutracker optional session cookie
2017-05-27 18:49:45 +12:00
Noam 3110659633 travis space 2017-05-18 18:28:01 +03:00
Noam ecadf8cdab log cookie attempt 2017-05-18 18:26:16 +03:00
Noam 6c0b451882 rutracker optional session cookie 2017-05-18 17:02:53 +03:00
AdeHub 755944f15c Merge pull request #2938 from Kallys/develop
Remove Deezloader integration
2017-05-03 17:34:14 +12:00
Kallys 0acb5a413a Revert "Integration of deezloader provider by ParadoxalManiak under CC BY-NC-SA 4.0"
This reverts commit 04e8767ea9.
2017-05-02 13:41:51 +02:00
Kallys e3c7790039 Revert "Fix #2930"
This reverts commit cd2860f4e3.
2017-05-02 13:41:29 +02:00
AdeHub d802948a35 Merge pull request #2934 from pratstercs/develop
Bug (#2933) - Can't remove artist with name "null"
2017-05-02 18:38:17 +12:00
AdeHub b2f5919b26 Merge pull request #2932 from Kallys/master
Aria2 & deezloader bugfixes
2017-05-02 18:37:26 +12:00
pratstercs 74d5333bdf Fixing tabs/spaces 2017-05-01 00:35:28 +01:00
pratstercs 54edacdfe7 Fixed bug (#2933 at least) where an artist will null name errors when trying to remove from library as logger is trying to concat artist name (NoneType) with string 2017-05-01 00:29:39 +01:00
Kallys cd2860f4e3 Fix #2930
Fix #2931
2017-04-30 14:00:10 +02:00
Ade bc0ce99adf Set clean name to lower 2017-04-29 18:05:10 +12:00
AdeHub 3bfdc9f069 Merge pull request #2925 from Kallys/master
Add deezloader provider and aria2 downloader (part 2)
2017-04-25 21:01:15 +12:00
Ade 39e589bc08 Use Release Id for beets matching if supplied 2017-04-25 18:16:33 +12:00
Ade cef1563b4b Trap errors for Scan 2017-04-25 17:03:59 +12:00
Kallys 04e8767ea9 Integration of deezloader provider by ParadoxalManiak under CC BY-NC-SA 4.0
Integration of aria2 downloader by Xyne under GPLv2

I'm not responsible of any kind for the usage of this programs by other people.
These integrations come with no warranty.
Please refer to your local country laws and respect Deezer's terms of service.
2017-04-24 14:32:51 +02:00
Ade 2df13ad823 bit more beets logging 2017-04-24 14:16:08 +12:00
Ade 18d85d12dd pep 2017-04-23 20:26:04 +12:00
Ade 1e3cbb64ca beets recommendation logging 2017-04-23 20:08:28 +12:00
Ade d6e1a16286 Pass full mb unicode names to beets
Possibly fixes https://github.com/rembo10/headphones/issues/2919
2017-04-22 11:54:18 +12:00
AdeHub 979b06ae55 Merge pull request #2920 from rembo10/revert-2918-master
Revert "Add deezloader provider and aria2 downloader"
2017-04-21 19:46:23 +12:00
AdeHub 7fec54289b Revert "Add deezloader provider and aria2 downloader" 2017-04-21 19:45:37 +12:00
AdeHub 49d632a156 Merge pull request #2918 from Kallys/master
Add deezloader provider and aria2 downloader
2017-04-21 19:35:26 +12:00
Kallys a2a14e185a Merge remote-tracking branch 'upstream/develop' 2017-04-20 14:32:27 +02:00
Ade 50851a4953 pep8 2017-04-20 20:42:04 +12:00
Ade 61a3abbf20 last.fm series info 2017-04-20 20:10:34 +12:00
Kallys e8f436a5db Fix travis errors bis 2017-04-19 20:01:45 +02:00
Kallys 34b8a11145 Fix travis errors 2017-04-19 19:55:41 +02:00
Kallys f0fe23899a Add deezloader provider
Add aria2 downloader
2017-04-19 18:59:27 +02:00
Ade 4eabc1c614 opus format
fixes https://github.com/rembo10/headphones/issues/2909
2017-04-14 08:47:09 +12:00
Ade b9d41ad779 Merge branch 'master' into develop 2017-04-14 08:40:15 +12:00
Ade 8e1cd3155f redacted config fix
Fixes https://github.com/rembo10/headphones/issues/2910
2017-04-14 08:40:04 +12:00
Ade 624a3d6cc1 Merge branch 'master' into develop 2017-04-14 08:39:06 +12:00
Ade a042fd44a2 Merge branch 'develop' 2017-04-14 08:24:10 +12:00
Ade 2e5ce520eb pp fix up
Fixes https://github.com/rembo10/headphones/issues/2911
2017-04-14 08:23:11 +12:00
Ade e9407f5dbc Merge branch 'develop' 2017-04-08 12:01:20 +12:00
Ade 8eb79701a6 t411, pb url change 2017-04-08 11:58:14 +12:00
Ade 4c95f99d80 Transmission pp folder name
Get folder name from hash
2017-04-08 11:51:40 +12:00
Ade e1e361a46e Merge branch 'develop' 2017-04-06 10:11:01 +12:00
Ade 437d33e7a8 Qbittorrent folder name 2017-04-05 21:38:59 +12:00
Ade 2832166842 pep 2017-04-04 22:34:46 +12:00
Ade 2841f36d8d artwork fix
fixes #2898
2017-04-04 22:28:26 +12:00
Ade de15f6b632 Qbittorrent
- Postprocessing get folder from hash
- Postprocessing allow for single file
- Rutracker fix up
2017-04-04 20:24:26 +12:00
AdeHub 5bb6ec04a9 Merge pull request #2892 from DJ-666/develop
Rename PTH to Redacted and fix invalid User-Agent (Again)
2017-04-01 16:22:11 +13:00
DJ-666 1da1f5124a Rename PTH to Redacted and fix invalid User-Agent (Again)
This is a duplicate of albshin's PR as I got impatient waiting for him
to submit it to the develop branch with the addition of removing the
previous user agents and replacing them with Headphones as the agent.
Redacted considers falsifying the agent as a bannable offence.

From albshin's PR;
"Changes all instances of PassTheHeadphones to Redacted. Removed the URL
option for Redacted as it's unnecessary(?).

Fixed a bug in the pyGazelle library in which the User-Agent was invalid
due to a missing space. Should work now for Redacted."
2017-03-31 23:36:34 +10:30
Ade f8b82337a2 error msg tidy up 2017-03-27 12:51:47 +13:00
AdeHub 3efd3931f5 Merge pull request #2887 from noam09/develop
Fix title markdown
2017-03-27 09:35:29 +13:00
Noam 95402144d1 Merge branch 'develop' of github.com:noam09/headphones into develop 2017-03-26 12:36:20 +03:00
Noam 220a8931d1 Fix title markdown
Missing space after ##
2017-03-26 12:34:00 +03:00
Ade a96b186825 Add deluge, qbittorrent to scheduled torrent removal 2017-03-26 11:36:38 +13:00
Ade de3b73dfa5 Move to 2.7 2017-03-25 23:17:41 +13:00
Ade 263564f15f pep8 2017-03-25 23:05:37 +13:00
Ade a5ca14e6d6 Waffles is back!
Ignore 0 seeders for now

Fixes https://github.com/rembo10/headphones/issues/2884
2017-03-25 14:38:23 +13:00
Ade 861747c235 qbittorrent folder name
Fixes #2869
2017-03-25 13:22:24 +13:00
Ade 4095bf213e mutagen, musicbrainzngs, beets lib updates 2017-03-25 11:47:04 +13:00
Ade f3020207c0 Artwork pp
Post processing Min/Max artwork size
2017-03-20 10:18:09 +13:00
Ade 1cee19be74 Merge branch 'develop' of https://github.com/rembo10/headphones into develop 2017-03-16 07:55:47 +13:00
Ade f877caff06 Latest artwork changes not working so well
Revisit later
2017-03-16 07:55:27 +13:00
AdeHub 9c0297326e Merge pull request #2875 from noam09/develop
More exception handling for Deluge torrent removal
2017-03-15 07:14:27 +13:00
Noam 1ed12770c2 Merge branch 'develop' of https://github.com/rembo10/headphones into develop 2017-03-14 15:02:32 +02:00
Ade 519fdc3015 Merge branch 'develop' of https://github.com/rembo10/headphones into develop 2017-03-14 17:39:36 +13:00
Noam 91ed781acb More exception handling for torrent removal 2017-03-13 22:10:57 +02:00
AdeHub bf5d07efec Merge pull request #2871 from noam09/develop
Fix Qbittorrent config appearing while Deluge is selected + Deluge check if torrent seeding before removal
2017-03-14 07:11:29 +13:00
Noam 33fdb3d7bd Wrong variable name 2017-03-13 18:46:04 +02:00
Noam 3f67396a55 Deluge: check if torrent finished seeding before deleting 2017-03-13 18:32:36 +02:00
Noam 3680c2b349 Fix typo causing Qbittorrent config to show while Deluge is selected 2017-03-13 10:41:46 +02:00
Ade ff7ff79f85 Merge branch 'master' into develop 2017-03-13 11:11:20 +13:00
Ade debc65db5d Temp processing fixup
Fixes
2017-03-13 11:01:21 +13:00
Ade a02cc4d7b9 pep8 2017-03-12 17:19:21 +13:00
Ade 578ae2c2d8 More post processing artwork changes
- Added min/max pixel width config options
- Artwork < min width is ignored
- Artwork > max width is downsized to max width using a proxy web
service
2017-03-12 12:47:12 +13:00
AdeHub 85749c4e94 Merge pull request #2870 from noam09/develop
Added JSON-related HTTP headers
2017-03-12 09:13:39 +13:00
Noam e415de7698 Added JSON-related HTTP headers 2017-03-11 15:38:12 +02:00
Ade a9bbd34a52 more pep8 2017-03-11 12:44:13 +13:00
Ade 7797cd0681 pep8 2017-03-11 12:37:13 +13:00
Ade 9ccb0f85dc Use CAA artwork for post processing
Preference is:
CAA
Amazon
last.fm
2017-03-11 12:19:53 +13:00
Ade 50185ab3d3 Merge branch 'develop' 2017-03-11 09:04:37 +13:00
Ade 697e695f7d More temp dir stuff 2017-03-11 08:38:42 +13:00
Ade 36ad37f005 Don't check temp pp dir if forced 2017-03-10 11:47:36 +13:00
Ade a45894b802 Merge branch 'develop' 2017-03-09 11:51:47 +13:00
Ade dd1e4bfe3b Label change 2017-03-09 11:35:06 +13:00
Ade 88366443fd Post processing temp dir fixup
Hopefully fixes #2859
2017-03-09 11:10:06 +13:00
widewing 6a1aca66b1 change ajax call method to POST 2017-03-04 11:24:42 +13:00
MrClayPole ef6a203cda Update init.freebsd
This path is incorrect for freebsd the path to python bin is /usr/local/bin/python rather than /usr/bin/python
2017-03-04 11:24:42 +13:00
hypsometric 83c50ccd4f HTML escape pth_password 2017-03-04 11:24:42 +13:00
Fritsbenik 3e4c5cb5e2 update init.freebsd
Allow to set variables in /etc/rc.conf
2017-03-04 11:24:42 +13:00
Ade 8c1621f604 Merge branch 'master' of https://github.com/rembo10/headphones 2017-03-04 11:17:18 +13:00
Ade bc465edd91 Merge branch 'master' into develop 2017-03-04 11:08:06 +13:00
AdeHub 3d1a9e8947 Merge pull request #2851 from MrClayPole/patch-1
Update init.freebsd
2017-03-04 11:02:56 +13:00
AdeHub 9d6a7d1d36 Merge pull request #2855 from widewing/patch-1
change ajax call method to POST
2017-03-04 11:02:32 +13:00
widewing 3cfc434e8a change ajax call method to POST 2017-02-24 16:32:54 +08:00
MrClayPole e7017305d8 Update init.freebsd
This path is incorrect for freebsd the path to python bin is /usr/local/bin/python rather than /usr/bin/python
2017-02-21 08:54:24 +00:00
AdeHub a0fa658956 Merge pull request #2848 from hypsometric/patch-1
HTML escape pth_password
2017-02-17 19:33:34 +13:00
hypsometric 709073358b HTML escape pth_password 2017-02-14 15:37:59 +01:00
Ade e6b74be1f6 Replace Piratebay apostrophe
Fixes #2839
2017-02-02 19:46:31 +13:00
Ade c29dda3af6 Torznab
- Use 3050 for music/other
- Does not return size for Torrentech, hack our own
2017-01-22 10:22:19 +13:00
Ade 335586893c Keep original folder
- Added config option for directory to use when keeping folder for
seeding
- cue processing fixup
2017-01-22 10:17:24 +13:00
Ade 44bb1bea08 rutracker links and cookie changes 2017-01-21 19:45:00 +13:00
Ade 9f378ec79c kat domain change 2017-01-21 09:55:38 +13:00
Ade 5501d84611 omgwtfnzbs domain change
Fixes #2820
2017-01-21 09:52:12 +13:00
AdeHub 34746400e6 Merge pull request #2818 from Fritsbenik/patch-1
update init.freebsd
2017-01-21 09:38:39 +13:00
AdeHub cf502aa880 Merge pull request #2806 from Darkitty/develop
Add Slack notification
2017-01-21 09:38:03 +13:00
AdeHub 2ca498d74f Merge pull request #2811 from Gadzy/develop
T411 specific download bugfix
2017-01-21 09:37:12 +13:00
AdeHub 432018a2a4 Merge pull request #2822 from dsm1212/qbittorrent
Add qbittorrent downloader support
2017-01-21 09:35:36 +13:00
dsm1212 784ec2927e more pep8 fixes and a typo 2017-01-14 01:20:42 -05:00
root fa1cb27e04 fix pep8 errors 2017-01-14 01:13:04 -05:00
root 7e915c5902 Add qbittorrent downloader support 2017-01-14 00:49:55 -05:00
Fritsbenik 7f48b021ec update init.freebsd
Allow to set variables in /etc/rc.conf
2017-01-04 19:08:48 +01:00
Gadzy e8009c00d2 T411 specific dowload bugfix
Fixed a bug where T411 was not checked when choosing specific download
2016-12-22 21:43:06 +01:00
Nicolas Le Gall 772fb524c7 Fix for pep8 2016-12-18 10:39:26 +01:00
Nicolas Le Gall b538e5b321 Add Slack notification 2016-12-18 10:32:39 +01:00
Ade decc548086 PEP8 2016-12-11 19:23:38 +13:00
Ade 82695311c4 PEP8 2016-12-11 19:12:29 +13:00
AdeHub 6d40178ce2 Merge pull request #2790 from noam09/develop
Apollo replaces WCD, PTH object fix, Deluge logging tweak
2016-12-11 17:17:38 +13:00
Ade 4d1107ee54 cue sheet + temp dir fix ups
- cue not processing correctly if using temp dir
-  allow temp dir to be specified from new config option
- attempt to stop multiple temp dirs created for the same albumpath
2016-12-11 17:11:42 +13:00
Noam 8fd9958d70 Separated use of Gazelle objects for Apollo and PTH so that objects don't collide, renamed objects to make this clear 2016-12-02 14:20:28 +02:00
Noam f8900795da Replaced all instances of WCD with Apollo 2016-12-02 13:08:03 +02:00
Noam b8e8e752f0 RIP WCD 2016-12-02 12:13:07 +02:00
Noam d04cdcd8fa Merge branch 'develop' of github.com:noam09/headphones into develop 2016-12-02 12:07:04 +02:00
Noam edc243a245 Changed some logging to better understand issue #2734 2016-12-02 12:06:13 +02:00
rembo10 b71c0d8b93 v0.5.18 2016-12-01 15:59:35 +00:00
rembo10 0ecfe499fc Updated changelog for v0.5.18 2016-12-01 15:59:11 +00:00
rembo10 68d85841ea Merge remote-tracking branch 'DenzoNL/master' into develop 2016-12-01 15:52:46 +00:00
rembo10 1ffcc177a4 Merge remote-tracking branch 'MIKAd3v/develop' into develop 2016-12-01 15:51:01 +00:00
rembo10 2230c5bc0c Merge remote-tracking branch 'wfriesen/develop' into develop 2016-12-01 15:49:58 +00:00
Denzo 352b009e91 # Removed slash from ajax.php 2016-11-29 20:54:20 +01:00
Denzo b6b33e1b1e + Added support for PassTheHeadphones.me
% Removed hardcoding for What.CD in pygazelle API, making it easy to add other Gazelle-based trackers

+ Added URL parameter for What.CD and PTH, in case they come back under a new domain name
2016-11-29 20:49:52 +01:00
massiliattak dff1ac2e41 Update T411 new domain ( t411.li ) 2016-11-20 20:15:37 +01:00
Noam 36f75f44e9 Changed some logging to better understand issue #2734 2016-11-19 22:58:57 +02:00
William Friesen 92601e0eb8 Use HTML escaping for password fields
Fixes rembo10/headphones#2474
2016-11-19 12:15:22 +11:00
rembo10 6e82423947 Merge remote-tracking branch 'Betree/search_with_partial_dates' into develop 2016-11-10 21:07:04 +00:00
rembo10 0a805a01f3 v0.5.17 2016-11-10 20:58:00 +00:00
rembo10 579b5a2f2f Updated changelog for v0.5.17 2016-11-10 20:56:51 +00:00
Benjamin Piouffle c77f43be5e Accept partial release date for search 2016-10-25 19:21:16 +11:00
AdeHub f10d1834dd Merge pull request #2749 from likeitneverwentaway/t411
Fixed lossless only for t411
2016-10-20 22:51:42 +13:00
AdeHub b4c7ed2227 Merge pull request #2756 from Betree/fix_rutracker_login
Fix Rutracker loggin by looking for the cookie in request history
2016-10-20 22:51:23 +13:00
Benjamin Piouffle 0086980b1b Fix Rutracker loggin by looking for the cookie in request history 2016-10-19 12:41:46 +11:00
likeitneverwentaway 89e1793978 Fixed lossless only for t411 2016-10-15 14:39:55 +02:00
rembo10 5a598fe611 Merge remote-tracking branch 'etomm/develop' into develop 2016-10-15 10:13:20 +01:00
etomm b58f670694 Handling Deluge empty password 2016-10-15 10:08:51 +02:00
AdeHub 5103436d17 Merge pull request #2745 from likeitneverwentaway/t411
New provider : add t411 support
2016-10-15 18:58:41 +13:00
Ade c9a1796e56 Allow hyphen in mb search
Fixes #2499
2016-10-15 09:59:11 +13:00
Ade 7428b56dd5 Remove mb apostrophe
Fixes #2737
2016-10-09 15:56:25 +13:00
likeitneverwentaway 42294d261d Fix typo, import loads from json 2016-10-08 01:55:55 +02:00
likeitneverwentaway 31a99a908a Added credit 2016-10-08 01:08:54 +02:00
likeitneverwentaway aa503f0fe8 Update config.py to add t411 support 2016-10-08 01:01:39 +02:00
likeitneverwentaway d3d48d27df Update webserve.py to add t411 support 2016-10-08 01:00:49 +02:00
likeitneverwentaway bd8eb74662 Update config.html to add t411 support 2016-10-08 00:50:48 +02:00
likeitneverwentaway 99680e9a0f Update searcher.py to add t411 support 2016-10-08 00:46:12 +02:00
rembo10 0cc58bdb46 Merge remote-tracking branch 'clowninasack/freebsd-init-script' into develop 2016-09-28 22:37:17 +01:00
rembo10 09435b8786 Merge remote-tracking branch 'kainz/add_crier' into develop 2016-09-28 22:37:14 +01:00
rembo10 5d2753a34e Merge remote-tracking branch 'VoidVolker/develop' into develop 2016-09-28 22:37:01 +01:00
Ade 8eb3ece2f9 rutracker login fix
stop redirecting to the same login page
2016-09-17 20:27:01 +12:00
Get your own 'tots. Geez! ed923a2a94 Update config.html 2016-08-03 11:09:05 -06:00
Get your own 'tots. Geez! 95cc0670e2 Update config.html 2016-08-03 11:03:28 -06:00
Get your own 'tots. Geez! a62b55c40f Update importer.py 2016-08-03 10:52:39 -06:00
Get your own 'tots. Geez! 7440b77377 Update importer.py 2016-08-03 10:22:55 -06:00
Get your own 'tots. Geez! 372c6ffd3b Update config.html 2016-08-03 10:07:01 -06:00
Get your own 'tots. Geez! 14d1524971 Update config.html 2016-08-03 09:53:53 -06:00
Get your own 'tots. Geez! 9cddcabad4 Update webserve.py 2016-08-03 09:31:17 -06:00
Get your own 'tots. Geez! 656e7161cb Update config.py 2016-08-03 09:29:09 -06:00
Get your own 'tots. Geez! 7c12761066 Update config.py 2016-08-03 09:27:44 -06:00
clowninasack d3584a5ae9 Fixed FreeBSD init script. 2016-08-01 10:30:10 -07:00
Bryon Roche c0c64621e6 Add a thread-dumping handler
Add a 'crier' handler to dump the thread stacks of running threads to
the log, for easier debugging.
2016-07-14 05:34:36 -07:00
Bryon Roche 323d364572 Added missing support method for current FauxSocket for pypy 2016-07-14 05:27:07 -07:00
VoidVolker 9a681a9422 [=] HP_HOST description 2016-07-08 14:44:01 +03:00
VoidVolker a7a709df51 [+] HP_HOST option 2016-07-08 14:42:19 +03:00
VoidVolker 3f23b5553a [+] --host command line option 2016-07-08 14:39:36 +03:00
rembo10 5a2082ede5 v0.5.16 2016-06-10 00:23:22 +01:00
rembo10 e29f3a0a9a Updated changelog for v0.5.16 2016-06-10 00:22:59 +01:00
rembo10 f4bebf6a41 Merge remote-tracking branch 'SlashingWolf/patch-1' into develop 2016-06-10 00:20:43 +01:00
Gabriel 564df8136c Update to searcher.py to work with waffles.ch
Changed the url used for searching waffles from https://www.waffles.ch/browse.php to https://waffles.ch/browse.php.
2016-06-09 16:19:49 -05:00
rembo10 e85560952f v0.5.15 2016-06-07 10:53:10 +01:00
rembo10 5d6a4adae8 Updated changelog for v0.5.15 2016-06-07 10:50:13 +01:00
rembo10 ecfd3204ff Updated vip server url 2016-06-07 10:49:59 +01:00
rembo10 f0e6554309 Merge branch 'develop' of https://github.com/rembo10/headphones into develop 2016-06-07 10:48:50 +01:00
AdeHub 995294e605 Merge pull request #2666 from noam09/develop
Waffles move from .fm to .ch
2016-06-06 11:59:58 +12:00
Noam 0525493c99 Waffles move from .fm to .ch 2016-06-05 14:39:19 +03:00
1630 changed files with 198451 additions and 86085 deletions
-1
View File
@@ -11,7 +11,6 @@ cache:
- lib - lib
python: python:
- "2.6"
- "2.7" - "2.7"
install: install:
+103
View File
@@ -1,5 +1,108 @@
# Changelog # Changelog
## v0.6.3
Released 26 May 2024
Highlights:
* Hotfix for searcher not returning results
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.6.2...v0.6.3).
## v0.6.2
Released 26 May 2024
Highlights:
* Added soulseek support
* Added bandcamp support
* Changes and dependency updates to work with Python >= 3.12
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.6.1...v0.6.2).
## v0.6.1
R eleased 26 November 2023
Highlights:
* Dependency updates to work with > Python 3.11
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.6.0...v0.6.1).
## v0.6.0
Released 13 November 2022
Highlights:
* Updated to python 3
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.20...v0.6.0).
## v0.5.20
Released 15 October 2021
Highlights:
* Improved: Last.fm & metacrtic api fixes
* Improved: Various database fixes
* Added: qBitTorrent
* Added: Telegram notifications
* Improved: Many more since the last release. Check the list
Many thanks to @AdeHub for keeping the ship afloat
and to everyone else who contributed.
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.19...v0.5.20).
## v0.5.19
Released 27 March 2018
Highlights:
* Improved: Windows encoding fixes
* Improved: Performance enhancements
* Improved: Many more since the last release. Check the list
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.18...v0.5.19).
## v0.5.18
Released 01 December 2016
Highlights:
* Added: PassTheHeadphones support
* Fixed: Special characters in password fields breaking on config page
* Improved: Updated t411 url
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.17...v0.5.18).
## v0.5.17
Released 10 November 2016
Highlights:
* Added: t411 support
* Fixed: Rutracker login
* Fixed: Deluge empty password
* Fixed: FreeBSD init script
* Improved: Musicbrainz searching
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.16...v0.5.17).
## v0.5.16
Released 10 June 2016
Hotfix update
Highlights:
* Fixed: Waffles url
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.15...v0.5.16).
## v0.5.15
Released 07 June 2016
Hotfix update
Highlights:
* Fixed: Update vip mirror url to point to new server
* Fixed: Update waffles url to .ch
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.14...v0.5.15).
## v0.5.14 ## v0.5.14
Released 02 June 2016 Released 02 June 2016
+18 -2
View File
@@ -17,6 +17,10 @@
import os import os
import sys import sys
if sys.version_info <= (3, 6):
sys.stdout.write("Headphones requires Python >= 3.7\n")
sys.exit(1)
# Ensure lib added to path, before any other imports # Ensure lib added to path, before any other imports
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'lib/')) sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'lib/'))
@@ -54,7 +58,10 @@ def main():
try: try:
locale.setlocale(locale.LC_ALL, "") locale.setlocale(locale.LC_ALL, "")
headphones.SYS_ENCODING = locale.getpreferredencoding() if headphones.SYS_PLATFORM == 'win32':
headphones.SYS_ENCODING = sys.getdefaultencoding().upper()
else:
headphones.SYS_ENCODING = locale.getpreferredencoding()
except (locale.Error, IOError): except (locale.Error, IOError):
pass pass
@@ -81,6 +88,8 @@ def main():
help='Prevent browser from launching on startup') help='Prevent browser from launching on startup')
parser.add_argument( parser.add_argument(
'--pidfile', help='Create a pid file (only relevant when running as a daemon)') '--pidfile', help='Create a pid file (only relevant when running as a daemon)')
parser.add_argument(
'--host', help='Specify a host (default - localhost)')
args = parser.parse_args() args = parser.parse_args()
@@ -170,6 +179,13 @@ def main():
else: else:
http_port = int(headphones.CONFIG.HTTP_PORT) http_port = int(headphones.CONFIG.HTTP_PORT)
# Force the http host if neccessary
if args.host:
http_host = args.host
logger.info('Using forced web server host: %s', http_host)
else:
http_host = headphones.CONFIG.HTTP_HOST
# Check if pyOpenSSL is installed. It is required for certificate generation # Check if pyOpenSSL is installed. It is required for certificate generation
# and for CherryPy. # and for CherryPy.
if headphones.CONFIG.ENABLE_HTTPS: if headphones.CONFIG.ENABLE_HTTPS:
@@ -183,7 +199,7 @@ def main():
# 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,
'http_host': headphones.CONFIG.HTTP_HOST, 'http_host': http_host,
'http_root': headphones.CONFIG.HTTP_ROOT, 'http_root': headphones.CONFIG.HTTP_ROOT,
'http_proxy': headphones.CONFIG.HTTP_PROXY, 'http_proxy': headphones.CONFIG.HTTP_PROXY,
'enable_https': headphones.CONFIG.ENABLE_HTTPS, 'enable_https': headphones.CONFIG.ENABLE_HTTPS,
+1 -1
View File
@@ -1,4 +1,4 @@
##![Headphones Logo](https://github.com/rembo10/headphones/raw/master/data/images/headphoneslogo.png) Headphones ## ![Headphones Logo](https://github.com/rembo10/headphones/raw/master/data/images/headphoneslogo.png) Headphones
[![Build Status](https://travis-ci.org/rembo10/headphones.svg?branch=master)](https://travis-ci.org/rembo10/headphones) [![Build Status](https://travis-ci.org/rembo10/headphones.svg?branch=master)](https://travis-ci.org/rembo10/headphones)
[![Build Status](https://img.shields.io/travis/rembo10/headphones/develop.svg?label=develop%20branch%20build)](https://travis-ci.org/rembo10/headphones) [![Build Status](https://img.shields.io/travis/rembo10/headphones/develop.svg?label=develop%20branch%20build)](https://travis-ci.org/rembo10/headphones)
+2 -2
View File
@@ -6,7 +6,7 @@ import sys
# Ensure that we use the Headphones provided libraries. # Ensure that we use the Headphones provided libraries.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../lib")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../lib"))
import urlparse import urllib.parse
def can_import(module): def can_import(module):
@@ -89,7 +89,7 @@ def main():
url = sys.argv[1] url = sys.argv[1]
# Check if it is a HTTPS website. # Check if it is a HTTPS website.
parts = urlparse.urlparse(url) parts = urllib.parse.urlparse(url)
if parts.scheme.lower() != "https": if parts.scheme.lower() != "https":
sys.stderr.write( sys.stderr.write(
Binary file not shown.

Before

Width:  |  Height:  |  Size: 612 B

After

Width:  |  Height:  |  Size: 576 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 807 B

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 635 B

After

Width:  |  Height:  |  Size: 601 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 852 B

After

Width:  |  Height:  |  Size: 817 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 B

After

Width:  |  Height:  |  Size: 79 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 B

After

Width:  |  Height:  |  Size: 86 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 B

After

Width:  |  Height:  |  Size: 86 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 B

After

Width:  |  Height:  |  Size: 86 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 B

After

Width:  |  Height:  |  Size: 125 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 B

After

Width:  |  Height:  |  Size: 121 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 B

After

Width:  |  Height:  |  Size: 121 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 B

After

Width:  |  Height:  |  Size: 114 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 B

After

Width:  |  Height:  |  Size: 120 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

+1 -1
View File
@@ -324,7 +324,7 @@
if (++x === 10) { if (++x === 10) {
clearInterval(refreshInterval); clearInterval(refreshInterval);
} }
var sts = $("#artistname").text(); var sts = $("#artistname").text().trim();
if (wasLoading == true || sts == "Loading"){ if (wasLoading == true || sts == "Loading"){
location.reload(); location.reload();
$('#albumnamespinner').remove() $('#albumnamespinner').remove()
+317 -125
View File
@@ -56,7 +56,7 @@
<label title="Password for web server authentication. Leave empty to disable."> <label title="Password for web server authentication. Leave empty to disable.">
HTTP Password HTTP Password
</label> </label>
<input type="password" name="http_password" value="${config['http_password']}" size="30"> <input type="password" name="http_password" value="${config['http_password'] | h}" size="30">
</div> </div>
<div class="row checkbox"> <div class="row checkbox">
<input type="checkbox" name="launch_browser" value="1" ${config['launch_browser']} /> <input type="checkbox" name="launch_browser" value="1" ${config['launch_browser']} />
@@ -133,6 +133,12 @@
</label> </label>
<input type="text" name="mb_ignore_age" value="${config['mb_ignore_age']}" size="4">days <input type="text" name="mb_ignore_age" value="${config['mb_ignore_age']}" size="4">days
</div> </div>
<div class="row checkbox">
<label title="Ignore MusicBrainz album updates missing a release date.">
Ignore Undated Releases
</label>
<input type="checkbox" id="mb_ignore_age_missing" name="mb_ignore_age_missing" value="1" ${config['mb_ignore_age_missing']}
</div>
</fieldset> </fieldset>
</td> </td>
</tr> </tr>
@@ -173,7 +179,7 @@
<label title="SABnzbd password. Leave empty if not applicable."> <label title="SABnzbd password. Leave empty if not applicable.">
SABnzbd Password SABnzbd Password
</label> </label>
<input type="password" name="sab_password" value="${config['sab_password']}" size="20"> <input type="password" name="sab_password" value="${config['sab_password'] | h}" size="20">
</div> </div>
<div class="row"> <div class="row">
<label title="SABnzbd API key. Can be found in SABnzbd settings."> <label title="SABnzbd API key. Can be found in SABnzbd settings.">
@@ -207,7 +213,7 @@
<label title="NZBGet password. Leave empty if not applicable"> <label title="NZBGet password. Leave empty if not applicable">
NZBget Password NZBget Password
</label> </label>
<input type="password" name="nzbget_password" value="${config['nzbget_password']}" size="20"> <input type="password" name="nzbget_password" value="${config['nzbget_password'] | h}" size="20">
</div> </div>
<div class="row"> <div class="row">
<label title="Name of NZBget category to add downloads to."> <label title="Name of NZBget category to add downloads to.">
@@ -304,6 +310,16 @@
<input type="text" name="usenet_retention" value="${config['usenet_retention']}" size="5"> <input type="text" name="usenet_retention" value="${config['usenet_retention']}" size="5">
</div> </div>
</fieldset> </fieldset>
<fieldset title="Method for downloading Bandcamp.com files.">
<legend>Bandcamp</legend>
<div class="row">
<label title="Path to folder where Headphones can store raw downloads from Bandcamp.com.">
Bandcamp Directory
</label>
<input type="text" name="bandcamp_dir" value="${config['bandcamp_dir']}" size="50">
<small>Full path where raw MP3s will be stored, e.g. /Users/name/Downloads/bandcamp</small>
</div>
</fieldset>
</td> </td>
<td> <td>
<fieldset title="Method for downloading torrent files."> <fieldset title="Method for downloading torrent files.">
@@ -311,7 +327,8 @@
<input type="radio" name="torrent_downloader" id="torrent_downloader_blackhole" value="0" ${config['torrent_downloader_blackhole']}> Black Hole <input type="radio" name="torrent_downloader" id="torrent_downloader_blackhole" value="0" ${config['torrent_downloader_blackhole']}> Black Hole
<input type="radio" name="torrent_downloader" id="torrent_downloader_transmission" value="1" ${config['torrent_downloader_transmission']}> Transmission <input type="radio" name="torrent_downloader" id="torrent_downloader_transmission" value="1" ${config['torrent_downloader_transmission']}> Transmission
<input type="radio" name="torrent_downloader" id="torrent_downloader_utorrent" value="2" ${config['torrent_downloader_utorrent']}> uTorrent (Beta) <input type="radio" name="torrent_downloader" id="torrent_downloader_utorrent" value="2" ${config['torrent_downloader_utorrent']}> uTorrent (Beta)
<input type="radio" name="torrent_downloader" id="torrent_downloader_deluge" value="3" ${config['torrent_downloader_deluge']}> Deluge (Beta) <input type="radio" name="torrent_downloader" id="torrent_downloader_deluge" value="3" ${config['torrent_downloader_deluge']}> Deluge
<input type="radio" name="torrent_downloader" id="torrent_downloader_qbittorrent" value="4" ${config['torrent_downloader_qbittorrent']}> QBitTorrent
</fieldset> </fieldset>
<fieldset id="torrent_blackhole_options"> <fieldset id="torrent_blackhole_options">
<div class="row"> <div class="row">
@@ -359,7 +376,7 @@
</div> </div>
<div class="row"> <div class="row">
<label>Transmission Password</label> <label>Transmission Password</label>
<input type="password" name="transmission_password" value="${config['transmission_password']}" size="30"> <input type="password" name="transmission_password" value="${config['transmission_password'] | h}" size="30">
</div> </div>
<div class="row"> <div class="row">
<small>Note: With Transmission, you can specify a different download directory for downloads sent from Headphones. <small>Note: With Transmission, you can specify a different download directory for downloads sent from Headphones.
@@ -379,13 +396,33 @@
</div> </div>
<div class="row"> <div class="row">
<label>uTorrent Password</label> <label>uTorrent Password</label>
<input type="password" name="utorrent_password" value="${config['utorrent_password']}" size="30"> <input type="password" name="utorrent_password" value="${config['utorrent_password'] | h}" size="30">
</div> </div>
<div class="row"> <div class="row">
<label>uTorrent Label</label> <label>uTorrent Label</label>
<input type="text" name="utorrent_label" value="${config['utorrent_label']}" size="30"> <input type="text" name="utorrent_label" value="${config['utorrent_label']}" size="30">
</div> </div>
</fieldset> </fieldset>
<fieldset id="qbittorrent_options">
<small class="heading"><i class="fa fa-info-circle"></i> Note: Works with WebAPI Rev 11 and later (QBitTorrent 3.4.0 and later) </small>
<div class="row">
<label>QBitTorrent Host</label>
<input type="text" name="qbittorrent_host" value="${config['qbittorrent_host']}" size="30">
<small>usually http://localhost:8081</small>
</div>
<div class="row">
<label>QBitTorrent Username</label>
<input type="text" name="qbittorrent_username" value="${config['qbittorrent_username']}" size="30">
</div>
<div class="row">
<label>QBitTorrent Password</label>
<input type="password" name="qbittorrent_password" value="${config['qbittorrent_password']}" size="30">
</div>
<div class="row">
<label>QBitTorrent Label</label>
<input type="text" name="qbittorrent_label" value="${config['qbittorrent_label']}" size="30">
</div>
</fieldset>
<fieldset id="deluge_options"> <fieldset id="deluge_options">
<div class="row"> <div class="row">
<label>Deluge WebUI Host and Port</label> <label>Deluge WebUI Host and Port</label>
@@ -400,7 +437,7 @@
</div> </div>
<div class="row"> <div class="row">
<label>Deluge Password</label> <label>Deluge Password</label>
<input type="password" name="deluge_password" value="${config['deluge_password']}" size="30"> <input type="password" name="deluge_password" value="${config['deluge_password'] | h}" size="30">
</div> </div>
<div class="row"> <div class="row">
<small>Note: With Deluge, you can specify a different download directory for downloads sent from Headphones. <small>Note: With Deluge, you can specify a different download directory for downloads sent from Headphones.
@@ -411,6 +448,11 @@
<input type="text" name="deluge_label" value="${config['deluge_label']}" size="30"> <input type="text" name="deluge_label" value="${config['deluge_label']}" size="30">
<small>Labels shouldn't contain spaces (requires Label plugin)</small> <small>Labels shouldn't contain spaces (requires Label plugin)</small>
</div> </div>
<div class="row">
<label>Download Directory</label>
<input type="text" name="deluge_download_directory" value="${config['deluge_download_directory']}" size="30">
<small>Directory where Deluge should download to</small>
</div>
<div class="row"> <div class="row">
<label>Move When Completed</label> <label>Move When Completed</label>
<input type="text" name="deluge_done_directory" value="${config['deluge_done_directory']}" size="30"> <input type="text" name="deluge_done_directory" value="${config['deluge_done_directory']}" size="30">
@@ -440,7 +482,33 @@
<label>Prefer</label> <label>Prefer</label>
<input type="radio" name="prefer_torrents" id="prefer_torrents_0" value="0" ${config['prefer_torrents_0']}>NZBs <input type="radio" name="prefer_torrents" id="prefer_torrents_0" value="0" ${config['prefer_torrents_0']}>NZBs
<input type="radio" name="prefer_torrents" id="prefer_torrents_1" value="1" ${config['prefer_torrents_1']}>Torrents <input type="radio" name="prefer_torrents" id="prefer_torrents_1" value="1" ${config['prefer_torrents_1']}>Torrents
<input type="radio" name="prefer_torrents" id="prefer_torrents_2" value="2" ${config['prefer_torrents_2']}>No Preference <input type="radio" name="prefer_torrents" id="prefer_torrents_2" value="2" ${config['prefer_torrents_2']}>Soulseek
<input type="radio" name="prefer_torrents" id="prefer_torrents_3" value="3" ${config['prefer_torrents_3']}>No Preference
</div>
</fieldset>
</td>
<td>
<fieldset>
<legend>Soulseek</legend>
<div class="row">
<label>Soulseek API URL</label>
<input type="text" name="soulseek_api_url" value="${config['soulseek_api_url']}" size="50">
</div>
<div class="row">
<label>Soulseek API KEY</label>
<input type="text" name="soulseek_api_key" value="${config['soulseek_api_key']}" size="20">
</div>
<div class="row">
<label title="Path to folder where Headphones can find the downloads.">
Soulseek Download Dir:
</label>
<input type="text" name="soulseek_download_dir" value="${config['soulseek_download_dir']}" size="50">
</div>
<div class="row">
<label title="Path to folder where Headphones can find the downloads.">
Soulseek Incomplete Download Dir:
</label>
<input type="text" name="soulseek_incomplete_download_dir" value="${config['soulseek_incomplete_download_dir']}" size="50">
</div> </div>
</fieldset> </fieldset>
</td> </td>
@@ -466,7 +534,7 @@
</div> </div>
<div class="row"> <div class="row">
<label>Password</label> <label>Password</label>
<input class="hppass" type="password" value="${config['hppass']}" size="20"> <input class="hppass" type="password" value="${config['hppass'] | h}" size="20">
</div> </div>
<div class="row"> <div class="row">
<a href="https://headphones.codeshy.com/vip" id="vipserver" target="_blank">Don't have an account? Sign up!</a> <a href="https://headphones.codeshy.com/vip" id="vipserver" target="_blank">Don't have an account? Sign up!</a>
@@ -552,6 +620,19 @@
</div> </div>
</div> </div>
</fieldset> </fieldset>
<fieldset>
<legend>Other</legend>
<fieldset>
<div class="row checkbox left">
<input id="use_bandcamp" type="checkbox" class="bigcheck" name="use_bandcamp" value="1" ${config['use_bandcamp']} /><label for="use_bandcamp"><span class="option">Bandcamp</span></label>
</div>
</fieldset>
<fieldset>
<div class="row checkbox left">
<input id="use_soulseek" type="checkbox" class="bigcheck" name="use_soulseek" value="1" ${config['use_soulseek']} /><label for="use_soulseek"><span class="option">Soulseek</span></label>
</div>
</fieldset>
</fieldset>
</td> </td>
<td> <td>
<fieldset> <fieldset>
@@ -574,43 +655,6 @@
</div> </div>
</fieldset> </fieldset>
<fieldset>
<div class="row checkbox left">
<input id="use_kat" type="checkbox" class="bigcheck" name="use_kat" value="1" ${config['use_kat']} /><label for="use_kat"><span class="option">Kick Ass Torrents</span></label>
</div>
<div class="config">
<div class="row">
<label>Proxy URL</label>
<input type="text" name="kat_proxy_url" value="${config['kat_proxy_url']}" size="36">
<small>Optional. Leave empty for default.</small>
</div>
<div class="row">
<label>Seed Ratio</label>
<input type="text" class="override-float" name="kat_ratio" value="${config['kat_ratio']}" size="10" title="Stop seeding when ratio met, 0 = unlimited. Scheduled job will remove torrent when post processed and finished seeding">
</div>
</div>
</fieldset>
<fieldset>
<div class="row checkbox left">
<input id="use_waffles" type="checkbox" class="bigcheck" name="use_waffles" value="1" ${config['use_waffles']} /><label for="use_waffles"><span class="option">Waffles.fm</span></label>
</div>
<div class="config">
<div class="row">
<label>UID Number</label>
<input type="text" name="waffles_uid" value="${config['waffles_uid']}" size="36">
</div>
<div class="row">
<label>Passkey</label>
<input type="password" name="waffles_passkey" value="${config['waffles_passkey']}" size="36">
</div>
<div class="row">
<label>Seed Ratio</label>
<input type="text" class="override-float" name="waffles_ratio" value="${config['waffles_ratio']}" size="10" title="Stop seeding when ratio met, 0 = unlimited. Scheduled job will remove torrent when post processed and finished seeding">
</div>
</div>
</fieldset>
<fieldset> <fieldset>
<div class="row checkbox left"> <div class="row checkbox left">
<input id="use_rutracker" type="checkbox" class="bigcheck" name="use_rutracker" value="1" ${config['use_rutracker']} /><label for="use_rutracker"><span class="option">rutracker.org</span></label> <input id="use_rutracker" type="checkbox" class="bigcheck" name="use_rutracker" value="1" ${config['use_rutracker']} /><label for="use_rutracker"><span class="option">rutracker.org</span></label>
@@ -622,62 +666,90 @@
</div> </div>
<div class="row"> <div class="row">
<label>Password</label> <label>Password</label>
<input type="password" name="rutracker_password" value="${config['rutracker_password']}" size="36"> <input type="password" name="rutracker_password" value="${config['rutracker_password'] | h}" size="36">
</div> </div>
<div class="row"> <div class="row">
<label>Seed Ratio</label> <label>Seed Ratio</label>
<input type="text" class="override-float" name="rutracker_ratio" value="${config['rutracker_ratio']}" size="10" title="Stop seeding when ratio met, 0 = unlimited. Scheduled job will remove torrent when post processed and finished seeding"> <input type="text" class="override-float" name="rutracker_ratio" value="${config['rutracker_ratio']}" size="10" title="Stop seeding when ratio met, 0 = unlimited. Scheduled job will remove torrent when post processed and finished seeding">
</div> </div>
<div class="row">
<label>Session Cookie (Optional - Advanced)</label>
<input type="text" class="override-float" name="rutracker_cookie" value="${config['rutracker_cookie']}" size="10" title="bb_session cookie (Advanced)">
</div>
</div> </div>
</fieldset> </fieldset>
<fieldset> <fieldset>
<div class="row checkbox left"> <div class="row checkbox left">
<input id="use_whatcd" type="checkbox" class="bigcheck" name="use_whatcd" value="1" ${config['use_whatcd']} /><label for="use_whatcd"><span class="option">What.cd</span></label> <input id="use_orpheus" type="checkbox" class="bigcheck" name="use_orpheus" value="1" ${config['use_orpheus']} /><label for="use_orpheus"><span class="option">Orpheus.network</span></label>
</div> </div>
<div class="config"> <div class="config">
<div class="row"> <div class="row">
<label>Username</label> <label>Username</label>
<input type="text" name="whatcd_username" value="${config['whatcd_username']}" size="36"> <input type="text" name="orpheus_username" value="${config['orpheus_username']}" size="36">
</div> </div>
<div class="row"> <div class="row">
<label>Password</label> <label>Password</label>
<input type="password" name="whatcd_password" value="${config['whatcd_password']}" size="36"> <input type="password" name="orpheus_password" value="${config['orpheus_password'] | h}" size="36">
</div>
<div class="row">
<label>URL</label>
<input type="text" name="orpheus_url" value="${config['orpheus_url']}" size="36">
</div> </div>
<div class="row"> <div class="row">
<label>Seed Ratio</label> <label>Seed Ratio</label>
<input type="text" class="override-float" name="whatcd_ratio" value="${config['whatcd_ratio']}" size="10" title="Stop seeding when ratio met, 0 = unlimited. Scheduled job will remove torrent when post processed and finished seeding"> <input type="text" class="override-float" name="orpheus_ratio" value="${config['orpheus_ratio']}" size="10" title="Stop seeding when ratio met, 0 = unlimited. Scheduled job will remove torrent when post processed and finished seeding">
</div> </div>
</div> </div>
</fieldset> </fieldset>
<fieldset> <fieldset>
<div class="row checkbox left"> <div class="row checkbox left">
<input id="use_strike" type="checkbox" class="bigcheck" name="use_strike" value="1" ${config['use_strike']} /><label for="use_strike"><span class="option">Strike Search</span></label> <input id="use_redacted" type="checkbox" class="bigcheck" name="use_redacted" value="1" ${config['use_redacted']} /><label for="use_redacted"><span class="option">Redacted</span></label>
</div> </div>
<div class="config"> <div class="config">
<div class="row">
<label>Api Key</label>
<input type="text" name="redacted_apikey" value="${config['redacted_apikey']}" size="36">
</div>
<div class="row">
<label>Username</label>
<input type="text" name="redacted_username" value="${config['redacted_username']}" size="36">
</div>
<div class="row">
<label>Password</label>
<input type="password" name="redacted_password" value="${config['redacted_password'] | h}" size="36">
</div>
<div class="row"> <div class="row">
<label>Seed Ratio</label> <label>Seed Ratio</label>
<input type="text" class="override-float" name="strike_ratio" value="${config['strike_ratio']}" size="10" title="Stop seeding when ratio met, 0 = unlimited. Scheduled job will remove torrent when post processed and finished seeding."> <input type="text" class="override-float" name="redacted_ratio" value="${config['redacted_ratio']}" size="10" title="Stop seeding when ratio met, 0 = unlimited. Scheduled job will remove torrent when post processed and finished seeding">
</div>
<div class="row">
<label>Use freeleech tokens when available</label>
<input type="checkbox" name="redacted_use_fltoken" value="1" ${config['redacted_use_fltoken']}>
</div> </div>
</div> </div>
</fieldset> </fieldset>
<fieldset> <fieldset>
<div class="row checkbox left"> <div class="row checkbox left">
<input id="use_torznab" type="checkbox" class="bigcheck" name="use_torznab" value="1" ${config['use_torznab']} /><label for="use_torznab"><span class="option">Jackett / Torznab Providers</span></label> <input id="use_torznab" type="checkbox" class="bigcheck" name="use_torznab" value="1" ${config['use_torznab']} /><label for="use_torznab"><span class="option">Torznab Providers</span></label>
</div> </div>
<div id="torznab_providers"> <div id="torznab_providers">
<div class="config" id="torznab1"> <div class="config" id="torznab1">
<div class="row"> <div class="row">
<label>Torznab Host</label> <label>Torznab Host</label>
<input type="text" name="torznab_host" value="${config['torznab_host']}" size="30"> <input type="text" name="torznab_host" value="${config['torznab_host']}" size="36">
<small>e.g. http://localhost:9117/torznab/iptorrents</small> <small>e.g. http://localhost:9117/api/v2.0/indexers/demonoid/results/torznab/</small>
</div> </div>
<div class="row"> <div class="row">
<label>Torznab API</label> <label>Torznab API</label>
<input type="text" name="torznab_apikey" value="${config['torznab_apikey']}" size="36"> <input type="text" name="torznab_apikey" value="${config['torznab_apikey']}" size="36">
</div> </div>
<div class="row">
<label>Torznab Seed Ratio</label>
<input type="text" class="override-float" name="torznab_ratio" value="${config['torznab_ratio']}" size="10" title="Stop seeding when ratio met, 0 = unlimited. Scheduled job will remove torrent when post processed and finished seeding.">
</div>
<div class="row checkbox"> <div class="row checkbox">
<input id="torznab_enabled" type="checkbox" name="torznab_enabled" value="1" ${config['torznab_enabled']} /><label>Enabled</label> <input id="torznab_enabled" type="checkbox" name="torznab_enabled" value="1" ${config['torznab_enabled']} /><label>Enabled</label>
</div> </div>
@@ -687,7 +759,7 @@
%> %>
%for torznab in config['extra_torznabs']: %for torznab in config['extra_torznabs']:
<% <%
if torznab[2] == '1' or torznab[2] == 1: if torznab[3] == '1' or torznab[3] == 1:
torznab_enabled = "checked" torznab_enabled = "checked"
else: else:
torznab_enabled = "" torznab_enabled = ""
@@ -695,12 +767,16 @@
<div class="config" id="torznab${torznab_number}"> <div class="config" id="torznab${torznab_number}">
<div class="row"> <div class="row">
<label>Torznab Host</label> <label>Torznab Host</label>
<input type="text" name="torznab_host${torznab_number}" value="${torznab[0]}" size="30"> <input type="text" name="torznab_host${torznab_number}" value="${torznab[0]}" size="36">
</div> </div>
<div class="row"> <div class="row">
<label>Torznab API</label> <label>Torznab API</label>
<input type="text" name="torznab_api${torznab_number}" value="${torznab[1]}" size="36"> <input type="text" name="torznab_api${torznab_number}" value="${torznab[1]}" size="36">
</div> </div>
<div class="row">
<label>Torznab Seed Ratio</label>
<input type="text" class="override-float" name="torznab_ratio${torznab_number}" value="${torznab[2]}" size="10" title="Stop seeding when ratio met, 0 = unlimited. Scheduled job will remove torrent when post processed and finished seeding.">
</div>
<div class="row checkbox"> <div class="row checkbox">
<input id="torznab_enabled" type="checkbox" name="torznab_enabled${torznab_number}" value="1" ${torznab_enabled} /><label>Enabled</label> <input id="torznab_enabled" type="checkbox" name="torznab_enabled${torznab_number}" value="1" ${torznab_enabled} /><label>Enabled</label>
</div> </div>
@@ -716,34 +792,6 @@
</div> </div>
</fieldset> </fieldset>
<fieldset>
<div class="row checkbox left">
<input id="use_oldpiratebay" type="checkbox" class="bigcheck" name="use_oldpiratebay" value="1" ${config['use_oldpiratebay']} /><label for="use_oldpiratebay"><span class="option">Old Pirate Bay</span></label>
</div>
<div class="config">
<div class="row">
<label>URL</label>
<input type="text" name="oldpiratebay_url" value="${config['oldpiratebay_url']}" size="36">
</div>
<div class="row">
<label>Seed Ratio</label>
<input type="text" class="override-float" name="oldpiratebay_ratio" value="${config['oldpiratebay_ratio']}" size="10" title="Stop seeding when ratio met, 0 = unlimited. Scheduled job will remove torrent when post processed and finished seeding.">
</div>
</div>
</fieldset>
<fieldset>
<div class="row checkbox left">
<input id="use_mininova" type="checkbox" class="bigcheck" name="use_mininova" value="1" ${config['use_mininova']} /><label for="use_mininova"><span class="option">Mininova</span></label>
</div>
<div class="config">
<div class="row">
<label>Seed Ratio</label>
<input type="text" class="override-float" name="mininova_ratio" value="${config['mininova_ratio']}" size="10" title="Stop seeding when ratio met, 0 = unlimited. Scheduled job will remove torrent when post processed and finished seeding">
</div>
</div>
</fieldset>
</fieldset> </fieldset>
</td> </td>
</tr> </tr>
@@ -884,30 +932,42 @@
<input type="checkbox" name="keep_nfo" value="1" ${config['keep_nfo']} /> <input type="checkbox" name="keep_nfo" value="1" ${config['keep_nfo']} />
</label> </label>
</div> </div>
<div class="row checkbox left clearfix nopad">
<label>
Embed lyrics
<input type="checkbox" name="embed_lyrics" value="1" ${config['embed_lyrics']}>
</label>
</div>
<div class="row checkbox left clearfix nopad">
<label>
Embed album art in each file
<input type="checkbox" name="embed_album_art" id="embed_album_art" value="1" ${config['embed_album_art']}>
</label>
</div>
<div class="row checkbox left clearfix nopad"> <div class="row checkbox left clearfix nopad">
<label> <label>
Add album art jpeg to album folder Add album art jpeg to album folder
<input type="checkbox" name="add_album_art" id="add_album_art" value="1" ${config['add_album_art']}> <input type="checkbox" name="add_album_art" id="add_album_art" value="1" ${config['add_album_art']}>
</label> </label>
</div> </div>
<div id="album_art_options" style="padding-left: 20px"> <div id="album_art_options" style="padding-left: 20px">
<div class="row"> <div class="row">
as <input type="text" class="override-float" name="album_art_format" value="${config['album_art_format']}" size="10">.jpg as <input type="text" class="override-float" name="album_art_format" value="${config['album_art_format']}" size="10">.jpg
</div> </div>
<small>Use $Artist/$artist, $Album/$album, $Year/$year, put optional variables in curly braces, use single-quote marks to escape curly braces literally ('{', '}').</small> <small>Use $Artist/$artist, $Album/$album, $Year/$year, put optional variables in curly braces, use single-quote marks to escape curly braces literally ('{', '}').</small>
</div> </div>
<div class="row checkbox left clearfix nopad">
<label> <div id="album_art_size_options" style="padding-left: 20px">
Embed album art in each file <div class="row">
<input type="checkbox" name="embed_album_art" value="1" ${config['embed_album_art']}> Album art min width <input type="text" class="override-float" name="album_art_min_width" value="${config['album_art_min_width']}" size="4" title="Only images with a pixel width greater or equal are considered as valid album art candidates. Default: 0.">\
</label> Album art max width <input type="text" class="override-float" name="album_art_max_width" value="${config['album_art_max_width']}" size="4" title="A maximum image width to downscale fetched images if they are too big.">
</div> </div>
<div class="row checkbox left clearfix nopad">
<label>
Embed lyrics
<input type="checkbox" name="embed_lyrics" value="1" ${config['embed_lyrics']}>
</label>
</div> </div>
<div class="row"> <div class="row">
<label> <label>
Destination Directory: Destination Directory:
@@ -964,7 +1024,7 @@
<label>SMTP User</label><input type="text" name="email_smtp_user" value="${config['email_smtp_user']}" size="254"> <label>SMTP User</label><input type="text" name="email_smtp_user" value="${config['email_smtp_user']}" size="254">
</div> </div>
<div class="row"> <div class="row">
<label>SMTP Password</label><input type="password" name="email_smtp_password" value="${config['email_smtp_password']}" size="50"> <label>SMTP Password</label><input type="password" name="email_smtp_password" value="${config['email_smtp_password'] | h}" size="50">
</div> </div>
<div class="row checkbox"> <div class="row checkbox">
<input type="text" class="override-float" name="email_smtp_port" value="${config['email_smtp_port']}" size="4"><label>SMTP Port</label> <input type="text" class="override-float" name="email_smtp_port" value="${config['email_smtp_port']}" size="4"><label>SMTP Port</label>
@@ -990,7 +1050,7 @@
<label>Growl Host:Port</label><input type="text" name="growl_host" value="${config['growl_host']}" size="30"> <label>Growl Host:Port</label><input type="text" name="growl_host" value="${config['growl_host']}" size="30">
</div> </div>
<div class="row"> <div class="row">
<label>Growl Password</label><input type="password" name="growl_password" value="${config['growl_password']}" size="30"> <label>Growl Password</label><input type="password" name="growl_password" value="${config['growl_password'] | h}" size="30">
</div> </div>
<div class="row checkbox"> <div class="row checkbox">
<input type="checkbox" name="growl_onsnatch" value="1" ${config['growl_onsnatch']} /><label>Notify on snatch?</label> <input type="checkbox" name="growl_onsnatch" value="1" ${config['growl_onsnatch']} /><label>Notify on snatch?</label>
@@ -1012,7 +1072,7 @@
<label>Username</label><input type="text" name="xbmc_username" value="${config['xbmc_username']}" size="30"> <label>Username</label><input type="text" name="xbmc_username" value="${config['xbmc_username']}" size="30">
</div> </div>
<div class="row"> <div class="row">
<label>Password</label><input type="password" name="xbmc_password" value="${config['xbmc_password']}" size="30"> <label>Password</label><input type="password" name="xbmc_password" value="${config['xbmc_password'] | h}" size="30">
</div> </div>
<div class="checkbox row"> <div class="checkbox row">
<input type="checkbox" name="xbmc_update" value="1" ${config['xbmc_update']} /><label>Update Library</label> <input type="checkbox" name="xbmc_update" value="1" ${config['xbmc_update']} /><label>Update Library</label>
@@ -1126,7 +1186,7 @@
<small>Username of your Plex client API (blank for none)</small> <small>Username of your Plex client API (blank for none)</small>
</div> </div>
<div class="row"> <div class="row">
<label>Plex Password</label><input type="password" name="plex_password" value="${config['plex_password']}" size="30"> <label>Plex Password</label><input type="password" name="plex_password" value="${config['plex_password'] | h}" size="30">
<small>Password of your Plex client API (blank for none)</small> <small>Password of your Plex client API (blank for none)</small>
</div> </div>
<div class="row"> <div class="row">
@@ -1226,7 +1286,7 @@
<label>Subsonic Username</label><input type="text" name="subsonic_username" value="${config['subsonic_username']}" size="30"> <label>Subsonic Username</label><input type="text" name="subsonic_username" value="${config['subsonic_username']}" size="30">
</div> </div>
<div class="row"> <div class="row">
<label>Subsonic Password</label><input type="password" name="subsonic_password" value="${config['subsonic_password']}" size="30"> <label>Subsonic Password</label><input type="password" name="subsonic_password" value="${config['subsonic_password'] | h}" size="30">
</div> </div>
</div> </div>
</fieldset> </fieldset>
@@ -1236,7 +1296,7 @@
<input type="checkbox" name="synoindex_enabled" id="synoindex" value="1" ${config['synoindex_enabled']} /><label for="synoindex"><span class="option">Synology NAS</span></label> <input type="checkbox" name="synoindex_enabled" id="synoindex" value="1" ${config['synoindex_enabled']} /><label for="synoindex"><span class="option">Synology NAS</span></label>
</div> </div>
</fieldset> </fieldset>
<!--
<fieldset> <fieldset>
<div class="row checkbox left"> <div class="row checkbox left">
<input type="checkbox" class="bigcheck" name="twitter_enabled" id="twitter" value="1" ${config['twitter_enabled']} /><label for="twitter"><span class="option">Twitter</span></label> <input type="checkbox" class="bigcheck" name="twitter_enabled" id="twitter" value="1" ${config['twitter_enabled']} /><label for="twitter"><span class="option">Twitter</span></label>
@@ -1257,6 +1317,26 @@
</div> </div>
</div> </div>
</fieldset> </fieldset>
-->
<fieldset>
<div class="row checkbox left">
<input type="checkbox" class="bigcheck" name="slack_enabled" id="slack" value="1" ${config['slack_enabled']} /><label for="slack"><span class="option">Slack</span></label>
</div>
<div id="slackoptions">
<div class="row">
<label>Slack Webhook</label><input type="text" name="slack_url" value="${config['slack_url']}" size="50">
</div>
<div class="row">
<label>Channel</label><input type="text" name="slack_channel" value="${config['slack_channel']}" size="50">
</div>
<div class="row checkbox">
<label>Emoji</label><input type="text" name="slack_emoji" value="${config['slack_emoji']}" size="50">
</div>
<div class="row checkbox">
<input type="checkbox" name="slack_onsnatch" value="1" ${config['slack_onsnatch']} /><label>Notify on snatch?</label>
</div>
</div>
</fieldset>
<fieldset> <fieldset>
<div class="row checkbox left"> <div class="row checkbox left">
@@ -1275,6 +1355,23 @@
</div> </div>
</fieldset> </fieldset>
<fieldset>
<div class="row checkbox left">
<input type="checkbox" class="bigcheck" name="join_enabled" id="join" value="1" ${config['join_enabled']} /><label for="join"><span class="option">Join</span></label>
</div>
<div id="joinoptions">
<div class="row">
<label>Join API Key</label><input type="text" name="join_apikey" value="${config['join_apikey']}" size="50">
</div>
<div class="row">
<label>Device ID(s)</label><input type="text" name="join_deviceid" value="${config['join_deviceid']}" size="50"><small>Comma separated list. Leave blank to send to all devices</small>
</div>
<div class="row checkbox">
<input type="checkbox" name="join_onsnatch" value="1" ${config['join_onsnatch']} /><label>Notify on snatch?</label>
</div>
</div>
</fieldset>
</td> </td>
</tr> </tr>
</table> </table>
@@ -1295,17 +1392,20 @@
<div class="row"> <div class="row">
<label>File Format</label> <label>File Format</label>
<input type="text" name="file_format" value="${config['file_format']}" size="43"> <input type="text" name="file_format" value="${config['file_format']}" size="43">
<small>Use: $Disc/$disc (disc #), $Track/$track (track #), $Title/$title, $Artist/$artist, $Album/$album and $Year/$year. Put optional variables in curly braces, use single-quote marks to escape curly braces literally ('{', '}').</small> <small>Use: In addition to the above, there is also $Title/$title (track title), $Track (track #), $Disc (disc #), $DiscTotal.</small>
</div> </div>
<div class="checkbox row clearfix"> <div class="checkbox row left clearfix nopad">
<input type="checkbox" name="file_underscores" id="file_underscores" value="1" ${config['file_underscores']}/><label>Use underscores instead of spaces</label> <input type="checkbox" name="file_underscores" id="file_underscores" value="1" ${config['file_underscores']}/><label>Use underscores instead of spaces</label>
</div> </div>
<div class="checkbox row left clearfix nopad">
<input type="checkbox" name="rename_single_disc_ignore" id="rename_single_disc_ignore" value="1" ${config['rename_single_disc_ignore']}/><label>Don't include disc# for single disc albums</label>
</div>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>Re-Encoding Options</legend> <legend>Re-Encoding Options</legend>
<small class="heading"><i class="fa fa-info-circle"></i> Note: this option requires the lame, ffmpeg or xld encoder</small> <small class="heading"><i class="fa fa-info-circle"></i> Note: this option requires the lame, ffmpeg or xld encoder</small>
<div class="checkbox row clearfix"> <div class="checkbox row left clearfix nopad">
<input type="checkbox" name="music_encoder" id="music_encoder" value="1" ${config['music_encoder']}/><label>Re-encode downloads during postprocessing</label> <input type="checkbox" name="music_encoder" id="music_encoder" value="1" ${config['music_encoder']}/><label>Re-encode downloads during postprocessing</label>
</div> </div>
<div id="encoderoptions" class="row clearfix checkbox"> <div id="encoderoptions" class="row clearfix checkbox">
@@ -1570,6 +1670,20 @@
<label>Cache Directory</label> <label>Cache Directory</label>
<input type="text" name="cache_dir" value="${config['cache_dir']}" size="50"> <input type="text" name="cache_dir" value="${config['cache_dir']}" size="50">
</div> </div>
<div class="row">
<label>Post Processing Temporary Directory</label>
<input type="text" name="keep_torrent_files_dir" value="${config['keep_torrent_files_dir']}" size="50" title="Enter the directory to preserve files for seeding, default is system temp directory">
</div>
</fieldset>
<fieldset>
<legend>Last.fm</legend>
<div id="lastfmoptions">
<div class="row">
<label>API Key</label>
<input type="text" name="lastfm_apikey" value="${config['lastfm_apikey']}" size="40" />
</div>
</div>
</fieldset> </fieldset>
<fieldset> <fieldset>
@@ -1626,7 +1740,7 @@
<label>Username</label><input type="text" class="customuser" name="customuser" value="${config['customuser']}" size="20"> <label>Username</label><input type="text" class="customuser" name="customuser" value="${config['customuser']}" size="20">
</div> </div>
<div class="row"> <div class="row">
<label>Password</label><input type="password" class="custompass" name="custompass" value="${config['custompass']}" size="15"><br> <label>Password</label><input type="password" class="custompass" name="custompass" value="${config['custompass'] | h}" size="15"><br>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
@@ -1639,7 +1753,7 @@
<label>Username</label><input type="text" class="hpuser" name="hpuser" value="${config['hpuser']}" size="20"> <label>Username</label><input type="text" class="hpuser" name="hpuser" value="${config['hpuser']}" size="20">
</div> </div>
<div class="row"> <div class="row">
<label>Password</label><input type="password" class="hppass" name="hppass" value="${config['hppass']}" size="20"><br> <label>Password</label><input type="password" class="hppass" name="hppass" value="${config['hppass'] | h}" size="20"><br>
<a href="https://headphones.codeshy.com/vip" id="vipserver" target="_blank">Get an Account!</a> <a href="https://headphones.codeshy.com/vip" id="vipserver" target="_blank">Get an Account!</a>
</div> </div>
</div> </div>
@@ -1814,9 +1928,36 @@
} }
}); });
if ($("#embed_album_art").is(":checked"))
{
$("#album_art_size_options").show();
}
else
{
if (!$("#add_album_art").is(":checked"))
{
$("#album_art_size_options").hide();
}
}
$("#embed_album_art").click(function(){
if ($("#embed_album_art").is(":checked"))
{
$("#album_art_size_options").slideDown();
}
else
{
if (!$("#add_album_art").is(":checked"))
{
$("#album_art_size_options").slideUp();
}
}
});
if ($("#add_album_art").is(":checked")) if ($("#add_album_art").is(":checked"))
{ {
$("#album_art_options").show(); $("#album_art_options").show();
$("#album_art_size_options").show();
} }
else else
{ {
@@ -1827,10 +1968,15 @@
if ($("#add_album_art").is(":checked")) if ($("#add_album_art").is(":checked"))
{ {
$("#album_art_options").slideDown(); $("#album_art_options").slideDown();
$("#album_art_size_options").slideDown();
} }
else else
{ {
$("#album_art_options").slideUp(); $("#album_art_options").slideUp();
if (!$("#embed_album_art").is(":checked"))
{
$("#album_art_size_options").slideUp();
}
} }
}); });
@@ -2035,6 +2181,27 @@
} }
}); });
if ($("#join").is(":checked"))
{
$("#joinoptions").show();
}
else
{
$("#joinoptions").hide();
}
$("#join").click(function(){
if ($("#join").is(":checked"))
{
$("#joinoptions").slideDown();
}
else
{
$("#joinoptions").slideUp();
}
});
if ($("#twitter").is(":checked")) if ($("#twitter").is(":checked"))
{ {
$("#twitteroptions").show(); $("#twitteroptions").show();
@@ -2055,6 +2222,27 @@
} }
}); });
if ($("#slack").is(":checked"))
{
$("#slackoptions").show();
}
else
{
$("#slackoptions").hide();
}
$("#slack").click(function(){
if ($("#slack").is(":checked"))
{
$("#slackoptions").slideDown();
}
else
{
$("#slackoptions").slideUp();
}
});
if ($("#telegram").is(":checked")) if ($("#telegram").is(":checked"))
{ {
$("#telegramoptions").show(); $("#telegramoptions").show();
@@ -2231,26 +2419,30 @@
if ($("#torrent_downloader_blackhole").is(":checked")) if ($("#torrent_downloader_blackhole").is(":checked"))
{ {
$("#transmission_options,#utorrent_options,#deluge_options").hide(); $("#transmission_options,#utorrent_options,#deluge_options,#qbittorrent_options").hide();
$("#torrent_blackhole_options").show(); $("#torrent_blackhole_options").show();
} }
if ($("#torrent_downloader_transmission").is(":checked")) if ($("#torrent_downloader_transmission").is(":checked"))
{ {
$("#torrent_blackhole_options,#utorrent_options,#deluge_options").hide(); $("#torrent_blackhole_options,#utorrent_options,#deluge_options,#qbittorrent_options").hide();
$("#transmission_options").show(); $("#transmission_options").show();
} }
if ($("#torrent_downloader_utorrent").is(":checked")) if ($("#torrent_downloader_utorrent").is(":checked"))
{ {
$("#torrent_blackhole_options,#transmission_options,#deluge_options").hide(); $("#torrent_blackhole_options,#transmission_options,#deluge_options,#qbittorrent_options").hide();
$("#utorrent_options").show(); $("#utorrent_options").show();
} }
if ($("#torrent_downloader_qbittorrent").is(":checked"))
{
$("#torrent_blackhole_options,#transmission_options,#utorrent_options,#deluge_options").hide();
$("#qbittorrent_options").show();
}
if ($("#torrent_downloader_deluge").is(":checked")) if ($("#torrent_downloader_deluge").is(":checked"))
{ {
$("#torrent_blackhole_options,#transmission_options,#utorrent_options").hide(); $("#torrent_blackhole_options,#transmission_options,#utorrent_options,#qbittorrent_options").hide();
$("#deluge_options").show(); $("#deluge_options").show();
} }
$('input[type=radio]').change(function(){ $('input[type=radio]').change(function(){
if ($("#preferred_bitrate").is(":checked")) if ($("#preferred_bitrate").is(":checked"))
{ {
@@ -2286,19 +2478,23 @@
} }
if ($("#torrent_downloader_blackhole").is(":checked")) if ($("#torrent_downloader_blackhole").is(":checked"))
{ {
$("#transmission_options,#utorrent_options,#deluge_options").fadeOut("fast", function() { $("#torrent_blackhole_options").fadeIn() }); $("#transmission_options,#utorrent_options,#deluge_options,#qbittorrent_options").fadeOut("fast", function() { $("#torrent_blackhole_options").fadeIn() });
} }
if ($("#torrent_downloader_transmission").is(":checked")) if ($("#torrent_downloader_transmission").is(":checked"))
{ {
$("#torrent_blackhole_options,#utorrent_options,#deluge_options").fadeOut("fast", function() { $("#transmission_options").fadeIn() }); $("#torrent_blackhole_options,#utorrent_options,#deluge_options,#qbittorrent_options").fadeOut("fast", function() { $("#transmission_options").fadeIn() });
} }
if ($("#torrent_downloader_utorrent").is(":checked")) if ($("#torrent_downloader_utorrent").is(":checked"))
{ {
$("#torrent_blackhole_options,#transmission_options,#deluge_options").fadeOut("fast", function() { $("#utorrent_options").fadeIn() }); $("#torrent_blackhole_options,#transmission_options,#deluge_options,#qbittorrent_options").fadeOut("fast", function() { $("#utorrent_options").fadeIn() });
} }
if ($("#torrent_downloader_qbittorrent").is(":checked"))
{
$("#torrent_blackhole_options,#transmission_options,#utorrent_options,#deluge_options").fadeOut("fast", function() { $("#qbittorrent_options").fadeIn() });
}
if ($("#torrent_downloader_deluge").is(":checked")) if ($("#torrent_downloader_deluge").is(":checked"))
{ {
$("#torrent_blackhole_options,#utorrent_options,#transmission_options").fadeOut("fast", function() { $("#deluge_options").fadeIn() }); $("#torrent_blackhole_options,#utorrent_options,#transmission_options,#qbittorrent_options").fadeOut("fast", function() { $("#deluge_options").fadeIn() });
} }
}); });
@@ -2360,7 +2556,7 @@
$("#add_torznab").click(function() { $("#add_torznab").click(function() {
var intId = $("#torznab_providers > div").size() + deletedTorznabs + 1; var intId = $("#torznab_providers > div").size() + deletedTorznabs + 1;
var formfields = $("<div class=\"config\" id=\"torznab" + intId + "\"><div class=\"row\"><label>Torznab Host</label><input type=\"text\" name=\"torznab_host" + intId + "\" size=\"30\"></div><div class=\"row\"><label>Torznab API</label><input type=\"text\" name=\"torznab_api" + intId + "\" size=\"36\"></div><div class=\"row checkbox\"><input type=\"checkbox\" name=\"torznab_enabled" + intId + "\" value=\"1\" checked /><label>Enabled</label></div>"); var formfields = $("<div class=\"config\" id=\"torznab" + intId + "\"><div class=\"row\"><label>Torznab Host</label><input type=\"text\" name=\"torznab_host" + intId + "\" size=\"30\"></div><div class=\"row\"><label>Torznab API</label><input type=\"text\" name=\"torznab_api" + intId + "\" size=\"36\"></div><div class=\"row\"><label>Torznab Seed Ratio</label><input type=\"text\" class=\"override-float\" name=\"torznab_ratio" + intId + "\" size=\"10\"></div><div class=\"row checkbox\"><input type=\"checkbox\" name=\"torznab_enabled" + intId + "\" value=\"1\" checked /><label>Enabled</label></div>");
var removeTorznabButton = $("<div class=\"row\"><input type=\"button\" class=\"remove_torznab\" value=\"Remove\" /></div>"); var removeTorznabButton = $("<div class=\"row\"><input type=\"button\" class=\"remove_torznab\" value=\"Remove\" /></div>");
removeTorznabButton.click(function() { removeTorznabButton.click(function() {
$(this).parent().remove(); $(this).parent().remove();
@@ -2389,18 +2585,14 @@
initConfigCheckbox("#use_nzbsorg"); initConfigCheckbox("#use_nzbsorg");
initConfigCheckbox("#use_omgwtfnzbs"); initConfigCheckbox("#use_omgwtfnzbs");
initConfigCheckbox("#use_torznab"); initConfigCheckbox("#use_torznab");
initConfigCheckbox("#use_kat");
initConfigCheckbox("#use_piratebay"); initConfigCheckbox("#use_piratebay");
initConfigCheckbox("#use_oldpiratebay");
initConfigCheckbox("#use_mininova");
initConfigCheckbox("#use_waffles");
initConfigCheckbox("#use_rutracker"); initConfigCheckbox("#use_rutracker");
initConfigCheckbox("#use_whatcd"); initConfigCheckbox("#use_orpheus");
initConfigCheckbox("#use_strike"); initConfigCheckbox("#use_redacted");
initConfigCheckbox("#api_enabled"); initConfigCheckbox("#api_enabled");
initConfigCheckbox("#enable_https"); initConfigCheckbox("#enable_https");
initConfigCheckbox("#customauth"); initConfigCheckbox("#customauth");
initConfigCheckbox("#mb_ignore_age_missing");
$('#twitterStep1').click(function () { $('#twitterStep1').click(function () {
$.get("/twitterStep1", function (data) {window.open(data); }) $.get("/twitterStep1", function (data) {window.open(data); })
+6 -4
View File
@@ -1,7 +1,7 @@
<%inherit file="base.html"/> <%inherit file="base.html"/>
<%! <%!
from headphones import helpers from headphones import helpers
import cgi from html import escape as html_escape
%> %>
<%def name="headerIncludes()"> <%def name="headerIncludes()">
@@ -56,17 +56,19 @@
fileid = 'torrent' fileid = 'torrent'
if item['URL'].find('codeshy') != -1: if item['URL'].find('codeshy') != -1:
fileid = 'nzb' fileid = 'nzb'
if item['URL'].find('bandcamp') != -1:
fileid = 'bandcamp'
folder = 'Folder: ' + item['FolderName'] 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">${html_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 title="${folder}" id="status">${item['Status']}</td> <td title="${folder}" id="status">${item['Status']}</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="action">[<a href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${item['AlbumID']}&redirect=history', $(this),'table')" data-success="Retrying download of '${html_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 '${html_escape(item['Title'], quote=True)}'">new</a>]</td>
<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> <td id="delete"><a href="javascript:void(0)" onclick="doAjaxCall('clearhistory?date_added=${item['DateAdded']}&title=${html_escape(item['Title'], quote=True)}',$(this),'table')" data-success="${html_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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 860 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 B

After

Width:  |  Height:  |  Size: 89 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 93 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 757 B

After

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 205 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 860 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 176 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 251 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 264 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 345 B

After

Width:  |  Height:  |  Size: 335 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 405 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 386 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 847 B

After

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 847 B

After

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 968 B

After

Width:  |  Height:  |  Size: 87 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 B

After

Width:  |  Height:  |  Size: 101 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 975 B

After

Width:  |  Height:  |  Size: 83 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 838 B

After

Width:  |  Height:  |  Size: 56 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 116 B

+1 -1
View File
@@ -36,7 +36,7 @@
"aTargets": [0], "aTargets": [0],
"mData":"ArtistID", "mData":"ArtistID",
"mRender": function ( data, type, full ) { "mRender": function ( data, type, full ) {
return '<div id="artistImg"><img class="albumArt" alt="" id="'+ data + '" data-src="artwork/thumbs/artist/' + data + '"/></div>'; return '<div id="artistImg"><img class="albumArt" height="50" width="50" alt="" id="'+ data + '" data-src="artwork/thumbs/artist/' + data + '"/></div>';
} }
}, },
{ {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1003 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1010 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 B

After

Width:  |  Height:  |  Size: 106 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 B

After

Width:  |  Height:  |  Size: 98 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 B

After

Width:  |  Height:  |  Size: 107 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 B

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 B

After

Width:  |  Height:  |  Size: 98 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 B

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 B

After

Width:  |  Height:  |  Size: 158 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 B

After

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+1
View File
@@ -312,6 +312,7 @@ function doAjaxCall(url,elem,reload,form) {
$.ajax({ $.ajax({
url: url, url: url,
data: dataString, data: dataString,
type: 'POST',
beforeSend: function(jqXHR, settings) { beforeSend: function(jqXHR, settings) {
// Start loader etc. // Start loader etc.
feedback.prepend(loader); feedback.prepend(loader);
+54 -10
View File
@@ -86,8 +86,8 @@ CURRENT_VERSION = None
LATEST_VERSION = None LATEST_VERSION = None
COMMITS_BEHIND = None COMMITS_BEHIND = None
LOSSY_MEDIA_FORMATS = ["mp3", "aac", "ogg", "ape", "m4a", "asf", "wma"] LOSSY_MEDIA_FORMATS = ["mp3", "aac", "ogg", "ape", "m4a", "asf", "wma", "opus"]
LOSSLESS_MEDIA_FORMATS = ["flac", "aiff"] LOSSLESS_MEDIA_FORMATS = ["flac", "aiff", "aif"]
MEDIA_FORMATS = LOSSY_MEDIA_FORMATS + LOSSLESS_MEDIA_FORMATS MEDIA_FORMATS = LOSSY_MEDIA_FORMATS + LOSSLESS_MEDIA_FORMATS
MIRRORLIST = ["musicbrainz.org", "headphones", "custom"] MIRRORLIST = ["musicbrainz.org", "headphones", "custom"]
@@ -218,7 +218,7 @@ def daemonize():
pid = os.fork() # @UndefinedVariable - only available in UNIX pid = os.fork() # @UndefinedVariable - only available in UNIX
if pid != 0: if pid != 0:
sys.exit(0) sys.exit(0)
except OSError, e: except OSError as e:
raise RuntimeError("1st fork failed: %s [%d]", e.strerror, e.errno) raise RuntimeError("1st fork failed: %s [%d]", e.strerror, e.errno)
os.setsid() os.setsid()
@@ -232,10 +232,10 @@ def daemonize():
pid = os.fork() # @UndefinedVariable - only available in UNIX pid = os.fork() # @UndefinedVariable - only available in UNIX
if pid != 0: if pid != 0:
sys.exit(0) sys.exit(0)
except OSError, e: except OSError as e:
raise RuntimeError("2nd fork failed: %s [%d]", e.strerror, e.errno) raise RuntimeError("2nd fork failed: %s [%d]", e.strerror, e.errno)
dev_null = file('/dev/null', 'r') dev_null = open('/dev/null', 'r')
os.dup2(dev_null.fileno(), sys.stdin.fileno()) os.dup2(dev_null.fileno(), sys.stdin.fileno())
si = open('/dev/null', "r") si = open('/dev/null', "r")
@@ -251,7 +251,7 @@ def daemonize():
if CREATEPID: if CREATEPID:
logger.info("Writing PID %d to %s", pid, PIDFILE) logger.info("Writing PID %d to %s", pid, PIDFILE)
with file(PIDFILE, 'w') as fp: with open(PIDFILE, 'w') as fp:
fp.write("%s\n" % pid) fp.write("%s\n" % pid)
@@ -306,9 +306,10 @@ def initialize_scheduler():
minutes=minutes) minutes=minutes)
# Remove Torrent + data if Post Processed and finished Seeding # Remove Torrent + data if Post Processed and finished Seeding
minutes = CONFIG.TORRENT_REMOVAL_INTERVAL if headphones.CONFIG.TORRENT_DOWNLOADER != 0:
schedule_job(torrentfinished.checkTorrentFinished, 'Torrent removal check', hours=0, minutes = CONFIG.TORRENT_REMOVAL_INTERVAL
minutes=minutes) schedule_job(torrentfinished.checkTorrentFinished, 'Torrent removal check', hours=0,
minutes=minutes)
# Start scheduler # Start scheduler
if start_jobs and len(SCHED.get_jobs()): if start_jobs and len(SCHED.get_jobs()):
@@ -359,6 +360,8 @@ def sig_handler(signum=None, frame=None):
def dbcheck(): def dbcheck():
logger.debug("SQLite Version: %s", sqlite3.sqlite_version)
logger.debug("DB-API Version: %s", sqlite3.version)
conn = sqlite3.connect(DB_FILE) conn = sqlite3.connect(DB_FILE)
c = conn.cursor() c = conn.cursor()
c.execute( c.execute(
@@ -376,7 +379,7 @@ def dbcheck():
c.execute( c.execute(
'CREATE TABLE IF NOT EXISTS alltracks (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 alltracks (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)')
c.execute( c.execute(
'CREATE TABLE IF NOT EXISTS snatched (AlbumID TEXT, Title TEXT, Size INTEGER, URL TEXT, DateAdded TEXT, Status TEXT, FolderName TEXT, Kind TEXT)') 'CREATE TABLE IF NOT EXISTS snatched (AlbumID TEXT, Title TEXT, Size INTEGER, URL TEXT, DateAdded TEXT, Status TEXT, FolderName TEXT, Kind TEXT, TorrentHash TEXT)')
# Matched is a temporary value used to see if there was a match found in # Matched is a temporary value used to see if there was a match found in
# alltracks # alltracks
c.execute( c.execute(
@@ -416,6 +419,33 @@ def dbcheck():
'CREATE INDEX IF NOT EXISTS tracks_Location ON tracks(Location ASC)') 'CREATE INDEX IF NOT EXISTS tracks_Location ON tracks(Location ASC)')
c.execute( c.execute(
'CREATE INDEX IF NOT EXISTS alltracks_Location ON alltracks(Location ASC)') 'CREATE INDEX IF NOT EXISTS alltracks_Location ON alltracks(Location ASC)')
c.execute(
'CREATE INDEX IF NOT EXISTS tracks_artistid ON tracks(ArtistID ASC)')
# Speed up album page
c.execute('CREATE INDEX IF NOT EXISTS allalbums_albumid ON allalbums(AlbumID ASC)')
c.execute('CREATE INDEX IF NOT EXISTS alltracks_albumid ON alltracks(AlbumID ASC)')
c.execute('CREATE INDEX IF NOT EXISTS releases_albumid ON releases(ReleaseGroupID ASC)')
c.execute('CREATE INDEX IF NOT EXISTS descriptions_albumid ON descriptions(ReleaseGroupID ASC)')
# Speed up artist deletion
c.execute('CREATE INDEX IF NOT EXISTS allalbums_artistid ON allalbums(ArtistID ASC)')
c.execute('CREATE INDEX IF NOT EXISTS alltracks_artistid ON alltracks(ArtistID ASC)')
c.execute('CREATE INDEX IF NOT EXISTS descriptions_artistid ON descriptions(ArtistID ASC)')
# Speed up Artist refresh hybrid release
c.execute('CREATE INDEX IF NOT EXISTS albums_releaseid ON albums(ReleaseID ASC)')
c.execute('CREATE INDEX IF NOT EXISTS tracks_releaseid ON tracks(ReleaseID ASC)')
# Speed up scanning and track matching
c.execute('CREATE INDEX IF NOT EXISTS artist_artistname ON artists(ArtistName COLLATE NOCASE ASC)')
# General speed up
c.execute('CREATE INDEX IF NOT EXISTS artist_artistsortname ON artists(ArtistSortName COLLATE NOCASE ASC)')
c.execute(
"""CREATE INDEX IF NOT EXISTS have_matched_artist_album ON have(Matched ASC, ArtistName COLLATE NOCASE ASC, AlbumTitle COLLATE NOCASE ASC)""")
c.execute('DROP INDEX IF EXISTS have_matched')
try: try:
c.execute('SELECT IncludeExtras from artists') c.execute('SELECT IncludeExtras from artists')
@@ -613,6 +643,20 @@ def dbcheck():
except sqlite3.OperationalError: except sqlite3.OperationalError:
c.execute('ALTER TABLE artists ADD COLUMN MetaCritic TEXT DEFAULT NULL') c.execute('ALTER TABLE artists ADD COLUMN MetaCritic TEXT DEFAULT NULL')
try:
c.execute('SELECT TorrentHash from snatched')
except sqlite3.OperationalError:
c.execute('ALTER TABLE snatched ADD COLUMN TorrentHash TEXT')
c.execute('UPDATE snatched SET TorrentHash = FolderName WHERE Status LIKE "Seed_%"')
# One off script to set CleanName to lower case
clean_name_mixed = c.execute('SELECT CleanName FROM have ORDER BY Date Desc').fetchone()
if clean_name_mixed and clean_name_mixed[0] != clean_name_mixed[0].lower():
logger.info("Updating track clean name, this could take some time...")
c.execute('UPDATE tracks SET CleanName = LOWER(CleanName) WHERE LOWER(CleanName) != CleanName')
c.execute('UPDATE alltracks SET CleanName = LOWER(CleanName) WHERE LOWER(CleanName) != CleanName')
c.execute('UPDATE have SET CleanName = LOWER(CleanName) WHERE LOWER(CleanName) != CleanName')
conn.commit() conn.commit()
c.close() c.close()
+187 -7
View File
@@ -13,16 +13,196 @@
# 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 request, db, logger import struct
from six.moves.urllib.parse import urlencode
from io import BytesIO
import headphones
from headphones import db, request, logger
def getAlbumArt(albumid): def getAlbumArt(albumid):
myDB = db.DBConnection()
asin = myDB.action(
'SELECT AlbumASIN from albums WHERE AlbumID=?', [albumid]).fetchone()[0]
if asin: artwork_path = None
return 'http://ec1.images-amazon.com/images/P/%s.01.LZZZZZZZ.jpg' % asin artwork = None
# CAA
logger.info("Searching for artwork at CAA")
#artwork_path = 'https://coverartarchive.org/release-group/%s/front' % albumid
artwork_path = 'https://coverartarchive.org/release-group/%s' % albumid
data = request.request_json(artwork_path, timeout=20, whitelist_status_code=404)
image_url = None
if data:
for item in data.get("images", []):
try:
if "Front" not in item["types"]:
continue
# Use desired size
image_url = item["image"]
if headphones.CONFIG.ALBUM_ART_MAX_WIDTH:
if isinstance(item.get("thumbnails"), dict):
image_url = item["thumbnails"].get(
headphones.CONFIG.ALBUM_ART_MAX_WIDTH, image_url
)
break
except KeyError:
pass
if image_url:
artwork = getartwork(image_url)
if artwork:
logger.info("Artwork found at CAA")
return artwork_path, artwork
# Amazon
logger.info("Searching for artwork at Amazon")
myDB = db.DBConnection()
dbalbum = myDB.action(
'SELECT ArtistName, AlbumTitle, ReleaseID, AlbumASIN FROM albums WHERE AlbumID=?',
[albumid]).fetchone()
if dbalbum['AlbumASIN']:
artwork_path = 'https://ec1.images-amazon.com/images/P/%s.01.LZZZZZZZ.jpg' % dbalbum['AlbumASIN']
artwork = getartwork(artwork_path)
if artwork:
logger.info("Artwork found at Amazon")
return artwork_path, artwork
# last.fm
from headphones import lastfm
logger.info("Searching for artwork at last.fm")
if dbalbum['ReleaseID'] != albumid:
data = lastfm.request_lastfm("album.getinfo", mbid=dbalbum['ReleaseID'])
if not data:
data = lastfm.request_lastfm("album.getinfo", artist=dbalbum['ArtistName'],
album=dbalbum['AlbumTitle'])
else:
data = lastfm.request_lastfm("album.getinfo", artist=dbalbum['ArtistName'],
album=dbalbum['AlbumTitle'])
if data:
try:
images = data['album']['image']
for image in images:
if image['size'] == 'extralarge':
artwork_path = image['#text']
elif image['size'] == 'mega':
artwork_path = image['#text']
break
except KeyError:
artwork_path = None
if artwork_path:
artwork = getartwork(artwork_path)
if artwork:
logger.info("Artwork found at last.fm")
return artwork_path, artwork
logger.info("No suitable album art found.")
return None, None
def jpeg(bites):
fhandle = BytesIO(bites)
try:
fhandle.seek(0)
size = 2
ftype = 0
while not 0xc0 <= ftype <= 0xcf:
fhandle.seek(size, 1)
byte = fhandle.read(1)
while ord(byte) == 0xff:
byte = fhandle.read(1)
ftype = ord(byte)
size = struct.unpack('>H', fhandle.read(2))[0] - 2
fhandle.seek(1, 1)
height, width = struct.unpack('>HH', fhandle.read(4))
return width, height
except struct.error:
return None, None
except TypeError:
return None, None
def png(bites):
try:
check = struct.unpack('>i', bites[4:8])[0]
if check != 0x0d0a1a0a:
return None, None
return struct.unpack('>ii', bites[16:24])
except struct.error:
return None, None
def get_image_data(bites):
type = None
width = None
height = None
if len(bites) < 24:
return None, None, None
peek = bites[0:2]
if peek == b'\xff\xd8':
width, height = jpeg(bites)
type = 'jpg'
elif peek == b'\x89P':
width, height = png(bites)
type = 'png'
return type, width, height
def getartwork(artwork_path):
artwork = bytes()
minwidth = 0
maxwidth = 0
if headphones.CONFIG.ALBUM_ART_MIN_WIDTH:
minwidth = int(headphones.CONFIG.ALBUM_ART_MIN_WIDTH)
if headphones.CONFIG.ALBUM_ART_MAX_WIDTH:
maxwidth = int(headphones.CONFIG.ALBUM_ART_MAX_WIDTH)
resp = request.request_response(artwork_path, timeout=20, stream=True, whitelist_status_code=404)
if resp:
img_width = None
for chunk in resp.iter_content(chunk_size=1024):
artwork += chunk
if not img_width and (minwidth or maxwidth):
img_type, img_width, img_height = get_image_data(artwork)
# Check min/max
if img_width and (minwidth or maxwidth):
if minwidth and img_width < minwidth:
logger.info("Artwork is too small. Type: %s. Width: %s. Height: %s",
img_type, img_width, img_height)
artwork = None
break
elif maxwidth and img_width > maxwidth:
# Downsize using proxy service to max width
artwork = bytes()
url = "https://images.weserv.nl"
params = {
"url": artwork_path,
"w": maxwidth
}
headers = {"User-Agent": "Headphones"}
r = request.request_response(
url,
params=params,
timeout=20,
stream=True,
whitelist_status_code=404,
headers=headers
)
if r:
for chunk in r.iter_content(chunk_size=1024):
artwork += chunk
r.close()
logger.info("Artwork is greater than the maximum width, downsized using proxy service")
break
resp.close()
return artwork
def getCachedArt(albumid): def getCachedArt(albumid):
@@ -34,7 +214,7 @@ def getCachedArt(albumid):
if not artwork_path: if not artwork_path:
return return
if artwork_path.startswith('http://'): if artwork_path.startswith("http"):
artwork = request.request_content(artwork_path, timeout=20) artwork = request.request_content(artwork_path, timeout=20)
if not artwork: if not artwork:
+2 -2
View File
@@ -85,8 +85,8 @@ def switch(AlbumID, ReleaseID):
# 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")', 'SELECT TrackTitle from tracks AS tr INNER JOIN albums AS al ON al.AlbumID = tr.AlbumID WHERE al.ArtistID=? '
[newalbumdata['ArtistID']])) 'AND al.Status != "Ignored"', [newalbumdata['ArtistID']]))
havetracks = len(myDB.select( havetracks = len(myDB.select(
'SELECT TrackTitle from tracks WHERE ArtistID=? AND Location IS NOT NULL', 'SELECT TrackTitle from tracks WHERE ArtistID=? AND Location IS NOT NULL',
[newalbumdata['ArtistID']])) [newalbumdata['ArtistID']]))
+8 -8
View File
@@ -82,11 +82,11 @@ class Api(object):
def fetchData(self): def fetchData(self):
if self.data == 'OK': if self.data == 'OK':
logger.info('Recieved API command: %s', self.cmd) logger.info('Received API command: %s', self.cmd)
methodToCall = getattr(self, "_" + self.cmd) methodToCall = getattr(self, "_" + self.cmd)
methodToCall(**self.kwargs) methodToCall(**self.kwargs)
if 'callback' not in self.kwargs: if 'callback' not in self.kwargs:
if isinstance(self.data, basestring): if isinstance(self.data, str):
return self.data return self.data
else: else:
return json.dumps(self.data) return json.dumps(self.data)
@@ -106,7 +106,7 @@ class Api(object):
rows_as_dic = [] rows_as_dic = []
for row in rows: for row in rows:
row_as_dic = dict(zip(row.keys(), row)) row_as_dic = dict(list(zip(list(row.keys()), row)))
rows_as_dic.append(row_as_dic) rows_as_dic.append(row_as_dic)
return rows_as_dic return rows_as_dic
@@ -474,17 +474,17 @@ class Api(object):
# Handle situations where the torrent url contains arguments that are # Handle situations where the torrent url contains arguments that are
# parsed # parsed
if kwargs: if kwargs:
import urllib import urllib.request, urllib.parse, urllib.error
import urllib2 import urllib.request, urllib.error, urllib.parse
url = urllib2.quote( url = urllib.parse.quote(
url, safe=":?/=&") + '&' + urllib.urlencode(kwargs) url, safe=":?/=&") + '&' + urllib.parse.urlencode(kwargs)
try: try:
result = [(title, int(size), url, provider, kind)] result = [(title, int(size), url, provider, kind)]
except ValueError: except ValueError:
result = [(title, float(size), url, provider, kind)] result = [(title, float(size), url, provider, kind)]
logger.info(u"Making sure we can download the chosen result") logger.info("Making sure we can download the chosen result")
(data, bestqual) = searcher.preprocess(result) (data, bestqual) = searcher.preprocess(result)
if data and bestqual: if data and bestqual:
+166
View File
@@ -0,0 +1,166 @@
# 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 headphones
import json
import os
import re
from headphones import logger, helpers, metadata, request
from headphones.common import USER_AGENT
from headphones.types import Result
from mediafile import MediaFile, UnreadableFileError
from bs4 import BeautifulSoup
from bs4 import FeatureNotFound
def search(album, albumlength=None, page=1, resultlist=None):
dic = {'...': '', ' & ': ' ', ' = ': ' ', '?': '', '$': 's', ' + ': ' ',
'"': '', ',': '', '*': '', '.': '', ':': ''}
if resultlist is None:
resultlist = []
cleanalbum = helpers.latinToAscii(
helpers.replace_all(album['AlbumTitle'], dic)
).strip()
cleanartist = helpers.latinToAscii(
helpers.replace_all(album['ArtistName'], dic)
).strip()
headers = {'User-Agent': USER_AGENT}
params = {
"page": page,
"q": cleanalbum,
}
logger.info("Looking up https://bandcamp.com/search with {}".format(
params))
content = request.request_content(
url='https://bandcamp.com/search',
params=params,
headers=headers
).decode('utf8')
try:
soup = BeautifulSoup(content, "html5lib")
except FeatureNotFound:
soup = BeautifulSoup(content, "html.parser")
for item in soup.find_all("li", class_="searchresult"):
type = item.find('div', class_='itemtype').text.strip().lower()
if type == "album":
data = parse_album(item)
cleanartist_found = helpers.latinToAscii(data['artist'])
cleanalbum_found = helpers.latinToAscii(data['album'])
logger.debug(u"{} - {}".format(data['album'], cleanalbum_found))
logger.debug("Comparing {} to {}".format(
cleanalbum, cleanalbum_found))
if (cleanartist.lower() == cleanartist_found.lower() and
cleanalbum.lower() == cleanalbum_found.lower()):
resultlist.append(Result(
data['title'], data['size'], data['url'],
'bandcamp', 'bandcamp', True))
else:
continue
if(soup.find('a', class_='next')):
page += 1
logger.debug("Calling next page ({})".format(page))
search(album, albumlength=albumlength,
page=page, resultlist=resultlist)
return resultlist
def download(album, bestqual):
html = request.request_content(url=bestqual.url).decode('utf-8')
trackinfo = []
try:
trackinfo = json.loads(
re.search(r"trackinfo&quot;:(\[.*?\]),", html)
.group(1)
.replace('&quot;', '"'))
except ValueError as e:
logger.warn("Couldn't load json: {}".format(e))
directory = os.path.join(
headphones.CONFIG.BANDCAMP_DIR,
u'{} - {}'.format(
album['ArtistName'].replace('/', '_'),
album['AlbumTitle'].replace('/', '_')))
directory = helpers.latinToAscii(directory)
if not os.path.exists(directory):
try:
os.makedirs(directory)
except Exception as e:
logger.warn("Could not create directory ({})".format(e))
index = 1
for track in trackinfo:
filename = helpers.replace_illegal_chars(
u'{:02d} - {}.mp3'.format(index, track['title']))
fullname = os.path.join(directory.encode('utf-8'),
filename.encode('utf-8'))
logger.debug("Downloading to {}".format(fullname))
if 'file' in track and track['file'] != None and 'mp3-128' in track['file']:
content = request.request_content(track['file']['mp3-128'])
open(fullname, 'wb').write(content)
try:
f = MediaFile(fullname)
date, year = metadata._date_year(album)
f.update({
'artist': album['ArtistName'].encode('utf-8'),
'album': album['AlbumTitle'].encode('utf-8'),
'title': track['title'].encode('utf-8'),
'track': track['track_num'],
'tracktotal': len(trackinfo),
'year': year,
})
f.save()
except UnreadableFileError as ex:
logger.warn("MediaFile couldn't parse: %s (%s)",
fullname,
str(ex))
index += 1
return directory
def parse_album(item):
album = item.find('div', class_='heading').text.strip()
artist = item.find('div', class_='subhead').text.strip().replace("by ", "")
released = item.find('div', class_='released').text.strip().replace(
"released ", "")
year = re.search(r"(\d{4})", released).group(1)
url = item.find('div', class_='heading').find('a')['href'].split("?")[0]
length = item.find('div', class_='length').text.strip()
tracks, minutes = length.split(",")
tracks = tracks.replace(" tracks", "").replace(" track", "").strip()
minutes = minutes.replace(" minutes", "").strip()
# bandcamp offers mp3 128b with should be 960KB/minute
size = int(minutes) * 983040
data = {"title": u'{} - {} [{}]'.format(artist, album, year),
"artist": artist, "album": album,
"url": url, "size": size}
return data
+141 -32
View File
@@ -14,11 +14,16 @@
# 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
from six.moves.urllib.parse import urlencode
import headphones import headphones
from headphones import db, helpers, logger, lastfm, request from headphones import db, helpers, logger, lastfm, request, mb
LASTFM_API_KEY = "690e1ed3bc00bc91804cd8f7fe5ed6d4" LASTFM_API_KEY = "8d983789c771afaeb7412ac358d4bad0"
FANART_URL = 'https://webservice.fanart.tv/v3/music/'
FANART_PROJECT_KEY = '22b73c9603eba09d0c855f2d2bdba31c'
FANART_CLIENT_KEY = '919b389a18a3f0b2c916090022ab3c7a'
class Cache(object): class Cache(object):
@@ -96,6 +101,11 @@ class Cache(object):
def _is_current(self, filename=None, date=None): def _is_current(self, filename=None, date=None):
if filename: if filename:
# 2019 last.fm no longer allows access to artist images, for now we'll keep the cached artist image if it exists and get new from fanart.tv
if self.id_type == 'artist' and 'fanart' not in filename:
return True
base_filename = os.path.basename(filename) base_filename = os.path.basename(filename)
date = base_filename.split('.')[1] date = base_filename.split('.')[1]
@@ -211,20 +221,34 @@ class Cache(object):
if ArtistID: if ArtistID:
self.id_type = 'artist' self.id_type = 'artist'
data = lastfm.request_lastfm("artist.getinfo", mbid=ArtistID, api_key=LASTFM_API_KEY)
# 2019 last.fm no longer allows access to artist images, try fanart.tv instead
image_url = None
thumb_url = None
data = request.request_json(FANART_URL + ArtistID, whitelist_status_code=404,
headers={'api-key': FANART_PROJECT_KEY, 'client-key': FANART_CLIENT_KEY})
if not data: if not data:
return return
try: if data.get('artistthumb'):
image_url = data['artist']['image'][-1]['#text'] image_url = data['artistthumb'][0]['url']
except (KeyError, IndexError): elif data.get('artistbackground'):
logger.debug('No artist image found') image_url = data['artistbackground'][0]['url']
image_url = None # elif data.get('hdmusiclogo'):
# image_url = data['hdmusiclogo'][0]['url']
thumb_url = self._get_thumb_url(data) # fallback to 1st album cover if none of the above
if not thumb_url: elif 'albums' in data:
logger.debug('No artist thumbnail image found') for mbid, art in list(data.get('albums', dict()).items()):
if 'albumcover' in art:
image_url = art['albumcover'][0]['url']
break
if image_url:
thumb_url = image_url
else:
logger.debug('No artist image found on fanart.tv for Artist Id: %s', self.id)
else: else:
@@ -283,6 +307,7 @@ class Cache(object):
""" """
myDB = db.DBConnection() myDB = db.DBConnection()
fanart = False
# 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
# Exception is when adding albums manually, then we should use release id # Exception is when adding albums manually, then we should use release id
@@ -290,6 +315,14 @@ class Cache(object):
data = lastfm.request_lastfm("artist.getinfo", mbid=self.id, api_key=LASTFM_API_KEY) data = lastfm.request_lastfm("artist.getinfo", mbid=self.id, api_key=LASTFM_API_KEY)
# Try with name if not found
if not data:
dbartist = myDB.action('SELECT ArtistName, Type FROM artists WHERE ArtistID=?', [self.id]).fetchone()
if dbartist:
data = lastfm.request_lastfm("artist.getinfo",
artist=helpers.clean_musicbrainz_name(dbartist['ArtistName']),
api_key=LASTFM_API_KEY)
if not data: if not data:
return return
@@ -303,30 +336,70 @@ class Cache(object):
except KeyError: except KeyError:
logger.debug('No artist bio found') logger.debug('No artist bio found')
self.info_content = None self.info_content = None
try:
image_url = data['artist']['image'][-1]['#text']
except KeyError:
logger.debug('No artist image found')
image_url = None
thumb_url = self._get_thumb_url(data) # 2019 last.fm no longer allows access to artist images, try fanart.tv instead
if not thumb_url: image_url = None
logger.debug('No artist thumbnail image found') thumb_url = None
data = request.request_json(FANART_URL + self.id, whitelist_status_code=404,
headers={'api-key': FANART_PROJECT_KEY, 'client-key': FANART_CLIENT_KEY})
if data.get('artistthumb'):
image_url = data['artistthumb'][0]['url']
elif data.get('artistbackground'):
image_url = data['artistbackground'][0]['url']
# elif data.get('hdmusiclogo'):
# image_url = data['hdmusiclogo'][0]['url']
# fallback to 1st album cover if none of the above
elif 'albums' in data:
for mbid, art in list(data.get('albums', dict()).items()):
if 'albumcover' in art:
image_url = art['albumcover'][0]['url']
break
# finally, use 1st album cover from last.fm
if image_url:
fanart = True
thumb_url = image_url
else:
dbalbum = myDB.action(
'SELECT ArtworkURL, ThumbURL FROM albums WHERE ArtworkURL IS NOT NULL AND ArtistID=?',
[self.id]).fetchone()
if dbalbum:
fanart = True
image_url = dbalbum['ArtworkURL']
thumb_url = dbalbum['ThumbURL']
if not image_url:
logger.debug('No artist image found on fanart.tv for Artist Id: %s', self.id)
else: else:
dbalbum = myDB.action( dbalbum = myDB.action(
'SELECT ArtistName, AlbumTitle, ReleaseID FROM albums WHERE AlbumID=?', 'SELECT ArtistName, AlbumTitle, ReleaseID, Type FROM albums WHERE AlbumID=?',
[self.id]).fetchone() [self.id]).fetchone()
if dbalbum['ReleaseID'] != self.id: if dbalbum['ReleaseID'] != self.id:
data = lastfm.request_lastfm("album.getinfo", mbid=dbalbum['ReleaseID'], data = lastfm.request_lastfm("album.getinfo", mbid=dbalbum['ReleaseID'],
api_key=LASTFM_API_KEY) api_key=LASTFM_API_KEY)
if not data: if not data:
data = lastfm.request_lastfm("album.getinfo", artist=dbalbum['ArtistName'], data = lastfm.request_lastfm("album.getinfo",
album=dbalbum['AlbumTitle'], artist=helpers.clean_musicbrainz_name(dbalbum['ArtistName']),
album=helpers.clean_musicbrainz_name(dbalbum['AlbumTitle']),
api_key=LASTFM_API_KEY) api_key=LASTFM_API_KEY)
else: else:
data = lastfm.request_lastfm("album.getinfo", artist=dbalbum['ArtistName'], if dbalbum['Type'] != "part of":
album=dbalbum['AlbumTitle'], api_key=LASTFM_API_KEY) data = lastfm.request_lastfm("album.getinfo",
artist=helpers.clean_musicbrainz_name(dbalbum['ArtistName']),
album=helpers.clean_musicbrainz_name(dbalbum['AlbumTitle']),
api_key=LASTFM_API_KEY)
else:
# Series, use actual artist for the release-group
artist = mb.getArtistForReleaseGroup(self.id)
if artist:
data = lastfm.request_lastfm("album.getinfo",
artist=helpers.clean_musicbrainz_name(artist),
album=helpers.clean_musicbrainz_name(dbalbum['AlbumTitle']),
api_key=LASTFM_API_KEY)
if not data: if not data:
return return
@@ -406,7 +479,11 @@ 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, if fanart:
artwork_path = os.path.join(self.path_to_art_cache,
self.id + '_fanart_' + '.' + helpers.today() + ext)
else:
artwork_path = os.path.join(self.path_to_art_cache,
self.id + '.' + helpers.today() + ext) self.id + '.' + helpers.today() + ext)
try: try:
with open(artwork_path, 'wb') as f: with open(artwork_path, 'wb') as f:
@@ -422,7 +499,9 @@ class Cache(object):
# as it's missing/outdated. # as it's missing/outdated.
if thumb_url and self.query_type in ['thumb', 'artwork'] and not ( if thumb_url and self.query_type in ['thumb', 'artwork'] and not (
self.thumb_files and self._is_current(self.thumb_files[0])): self.thumb_files and self._is_current(self.thumb_files[0])):
artwork = request.request_content(thumb_url, timeout=20)
if not (self.query_type == 'artwork' and 'fanart' in thumb_url and artwork):
artwork = request.request_content(thumb_url, timeout=20)
if artwork: if artwork:
# Make sure the artwork dir exists: # Make sure the artwork dir exists:
@@ -445,11 +524,41 @@ class Cache(object):
ext = os.path.splitext(image_url)[1] ext = os.path.splitext(image_url)[1]
thumb_path = os.path.join(self.path_to_art_cache, if fanart:
'T_' + self.id + '.' + helpers.today() + ext) thumb_path = os.path.join(self.path_to_art_cache,
'T_' + self.id + '_fanart_' + '.' + helpers.today() + ext)
else:
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: if self.id_type != 'artist':
f.write(artwork) with open(thumb_path, 'wb') as f:
f.write(artwork)
else:
# 2019 last.fm no longer allows access to artist images, use the fanart.tv image to create a thumb
artwork_thumb = None
if 'fanart' in thumb_url:
# Create thumb using image resizing service
url = "https://images.weserv.nl"
params = {
"url": thumb_url,
"w": 300
}
headers = {"User-Agent": "Headphones"}
artwork_thumb = request.request_content(
url,
params=params,
timeout=20,
whitelist_status_code=404,
headers=headers
)
if artwork_thumb:
with open(thumb_path, 'wb') as f:
f.write(artwork_thumb)
else:
with open(thumb_path, 'wb') as f:
f.write(artwork)
os.chmod(thumb_path, int(headphones.CONFIG.FILE_PERMISSIONS, 8)) os.chmod(thumb_path, int(headphones.CONFIG.FILE_PERMISSIONS, 8))
except (OSError, IOError) as e: except (OSError, IOError) as e:
@@ -465,7 +574,7 @@ def getArtwork(ArtistID=None, AlbumID=None):
if not artwork_path: if not artwork_path:
return None return None
if artwork_path.startswith('http://'): if artwork_path.startswith(('http://', 'https://')):
return artwork_path return artwork_path
else: else:
artwork_file = os.path.basename(artwork_path) artwork_file = os.path.basename(artwork_path)
@@ -479,7 +588,7 @@ def getThumb(ArtistID=None, AlbumID=None):
if not artwork_path: if not artwork_path:
return None return None
if artwork_path.startswith('http://'): if artwork_path.startswith(('http://', 'https://')):
return artwork_path return artwork_path
else: else:
thumbnail_file = os.path.basename(artwork_path) thumbnail_file = os.path.basename(artwork_path)
+4 -4
View File
@@ -18,12 +18,12 @@
####################################### #######################################
import urllib import urllib.request, urllib.parse, urllib.error
from common import USER_AGENT from .common import USER_AGENT
class HeadphonesURLopener(urllib.FancyURLopener): class HeadphonesURLopener(urllib.request.FancyURLopener):
version = USER_AGENT version = USER_AGENT
@@ -44,7 +44,7 @@ class AuthURLOpener(HeadphonesURLopener):
self.numTries = 0 self.numTries = 0
# call the base class # call the base class
urllib.FancyURLopener.__init__(self) urllib.request.FancyURLopener.__init__(self)
def prompt_user_passwd(self, host, realm): def prompt_user_passwd(self, host, realm):
""" """
+7 -43
View File
@@ -24,6 +24,7 @@ import operator
import os import os
import re import re
from headphones import version from headphones import version
from functools import reduce
# Identify Our Application # Identify Our Application
@@ -74,7 +75,7 @@ class Quality:
@staticmethod @staticmethod
def _getStatusStrings(status): def _getStatusStrings(status):
toReturn = {} toReturn = {}
for x in Quality.qualityStrings.keys(): for x in list(Quality.qualityStrings.keys()):
toReturn[Quality.compositeStatus(status, x)] = Quality.statusPrefixes[status] + " (" + \ toReturn[Quality.compositeStatus(status, x)] = Quality.statusPrefixes[status] + " (" + \
Quality.qualityStrings[x] + ")" Quality.qualityStrings[x] + ")"
return toReturn return toReturn
@@ -93,7 +94,7 @@ class Quality:
def splitQuality(quality): def splitQuality(quality):
anyQualities = [] anyQualities = []
bestQualities = [] bestQualities = []
for curQual in Quality.qualityStrings.keys(): for curQual in list(Quality.qualityStrings.keys()):
if curQual & quality: if curQual & quality:
anyQualities.append(curQual) anyQualities.append(curQual)
if curQual << 16 & quality: if curQual << 16 & quality:
@@ -101,36 +102,6 @@ class Quality:
return (anyQualities, bestQualities) return (anyQualities, bestQualities)
@staticmethod
def nameQuality(name):
def checkName(list, func):
return func([re.search(x, name, re.I) for x in list])
name = os.path.basename(name)
# if we have our exact text then assume we put it there
for x in Quality.qualityStrings:
if x == Quality.UNKNOWN:
continue
regex = '\W' + Quality.qualityStrings[x].replace(' ', '\W') + '\W'
regex_match = re.search(regex, name, re.I)
if regex_match:
return x
# TODO: fix quality checking here
if checkName(["mp3", "192"], any) and not checkName(["flac"], all):
return Quality.B192
elif checkName(["mp3", "256"], any) and not checkName(["flac"], all):
return Quality.B256
elif checkName(["mp3", "vbr"], any) and not checkName(["flac"], all):
return Quality.VBR
elif checkName(["mp3", "320"], any) and not checkName(["flac"], all):
return Quality.B320
else:
return Quality.UNKNOWN
@staticmethod @staticmethod
def assumeQuality(name): def assumeQuality(name):
if name.lower().endswith(".mp3"): if name.lower().endswith(".mp3"):
@@ -151,28 +122,21 @@ class Quality:
@staticmethod @staticmethod
def splitCompositeStatus(status): def splitCompositeStatus(status):
"""Returns a tuple containing (status, quality)""" """Returns a tuple containing (status, quality)"""
for x in sorted(Quality.qualityStrings.keys(), reverse=True): for x in sorted(list(Quality.qualityStrings.keys()), reverse=True):
if status > x * 100: if status > x * 100:
return (status - x * 100, x) return (status - x * 100, x)
return (Quality.NONE, status) return (Quality.NONE, status)
@staticmethod
def statusFromName(name, assume=True):
quality = Quality.nameQuality(name)
if assume and quality == Quality.UNKNOWN:
quality = Quality.assumeQuality(name)
return Quality.compositeStatus(DOWNLOADED, quality)
DOWNLOADED = None DOWNLOADED = None
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 list(Quality.qualityStrings.keys())]
Quality.SNATCHED = [Quality.compositeStatus(SNATCHED, x) for x in Quality.qualityStrings.keys()] Quality.SNATCHED = [Quality.compositeStatus(SNATCHED, x) for x in list(Quality.qualityStrings.keys())]
Quality.SNATCHED_PROPER = [Quality.compositeStatus(SNATCHED_PROPER, x) for x in Quality.SNATCHED_PROPER = [Quality.compositeStatus(SNATCHED_PROPER, x) for x in
Quality.qualityStrings.keys()] list(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], [])
+90 -41
View File
@@ -2,15 +2,16 @@ import itertools
import os import os
import re import re
import ast
from configparser import ConfigParser
import headphones.logger import headphones.logger
from configobj import ConfigObj
def bool_int(value): def bool_int(value):
""" """
Casts a config value into a 0 or 1 Casts a config value into a 0 or 1
""" """
if isinstance(value, basestring): if isinstance(value, str):
if value.lower() in ('', '0', 'false', 'f', 'no', 'n', 'off'): if value.lower() in ('', '0', 'false', 'f', 'no', 'n', 'off'):
value = 0 value = 0
return int(bool(value)) return int(bool(value))
@@ -34,11 +35,18 @@ _CONFIG_DEFINITIONS = {
'ADD_ALBUM_ART': (int, 'General', 0), 'ADD_ALBUM_ART': (int, 'General', 0),
'ADVANCEDENCODER': (str, 'General', ''), 'ADVANCEDENCODER': (str, 'General', ''),
'ALBUM_ART_FORMAT': (str, 'General', 'folder'), 'ALBUM_ART_FORMAT': (str, 'General', 'folder'),
'ALBUM_ART_MIN_WIDTH': (str, 'General', ''),
'ALBUM_ART_MAX_WIDTH': (str, 'General', ''),
# This is used in importer.py to determine how complete an album needs to # This is used in importer.py to determine how complete an album needs to
# be - to be considered "downloaded". Percentage from 0-100 # be - to be considered "downloaded". Percentage from 0-100
'ALBUM_COMPLETION_PCT': (int, 'Advanced', 80), 'ALBUM_COMPLETION_PCT': (int, 'Advanced', 80),
'API_ENABLED': (int, 'General', 0), 'API_ENABLED': (int, 'General', 0),
'API_KEY': (str, 'General', ''), 'API_KEY': (str, 'General', ''),
'ORPHEUS': (int, 'Orpheus.network', 0),
'ORPHEUS_PASSWORD': (str, 'Orpheus.network', ''),
'ORPHEUS_RATIO': (str, 'Orpheus.network', ''),
'ORPHEUS_USERNAME': (str, 'Orpheus.network', ''),
'ORPHEUS_URL': (str, 'Orpheus.network', 'https://orpheus.network'),
'AUTOWANT_ALL': (int, 'General', 0), 'AUTOWANT_ALL': (int, 'General', 0),
'AUTOWANT_MANUALLY_ADDED': (int, 'General', 1), 'AUTOWANT_MANUALLY_ADDED': (int, 'General', 1),
'AUTOWANT_UPCOMING': (int, 'General', 1), 'AUTOWANT_UPCOMING': (int, 'General', 1),
@@ -72,6 +80,7 @@ _CONFIG_DEFINITIONS = {
'DELUGE_PASSWORD': (str, 'Deluge', ''), 'DELUGE_PASSWORD': (str, 'Deluge', ''),
'DELUGE_LABEL': (str, 'Deluge', ''), 'DELUGE_LABEL': (str, 'Deluge', ''),
'DELUGE_DONE_DIRECTORY': (str, 'Deluge', ''), 'DELUGE_DONE_DIRECTORY': (str, 'Deluge', ''),
'DELUGE_DOWNLOAD_DIRECTORY': (str, 'Deluge', ''),
'DELUGE_PAUSED': (int, 'Deluge', 0), 'DELUGE_PAUSED': (int, 'Deluge', 0),
'DESTINATION_DIR': (str, 'General', ''), 'DESTINATION_DIR': (str, 'General', ''),
'DETECT_BITRATE': (int, 'General', 0), 'DETECT_BITRATE': (int, 'General', 0),
@@ -138,16 +147,19 @@ _CONFIG_DEFINITIONS = {
'IGNORED_FILES': (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'),
'JOIN_APIKEY': (str, 'Join', ''),
'JOIN_DEVICEID': (str, 'Join', ''),
'JOIN_ENABLED': (int, 'Join', 0),
'JOIN_ONSNATCH': (int, 'Join', 0),
'JOURNAL_MODE': (str, 'Advanced', 'wal'), 'JOURNAL_MODE': (str, 'Advanced', 'wal'),
'KAT': (int, 'Kat', 0),
'KAT_PROXY_URL': (str, 'Kat', ''),
'KAT_RATIO': (str, 'Kat', ''),
'KEEP_NFO': (int, 'General', 0), 'KEEP_NFO': (int, 'General', 0),
'KEEP_TORRENT_FILES': (int, 'General', 0), 'KEEP_TORRENT_FILES': (int, 'General', 0),
'KEEP_TORRENT_FILES_DIR': (path, 'General', ''),
'LASTFM_USERNAME': (str, 'General', ''), 'LASTFM_USERNAME': (str, 'General', ''),
'LASTFM_APIKEY': (str, 'General', ''),
'LAUNCH_BROWSER': (int, 'General', 1), 'LAUNCH_BROWSER': (int, 'General', 1),
'LIBRARYSCAN': (int, 'General', 1), 'LIBRARYSCAN': (int, 'General', 1),
'LIBRARYSCAN_INTERVAL': (int, 'General', 300), 'LIBRARYSCAN_INTERVAL': (int, 'General', 24),
'LMS_ENABLED': (int, 'LMS', 0), 'LMS_ENABLED': (int, 'LMS', 0),
'LMS_HOST': (str, 'LMS', ''), 'LMS_HOST': (str, 'LMS', ''),
'LOG_DIR': (path, 'General', ''), 'LOG_DIR': (path, 'General', ''),
@@ -155,8 +167,7 @@ _CONFIG_DEFINITIONS = {
'LOSSLESS_BITRATE_TO': (int, 'General', 0), 'LOSSLESS_BITRATE_TO': (int, 'General', 0),
'LOSSLESS_DESTINATION_DIR': (path, 'General', ''), 'LOSSLESS_DESTINATION_DIR': (path, 'General', ''),
'MB_IGNORE_AGE': (int, 'General', 365), 'MB_IGNORE_AGE': (int, 'General', 365),
'MININOVA': (int, 'Mininova', 0), 'MB_IGNORE_AGE_MISSING': (int, 'General', 0),
'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),
@@ -192,9 +203,6 @@ _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),
@@ -226,7 +234,12 @@ _CONFIG_DEFINITIONS = {
'PUSHOVER_KEYS': (str, 'Pushover', ''), 'PUSHOVER_KEYS': (str, 'Pushover', ''),
'PUSHOVER_ONSNATCH': (int, 'Pushover', 0), 'PUSHOVER_ONSNATCH': (int, 'Pushover', 0),
'PUSHOVER_PRIORITY': (int, 'Pushover', 0), 'PUSHOVER_PRIORITY': (int, 'Pushover', 0),
'QBITTORRENT_HOST': (str, 'QBitTorrent', ''),
'QBITTORRENT_LABEL': (str, 'QBitTorrent', ''),
'QBITTORRENT_PASSWORD': (str, 'QBitTorrent', ''),
'QBITTORRENT_USERNAME': (str, 'QBitTorrent', ''),
'RENAME_FILES': (int, 'General', 0), 'RENAME_FILES': (int, 'General', 0),
'RENAME_SINGLE_DISC_IGNORE': (int, 'General', 0),
'RENAME_UNPROCESSED': (bool_int, 'General', 1), 'RENAME_UNPROCESSED': (bool_int, 'General', 1),
'RENAME_FROZEN': (bool_int, 'General', 1), 'RENAME_FROZEN': (bool_int, 'General', 1),
'REPLACE_EXISTING_FOLDERS': (int, 'General', 0), 'REPLACE_EXISTING_FOLDERS': (int, 'General', 0),
@@ -236,6 +249,7 @@ _CONFIG_DEFINITIONS = {
'RUTRACKER_PASSWORD': (str, 'Rutracker', ''), 'RUTRACKER_PASSWORD': (str, 'Rutracker', ''),
'RUTRACKER_RATIO': (str, 'Rutracker', ''), 'RUTRACKER_RATIO': (str, 'Rutracker', ''),
'RUTRACKER_USER': (str, 'Rutracker', ''), 'RUTRACKER_USER': (str, 'Rutracker', ''),
'RUTRACKER_COOKIE': (str, 'Rutracker', ''),
'SAB_APIKEY': (str, 'SABnzbd', ''), 'SAB_APIKEY': (str, 'SABnzbd', ''),
'SAB_CATEGORY': (str, 'SABnzbd', ''), 'SAB_CATEGORY': (str, 'SABnzbd', ''),
'SAB_HOST': (str, 'SABnzbd', ''), 'SAB_HOST': (str, 'SABnzbd', ''),
@@ -243,13 +257,21 @@ _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),
'SLACK_ENABLED': (int, 'Slack', 0),
'SLACK_URL': (str, 'Slack', ''),
'SLACK_CHANNEL': (str, 'Slack', ''),
'SLACK_EMOJI': (str, 'Slack', ''),
'SLACK_ONSNATCH': (int, 'Slack', 0),
'SOFT_CHROOT': (path, 'General', ''), '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), 'SOULSEEK_API_URL': (str, 'Soulseek', ''),
'STRIKE_RATIO': (str, 'Strike', ''), 'SOULSEEK_API_KEY': (str, 'Soulseek', ''),
'SOULSEEK_DOWNLOAD_DIR': (str, 'Soulseek', ''),
'SOULSEEK_INCOMPLETE_DOWNLOAD_DIR': (str, 'Soulseek', ''),
'SOULSEEK': (int, 'Soulseek', 0),
'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', ''),
@@ -266,6 +288,7 @@ _CONFIG_DEFINITIONS = {
'TORZNAB_APIKEY': (str, 'Torznab', ''), 'TORZNAB_APIKEY': (str, 'Torznab', ''),
'TORZNAB_ENABLED': (int, 'Torznab', 1), 'TORZNAB_ENABLED': (int, 'Torznab', 1),
'TORZNAB_HOST': (str, 'Torznab', ''), 'TORZNAB_HOST': (str, 'Torznab', ''),
'TORZNAB_RATIO': (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', ''),
@@ -282,21 +305,21 @@ _CONFIG_DEFINITIONS = {
'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), 'WAIT_UNTIL_RELEASE_DATE': (int, 'General', 0),
'WAFFLES': (int, 'Waffles', 0), 'REDACTED': (int, 'Redacted', 0),
'WAFFLES_PASSKEY': (str, 'Waffles', ''), 'REDACTED_APIKEY': (str, 'Redacted', ''),
'WAFFLES_RATIO': (str, 'Waffles', ''), 'REDACTED_USERNAME': (str, 'Redacted', ''),
'WAFFLES_UID': (str, 'Waffles', ''), 'REDACTED_PASSWORD': (str, 'Redacted', ''),
'WHATCD': (int, 'What.cd', 0), 'REDACTED_RATIO': (str, 'Redacted', ''),
'WHATCD_PASSWORD': (str, 'What.cd', ''), 'REDACTED_USE_FLTOKEN': (int, 'Redacted', 0),
'WHATCD_RATIO': (str, 'What.cd', ''),
'WHATCD_USERNAME': (str, 'What.cd', ''),
'XBMC_ENABLED': (int, 'XBMC', 0), 'XBMC_ENABLED': (int, 'XBMC', 0),
'XBMC_HOST': (str, 'XBMC', ''), 'XBMC_HOST': (str, 'XBMC', ''),
'XBMC_NOTIFY': (int, 'XBMC', 0), 'XBMC_NOTIFY': (int, 'XBMC', 0),
'XBMC_PASSWORD': (str, 'XBMC', ''), 'XBMC_PASSWORD': (str, 'XBMC', ''),
'XBMC_UPDATE': (int, 'XBMC', 0), 'XBMC_UPDATE': (int, 'XBMC', 0),
'XBMC_USERNAME': (str, 'XBMC', ''), 'XBMC_USERNAME': (str, 'XBMC', ''),
'XLDPROFILE': (str, 'General', '') 'XLDPROFILE': (str, 'General', ''),
'BANDCAMP': (int, 'General', 0),
'BANDCAMP_DIR': (path, 'General', '')
} }
@@ -308,8 +331,9 @@ class Config(object):
def __init__(self, config_file): def __init__(self, config_file):
""" Initialize the config with values from a file """ """ Initialize the config with values from a file """
self._config_file = config_file self._config_file = config_file
self._config = ConfigObj(self._config_file, encoding='utf-8') self._config = ConfigParser(interpolation=None)
for key in _CONFIG_DEFINITIONS.keys(): self._config.read(self._config_file)
for key in list(_CONFIG_DEFINITIONS.keys()):
self.check_setting(key) self.check_setting(key)
self.ENCODER_MULTICORE_COUNT = max(0, self.ENCODER_MULTICORE_COUNT) self.ENCODER_MULTICORE_COUNT = max(0, self.ENCODER_MULTICORE_COUNT)
self._upgrade() self._upgrade()
@@ -326,7 +350,7 @@ class Config(object):
def check_section(self, section): def check_section(self, section):
""" Check if INI section exists, if not create it """ """ Check if INI section exists, if not create it """
if section not in self._config: if not self._config.has_section(section):
self._config[section] = {} self._config[section] = {}
return True return True
else: else:
@@ -336,28 +360,38 @@ class Config(object):
""" Cast any value in the config to the right type or use the default """ """ Cast any value in the config to the right type or use the default """
key, definition_type, section, ini_key, default = self._define(key) key, definition_type, section, ini_key, default = self._define(key)
self.check_section(section) self.check_section(section)
# ConfigParser values are strings, so need to convert to actual list
if definition_type == list:
definition_type = ast.literal_eval
try: try:
my_val = definition_type(self._config[section][ini_key]) my_val = definition_type(self._config[section][ini_key])
# ConfigParser interprets quotes in the config
# literally, so we need to sanitize it. It's not really
# a config upgrade, since a user can at any time put
# some_key = 'some_val'
if type(my_val) in [str, path]:
my_val = my_val.strip('"').strip("'")
except Exception: except Exception:
my_val = definition_type(default) my_val = default
self._config[section][ini_key] = my_val self._config[section][ini_key] = str(my_val)
return my_val return my_val
def write(self): def write(self):
""" Make a copy of the stored config and write it to the configured file """ """ Make a copy of the stored config and write it to the configured file """
new_config = ConfigObj(encoding="UTF-8") new_config = ConfigParser(interpolation=None)
new_config.filename = self._config_file
# first copy over everything from the old config, even if it is not # first copy over everything from the old config, even if it is not
# correctly defined to keep from losing data # correctly defined to keep from losing data
for key, subkeys in self._config.items(): for key, subkeys in list(self._config.items()):
if key not in new_config: if key not in new_config:
new_config[key] = {} new_config[key] = {}
for subkey, value in subkeys.items(): for subkey, value in list(subkeys.items()):
new_config[key][subkey] = value new_config[key][subkey] = value
# next make sure that everything we expect to have defined is so # next make sure that everything we expect to have defined is so
for key in _CONFIG_DEFINITIONS.keys(): for key in list(_CONFIG_DEFINITIONS.keys()):
key, definition_type, section, ini_key, default = self._define(key) key, definition_type, section, ini_key, default = self._define(key)
self.check_setting(key) self.check_setting(key)
if section not in new_config: if section not in new_config:
@@ -368,14 +402,15 @@ class Config(object):
headphones.logger.info("Writing configuration to file") headphones.logger.info("Writing configuration to file")
try: try:
new_config.write() with open(self._config_file, 'w') as configfile:
new_config.write(configfile)
except IOError as e: except IOError as e:
headphones.logger.error("Error writing configuration file: %s", e) headphones.logger.error("Error writing configuration file: %s", e)
def get_extra_newznabs(self): def get_extra_newznabs(self):
""" Return the extra newznab tuples """ """ Return the extra newznab tuples """
extra_newznabs = list( extra_newznabs = list(
itertools.izip(*[itertools.islice(self.EXTRA_NEWZNABS, i, None, 3) zip(*[itertools.islice(self.EXTRA_NEWZNABS, i, None, 3)
for i in range(3)]) for i in range(3)])
) )
return extra_newznabs return extra_newznabs
@@ -394,8 +429,8 @@ class Config(object):
def get_extra_torznabs(self): def get_extra_torznabs(self):
""" Return the extra torznab tuples """ """ Return the extra torznab tuples """
extra_torznabs = list( extra_torznabs = list(
itertools.izip(*[itertools.islice(self.EXTRA_TORZNABS, i, None, 3) zip(*[itertools.islice(self.EXTRA_TORZNABS, i, None, 4)
for i in range(3)]) for i in range(4)])
) )
return extra_torznabs return extra_torznabs
@@ -430,20 +465,21 @@ class Config(object):
return value return value
else: else:
key, definition_type, section, ini_key, default = self._define(name) key, definition_type, section, ini_key, default = self._define(name)
self._config[section][ini_key] = definition_type(value) self._config[section][ini_key] = str(value)
return self._config[section][ini_key] return self._config[section][ini_key]
def process_kwargs(self, kwargs): def process_kwargs(self, kwargs):
""" """
Given a big bunch of key value pairs, apply them to the ini. Given a big bunch of key value pairs, apply them to the ini.
""" """
for name, value in kwargs.items(): for name, value in list(kwargs.items()):
key, definition_type, section, ini_key, default = self._define(name) key, definition_type, section, ini_key, default = self._define(name)
self._config[section][ini_key] = definition_type(value) self._config[section][ini_key] = str(value)
def _upgrade(self): def _upgrade(self):
""" """
Bring old configs up to date Bring old configs up to date. Although this is kind of a dumb
way to do it because it doesn't handle multi-step upgrades
""" """
if self.CONFIG_VERSION == '2': if self.CONFIG_VERSION == '2':
# Update the config to use direct path to the encoder rather than the encoder folder # Update the config to use direct path to the encoder rather than the encoder folder
@@ -466,4 +502,17 @@ class Config(object):
if self.CONFIG_VERSION == '5': if self.CONFIG_VERSION == '5':
if self.OPEN_MAGNET_LINKS: if self.OPEN_MAGNET_LINKS:
self.MAGNET_LINKS = 2 self.MAGNET_LINKS = 2
self.CONFIG_VERSION = '5'
# Add Seed Ratio to Torznabs
if self.EXTRA_TORZNABS:
extra_torznabs = list(
zip(*[itertools.islice(self.EXTRA_TORZNABS, i, None, 3)
for i in range(3)])
)
new_torznabs = []
for torznab in extra_torznabs:
new_torznabs.extend([torznab[0], torznab[1], '', torznab[2]])
if new_torznabs:
self.EXTRA_TORZNABS = new_torznabs
self.CONFIG_VERSION = '6'
+89 -86
View File
@@ -2,8 +2,8 @@ import mock
from mock import MagicMock from mock import MagicMock
import headphones.config import headphones.config
import re import re
import unittestcompat from . import unittestcompat
from unittestcompat import TestCase, TestArgs from .unittestcompat import TestCase, TestArgs
class ConfigApiTest(TestCase): class ConfigApiTest(TestCase):
@@ -101,7 +101,7 @@ class ConfigApiTest(TestCase):
# call methods # call methods
c = headphones.config.Config(path) c = headphones.config.Config(path)
# assertions: # assertions:
with self.assertRaisesRegexp(KeyError, exc_regex): with self.assertRaisesRegex(KeyError, exc_regex):
c.check_setting(setting_name) c.check_setting(setting_name)
pass pass
@@ -355,86 +355,89 @@ class ConfigApiTest(TestCase):
# TORZNABS # TORZNABS
# TODO : here is copypaste from of NEZNABS tests. Make tests better, plz refactor them # TODO : here is copypaste from of NEZNABS tests. Make tests better, plz refactor them
# #
@TestArgs( # TODO: Fix tests for following:
('', []), # CONFIG_VERSION == '5' each entry = 'host, api, enabled'
('ABCDEF', [('A', 'B', 'C'), ('D', 'E', 'F')]), # CONFIG_VERSION > '5' each entry = 'host, api, seed ratio, enabled'
(['ABC', 'DEF'], []), # @TestArgs(
([1], []), # ('', []),
([1, 2], []), # ('ABCDEF', [('A', 'B', 'C'), ('D', 'E', 'F')]),
([1, 2, 3], [(1, 2, 3)]), # (['ABC', 'DEF'], []),
# ([1], []),
([1, 2, 3, 'Aaa'], [(1, 2, 3)]), # ([1, 2], []),
([1, 2, 3, 'Aaa', 'Bbba'], [(1, 2, 3)]), # ([1, 2, 3], [(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')]), # ([1, 2, 3, 'Aaa'], [(1, 2, 3)]),
) # ([1, 2, 3, 'Aaa', 'Bbba'], [(1, 2, 3)]),
def test_get_extra_torznabs(self, conf_value, expected): # ([1, 2, 3, 'Aaa', 'Bbba', 'Ccccc'], [(1, 2, 3), ('Aaa', 'Bbba', 'Ccccc')]),
""" Config: get_extra_torznabs """ # ([1, 2, 3, 'Aaa', 'Bbba', 'Ccccc', 'Ddddda'], [(1, 2, 3), ('Aaa', 'Bbba', 'Ccccc')]),
path = '/tmp/notexist' # )
# def test_get_extra_torznabs(self, conf_value, expected):
# itertools.izip(*[itertools.islice('', i, None, 3) for i in range(3)]) # """ Config: get_extra_torznabs """
# set up mocks: # path = '/tmp/notexist'
# 'EXTRA_TORZNABS': (list, '', ''), #
self.config_mock["Torznab"] = {"extra_torznabs": conf_value} # # itertools.izip(*[itertools.islice('', i, None, 3) for i in range(3)])
# # set up mocks:
# call methods # # 'EXTRA_TORZNABS': (list, '', ''),
c = headphones.config.Config(path) # self.config_mock["Torznab"] = {"extra_torznabs": conf_value}
res = c.get_extra_torznabs() #
# # call methods
# assertions: # c = headphones.config.Config(path)
self.assertEqual(res, expected) # res = c.get_extra_torznabs()
#
def test_clear_extra_torznabs(self): # # assertions:
""" Config: clear_extra_torznabs """ # self.assertEqual(res, expected)
path = '/tmp/notexist' #
# def test_clear_extra_torznabs(self):
random_value = -1292721 # """ Config: clear_extra_torznabs """
self.config_mock["Torznab"] = {"extra_torznabs": [1, 2, 3]} # path = '/tmp/notexist'
self.config_mock["Torznab"] = {"do_not_touch": random_value} #
# random_value = -1292721
# call methods # self.config_mock["Torznab"] = {"extra_torznabs": [1, 2, 3]}
c = headphones.config.Config(path) # self.config_mock["Torznab"] = {"do_not_touch": random_value}
res = c.clear_extra_torznabs() #
# # call methods
# assertions: # c = headphones.config.Config(path)
self.assertIsNone(res) # res = c.clear_extra_torznabs()
self.assertEqual(self.config_mock["Torznab"]["extra_torznabs"], []) #
self.assertEqual(self.config_mock["Torznab"]["do_not_touch"], random_value) # # assertions:
# self.assertIsNone(res)
@TestArgs( # self.assertEqual(self.config_mock["Torznab"]["extra_torznabs"], [])
([], [''], ['']), # self.assertEqual(self.config_mock["Torznab"]["do_not_touch"], random_value)
([], 'ABCDEF', ['A', 'B', 'C', 'D', 'E', 'F']), #
# @TestArgs(
([1, 2, [False, True]], ['3', [0, 0]], [1, 2, [False, True], '3', [0, 0]]), # ([], [''], ['']),
) # ([], 'ABCDEF', ['A', 'B', 'C', 'D', 'E', 'F']),
def test_add_extra_torznab(self, initial, added, expected): #
""" Config: add_extra_torznab """ # ([1, 2, [False, True]], ['3', [0, 0]], [1, 2, [False, True], '3', [0, 0]]),
path = '/tmp/notexist' # )
# def test_add_extra_torznab(self, initial, added, expected):
self.config_mock["Torznab"] = {"extra_torznabs": initial} # """ Config: add_extra_torznab """
# path = '/tmp/notexist'
# call methods #
c = headphones.config.Config(path) # self.config_mock["Torznab"] = {"extra_torznabs": initial}
c.add_extra_torznab(added) #
act = self.config_mock["Torznab"]["extra_torznabs"] # # call methods
# c = headphones.config.Config(path)
# assertions: # c.add_extra_torznab(added)
self.assertEqual(act, expected) # act = self.config_mock["Torznab"]["extra_torznabs"]
#
@TestArgs( # # assertions:
(None), # self.assertEqual(act, expected)
([]), #
([1, 2, 3]), # @TestArgs(
([True]), # (None),
) # ([]),
def test_add_extra_torznab_raise_on_none(self, initial): # ([1, 2, 3]),
""" Config: add_extra_torznab should raise on None adding""" # ([True]),
path = '/tmp/notexist' # )
# def test_add_extra_torznab_raise_on_none(self, initial):
self.config_mock["Torznab"] = {"extra_torznabs": initial} # """ Config: add_extra_torznab should raise on None adding"""
# path = '/tmp/notexist'
# call methods #
c = headphones.config.Config(path) # self.config_mock["Torznab"] = {"extra_torznabs": initial}
with self.assertRaises(TypeError): #
c.add_extra_torznab(None) # # call methods
pass # c = headphones.config.Config(path)
# with self.assertRaises(TypeError):
# c.add_extra_torznab(None)
# pass
+36
View File
@@ -0,0 +1,36 @@
import pprint
import sys
import threading
import traceback
from headphones import logger
def cry():
"""
Logs thread traces.
"""
tmap = {}
main_thread = None
# get a map of threads by their ID so we can print their names
# during the traceback dump
for t in threading.enumerate():
if t.ident:
tmap[t.ident] = t
else:
main_thread = t
# Loop over each thread's current frame, writing info about it
for tid, frame in sys._current_frames().items():
thread = tmap.get(tid, main_thread)
lines = []
lines.append('%s\n' % thread.getName())
lines.append('========================================\n')
lines += traceback.format_stack(frame)
lines.append('========================================\n')
lines.append('LOCAL VARIABLES:\n')
lines.append('========================================\n')
lines.append(pprint.pformat(frame.f_locals))
lines.append('\n\n')
logger.info("".join(lines))
+10 -10
View File
@@ -87,7 +87,7 @@ def check_splitter(command):
def split_baby(split_file, split_cmd): def split_baby(split_file, split_cmd):
'''Let's split baby''' '''Let's split baby'''
logger.info('Splitting %s...', split_file.decode(headphones.SYS_ENCODING, 'replace')) logger.info(f"Splitting {split_file}...")
logger.debug(subprocess.list2cmdline(split_cmd)) logger.debug(subprocess.list2cmdline(split_cmd))
# Prevent Windows from opening a terminal window # Prevent Windows from opening a terminal window
@@ -108,16 +108,16 @@ def split_baby(split_file, split_cmd):
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, text=True)
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(f"Split failed for {split_file}")
out = stdout if stdout else stderr out = stdout or stderr
logger.error('Error details: %s', out.decode(headphones.SYS_ENCODING, 'replace')) logger.error(f"Error details: {out}")
return False return False
else: else:
logger.info('Split success %s', split_file.decode(headphones.SYS_ENCODING, 'replace')) logger.info(f"Split succeeded for {split_file}")
return True return True
@@ -232,7 +232,7 @@ class Directory:
for i in list_dir: for i in list_dir:
if not check_match(i): if not check_match(i):
# music file # music file
if os.path.splitext(i)[-1] in WAVE_FILE_TYPE_BY_EXTENSION.keys(): if os.path.splitext(i)[-1] in list(WAVE_FILE_TYPE_BY_EXTENSION.keys()):
track_nr = identify_track_number(i) track_nr = identify_track_number(i)
if track_nr: if track_nr:
self.content.append(WaveFile(self.path + os.sep + i, track_nr=track_nr)) self.content.append(WaveFile(self.path + os.sep + i, track_nr=track_nr))
@@ -378,7 +378,7 @@ class CueFile(File):
except: except:
raise ValueError('Cant encode CUE Sheet.') raise ValueError('Cant encode CUE Sheet.')
if self.content[0] == u'\ufeff': if self.content[0] == '\ufeff':
self.content = self.content[1:] self.content = self.content[1:]
header = header_parser() header = header_parser()
@@ -581,7 +581,7 @@ def split(albumpath):
# use xld profile to split cue # use xld profile to split cue
if headphones.CONFIG.ENCODER == 'xld' and headphones.CONFIG.MUSIC_ENCODER and headphones.CONFIG.XLDPROFILE: if headphones.CONFIG.ENCODER == 'xld' and headphones.CONFIG.MUSIC_ENCODER and headphones.CONFIG.XLDPROFILE:
import getXldProfile from . import getXldProfile
xldprofile, xldformat, _ = getXldProfile.getXldProfile(headphones.CONFIG.XLDPROFILE) xldprofile, xldformat, _ = getXldProfile.getXldProfile(headphones.CONFIG.XLDPROFILE)
if not xldformat: if not xldformat:
raise ValueError( raise ValueError(
@@ -601,7 +601,7 @@ def split(albumpath):
raise ValueError('Command not found, ensure shntool or xld installed') raise ValueError('Command not found, ensure shntool or xld installed')
# Determine if file can be split # Determine if file can be split
if wave.name_ext not in WAVE_FILE_TYPE_BY_EXTENSION.keys(): if wave.name_ext not in list(WAVE_FILE_TYPE_BY_EXTENSION.keys()):
raise ValueError('Cannot split, audio file has unsupported extension') raise ValueError('Cannot split, audio file has unsupported extension')
# Split with xld # Split with xld
+92 -34
View File
@@ -17,7 +17,9 @@
# Stolen from Sick-Beard's db.py # # Stolen from Sick-Beard's db.py #
################################### ###################################
from __future__ import with_statement
import time
import sqlite3 import sqlite3
@@ -45,36 +47,97 @@ class DBConnection:
self.connection = sqlite3.connect(dbFilename(filename), timeout=20) self.connection = sqlite3.connect(dbFilename(filename), timeout=20)
# don't wait for the disk to finish writing # don't wait for the disk to finish writing
self.connection.execute("PRAGMA synchronous = OFF") self.connection.execute("PRAGMA synchronous = OFF")
# journal disabled since we never do rollbacks # default set to Write-Ahead Logging WAL
self.connection.execute("PRAGMA journal_mode = %s" % headphones.CONFIG.JOURNAL_MODE) self.connection.execute("PRAGMA journal_mode = %s" % headphones.CONFIG.JOURNAL_MODE)
# 64mb of cache memory,probably need to make it user configurable # 64mb of cache memory,probably need to make it user configurable
self.connection.execute("PRAGMA cache_size=-%s" % (getCacheSize() * 1024)) self.connection.execute("PRAGMA cache_size=-%s" % (getCacheSize() * 1024))
self.connection.row_factory = sqlite3.Row self.connection.row_factory = sqlite3.Row
def action(self, query, args=None): def action(self, query, args=None, upsert_insert_qry=None):
if query is None: if query is None:
return return
sqlResult = None sqlResult = None
attempts = 0
dberror = None
try: while attempts < 10:
with self.connection as c: try:
if args is None: with self.connection as c:
sqlResult = c.execute(query)
# log that previous attempt was locked and we're trying again
if dberror:
if args is None:
logger.debug('SQL: Database was previously locked, trying again. Attempt number %i. Query: %s', attempts + 1, query)
else:
logger.debug('SQL: Database was previously locked, trying again. Attempt number %i. Query: %s. Args: %s', attempts + 1, query, args)
# debugging
# try:
# explain_query = 'EXPLAIN QUERY PLAN ' + query
# if not args:
# sql_results = c.execute(explain_query)
# else:
# sql_results = c.execute(explain_query, args)
# if not args:
# print(explain_query)
# else:
# print(explain_query + ' ' + str(args))
# explain_results = sql_results
# for row in explain_results:
# print row
# except Exception as e:
# print(e)
# Execute query
# time0 = time.time()
if args is None:
sqlResult = c.execute(query)
# logger.debug('SQL: ' + query)
else:
sqlResult = c.execute(query, args)
# logger.debug('SQL: %s. Args: %s', query, args)
# INSERT part of upsert query
if upsert_insert_qry:
sqlResult = c.execute(upsert_insert_qry, args)
# logger.debug('SQL: %s. Args: %s', upsert_insert_qry, args)
# debugging: loose test to log queries taking longer than 5 seconds
# seconds = time.time() - time0
# if seconds > 5:
# if args is None:
# logger.debug("SQL: Query ran for %s seconds: %s", seconds, query)
# else:
# logger.debug("SQL: Query ran for %s seconds: %s with args %s", seconds, query, args)
break
except sqlite3.OperationalError as e:
if "unable to open database file" in str(e) or "database is locked" in str(e):
dberror = e
if args is None:
logger.debug('Database error: %s. Query: %s', e, query)
else:
logger.debug('Database error: %s. Query: %s. Args: %s', e, query, args)
attempts += 1
time.sleep(1)
else: else:
sqlResult = c.execute(query, args) logger.error('Database error: %s', e)
raise
except sqlite3.OperationalError, e: except sqlite3.DatabaseError as e:
if "unable to open database file" in e.message or "database is locked" in e.message: logger.error('Fatal Error executing %s :: %s', query, e)
logger.warn('Database Error: %s', e)
else:
logger.error('Database error: %s', e)
raise raise
except sqlite3.DatabaseError, e: # log if no results returned due to lock
logger.error('Fatal Error executing %s :: %s', query, e) if not sqlResult and attempts:
raise if args is None:
logger.warn('SQL: Query failed due to database error: %s. Query: %s', dberror, query)
else:
logger.warn('SQL: Query failed due to database error: %s. Query: %s. Args: %s', dberror, query, args)
return sqlResult return sqlResult
@@ -88,24 +151,19 @@ class DBConnection:
return sqlResults return sqlResults
def upsert(self, tableName, valueDict, keyDict): def upsert(self, tableName, valueDict, keyDict):
"""
Transactions an Update or Insert to a table based on key.
If the table is not updated then the 'WHERE changes' will be 0 and the table inserted
"""
def genParams(myDict): def genParams(myDict):
return [x + " = ?" for x in myDict.keys()] return [x + " = ?" for x in list(myDict.keys())]
changesBefore = self.connection.total_changes update_query = "UPDATE " + tableName + " SET " + ", ".join(genParams(valueDict)) + " WHERE " + " AND ".join(genParams(keyDict))
update_query = "UPDATE " + tableName + " SET " + ", ".join( insert_query = ("INSERT INTO " + tableName + " (" + ", ".join(list(valueDict.keys()) + list(keyDict.keys())) + ")" + " SELECT " + ", ".join(
genParams(valueDict)) + " WHERE " + " AND ".join(genParams(keyDict)) ["?"] * len(list(valueDict.keys()) + list(keyDict.keys()))) + " WHERE changes()=0")
self.action(update_query, valueDict.values() + keyDict.values()) try:
self.action(update_query, list(valueDict.values()) + list(keyDict.values()), upsert_insert_qry=insert_query)
if self.connection.total_changes == changesBefore: except sqlite3.IntegrityError:
insert_query = ( logger.info('Queries failed: %s and %s', update_query, insert_query)
"INSERT INTO " + tableName + " (" + ", ".join(
valueDict.keys() + keyDict.keys()) + ")" +
" VALUES (" + ", ".join(["?"] * len(valueDict.keys() + keyDict.keys())) + ")"
)
try:
self.action(insert_query, valueDict.values() + keyDict.values())
except sqlite3.IntegrityError:
logger.info('Queries failed: %s and %s', update_query, insert_query)
+99 -122
View File
@@ -1,3 +1,5 @@
# -*- coding: utf-8 -*-
# This file is part of Headphones. # This file is part of Headphones.
# #
# Headphones is free software: you can redistribute it and/or modify # Headphones is free software: you can redistribute it and/or modify
@@ -32,7 +34,7 @@
# 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 SickRage. If not, see <http://www.gnu.org/licenses/>. # along with SickRage. If not, see <http://www.gnu.org/licenses/>.
from __future__ import unicode_literals
from headphones import logger from headphones import logger
@@ -49,18 +51,19 @@ delugeweb_auth = {}
delugeweb_url = '' delugeweb_url = ''
deluge_verify_cert = False deluge_verify_cert = False
scrub_logs = True scrub_logs = True
headers = {'Accept': 'application/json', 'Content-Type': 'application/json'}
def _scrubber(text): def _scrubber(text):
if scrub_logs: if scrub_logs:
try: try:
# URL parameter values # URL parameter values
text = re.sub('=[0-9a-zA-Z]*', '=REMOVED', text) text = re.sub(r'=[0-9a-zA-Z]*', r'=REMOVED', text)
# Local host with port # Local host with port
# text = re.sub('\:\/\/.*\:', '://REMOVED:', text) # just host # text = re.sub('\:\/\/.*\:', '://REMOVED:', text) # just host
text = re.sub('\:\/\/.*\:[0-9]*', '://REMOVED:', text) text = re.sub(r'\:\/\/.*\:[0-9]*', r'://REMOVED:', text)
# Session cookie # Session cookie
text = re.sub("_session_id'\: '.*'", "_session_id': 'REMOVED'", text) text = re.sub(r"_session_id'\: '.*'", r"_session_id': 'REMOVED'", text)
# Local Windows user path # Local Windows user path
if text.lower().startswith('c:\\users\\'): if text.lower().startswith('c:\\users\\'):
k = text.split('\\') k = text.split('\\')
@@ -80,8 +83,7 @@ def addTorrent(link, data=None, name=None):
result = {} result = {}
retid = False retid = False
url_what = ['https://what.cd/', 'http://what.cd/'] url_orpheus = ['https://orpheus.network/', 'http://orpheus.network/']
url_waffles = ['https://waffles.fm/', 'http://waffles.fm/']
if link.lower().startswith('magnet:'): if link.lower().startswith('magnet:'):
logger.debug('Deluge: Got a magnet link: %s' % _scrubber(link)) logger.debug('Deluge: Got a magnet link: %s' % _scrubber(link))
@@ -91,10 +93,7 @@ def addTorrent(link, data=None, name=None):
elif link.lower().startswith('http://') or link.lower().startswith('https://'): elif link.lower().startswith('http://') or link.lower().startswith('https://'):
logger.debug('Deluge: Got a URL: %s' % _scrubber(link)) logger.debug('Deluge: Got a URL: %s' % _scrubber(link))
if link.lower().startswith(tuple(url_waffles)): if link.lower().startswith(tuple(url_orpheus)):
if 'rss=' not in link:
link = link + '&rss=1'
if link.lower().startswith(tuple(url_what)):
logger.debug('Deluge: Using different User-Agent for this site') logger.debug('Deluge: Using different User-Agent for this site')
user_agent = 'Headphones' user_agent = 'Headphones'
# This method will make Deluge download the file # This method will make Deluge download the file
@@ -104,11 +103,11 @@ def addTorrent(link, data=None, name=None):
# return addTorrent(local_torrent_path) # return addTorrent(local_torrent_path)
else: else:
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' 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} get_headers = {'User-Agent': user_agent}
torrentfile = '' torrentfile = ''
logger.debug('Deluge: Trying to download (GET)') logger.debug('Deluge: Trying to download (GET)')
try: try:
r = requests.get(link, headers=headers) r = requests.get(link, headers=get_headers)
if r.status_code == 200: if r.status_code == 200:
logger.debug('Deluge: 200 OK') logger.debug('Deluge: 200 OK')
# .text will ruin the encoding for some torrents # .text will ruin the encoding for some torrents
@@ -125,9 +124,9 @@ def addTorrent(link, data=None, name=None):
# Extract torrent name from .torrent # Extract torrent name from .torrent
try: try:
logger.debug('Deluge: Getting torrent name length') logger.debug('Deluge: Getting torrent name length')
name_length = int(re.findall('name([0-9]*)\:.*?\:', str(torrentfile))[0]) name_length = int(re.findall(r'name([0-9]*)\:.*?\:', str(torrentfile))[0])
logger.debug('Deluge: Getting torrent name') logger.debug('Deluge: Getting torrent name')
name = re.findall('name[0-9]*\:(.*?)\:', str(torrentfile))[0][:name_length] name = re.findall(r'name[0-9]*\:(.*?)\:', str(torrentfile))[0][:name_length]
except Exception as e: except Exception as e:
logger.debug('Deluge: Could not get torrent name, getting file name') logger.debug('Deluge: Could not get torrent name, getting file name')
# get last part of link/path (name only) # get last part of link/path (name only)
@@ -135,7 +134,10 @@ def addTorrent(link, data=None, name=None):
# remove '.torrent' suffix # remove '.torrent' suffix
if name[-len('.torrent'):] == '.torrent': if name[-len('.torrent'):] == '.torrent':
name = name[:-len('.torrent')] name = name[:-len('.torrent')]
logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name, str(torrentfile)[:40])) try:
logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name, str(torrentfile)[:40]))
except:
logger.debug('Deluge: Sending Deluge torrent with problematic name and some content')
result = {'type': 'torrent', result = {'type': 'torrent',
'name': name, 'name': name,
'content': torrentfile} 'content': torrentfile}
@@ -154,9 +156,9 @@ def addTorrent(link, data=None, name=None):
# Extract torrent name from .torrent # Extract torrent name from .torrent
try: try:
logger.debug('Deluge: Getting torrent name length') logger.debug('Deluge: Getting torrent name length')
name_length = int(re.findall('name([0-9]*)\:.*?\:', str(torrentfile))[0]) name_length = int(re.findall(r'name([0-9]*)\:.*?\:', str(torrentfile))[0])
logger.debug('Deluge: Getting torrent name') logger.debug('Deluge: Getting torrent name')
name = re.findall('name[0-9]*\:(.*?)\:', str(torrentfile))[0][:name_length] name = re.findall(r'name[0-9]*\:(.*?)\:', str(torrentfile))[0][:name_length]
except Exception as e: except Exception as e:
logger.debug('Deluge: Could not get torrent name, getting file name') logger.debug('Deluge: Could not get torrent name, getting file name')
# get last part of link/path (name only) # get last part of link/path (name only)
@@ -164,7 +166,10 @@ def addTorrent(link, data=None, name=None):
# remove '.torrent' suffix # remove '.torrent' suffix
if name[-len('.torrent'):] == '.torrent': if name[-len('.torrent'):] == '.torrent':
name = name[:-len('.torrent')] name = name[:-len('.torrent')]
logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name, str(torrentfile)[:40])) try:
logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name, str(torrentfile)[:40]))
except UnicodeDecodeError:
logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name.decode('utf-8'), str(torrentfile)[:40]))
result = {'type': 'torrent', result = {'type': 'torrent',
'name': name, 'name': name,
'content': torrentfile} 'content': torrentfile}
@@ -199,7 +204,7 @@ def getTorrentFolder(result):
], ],
"id": 21}) "id": 21})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert) verify=deluge_verify_cert, headers=headers)
result['total_done'] = json.loads(response.text)['result']['total_done'] result['total_done'] = json.loads(response.text)['result']['total_done']
tries = 0 tries = 0
@@ -207,7 +212,7 @@ def getTorrentFolder(result):
tries += 1 tries += 1
time.sleep(5) time.sleep(5)
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert) verify=deluge_verify_cert, headers=headers)
result['total_done'] = json.loads(response.text)['result']['total_done'] result['total_done'] = json.loads(response.text)['result']['total_done']
post_data = json.dumps({"method": "web.get_torrent_status", post_data = json.dumps({"method": "web.get_torrent_status",
@@ -226,7 +231,7 @@ def getTorrentFolder(result):
"id": 23}) "id": 23})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert) verify=deluge_verify_cert, headers=headers)
result['save_path'] = json.loads(response.text)['result']['save_path'] result['save_path'] = json.loads(response.text)['result']['save_path']
result['name'] = json.loads(response.text)['result']['name'] result['name'] = json.loads(response.text)['result']['name']
@@ -242,18 +247,45 @@ def removeTorrent(torrentid, remove_data=False):
_get_auth() _get_auth()
try: try:
result = False logger.debug('Deluge: Checking if torrent %s finished seeding' % str(torrentid))
post_data = json.dumps({"method": "core.remove_torrent", post_data = json.dumps({"method": "web.get_torrent_status",
"params": [ "params": [
torrentid, torrentid,
remove_data [
], "name",
"id": 25}) "ratio",
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, "state"
verify=deluge_verify_cert) ]
result = json.loads(response.text)['result'] ],
"id": 26})
return result response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
try:
state = json.loads(response.text)['result']['state']
except KeyError as e:
logger.debug('Deluge: "state" KeyError when trying to remove torrent %s' % str(torrentid))
return False
not_finished = ["queued", "seeding", "downloading", "checking", "error"]
result = False
if state.lower() in not_finished:
logger.debug('Deluge: Torrent %s is either queued or seeding, not removing yet' % str(torrentid))
return False
else:
logger.debug('Deluge: Removing torrent %s' % str(torrentid))
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,
verify=deluge_verify_cert, headers=headers)
result = json.loads(response.text)['result']
return result
except Exception as e: except Exception as e:
logger.error('Deluge: Removing torrent failed: %s' % str(e)) logger.error('Deluge: Removing torrent failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines() formatted_lines = traceback.format_exc().splitlines()
@@ -269,7 +301,8 @@ def _get_auth():
delugeweb_host = headphones.CONFIG.DELUGE_HOST delugeweb_host = headphones.CONFIG.DELUGE_HOST
delugeweb_cert = headphones.CONFIG.DELUGE_CERT delugeweb_cert = headphones.CONFIG.DELUGE_CERT
delugeweb_password = headphones.CONFIG.DELUGE_PASSWORD delugeweb_password = headphones.CONFIG.DELUGE_PASSWORD
logger.debug('Deluge: Using password %s******%s' % (delugeweb_password[0], delugeweb_password[-1])) if len(delugeweb_password) > 0:
logger.debug('Deluge: Using password %s******%s' % (delugeweb_password[0], delugeweb_password[-1]))
if not delugeweb_host.startswith('http'): if not delugeweb_host.startswith('http'):
delugeweb_host = 'http://%s' % delugeweb_host delugeweb_host = 'http://%s' % delugeweb_host
@@ -292,12 +325,12 @@ def _get_auth():
"id": 1}) "id": 1})
try: try:
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert) verify=deluge_verify_cert, headers=headers)
except requests.ConnectionError: except requests.ConnectionError:
try: try:
logger.debug('Deluge: Connection failed, let\'s try HTTPS just in case') logger.debug('Deluge: Connection failed, let\'s try HTTPS just in case')
response = requests.post(delugeweb_url.replace('http:', 'https:'), data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url.replace('http:', 'https:'), data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert) verify=deluge_verify_cert, headers=headers)
# If the previous line didn't fail, change delugeweb_url for the rest of this session # If the previous line didn't fail, change delugeweb_url for the rest of this session
logger.error('Deluge: Switching to HTTPS, but certificate won\'t be verified because NO CERTIFICATE WAS CONFIGURED!') logger.error('Deluge: Switching to HTTPS, but certificate won\'t be verified because NO CERTIFICATE WAS CONFIGURED!')
delugeweb_url = delugeweb_url.replace('http:', 'https:') delugeweb_url = delugeweb_url.replace('http:', 'https:')
@@ -322,7 +355,7 @@ def _get_auth():
"id": 10}) "id": 10})
try: try:
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert) verify=deluge_verify_cert, headers=headers)
except Exception as e: except Exception as e:
logger.error('Deluge: Authentication failed: %s' % str(e)) logger.error('Deluge: Authentication failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines() formatted_lines = traceback.format_exc().splitlines()
@@ -339,7 +372,7 @@ def _get_auth():
"id": 11}) "id": 11})
try: try:
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert) verify=deluge_verify_cert, headers=headers)
except Exception as e: except Exception as e:
logger.error('Deluge: Authentication failed: %s' % str(e)) logger.error('Deluge: Authentication failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines() formatted_lines = traceback.format_exc().splitlines()
@@ -347,7 +380,8 @@ def _get_auth():
return None return None
delugeweb_hosts = json.loads(response.text)['result'] delugeweb_hosts = json.loads(response.text)['result']
if len(delugeweb_hosts) == 0: # Check if delugeweb_hosts is None before checking its length
if not delugeweb_hosts or len(delugeweb_hosts) == 0:
logger.error('Deluge: WebUI does not contain daemons') logger.error('Deluge: WebUI does not contain daemons')
return None return None
@@ -357,7 +391,7 @@ def _get_auth():
try: try:
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert) verify=deluge_verify_cert, headers=headers)
except Exception as e: except Exception as e:
logger.error('Deluge: Authentication failed: %s' % str(e)) logger.error('Deluge: Authentication failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines() formatted_lines = traceback.format_exc().splitlines()
@@ -370,7 +404,7 @@ def _get_auth():
try: try:
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert) verify=deluge_verify_cert, headers=headers)
except Exception as e: except Exception as e:
logger.error('Deluge: Authentication failed: %s' % str(e)) logger.error('Deluge: Authentication failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines() formatted_lines = traceback.format_exc().splitlines()
@@ -395,7 +429,7 @@ def _add_torrent_magnet(result):
"params": [result['url'], {}], "params": [result['url'], {}],
"id": 2}) "id": 2})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert) verify=deluge_verify_cert, headers=headers)
result['hash'] = json.loads(response.text)['result'] result['hash'] = json.loads(response.text)['result']
logger.debug('Deluge: Response was %s' % str(json.loads(response.text))) logger.debug('Deluge: Response was %s' % str(json.loads(response.text)))
return json.loads(response.text)['result'] return json.loads(response.text)['result']
@@ -415,7 +449,7 @@ def _add_torrent_url(result):
"params": [result['url'], {}], "params": [result['url'], {}],
"id": 32}) "id": 32})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert) verify=deluge_verify_cert, headers=headers)
result['location'] = json.loads(response.text)['result'] result['location'] = json.loads(response.text)['result']
logger.debug('Deluge: Response was %s' % str(json.loads(response.text))) logger.debug('Deluge: Response was %s' % str(json.loads(response.text)))
return json.loads(response.text)['result'] return json.loads(response.text)['result']
@@ -428,35 +462,36 @@ def _add_torrent_url(result):
def _add_torrent_file(result): def _add_torrent_file(result):
logger.debug('Deluge: Adding file') logger.debug('Deluge: Adding file')
options = {}
if headphones.CONFIG.DELUGE_DOWNLOAD_DIRECTORY:
options['download_location'] = headphones.CONFIG.DELUGE_DOWNLOAD_DIRECTORY
if headphones.CONFIG.DELUGE_DONE_DIRECTORY or headphones.CONFIG.DOWNLOAD_TORRENT_DIR:
options['move_completed'] = 1
if headphones.CONFIG.DELUGE_DONE_DIRECTORY:
options['move_completed_path'] = headphones.CONFIG.DELUGE_DONE_DIRECTORY
else:
options['move_completed_path'] = headphones.CONFIG.DOWNLOAD_TORRENT_DIR
if headphones.CONFIG.DELUGE_PAUSED:
options['add_paused'] = headphones.CONFIG.DELUGE_PAUSED
if not any(delugeweb_auth): if not any(delugeweb_auth):
_get_auth() _get_auth()
try: try:
# content is torrent file contents that needs to be encoded to base64 # content is torrent file contents that needs to be encoded to base64
post_data = json.dumps({"method": "core.add_torrent_file", post_data = json.dumps({"method": "core.add_torrent_file",
"params": [result['name'] + '.torrent', b64encode(result['content'].encode('utf8')), {}], "params": [result['name'] + '.torrent',
b64encode(result['content']).decode(),
options],
"id": 2}) "id": 2})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert) verify=deluge_verify_cert, headers=headers)
result['hash'] = json.loads(response.text)['result'] result['hash'] = json.loads(response.text)['result']
logger.debug('Deluge: Response was %s' % str(json.loads(response.text))) logger.debug('Deluge: Response was %s' % str(json.loads(response.text)))
return json.loads(response.text)['result'] return json.loads(response.text)['result']
except UnicodeDecodeError:
try:
# content is torrent file contents that needs to be encoded to base64
# this time let's try leaving the encoding as is
post_data = json.dumps({"method": "core.add_torrent_file",
"params": [result['name'] + '.torrent', b64encode(result['content']), {}],
"id": 22})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert)
result['hash'] = json.loads(response.text)['result']
logger.debug('Deluge: Response was %s' % str(json.loads(response.text)))
return json.loads(response.text)['result']
except Exception as e:
logger.error('Deluge: Adding torrent file failed after decode: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines()
logger.error('; '.join(formatted_lines))
return False
except Exception as e: except Exception as e:
logger.error('Deluge: Adding torrent file failed: %s' % str(e)) logger.error('Deluge: Adding torrent file failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines() formatted_lines = traceback.format_exc().splitlines()
@@ -480,7 +515,7 @@ def setTorrentLabel(result):
"params": [], "params": [],
"id": 3}) "id": 3})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert) verify=deluge_verify_cert, headers=headers)
labels = json.loads(response.text)['result'] labels = json.loads(response.text)['result']
if labels is not None: if labels is not None:
@@ -491,7 +526,7 @@ def setTorrentLabel(result):
"params": [label], "params": [label],
"id": 4}) "id": 4})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert) verify=deluge_verify_cert, headers=headers)
logger.debug('Deluge: %s label added to Deluge' % label) logger.debug('Deluge: %s label added to Deluge' % label)
except Exception as e: except Exception as e:
logger.error('Deluge: Setting label failed: %s' % str(e)) logger.error('Deluge: Setting label failed: %s' % str(e))
@@ -503,7 +538,7 @@ def setTorrentLabel(result):
"params": [result['hash'], label], "params": [result['hash'], label],
"id": 5}) "id": 5})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert) verify=deluge_verify_cert, headers=headers)
logger.debug('Deluge: %s label added to torrent' % label) logger.debug('Deluge: %s label added to torrent' % label)
else: else:
logger.debug('Deluge: Label plugin not detected') logger.debug('Deluge: Label plugin not detected')
@@ -527,12 +562,12 @@ def setSeedRatio(result):
"params": [result['hash'], True], "params": [result['hash'], True],
"id": 5}) "id": 5})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert) verify=deluge_verify_cert, headers=headers)
post_data = json.dumps({"method": "core.set_torrent_stop_ratio", post_data = json.dumps({"method": "core.set_torrent_stop_ratio",
"params": [result['hash'], float(ratio)], "params": [result['hash'], float(ratio)],
"id": 6}) "id": 6})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert) verify=deluge_verify_cert, headers=headers)
return not json.loads(response.text)['error'] return not json.loads(response.text)['error']
@@ -544,61 +579,3 @@ def setSeedRatio(result):
return None return None
def setTorrentPath(result):
logger.debug('Deluge: Setting download path')
if not any(delugeweb_auth):
_get_auth()
try:
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,
verify=deluge_verify_cert)
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,
verify=deluge_verify_cert)
return not json.loads(response.text)['error']
return True
except Exception as e:
logger.error('Deluge: Setting torrent move-to directory failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines()
logger.error('; '.join(formatted_lines))
return None
def setTorrentPause(result):
logger.debug('Deluge: Pausing torrent')
if not any(delugeweb_auth):
_get_auth()
try:
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,
verify=deluge_verify_cert)
return not json.loads(response.text)['error']
return True
except Exception as e:
logger.error('Deluge: Setting torrent paused failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines()
logger.error('; '.join(formatted_lines))
return None
+4 -3
View File
@@ -1,6 +1,6 @@
import os.path import os.path
import biplist import plistlib
from headphones import logger from headphones import logger
@@ -14,8 +14,9 @@ def getXldProfile(xldProfile):
# Get xld preferences plist # Get xld preferences plist
try: try:
preferences = biplist.readPlist(expanded) with open(expanded, 'rb') as _f:
except (biplist.InvalidPlistException, biplist.NotBinaryPlistException), e: preferences = plistlib.load(_f)
except Exception as e:
logger.error("Error reading xld preferences plist: %s", e) logger.error("Error reading xld preferences plist: %s", e)
return (xldProfileNotFound, None, None) return (xldProfileNotFound, None, None)
+279 -117
View File
@@ -14,17 +14,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 operator import itemgetter
import unicodedata
import datetime
import shutil
import time
import sys
import fnmatch
import re
import os import os
from beets.mediafile import MediaFile, FileTypeError, UnreadableFileError import re
import shutil
import sys
import tempfile
import time
import unicodedata
from contextlib import contextmanager
from datetime import datetime, date
from fnmatch import fnmatch
from functools import cmp_to_key
from glob import glob
from operator import itemgetter
from beets import logging as beetslogging
from mediafile import MediaFile, FileTypeError, UnreadableFileError
from six import text_type
from unidecode import unidecode
import headphones import headphones
@@ -34,6 +42,24 @@ RE_FEATURING = re.compile(r"[fF]t\.|[fF]eaturing|[fF]eat\.|\b[wW]ith\b|&|vs\.")
RE_CD_ALBUM = re.compile(r"\(?((CD|disc)\s*[0-9]+)\)?", re.I) RE_CD_ALBUM = re.compile(r"\(?((CD|disc)\s*[0-9]+)\)?", re.I)
RE_CD = re.compile(r"^(CD|dics)\s*[0-9]+$", re.I) RE_CD = re.compile(r"^(CD|dics)\s*[0-9]+$", re.I)
def cmp(x, y):
"""
Replacement for built-in function cmp that was removed in Python 3
Compare the two objects x and y and return an integer according to
the outcome. The return value is negative if x < y, zero if x == y
and strictly positive if x > y.
https://portingguide.readthedocs.io/en/latest/comparisons.html#the-cmp-function
"""
if x is None and y is None:
return 0
elif x is None:
return -1
elif y is None:
return 1
else:
return (x > y) - (x < y)
def multikeysort(items, columns): def multikeysort(items, columns):
comparers = [ comparers = [
@@ -48,7 +74,7 @@ def multikeysort(items, columns):
else: else:
return 0 return 0
return sorted(items, cmp=comparer) return sorted(items, key=cmp_to_key(comparer))
def checked(variable): def checked(variable):
@@ -130,28 +156,25 @@ def convert_seconds(s):
def today(): def today():
today = datetime.date.today() return date.isoformat(date.today())
yyyymmdd = datetime.date.isoformat(today)
return yyyymmdd
def now(): def now():
now = datetime.datetime.now() now = datetime.now()
return now.strftime("%Y-%m-%d %H:%M:%S") return now.strftime("%Y-%m-%d %H:%M:%S")
def get_age(date): def is_valid_date(d):
try: if not d:
split_date = date.split('-')
except:
return False return False
else:
return bool(re.match(r'\d{4}-\d{2}-\d{2}', d))
try:
days_old = int(split_date[0]) * 365 + int(split_date[1]) * 30 + int(split_date[2])
except (IndexError, ValueError):
days_old = False
return days_old def age(d):
'''Requires a valid date'''
delta = date.today() - date.fromisoformat(d)
return delta.days
def bytes_to_mb(bytes): def bytes_to_mb(bytes):
@@ -161,7 +184,7 @@ def bytes_to_mb(bytes):
def mb_to_bytes(mb_str): def mb_to_bytes(mb_str):
result = re.search('^(\d+(?:\.\d+)?)\s?(?:mb)?', mb_str, flags=re.I) result = re.search(r"^(\d+(?:\.\d+)?)\s?(?:mb)?", mb_str, flags=re.I)
if result: if result:
return int(float(result.group(1)) * 1048576) return int(float(result.group(1)) * 1048576)
@@ -191,14 +214,20 @@ def piratesize(size):
return size return size
def replace_all(text, dic, normalize=False): def pattern_substitute(pattern, dic, normalize=False):
"""
Execute path rendering/substitution based on replacement dictionary
e.g. pattern = $Artist/$Album
dic = {Artist: 'My artist', Album: 'My album'}
returns My artist/My album
"""
from headphones import pathrender from headphones import pathrender
if not text: if not pattern:
return '' return ''
if normalize: if normalize:
new_dic = {} new_dic = {}
for i, j in dic.iteritems(): for i, j in dic.items():
if j is not None: if j is not None:
try: try:
if sys.platform == 'darwin': if sys.platform == 'darwin':
@@ -210,20 +239,28 @@ def replace_all(text, dic, normalize=False):
j.decode(headphones.SYS_ENCODING, 'replace')) j.decode(headphones.SYS_ENCODING, 'replace'))
new_dic[i] = j new_dic[i] = j
dic = new_dic dic = new_dic
return pathrender.render(text, dic)[0] return pathrender.render(pattern, dic)[0]
def replace_all(text, dic):
if not text:
return ''
for i, j in dic.items():
text = text.replace(i, j)
return text
def replace_illegal_chars(string, type="file"): def replace_illegal_chars(string, type="file"):
if type == "file": if type == "file":
string = re.sub('[\?"*:|<>/]', '_', string) string = re.sub(r"[\?\"*:|<>/]", "_", string)
if type == "folder": if type == "folder":
string = re.sub('[:\?<>"|]', '_', string) string = re.sub(r"[:\?<>\"|*]", "_", string)
return string return string
_CN_RE1 = re.compile(ur'[^\w]+', re.UNICODE) _CN_RE1 = re.compile(r'[^\w]+', re.UNICODE)
_CN_RE2 = re.compile(ur'[\s_]+', re.UNICODE) _CN_RE2 = re.compile(r'[\s_]+', re.UNICODE)
_XLATE_GRAPHICAL_AND_DIACRITICAL = { _XLATE_GRAPHICAL_AND_DIACRITICAL = {
@@ -233,25 +270,33 @@ _XLATE_GRAPHICAL_AND_DIACRITICAL = {
# ©ª«®²³¹»¼½¾ÆÐØÞßæðøþĐđĦħıIJijĸĿŀŁłŒœŦŧDŽDždžLJLjljNJNjnjǤǥDZDzdzȤȥ. This # ©ª«®²³¹»¼½¾ÆÐØÞßæðøþĐđĦħıIJijĸĿŀŁłŒœŦŧDŽDždžLJLjljNJNjnjǤǥDZDzdzȤȥ. This
# includes also some graphical symbols which can be easily replaced and # includes also some graphical symbols which can be easily replaced and
# usually are written by people who don't have appropriate keyboard layout. # usually are written by people who don't have appropriate keyboard layout.
u'©': '(C)', u'ª': 'a.', u'«': '<<', u'®': '(R)', u'²': '2', u'³': '3', '©': '(C)', 'ª': 'a.', '«': '<<', '®': '(R)', '²': '2', '³': '3',
u'¹': '1', u'»': '>>', u'¼': ' 1/4 ', u'½': ' 1/2 ', u'¾': ' 3/4 ', '¹': '1', '»': '>>', '¼': ' 1/4 ', '½': ' 1/2 ', '¾': ' 3/4 ',
u'Æ': 'AE', u'Ð': 'D', u'Ø': 'O', u'Þ': 'Th', u'ß': 'ss', u'æ': 'ae', 'Æ': 'AE', 'Ð': 'D', 'Ø': 'O', 'Þ': 'Th', 'ß': 'ss', 'æ': 'ae',
u'ð': 'd', u'ø': 'o', u'þ': 'th', u'Đ': 'D', u'đ': 'd', u'Ħ': 'H', 'ð': 'd', 'ø': 'o', 'þ': 'th', 'Đ': 'D', 'đ': 'd', 'Ħ': 'H',
u'ħ': 'h', u'ı': 'i', u'IJ': 'IJ', u'ij': 'ij', u'ĸ': 'q', u'Ŀ': 'L', 'ħ': 'h', 'ı': 'i', 'IJ': 'IJ', 'ij': 'ij', 'ĸ': 'q', 'Ŀ': 'L',
u'ŀ': 'l', u'Ł': 'L', u'ł': 'l', u'Œ': 'OE', u'œ': 'oe', u'Ŧ': 'T', 'ŀ': 'l', 'Ł': 'L', 'ł': 'l', 'Œ': 'OE', 'œ': 'oe', 'Ŧ': 'T',
u'ŧ': 't', u'DŽ': 'DZ', u'Dž': 'Dz', u'LJ': 'LJ', u'Lj': 'Lj', 'ŧ': 't', 'DŽ': 'DZ', 'Dž': 'Dz', 'LJ': 'LJ', 'Lj': 'Lj',
u'lj': 'lj', u'NJ': 'NJ', u'Nj': 'Nj', u'nj': 'nj', 'lj': 'lj', 'NJ': 'NJ', 'Nj': 'Nj', 'nj': 'nj',
u'Ǥ': 'G', u'ǥ': 'g', u'DZ': 'DZ', u'Dz': 'Dz', u'dz': 'dz', 'Ǥ': 'G', 'ǥ': 'g', 'DZ': 'DZ', 'Dz': 'Dz', 'dz': 'dz',
u'Ȥ': 'Z', u'ȥ': 'z', u'': 'No.', 'Ȥ': 'Z', 'ȥ': 'z', '': 'No.',
u'º': 'o.', # normalize Nº abbrev (popular w/ classical music), 'º': 'o.', # normalize Nº abbrev (popular w/ classical music),
# this is 'masculine ordering indicator', not degree # this is 'masculine ordering indicator', not degree
} }
_XLATE_SPECIAL = { _XLATE_SPECIAL = {
# Translation table. # Translation table.
# Cover additional special characters processing normalization. # Cover additional special characters processing normalization.
u"'": '', # replace apostrophe with nothing "'": '', # replace apostrophe with nothing
u'&': ' and ', # expand & to ' and ' "": '', # replace musicbrainz style apostrophe with nothing
'&': ' and ', # expand & to ' and '
}
_XLATE_MUSICBRAINZ = {
# Translation table for Musicbrainz.
"": '...', # HORIZONTAL ELLIPSIS (U+2026)
"": "'", # APOSTROPHE (U+0027)
"": "-", # EN DASH (U+2013)
} }
@@ -286,10 +331,10 @@ def _transliterate(u, xlate):
Perform transliteration using the specified dictionary Perform transliteration using the specified dictionary
""" """
u = unicodedata.normalize('NFD', u) u = unicodedata.normalize('NFD', u)
u = u''.join([u'' if _is_unicode_combining(x) else x for x in u]) u = ''.join(['' if _is_unicode_combining(x) else x for x in u])
u = _translate(u, xlate) u = _translate(u, xlate)
# at this point output is either unicode, or plain ascii # at this point output is either unicode, or plain ascii
return unicode(u) return str(u)
def clean_name(s): def clean_name(s):
@@ -299,10 +344,10 @@ def clean_name(s):
:param s: string to clean up, possibly unicode one. :param s: string to clean up, possibly unicode one.
:return: cleaned-up version of input string. :return: cleaned-up version of input string.
""" """
if not isinstance(s, unicode): if not isinstance(s, str):
# ignore extended chars if someone was dumb enough to pass non-ascii # ignore extended chars if someone was dumb enough to pass non-ascii
# narrow string here, use only unicode for meaningful texts # narrow string here, use only unicode for meaningful texts
u = unicode(s, 'ascii', 'replace') u = str(s, 'ascii', 'replace')
else: else:
u = s u = s
# 1. don't bother doing normalization NFKC, rather transliterate # 1. don't bother doing normalization NFKC, rather transliterate
@@ -313,17 +358,35 @@ def clean_name(s):
# 3. translate spacials # 3. translate spacials
u = _translate(u, _XLATE_SPECIAL) u = _translate(u, _XLATE_SPECIAL)
# 4. replace any non-alphanumeric character sequences by spaces # 4. replace any non-alphanumeric character sequences by spaces
u = _CN_RE1.sub(u' ', u) u = _CN_RE1.sub(' ', u)
# 5. coalesce interleaved space/underscore sequences # 5. coalesce interleaved space/underscore sequences
u = _CN_RE2.sub(u' ', u) u = _CN_RE2.sub(' ', u)
# 6. trim # 6. trim
u = u.strip() u = u.strip()
# 7. lowercase # 7. lowercase
u = u.lower()
return u return u
def clean_musicbrainz_name(s, return_as_string=True):
# type: (basestring)->unicode
"""Substitute special Musicbrainz characters.
:param s: string to clean up, probably unicode.
:return: cleaned-up version of input string.
"""
if not isinstance(s, str):
u = str(s, 'ascii', 'replace')
else:
u = s
u = _translate(u, _XLATE_MUSICBRAINZ)
if return_as_string:
return u.encode('utf-8')
else:
return u
def cleanTitle(title): def cleanTitle(title):
title = re.sub('[\.\-\/\_]', ' ', title).lower() title = re.sub(r"[\.\-\/\_]", " ", title).lower()
# Strip out extra whitespace # Strip out extra whitespace
title = ' '.join(title.split()) title = ' '.join(title.split())
@@ -406,8 +469,7 @@ def expand_subfolders(f):
if difference > 0: if difference > 0:
logger.info( 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.", f"Found {len(media_folders)} media folders, but depth difference between lowest and deepest media folder is {difference} (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
@@ -419,8 +481,7 @@ def expand_subfolders(f):
set([os.path.join(*media_folder) for media_folder in extra_media_folders])) set([os.path.join(*media_folder) for media_folder in extra_media_folders]))
logger.info( logger.info(
"Please look at the following folder(s), since they cause the depth difference: %s", f"Please look at the following folder(s), since they cause the depth difference: {extra_media_folders}")
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
@@ -434,7 +495,7 @@ def expand_subfolders(f):
logger.debug("Did not expand subfolder, as it resulted in one folder.") logger.debug("Did not expand subfolder, as it resulted in one folder.")
return return
logger.debug("Expanded subfolders in folder: %s", media_folders) logger.debug(f"Expanded subfolders in folder: {media_folders}")
return media_folders return media_folders
@@ -445,14 +506,14 @@ def path_match_patterns(path, patterns):
""" """
for pattern in patterns: for pattern in patterns:
if fnmatch.fnmatch(path, pattern): if fnmatch(path, pattern):
return True return True
# No match # No match
return False return False
def path_filter_patterns(paths, patterns, root=None): def path_filter_patterns(paths, patterns, root=''):
""" """
Scan for ignored paths based on glob patterns. Note that the whole path Scan for ignored paths based on glob patterns. Note that the whole path
will be matched, therefore paths should only contain the relative paths. will be matched, therefore paths should only contain the relative paths.
@@ -466,8 +527,7 @@ def path_filter_patterns(paths, patterns, root=None):
for path in paths[:]: for path in paths[:]:
if path_match_patterns(path, patterns): if path_match_patterns(path, patterns):
logger.debug("Path ignored by pattern: %s", logger.debug(f"Path ignored by pattern: {os.path.join(root, path)}")
os.path.join(root or "", path))
ignored += 1 ignored += 1
paths.remove(path) paths.remove(path)
@@ -549,7 +609,7 @@ def extract_metadata(f):
count_ratio = 0.75 count_ratio = 0.75
if count < (count_ratio * len(results)): if count < (count_ratio * len(results)):
logger.info("Counted %d media files, but only %d have tags, ignoring.", count, len(results)) logger.info(f"Counted {count} media files, but only {len(results)} have tags, ignoring.")
return (None, None, None) return (None, None, None)
# Count distinct values # Count distinct values
@@ -567,8 +627,7 @@ 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, logger.debug(f"Stripped album number identifier: {old_album} -> {new_albums[index]}")
new_albums[index])
# Remove duplicates # Remove duplicates
new_albums = list(set(new_albums)) new_albums = list(set(new_albums))
@@ -586,7 +645,7 @@ def extract_metadata(f):
if len(artists) > 1 and len(albums) == 1: if len(artists) > 1 and len(albums) == 1:
split_artists = [RE_FEATURING.split(x) for x in artists] split_artists = [RE_FEATURING.split(x) for x in artists]
featurings = [len(split_artist) - 1 for split_artist in split_artists] featurings = [len(split_artist) - 1 for split_artist in split_artists]
logger.info("Album seem to feature %d different artists", sum(featurings)) logger.info("Album seem to feature {sum(featurings)} different artists")
if sum(featurings) > 0: if sum(featurings) > 0:
# Find the artist of which the least splits have been generated. # Find the artist of which the least splits have been generated.
@@ -598,9 +657,11 @@ 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), logger.info(
len(albums), len(years)) f"Found {len(artists)} artists, {len(albums)} albums and "
logger.debug("Artists: %s, Albums: %s, Years: %s", artists, albums, years) f"{len(years)} years in metadata, so ignoring"
)
logger.debug("Artists: {artists}, Albums: {albums}, Years: {years}")
return (None, None, None) return (None, None, None)
@@ -620,26 +681,71 @@ def get_downloaded_track_list(albumpath):
return downloaded_track_list return downloaded_track_list
def preserve_torrent_directory(albumpath): def preserve_torrent_directory(albumpath, forced=False, single=False):
""" """
Copy torrent directory to headphones-modified to keep files for seeding. Copy torrent directory to temp headphones_ directory 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')) # Create temp dir
logger.info( if headphones.CONFIG.KEEP_TORRENT_FILES_DIR:
"Copying files to 'headphones-modified' subfolder to preserve downloaded files for seeding") tempdir = headphones.CONFIG.KEEP_TORRENT_FILES_DIR
else:
tempdir = tempfile.gettempdir()
logger.info(f"Preparing to copy to a temporary directory for post processing: {albumpath}")
try: try:
shutil.copytree(albumpath, new_folder) file_name = os.path.basename(os.path.normpath(albumpath))
return new_folder if not single:
prefix = "headphones_" + file_name + "_@hp@_"
else:
prefix = "headphones_" + os.path.splitext(file_name)[0] + "_@hp@_"
new_folder = tempfile.mkdtemp(prefix=prefix, dir=tempdir)
except Exception as e: except Exception as e:
logger.warn("Cannot copy/move files to temp folder: " + logger.error(f"Cannot create temp directory: {tempdir}. Error: {e}")
new_folder.decode(headphones.SYS_ENCODING, 'replace') + return None
". Not continuing. Error: " + str(e))
# Attempt to stop multiple temp dirs being created for the same albumpath
if not forced:
try:
workdir = os.path.join(tempdir, prefix)
workdir = re.sub(r'\[', '[[]', workdir)
workdir = re.sub(r'(?<!\[)\]', '[]]', workdir)
if len(glob(workdir + '*/')) >= 3:
logger.error(
"Looks like a temp directory has previously been created "
"for this albumpath, not continuing "
)
shutil.rmtree(new_folder)
return None
except Exception as e:
logger.warn(
"Cannot determine if already copied/processed, will copy anyway. "
f"Warning: {e}"
)
# Copy to temp dir
try:
subdir = os.path.join(new_folder, "headphones")
logger.info(f"Copying files to {subdir}")
if not single:
shutil.copytree(albumpath, subdir)
else:
os.makedirs(subdir)
shutil.copy(albumpath, subdir)
# Update the album path with the new location
return subdir
except Exception as e:
logger.warn(
f"Cannot copy/move files to temp directory: {new_folder}. "
f"Not continuing. Error: {e}"
)
shutil.rmtree(new_folder)
return None return None
def cue_split(albumpath): def cue_split(albumpath, keep_original_folder=False):
""" """
Attempts to check and split audio files by a cue for the given directory. Attempts to check and split audio files by a cue for the given directory.
""" """
@@ -661,6 +767,15 @@ def cue_split(albumpath):
# Split cue # Split cue
if cue_count and cue_count >= count and cue_dirs: if cue_count and cue_count >= count and cue_dirs:
# Copy to temp directory
if keep_original_folder:
temppath = preserve_torrent_directory(albumpath)
if temppath:
cue_dirs = [cue_dir.replace(albumpath, temppath) for cue_dir in cue_dirs]
albumpath = temppath
else:
return None
from headphones import logger, cuesplit from headphones import logger, cuesplit
logger.info("Attempting to split audio files by cue") logger.info("Attempting to split audio files by cue")
@@ -670,13 +785,13 @@ def cue_split(albumpath):
cuesplit.split(cue_dir) cuesplit.split(cue_dir)
except Exception as e: except Exception as e:
os.chdir(cwd) os.chdir(cwd)
logger.warn("Cue not split: " + str(e)) logger.warn(f"Cue not split. Error: {e}")
return False return None
os.chdir(cwd) os.chdir(cwd)
return True return albumpath
return False return None
def extract_logline(s): def extract_logline(s):
@@ -708,7 +823,7 @@ def extract_song_data(s):
year = match.group("year") year = match.group("year")
return (name, album, year) return (name, album, year)
else: else:
logger.info("Couldn't parse %s into a valid default format", s) logger.info(f"Couldn't parse {s} into a valid default format")
# newzbin default format # newzbin default format
pattern = re.compile(r'(?P<name>.*?)\s\-\s(?P<album>.*?)\s\((?P<year>\d+?\))', re.VERBOSE) pattern = re.compile(r'(?P<name>.*?)\s\-\s(?P<album>.*?)\s\((?P<year>\d+?\))', re.VERBOSE)
@@ -719,7 +834,7 @@ def extract_song_data(s):
year = match.group("year") year = match.group("year")
return (name, album, year) return (name, album, year)
else: else:
logger.info("Couldn't parse %s into a valid Newbin format", s) logger.info(f"Couldn't parse {s} into a valid Newbin format")
return (name, album, year) return (name, album, year)
@@ -728,9 +843,11 @@ def smartMove(src, dest, delete=True):
source_dir = os.path.dirname(src) source_dir = os.path.dirname(src)
filename = os.path.basename(src) filename = os.path.basename(src)
source_path = os.path.join(source_dir, filename)
dest_path = os.path.join(dest, filename)
if os.path.isfile(os.path.join(dest, filename)): if os.path.isfile(dest_path):
logger.info('Destination file exists: %s', os.path.join(dest, filename)) logger.info(f"Destination file exists: {dest_path}")
title = os.path.splitext(filename)[0] title = os.path.splitext(filename)[0]
ext = os.path.splitext(filename)[1] ext = os.path.splitext(filename)[1]
i = 1 i = 1
@@ -739,24 +856,39 @@ def smartMove(src, dest, delete=True):
if os.path.isfile(os.path.join(dest, newfile)): if os.path.isfile(os.path.join(dest, newfile)):
i += 1 i += 1
else: else:
logger.info('Renaming to %s', newfile) logger.info(f"Renaming to {newfile}")
try: try:
os.rename(src, os.path.join(source_dir, newfile)) os.rename(src, os.path.join(source_dir, newfile))
filename = newfile filename = newfile
source_path = os.path.join(source_dir, filename)
dest_path = os.path.join(dest, filename)
except Exception as e: except Exception as e:
logger.warn('Error renaming %s: %s', logger.warn(f"Error renaming {src}: {e}")
src.decode(headphones.SYS_ENCODING, 'replace'), e)
break break
try: if delete:
if delete: try:
shutil.move(os.path.join(source_dir, filename), os.path.join(dest, filename)) logger.info('Moving "%s" to "%s"', source_path, dest_path)
else: shutil.move(source_path, dest_path)
shutil.copy(os.path.join(source_dir, filename), os.path.join(dest, filename)) except Exception as e:
exists = os.path.exists(dest_path)
if exists and os.path.getsize(source_path) == os.path.getsize(dest_path):
logger.warn(
f"Successfully moved {filename}, but something went wrong: {e}"
)
os.unlink(source_path)
else:
# remove faultly copied file
if exists:
os.unlink(dest_path)
raise
else:
try:
logger.info(f"Copying {source_path} to {dest_path}")
shutil.copy(source_path, dest_path)
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'), logger.warn(f"Error copying {filename}: {e}")
e)
def walk_directory(basedir, followlinks=True): def walk_directory(basedir, followlinks=True):
@@ -765,7 +897,7 @@ def walk_directory(basedir, followlinks=True):
with care. In case a folder is already processed, don't traverse it again. with care. In case a folder is already processed, don't traverse it again.
""" """
import logger from . import logger
# Add the base path, because symlinks poiting to the basedir should not be # Add the base path, because symlinks poiting to the basedir should not be
# traversed again. # traversed again.
@@ -779,8 +911,10 @@ def walk_directory(basedir, followlinks=True):
real_path = os.path.abspath(os.readlink(path)) real_path = os.path.abspath(os.readlink(path))
if real_path in traversed: if real_path in traversed:
logger.debug("Skipping '%s' since it is a symlink to " logger.debug(
"'%s', which is already visited.", path, real_path) f"Skipping {path} since it is a symlink to "
f"{real_path}, which is already visited."
)
else: else:
traversed.append(real_path) traversed.append(real_path)
@@ -822,22 +956,15 @@ def sab_sanitize_foldername(name):
FL_ILLEGAL = CH_ILLEGAL + ':\x92"' FL_ILLEGAL = CH_ILLEGAL + ':\x92"'
FL_LEGAL = CH_LEGAL + "-''" FL_LEGAL = CH_LEGAL + "-''"
uFL_ILLEGAL = FL_ILLEGAL.decode('latin-1')
uFL_LEGAL = FL_LEGAL.decode('latin-1')
if not name: if not name:
return name return
if isinstance(name, unicode):
illegal = uFL_ILLEGAL name = unidecode(name)
legal = uFL_LEGAL
else:
illegal = FL_ILLEGAL
legal = FL_LEGAL
lst = [] lst = []
for ch in name.strip(): for ch in name.strip():
if ch in illegal: if ch in FL_ILLEGAL:
ch = legal[illegal.find(ch)] ch = FL_LEGAL[FL_ILLEGAL.find(ch)]
lst.append(ch) lst.append(ch)
else: else:
lst.append(ch) lst.append(ch)
@@ -893,7 +1020,42 @@ def create_https_certificates(ssl_cert, ssl_key):
with open(ssl_cert, "w") as fp: with open(ssl_cert, "w") as fp:
fp.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(f"Error creating SSL key and certificate: e")
return False return False
return True return True
class BeetsLogCapture(beetslogging.Handler):
def __init__(self):
beetslogging.Handler.__init__(self)
self.messages = []
def emit(self, record):
self.messages.append(text_type(record.msg))
@contextmanager
def capture_beets_log(logger='beets'):
capture = BeetsLogCapture()
log = beetslogging.getLogger(logger)
log.addHandler(capture)
try:
yield capture.messages
finally:
log.removeHandler(capture)
def have_pct_have_total(db_artist):
have_tracks = db_artist['HaveTracks'] or 0
total_tracks = db_artist['TotalTracks'] or 0
have_pct = have_tracks / total_tracks if total_tracks else 0
return (have_pct, total_tracks)
def has_token(title, token):
return bool(
re.search(rf'(?:\W|^)+{token}(?:\W|$)+',
title,
re.IGNORECASE | re.UNICODE)
)
+42 -17
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from unittestcompat import TestCase from .unittestcompat import TestCase
from headphones.helpers import clean_name from headphones.helpers import clean_name, is_valid_date, age, has_token
class HelpersTest(TestCase): class HelpersTest(TestCase):
@@ -8,28 +8,28 @@ class HelpersTest(TestCase):
def test_clean_name(self): def test_clean_name(self):
"""helpers: check correctness of clean_name() function""" """helpers: check correctness of clean_name() function"""
cases = { cases = {
u' Weiße & rose ': 'Weisse and rose', ' Weiße & rose ': 'Weisse and rose',
u'Multiple / spaces': 'Multiple spaces', 'Multiple / spaces': 'Multiple spaces',
u'Kevin\'s m²': 'Kevins m2', 'Kevin\'s m²': 'Kevins m2',
u'Symphonęy Nº9': 'Symphoney No.9', 'Symphonęy Nº9': 'Symphoney No.9',
u'ÆæßðÞIJij': u'AeaessdThIJıj', 'ÆæßðÞIJij': 'AeaessdThIJıj',
u'Obsessió (Cerebral Apoplexy remix)': 'obsessio cerebral ' 'Obsessió (Cerebral Apoplexy remix)': 'obsessio cerebral '
'apoplexy remix', 'apoplexy remix',
u'Doktór Hałabała i siedmiu zbojów': 'doktor halabala i siedmiu ' 'Doktór Hałabała i siedmiu zbojów': 'doktor halabala i siedmiu '
'zbojow', 'zbojow',
u'Arbetets Söner och Döttrar': 'arbetets soner och dottrar', 'Arbetets Söner och Döttrar': 'arbetets soner och dottrar',
u'Björk Guðmundsdóttir': 'bjork gudmundsdottir', 'Björk Guðmundsdóttir': 'bjork gudmundsdottir',
u'L\'Arc~en~Ciel': 'larc en ciel', 'L\'Arc~en~Ciel': 'larc en ciel',
u'Orquesta de la Luz (オルケスタ・デ・ラ・ルス)': 'Orquesta de la Luz (オルケスタ・デ・ラ・ルス)':
u'Orquesta de la Luz オルケスタ デ ラ ルス' 'Orquesta de la Luz オルケスタ デ ラ ルス'
} }
for first, second in cases.iteritems(): for first, second in cases.items():
nf = clean_name(first).lower() nf = clean_name(first).lower()
ns = clean_name(second).lower() ns = clean_name(second).lower()
self.assertEqual( self.assertEqual(
nf, ns, u"check cleaning of case (%s," nf, ns, "check cleaning of case (%s,"
u"%s)" % (nf, ns) "%s)" % (nf, ns)
) )
def test_clean_name_nonunicode(self): def test_clean_name_nonunicode(self):
@@ -46,3 +46,28 @@ class HelpersTest(TestCase):
self.assertEqual( self.assertEqual(
test, expected, "check clean_name() with narrow non-ascii input" test, expected, "check clean_name() with narrow non-ascii input"
) )
def test_is_valid_date(date):
test_cases = [
('2021-11-12', True, "check is_valid_date returns True for valid date"),
(None, False, "check is_valid_date returns False for None"),
('2021-11', False, "check is_valid_date returns False for incomplete"),
('2021', False, "check is_valid_date returns False for incomplete")
]
for input, expected, desc in test_cases:
self.assertEqual(is_valid_date(input), expected, desc)
def test_has_token(self):
"""helpers: has_token()"""
self.assertEqual(
has_token("a cat ran", "cat"),
True,
"return True if token is in string"
)
self.assertEqual(
has_token("acatran", "cat"),
False,
"return False if token is part of another word"
)
+49 -44
View File
@@ -16,7 +16,7 @@
import time import time
from headphones import logger, helpers, db, mb, lastfm, metacritic from headphones import logger, helpers, db, mb, lastfm, metacritic
from beets.mediafile import MediaFile from mediafile import MediaFile
import headphones import headphones
blacklisted_special_artist_names = ['[anonymous]', '[data]', '[no artist]', blacklisted_special_artist_names = ['[anonymous]', '[data]', '[no artist]',
@@ -39,7 +39,7 @@ def is_exists(artistid):
if any(artistid in x for x in artistlist): if any(artistid in x for x in artistlist):
logger.info(artistlist[0][ logger.info(artistlist[0][
1] + u" is already in the database. Updating 'have tracks', but not artist information") 1] + " is already in the database. Updating 'have tracks', but not artist information")
return True return True
else: else:
return False return False
@@ -53,7 +53,7 @@ def artistlist_to_mbids(artistlist, forced=False):
# 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?)
# and screwing everything up # and screwing everything up
if not isinstance(artist, unicode): if not isinstance(artist, str):
try: try:
artist = artist.decode('utf-8', 'replace') artist = artist.decode('utf-8', 'replace')
except Exception: except Exception:
@@ -89,23 +89,20 @@ def artistlist_to_mbids(artistlist, forced=False):
addArtisttoDB(artistid) addArtisttoDB(artistid)
# Just update the tracks if it does # Just update the tracks if it does
else:
havetracks = len( # not sure this is correct and we're updating during scanning in librarysync
myDB.select('SELECT TrackTitle from tracks WHERE ArtistID=?', [artistid])) + len( # else:
myDB.select('SELECT TrackTitle from have WHERE ArtistName like ?', [artist])) # havetracks = len(
myDB.action('UPDATE artists SET HaveTracks=? WHERE ArtistID=?', [havetracks, artistid]) # 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])
# Delete it from the New Artists if the request came from there # Delete it from the New Artists if the request came from there
if forced: if forced:
myDB.action('DELETE from newartists WHERE ArtistName=?', [artist]) myDB.action('DELETE from newartists WHERE ArtistName=?', [artist])
# Update the similar artist tag cloud: # Update the similar artist tag cloud:
logger.info('Updating artist information from Last.fm') lastfm.getSimilar()
try:
lastfm.getSimilar()
except Exception as e:
logger.warn('Failed to update arist information from Last.fm: %s' % e)
def addArtistIDListToDB(artistidlist): def addArtistIDListToDB(artistidlist):
@@ -182,7 +179,7 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
else: else:
sortname = artist['artist_name'] sortname = artist['artist_name']
logger.info(u"Now adding/updating: " + artist['artist_name']) logger.info("Now adding/updating: " + artist['artist_name'])
controlValueDict = {"ArtistID": artistid} controlValueDict = {"ArtistID": artistid}
newValueDict = {"ArtistName": artist['artist_name'], newValueDict = {"ArtistName": artist['artist_name'],
"ArtistSortName": sortname, "ArtistSortName": sortname,
@@ -243,7 +240,7 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
rgid = rg['id'] rgid = rg['id']
skip_log = 0 skip_log = 0
# Make a user configurable variable to skip update of albums with release dates older than this date (in days) # Make a user configurable variable to skip update of albums with release dates older than this date (in days)
pause_delta = headphones.CONFIG.MB_IGNORE_AGE ignore_age = headphones.CONFIG.MB_IGNORE_AGE
rg_exists = myDB.action("SELECT * from albums WHERE AlbumID=?", [rg['id']]).fetchone() rg_exists = myDB.action("SELECT * from albums WHERE AlbumID=?", [rg['id']]).fetchone()
@@ -261,25 +258,29 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
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 == "None":
logger.info("[%s] Now updating: %s (No Release Date)" % (artist['artist_name'], rg['title'])) if not headphones.CONFIG.MB_IGNORE_AGE_MISSING:
new_releases = mb.get_new_releases(rgid, includeExtras, True) logger.info("[%s] Now updating: %s (No Release Date)" % (artist['artist_name'], rg['title']))
new_releases = mb.get_new_releases(rgid, includeExtras, True)
else:
logger.info("[%s] Skipping update of: %s (No Release Date)" % (artist['artist_name'], rg['title']))
new_releases = 0
else: else:
if len(check_release_date) == 10: if len(check_release_date) == 10:
release_date = check_release_date release_date = check_release_date
elif len(check_release_date) == 7: elif len(check_release_date) == 7:
release_date = check_release_date + "-31" release_date = check_release_date + "-27"
elif len(check_release_date) == 4: elif len(check_release_date) == 4:
release_date = check_release_date + "-12-31" release_date = check_release_date + "-12-27"
else: else:
release_date = today release_date = today
if helpers.get_age(today) - helpers.get_age(release_date) < pause_delta: if helpers.age(release_date) < ignore_age:
logger.info("[%s] Now updating: %s (Release Date <%s Days)", logger.info("[%s] Now updating: %s (Release Date <%s Days)",
artist['artist_name'], rg['title'], pause_delta) artist['artist_name'], rg['title'], ignore_age)
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)", logger.info("[%s] Skipping: %s (Release Date >%s Days)",
artist['artist_name'], rg['title'], pause_delta) artist['artist_name'], rg['title'], ignore_age)
skip_log = 1 skip_log = 1
new_releases = 0 new_releases = 0
@@ -305,7 +306,7 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
# 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=?", find_hybrid_releases = myDB.select("SELECT * from allalbums WHERE AlbumID=?",
[rg['id']]) [rg['id']])
# Build the dictionary for the fullreleaselist # Build the dictionary for the fullreleaselist
@@ -444,14 +445,9 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
if headphones.CONFIG.AUTOWANT_ALL: if headphones.CONFIG.AUTOWANT_ALL:
newValueDict['Status'] = "Wanted" newValueDict['Status'] = "Wanted"
elif album['ReleaseDate'] > today and headphones.CONFIG.AUTOWANT_UPCOMING: elif headphones.CONFIG.AUTOWANT_UPCOMING:
newValueDict['Status'] = "Wanted" if helpers.is_valid_date(album['ReleaseDate']) and helpers.age(album['ReleaseDate']) < 21:
# Sometimes "new" albums are added to musicbrainz after their release date, so let's try to catch these newValueDict['Status'] = "Wanted"
# 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:
newValueDict['Status'] = "Wanted"
else: else:
newValueDict['Status'] = "Skipped" newValueDict['Status'] = "Skipped"
@@ -511,8 +507,11 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
marked_as_downloaded = True marked_as_downloaded = True
logger.info( logger.info(
u"[%s] Seeing if we need album art for %s" % (artist['artist_name'], rg['title'])) "[%s] Seeing if we need album art for %s" % (artist['artist_name'], rg['title']))
cache.getThumb(AlbumID=rg['id']) try:
cache.getThumb(AlbumID=rg['id'])
except Exception as e:
logger.error("Error getting album art: %s", e)
# 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
# downloaded and autowant_all is selected. This search is deferred, # downloaded and autowant_all is selected. This search is deferred,
@@ -521,17 +520,23 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
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" % ( logger.info("[%s] No new releases, so no changes made to %s" % (
artist['artist_name'], rg['title'])) 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)
logger.info(u"Seeing if we need album art for: %s" % artist['artist_name']) logger.info("Seeing if we need album art for: %s" % artist['artist_name'])
cache.getThumb(ArtistID=artistid) try:
cache.getThumb(ArtistID=artistid)
except Exception as e:
logger.error("Error getting album art: %s", e)
logger.info(u"Fetching Metacritic reviews for: %s" % artist['artist_name']) logger.info("Fetching Metacritic reviews for: %s" % artist['artist_name'])
metacritic.update(artistid, artist['artist_name'], artist['releasegroups']) try:
metacritic.update(artistid, artist['artist_name'], artist['releasegroups'])
except Exception as e:
logger.error("Error getting Metacritic reviews: %s", e)
if errors: if errors:
logger.info( logger.info(
@@ -539,7 +544,7 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
artist['artist_name'], artist['artist_name'])) 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("Updating complete for: %s" % artist['artist_name'])
# Start searching for newly added albums # Start searching for newly added albums
if album_searches: if album_searches:
@@ -648,7 +653,7 @@ def addReleaseById(rid, rgid=None):
sortname = release_dict['artist_name'] sortname = release_dict['artist_name']
logger.info( logger.info(
u"Now manually adding: " + release_dict['artist_name'] + " - with status Paused") "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,
@@ -681,7 +686,7 @@ def addReleaseById(rid, rgid=None):
if not rg_exists and release_dict or status == 'Loading' and release_dict: # it should never be the case that we have an rg and not the artist if not rg_exists and release_dict or status == 'Loading' and release_dict: # it should never be the case that we have an rg and not the artist
# but if it is this will fail # but if it is this will fail
logger.info(u"Now adding-by-id album (" + release_dict['title'] + ") from id: " + rgid) logger.info("Now adding-by-id album (" + release_dict['title'] + ") from id: " + rgid)
controlValueDict = {"AlbumID": rgid} controlValueDict = {"AlbumID": rgid}
if status != 'Loading': if status != 'Loading':
status = 'Wanted' status = 'Wanted'
@@ -757,7 +762,7 @@ def addReleaseById(rid, rgid=None):
# Start a search for the album # Start a search for the album
if headphones.CONFIG.AUTOWANT_MANUALLY_ADDED: if headphones.CONFIG.AUTOWANT_MANUALLY_ADDED:
import searcher from . import searcher
searcher.searchforalbum(rgid, False) searcher.searchforalbum(rgid, False)
elif not rg_exists and not release_dict: elif not rg_exists and not release_dict:
+33 -23
View File
@@ -22,8 +22,8 @@ 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 = "https://ws.audioscrobbler.com/2.0/"
API_KEY = "395e6ec6bb557382fc41fde867bce66f" APP_API_KEY = "395e6ec6bb557382fc41fde867bce66f"
# Required for API request limit # Required for API request limit
lastfm_lock = headphones.lock.TimedLock(REQUEST_LIMIT) lastfm_lock = headphones.lock.TimedLock(REQUEST_LIMIT)
@@ -31,7 +31,7 @@ lastfm_lock = headphones.lock.TimedLock(REQUEST_LIMIT)
def request_lastfm(method, **kwargs): def request_lastfm(method, **kwargs):
""" """
Call a Last.FM API method. Automatically sets the method and API key. Method Call a Last.fm API method. Automatically sets the method and API key. Method
will return the result if no error occured. will return the result if no error occured.
By default, this method will request the JSON format, since it is more By default, this method will request the JSON format, since it is more
@@ -40,35 +40,42 @@ def request_lastfm(method, **kwargs):
# Prepare request # Prepare request
kwargs["method"] = method kwargs["method"] = method
kwargs.setdefault("api_key", API_KEY) kwargs.setdefault("api_key", headphones.CONFIG.LASTFM_APIKEY or APP_API_KEY)
kwargs.setdefault("format", "json") kwargs.setdefault("format", "json")
# Send request # Send request
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, lock=lastfm_lock) data = request.request_json(ENTRY_POINT, timeout=TIMEOUT, params=kwargs, lock=lastfm_lock)
# Parse response and check for errors. # Parse response and check for errors.
if not data: if not data:
logger.error("Error calling Last.FM method: %s", method) logger.error("Error calling Last.fm method: %s", method)
return return
if "error" in data: if "error" in data:
logger.error("Last.FM returned an error: %s", data["message"]) logger.debug("Last.fm returned an error: %s", data["message"])
return return
return data return data
def getSimilar(): def getSimilar():
myDB = db.DBConnection() if not headphones.CONFIG.LASTFM_APIKEY:
results = myDB.select("SELECT ArtistID from artists ORDER BY HaveTracks DESC") logger.info(
'To update the Similar Artists cloud tag, create a Last.fm application api key '
'and add it under the Advanced config tab'
)
return
logger.info("Fetching similar artists from Last.FM for tag cloud") myDB = db.DBConnection()
results = myDB.select("SELECT ArtistID from artists ORDER BY HaveTracks DESC LIMIT 10")
logger.info("Fetching similar artists from Last.fm for tag cloud")
artistlist = [] artistlist = []
for result in results[:12]: for result in results:
data = request_lastfm("artist.getsimilar", mbid=result["ArtistId"]) data = request_lastfm("artist.getsimilar", mbid=result["ArtistId"])
if data and "similarartists" in data: if data and "similarartists" in data:
@@ -85,13 +92,13 @@ def getSimilar():
artistlist.append((artist_name, artist_mbid)) artistlist.append((artist_name, artist_mbid))
# Add new artists to tag cloud # Add new artists to tag cloud
logger.debug("Fetched %d artists from Last.FM", len(artistlist)) logger.debug("Fetched %d artists from Last.fm", len(artistlist))
count = defaultdict(int) count = defaultdict(int)
for artist, mbid in artistlist: for artist, mbid in artistlist:
count[artist, mbid] += 1 count[artist, mbid] += 1
items = count.items() items = list(count.items())
top_list = sorted(items, key=lambda x: x[1], reverse=True)[:25] top_list = sorted(items, key=lambda x: x[1], reverse=True)[:25]
random.shuffle(top_list) random.shuffle(top_list)
@@ -103,7 +110,7 @@ def getSimilar():
myDB.action("INSERT INTO lastfmcloud VALUES( ?, ?, ?)", [artist_name, artist_mbid, count]) myDB.action("INSERT INTO lastfmcloud VALUES( ?, ?, ?)", [artist_name, artist_mbid, count])
logger.debug("Inserted %d artists into Last.FM tag cloud", len(top_list)) logger.debug("Inserted %d artists into Last.fm tag cloud", len(top_list))
def getArtists(): def getArtists():
@@ -111,16 +118,16 @@ def getArtists():
results = myDB.select("SELECT ArtistID from artists") results = myDB.select("SELECT ArtistID from artists")
if not headphones.CONFIG.LASTFM_USERNAME: if not headphones.CONFIG.LASTFM_USERNAME:
logger.warn("Last.FM username not set, not importing artists.") logger.warn("Last.fm username not set, not importing artists.")
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=1000, 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 = []
artists = data["artists"]["artist"] artists = data["artists"]["artist"]
logger.debug("Fetched %d artists from Last.FM", len(artists)) logger.debug("Fetched %d artists from Last.fm", len(artists))
for artist in artists: for artist in artists:
artist_mbid = artist["mbid"] artist_mbid = artist["mbid"]
@@ -133,23 +140,26 @@ def getArtists():
for artistid in artistlist: for artistid in artistlist:
importer.addArtisttoDB(artistid) importer.addArtisttoDB(artistid)
logger.info("Imported %d new artists from Last.FM", len(artistlist)) logger.info("Imported %d new artists from Last.fm", len(artistlist))
def getTagTopArtists(tag, limit=50): def getTagTopArtists(tag, limit=50):
myDB = db.DBConnection() myDB = db.DBConnection()
results = myDB.select("SELECT ArtistID from artists") results = myDB.select("SELECT ArtistID from artists")
logger.info("Fetching top artists from Last.FM for tag: %s", tag) logger.info("Fetching top artists from Last.fm for tag: %s", tag)
data = request_lastfm("tag.gettopartists", limit=limit, tag=tag) data = request_lastfm("tag.gettopartists", limit=limit, tag=tag)
if data and "topartists" in data: if data and "topartists" in data:
artistlist = [] artistlist = []
artists = data["topartists"]["artist"] artists = data["topartists"]["artist"]
logger.debug("Fetched %d artists from Last.FM", len(artists)) logger.debug("Fetched %d artists from Last.fm", len(artists))
for artist in artists: for artist in artists:
artist_mbid = artist["mbid"] try:
artist_mbid = artist["mbid"]
except KeyError:
continue
if not any(artist_mbid in x for x in results): if not any(artist_mbid in x for x in results):
artistlist.append(artist_mbid) artistlist.append(artist_mbid)
@@ -159,4 +169,4 @@ def getTagTopArtists(tag, limit=50):
for artistid in artistlist: for artistid in artistlist:
importer.addArtisttoDB(artistid) importer.addArtisttoDB(artistid)
logger.debug("Added %d new artists from Last.FM", len(artistlist)) logger.debug("Added %d new artists from Last.fm", len(artistlist))
+307 -229
View File
@@ -17,7 +17,7 @@ import os
import math import math
import headphones import headphones
from beets.mediafile import MediaFile, FileTypeError, UnreadableFileError from mediafile import MediaFile, FileTypeError, UnreadableFileError
from headphones import db, logger, helpers, importer, lastfm from headphones import db, logger, helpers, importer, lastfm
@@ -30,56 +30,64 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
if not dir: if not dir:
if not headphones.CONFIG.MUSIC_DIR: if not headphones.CONFIG.MUSIC_DIR:
logger.info(
"No music directory configured. Add it under "
"Manage -> Scan Music Library"
)
return return
else: else:
dir = headphones.CONFIG.MUSIC_DIR dir = headphones.CONFIG.MUSIC_DIR
# If we're appending a dir, it's coming from the post processor which is
# already bytestring
if not append or artistScan:
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, logger.warn(f"Cannot find music directory: {dir}")
'replace'))
return return
myDB = db.DBConnection() myDB = db.DBConnection()
new_artists = [] new_artists = []
logger.info('Scanning music directory: %s' % dir.decode(headphones.SYS_ENCODING, 'replace')) logger.info(f"Scanning music directory: {dir}")
if not append: if not append:
# 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')
for track in tracks: # Clean up bad filepaths. Queries can take some time, ensure all results are loaded before processing
encoded_track_string = track['Location'].encode(headphones.SYS_ENCODING, 'replace') if ArtistID:
if not os.path.isfile(encoded_track_string): dbtracks = myDB.action(
'SELECT Location FROM alltracks WHERE ArtistID = ? AND Location IS NOT NULL UNION SELECT Location FROM tracks WHERE ArtistID = ? AND Location '
'IS NOT NULL',
[ArtistID, ArtistID])
else:
dbtracks = myDB.action(
'SELECT Location FROM alltracks WHERE Location IS NOT NULL UNION SELECT Location FROM tracks WHERE Location IS NOT NULL')
for track in dbtracks:
track_location = track['Location']
if not os.path.isfile(track_location):
myDB.action('UPDATE tracks SET Location=?, BitRate=?, Format=? WHERE Location=?', myDB.action('UPDATE tracks 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=?', myDB.action('UPDATE alltracks SET Location=?, BitRate=?, Format=? WHERE Location=?',
[None, None, None, track['Location']]) [None, None, None, track_location])
del_have_tracks = myDB.select('SELECT Location, Matched, ArtistName from have') if ArtistName:
del_have_tracks = myDB.select('SELECT Location, Matched, ArtistName FROM have WHERE ArtistName = ? COLLATE NOCASE', [ArtistName])
else:
del_have_tracks = myDB.select('SELECT Location, Matched, ArtistName FROM have')
for track in del_have_tracks: for track in del_have_tracks:
encoded_track_string = track['Location'].encode(headphones.SYS_ENCODING, 'replace') if not os.path.isfile(track['Location']):
if not os.path.isfile(encoded_track_string):
if track['ArtistName']: if track['ArtistName']:
# 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( logger.info(
'File %s removed from Headphones, as it is no longer on disk' % encoded_track_string.decode( f"{track['Location']} removed from Headphones, as it "
headphones.SYS_ENCODING, 'replace')) f"is no longer on disk"
)
bitrates = [] bitrates = []
song_list = [] track_list = []
latest_subdirectory = [] latest_subdirectory = []
new_song_count = 0 new_track_count = 0
file_count = 0 file_count = 0
for r, d, f in helpers.walk_directory(dir): for r, d, f in helpers.walk_directory(dir):
@@ -94,32 +102,16 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
subdirectory = r.replace(dir, '') subdirectory = r.replace(dir, '')
latest_subdirectory.append(subdirectory) latest_subdirectory.append(subdirectory)
if file_count == 0 and r.replace(dir, '') != '': track_path = os.path.join(r, files)
logger.info("[%s] Now scanning subdirectory %s" % (
dir.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)
# We need the unicode path to use for logging, inserting into database
unicode_song_path = song.decode(headphones.SYS_ENCODING, 'replace')
# Try to read the metadata # Try to read the metadata
try: try:
f = MediaFile(song) f = MediaFile(track_path)
except (FileTypeError, UnreadableFileError): except (FileTypeError, UnreadableFileError):
logger.warning( logger.warning(f"Cannot read `{track_path}`. It may be corrupted or not a media file.")
"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?", logger.warning(f"Cannnot read `{track_path}`. 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
@@ -134,15 +126,15 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
else: else:
f_artist = None f_artist = None
# Add the song to our song list - # Add the track to our track list -
# TODO: skip adding songs without the minimum requisite information (just a matter of putting together the right if statements) # TODO: skip adding tracks without the minimum requisite information (just a matter of putting together the right if statements)
if f_artist and f.album and f.title: if f_artist and f.album and f.title:
CleanName = helpers.clean_name(f_artist + ' ' + f.album + ' ' + f.title) CleanName = helpers.clean_name(f_artist + ' ' + f.album + ' ' + f.title)
else: else:
CleanName = None CleanName = None
controlValueDict = {'Location': unicode_song_path} controlValueDict = {'Location': track_path}
newValueDict = {'TrackID': f.mb_trackid, newValueDict = {'TrackID': f.mb_trackid,
# 'ReleaseID' : f.mb_albumid, # 'ReleaseID' : f.mb_albumid,
@@ -158,24 +150,24 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
'CleanName': CleanName 'CleanName': CleanName
} }
# song_list.append(song_dict) # track_list.append(track_dict)
check_exist_song = myDB.action("SELECT * FROM have WHERE Location=?", check_exist_track = myDB.action("SELECT * FROM have WHERE Location=?",
[unicode_song_path]).fetchone() [track_path]).fetchone()
# Only attempt to match songs that are new, haven't yet been matched, or metadata has changed. # Only attempt to match tracks that are new, haven't yet been matched, or metadata has changed.
if not check_exist_song: if not check_exist_track:
# This is a new track # This is a new track
if f_artist: if f_artist:
new_artists.append(f_artist) new_artists.append(f_artist)
myDB.upsert("have", newValueDict, controlValueDict) myDB.upsert("have", newValueDict, controlValueDict)
new_song_count += 1 new_track_count += 1
else: else:
if check_exist_song['ArtistName'] != f_artist or check_exist_song[ if check_exist_track['ArtistName'] != f_artist or check_exist_track[
'AlbumTitle'] != f.album or check_exist_song['TrackTitle'] != f.title: 'AlbumTitle'] != f.album or check_exist_track['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_track['ArtistName']:
new_artists.append(f_artist) new_artists.append(f_artist)
elif f_artist and f_artist == check_exist_song['ArtistName'] and \ elif f_artist and f_artist == check_exist_track['ArtistName'] and \
check_exist_song['Matched'] != "Ignored": check_exist_track['Matched'] != "Ignored":
new_artists.append(f_artist) new_artists.append(f_artist)
else: else:
continue continue
@@ -184,49 +176,59 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
myDB.upsert("have", newValueDict, controlValueDict) myDB.upsert("have", newValueDict, controlValueDict)
myDB.action( myDB.action(
'UPDATE tracks SET Location=?, BitRate=?, Format=? WHERE Location=?', 'UPDATE tracks SET Location=?, BitRate=?, Format=? WHERE Location=?',
[None, None, None, unicode_song_path]) [None, None, None, track_path])
myDB.action( myDB.action(
'UPDATE alltracks SET Location=?, BitRate=?, Format=? WHERE Location=?', 'UPDATE alltracks SET Location=?, BitRate=?, Format=? WHERE Location=?',
[None, None, None, unicode_song_path]) [None, None, None, track_path])
new_song_count += 1 new_track_count += 1
else: else:
# This track information hasn't changed # This track information hasn't changed
if f_artist and check_exist_song['Matched'] != "Ignored": if f_artist and check_exist_track['Matched'] != "Ignored":
new_artists.append(f_artist) new_artists.append(f_artist)
file_count += 1 file_count += 1
# 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(f"{new_track_count} new/modified tracks found and added to the database")
song_list = myDB.action("SELECT * FROM have WHERE Matched IS NULL AND LOCATION LIKE ?", dbtracks = myDB.action(
[dir.decode(headphones.SYS_ENCODING, 'replace') + "%"]) "SELECT * FROM have WHERE Matched IS NULL AND LOCATION LIKE ?",
total_number_of_songs = \ [f"{dir}%"]
myDB.action("SELECT COUNT(*) FROM have WHERE Matched IS NULL AND LOCATION LIKE ?", )
[dir.decode(headphones.SYS_ENCODING, 'replace') + "%"]).fetchone()[0] dbtracks_count = myDB.action(
logger.info("Found " + str(total_number_of_songs) + " new/modified tracks in: '" + dir.decode( "SELECT COUNT(*) FROM have WHERE Matched IS NULL AND LOCATION LIKE ?",
headphones.SYS_ENCODING, 'replace') + "'. Matching tracks to the appropriate releases....") [f"{dir}%"]
).fetchone()[0]
logger.info(f"Found {dbtracks_count} new/modified tracks in `{dir}`")
logger.info("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)
# When we insert into the database, the tracks with the most specific information will overwrite the more general matches
# song_list = helpers.multikeysort(song_list, ['ReleaseID', 'TrackID'])
song_list = helpers.multikeysort(song_list, ['ArtistName', 'AlbumTitle'])
# We'll use this to give a % completion, just because the track matching might take a while # Sort the track_list by most vague (e.g. no trackid or releaseid)
song_count = 0 # to most specific (both trackid & releaseid)
latest_artist = [] # When we insert into the database, the tracks with the most
# specific information will overwrite the more general matches
sorted_dbtracks = helpers.multikeysort(dbtracks, ['ArtistName', 'AlbumTitle'])
# We'll use this to give a % completion, just because the
# track matching might take a while
tracks_completed = 0
latest_artist = None
last_completion_percentage = 0 last_completion_percentage = 0
prev_artist_name = None
artistid = None
for song in song_list: for track in sorted_dbtracks:
latest_artist.append(song['ArtistName']) if latest_artist != track['ArtistName']:
if song_count == 0: logger.info(f"Now matching tracks by {track['ArtistName']}")
logger.info("Now matching songs by %s" % song['ArtistName']) latest_artist = track['ArtistName']
elif latest_artist[song_count] != latest_artist[song_count - 1] and song_count != 0:
logger.info("Now matching songs by %s" % song['ArtistName'])
song_count += 1 tracks_completed += 1
completion_percentage = math.floor(float(song_count) / total_number_of_songs * 1000) / 10 completion_percentage = math.floor(
float(tracks_completed) / dbtracks_count * 1000
) / 10
if completion_percentage >= (last_completion_percentage + 10): 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")
@@ -237,139 +239,184 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
# ARE NEVER FOUND, AND THE OTHER CLAUSES WERE NEVER HIT. FURTHERMORE, OTHER MATCHING FUNCTIONS IN THIS PROGRAM # ARE NEVER FOUND, AND THE OTHER CLAUSES WERE NEVER HIT. FURTHERMORE, OTHER MATCHING FUNCTIONS IN THIS PROGRAM
# (IMPORTER.PY, MB.PY) SIMPLY DO A [ARTIST/ALBUM/TRACK] OR CLEANNAME MATCH, SO IT'S ALL CONSISTENT. # (IMPORTER.PY, MB.PY) SIMPLY DO A [ARTIST/ALBUM/TRACK] OR CLEANNAME MATCH, SO IT'S ALL CONSISTENT.
if song['ArtistName'] and song['AlbumTitle'] and song['TrackTitle']: albumid = None
track = myDB.action( if track['ArtistName'] and track['CleanName']:
'SELECT ArtistName, AlbumTitle, TrackTitle, AlbumID from tracks WHERE ArtistName LIKE ? AND AlbumTitle LIKE ? AND TrackTitle LIKE ?', artist_name = track['ArtistName']
[song['ArtistName'], song['AlbumTitle'], song['TrackTitle']]).fetchone() clean_name = track['CleanName']
have_updated = False
if track:
controlValueDict = {'ArtistName': track['ArtistName'],
'AlbumTitle': track['AlbumTitle'],
'TrackTitle': track['TrackTitle']}
newValueDict = {'Location': song['Location'],
'BitRate': song['BitRate'],
'Format': song['Format']}
myDB.upsert("tracks", newValueDict, controlValueDict)
controlValueDict2 = {'Location': song['Location']} # Only update if artist is in the db
newValueDict2 = {'Matched': track['AlbumID']} if artist_name != prev_artist_name:
myDB.upsert("have", newValueDict2, controlValueDict2) prev_artist_name = artist_name
have_updated = True artistid = None
else:
track = myDB.action('SELECT CleanName, AlbumID from tracks WHERE CleanName LIKE ?',
[song['CleanName']]).fetchone()
if track:
controlValueDict = {'CleanName': track['CleanName']}
newValueDict = {'Location': song['Location'],
'BitRate': song['BitRate'],
'Format': song['Format']}
myDB.upsert("tracks", newValueDict, controlValueDict)
controlValueDict2 = {'Location': song['Location']} artist_lookup = "\"" + artist_name.replace("\"", "\"\"") + "\""
newValueDict2 = {'Matched': track['AlbumID']}
myDB.upsert("have", newValueDict2, controlValueDict2)
have_updated = True
else:
controlValueDict2 = {'Location': song['Location']}
newValueDict2 = {'Matched': "Failed"}
myDB.upsert("have", newValueDict2, controlValueDict2)
have_updated = True
alltrack = myDB.action( try:
'SELECT ArtistName, AlbumTitle, TrackTitle, AlbumID from alltracks WHERE ArtistName LIKE ? AND AlbumTitle LIKE ? AND TrackTitle LIKE ?', dbartist = myDB.select('SELECT DISTINCT ArtistID, ArtistName FROM artists WHERE ArtistName LIKE ' + artist_lookup + '')
[song['ArtistName'], song['AlbumTitle'], song['TrackTitle']]).fetchone() except:
if alltrack: dbartist = None
controlValueDict = {'ArtistName': alltrack['ArtistName'], if not dbartist:
'AlbumTitle': alltrack['AlbumTitle'], dbartist = myDB.select('SELECT DISTINCT ArtistID, ArtistName FROM tracks WHERE CleanName = ?', [clean_name])
'TrackTitle': alltrack['TrackTitle']} if not dbartist:
newValueDict = {'Location': song['Location'], dbartist = myDB.select('SELECT DISTINCT ArtistID, ArtistName FROM alltracks WHERE CleanName = ?', [clean_name])
'BitRate': song['BitRate'], if not dbartist:
'Format': song['Format']} clean_artist = helpers.clean_name(artist_name)
myDB.upsert("alltracks", newValueDict, controlValueDict) if clean_artist:
dbartist = myDB.select('SELECT DISTINCT ArtistID, ArtistName FROM tracks WHERE CleanName >= ? and CleanName < ?',
[clean_artist, clean_artist + '{'])
if not dbartist:
dbartist = myDB.select('SELECT DISTINCT ArtistID, ArtistName FROM alltracks WHERE CleanName >= ? and CleanName < ?',
[clean_artist, clean_artist + '{'])
controlValueDict2 = {'Location': song['Location']} if dbartist:
newValueDict2 = {'Matched': alltrack['AlbumID']} artistid = dbartist[0][0]
myDB.upsert("have", newValueDict2, controlValueDict2)
else: if artistid:
alltrack = myDB.action(
'SELECT CleanName, AlbumID from alltracks WHERE CleanName LIKE ?', # This was previously using Artist, Album, Title with a SELECT LIKE ? and was not using an index
[song['CleanName']]).fetchone() # (Possible issue: https://stackoverflow.com/questions/37845854/python-sqlite3-not-using-index-with-like)
# Now selects/updates using CleanName index (may have to revert if not working)
# matching on CleanName should be enough, ensure it's the same artist just in case
# Update tracks
dbtrack = myDB.action('SELECT AlbumID, ArtistName FROM tracks WHERE CleanName = ? AND ArtistID = ?', [clean_name, artistid]).fetchone()
if dbtrack:
albumid = dbtrack['AlbumID']
myDB.action(
'UPDATE tracks SET Location = ?, BitRate = ?, Format = ? WHERE CleanName = ? AND ArtistID = ?',
[track['Location'], track['BitRate'], track['Format'], clean_name, artistid])
# Update alltracks
alltrack = myDB.action('SELECT AlbumID, ArtistName FROM alltracks WHERE CleanName = ? AND ArtistID = ?', [clean_name, artistid]).fetchone()
if alltrack: if alltrack:
controlValueDict = {'CleanName': alltrack['CleanName']} albumid = alltrack['AlbumID']
newValueDict = {'Location': song['Location'], myDB.action(
'BitRate': song['BitRate'], 'UPDATE alltracks SET Location = ?, BitRate = ?, Format = ? WHERE CleanName = ? AND ArtistID = ?',
'Format': song['Format']} [track['Location'], track['BitRate'], track['Format'], clean_name, artistid])
myDB.upsert("alltracks", newValueDict, controlValueDict)
controlValueDict2 = {'Location': song['Location']}
newValueDict2 = {'Matched': alltrack['AlbumID']}
myDB.upsert("have", newValueDict2, controlValueDict2)
else:
# alltracks may not exist if adding album manually, have should only be set to failed if not already updated in tracks
if not have_updated:
controlValueDict2 = {'Location': song['Location']}
newValueDict2 = {'Matched': "Failed"}
myDB.upsert("have", newValueDict2, controlValueDict2)
# Update have
controlValueDict2 = {'Location': track['Location']}
if albumid:
newValueDict2 = {'Matched': albumid}
else: else:
controlValueDict2 = {'Location': song['Location']}
newValueDict2 = {'Matched': "Failed"} newValueDict2 = {'Matched': "Failed"}
myDB.upsert("have", newValueDict2, controlValueDict2) myDB.upsert("have", newValueDict2, controlValueDict2)
# 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( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [track['ArtistName'], track['AlbumTitle'], track['TrackNumber'], track['TrackTitle'], track['TrackLength'], track['BitRate'], track['Genre'], track['Date'], track['TrackID'], track['Location'], CleanName, track['Format']])
logger.info('Completed matching tracks from directory: %s' % dir.decode(headphones.SYS_ENCODING, logger.info(f"Completed matching tracks from `{dir}`")
'replace'))
if not append or artistScan: 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
unique_artists = {}.fromkeys(new_artists).keys() unique_artists = list({}.fromkeys(new_artists).keys())
current_artists = myDB.select('SELECT ArtistName, ArtistID from artists')
# There was a bug where artists with special characters (-,') would show up in new artists. # # Don't think we need to do this, check the db instead below
artist_list = [ #
x for x in unique_artists # # artist scan
if helpers.clean_name(x).lower() not in [ # if ArtistName:
helpers.clean_name(y[0]).lower() # current_artists = [[ArtistName]]
for y in current_artists # # directory scan
] # else:
] # current_artists = myDB.select('SELECT ArtistName, ArtistID FROM artists WHERE ArtistName IS NOT NULL')
artists_checked = [ #
x for x in unique_artists # # There was a bug where artists with special characters (-,') would show up in new artists.
if helpers.clean_name(x).lower() in [ #
helpers.clean_name(y[0]).lower() # # artist_list = scanned artists not in the db
for y in current_artists # artist_list = [
] # x for x in unique_artists
] # if helpers.clean_name(x).lower() not in [
# helpers.clean_name(y[0]).lower()
# for y in current_artists
# ]
# ]
#
# # artists_checked = scanned artists that exist in the db
# artists_checked = [
# x for x in unique_artists
# if helpers.clean_name(x).lower() in [
# helpers.clean_name(y[0]).lower()
# for y in current_artists
# ]
# ]
# Update track counts new_artist_list = []
for artist in artists_checked:
# 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
havetracks = (
len(myDB.select(
'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
# (can fix by getting rid of second len statement)
myDB.action('UPDATE artists SET HaveTracks=? WHERE ArtistName=?', [havetracks, artist])
logger.info('Found %i new artists' % len(artist_list)) for artist in unique_artists:
if artist_list: if not artist:
continue
logger.info('Processing artist: %s' % artist)
# check if artist is already in the db
artist_lookup = "\"" + artist.replace("\"", "\"\"") + "\""
try:
dbartist = myDB.select('SELECT DISTINCT ArtistID, ArtistName FROM artists WHERE ArtistName LIKE ' + artist_lookup + '')
except:
dbartist = None
if not dbartist:
clean_artist = helpers.clean_name(artist)
if clean_artist:
dbartist = myDB.select('SELECT DISTINCT ArtistID, ArtistName FROM tracks WHERE CleanName >= ? and CleanName < ?',
[clean_artist, clean_artist + '{'])
if not dbartist:
dbartist = myDB.select('SELECT DISTINCT ArtistID, ArtistName FROM alltracks WHERE CleanName >= ? and CleanName < ?',
[clean_artist, clean_artist + '{'])
# new artist not in db, add to list
if not dbartist:
new_artist_list.append(artist)
else:
# artist in db, update have track counts
artistid = dbartist[0][0]
# 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
# havetracks = (
# len(myDB.select(
# '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]))
# )
try:
havetracks = (
len(myDB.select(
'SELECT ArtistID From tracks WHERE ArtistID = ? AND Location IS NOT NULL',
[artistid])) + len(myDB.select(
'SELECT ArtistName FROM have WHERE ArtistName LIKE ' + artist_lookup + ' AND Matched = "Failed"'))
)
except Exception as e:
logger.warn('Error updating counts for artist: %s: %s' % (artist, e))
# 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)
if havetracks:
myDB.action('UPDATE artists SET HaveTracks = ? WHERE ArtistID = ?', [havetracks, artistid])
# Update albums to downloaded
update_album_status(ArtistID=artistid)
logger.info('Found %i new artists' % len(new_artist_list))
# Add scanned artists not in the db
if new_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(new_artist_list))
importer.artistlist_to_mbids(artist_list) importer.artistlist_to_mbids(new_artist_list)
else: else:
logger.info('To add these artists, go to Manage->Manage New Artists') logger.info('To add these artists, go to Manage->Manage New Artists')
# myDB.action('DELETE from newartists') # myDB.action('DELETE from newartists')
for artist in artist_list: for artist in new_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 and bitrates: if headphones.CONFIG.DETECT_BITRATE and bitrates:
@@ -379,50 +426,78 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
# 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( artist_lookup = "\"" + ArtistName.replace("\"", "\"\"") + "\""
myDB.select('SELECT TrackTitle from tracks WHERE ArtistID=? AND Location IS NOT NULL', try:
[ArtistID])) + len(myDB.select( havetracks = len(
'SELECT TrackTitle from have WHERE ArtistName like ? AND Matched = "Failed"', myDB.select('SELECT ArtistID FROM tracks WHERE ArtistID = ? AND Location IS NOT NULL',
[ArtistName])) [ArtistID])) + len(myDB.select(
myDB.action('UPDATE artists SET HaveTracks=? WHERE ArtistID=?', [havetracks, ArtistID]) 'SELECT ArtistName FROM have WHERE ArtistName LIKE ' + artist_lookup + ' AND Matched = "Failed"'))
except Exception as e:
logger.warn('Error updating counts for artist: %s: %s' % (ArtistName, e))
if not append: if havetracks:
update_album_status() myDB.action('UPDATE artists SET HaveTracks=? WHERE ArtistID=?', [havetracks, ArtistID])
# Moved above to call for each artist
# if not append:
# update_album_status()
if not append and not artistScan: if not append and not artistScan:
lastfm.getSimilar() lastfm.getSimilar()
logger.info('Library scan complete') if ArtistName:
logger.info('Scanning complete for artist: %s', ArtistName)
else:
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
# Think the above comment relates to calling from Manage Unmatched
def update_album_status(AlbumID=None): # This used to select and update all albums and would clobber the db, changed to run by ArtistID.
def update_album_status(AlbumID=None, ArtistID=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( album_status_updater = myDB.action(
'SELECT AlbumID, AlbumTitle, Status from albums WHERE AlbumID=?', [AlbumID]) 'SELECT'
' a.AlbumID, a.ArtistName, a.AlbumTitle, a.Status, AVG(t.Location IS NOT NULL) * 100 AS album_completion '
'FROM'
' albums AS a '
'JOIN tracks AS t ON t.AlbumID = a.AlbumID '
'WHERE'
' a.AlbumID = ? AND a.Status != "Downloaded" '
'GROUP BY'
' a.AlbumID '
'HAVING'
' AVG(t.Location IS NOT NULL) * 100 >= ?',
[AlbumID, headphones.CONFIG.ALBUM_COMPLETION_PCT]
)
else: else:
album_status_updater = myDB.action('SELECT AlbumID, AlbumTitle, Status from albums') album_status_updater = myDB.action(
for album in album_status_updater: 'SELECT'
track_counter = myDB.action('SELECT Location from tracks where AlbumID=?', ' a.AlbumID, a.ArtistID, a.ArtistName, a.AlbumTitle, a.Status, AVG(t.Location IS NOT NULL) * 100 AS album_completion '
[album['AlbumID']]) 'FROM'
total_tracks = 0 ' albums AS a '
have_tracks = 0 'JOIN tracks AS t ON t.AlbumID = a.AlbumID '
for track in track_counter: 'WHERE'
total_tracks += 1 ' a.ArtistID = ? AND a.Status != "Downloaded" '
if track['Location']: 'GROUP BY'
have_tracks += 1 ' a.AlbumID '
if total_tracks != 0: 'HAVING'
album_completion = float(float(have_tracks) / float(total_tracks)) * 100 ' AVG(t.Location IS NOT NULL) * 100 >= ?',
else: [ArtistID, headphones.CONFIG.ALBUM_COMPLETION_PCT]
album_completion = 0 )
logger.info('Album %s does not have any tracks in database' % album['AlbumTitle'])
if album_completion >= headphones.CONFIG.ALBUM_COMPLETION_PCT: new_album_status = "Downloaded"
new_album_status = "Downloaded"
albums = []
for album in album_status_updater:
albums.append([album['AlbumID'], album['ArtistName'], album['AlbumTitle']])
for album in albums:
# I don't think we want to change Downloaded->Skipped..... # I don't think we want to change Downloaded->Skipped.....
# I think we can only automatically change Skipped->Downloaded when updating # I think we can only automatically change Skipped->Downloaded when updating
@@ -433,10 +508,13 @@ def update_album_status(AlbumID=None):
# new_album_status = "Skipped" # new_album_status = "Skipped"
# else: # else:
# new_album_status = album['Status'] # new_album_status = album['Status']
else: # else:
new_album_status = album['Status'] # new_album_status = album['Status']
#
# myDB.upsert("albums", {'Status': new_album_status}, {'AlbumID': album['AlbumID']})
# if new_album_status != album['Status']:
# logger.info('Album %s changed to %s' % (album['AlbumTitle'], new_album_status))
# logger.info('Album status update complete')
myDB.upsert("albums", {'Status': new_album_status}, {'AlbumID': album['AlbumID']}) myDB.action('UPDATE albums SET Status = ? WHERE AlbumID = ?', [new_album_status, album[0]])
if new_album_status != album['Status']: logger.info('Album: %s - %s. Status updated to %s' % (album[1], album[2], new_album_status))
logger.info('Album %s changed to %s' % (album['AlbumTitle'], new_album_status))
logger.info('Album status update complete')
+3 -3
View File
@@ -4,7 +4,7 @@ Locking-related classes
import time import time
import threading import threading
import Queue import queue
import headphones.logger import headphones.logger
@@ -29,7 +29,7 @@ class TimedLock(object):
self.lock = threading.Lock() self.lock = threading.Lock()
self.last_used = 0 self.last_used = 0
self.minimum_delta = minimum_delta self.minimum_delta = minimum_delta
self.queue = Queue.Queue() self.queue = queue.Queue()
def __enter__(self): def __enter__(self):
""" """
@@ -47,7 +47,7 @@ class TimedLock(object):
seconds = self.queue.get(False) seconds = self.queue.get(False)
headphones.logger.debug('Sleeping %s (queued)', seconds) headphones.logger.debug('Sleeping %s (queued)', seconds)
time.sleep(seconds) time.sleep(seconds)
except Queue.Empty: except queue.Empty:
continue continue
self.queue.task_done() self.queue.task_done()
+2 -1
View File
@@ -153,7 +153,8 @@ def initLogger(console=False, log_dir=False, verbose=False):
file_formatter = logging.Formatter( file_formatter = logging.Formatter(
'%(asctime)s - %(levelname)-7s :: %(threadName)s : %(message)s', '%d-%b-%Y %H:%M:%S') '%(asctime)s - %(levelname)-7s :: %(threadName)s : %(message)s', '%d-%b-%Y %H:%M:%S')
file_handler = handlers.RotatingFileHandler(filename, maxBytes=MAX_SIZE, file_handler = handlers.RotatingFileHandler(filename, maxBytes=MAX_SIZE,
backupCount=MAX_FILES) backupCount=MAX_FILES,
encoding='utf8')
file_handler.setLevel(logging.DEBUG) file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(file_formatter) file_handler.setFormatter(file_formatter)
+6 -6
View File
@@ -13,7 +13,7 @@
# 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 htmlentitydefs import html.entities
import re import re
from headphones import logger, request from headphones import logger, request
@@ -25,7 +25,7 @@ def getLyrics(artist, song):
"fmt": 'xml' "fmt": 'xml'
} }
url = 'http://lyrics.wikia.com/api.php' url = 'https://lyrics.wikia.com/api.php'
data = request.request_minidom(url, params=params) data = request.request_minidom(url, params=params)
if not data: if not data:
@@ -53,7 +53,7 @@ def getLyrics(artist, song):
'''<div class='lyricbox'><span style="padding:1em"><a href="/Category:Instrumental" title="Instrumental">''').search( '''<div class='lyricbox'><span style="padding:1em"><a href="/Category:Instrumental" title="Instrumental">''').search(
lyricspage) lyricspage)
if m: if m:
return u'(Instrumental)' return '(Instrumental)'
else: else:
logger.warn('Cannot find lyrics on: %s' % lyricsurl) logger.warn('Cannot find lyrics on: %s' % lyricsurl)
return return
@@ -72,7 +72,7 @@ def convert_html_entities(s):
name = hit[2:-1] name = hit[2:-1]
try: try:
entnum = int(name) entnum = int(name)
s = s.replace(hit, unichr(entnum)) s = s.replace(hit, chr(entnum))
except ValueError: except ValueError:
pass pass
@@ -83,7 +83,7 @@ def convert_html_entities(s):
hits.remove(amp) hits.remove(amp)
for hit in hits: for hit in hits:
name = hit[1:-1] name = hit[1:-1]
if name in htmlentitydefs.name2codepoint: if name in html.entities.name2codepoint:
s = s.replace(hit, unichr(htmlentitydefs.name2codepoint[name])) s = s.replace(hit, chr(html.entities.name2codepoint[name]))
s = s.replace(amp, "&") s = s.replace(amp, "&")
return s return s

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