Merge branch 'develop' into master

This commit is contained in:
Get your own 'tots. Geez!
2018-02-22 19:02:29 -07:00
committed by GitHub
206 changed files with 19029 additions and 6853 deletions

View File

@@ -11,7 +11,6 @@ cache:
- lib
python:
- "2.6"
- "2.7"
install:

View File

@@ -1,5 +1,28 @@
# Changelog
## 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

View File

@@ -54,7 +54,10 @@ def main():
try:
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):
pass
@@ -81,6 +84,8 @@ def main():
help='Prevent browser from launching on startup')
parser.add_argument(
'--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()
@@ -170,6 +175,13 @@ def main():
else:
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
# and for CherryPy.
if headphones.CONFIG.ENABLE_HTTPS:
@@ -183,7 +195,7 @@ def main():
# Try to start the server. Will exit here is address is already in use.
web_config = {
'http_port': http_port,
'http_host': headphones.CONFIG.HTTP_HOST,
'http_host': http_host,
'http_root': headphones.CONFIG.HTTP_ROOT,
'http_proxy': headphones.CONFIG.HTTP_PROXY,
'enable_https': headphones.CONFIG.ENABLE_HTTPS,

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://img.shields.io/travis/rembo10/headphones/develop.svg?label=develop%20branch%20build)](https://travis-ci.org/rembo10/headphones)

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

View File

@@ -56,7 +56,7 @@
<label title="Password for web server authentication. Leave empty to disable.">
HTTP Password
</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 class="row checkbox">
<input type="checkbox" name="launch_browser" value="1" ${config['launch_browser']} />
@@ -179,7 +179,7 @@
<label title="SABnzbd password. Leave empty if not applicable.">
SABnzbd Password
</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 class="row">
<label title="SABnzbd API key. Can be found in SABnzbd settings.">
@@ -213,7 +213,7 @@
<label title="NZBGet password. Leave empty if not applicable">
NZBget Password
</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 class="row">
<label title="Name of NZBget category to add downloads to.">
@@ -318,6 +318,7 @@
<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_deluge" value="3" ${config['torrent_downloader_deluge']}> Deluge (Beta)
<input type="radio" name="torrent_downloader" id="torrent_downloader_qbittorrent" value="4" ${config['torrent_downloader_qbittorrent']}> QBitTorrent
</fieldset>
<fieldset id="torrent_blackhole_options">
<div class="row">
@@ -365,7 +366,7 @@
</div>
<div class="row">
<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 class="row">
<small>Note: With Transmission, you can specify a different download directory for downloads sent from Headphones.
@@ -385,13 +386,33 @@
</div>
<div class="row">
<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 class="row">
<label>uTorrent Label</label>
<input type="text" name="utorrent_label" value="${config['utorrent_label']}" size="30">
</div>
</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">
<div class="row">
<label>Deluge WebUI Host and Port</label>
@@ -406,7 +427,7 @@
</div>
<div class="row">
<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 class="row">
<small>Note: With Deluge, you can specify a different download directory for downloads sent from Headphones.
@@ -472,7 +493,7 @@
</div>
<div class="row">
<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 class="row">
<a href="https://headphones.codeshy.com/vip" id="vipserver" target="_blank">Don't have an account? Sign up!</a>
@@ -628,31 +649,59 @@
</div>
<div class="row">
<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 class="row">
<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">
</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>
</fieldset>
<fieldset>
<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_apollo" type="checkbox" class="bigcheck" name="use_apollo" value="1" ${config['use_apollo']} /><label for="use_apollo"><span class="option">Apollo.rip</span></label>
</div>
<div class="config">
<div class="row">
<label>Username</label>
<input type="text" name="whatcd_username" value="${config['whatcd_username']}" size="36">
<input type="text" name="apollo_username" value="${config['apollo_username']}" size="36">
</div>
<div class="row">
<label>Password</label>
<input type="password" name="whatcd_password" value="${config['whatcd_password']}" size="36">
<input type="password" name="apollo_password" value="${config['apollo_password'] | h}" size="36">
</div>
<div class="row">
<label>URL</label>
<input type="text" name="apollo_url" value="${config['apollo_url']}" size="36">
</div>
<div class="row">
<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="apollo_ratio" value="${config['apollo_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_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 class="config">
<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">
<label>Seed Ratio</label>
<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>
</fieldset>
@@ -677,8 +726,8 @@
<div class="config" id="torznab1">
<div class="row">
<label>Torznab Host</label>
<input type="text" name="torznab_host" value="${config['torznab_host']}" size="30">
<small>e.g. http://localhost:9117/torznab/iptorrents</small>
<input type="text" name="torznab_host" value="${config['torznab_host']}" size="36">
<small>e.g. http://localhost:9117/api/v2.0/indexers/demonoid/results/torznab/</small>
</div>
<div class="row">
<label>Torznab API</label>
@@ -701,7 +750,7 @@
<div class="config" id="torznab${torznab_number}">
<div class="row">
<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 class="row">
<label>Torznab API</label>
@@ -749,6 +798,22 @@
</div>
</div>
</fieldset>
<fieldset>
<div class="row checkbox left">
<input id="use_tquattrecentonze" type="checkbox" class="bigcheck" name="use_tquattrecentonze" value="1" ${config['use_tquattrecentonze']} /><label for="use_tquattrecentonze"><span class="option">t411</span></label>
</div>
<div class="config">
<div class="row">
<label>Username</label>
<input type="text" name="tquattrecentonze_user" value="${config['tquattrecentonze_user']}" size="36">
</div>
<div class="row">
<label>Password</label>
<input type="password" name="tquattrecentonze_password" value="${config['tquattrecentonze_password'] | h}" size="36">
</div>
</div>
</fieldset>
</fieldset>
</td>
@@ -890,30 +955,42 @@
<input type="checkbox" name="keep_nfo" value="1" ${config['keep_nfo']} />
</label>
</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">
<label>
Add album art jpeg to album folder
<input type="checkbox" name="add_album_art" id="add_album_art" value="1" ${config['add_album_art']}>
</label>
</div>
<div id="album_art_options" style="padding-left: 20px">
<div class="row">
as <input type="text" class="override-float" name="album_art_format" value="${config['album_art_format']}" size="10">.jpg
</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>
</div>
<div class="row checkbox left clearfix nopad">
<label>
Embed album art in each file
<input type="checkbox" name="embed_album_art" value="1" ${config['embed_album_art']}>
</label>
</div>
<div class="row checkbox left clearfix nopad">
<label>
Embed lyrics
<input type="checkbox" name="embed_lyrics" value="1" ${config['embed_lyrics']}>
</label>
<div id="album_art_size_options" style="padding-left: 20px">
<div class="row">
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.">\
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">
<label>
Destination Directory:
@@ -970,7 +1047,7 @@
<label>SMTP User</label><input type="text" name="email_smtp_user" value="${config['email_smtp_user']}" size="254">
</div>
<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 class="row checkbox">
<input type="text" class="override-float" name="email_smtp_port" value="${config['email_smtp_port']}" size="4"><label>SMTP Port</label>
@@ -996,7 +1073,7 @@
<label>Growl Host:Port</label><input type="text" name="growl_host" value="${config['growl_host']}" size="30">
</div>
<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 class="row checkbox">
<input type="checkbox" name="growl_onsnatch" value="1" ${config['growl_onsnatch']} /><label>Notify on snatch?</label>
@@ -1018,7 +1095,7 @@
<label>Username</label><input type="text" name="xbmc_username" value="${config['xbmc_username']}" size="30">
</div>
<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 class="checkbox row">
<input type="checkbox" name="xbmc_update" value="1" ${config['xbmc_update']} /><label>Update Library</label>
@@ -1132,7 +1209,7 @@
<small>Username of your Plex client API (blank for none)</small>
</div>
<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>
</div>
<div class="row">
@@ -1232,7 +1309,7 @@
<label>Subsonic Username</label><input type="text" name="subsonic_username" value="${config['subsonic_username']}" size="30">
</div>
<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>
</fieldset>
@@ -1264,6 +1341,26 @@
</div>
</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>
<div class="row checkbox left">
<input type="checkbox" class="bigcheck" name="telegram_enabled" id="telegram" value="1" ${config['telegram_enabled']} /><label for="telegram"><span class="option">Telegram</span></label>
@@ -1281,6 +1378,23 @@
</div>
</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>
</tr>
</table>
@@ -1576,6 +1690,10 @@
<label>Cache Directory</label>
<input type="text" name="cache_dir" value="${config['cache_dir']}" size="50">
</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>
@@ -1632,7 +1750,7 @@
<label>Username</label><input type="text" class="customuser" name="customuser" value="${config['customuser']}" size="20">
</div>
<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 class="row">
@@ -1645,7 +1763,7 @@
<label>Username</label><input type="text" class="hpuser" name="hpuser" value="${config['hpuser']}" size="20">
</div>
<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>
</div>
</div>
@@ -1820,9 +1938,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"))
{
$("#album_art_options").show();
$("#album_art_size_options").show();
}
else
{
@@ -1833,10 +1978,15 @@
if ($("#add_album_art").is(":checked"))
{
$("#album_art_options").slideDown();
$("#album_art_size_options").slideDown();
}
else
{
$("#album_art_options").slideUp();
if (!$("#embed_album_art").is(":checked"))
{
$("#album_art_size_options").slideUp();
}
}
});
@@ -2041,6 +2191,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"))
{
$("#twitteroptions").show();
@@ -2061,6 +2232,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"))
{
$("#telegramoptions").show();
@@ -2237,26 +2429,30 @@
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();
}
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();
}
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();
}
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"))
{
$("#torrent_blackhole_options,#transmission_options,#utorrent_options").hide();
$("#torrent_blackhole_options,#transmission_options,#utorrent_options,#qbittorrent_options").hide();
$("#deluge_options").show();
}
$('input[type=radio]').change(function(){
if ($("#preferred_bitrate").is(":checked"))
{
@@ -2292,19 +2488,23 @@
}
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"))
{
$("#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"))
{
$("#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"))
{
$("#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() });
}
});
@@ -2401,13 +2601,14 @@
initConfigCheckbox("#use_mininova");
initConfigCheckbox("#use_waffles");
initConfigCheckbox("#use_rutracker");
initConfigCheckbox("#use_whatcd");
initConfigCheckbox("#use_apollo");
initConfigCheckbox("#use_redacted");
initConfigCheckbox("#use_strike");
initConfigCheckbox("#api_enabled");
initConfigCheckbox("#enable_https");
initConfigCheckbox("#customauth");
initConfigCheckbox("#mb_ignore_age_missing");
initConfigCheckbox("#use_tquattrecentonze");
$('#twitterStep1').click(function () {
$.get("/twitterStep1", function (data) {window.open(data); })

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

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

View File

@@ -312,6 +312,7 @@ function doAjaxCall(url,elem,reload,form) {
$.ajax({
url: url,
data: dataString,
type: 'POST',
beforeSend: function(jqXHR, settings) {
// Start loader etc.
feedback.prepend(loader);

View File

@@ -86,8 +86,8 @@ CURRENT_VERSION = None
LATEST_VERSION = None
COMMITS_BEHIND = None
LOSSY_MEDIA_FORMATS = ["mp3", "aac", "ogg", "ape", "m4a", "asf", "wma"]
LOSSLESS_MEDIA_FORMATS = ["flac", "aiff"]
LOSSY_MEDIA_FORMATS = ["mp3", "aac", "ogg", "ape", "m4a", "asf", "wma", "opus"]
LOSSLESS_MEDIA_FORMATS = ["flac", "aiff", "aif"]
MEDIA_FORMATS = LOSSY_MEDIA_FORMATS + LOSSLESS_MEDIA_FORMATS
MIRRORLIST = ["musicbrainz.org", "headphones", "custom"]
@@ -306,9 +306,10 @@ def initialize_scheduler():
minutes=minutes)
# Remove Torrent + data if Post Processed and finished Seeding
minutes = CONFIG.TORRENT_REMOVAL_INTERVAL
schedule_job(torrentfinished.checkTorrentFinished, 'Torrent removal check', hours=0,
minutes=minutes)
if headphones.CONFIG.TORRENT_DOWNLOADER != 0:
minutes = CONFIG.TORRENT_REMOVAL_INTERVAL
schedule_job(torrentfinished.checkTorrentFinished, 'Torrent removal check', hours=0,
minutes=minutes)
# Start scheduler
if start_jobs and len(SCHED.get_jobs()):
@@ -376,7 +377,7 @@ def dbcheck():
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)')
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
# alltracks
c.execute(
@@ -613,6 +614,20 @@ def dbcheck():
except sqlite3.OperationalError:
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()
c.close()

View File

@@ -13,16 +13,164 @@
# You should have received a copy of the GNU General Public License
# 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):
myDB = db.DBConnection()
asin = myDB.action(
'SELECT AlbumASIN from albums WHERE AlbumID=?', [albumid]).fetchone()[0]
if asin:
return 'http://ec1.images-amazon.com/images/P/%s.01.LZZZZZZZ.jpg' % asin
artwork_path = None
artwork = None
# CAA
logger.info("Searching for artwork at CAA")
artwork_path = 'http://coverartarchive.org/release-group/%s/front' % albumid
artwork = getartwork(artwork_path)
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 = 'http://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_path = '{0}?{1}'.format('http://images.weserv.nl/', urlencode({
'url': artwork_path.replace('http://', ''),
'w': maxwidth,
}))
artwork = bytes()
r = request.request_response(artwork_path, timeout=20, stream=True, whitelist_status_code=404)
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):

View File

@@ -16,7 +16,7 @@
import os
import headphones
from headphones import db, helpers, logger, lastfm, request
from headphones import db, helpers, logger, lastfm, request, mb
LASTFM_API_KEY = "690e1ed3bc00bc91804cd8f7fe5ed6d4"
@@ -290,6 +290,14 @@ class Cache(object):
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:
return
@@ -315,18 +323,31 @@ class Cache(object):
else:
dbalbum = myDB.action(
'SELECT ArtistName, AlbumTitle, ReleaseID FROM albums WHERE AlbumID=?',
'SELECT ArtistName, AlbumTitle, ReleaseID, Type FROM albums WHERE AlbumID=?',
[self.id]).fetchone()
if dbalbum['ReleaseID'] != self.id:
data = lastfm.request_lastfm("album.getinfo", mbid=dbalbum['ReleaseID'],
api_key=LASTFM_API_KEY)
if not data:
data = lastfm.request_lastfm("album.getinfo", artist=dbalbum['ArtistName'],
album=dbalbum['AlbumTitle'],
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:
data = lastfm.request_lastfm("album.getinfo", artist=dbalbum['ArtistName'],
album=dbalbum['AlbumTitle'], api_key=LASTFM_API_KEY)
if dbalbum['Type'] != "part of":
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:
return

View File

@@ -34,11 +34,18 @@ _CONFIG_DEFINITIONS = {
'ADD_ALBUM_ART': (int, 'General', 0),
'ADVANCEDENCODER': (str, 'General', ''),
'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
# be - to be considered "downloaded". Percentage from 0-100
'ALBUM_COMPLETION_PCT': (int, 'Advanced', 80),
'API_ENABLED': (int, 'General', 0),
'API_KEY': (str, 'General', ''),
'APOLLO': (int, 'Apollo.rip', 0),
'APOLLO_PASSWORD': (str, 'Apollo.rip', ''),
'APOLLO_RATIO': (str, 'Apollo.rip', ''),
'APOLLO_USERNAME': (str, 'Apollo.rip', ''),
'APOLLO_URL': (str, 'Apollo.rip', 'https://apollo.rip'),
'AUTOWANT_ALL': (int, 'General', 0),
'AUTOWANT_MANUALLY_ADDED': (int, 'General', 1),
'AUTOWANT_UPCOMING': (int, 'General', 1),
@@ -138,12 +145,17 @@ _CONFIG_DEFINITIONS = {
'IGNORED_FILES': (list, 'Advanced', []), # path
'INCLUDE_EXTRAS': (int, 'General', 0),
'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'),
'KAT': (int, 'Kat', 0),
'KAT_PROXY_URL': (str, 'Kat', ''),
'KAT_RATIO': (str, 'Kat', ''),
'KEEP_NFO': (int, 'General', 0),
'KEEP_TORRENT_FILES': (int, 'General', 0),
'KEEP_TORRENT_FILES_DIR': (path, 'General', ''),
'LASTFM_USERNAME': (str, 'General', ''),
'LAUNCH_BROWSER': (int, 'General', 1),
'LIBRARYSCAN': (int, 'General', 1),
@@ -227,6 +239,10 @@ _CONFIG_DEFINITIONS = {
'PUSHOVER_KEYS': (str, 'Pushover', ''),
'PUSHOVER_ONSNATCH': (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_UNPROCESSED': (bool_int, 'General', 1),
'RENAME_FROZEN': (bool_int, 'General', 1),
@@ -237,6 +253,7 @@ _CONFIG_DEFINITIONS = {
'RUTRACKER_PASSWORD': (str, 'Rutracker', ''),
'RUTRACKER_RATIO': (str, 'Rutracker', ''),
'RUTRACKER_USER': (str, 'Rutracker', ''),
'RUTRACKER_COOKIE': (str, 'Rutracker', ''),
'SAB_APIKEY': (str, 'SABnzbd', ''),
'SAB_CATEGORY': (str, 'SABnzbd', ''),
'SAB_HOST': (str, 'SABnzbd', ''),
@@ -244,6 +261,11 @@ _CONFIG_DEFINITIONS = {
'SAB_USERNAME': (str, 'SABnzbd', ''),
'SAMPLINGFREQUENCY': (int, 'General', 44100),
'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', ''),
'SONGKICK_APIKEY': (str, 'Songkick', 'nd1We7dFW2RqxPw8'),
'SONGKICK_ENABLED': (int, 'Songkick', 1),
@@ -275,6 +297,9 @@ _CONFIG_DEFINITIONS = {
'TWITTER_PASSWORD': (str, 'Twitter', ''),
'TWITTER_PREFIX': (str, 'Twitter', 'Headphones'),
'TWITTER_USERNAME': (str, 'Twitter', ''),
'TQUATTRECENTONZE': (int, 'tquattrecentonze', 0),
'TQUATTRECENTONZE_PASSWORD': (str, 'tquattrecentonze', ''),
'TQUATTRECENTONZE_USER': (str, 'tquattrecentonze', ''),
'UPDATE_DB_INTERVAL': (int, 'General', 24),
'USENET_RETENTION': (int, 'General', '1500'),
'UTORRENT_HOST': (str, 'uTorrent', ''),
@@ -287,10 +312,10 @@ _CONFIG_DEFINITIONS = {
'WAFFLES_PASSKEY': (str, 'Waffles', ''),
'WAFFLES_RATIO': (str, 'Waffles', ''),
'WAFFLES_UID': (str, 'Waffles', ''),
'WHATCD': (int, 'What.cd', 0),
'WHATCD_PASSWORD': (str, 'What.cd', ''),
'WHATCD_RATIO': (str, 'What.cd', ''),
'WHATCD_USERNAME': (str, 'What.cd', ''),
'REDACTED': (int, 'Redacted', 0),
'REDACTED_USERNAME': (str, 'Redacted', ''),
'REDACTED_PASSWORD': (str, 'Redacted', ''),
'REDACTED_RATIO': (str, 'Redacted', ''),
'XBMC_ENABLED': (int, 'XBMC', 0),
'XBMC_HOST': (str, 'XBMC', ''),
'XBMC_NOTIFY': (int, 'XBMC', 0),

36
headphones/crier.py Normal file
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().iteritems():
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))

View File

@@ -1,3 +1,5 @@
# -*- coding: utf-8 -*-
# This file is part of Headphones.
#
# Headphones is free software: you can redistribute it and/or modify
@@ -49,6 +51,7 @@ delugeweb_auth = {}
delugeweb_url = ''
deluge_verify_cert = False
scrub_logs = True
headers = {'Accept': 'application/json', 'Content-Type': 'application/json'}
def _scrubber(text):
@@ -80,7 +83,7 @@ def addTorrent(link, data=None, name=None):
result = {}
retid = False
url_what = ['https://what.cd/', 'http://what.cd/']
url_apollo = ['https://apollo.rip/', 'http://apollo.rip/']
url_waffles = ['https://waffles.ch/', 'http://waffles.ch/']
if link.lower().startswith('magnet:'):
@@ -94,7 +97,7 @@ def addTorrent(link, data=None, name=None):
if link.lower().startswith(tuple(url_waffles)):
if 'rss=' not in link:
link = link + '&rss=1'
if link.lower().startswith(tuple(url_what)):
if link.lower().startswith(tuple(url_apollo)):
logger.debug('Deluge: Using different User-Agent for this site')
user_agent = 'Headphones'
# This method will make Deluge download the file
@@ -104,11 +107,11 @@ def addTorrent(link, data=None, name=None):
# return addTorrent(local_torrent_path)
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'
headers = {'User-Agent': user_agent}
get_headers = {'User-Agent': user_agent}
torrentfile = ''
logger.debug('Deluge: Trying to download (GET)')
try:
r = requests.get(link, headers=headers)
r = requests.get(link, headers=get_headers)
if r.status_code == 200:
logger.debug('Deluge: 200 OK')
# .text will ruin the encoding for some torrents
@@ -135,7 +138,10 @@ def addTorrent(link, data=None, name=None):
# remove '.torrent' suffix
if name[-len('.torrent'):] == '.torrent':
name = name[:-len('.torrent')]
logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name, 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',
'name': name,
'content': torrentfile}
@@ -164,7 +170,10 @@ def addTorrent(link, data=None, name=None):
# remove '.torrent' suffix
if name[-len('.torrent'):] == '.torrent':
name = name[:-len('.torrent')]
logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name, 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',
'name': name,
'content': torrentfile}
@@ -199,7 +208,7 @@ def getTorrentFolder(result):
],
"id": 21})
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']
tries = 0
@@ -207,7 +216,7 @@ def getTorrentFolder(result):
tries += 1
time.sleep(5)
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']
post_data = json.dumps({"method": "web.get_torrent_status",
@@ -226,7 +235,7 @@ def getTorrentFolder(result):
"id": 23})
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['name'] = json.loads(response.text)['result']['name']
@@ -242,18 +251,45 @@ def removeTorrent(torrentid, remove_data=False):
_get_auth()
try:
result = False
post_data = json.dumps({"method": "core.remove_torrent",
logger.debug('Deluge: Checking if torrent %s finished seeding' % str(torrentid))
post_data = json.dumps({"method": "web.get_torrent_status",
"params": [
torrentid,
remove_data
],
"id": 25})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert)
result = json.loads(response.text)['result']
[
"name",
"ratio",
"state"
]
],
"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:
logger.error('Deluge: Removing torrent failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines()
@@ -269,7 +305,8 @@ def _get_auth():
delugeweb_host = headphones.CONFIG.DELUGE_HOST
delugeweb_cert = headphones.CONFIG.DELUGE_CERT
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'):
delugeweb_host = 'http://%s' % delugeweb_host
@@ -292,12 +329,12 @@ def _get_auth():
"id": 1})
try:
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:
try:
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,
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
logger.error('Deluge: Switching to HTTPS, but certificate won\'t be verified because NO CERTIFICATE WAS CONFIGURED!')
delugeweb_url = delugeweb_url.replace('http:', 'https:')
@@ -322,7 +359,7 @@ def _get_auth():
"id": 10})
try:
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:
logger.error('Deluge: Authentication failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines()
@@ -339,7 +376,7 @@ def _get_auth():
"id": 11})
try:
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:
logger.error('Deluge: Authentication failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines()
@@ -347,7 +384,8 @@ def _get_auth():
return None
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')
return None
@@ -357,7 +395,7 @@ def _get_auth():
try:
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:
logger.error('Deluge: Authentication failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines()
@@ -370,7 +408,7 @@ def _get_auth():
try:
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:
logger.error('Deluge: Authentication failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines()
@@ -395,7 +433,7 @@ def _add_torrent_magnet(result):
"params": [result['url'], {}],
"id": 2})
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']
logger.debug('Deluge: Response was %s' % str(json.loads(response.text)))
return json.loads(response.text)['result']
@@ -415,7 +453,7 @@ def _add_torrent_url(result):
"params": [result['url'], {}],
"id": 32})
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']
logger.debug('Deluge: Response was %s' % str(json.loads(response.text)))
return json.loads(response.text)['result']
@@ -433,10 +471,11 @@ def _add_torrent_file(result):
try:
# content is torrent file contents that needs to be encoded to base64
post_data = json.dumps({"method": "core.add_torrent_file",
"params": [result['name'] + '.torrent', b64encode(result['content'].encode('utf8')), {}],
"params": [result['name'] + '.torrent',
b64encode(result['content'].encode('utf8')), {}],
"id": 2})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert)
verify=deluge_verify_cert, headers=headers)
result['hash'] = json.loads(response.text)['result']
logger.debug('Deluge: Response was %s' % str(json.loads(response.text)))
return json.loads(response.text)['result']
@@ -444,11 +483,13 @@ def _add_torrent_file(result):
try:
# content is torrent file contents that needs to be encoded to base64
# this time let's try leaving the encoding as is
logger.debug('Deluge: There was a decoding issue, let\'s try again')
post_data = json.dumps({"method": "core.add_torrent_file",
"params": [result['name'] + '.torrent', b64encode(result['content']), {}],
"params": [result['name'].decode('utf8') + '.torrent',
b64encode(result['content']), {}],
"id": 22})
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']
logger.debug('Deluge: Response was %s' % str(json.loads(response.text)))
return json.loads(response.text)['result']
@@ -480,7 +521,7 @@ def setTorrentLabel(result):
"params": [],
"id": 3})
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']
if labels is not None:
@@ -491,7 +532,7 @@ def setTorrentLabel(result):
"params": [label],
"id": 4})
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)
except Exception as e:
logger.error('Deluge: Setting label failed: %s' % str(e))
@@ -503,7 +544,7 @@ def setTorrentLabel(result):
"params": [result['hash'], label],
"id": 5})
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)
else:
logger.debug('Deluge: Label plugin not detected')
@@ -527,12 +568,12 @@ def setSeedRatio(result):
"params": [result['hash'], True],
"id": 5})
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",
"params": [result['hash'], float(ratio)],
"id": 6})
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']
@@ -555,7 +596,7 @@ def setTorrentPath(result):
"params": [result['hash'], True],
"id": 7})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert)
verify=deluge_verify_cert, headers=headers)
if headphones.CONFIG.DELUGE_DONE_DIRECTORY:
move_to = headphones.CONFIG.DELUGE_DONE_DIRECTORY
@@ -569,7 +610,7 @@ def setTorrentPath(result):
"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)
verify=deluge_verify_cert, headers=headers)
return not json.loads(response.text)['error']
@@ -592,7 +633,7 @@ def setTorrentPause(result):
"params": [[result['hash']]],
"id": 9})
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']

View File

@@ -20,6 +20,12 @@ import datetime
import shutil
import time
import sys
import tempfile
import glob
from beets import logging as beetslogging
import six
from contextlib import contextmanager
import fnmatch
import re
@@ -217,8 +223,7 @@ def replace_illegal_chars(string, type="file"):
if type == "file":
string = re.sub('[\?"*:|<>/]', '_', string)
if type == "folder":
string = re.sub('[:\?<>"|]', '_', string)
string = re.sub('[:\?<>"|*]', '_', string)
return string
@@ -251,9 +256,17 @@ _XLATE_SPECIAL = {
# Translation table.
# Cover additional special characters processing normalization.
u"'": '', # replace apostrophe with nothing
u"": '', # replace musicbrainz style apostrophe with nothing
u'&': ' and ', # expand & to ' and '
}
_XLATE_MUSICBRAINZ = {
# Translation table for Musicbrainz.
u"": '...', # HORIZONTAL ELLIPSIS (U+2026)
u"": "'", # APOSTROPHE (U+0027)
u"": "-", # EN DASH (U+2013)
}
def _translate(s, dictionary):
# type: (basestring,Mapping[basestring,basestring])->basestring
@@ -319,9 +332,27 @@ def clean_name(s):
# 6. trim
u = u.strip()
# 7. lowercase
u = u.lower()
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, unicode):
u = unicode(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):
title = re.sub('[\.\-\/\_]', ' ', title).lower()
@@ -620,26 +651,68 @@ def get_downloaded_track_list(albumpath):
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
new_folder = os.path.join(albumpath,
'headphones-modified'.encode(headphones.SYS_ENCODING, 'replace'))
logger.info(
"Copying files to 'headphones-modified' subfolder to preserve downloaded files for seeding")
# Create temp dir
if headphones.CONFIG.KEEP_TORRENT_FILES_DIR:
tempdir = headphones.CONFIG.KEEP_TORRENT_FILES_DIR
else:
tempdir = tempfile.gettempdir()
logger.info("Preparing to copy to a temporary directory for post processing: " + albumpath.decode(
headphones.SYS_ENCODING, 'replace'))
try:
shutil.copytree(albumpath, new_folder)
return new_folder
file_name = os.path.basename(os.path.normpath(albumpath))
if not single:
prefix = "headphones_" + file_name + "_"
else:
prefix = "headphones_" + os.path.splitext(file_name)[0] + "_"
new_folder = tempfile.mkdtemp(prefix=prefix, dir=tempdir)
except Exception as e:
logger.warn("Cannot copy/move files to temp folder: " +
new_folder.decode(headphones.SYS_ENCODING, 'replace') +
". Not continuing. Error: " + str(e))
logger.error("Cannot create temp directory: " + tempdir.decode(
headphones.SYS_ENCODING, 'replace') + ". Error: " + str(e))
return None
# 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.glob(workdir + '*/')) >= 3:
logger.error(
"Looks like a temp directory has previously been created for this albumpath, not continuing " + workdir.decode(
headphones.SYS_ENCODING, 'replace'))
shutil.rmtree(new_folder)
return None
except Exception as e:
logger.warn("Cannot determine if already copied/processed, will copy anyway: Warning: " + str(e))
# Copy to temp dir
try:
subdir = os.path.join(new_folder, "headphones")
logger.info("Copying files to " + subdir.decode(headphones.SYS_ENCODING, 'replace'))
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("Cannot copy/move files to temp directory: " + new_folder.decode(headphones.SYS_ENCODING,
'replace') + ". Not continuing. Error: " + str(
e))
shutil.rmtree(new_folder)
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.
"""
@@ -661,6 +734,15 @@ def cue_split(albumpath):
# Split cue
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
logger.info("Attempting to split audio files by cue")
@@ -671,12 +753,12 @@ def cue_split(albumpath):
except Exception as e:
os.chdir(cwd)
logger.warn("Cue not split: " + str(e))
return False
return None
os.chdir(cwd)
return True
return albumpath
return False
return None
def extract_logline(s):
@@ -897,3 +979,24 @@ def create_https_certificates(ssl_cert, ssl_key):
return False
return True
class BeetsLogCapture(beetslogging.Handler):
def __init__(self):
beetslogging.Handler.__init__(self)
self.messages = []
def emit(self, record):
self.messages.append(six.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)

View File

@@ -55,7 +55,7 @@ def request_lastfm(method, **kwargs):
return
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 data
@@ -149,7 +149,10 @@ def getTagTopArtists(tag, limit=50):
logger.debug("Fetched %d artists from Last.FM", len(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):
artistlist.append(artist_mbid)

View File

@@ -91,10 +91,6 @@ def findArtist(name, limit=1):
artistlist = []
artistResults = None
chars = set('!?*-')
if any((c in chars) for c in name):
name = '"' + name + '"'
criteria = {'artist': name.lower()}
with mb_lock:
@@ -156,16 +152,13 @@ def findRelease(name, limit=1, artist=None):
if not artist and ':' in name:
name, artist = name.rsplit(":", 1)
chars = set('!?*-')
if any((c in chars) for c in name):
name = '"' + name + '"'
if artist and any((c in chars) for c in artist):
artist = '"' + artist + '"'
criteria = {'release': name.lower()}
if artist:
criteria['artist'] = artist.lower()
with mb_lock:
try:
releaseResults = musicbrainzngs.search_releases(query=name, limit=limit, artist=artist)[
'release-list']
releaseResults = musicbrainzngs.search_releases(limit=limit, **criteria)['release-list']
except musicbrainzngs.WebServiceError as e: # need to update exceptions
logger.warn('Attempt to query MusicBrainz for "%s" failed: %s' % (name, str(e)))
mb_lock.snooze(5)
@@ -234,10 +227,6 @@ def findSeries(name, limit=1):
serieslist = []
seriesResults = None
chars = set('!?*-')
if any((c in chars) for c in name):
name = '"' + name + '"'
criteria = {'series': name.lower()}
with mb_lock:
@@ -759,19 +748,12 @@ def findArtistbyAlbum(name):
def findAlbumID(artist=None, album=None):
results = None
chars = set('!?*-')
try:
if album and artist:
if any((c in chars) for c in album):
album = '"' + album + '"'
if any((c in chars) for c in artist):
artist = '"' + artist + '"'
criteria = {'release': album.lower()}
criteria['artist'] = artist.lower()
else:
if any((c in chars) for c in album):
album = '"' + album + '"'
criteria = {'release': album.lower()}
with mb_lock:
results = musicbrainzngs.search_release_groups(limit=1, **criteria).get(
@@ -788,3 +770,26 @@ def findAlbumID(artist=None, album=None):
return False
rgid = unicode(results[0]['id'])
return rgid
def getArtistForReleaseGroup(rgid):
"""
Returns artist name for a release group
Used for series where we store the series instead of the artist
"""
releaseGroup = None
try:
with mb_lock:
releaseGroup = musicbrainzngs.get_release_group_by_id(
rgid, ["artists"])
releaseGroup = releaseGroup['release-group']
except musicbrainzngs.WebServiceError as e:
logger.warn(
'Attempt to retrieve information from MusicBrainz for release group "%s" failed (%s)' % (
rgid, str(e)))
mb_lock.snooze(5)
if not releaseGroup:
return False
else:
return releaseGroup['artist-credit'][0]['artist']['name']

View File

@@ -27,8 +27,9 @@ def update(artistid, artist_name, release_groups):
# cut down on api calls. If it's ineffective then we'll switch to search
replacements = {" & ": " ", ".": ""}
mc_artist_name = helpers.clean_musicbrainz_name(artist_name, return_as_string=False)
mc_artist_name = mc_artist_name.replace("'", " ")
mc_artist_name = helpers.replace_all(artist_name.lower(), replacements)
mc_artist_name = mc_artist_name.replace(" ", "-")
headers = {

View File

@@ -13,7 +13,7 @@
# You should have received a copy of the GNU General Public License
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
from urllib import urlencode
from urllib import urlencode, quote_plus
import urllib
import subprocess
import json
@@ -148,7 +148,9 @@ class PROWL(object):
http_handler.request("POST",
"/publicapi/add",
headers={'Content-type': "application/x-www-form-urlencoded"},
headers={
'Content-type':
"application/x-www-form-urlencoded"},
body=urlencode(data))
response = http_handler.getresponse()
request_status = response.status
@@ -203,28 +205,34 @@ class XBMC(object):
url = host + '/xbmcCmds/xbmcHttp/?' + url_command
if self.password:
return request.request_content(url, auth=(self.username, self.password))
return request.request_content(url,
auth=(self.username, self.password))
else:
return request.request_content(url)
def _sendjson(self, host, method, params={}):
data = [{'id': 0, 'jsonrpc': '2.0', 'method': method, 'params': params}]
data = [
{'id': 0, 'jsonrpc': '2.0', 'method': method, 'params': params}]
headers = {'Content-Type': 'application/json'}
url = host + '/jsonrpc'
if self.password:
response = request.request_json(url, method="post", data=json.dumps(data),
headers=headers, auth=(self.username, self.password))
response = request.request_json(
url, method="post",
data=json.dumps(data),
headers=headers, auth=(
self.username, self.password))
else:
response = request.request_json(url, method="post", data=json.dumps(data),
response = request.request_json(url, method="post",
data=json.dumps(data),
headers=headers)
if response:
return response[0]['result']
def update(self):
# From what I read you can't update the music library on a per directory or per path basis
# so need to update the whole thing
# From what I read you can't update the music library on a per
# directory or per path basis so need to update the whole thing
hosts = [x.strip() for x in self.hosts.split(',')]
@@ -247,18 +255,23 @@ class XBMC(object):
logger.info('Sending notification command to XMBC @ ' + host)
try:
version = self._sendjson(host, 'Application.GetProperties',
{'properties': ['version']})['version']['major']
{'properties': ['version']})[
'version']['major']
if version < 12: # Eden
notification = header + "," + message + "," + time + "," + albumartpath
notification = header + "," + message + "," + time + \
"," + albumartpath
notifycommand = {'command': 'ExecBuiltIn',
'parameter': 'Notification(' + notification + ')'}
'parameter': 'Notification(' +
notification + ')'}
request = self._sendhttp(host, notifycommand)
else: # Frodo
params = {'title': header, 'message': message, 'displaytime': int(time),
params = {'title': header, 'message': message,
'displaytime': int(time),
'image': albumartpath}
request = self._sendjson(host, 'GUI.ShowNotification', params)
request = self._sendjson(host, 'GUI.ShowNotification',
params)
if not request:
raise Exception
@@ -323,22 +336,28 @@ class Plex(object):
url = host + '/xbmcCmds/xbmcHttp/?' + command
if self.password:
response = request.request_response(url, auth=(self.username, self.password))
response = request.request_response(url, auth=(
self.username, self.password))
else:
response = request.request_response(url)
return response
def _sendjson(self, host, method, params={}):
data = [{'id': 0, 'jsonrpc': '2.0', 'method': method, 'params': params}]
data = [
{'id': 0, 'jsonrpc': '2.0', 'method': method, 'params': params}]
headers = {'Content-Type': 'application/json'}
url = host + '/jsonrpc'
if self.password:
response = request.request_json(url, method="post", data=json.dumps(data),
headers=headers, auth=(self.username, self.password))
response = request.request_json(
url, method="post",
data=json.dumps(data),
headers=headers, auth=(
self.username, self.password))
else:
response = request.request_json(url, method="post", data=json.dumps(data),
response = request.request_json(url, method="post",
data=json.dumps(data),
headers=headers)
if response:
@@ -346,13 +365,14 @@ class Plex(object):
def update(self):
# From what I read you can't update the music library on a per directory or per path basis
# so need to update the whole thing
# From what I read you can't update the music library on a per
# directory or per path basis so need to update the whole thing
hosts = [x.strip() for x in self.server_hosts.split(',')]
for host in hosts:
logger.info('Sending library update command to Plex Media Server@ ' + host)
logger.info(
'Sending library update command to Plex Media Server@ ' + host)
url = "%s/library/sections" % host
if self.token:
params = {'X-Plex-Token': self.token}
@@ -369,7 +389,8 @@ class Plex(object):
for s in sections:
if s.getAttribute('type') == "artist":
url = "%s/library/sections/%s/refresh" % (host, s.getAttribute('key'))
url = "%s/library/sections/%s/refresh" % (
host, s.getAttribute('key'))
request.request_response(url, params=params)
def notify(self, artist, album, albumartpath):
@@ -381,27 +402,35 @@ class Plex(object):
time = "3000" # in ms
for host in hosts:
logger.info('Sending notification command to Plex client @ ' + host)
logger.info(
'Sending notification command to Plex client @ ' + host)
try:
version = self._sendjson(host, 'Application.GetProperties',
{'properties': ['version']})['version']['major']
{'properties': ['version']})[
'version']['major']
if version < 12: # Eden
notification = header + "," + message + "," + time + "," + albumartpath
notification = header + "," + message + "," + time + \
"," + albumartpath
notifycommand = {'command': 'ExecBuiltIn',
'parameter': 'Notification(' + notification + ')'}
'parameter': 'Notification(' +
notification + ')'}
request = self._sendhttp(host, notifycommand)
else: # Frodo
params = {'title': header, 'message': message, 'displaytime': int(time),
params = {'title': header, 'message': message,
'displaytime': int(time),
'image': albumartpath}
request = self._sendjson(host, 'GUI.ShowNotification', params)
request = self._sendjson(host, 'GUI.ShowNotification',
params)
if not request:
raise Exception
except Exception:
logger.error('Error sending notification request to Plex client @ ' + host)
logger.error(
'Error sending notification request to Plex client @ ' +
host)
class NMA(object):
@@ -419,7 +448,8 @@ class NMA(object):
message = "Headphones has snatched: " + snatched
else:
event = artist + ' - ' + album + ' complete!'
message = "Headphones has downloaded and postprocessed: " + artist + ' [' + album + ']'
message = "Headphones has downloaded and postprocessed: " + \
artist + ' [' + album + ']'
logger.debug(u"NMA event: " + event)
logger.debug(u"NMA message: " + message)
@@ -433,7 +463,8 @@ class NMA(object):
if len(keys) > 1:
batch = True
response = p.push(title, event, message, priority=nma_priority, batch_mode=batch)
response = p.push(title, event, message, priority=nma_priority,
batch_mode=batch)
if not response[api][u'code'] == u'200':
logger.error(u'Could not send notification to NotifyMyAndroid')
@@ -461,9 +492,11 @@ class PUSHBULLET(object):
data['device_iden'] = self.deviceid
headers = {'Content-type': "application/json",
'Authorization': 'Bearer ' + headphones.CONFIG.PUSHBULLET_APIKEY}
'Authorization': 'Bearer ' +
headphones.CONFIG.PUSHBULLET_APIKEY}
response = request.request_json(url, method="post", headers=headers, data=json.dumps(data))
response = request.request_json(url, method="post", headers=headers,
data=json.dumps(data))
if response:
logger.info(u"PushBullet notifications sent.")
@@ -492,7 +525,9 @@ class PUSHALOT(object):
http_handler.request("POST",
"/api/sendmessage",
headers={'Content-type': "application/x-www-form-urlencoded"},
headers={
'Content-type':
"application/x-www-form-urlencoded"},
body=urlencode(data))
response = http_handler.getresponse()
request_status = response.status
@@ -512,6 +547,49 @@ class PUSHALOT(object):
return False
class JOIN(object):
def __init__(self):
self.enabled = headphones.CONFIG.JOIN_ENABLED
self.apikey = headphones.CONFIG.JOIN_APIKEY
self.deviceid = headphones.CONFIG.JOIN_DEVICEID
self.url = 'https://joinjoaomgcd.appspot.com/_ah/' \
'api/messaging/v1/sendPush?apikey={apikey}' \
'&title={title}&text={text}' \
'&icon={icon}'
def notify(self, message, event):
if not headphones.CONFIG.JOIN_ENABLED or \
not headphones.CONFIG.JOIN_APIKEY:
return
icon = "https://cdn.rawgit.com/Headphones/" \
"headphones/develop/data/images/headphoneslogo.png"
if not self.deviceid:
self.deviceid = "group.all"
l = [x.strip() for x in self.deviceid.split(',')]
if len(l) > 1:
self.url += '&deviceIds={deviceid}'
else:
self.url += '&deviceId={deviceid}'
response = urllib2.urlopen(self.url.format(apikey=self.apikey,
title=quote_plus(event),
text=quote_plus(
message.encode(
"utf-8")),
icon=icon,
deviceid=self.deviceid))
if response:
logger.info(u"Join notifications sent.")
return True
else:
logger.error(u"Join notification failed.")
return False
class Synoindex(object):
def __init__(self, util_loc='/usr/syno/bin/synoindex'):
self.util_loc = util_loc
@@ -524,7 +602,8 @@ class Synoindex(object):
if not self.util_exists():
logger.warn(
"Error sending notification: synoindex utility not found at %s" % self.util_loc)
"Error sending notification: synoindex utility "
"not found at %s" % self.util_loc)
return
if os.path.isfile(path):
@@ -533,16 +612,19 @@ class Synoindex(object):
cmd_arg = '-A'
else:
logger.warn(
"Error sending notification: Path passed to synoindex was not a file or folder.")
"Error sending notification: Path passed to synoindex "
"was not a file or folder.")
return
cmd = [self.util_loc, cmd_arg, path]
logger.info("Calling synoindex command: %s" % str(cmd))
try:
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
p = subprocess.Popen(cmd, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=headphones.PROG_DIR)
out, error = p.communicate()
# synoindex never returns any codes other than '0', highly irritating
# synoindex never returns any codes other than '0',
# highly irritating
except OSError, e:
logger.warn("Error sending notification: %s" % str(e))
@@ -580,7 +662,8 @@ class PUSHOVER(object):
headers = {'Content-type': "application/x-www-form-urlencoded"}
response = request.request_response(url, method="POST", headers=headers, data=data)
response = request.request_response(url, method="POST",
headers=headers, data=data)
if response:
logger.info(u"Pushover notifications sent.")
@@ -614,20 +697,25 @@ class TwitterNotifier(object):
def notify_snatch(self, title):
if headphones.CONFIG.TWITTER_ONSNATCH:
self._notifyTwitter(
common.notifyStrings[common.NOTIFY_SNATCH] + ': ' + title + ' at ' + helpers.now())
common.notifyStrings[
common.NOTIFY_SNATCH] + ': ' + title + ' at ' +
helpers.now())
def notify_download(self, title):
if headphones.CONFIG.TWITTER_ENABLED:
self._notifyTwitter(common.notifyStrings[
common.NOTIFY_DOWNLOAD] + ': ' + title + ' at ' + helpers.now())
common.NOTIFY_DOWNLOAD] + ': ' +
title + ' at ' + helpers.now())
def test_notify(self):
return self._notifyTwitter(
"This is a test notification from Headphones at " + helpers.now(), force=True)
"This is a test notification from Headphones at " + helpers.now(),
force=True)
def _get_authorization(self):
oauth_consumer = oauth.Consumer(key=self.consumer_key, secret=self.consumer_secret)
oauth_consumer = oauth.Consumer(key=self.consumer_key,
secret=self.consumer_secret)
oauth_client = oauth.Client(oauth_consumer)
logger.info('Requesting temp token from Twitter')
@@ -635,32 +723,42 @@ class TwitterNotifier(object):
resp, content = oauth_client.request(self.REQUEST_TOKEN_URL, 'GET')
if resp['status'] != '200':
logger.info('Invalid respond from Twitter requesting temp token: %s' % resp['status'])
logger.info(
'Invalid respond from Twitter requesting temp token: %s' %
resp['status'])
else:
request_token = dict(parse_qsl(content))
headphones.CONFIG.TWITTER_USERNAME = request_token['oauth_token']
headphones.CONFIG.TWITTER_PASSWORD = request_token['oauth_token_secret']
headphones.CONFIG.TWITTER_PASSWORD = request_token[
'oauth_token_secret']
return self.AUTHORIZATION_URL + "?oauth_token=" + request_token['oauth_token']
return self.AUTHORIZATION_URL + "?oauth_token=" + request_token[
'oauth_token']
def _get_credentials(self, key):
request_token = {}
request_token['oauth_token'] = headphones.CONFIG.TWITTER_USERNAME
request_token['oauth_token_secret'] = headphones.CONFIG.TWITTER_PASSWORD
request_token[
'oauth_token_secret'] = headphones.CONFIG.TWITTER_PASSWORD
request_token['oauth_callback_confirmed'] = 'true'
token = oauth.Token(request_token['oauth_token'], request_token['oauth_token_secret'])
token = oauth.Token(request_token['oauth_token'],
request_token['oauth_token_secret'])
token.set_verifier(key)
logger.info('Generating and signing request for an access token using key ' + key)
logger.info(
'Generating and signing request for an access token using key ' +
key)
oauth_consumer = oauth.Consumer(key=self.consumer_key, secret=self.consumer_secret)
oauth_consumer = oauth.Consumer(key=self.consumer_key,
secret=self.consumer_secret)
logger.info('oauth_consumer: ' + str(oauth_consumer))
oauth_client = oauth.Client(oauth_consumer, token)
logger.info('oauth_client: ' + str(oauth_client))
resp, content = oauth_client.request(self.ACCESS_TOKEN_URL, method='POST',
resp, content = oauth_client.request(self.ACCESS_TOKEN_URL,
method='POST',
body='oauth_verifier=%s' % key)
logger.info('resp, content: ' + str(resp) + ',' + str(content))
@@ -669,14 +767,18 @@ class TwitterNotifier(object):
logger.info('resp[status] = ' + str(resp['status']))
if resp['status'] != '200':
logger.info('The request for a token with did not succeed: ' + str(resp['status']),
logger.info('The request for a token with did not succeed: ' + str(
resp['status']),
logger.ERROR)
return False
else:
logger.info('Your Twitter Access Token key: %s' % access_token['oauth_token'])
logger.info('Access Token secret: %s' % access_token['oauth_token_secret'])
logger.info('Your Twitter Access Token key: %s' % access_token[
'oauth_token'])
logger.info(
'Access Token secret: %s' % access_token['oauth_token_secret'])
headphones.CONFIG.TWITTER_USERNAME = access_token['oauth_token']
headphones.CONFIG.TWITTER_PASSWORD = access_token['oauth_token_secret']
headphones.CONFIG.TWITTER_PASSWORD = access_token[
'oauth_token_secret']
return True
def _send_tweet(self, message=None):
@@ -688,7 +790,8 @@ class TwitterNotifier(object):
logger.info(u"Sending tweet: " + message)
api = twitter.Api(username, password, access_token_key, access_token_secret)
api = twitter.Api(username, password, access_token_key,
access_token_secret)
try:
api.PostUpdate(message)
@@ -741,7 +844,8 @@ class OSX_NOTIFY(object):
)
NSUserNotification = self.objc.lookUpClass('NSUserNotification')
NSUserNotificationCenter = self.objc.lookUpClass('NSUserNotificationCenter')
NSUserNotificationCenter = self.objc.lookUpClass(
'NSUserNotificationCenter')
NSAutoreleasePool = self.objc.lookUpClass('NSAutoreleasePool')
if not NSUserNotification or not NSUserNotificationCenter:
@@ -756,14 +860,17 @@ class OSX_NOTIFY(object):
if text:
notification.setInformativeText_(text)
if sound:
notification.setSoundName_("NSUserNotificationDefaultSoundName")
notification.setSoundName_(
"NSUserNotificationDefaultSoundName")
if image:
source_img = self.AppKit.NSImage.alloc().initByReferencingFile_(image)
source_img = self.AppKit.NSImage.alloc().\
initByReferencingFile_(image)
notification.setContentImage_(source_img)
# notification.set_identityImage_(source_img)
notification.setHasActionButton_(False)
notification_center = NSUserNotificationCenter.defaultUserNotificationCenter()
notification_center = NSUserNotificationCenter.\
defaultUserNotificationCenter()
notification_center.deliverNotification_(notification)
del pool
@@ -784,13 +891,16 @@ class BOXCAR(object):
def notify(self, title, message, rgid=None):
try:
if rgid:
message += '<br></br><a href="http://musicbrainz.org/release-group/%s">MusicBrainz</a>' % rgid
message += '<br></br><a href="http://musicbrainz.org/' \
'release-group/%s">MusicBrainz</a>' % rgid
data = urllib.urlencode({
'user_credentials': headphones.CONFIG.BOXCAR_TOKEN,
'notification[title]': title.encode('utf-8'),
'notification[long_message]': message.encode('utf-8'),
'notification[sound]': "done"
'notification[sound]': "done",
'notification[icon_url]': "https://raw.githubusercontent.com/rembo10/headphones/master/data/images"
"/headphoneslogo.png"
})
req = urllib2.Request(self.url)
@@ -818,8 +928,9 @@ class SubSonicNotifier(object):
self.host = self.host + "/"
# Invoke request
request.request_response(self.host + "musicFolderSettings.view?scanNow",
auth=(self.username, self.password))
request.request_response(
self.host + "musicFolderSettings.view?scanNow",
auth=(self.username, self.password))
class Email(object):
@@ -827,13 +938,15 @@ class Email(object):
message = MIMEText(message, 'plain', "utf-8")
message['Subject'] = subject
message['From'] = email.utils.formataddr(('Headphones', headphones.CONFIG.EMAIL_FROM))
message['From'] = email.utils.formataddr(
('Headphones', headphones.CONFIG.EMAIL_FROM))
message['To'] = headphones.CONFIG.EMAIL_TO
try:
if headphones.CONFIG.EMAIL_SSL:
mailserver = smtplib.SMTP_SSL(headphones.CONFIG.EMAIL_SMTP_SERVER,
headphones.CONFIG.EMAIL_SMTP_PORT)
mailserver = smtplib.SMTP_SSL(
headphones.CONFIG.EMAIL_SMTP_SERVER,
headphones.CONFIG.EMAIL_SMTP_PORT)
else:
mailserver = smtplib.SMTP(headphones.CONFIG.EMAIL_SMTP_SERVER,
headphones.CONFIG.EMAIL_SMTP_PORT)
@@ -847,7 +960,8 @@ class Email(object):
mailserver.login(headphones.CONFIG.EMAIL_SMTP_USER,
headphones.CONFIG.EMAIL_SMTP_PASSWORD)
mailserver.sendmail(headphones.CONFIG.EMAIL_FROM, headphones.CONFIG.EMAIL_TO,
mailserver.sendmail(headphones.CONFIG.EMAIL_FROM,
headphones.CONFIG.EMAIL_TO,
message.as_string())
mailserver.quit()
return True
@@ -858,7 +972,6 @@ class Email(object):
class TELEGRAM(object):
def notify(self, message, status):
if not headphones.CONFIG.TELEGRAM_ENABLED:
return
@@ -876,15 +989,49 @@ class TELEGRAM(object):
# Send message to user using Telegram's Bot API
try:
response = requests.post(TELEGRAM_API % (token, "sendMessage"), data=payload)
response = requests.post(TELEGRAM_API % (token, "sendMessage"),
data=payload)
except Exception, e:
logger.info(u'Telegram notify failed: ' + str(e))
# Error logging
sent_successfuly = True
if not response.status_code == 200:
logger.info(u'Could not send notification to TelegramBot (token=%s). Response: [%s]', (token, response.text))
logger.info(
u'Could not send notification to TelegramBot '
u'(token=%s). Response: [%s]',
(token, response.text))
sent_successfuly = False
logger.info(u"Telegram notifications sent.")
return sent_successfuly
class SLACK(object):
def notify(self, message, status):
if not headphones.CONFIG.SLACK_ENABLED:
return
import requests
SLACK_URL = headphones.CONFIG.SLACK_URL
channel = headphones.CONFIG.SLACK_CHANNEL
emoji = headphones.CONFIG.SLACK_EMOJI
payload = {'channel': channel, 'text': status + ': ' + message,
'icon_emoji': emoji}
try:
response = requests.post(SLACK_URL, json=payload)
except Exception, e:
logger.info(u'Slack notify failed: ' + str(e))
sent_successfuly = True
if not response.status_code == 200:
logger.info(
u'Could not send notification to Slack. Response: [%s]',
(response.text))
sent_successfuly = False
logger.info(u"Slack notifications sent.")
return sent_successfuly

View File

@@ -17,7 +17,6 @@ import shutil
import uuid
import threading
import itertools
import tempfile
import os
import re
@@ -25,11 +24,12 @@ import beets
import headphones
from beets import autotag
from beets import config as beetsconfig
from beets import logging as beetslogging
from beets.mediafile import MediaFile, FileTypeError, UnreadableFileError
from beetsplug import lyrics as beetslyrics
from headphones import notifiers, utorrent, transmission, deluge
from headphones import notifiers, utorrent, transmission, deluge, qbittorrent
from headphones import db, albumart, librarysync
from headphones import logger, helpers, request, mb, music_encoder
from headphones import logger, helpers, mb, music_encoder
from headphones import metadata
postprocessor_lock = threading.Lock()
@@ -44,6 +44,8 @@ def checkFolder():
for album in snatched:
if album['FolderName']:
folder_name = album['FolderName']
single = False
if album['Kind'] == 'nzb':
download_dir = headphones.CONFIG.DOWNLOAD_DIR
else:
@@ -52,21 +54,32 @@ def checkFolder():
else:
download_dir = headphones.CONFIG.DOWNLOAD_TORRENT_DIR
album_path = os.path.join(download_dir, album['FolderName']).encode(
headphones.SYS_ENCODING, 'replace')
logger.debug("Checking if %s exists" % album_path)
# Get folder from torrent hash
if album['TorrentHash'] and headphones.CONFIG.TORRENT_DOWNLOADER:
torrent_folder_name = None
if headphones.CONFIG.TORRENT_DOWNLOADER == 1:
torrent_folder_name, single = transmission.getFolder(album['TorrentHash'])
elif headphones.CONFIG.TORRENT_DOWNLOADER == 4:
torrent_folder_name, single = qbittorrent.getFolder(album['TorrentHash'])
if torrent_folder_name:
folder_name = torrent_folder_name
if os.path.exists(album_path):
logger.info('Found "' + album['FolderName'] + '" in ' + album[
'Kind'] + ' download folder. Verifying....')
verify(album['AlbumID'], album_path, album['Kind'])
if folder_name:
album_path = os.path.join(download_dir, folder_name).encode(
headphones.SYS_ENCODING, 'replace')
logger.debug("Checking if %s exists" % album_path)
if os.path.exists(album_path):
logger.info('Found "' + folder_name + '" in ' + album[
'Kind'] + ' download folder. Verifying....')
verify(album['AlbumID'], album_path, album['Kind'], single=single)
else:
logger.info("No folder name found for " + album['Title'])
logger.debug("Checking download folder finished.")
def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=False):
def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=False, single=False):
myDB = db.DBConnection()
release = myDB.action('SELECT * from albums WHERE AlbumID=?', [albumid]).fetchone()
tracks = myDB.select('SELECT * from tracks WHERE AlbumID=?', [albumid])
@@ -207,13 +220,38 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
'replace') + " isn't complete yet. Will try again on the next run")
return
# Split cue
# Force single file through
if single and not downloaded_track_list:
downloaded_track_list.append(albumpath)
# Check to see if we're preserving the torrent dir
if (headphones.CONFIG.KEEP_TORRENT_FILES and Kind == "torrent") or headphones.CONFIG.KEEP_ORIGINAL_FOLDER:
keep_original_folder = True
# Split cue before metadata check
if headphones.CONFIG.CUE_SPLIT and downloaded_cuecount and downloaded_cuecount >= len(
downloaded_track_list):
if headphones.CONFIG.KEEP_TORRENT_FILES and Kind == "torrent":
albumpath = helpers.preserve_torrent_directory(albumpath)
if albumpath and helpers.cue_split(albumpath):
new_folder = None
new_albumpath = albumpath
if keep_original_folder:
temp_path = helpers.preserve_torrent_directory(new_albumpath, forced)
if not temp_path:
markAsUnprocessed(albumid, new_albumpath, keep_original_folder)
return
else:
new_albumpath = temp_path
new_folder = os.path.split(new_albumpath)[0]
Kind = "cue_split"
cuepath = helpers.cue_split(new_albumpath)
if not cuepath:
if new_folder:
shutil.rmtree(new_folder)
markAsUnprocessed(albumid, albumpath, keep_original_folder)
return
else:
albumpath = cuepath
downloaded_track_list = helpers.get_downloaded_track_list(albumpath)
keep_original_folder = False
# test #1: metadata - usually works
logger.debug('Verifying metadata...')
@@ -243,7 +281,7 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
if metaartist == dbartist and metaalbum == dbalbum:
doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, Kind,
keep_original_folder)
keep_original_folder, forced, single)
return
# test #2: filenames
@@ -262,7 +300,7 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
if dbtrack in filetrack:
doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, Kind,
keep_original_folder)
keep_original_folder, forced, single)
return
# test #3: number of songs and duration
@@ -295,46 +333,49 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
delta = abs(downloaded_track_duration - db_track_duration)
if delta < 240:
doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, Kind,
keep_original_folder)
keep_original_folder, forced, single)
return
logger.warn(u'Could not identify album: %s. It may not be the intended album.',
albumpath.decode(headphones.SYS_ENCODING, 'replace'))
markAsUnprocessed(albumid, albumpath, keep_original_folder)
def markAsUnprocessed(albumid, albumpath, keep_original_folder=False):
myDB = db.DBConnection()
myDB.action(
'UPDATE snatched SET status = "Unprocessed" WHERE status NOT LIKE "Seed%" and AlbumID=?',
[albumid])
'UPDATE snatched SET status = "Unprocessed" WHERE status NOT LIKE "Seed%" and AlbumID=?', [albumid])
processed = re.search(r' \(Unprocessed\)(?:\[\d+\])?', albumpath)
if not processed:
if headphones.CONFIG.RENAME_UNPROCESSED:
if headphones.CONFIG.RENAME_UNPROCESSED and not keep_original_folder:
renameUnprocessedFolder(albumpath, tag="Unprocessed")
else:
logger.warn(u"Won't rename %s to mark as 'Unprocessed', because it is disabled.",
logger.warn(u"Won't rename %s to mark as 'Unprocessed', because it is disabled or folder is being kept.",
albumpath.decode(headphones.SYS_ENCODING, 'replace'))
def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, Kind=None,
keep_original_folder=False):
keep_original_folder=False, forced=False, single=False):
logger.info('Starting post-processing for: %s - %s' % (release['ArtistName'], release['AlbumTitle']))
new_folder = None
# Check to see if we're preserving the torrent dir
if (headphones.CONFIG.KEEP_TORRENT_FILES and Kind == "torrent" and 'headphones-modified' not in albumpath) or headphones.CONFIG.KEEP_ORIGINAL_FOLDER or keep_original_folder:
new_folder = tempfile.mkdtemp(prefix="headphones_")
subdir = os.path.join(new_folder, "headphones")
logger.info("Copying files to " + subdir.decode(headphones.SYS_ENCODING, 'replace') + " subfolder to preserve downloaded files for seeding")
try:
shutil.copytree(albumpath, subdir)
# Update the album path with the new location
albumpath = subdir
except Exception as e:
logger.warn("Cannot copy/move files to temp folder: " + new_folder.decode(headphones.SYS_ENCODING, 'replace') + ". Not continuing. Error: " + str(e))
shutil.rmtree(new_folder)
# Preserve the torrent dir
if keep_original_folder or single:
temp_path = helpers.preserve_torrent_directory(albumpath, forced, single)
if not temp_path:
markAsUnprocessed(albumid, albumpath, keep_original_folder)
return
else:
albumpath = temp_path
new_folder = os.path.split(albumpath)[0]
elif Kind == "cue_split":
new_folder = os.path.split(albumpath)[0]
# Need to update the downloaded track list with the new location.
# Could probably just throw in the "headphones-modified" folder,
# but this is good to make sure we're not counting files that may have failed to move
# Need to update the downloaded track list with the new location.
# Could probably just throw in the "headphones-modified" folder,
# but this is good to make sure we're not counting files that may have failed to move
if new_folder:
downloaded_track_list = []
for r, d, f in os.walk(albumpath):
for files in f:
if any(files.lower().endswith('.' + x.lower()) for x in headphones.MEDIA_FORMATS):
@@ -352,7 +393,8 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
downloaded_track.decode(headphones.SYS_ENCODING, "replace"))
return
except IOError:
logger.error("Unable to find media file: %s. Not continuing.")
logger.error("Unable to find media file: %s. Not continuing.", downloaded_track.decode(
headphones.SYS_ENCODING, "replace"))
if new_folder:
shutil.rmtree(new_folder)
return
@@ -387,21 +429,13 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
shutil.rmtree(new_folder)
return
# get artwork and path
album_art_path = None
artwork = None
album_art_path = albumart.getAlbumArt(albumid)
if headphones.CONFIG.EMBED_ALBUM_ART or headphones.CONFIG.ADD_ALBUM_ART:
if album_art_path:
artwork = request.request_content(album_art_path)
else:
artwork = None
if not album_art_path or not artwork or len(artwork) < 100:
logger.info("No suitable album art found from Amazon. Checking Last.FM....")
artwork = albumart.getCachedArt(albumid)
if not artwork or len(artwork) < 100:
artwork = False
logger.info("No suitable album art found from Last.FM. Not adding album art")
if headphones.CONFIG.EMBED_ALBUM_ART or headphones.CONFIG.ADD_ALBUM_ART or \
(headphones.CONFIG.PLEX_ENABLED and headphones.CONFIG.PLEX_NOTIFY) or \
(headphones.CONFIG.XBMC_ENABLED and headphones.CONFIG.XBMC_NOTIFY):
album_art_path, artwork = albumart.getAlbumArt(albumid)
if headphones.CONFIG.EMBED_ALBUM_ART and artwork:
embedAlbumArt(artwork, downloaded_track_list)
@@ -446,12 +480,12 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
[albumid])
# Check if torrent has finished seeding
if headphones.CONFIG.TORRENT_DOWNLOADER == 1 or headphones.CONFIG.TORRENT_DOWNLOADER == 2:
if headphones.CONFIG.TORRENT_DOWNLOADER != 0:
seed_snatched = myDB.action(
'SELECT * from snatched WHERE Status="Seed_Snatched" and AlbumID=?',
[albumid]).fetchone()
if seed_snatched:
hash = seed_snatched['FolderName']
hash = seed_snatched['TorrentHash']
torrent_removed = False
logger.info(u'%s - %s. Checking if torrent has finished seeding and can be removed' % (
release['ArtistName'], release['AlbumTitle']))
@@ -459,8 +493,10 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
torrent_removed = transmission.removeTorrent(hash, True)
elif headphones.CONFIG.TORRENT_DOWNLOADER == 3: # Deluge
torrent_removed = deluge.removeTorrent(hash, True)
else:
elif headphones.CONFIG.TORRENT_DOWNLOADER == 2:
torrent_removed = utorrent.removeTorrent(hash, True)
else:
torrent_removed = qbittorrent.removeTorrent(hash, True)
# Torrent removed, delete the snatched record, else update Status for scheduled job to check
if torrent_removed:
@@ -538,6 +574,11 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
pushbullet = notifiers.PUSHBULLET()
pushbullet.notify(pushmessage, statusmessage)
if headphones.CONFIG.JOIN_ENABLED:
logger.info(u"Join request")
join = notifiers.JOIN()
join.notify(pushmessage, statusmessage)
if headphones.CONFIG.TELEGRAM_ENABLED:
logger.info(u"Telegram request")
telegram = notifiers.TELEGRAM()
@@ -918,12 +959,29 @@ def correctMetadata(albumid, release, downloaded_track_list):
if not items:
continue
search_ids = []
logger.debug('Getting recommendation from beets. Artist: %s. Album: %s. Tracks: %s', release['ArtistName'],
release['AlbumTitle'], len(items))
# Try with specific release, e.g. alternate release selected from albumPage
if release['ReleaseID'] != release['AlbumID']:
logger.debug('trying beets with specific Release ID: %s', release['ReleaseID'])
search_ids = [release['ReleaseID']]
try:
cur_artist, cur_album, candidates, rec = autotag.tag_album(items,
search_artist=helpers.latinToAscii(
release['ArtistName']),
search_album=helpers.latinToAscii(
release['AlbumTitle']))
beetslog = beetslogging.getLogger('beets')
beetslog.set_global_level(beetslogging.DEBUG) if headphones.VERBOSE else beetslog.set_global_level(
beetslogging.CRITICAL)
with helpers.capture_beets_log() as logs:
cur_artist, cur_album, prop = autotag.tag_album(items,
search_artist=release['ArtistName'],
search_album=release['AlbumTitle'],
search_ids=search_ids)
candidates = prop.candidates
rec = prop.recommendation
for log in logs:
logger.debug('Beets: %s', log)
beetslog.set_global_level(beetslogging.NOTSET)
except Exception as e:
logger.error('Error getting recommendation: %s. Not writing metadata', e)
return False
@@ -1186,7 +1244,7 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
'Found a match in the database: %s. Verifying to make sure it is the correct album',
snatched['Title'])
verify(snatched['AlbumID'], folder, snatched['Kind'],
keep_original_folder=keep_original_folder)
forced=True, keep_original_folder=keep_original_folder)
continue
# Attempt 2: strip release group id from filename
@@ -1213,7 +1271,8 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
else:
logger.info(
'Found a (possibly) valid Musicbrainz release group id in album folder name.')
verify(rgid, folder, forced=True)
verify(rgid, folder, forced=True,
keep_original_folder=keep_original_folder)
continue
# Attempt 3a: parse the folder name into a valid format
@@ -1232,7 +1291,7 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
logger.info(
'Found a match in the database: %s - %s. Verifying to make sure it is the correct album',
release['ArtistName'], release['AlbumTitle'])
verify(release['AlbumID'], folder, keep_original_folder=keep_original_folder)
verify(release['AlbumID'], folder, forced=True, keep_original_folder=keep_original_folder)
continue
else:
logger.info('Querying MusicBrainz for the release group id for: %s - %s', name,
@@ -1244,7 +1303,7 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
rgid = None
if rgid:
verify(rgid, folder, keep_original_folder=keep_original_folder)
verify(rgid, folder, forced=True, keep_original_folder=keep_original_folder)
continue
else:
logger.info('No match found on MusicBrainz for: %s - %s', name, album)
@@ -1257,12 +1316,23 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
except Exception:
name = album = None
# Check if there's a cue to split
if headphones.CONFIG.CUE_SPLIT and not name and not album and helpers.cue_split(folder):
try:
name, album, year = helpers.extract_metadata(folder)
except Exception:
name = album = None
# Not found from meta data, check if there's a cue to split and try meta data again
kind = None
if headphones.CONFIG.CUE_SPLIT and not name and not album:
cue_folder = helpers.cue_split(folder, keep_original_folder=keep_original_folder)
if cue_folder:
try:
name, album, year = helpers.extract_metadata(cue_folder)
except Exception:
name = album = None
if name:
folder = cue_folder
if keep_original_folder:
keep_original_folder = False
kind = "cue_split"
elif folder != cue_folder:
cue_folder = os.path.split(cue_folder)[0]
shutil.rmtree(cue_folder)
if name and album:
release = myDB.action(
@@ -1272,7 +1342,7 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
logger.info(
'Found a match in the database: %s - %s. Verifying to make sure it is the correct album',
release['ArtistName'], release['AlbumTitle'])
verify(release['AlbumID'], folder, keep_original_folder=keep_original_folder)
verify(release['AlbumID'], folder, Kind=kind, forced=True, keep_original_folder=keep_original_folder)
continue
else:
logger.info('Querying MusicBrainz for the release group id for: %s - %s', name,
@@ -1284,7 +1354,7 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
rgid = None
if rgid:
verify(rgid, folder, keep_original_folder=keep_original_folder)
verify(rgid, folder, Kind=kind, forced=True, keep_original_folder=keep_original_folder)
continue
else:
logger.info('No match found on MusicBrainz for: %s - %s', name, album)
@@ -1301,7 +1371,7 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
logger.info(
'Found a match in the database: %s - %s. Verifying to make sure it is the correct album',
release['ArtistName'], release['AlbumTitle'])
verify(release['AlbumID'], folder, keep_original_folder=keep_original_folder)
verify(release['AlbumID'], folder, forced=True, keep_original_folder=keep_original_folder)
continue
else:
logger.info('Querying MusicBrainz for the release group id for: %s',
@@ -1313,7 +1383,7 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
rgid = None
if rgid:
verify(rgid, folder, keep_original_folder=keep_original_folder)
verify(rgid, folder, forced=True, keep_original_folder=keep_original_folder)
continue
else:
logger.info('No match found on MusicBrainz for: %s - %s', name, album)

294
headphones/qbittorrent.py Normal file
View File

@@ -0,0 +1,294 @@
# 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 urllib
import urllib2
import cookielib
import json
import time
import mimetypes
import random
import string
import os
import headphones
from headphones import logger
from collections import namedtuple
class qbittorrentclient(object):
TOKEN_REGEX = "<div id='token' style='display:none;'>([^<>]+)</div>"
UTSetting = namedtuple("UTSetting", ["name", "int", "str", "access"])
def __init__(self, base_url=None, username=None, password=None,):
host = headphones.CONFIG.QBITTORRENT_HOST
if not host.startswith('http'):
host = 'http://' + host
if host.endswith('/'):
host = host[:-1]
if host.endswith('/gui'):
host = host[:-4]
self.base_url = host
self.username = headphones.CONFIG.QBITTORRENT_USERNAME
self.password = headphones.CONFIG.QBITTORRENT_PASSWORD
self.cookiejar = cookielib.CookieJar()
self.opener = self._make_opener()
self._get_sid(self.base_url, self.username, self.password)
def _make_opener(self):
# create opener with cookie handler to carry QBitTorrent SID cookie
cookie_handler = urllib2.HTTPCookieProcessor(self.cookiejar)
handlers = [cookie_handler]
return urllib2.build_opener(*handlers)
def _get_sid(self, base_url, username, password):
# login so we can capture SID cookie
login_data = urllib.urlencode({'username': username, 'password': password})
try:
self.opener.open(base_url + '/login', login_data)
except urllib2.URLError as err:
logger.debug('Error getting SID. qBittorrent responded with error: ' + str(err.reason))
return
for cookie in self.cookiejar:
logger.debug('login cookie: ' + cookie.name + ', value: ' + cookie.value)
return
def _command(self, command, args=None, content_type=None, files=None):
logger.debug('QBittorrent WebAPI Command: %s' % command)
url = self.base_url + '/' + command
data = None
headers = dict()
if content_type == 'multipart/form-data':
data, headers = encode_multipart(args, files)
else:
if args:
data = urllib.urlencode(args)
if content_type:
headers['Content-Type'] = content_type
logger.debug('%s' % json.dumps(headers, indent=4))
logger.debug('%s' % data)
request = urllib2.Request(url, data, headers)
try:
response = self.opener.open(request)
info = response.info()
if info:
if info.getheader('content-type'):
if info.getheader('content-type') == 'application/json':
resp = ''
for line in response:
resp = resp + line
logger.debug('response code: %s' % str(response.code))
logger.debug('response: %s' % resp)
return response.code, json.loads(resp)
logger.debug('response code: %s' % str(response.code))
return response.code, None
except urllib2.URLError as err:
logger.debug('Failed URL: %s' % url)
logger.debug('QBitTorrent webUI raised the following error: %s' % str(err))
return None, None
def _get_list(self, **args):
return self._command('query/torrents', args)
def _get_settings(self):
status, value = self._command('query/preferences')
logger.debug('get_settings() returned %d items' % len(value))
return value
def get_savepath(self, hash):
logger.debug('qb.get_savepath(%s)' % hash)
status, torrentList = self._get_list()
for torrent in torrentList:
if torrent['hash']:
if torrent['hash'].upper() == hash.upper():
return torrent['save_path']
return None
def start(self, hash):
logger.debug('qb.start(%s)' % hash)
args = {'hash': hash}
return self._command('command/resume', args, 'application/x-www-form-urlencoded')
def pause(self, hash):
logger.debug('qb.pause(%s)' % hash)
args = {'hash': hash}
return self._command('command/pause', args, 'application/x-www-form-urlencoded')
def getfiles(self, hash):
logger.debug('qb.getfiles(%s)' % hash)
return self._command('query/propertiesFiles/' + hash)
def getprops(self, hash):
logger.debug('qb.getprops(%s)' % hash)
return self._command('query/propertiesGeneral/' + hash)
def setprio(self, hash, priority):
logger.debug('qb.setprio(%s,%d)' % (hash, priority))
args = {'hash': hash, 'priority': priority}
return self._command('command/setFilePrio', args, 'application/x-www-form-urlencoded')
def remove(self, hash, remove_data=False):
logger.debug('qb.remove(%s,%s)' % (hash, remove_data))
args = {'hashes': hash}
if remove_data:
command = 'command/deletePerm'
else:
command = 'command/delete'
return self._command(command, args, 'application/x-www-form-urlencoded')
def removeTorrent(hash, remove_data=False):
logger.debug('removeTorrent(%s,%s)' % (hash, remove_data))
qbclient = qbittorrentclient()
status, torrentList = qbclient._get_list()
for torrent in torrentList:
if torrent['hash'].upper() == hash.upper():
if torrent['state'] == 'uploading' or torrent['state'] == 'stalledUP':
logger.info('%s has finished seeding, removing torrent and data' % torrent['name'])
qbclient.remove(hash, remove_data)
return True
else:
logger.info('%s has not finished seeding yet, torrent will not be removed, will try again on next run' % torrent['name'])
return False
return False
def addTorrent(link):
logger.debug('addTorrent(%s)' % link)
qbclient = qbittorrentclient()
args = {'urls': link, 'savepath': headphones.CONFIG.DOWNLOAD_TORRENT_DIR}
if headphones.CONFIG.QBITTORRENT_LABEL:
args['category'] = headphones.CONFIG.QBITTORRENT_LABEL
return qbclient._command('command/download', args, 'multipart/form-data')
def addFile(data):
logger.debug('addFile(data)')
qbclient = qbittorrentclient()
files = {'torrents': {'filename': '', 'content': data}}
return qbclient._command('command/upload', filelist=files)
def getName(hash):
logger.debug('getName(%s)' % hash)
qbclient = qbittorrentclient()
tries = 1
while tries <= 6:
time.sleep(10)
status, torrentlist = qbclient._get_list()
for torrent in torrentlist:
if torrent['hash'].lower() == hash.lower():
return torrent['name']
tries += 1
return None
def getFolder(hash):
logger.debug('getFolder(%s)' % hash)
torrent_folder = None
single_file = False
qbclient = qbittorrentclient()
try:
status, torrent_files = qbclient.getfiles(hash.lower())
if torrent_files:
if len(torrent_files) == 1:
torrent_folder = torrent_files[0]['name']
single_file = True
else:
torrent_folder = os.path.split(torrent_files[0]['name'])[0]
single_file = False
except:
torrent_folder = None
single_file = False
return torrent_folder, single_file
_BOUNDARY_CHARS = string.digits + string.ascii_letters
# Taken from http://code.activestate.com/recipes/578668-encode-multipart-form-data-for-uploading-files-via/
# "MIT License" which is compatible with GPL
def encode_multipart(args, files, boundary=None):
logger.debug('encode_multipart()')
def escape_quote(s):
return s.replace('"', '\\"')
if boundary is None:
boundary = ''.join(random.choice(_BOUNDARY_CHARS) for i in range(30))
lines = []
if args:
for name, value in args.items():
lines.extend((
'--{0}'.format(boundary),
'Content-Disposition: form-data; name="{0}"'.format(escape_quote(name)),
'',
str(value),
))
logger.debug(''.join(lines))
if files:
for name, value in files.items():
filename = value['filename']
if 'mimetype' in value:
mimetype = value['mimetype']
else:
mimetype = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
lines.extend((
'--{0}'.format(boundary),
'Content-Disposition: form-data; name="{0}"; filename="{1}"'.format(
escape_quote(name), escape_quote(filename)),
'Content-Type: {0}'.format(mimetype),
'',
value['content'],
))
lines.extend((
'--{0}--'.format(boundary),
'',
))
body = '\r\n'.join(lines)
headers = {
'Content-Type': 'multipart/form-data; boundary={0}'.format(boundary),
'Content-Length': str(len(body)),
}
return (body, headers)

View File

@@ -6,6 +6,7 @@ from urlparse import urlparse
import re
import requests as requests
# from requests.auth import HTTPDigestAuth
from bs4 import BeautifulSoup
import headphones
@@ -24,7 +25,7 @@ class Rutracker(object):
return self.loggedin
def still_logged_in(self, html):
if not html or "action=\"http://login.rutracker.org/forum/login.php\">" in html:
if not html or "action=\"http://rutracker.org/forum/login.php\">" in html:
return False
else:
return True
@@ -34,7 +35,7 @@ class Rutracker(object):
Logs in user
"""
loginpage = 'http://login.rutracker.org/forum/login.php'
loginpage = 'http://rutracker.org/forum/login.php'
post_params = {
'login_username': headphones.CONFIG.RUTRACKER_USER,
'login_password': headphones.CONFIG.RUTRACKER_PASSWORD,
@@ -44,28 +45,34 @@ class Rutracker(object):
logger.info("Attempting to log in to rutracker...")
try:
r = self.session.post(loginpage, data=post_params, timeout=self.timeout)
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False)
# try again
if 'bb_data' not in r.cookies.keys():
if not self.has_bb_session_cookie(r):
time.sleep(10)
r = self.session.post(loginpage, data=post_params, timeout=self.timeout)
if r.status_code != 200:
logger.error("rutracker login returned status code %s" % r.status_code)
self.loggedin = False
else:
if 'bb_data' in r.cookies.keys():
self.loggedin = True
logger.info("Successfully logged in to rutracker")
if headphones.CONFIG.RUTRACKER_COOKIE:
logger.info("Attempting to log in using predefined cookie...")
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False, cookies={'bb_session': headphones.CONFIG.RUTRACKER_COOKIE})
else:
logger.error(
"Could not login to rutracker, credentials maybe incorrect, site is down or too many attempts. Try again later")
self.loggedin = False
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False)
if self.has_bb_session_cookie(r):
self.loggedin = True
logger.info("Successfully logged in to rutracker")
else:
logger.error(
"Could not login to rutracker, credentials maybe incorrect, site is down or too many attempts. Try again later")
self.loggedin = False
return self.loggedin
except Exception as e:
logger.error("Unknown error logging in to rutracker: %s" % e)
self.loggedin = False
return self.loggedin
def has_bb_session_cookie(self, response):
if 'bb_session' in response.cookies.keys():
return True
# Rutracker randomly send a 302 redirect code, cookie may be present in response history
return next(('bb_session' in r.cookies.keys() for r in response.history), False)
def searchurl(self, artist, album, year, format):
"""
Return the search url
@@ -91,7 +98,11 @@ class Rutracker(object):
# sort by size, descending.
sort = '&o=7&s=2'
searchurl = "%s?nm=%s%s%s" % (self.search_referer, urllib.quote(searchterm), format, sort)
try:
searchurl = "%s?nm=%s%s%s" % (self.search_referer, urllib.quote(searchterm), format, sort)
except:
searchterm = searchterm.encode('utf-8')
searchurl = "%s?nm=%s%s%s" % (self.search_referer, urllib.quote(searchterm), format, sort)
logger.info("Searching rutracker using term: %s", searchterm)
return searchurl
@@ -167,7 +178,7 @@ class Rutracker(object):
return the .torrent data
"""
torrent_id = dict([part.split('=') for part in urlparse(url)[4].split('&')])['t']
downloadurl = 'http://dl.rutracker.org/forum/dl.php?t=' + torrent_id
downloadurl = 'http://rutracker.org/forum/dl.php?t=' + torrent_id
cookie = {'bb_dl': torrent_id}
try:
headers = {'Referer': url}
@@ -214,3 +225,37 @@ class Rutracker(object):
self.session.post(url, params={'action': 'add-file'}, files=files)
except Exception as e:
logger.exception('Error adding file to utorrent %s', e)
# TODO get this working in qbittorrent.py
def qbittorrent_add_file(self, data):
host = headphones.CONFIG.QBITTORRENT_HOST
if not host.startswith('http'):
host = 'http://' + host
if host.endswith('/'):
host = host[:-1]
if host.endswith('/gui'):
host = host[:-4]
base_url = host
# self.session.auth = HTTPDigestAuth(headphones.CONFIG.QBITTORRENT_USERNAME, headphones.CONFIG.QBITTORRENT_PASSWORD)
url = base_url + '/login'
try:
self.session.post(url, data={'username': headphones.CONFIG.QBITTORRENT_USERNAME,
'password': headphones.CONFIG.QBITTORRENT_PASSWORD})
except Exception as e:
logger.exception('Error adding file to qbittorrent %s', e)
return
url = base_url + '/command/upload'
args = {'savepath': headphones.CONFIG.DOWNLOAD_TORRENT_DIR}
if headphones.CONFIG.QBITTORRENT_LABEL:
args['category'] = headphones.CONFIG.QBITTORRENT_LABEL
torrent_files = {'torrents': data}
try:
self.session.post(url, data=args, files=torrent_files)
except Exception as e:
logger.exception('Error adding file to qbittorrent %s', e)

View File

@@ -14,6 +14,7 @@
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
# NZBGet support added by CurlyMo <curlymoo1@gmail.com> as a part of XBian - XBMC on the Raspberry Pi
# t411 support added by a1ex, @likeitneverwentaway on github for maintenance
from base64 import b16encode, b32decode
from hashlib import sha1
@@ -24,29 +25,33 @@ import datetime
import subprocess
import unicodedata
import urlparse
from json import loads
import os
import re
from pygazelle import api as gazelleapi
from pygazelle import encoding as gazelleencoding
from pygazelle import format as gazelleformat
from pygazelle import release_type as gazellerelease_type
import headphones
from headphones.common import USER_AGENT
from headphones import logger, db, helpers, classes, sab, nzbget, request
from headphones import utorrent, transmission, notifiers, rutracker, deluge
from headphones import utorrent, transmission, notifiers, rutracker, deluge, qbittorrent
from bencode import bencode, bdecode
# Magnet to torrent services, for Black hole. Stolen from CouchPotato.
TORRENT_TO_MAGNET_SERVICES = [
# 'https://zoink.it/torrent/%s.torrent',
# 'http://torrage.com/torrent/%s.torrent',
'https://torcache.net/torrent/%s.torrent',
# 'https://torcache.net/torrent/%s.torrent',
'http://itorrents.org/torrent/%s.torrent',
]
# Persistent What.cd API object
gazelle = None
# Persistent Apollo.rip API object
apolloobj = None
ruobj = None
# Persistent RED API object
redobj = None
def fix_url(s, charset="utf-8"):
@@ -159,8 +164,10 @@ def get_seed_ratio(provider):
seed_ratio = headphones.CONFIG.RUTRACKER_RATIO
elif provider == 'Kick Ass Torrents':
seed_ratio = headphones.CONFIG.KAT_RATIO
elif provider == 'What.cd':
seed_ratio = headphones.CONFIG.WHATCD_RATIO
elif provider == 'Apollo.rip':
seed_ratio = headphones.CONFIG.APOLLO_RATIO
elif provider == 'Redacted':
seed_ratio = headphones.CONFIG.REDACTED_RATIO
elif provider == 'The Pirate Bay':
seed_ratio = headphones.CONFIG.PIRATEBAY_RATIO
elif provider == 'Old Pirate Bay':
@@ -199,14 +206,13 @@ def searchforalbum(albumid=None, new=False, losslessOnly=False,
continue
if headphones.CONFIG.WAIT_UNTIL_RELEASE_DATE and album['ReleaseDate']:
try:
release_date = datetime.datetime.strptime(album['ReleaseDate'], "%Y-%m-%d")
except:
logger.warn(
"No valid date for: %s. Skipping automatic search" % album['AlbumTitle'])
release_date = strptime_musicbrainz(album['ReleaseDate'])
if not release_date:
logger.warn("No valid date for: %s. Skipping automatic search" %
album['AlbumTitle'])
continue
if release_date > datetime.datetime.today():
elif release_date > datetime.datetime.today():
logger.info("Skipping: %s. Waiting for release date of: %s" % (
album['AlbumTitle'], album['ReleaseDate']))
continue
@@ -235,6 +241,26 @@ def searchforalbum(albumid=None, new=False, losslessOnly=False,
logger.info('Search for wanted albums complete')
def strptime_musicbrainz(date_str):
"""
Release date as returned by Musicbrainz may contain the full date (Year-Month-Day)
but it may as well be just Year-Month or even just the year.
Args:
date_str: the date as a string (ex: "2003-05-01", "2003-03", "2003")
Returns:
The more accurate datetime object we can create or None if parse failed
"""
acceptable_formats = ('%Y-%m-%d', '%Y-%m', '%Y')
for date_format in acceptable_formats:
try:
return datetime.datetime.strptime(date_str, date_format)
except:
pass
return None
def do_sorted_search(album, new, losslessOnly, choose_specific_download=False):
NZB_PROVIDERS = (headphones.CONFIG.HEADPHONES_INDEXER or
headphones.CONFIG.NEWZNAB or
@@ -252,8 +278,10 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False):
headphones.CONFIG.MININOVA or
headphones.CONFIG.WAFFLES or
headphones.CONFIG.RUTRACKER or
headphones.CONFIG.WHATCD or
headphones.CONFIG.STRIKE)
headphones.CONFIG.APOLLO or
headphones.CONFIG.REDACTED or
headphones.CONFIG.STRIKE or
headphones.CONFIG.TQUATTRECENTONZE)
results = []
myDB = db.DBConnection()
@@ -714,7 +742,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
}
data = request.request_json(
url='http://api.omgwtfnzbs.org/json/',
url='http://api.omgwtfnzbs.me/json/',
params=params, headers=headers
)
@@ -813,6 +841,19 @@ def send_to_downloader(data, bestqual, album):
torrent_name = helpers.replace_illegal_chars(folder_name) + '.torrent'
download_path = os.path.join(headphones.CONFIG.TORRENTBLACKHOLE_DIR, torrent_name)
# Blackhole for t411
if bestqual[2].lower().startswith("http://api.t411"):
if headphones.CONFIG.MAGNET_LINKS == 2:
try:
url = bestqual[2].split('TOKEN')[0]
token = bestqual[2].split('TOKEN')[1]
data = request.request_content(url, headers={'Authorization': token})
torrent_to_file(download_path, data)
logger.info('Successfully converted magnet to torrent file')
except Exception as e:
logger.error("Error converting magnet link: %s" % str(e))
return
if bestqual[2].lower().startswith("magnet:"):
if headphones.CONFIG.MAGNET_LINKS == 1:
try:
@@ -838,12 +879,11 @@ def send_to_downloader(data, bestqual, album):
services = TORRENT_TO_MAGNET_SERVICES[:]
random.shuffle(services)
headers = {'User-Agent': USER_AGENT}
headers['Referer'] = 'https://torcache.net/'
for service in services:
data = request.request_content(service % torrent_hash, headers=headers)
if data and "torcache" in data:
if data:
if not torrent_to_file(download_path, data):
return
# Extract folder name from torrent
@@ -889,11 +929,11 @@ def send_to_downloader(data, bestqual, album):
logger.error("Error sending torrent to Transmission. Are you sure it's running?")
return
folder_name = transmission.getTorrentFolder(torrentid)
folder_name = transmission.getName(torrentid)
if folder_name:
logger.info('Torrent folder name: %s' % folder_name)
logger.info('Torrent name: %s' % folder_name)
else:
logger.error('Torrent folder name could not be determined')
logger.error('Torrent name could not be determined')
return
# Set Seed Ratio
@@ -943,7 +983,7 @@ def send_to_downloader(data, bestqual, album):
except Exception as e:
logger.error('Error sending torrent to Deluge: %s' % str(e))
else: # if headphones.CONFIG.TORRENT_DOWNLOADER == 2:
elif headphones.CONFIG.TORRENT_DOWNLOADER == 2:
logger.info("Sending torrent to uTorrent")
# Add torrent
@@ -974,19 +1014,42 @@ def send_to_downloader(data, bestqual, album):
seed_ratio = get_seed_ratio(bestqual[3])
if seed_ratio is not None:
utorrent.setSeedRatio(torrentid, seed_ratio)
else: # if headphones.CONFIG.TORRENT_DOWNLOADER == 4:
logger.info("Sending torrent to QBiTorrent")
# Add torrent
if bestqual[3] == 'rutracker.org':
ruobj.qbittorrent_add_file(data)
else:
qbittorrent.addTorrent(bestqual[2])
# Get hash
torrentid = calculate_torrent_hash(bestqual[2], data)
torrentid = torrentid.lower()
if not torrentid:
logger.error('Torrent id could not be determined')
return
# Get name
folder_name = qbittorrent.getName(torrentid)
if folder_name:
logger.info('Torrent name: %s' % folder_name)
else:
logger.error('Torrent name could not be determined')
return
myDB = db.DBConnection()
myDB.action('UPDATE albums SET status = "Snatched" WHERE AlbumID=?', [album['AlbumID']])
myDB.action('INSERT INTO snatched VALUES( ?, ?, ?, ?, DATETIME("NOW", "localtime"), ?, ?, ?)',
myDB.action('INSERT INTO snatched VALUES( ?, ?, ?, ?, DATETIME("NOW", "localtime"), ?, ?, ?, ?)',
[album['AlbumID'], bestqual[0], bestqual[1], bestqual[2], "Snatched", folder_name,
kind])
kind, torrentid])
# Store the torrent id so we can check later if it's finished seeding and can be removed
if seed_ratio is not None and seed_ratio != 0 and torrentid:
myDB.action(
'INSERT INTO snatched VALUES( ?, ?, ?, ?, DATETIME("NOW", "localtime"), ?, ?, ?)',
[album['AlbumID'], bestqual[0], bestqual[1], bestqual[2], "Seed_Snatched", torrentid,
kind])
'INSERT INTO snatched VALUES( ?, ?, ?, ?, DATETIME("NOW", "localtime"), ?, ?, ?, ?)',
[album['AlbumID'], bestqual[0], bestqual[1], bestqual[2], "Seed_Snatched", folder_name,
kind, torrentid])
# notify
artist = album[1]
@@ -1014,6 +1077,14 @@ def send_to_downloader(data, bestqual, album):
logger.info(u"Sending PushBullet notification")
pushbullet = notifiers.PUSHBULLET()
pushbullet.notify(name, "Download started")
if headphones.CONFIG.JOIN_ENABLED and headphones.CONFIG.JOIN_ONSNATCH:
logger.info(u"Sending Join notification")
join = notifiers.JOIN()
join.notify(name, "Download started")
if headphones.CONFIG.SLACK_ENABLED and headphones.CONFIG.SLACK_ONSNATCH:
logger.info(u"Sending Slack notification")
slack = notifiers.SLACK()
slack.notify(name, "Download started")
if headphones.CONFIG.TELEGRAM_ENABLED and headphones.CONFIG.TELEGRAM_ONSNATCH:
logger.info(u"Sending Telegram notification")
telegram = notifiers.TELEGRAM()
@@ -1140,7 +1211,8 @@ def verifyresult(title, artistterm, term, lossless):
def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
choose_specific_download=False):
global gazelle # persistent what.cd api object to reduce number of login attempts
global apolloobj # persistent apollo.rip api object to reduce number of login attempts
global redobj # persistent redacted api object to reduce number of login attempts
global ruobj # and rutracker
reldate = album['ReleaseDate']
@@ -1221,9 +1293,9 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
if headphones.CONFIG.PREFERRED_QUALITY == 3 or losslessOnly:
categories = "3040"
elif headphones.CONFIG.PREFERRED_QUALITY == 1 or allow_lossless:
categories = "3040,3010"
categories = "3040,3010,3050"
else:
categories = "3010"
categories = "3010,3050"
if album['Type'] == 'Other':
categories = "3030"
@@ -1233,8 +1305,12 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
provider = torznab_host[0]
# Format Jackett provider
if "api/v2.0/indexers" in torznab_host[0]:
provider = "Jackett_" + provider.split("/indexers/", 1)[1].split('/', 1)[0]
# Request results
logger.info('Parsing results from %s using search term: %s' % (torznab_host[0], term))
logger.info('Parsing results from %s using search term: %s' % (provider, term))
headers = {'User-Agent': USER_AGENT}
params = {
@@ -1246,20 +1322,35 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
}
data = request.request_feed(
url=torznab_host[0] + '/api?',
url=torznab_host[0],
params=params, headers=headers
)
# Process feed
if data:
if not len(data.entries):
logger.info(u"No results found from %s for %s", torznab_host[0], term)
logger.info(u"No results found from %s for %s", provider, term)
else:
for item in data.entries:
try:
url = item.link
title = item.title
size = int(item.links[1]['length'])
# Torrentech hack - size currently not returned, make it up
if 'torrentech' in torznab_host[0]:
if albumlength:
if 'Lossless' in title:
size = albumlength / 1000 * 800 * 128
elif 'MP3' in title:
size = albumlength / 1000 * 320 * 128
else:
size = albumlength / 1000 * 256 * 128
else:
logger.info('Skipping %s, could not determine size' % title)
continue
else:
size = int(item.links[1]['length'])
if all(word.lower() in title.lower() for word in term.split()):
logger.info(
'Found %s. Size: %s' % (title, helpers.bytes_to_mb(size)))
@@ -1279,7 +1370,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
if headphones.CONFIG.KAT_PROXY_URL:
providerurl = fix_url(set_proxy(headphones.CONFIG.KAT_PROXY_URL))
else:
providerurl = fix_url("https://kat.cr")
providerurl = fix_url("https://katcr.co/new/")
# Build URL
providerurl = providerurl + "/usearch/" + ka_term
@@ -1355,8 +1446,9 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
query_items = [usersearchterm]
query_items.extend(['format:(%s)' % format,
'size:[0 TO %d]' % maxsize,
'-seeders:0']) # cut out dead torrents
'size:[0 TO %d]' % maxsize])
# (25/03/2017 Waffles back up after 5 months, all torrents currently have no seeders, remove for now)
# '-seeders:0']) cut out dead torrents
if bitrate:
query_items.append('bitrate:"%s"' % bitrate)
@@ -1432,9 +1524,9 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
if rulist:
resultlist.extend(rulist)
if headphones.CONFIG.WHATCD:
provider = "What.cd"
providerurl = "http://what.cd/"
if headphones.CONFIG.APOLLO:
provider = "Apollo.rip"
providerurl = "http://apollo.rip/"
bitrate = None
bitrate_string = bitrate
@@ -1457,7 +1549,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
bitrate_string = encoding_string
if bitrate_string not in gazelleencoding.ALL_ENCODINGS:
logger.info(
u"Your preferred bitrate is not one of the available What.cd filters, so not using it as a search parameter.")
u"Your preferred bitrate is not one of the available Apollo.rip filters, so not using it as a search parameter.")
maxsize = 10000000000
elif headphones.CONFIG.PREFERRED_QUALITY == 1 or allow_lossless: # Highest quality including lossless
search_formats = [gazelleformat.FLAC, gazelleformat.MP3]
@@ -1466,27 +1558,157 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
search_formats = [gazelleformat.MP3]
maxsize = 300000000
if not gazelle or not gazelle.logged_in():
if not apolloobj or not apolloobj.logged_in():
try:
logger.info(u"Attempting to log in to What.cd...")
gazelle = gazelleapi.GazelleAPI(headphones.CONFIG.WHATCD_USERNAME,
headphones.CONFIG.WHATCD_PASSWORD)
gazelle._login()
logger.info(u"Attempting to log in to Apollo.rip...")
apolloobj = gazelleapi.GazelleAPI(headphones.CONFIG.APOLLO_USERNAME,
headphones.CONFIG.APOLLO_PASSWORD,
headphones.CONFIG.APOLLO_URL)
apolloobj._login()
except Exception as e:
gazelle = None
logger.error(u"What.cd credentials incorrect or site is down. Error: %s %s" % (
apolloobj = None
logger.error(u"Apollo.rip credentials incorrect or site is down. Error: %s %s" % (
e.__class__.__name__, str(e)))
if gazelle and gazelle.logged_in():
if apolloobj and apolloobj.logged_in():
logger.info(u"Searching %s..." % provider)
all_torrents = []
# Specify release types to filter by
if album['Type'] == 'Album':
album_type = [gazellerelease_type.ALBUM]
if album['Type'] == 'Soundtrack':
album_type = [gazellerelease_type.SOUNDTRACK]
if album['Type'] == 'EP':
album_type = [gazellerelease_type.EP]
# No musicbrainz match for this type
# if album['Type'] == 'Anthology':
# album_type = [gazellerelease_type.ANTHOLOGY]
if album['Type'] == 'Compilation':
album_type = [gazellerelease_type.COMPILATION]
if album['Type'] == 'DJ-mix':
album_type = [gazellerelease_type.DJ_MIX]
if album['Type'] == 'Single':
album_type = [gazellerelease_type.SINGLE]
if album['Type'] == 'Live':
album_type = [gazellerelease_type.LIVE_ALBUM]
if album['Type'] == 'Remix':
album_type = [gazellerelease_type.REMIX]
if album['Type'] == 'Bootleg':
album_type = [gazellerelease_type.BOOTLEG]
if album['Type'] == 'Interview':
album_type = [gazellerelease_type.INTERVIEW]
if album['Type'] == 'Mixtape/Street':
album_type = [gazellerelease_type.MIXTAPE]
for search_format in search_formats:
if usersearchterm:
all_torrents.extend(
apolloobj.search_torrents(searchstr=usersearchterm, format=search_format,
encoding=bitrate_string, releasetype=album_type)['results'])
else:
all_torrents.extend(apolloobj.search_torrents(artistname=semi_clean_artist_term,
groupname=semi_clean_album_term,
format=search_format,
encoding=bitrate_string,
releasetype=album_type)['results'])
# filter on format, size, and num seeders
logger.info(u"Filtering torrents by format, maximum size, and minimum seeders...")
match_torrents = [t for t in all_torrents if
t.size <= maxsize and t.seeders >= minimumseeders]
logger.info(
u"Remaining torrents: %s" % ", ".join(repr(torrent) for torrent in match_torrents))
# sort by times d/l'd
if not len(match_torrents):
logger.info(u"No results found from %s for %s after filtering" % (provider, term))
elif len(match_torrents) > 1:
logger.info(u"Found %d matching releases from %s for %s - %s after filtering" %
(len(match_torrents), provider, artistterm, albumterm))
logger.info(
"Sorting torrents by times snatched and preferred bitrate %s..." % bitrate_string)
match_torrents.sort(key=lambda x: int(x.snatched), reverse=True)
if gazelleformat.MP3 in search_formats:
# sort by size after rounding to nearest 10MB...hacky, but will favor highest quality
match_torrents.sort(key=lambda x: int(10 * round(x.size / 1024. / 1024. / 10.)),
reverse=True)
if search_formats and None not in search_formats:
match_torrents.sort(
key=lambda x: int(search_formats.index(x.format))) # prefer lossless
# if bitrate:
# match_torrents.sort(key=lambda x: re.match("mp3", x.getTorrentDetails(), flags=re.I), reverse=True)
# match_torrents.sort(key=lambda x: str(bitrate) in x.getTorrentFolderName(), reverse=True)
logger.info(
u"New order: %s" % ", ".join(repr(torrent) for torrent in match_torrents))
for torrent in match_torrents:
if not torrent.file_path:
torrent.group.update_group_data() # will load the file_path for the individual torrents
resultlist.append((torrent.file_path,
torrent.size,
apolloobj.generate_torrent_link(torrent.id),
provider,
'torrent', True))
# Redacted - Using same logic as What.CD as it's also Gazelle, so should really make this into something reusable
if headphones.CONFIG.REDACTED:
provider = "Redacted"
providerurl = "https://redacted.ch"
bitrate = None
bitrate_string = bitrate
if headphones.CONFIG.PREFERRED_QUALITY == 3 or losslessOnly: # Lossless Only mode
search_formats = [gazelleformat.FLAC]
maxsize = 10000000000
elif headphones.CONFIG.PREFERRED_QUALITY == 2: # Preferred quality mode
search_formats = [None] # should return all
bitrate = headphones.CONFIG.PREFERRED_BITRATE
if bitrate:
if 225 <= int(bitrate) < 256:
bitrate = 'V0'
elif 200 <= int(bitrate) < 225:
bitrate = 'V1'
elif 175 <= int(bitrate) < 200:
bitrate = 'V2'
for encoding_string in gazelleencoding.ALL_ENCODINGS:
if re.search(bitrate, encoding_string, flags=re.I):
bitrate_string = encoding_string
if bitrate_string not in gazelleencoding.ALL_ENCODINGS:
logger.info(
u"Your preferred bitrate is not one of the available RED filters, so not using it as a search parameter.")
maxsize = 10000000000
elif headphones.CONFIG.PREFERRED_QUALITY == 1 or allow_lossless: # Highest quality including lossless
search_formats = [gazelleformat.FLAC, gazelleformat.MP3]
maxsize = 10000000000
else: # Highest quality excluding lossless
search_formats = [gazelleformat.MP3]
maxsize = 300000000
if not redobj or not redobj.logged_in():
try:
logger.info(u"Attempting to log in to Redacted...")
redobj = gazelleapi.GazelleAPI(headphones.CONFIG.REDACTED_USERNAME,
headphones.CONFIG.REDACTED_PASSWORD,
providerurl)
redobj._login()
except Exception as e:
redobj = None
logger.error(u"Redacted credentials incorrect or site is down. Error: %s %s" % (
e.__class__.__name__, str(e)))
if redobj and redobj.logged_in():
logger.info(u"Searching %s..." % provider)
all_torrents = []
for search_format in search_formats:
if usersearchterm:
all_torrents.extend(
gazelle.search_torrents(searchstr=usersearchterm, format=search_format,
redobj.search_torrents(searchstr=usersearchterm, format=search_format,
encoding=bitrate_string)['results'])
else:
all_torrents.extend(gazelle.search_torrents(artistname=semi_clean_artist_term,
all_torrents.extend(redobj.search_torrents(artistname=semi_clean_artist_term,
groupname=semi_clean_album_term,
format=search_format,
encoding=bitrate_string)['results'])
@@ -1526,20 +1748,20 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
torrent.group.update_group_data() # will load the file_path for the individual torrents
resultlist.append((torrent.file_path,
torrent.size,
gazelle.generate_torrent_link(torrent.id),
redobj.generate_torrent_link(torrent.id),
provider,
'torrent', True))
# Pirate Bay
if headphones.CONFIG.PIRATEBAY:
provider = "The Pirate Bay"
tpb_term = term.replace("!", "")
tpb_term = term.replace("!", "").replace("'", " ")
# Use proxy if specified
if headphones.CONFIG.PIRATEBAY_PROXY_URL:
providerurl = fix_url(set_proxy(headphones.CONFIG.PIRATEBAY_PROXY_URL))
else:
providerurl = fix_url("https://thepiratebay.se")
providerurl = fix_url("https://thepiratebay.org")
# Build URL
providerurl = providerurl + "/search/" + tpb_term + "/0/7/" # 7 is sort by seeders
@@ -1763,6 +1985,77 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
resultlist.append((title, size, url, provider, 'torrent', match))
except Exception as e:
logger.exception("Unhandled exception in Mininova Parser")
# t411
if headphones.CONFIG.TQUATTRECENTONZE:
username = headphones.CONFIG.TQUATTRECENTONZE_USER
password = headphones.CONFIG.TQUATTRECENTONZE_PASSWORD
API_URL = "http://api.t411.ai"
AUTH_URL = API_URL + '/auth'
DL_URL = API_URL + '/torrents/download/'
provider = "t411"
t411_term = term.replace(" ", "%20")
SEARCH_URL = API_URL + '/torrents/search/' + t411_term + "?limit=15&cid=395&subcat=623"
headers_login = {'username': username, 'password': password}
# Requesting content
logger.info('Parsing results from t411 using search term: %s' % term)
req = request.request_content(AUTH_URL, method='post', data=headers_login)
if len(req.split('"')) == 9:
token = req.split('"')[7]
headers_auth = {'Authorization': token}
logger.info('t411 - User %s logged in' % username)
else:
logger.info('t411 - Login error : %s' % req.split('"')[3])
# Quality
if headphones.CONFIG.PREFERRED_QUALITY == 3 or losslessOnly:
providerurl = fix_url(SEARCH_URL + "&term[16][]=529&term[16][]=1184")
elif headphones.CONFIG.PREFERRED_QUALITY == 1 or allow_lossless:
providerurl = fix_url(SEARCH_URL + "&term[16][]=685&term[16][]=527&term[16][]=1070&term[16][]=528&term[16][]=1167&term[16][]=1166&term[16][]=530&term[16][]=529&term[16][]=1184&term[16][]=532&term[16][]=533&term[16][]=1085&term[16][]=534&term[16][]=535&term[16][]=1069&term[16][]=537&term[16][]=538")
elif headphones.CONFIG.PREFERRED_QUALITY == 0:
providerurl = fix_url(SEARCH_URL + "&term[16][]=685&term[16][]=527&term[16][]=1070&term[16][]=528&term[16][]=1167&term[16][]=1166&term[16][]=530&term[16][]=532&term[16][]=533&term[16][]=1085&term[16][]=534&term[16][]=535&term[16][]=1069&term[16][]=537&term[16][]=538")
else:
providerurl = fix_url(SEARCH_URL)
# Tracker search
req = request.request_content(providerurl, headers=headers_auth)
req = loads(req)
total = req['total']
# Process feed
if total == '0':
logger.info("No results found from t411 for %s" % term)
else:
logger.info('Found %s results from t411' % total)
torrents = req['torrents']
for torrent in torrents:
try:
title = torrent['name']
if torrent['seeders'] < minimumseeders:
logger.info('Skipping torrent %s : seeders below minimum set' % title)
continue
id = torrent['id']
size = int(torrent['size'])
data = request.request_content(DL_URL + id, headers=headers_auth)
# Blackhole
if headphones.CONFIG.TORRENT_DOWNLOADER == 0 and headphones.CONFIG.MAGNET_LINKS == 2:
url = DL_URL + id + 'TOKEN' + token
resultlist.append((title, size, url, provider, 'torrent', True))
# Build magnet
else:
metadata = bdecode(data)
hashcontents = bencode(metadata['info'])
digest = sha1(hashcontents).hexdigest()
trackers = [metadata["announce"]][0]
url = 'magnet:?xt=urn:btih:%s&tr=%s' % (digest, trackers)
resultlist.append((title, size, url, provider, 'torrent', True))
except Exception as e:
logger.error("Error converting magnet link: %s" % str(e))
return
# attempt to verify that this isn't a substring result
# when looking for "Foo - Foo" we don't want "Foobar"
@@ -1800,7 +2093,9 @@ def preprocess(resultlist):
if result[3] == 'Kick Ass Torrents':
headers['Referer'] = 'https://torcache.net/'
headers['User-Agent'] = USER_AGENT
elif result[3] == 'What.cd':
elif result[3] == 'Apollo.rip':
headers['User-Agent'] = 'Headphones'
elif result[3] == 'Redacted':
headers['User-Agent'] = 'Headphones'
elif result[3] == "The Pirate Bay" or result[3] == "Old Pirate Bay":
headers[

View File

@@ -13,13 +13,10 @@
# You should have received a copy of the GNU General Public License
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
import threading
from headphones import db, utorrent, transmission, logger
from headphones import db, utorrent, transmission, deluge, qbittorrent, logger
import headphones
postprocessor_lock = threading.Lock()
def checkTorrentFinished():
"""
@@ -28,21 +25,25 @@ def checkTorrentFinished():
logger.info("Checking if any torrents have finished seeding and can be removed")
with postprocessor_lock:
myDB = db.DBConnection()
results = myDB.select('SELECT * from snatched WHERE Status="Seed_Processed"')
myDB = db.DBConnection()
results = myDB.select('SELECT * from snatched WHERE Status="Seed_Processed"')
for album in results:
hash = album['FolderName']
albumid = album['AlbumID']
torrent_removed = False
if headphones.CONFIG.TORRENT_DOWNLOADER == 1:
torrent_removed = transmission.removeTorrent(hash, True)
else:
torrent_removed = utorrent.removeTorrent(hash, True)
for album in results:
hash = album['TorrentHash']
albumid = album['AlbumID']
torrent_removed = False
if torrent_removed:
myDB.action('DELETE from snatched WHERE status = "Seed_Processed" and AlbumID=?',
[albumid])
if headphones.CONFIG.TORRENT_DOWNLOADER == 1:
torrent_removed = transmission.removeTorrent(hash, True)
elif headphones.CONFIG.TORRENT_DOWNLOADER == 2:
torrent_removed = utorrent.removeTorrent(hash, True)
elif headphones.CONFIG.TORRENT_DOWNLOADER == 3:
torrent_removed = deluge.removeTorrent(hash, True)
else:
torrent_removed = qbittorrent.removeTorrent(hash, True)
if torrent_removed:
myDB.action('DELETE from snatched WHERE status = "Seed_Processed" and AlbumID=?',
[albumid])
logger.info("Checking finished torrents completed")

View File

@@ -17,6 +17,7 @@ import time
import json
import base64
import urlparse
import os
from headphones import logger, request
import headphones
@@ -33,7 +34,7 @@ _session_id = None
def addTorrent(link, data=None):
method = 'torrent-add'
if link.endswith('.torrent') and not link.startswith('http') or data:
if link.endswith('.torrent') and not link.startswith(('http', 'magnet')) or data:
if data:
metainfo = str(base64.b64encode(data))
else:
@@ -64,7 +65,31 @@ def addTorrent(link, data=None):
return False
def getTorrentFolder(torrentid):
def getFolder(torrentid):
torrent_folder = None
single_file = False
method = 'torrent-get'
arguments = {'ids': torrentid, 'fields': ['files']}
response = torrentAction(method, arguments)
try:
torrent_files = response['arguments']['torrents'][0]['files']
if torrent_files:
if len(torrent_files) == 1:
torrent_folder = torrent_files[0]['name']
single_file = True
else:
torrent_folder = os.path.split(torrent_files[0]['name'])[0]
single_file = False
except:
torrent_folder = None
single_file = False
return torrent_folder, single_file
def getName(torrentid):
method = 'torrent-get'
arguments = {'ids': torrentid, 'fields': ['name', 'percentDone']}

View File

@@ -39,14 +39,15 @@ def runGit(args):
try:
logger.debug('Trying to execute: "' + cmd + '" with shell in ' + headphones.PROG_DIR)
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True,
p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
shell=True,
cwd=headphones.PROG_DIR)
output, err = p.communicate()
output = output.strip()
logger.debug('Git output: ' + output)
except OSError:
logger.debug('Command failed: %s', cmd)
except OSError as e:
logger.debug('Command failed: %s. Error: %s' % (cmd, e))
continue
if 'not found' in output or "not recognized as an internal or external command" in output:

View File

@@ -14,6 +14,7 @@
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
# NZBGet support added by CurlyMo <curlymoo1@gmail.com> as a part of XBian - XBMC on the Raspberry Pi
# t411 support added by a1ex, @likeitneverwentaway on github for maintenance
from operator import itemgetter
import threading
@@ -28,7 +29,7 @@ import urllib2
import os
import re
from headphones import logger, searcher, db, importer, mb, lastfm, librarysync, helpers, notifiers
from headphones import logger, searcher, db, importer, mb, lastfm, librarysync, helpers, notifiers, crier
from headphones.helpers import checked, radio, today, clean_name
from mako.lookup import TemplateLookup
from mako import exceptions
@@ -69,6 +70,11 @@ class WebInterface(object):
artists = myDB.select('SELECT * from artists order by ArtistSortName COLLATE NOCASE')
return serve_template(templatename="index.html", title="Home", artists=artists)
@cherrypy.expose
def threads(self):
crier.cry()
raise cherrypy.HTTPRedirect("home")
@cherrypy.expose
def artistPage(self, ArtistID):
myDB = db.DBConnection()
@@ -246,7 +252,10 @@ class WebInterface(object):
namecheck = myDB.select('SELECT ArtistName from artists where ArtistID=?', [ArtistID])
for name in namecheck:
artistname = name['ArtistName']
logger.info(u"Deleting all traces of artist: " + artistname)
try:
logger.info(u"Deleting all traces of artist: " + artistname)
except TypeError:
logger.info(u"Deleting all traces of artist: null")
myDB.action('DELETE from artists WHERE ArtistID=?', [ArtistID])
from headphones import cache
@@ -339,14 +348,18 @@ class WebInterface(object):
dirs = set(dirs)
for dir in dirs:
artistfolder = os.path.join(dir, folder)
if not os.path.isdir(artistfolder.encode(headphones.SYS_ENCODING)):
logger.debug("Cannot find directory: " + artistfolder)
continue
threading.Thread(target=librarysync.libraryScan,
kwargs={"dir": artistfolder, "artistScan": True, "ArtistID": ArtistID,
"ArtistName": artist_name}).start()
try:
for dir in dirs:
artistfolder = os.path.join(dir, folder)
if not os.path.isdir(artistfolder.encode(headphones.SYS_ENCODING)):
logger.debug("Cannot find directory: " + artistfolder)
continue
threading.Thread(target=librarysync.libraryScan,
kwargs={"dir": artistfolder, "artistScan": True, "ArtistID": ArtistID,
"ArtistName": artist_name}).start()
except Exception as e:
logger.error('Unable to complete the scan: %s' % e)
raise cherrypy.HTTPRedirect("artistPage?ArtistID=%s" % ArtistID)
@cherrypy.expose
@@ -528,7 +541,7 @@ class WebInterface(object):
myDB = db.DBConnection()
upcoming = myDB.select(
"SELECT * from albums WHERE ReleaseDate > date('now') order by ReleaseDate ASC")
wanted = myDB.select("SELECT * from albums WHERE Status='Wanted'")
wanted = myDB.select("SELECT * from albums WHERE Status='Wanted' order by ReleaseDate ASC")
return serve_template(templatename="upcoming.html", title="Upcoming", upcoming=upcoming,
wanted=wanted)
@@ -865,11 +878,19 @@ class WebInterface(object):
def musicScan(self, path, scan=0, redirect=None, autoadd=0, libraryscan=0):
headphones.CONFIG.LIBRARYSCAN = libraryscan
headphones.CONFIG.AUTO_ADD_ARTISTS = autoadd
headphones.CONFIG.MUSIC_DIR = path
headphones.CONFIG.write()
try:
params = {}
headphones.CONFIG.MUSIC_DIR = path
headphones.CONFIG.write()
except Exception as e:
logger.warn("Cannot save scan directory to config: %s", e)
if scan:
params = {"dir": path}
if scan:
try:
threading.Thread(target=librarysync.libraryScan).start()
threading.Thread(target=librarysync.libraryScan, kwargs=params).start()
except Exception as e:
logger.error('Unable to complete the scan: %s' % e)
if redirect:
@@ -1152,6 +1173,10 @@ class WebInterface(object):
"nzbget_password": headphones.CONFIG.NZBGET_PASSWORD,
"nzbget_category": headphones.CONFIG.NZBGET_CATEGORY,
"nzbget_priority": headphones.CONFIG.NZBGET_PRIORITY,
"qbittorrent_host": headphones.CONFIG.QBITTORRENT_HOST,
"qbittorrent_username": headphones.CONFIG.QBITTORRENT_USERNAME,
"qbittorrent_password": headphones.CONFIG.QBITTORRENT_PASSWORD,
"qbittorrent_label": headphones.CONFIG.QBITTORRENT_LABEL,
"transmission_host": headphones.CONFIG.TRANSMISSION_HOST,
"transmission_username": headphones.CONFIG.TRANSMISSION_USERNAME,
"transmission_password": headphones.CONFIG.TRANSMISSION_PASSWORD,
@@ -1172,6 +1197,7 @@ class WebInterface(object):
"torrent_downloader_transmission": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 1),
"torrent_downloader_utorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 2),
"torrent_downloader_deluge": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 3),
"torrent_downloader_qbittorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 4),
"download_dir": headphones.CONFIG.DOWNLOAD_DIR,
"use_blackhole": checked(headphones.CONFIG.BLACKHOLE),
"blackhole_dir": headphones.CONFIG.BLACKHOLE_DIR,
@@ -1219,12 +1245,21 @@ class WebInterface(object):
"rutracker_user": headphones.CONFIG.RUTRACKER_USER,
"rutracker_password": headphones.CONFIG.RUTRACKER_PASSWORD,
"rutracker_ratio": headphones.CONFIG.RUTRACKER_RATIO,
"use_whatcd": checked(headphones.CONFIG.WHATCD),
"whatcd_username": headphones.CONFIG.WHATCD_USERNAME,
"whatcd_password": headphones.CONFIG.WHATCD_PASSWORD,
"whatcd_ratio": headphones.CONFIG.WHATCD_RATIO,
"rutracker_cookie": headphones.CONFIG.RUTRACKER_COOKIE,
"use_apollo": checked(headphones.CONFIG.APOLLO),
"apollo_username": headphones.CONFIG.APOLLO_USERNAME,
"apollo_password": headphones.CONFIG.APOLLO_PASSWORD,
"apollo_ratio": headphones.CONFIG.APOLLO_RATIO,
"apollo_url": headphones.CONFIG.APOLLO_URL,
"use_redacted": checked(headphones.CONFIG.REDACTED),
"redacted_username": headphones.CONFIG.REDACTED_USERNAME,
"redacted_password": headphones.CONFIG.REDACTED_PASSWORD,
"redacted_ratio": headphones.CONFIG.REDACTED_RATIO,
"use_strike": checked(headphones.CONFIG.STRIKE),
"strike_ratio": headphones.CONFIG.STRIKE_RATIO,
"use_tquattrecentonze": checked(headphones.CONFIG.TQUATTRECENTONZE),
"tquattrecentonze_user": headphones.CONFIG.TQUATTRECENTONZE_USER,
"tquattrecentonze_password": headphones.CONFIG.TQUATTRECENTONZE_PASSWORD,
"pref_qual_0": radio(headphones.CONFIG.PREFERRED_QUALITY, 0),
"pref_qual_1": radio(headphones.CONFIG.PREFERRED_QUALITY, 1),
"pref_qual_2": radio(headphones.CONFIG.PREFERRED_QUALITY, 2),
@@ -1248,6 +1283,8 @@ class WebInterface(object):
"keep_nfo": checked(headphones.CONFIG.KEEP_NFO),
"add_album_art": checked(headphones.CONFIG.ADD_ALBUM_ART),
"album_art_format": headphones.CONFIG.ALBUM_ART_FORMAT,
"album_art_min_width": headphones.CONFIG.ALBUM_ART_MIN_WIDTH,
"album_art_max_width": headphones.CONFIG.ALBUM_ART_MAX_WIDTH,
"embed_album_art": checked(headphones.CONFIG.EMBED_ALBUM_ART),
"embed_lyrics": checked(headphones.CONFIG.EMBED_LYRICS),
"replace_existing_folders": checked(headphones.CONFIG.REPLACE_EXISTING_FOLDERS),
@@ -1274,6 +1311,7 @@ class WebInterface(object):
"magnet_links_3": radio(headphones.CONFIG.MAGNET_LINKS, 3),
"log_dir": headphones.CONFIG.LOG_DIR,
"cache_dir": headphones.CONFIG.CACHE_DIR,
"keep_torrent_files_dir": headphones.CONFIG.KEEP_TORRENT_FILES_DIR,
"interface_list": interface_list,
"music_encoder": checked(headphones.CONFIG.MUSIC_ENCODER),
"encoder": headphones.CONFIG.ENCODER,
@@ -1374,7 +1412,16 @@ class WebInterface(object):
"email_ssl": checked(headphones.CONFIG.EMAIL_SSL),
"email_tls": checked(headphones.CONFIG.EMAIL_TLS),
"email_onsnatch": checked(headphones.CONFIG.EMAIL_ONSNATCH),
"idtag": checked(headphones.CONFIG.IDTAG)
"idtag": checked(headphones.CONFIG.IDTAG),
"slack_enabled": checked(headphones.CONFIG.SLACK_ENABLED),
"slack_url": headphones.CONFIG.SLACK_URL,
"slack_channel": headphones.CONFIG.SLACK_CHANNEL,
"slack_emoji": headphones.CONFIG.SLACK_EMOJI,
"slack_onsnatch": checked(headphones.CONFIG.SLACK_ONSNATCH),
"join_enabled": checked(headphones.CONFIG.JOIN_ENABLED),
"join_onsnatch": checked(headphones.CONFIG.JOIN_ONSNATCH),
"join_apikey": headphones.CONFIG.JOIN_APIKEY,
"join_deviceid": headphones.CONFIG.JOIN_DEVICEID
}
for k, v in config.iteritems():
@@ -1421,8 +1468,8 @@ class WebInterface(object):
"use_newznab", "newznab_enabled", "use_torznab", "torznab_enabled",
"use_nzbsorg", "use_omgwtfnzbs", "use_kat", "use_piratebay", "use_oldpiratebay",
"use_mininova", "use_waffles", "use_rutracker",
"use_whatcd", "use_strike", "preferred_bitrate_allow_lossless", "detect_bitrate",
"ignore_clean_releases", "freeze_db", "cue_split", "move_files",
"use_apollo", "use_redacted", "use_strike", "use_tquattrecentonze", "preferred_bitrate_allow_lossless",
"detect_bitrate", "ignore_clean_releases", "freeze_db", "cue_split", "move_files",
"rename_files", "correct_metadata", "cleanup_files", "keep_nfo", "add_album_art",
"embed_album_art", "embed_lyrics",
"replace_existing_folders", "keep_original_folder", "file_underscores",
@@ -1442,7 +1489,8 @@ class WebInterface(object):
"osx_notify_enabled", "osx_notify_onsnatch", "boxcar_enabled", "boxcar_onsnatch",
"songkick_enabled", "songkick_filter_enabled",
"mpc_enabled", "email_enabled", "email_ssl", "email_tls", "email_onsnatch",
"customauth", "idtag", "deluge_paused"
"customauth", "idtag", "deluge_paused",
"join_enabled", "join_onsnatch"
]
for checked_config in checked_configs:
if checked_config not in kwargs:
@@ -1711,6 +1759,12 @@ class WebInterface(object):
telegram = notifiers.TELEGRAM()
telegram.notify("it works!", "lazers pew pew")
@cherrypy.expose
def testJoin(self):
logger.info("Testing Join notifications")
join = notifiers.JOIN()
join.notify("it works!", "Test message")
class Artwork(object):
@cherrypy.expose

View File

@@ -1,7 +1,8 @@
#!/bin/sh
#
# PROVIDE: headphones
# REQUIRE: DAEMON sabnzbd
# REQUIRE: DAEMON
# BEFORE: LOGIN
# KEYWORD: shutdown
#
# Add the following lines to /etc/rc.conf.local or /etc/rc.conf
@@ -15,56 +16,35 @@
# as root.
# headphones_dir: Directory where Headphones lives.
# Default: /usr/local/headphones
# headphones_chdir: Change to this directory before running Headphones.
# Default is same as headphones_dir.
# headphones_pid: The name of the pidfile to create.
# Default is headphones.pid in headphones_dir.
PATH="/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin"
. /etc/rc.subr
name="headphones"
rcvar=${name}_enable
load_rc_config ${name}
: "${headphones_enable:="NO"}"
: "${headphones_user:="_sabnzbd"}"
: "${headphones_dir:="/usr/local/headphones"}"
: "${headphones_chdir:="${headphones_dir}"}"
: "${headphones_pid:="${headphones_dir}/headphones.pid"}"
: "${headphones_conf:="/usr/local/headphones/config.ini"}"
status_cmd="${name}_status"
stop_cmd="${name}_stop"
command="${headphones_dir}/Headphones.py"
command_interpreter="/usr/local/bin/python"
pidfile="/var/run/headphones/headphones.pid"
start_precmd="headphones_start_precmd"
headphones_flags="--daemon --nolaunch --pidfile $pidfile --config $headphones_conf $headphones_flags"
command="/usr/sbin/daemon"
command_args="-f -p ${headphones_pid} python ${headphones_dir}/Headphones.py ${headphones_flags} --quiet --nolaunch"
headphones_start_precmd() {
if [ $($ID -u) != 0 ]; then
err 1 "Must be root."
fi
# Ensure user is root when running this script.
if [ "$(id -u)" != "0" ]; then
echo "Oops, you should be root before running this!"
exit 1
fi
verify_headphones_pid() {
# Make sure the pid corresponds to the Headphones process.
pid=$(cat "${headphones_pid}" 2>/dev/null)
pgrep -F "${headphones_pid}" -q "python ${headphones_dir}/Headphones.py"
return $?
}
# Try to stop Headphones cleanly by calling shutdown over http.
headphones_stop() {
echo "Stopping $name"
verify_headphones_pid
if [ -n "${pid}" ]; then
wait_for_pids "${pid}"
echo "Stopped"
fi
}
headphones_status() {
verify_headphones_pid && echo "$name is running as ${pid}" || echo "$name is not running"
if [ ! -d /var/run/headphones ]; then
install -do $headphones_user /var/run/headphones
fi
}
load_rc_config ${name}
run_rc_command "$1"

View File

@@ -33,6 +33,7 @@
## PYTHON_BIN= #$DAEMON, the location of the python binary, the default is /usr/bin/python
## HP_OPTS= #$EXTRA_DAEMON_OPTS, extra cli option for headphones, i.e. " --config=/home/headphones/config.ini"
## HP_PORT= #$PORT_OPTS, hardcoded port for the webserver, overrides value in config.ini
## HP_HOST= #$HOST_OPTS, host for the webserver, overrides value in config.ini
##
## EXAMPLE if want to run as different user
## add HP_USER=username to /etc/default/headphones
@@ -105,7 +106,12 @@ load_settings() {
PORT_OPTS=" --port=${HP_PORT} "
}
DAEMON_OPTS=" Headphones.py --quiet --daemon --nolaunch --pidfile=${PID_FILE} --datadir=${DATA_DIR} ${PORT_OPTS}${EXTRA_DAEMON_OPTS}"
# Host config
[ -n "$HP_HOST" ] && {
HOST_OPTS=" --host=${HP_HOST} "
}
DAEMON_OPTS=" Headphones.py --quiet --daemon --nolaunch --pidfile=${PID_FILE} --datadir=${DATA_DIR} ${PORT_OPTS} ${HOST_OPTS} ${EXTRA_DAEMON_OPTS}"
SETTINGS_LOADED=TRUE
fi

View File

@@ -1,21 +0,0 @@
The MIT License
Copyright (c) 2010-2014 Adrian Sampson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -1,94 +0,0 @@
.. image:: https://travis-ci.org/sampsyo/beets.svg?branch=master
:target: https://travis-ci.org/sampsyo/beets
.. image:: http://img.shields.io/coveralls/sampsyo/beets.svg
:target: https://coveralls.io/r/sampsyo/beets
.. image:: http://img.shields.io/pypi/v/beets.svg
:target: https://pypi.python.org/pypi/beets
Beets is the media library management system for obsessive-compulsive music
geeks.
The purpose of beets is to get your music collection right once and for all.
It catalogs your collection, automatically improving its metadata as it goes.
It then provides a bouquet of tools for manipulating and accessing your music.
Here's an example of beets' brainy tag corrector doing its thing::
$ beet import ~/music/ladytron
Tagging:
Ladytron - Witching Hour
(Similarity: 98.4%)
* Last One Standing -> The Last One Standing
* Beauty -> Beauty*2
* White Light Generation -> Whitelightgenerator
* All the Way -> All the Way...
Because beets is designed as a library, it can do almost anything you can
imagine for your music collection. Via `plugins`_, beets becomes a panacea:
- Fetch or calculate all the metadata you could possibly need: `album art`_,
`lyrics`_, `genres`_, `tempos`_, `ReplayGain`_ levels, or `acoustic
fingerprints`_.
- Get metadata from `MusicBrainz`_, `Discogs`_, or `Beatport`_. Or guess
metadata using songs' filenames or their acoustic fingerprints.
- `Transcode audio`_ to any format you like.
- Check your library for `duplicate tracks and albums`_ or for `albums that
are missing tracks`_.
- Clean up crufty tags left behind by other, less-awesome tools.
- Embed and extract album art from files' metadata.
- Browse your music library graphically through a Web browser and play it in any
browser that supports `HTML5 Audio`_.
- Analyze music files' metadata from the command line.
- Listen to your library with a music player that speaks the `MPD`_ protocol
and works with a staggering variety of interfaces.
If beets doesn't do what you want yet, `writing your own plugin`_ is
shockingly simple if you know a little Python.
.. _plugins: http://beets.readthedocs.org/page/plugins/
.. _MPD: http://www.musicpd.org/
.. _MusicBrainz music collection: http://musicbrainz.org/doc/Collections/
.. _writing your own plugin:
http://beets.readthedocs.org/page/dev/plugins.html
.. _HTML5 Audio:
http://www.w3.org/TR/html-markup/audio.html
.. _albums that are missing tracks:
http://beets.readthedocs.org/page/plugins/missing.html
.. _duplicate tracks and albums:
http://beets.readthedocs.org/page/plugins/duplicates.html
.. _Transcode audio:
http://beets.readthedocs.org/page/plugins/convert.html
.. _Beatport: http://www.beatport.com/
.. _Discogs: http://www.discogs.com/
.. _acoustic fingerprints:
http://beets.readthedocs.org/page/plugins/chroma.html
.. _ReplayGain: http://beets.readthedocs.org/page/plugins/replaygain.html
.. _tempos: http://beets.readthedocs.org/page/plugins/echonest.html
.. _genres: http://beets.readthedocs.org/page/plugins/lastgenre.html
.. _album art: http://beets.readthedocs.org/page/plugins/fetchart.html
.. _lyrics: http://beets.readthedocs.org/page/plugins/lyrics.html
.. _MusicBrainz: http://musicbrainz.org/
Read More
---------
Learn more about beets at `its Web site`_. Follow `@b33ts`_ on Twitter for
news and updates.
You can install beets by typing ``pip install beets``. Then check out the
`Getting Started`_ guide.
.. _its Web site: http://beets.radbox.org/
.. _Getting Started: http://beets.readthedocs.org/page/guides/main.html
.. _@b33ts: http://twitter.com/b33ts/
Authors
-------
Beets is by `Adrian Sampson`_ with a supporting cast of thousands. For help,
please contact the `mailing list`_.
.. _mailing list: https://groups.google.com/forum/#!forum/beets-users
.. _Adrian Sampson: http://homes.cs.washington.edu/~asampson/

35
lib/beets/__init__.py Normal file → Executable file
View File

@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2014, Adrian Sampson.
# Copyright 2016, Adrian Sampson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
@@ -12,17 +13,33 @@
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
# This particular version has been slightly modified to work with Headphones
# https://github.com/rembo10/headphones
__version__ = '1.3.10-headphones'
__author__ = 'Adrian Sampson <adrian@radbox.org>'
from __future__ import division, absolute_import, print_function
import os
import beets.library
from beets.util import confit
Library = beets.library.Library
# This particular version has been slightly modified to work with Headphones
# https://github.com/rembo10/headphones
__version__ = u'1.4.4-headphones'
__author__ = u'Adrian Sampson <adrian@radbox.org>'
config = confit.LazyConfig(os.path.dirname(__file__), __name__)
class IncludeLazyConfig(confit.LazyConfig):
"""A version of Confit's LazyConfig that also merges in data from
YAML files specified in an `include` setting.
"""
def read(self, user=True, defaults=True):
super(IncludeLazyConfig, self).read(user, defaults)
try:
for view in self['include']:
filename = view.as_filename()
if os.path.isfile(filename):
self.set_file(filename)
except confit.NotFoundError:
pass
# headphones
#config = IncludeLazyConfig('beets', __name__)
config = IncludeLazyConfig(os.path.dirname(__file__), __name__)

26
lib/beets/__main__.py Executable file
View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2017, Adrian Sampson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""The __main__ module lets you run the beets CLI interface by typing
`python -m beets`.
"""
from __future__ import division, absolute_import, print_function
import sys
from .ui import main
if __name__ == "__main__":
main(sys.argv[1:])

222
lib/beets/art.py Executable file
View File

@@ -0,0 +1,222 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""High-level utilities for manipulating image files associated with
music and items' embedded album art.
"""
from __future__ import division, absolute_import, print_function
import subprocess
import platform
from tempfile import NamedTemporaryFile
import os
from beets.util import displayable_path, syspath, bytestring_path
from beets.util.artresizer import ArtResizer
from beets import mediafile
def mediafile_image(image_path, maxwidth=None):
"""Return a `mediafile.Image` object for the path.
"""
with open(syspath(image_path), 'rb') as f:
data = f.read()
return mediafile.Image(data, type=mediafile.ImageType.front)
def get_art(log, item):
# Extract the art.
try:
mf = mediafile.MediaFile(syspath(item.path))
except mediafile.UnreadableFileError as exc:
log.warning(u'Could not extract art from {0}: {1}',
displayable_path(item.path), exc)
return
return mf.art
def embed_item(log, item, imagepath, maxwidth=None, itempath=None,
compare_threshold=0, ifempty=False, as_album=False):
"""Embed an image into the item's media file.
"""
# Conditions and filters.
if compare_threshold:
if not check_art_similarity(log, item, imagepath, compare_threshold):
log.info(u'Image not similar; skipping.')
return
if ifempty and get_art(log, item):
log.info(u'media file already contained art')
return
if maxwidth and not as_album:
imagepath = resize_image(log, imagepath, maxwidth)
# Get the `Image` object from the file.
try:
log.debug(u'embedding {0}', displayable_path(imagepath))
image = mediafile_image(imagepath, maxwidth)
except IOError as exc:
log.warning(u'could not read image file: {0}', exc)
return
# Make sure the image kind is safe (some formats only support PNG
# and JPEG).
if image.mime_type not in ('image/jpeg', 'image/png'):
log.info('not embedding image of unsupported type: {}',
image.mime_type)
return
item.try_write(path=itempath, tags={'images': [image]})
def embed_album(log, album, maxwidth=None, quiet=False,
compare_threshold=0, ifempty=False):
"""Embed album art into all of the album's items.
"""
imagepath = album.artpath
if not imagepath:
log.info(u'No album art present for {0}', album)
return
if not os.path.isfile(syspath(imagepath)):
log.info(u'Album art not found at {0} for {1}',
displayable_path(imagepath), album)
return
if maxwidth:
imagepath = resize_image(log, imagepath, maxwidth)
log.info(u'Embedding album art into {0}', album)
for item in album.items():
embed_item(log, item, imagepath, maxwidth, None,
compare_threshold, ifempty, as_album=True)
def resize_image(log, imagepath, maxwidth):
"""Returns path to an image resized to maxwidth.
"""
log.debug(u'Resizing album art to {0} pixels wide', maxwidth)
imagepath = ArtResizer.shared.resize(maxwidth, syspath(imagepath))
return imagepath
def check_art_similarity(log, item, imagepath, compare_threshold):
"""A boolean indicating if an image is similar to embedded item art.
"""
with NamedTemporaryFile(delete=True) as f:
art = extract(log, f.name, item)
if art:
is_windows = platform.system() == "Windows"
# Converting images to grayscale tends to minimize the weight
# of colors in the diff score. So we first convert both images
# to grayscale and then pipe them into the `compare` command.
# On Windows, ImageMagick doesn't support the magic \\?\ prefix
# on paths, so we pass `prefix=False` to `syspath`.
convert_cmd = ['convert', syspath(imagepath, prefix=False),
syspath(art, prefix=False),
'-colorspace', 'gray', 'MIFF:-']
compare_cmd = ['compare', '-metric', 'PHASH', '-', 'null:']
log.debug(u'comparing images with pipeline {} | {}',
convert_cmd, compare_cmd)
convert_proc = subprocess.Popen(
convert_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
close_fds=not is_windows,
)
compare_proc = subprocess.Popen(
compare_cmd,
stdin=convert_proc.stdout,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
close_fds=not is_windows,
)
# Check the convert output. We're not interested in the
# standard output; that gets piped to the next stage.
convert_proc.stdout.close()
convert_stderr = convert_proc.stderr.read()
convert_proc.stderr.close()
convert_proc.wait()
if convert_proc.returncode:
log.debug(
u'ImageMagick convert failed with status {}: {!r}',
convert_proc.returncode,
convert_stderr,
)
return
# Check the compare output.
stdout, stderr = compare_proc.communicate()
if compare_proc.returncode:
if compare_proc.returncode != 1:
log.debug(u'ImageMagick compare failed: {0}, {1}',
displayable_path(imagepath),
displayable_path(art))
return
out_str = stderr
else:
out_str = stdout
try:
phash_diff = float(out_str)
except ValueError:
log.debug(u'IM output is not a number: {0!r}', out_str)
return
log.debug(u'ImageMagick compare score: {0}', phash_diff)
return phash_diff <= compare_threshold
return True
def extract(log, outpath, item):
art = get_art(log, item)
outpath = bytestring_path(outpath)
if not art:
log.info(u'No album art present in {0}, skipping.', item)
return
# Add an extension to the filename.
ext = mediafile.image_extension(art)
if not ext:
log.warning(u'Unknown image type in {0}.',
displayable_path(item.path))
return
outpath += bytestring_path('.' + ext)
log.info(u'Extracting album art from: {0} to: {1}',
item, displayable_path(outpath))
with open(syspath(outpath), 'wb') as f:
f.write(art)
return outpath
def extract_first(log, outpath, items):
for item in items:
real_path = extract(log, outpath, item)
if real_path:
return real_path
def clear(log, lib, query):
items = lib.items(query)
log.info(u'Clearing album art from {0} items', len(items))
for item in items:
log.debug(u'Clearing art for {0}', item)
item.try_write(tags={'images': None})

42
lib/beets/autotag/__init__.py Normal file → Executable file
View File

@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2013, Adrian Sampson.
# Copyright 2016, Adrian Sampson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
@@ -14,13 +15,15 @@
"""Facilities for automatically determining files' correct metadata.
"""
import logging
from __future__ import division, absolute_import, print_function
from beets import logging
from beets import config
# Parts of external interface.
from .hooks import AlbumInfo, TrackInfo, AlbumMatch, TrackMatch # noqa
from .match import tag_item, tag_album # noqa
from .match import tag_item, tag_album, Proposal # noqa
from .match import Recommendation # noqa
# Global logger.
@@ -39,6 +42,16 @@ def apply_item_metadata(item, track_info):
item.mb_trackid = track_info.track_id
if track_info.artist_id:
item.mb_artistid = track_info.artist_id
if track_info.data_source:
item.data_source = track_info.data_source
if track_info.lyricist is not None:
item.lyricist = track_info.lyricist
if track_info.composer is not None:
item.composer = track_info.composer
if track_info.arranger is not None:
item.arranger = track_info.arranger
# At the moment, the other metadata is left intact (including album
# and track number). Perhaps these should be emptied?
@@ -47,7 +60,7 @@ def apply_metadata(album_info, mapping):
"""Set the items' metadata to match an AlbumInfo object using a
mapping from Items to TrackInfo objects.
"""
for item, track_info in mapping.iteritems():
for item, track_info in mapping.items():
# Album, artist, track count.
if track_info.artist:
item.artist = track_info.artist
@@ -90,7 +103,12 @@ def apply_metadata(album_info, mapping):
item.title = track_info.title
if config['per_disc_numbering']:
item.track = track_info.medium_index or track_info.index
# We want to let the track number be zero, but if the medium index
# is not provided we need to fall back to the overall index.
if track_info.medium_index is not None:
item.track = track_info.medium_index
else:
item.track = track_info.index
item.tracktotal = track_info.medium_total or len(album_info.tracks)
else:
item.track = track_info.index
@@ -122,7 +140,8 @@ def apply_metadata(album_info, mapping):
'language',
'country',
'albumstatus',
'albumdisambig'):
'albumdisambig',
'data_source',):
value = getattr(album_info, field)
if value is not None:
item[field] = value
@@ -132,5 +151,14 @@ def apply_metadata(album_info, mapping):
if track_info.media is not None:
item.media = track_info.media
if track_info.lyricist is not None:
item.lyricist = track_info.lyricist
if track_info.composer is not None:
item.composer = track_info.composer
if track_info.arranger is not None:
item.arranger = track_info.arranger
item.track_alt = track_info.track_alt
# Headphones seal of approval
item.comments = 'tagged by headphones/beets'
item.comments = 'tagged by headphones/beets'

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