mirror of
https://github.com/rembo10/headphones.git
synced 2026-09-10 00:32:52 +01:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
742529a92d | ||
|
|
944d066903 | ||
|
|
87819a3c74 | ||
|
|
c9fbe29c90 | ||
|
|
d78cb7d14e | ||
|
|
e8c392824f | ||
|
|
9811df2779 | ||
|
|
1a4865ed38 | ||
|
|
a06fb40f50 | ||
|
|
ad6a4f570e | ||
|
|
3685d32a7d | ||
|
|
152f5daa8c | ||
|
|
39054a04df | ||
|
|
1c4b9c10f0 | ||
|
|
73ca787cf1 | ||
|
|
c7bc852868 | ||
|
|
391b0cc465 | ||
|
|
4aaeaa704f | ||
|
|
4d14b028ff | ||
|
|
a78f38c174 | ||
|
|
14f2a6d22c | ||
|
|
2e4299efa7 | ||
|
|
0610c2fa93 | ||
|
|
9add571886 | ||
|
|
fcf59a9b38 | ||
|
|
74f9e91afc | ||
|
|
83398cb102 | ||
|
|
61c2e1f821 | ||
|
|
3e3047aef2 | ||
|
|
fff44e4631 | ||
|
|
0964371de8 | ||
|
|
654f923a8d | ||
|
|
b91206c64a | ||
|
|
c9ba59ee9a | ||
|
|
b7e35d5ff0 | ||
|
|
9d82143abe | ||
|
|
eaf2db6c59 | ||
|
|
586b9ed3c8 | ||
|
|
d89f4171da | ||
|
|
9f7be5348b | ||
|
|
9c254ff222 | ||
|
|
ba969fd3b8 | ||
|
|
c851d5ed1a | ||
|
|
2223928958 | ||
|
|
164c3cacbc | ||
|
|
16d4ac8895 | ||
|
|
f4d60226b3 | ||
|
|
9ca87e23b2 | ||
|
|
d934c865c6 | ||
|
|
de74cd2502 | ||
|
|
f41db714a9 | ||
|
|
f03b82e5f6 | ||
|
|
e2db680b9e | ||
|
|
a3db89c11d |
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## v0.6.1
|
||||
Released 26 November 2023
|
||||
|
||||
Highlights:
|
||||
* Dependency updates to work with > Python 3.11
|
||||
|
||||
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.6.0...v0.6.1).
|
||||
|
||||
## v0.6.0
|
||||
Released 13 November 2022
|
||||
|
||||
Highlights:
|
||||
* Updated to python 3
|
||||
|
||||
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.20...v0.6.0).
|
||||
|
||||
## v0.5.20
|
||||
Released 15 October 2021
|
||||
|
||||
|
||||
+2
-2
@@ -17,8 +17,8 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
if sys.version_info <= (3, 5):
|
||||
sys.stdout.write("Headphones requires Python >= 3.5\n")
|
||||
if sys.version_info <= (3, 6):
|
||||
sys.stdout.write("Headphones requires Python >= 3.7\n")
|
||||
sys.exit(1)
|
||||
|
||||
# Ensure lib added to path, before any other imports
|
||||
|
||||
@@ -310,6 +310,16 @@
|
||||
<input type="text" name="usenet_retention" value="${config['usenet_retention']}" size="5">
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset title="Method for downloading Bandcamp.com files.">
|
||||
<legend>Bandcamp</legend>
|
||||
<div class="row">
|
||||
<label title="Path to folder where Headphones can store raw downloads from Bandcamp.com.">
|
||||
Bandcamp Directory
|
||||
</label>
|
||||
<input type="text" name="bandcamp_dir" value="${config['bandcamp_dir']}" size="50">
|
||||
<small>Full path where raw MP3s will be stored, e.g. /Users/name/Downloads/bandcamp</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
</td>
|
||||
<td>
|
||||
<fieldset title="Method for downloading torrent files.">
|
||||
@@ -579,6 +589,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Other</legend>
|
||||
<fieldset>
|
||||
<div class="row checkbox left">
|
||||
<input id="use_bandcamp" type="checkbox" class="bigcheck" name="use_bandcamp" value="1" ${config['use_bandcamp']} /><label for="use_bandcamp"><span class="option">Bandcamp</span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</fieldset>
|
||||
</td>
|
||||
<td>
|
||||
<fieldset>
|
||||
@@ -1370,17 +1389,20 @@
|
||||
<div class="row">
|
||||
<label>File Format</label>
|
||||
<input type="text" name="file_format" value="${config['file_format']}" size="43">
|
||||
<small>Use: $Disc/$disc (disc #), $Track/$track (track #), $Title/$title, $Artist/$artist, $Album/$album and $Year/$year. Put optional variables in curly braces, use single-quote marks to escape curly braces literally ('{', '}').</small>
|
||||
<small>Use: In addition to the above, there is also $Title/$title (track title), $Track (track #), $Disc (disc #), $DiscTotal.</small>
|
||||
</div>
|
||||
<div class="checkbox row clearfix">
|
||||
<div class="checkbox row left clearfix nopad">
|
||||
<input type="checkbox" name="file_underscores" id="file_underscores" value="1" ${config['file_underscores']}/><label>Use underscores instead of spaces</label>
|
||||
</div>
|
||||
<div class="checkbox row left clearfix nopad">
|
||||
<input type="checkbox" name="rename_single_disc_ignore" id="rename_single_disc_ignore" value="1" ${config['rename_single_disc_ignore']}/><label>Don't include disc# for single disc albums</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Re-Encoding Options</legend>
|
||||
<small class="heading"><i class="fa fa-info-circle"></i> Note: this option requires the lame, ffmpeg or xld encoder</small>
|
||||
<div class="checkbox row clearfix">
|
||||
<div class="checkbox row left clearfix nopad">
|
||||
<input type="checkbox" name="music_encoder" id="music_encoder" value="1" ${config['music_encoder']}/><label>Re-encode downloads during postprocessing</label>
|
||||
</div>
|
||||
<div id="encoderoptions" class="row clearfix checkbox">
|
||||
@@ -1651,6 +1673,16 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Last.fm</legend>
|
||||
<div id="lastfmoptions">
|
||||
<div class="row">
|
||||
<label>API Key</label>
|
||||
<input type="text" name="lastfm_apikey" value="${config['lastfm_apikey']}" size="40" />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Songkick</legend>
|
||||
<div class="row checkbox">
|
||||
|
||||
@@ -56,6 +56,8 @@
|
||||
fileid = 'torrent'
|
||||
if item['URL'].find('codeshy') != -1:
|
||||
fileid = 'nzb'
|
||||
if item['URL'].find('bandcamp') != -1:
|
||||
fileid = 'bandcamp'
|
||||
|
||||
folder = 'Folder: ' + item['FolderName']
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# This file is part of Headphones.
|
||||
#
|
||||
# Headphones is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Headphones is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>
|
||||
|
||||
import headphones
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from headphones import logger, helpers, metadata, request
|
||||
from headphones.common import USER_AGENT
|
||||
|
||||
from mediafile import MediaFile, UnreadableFileError
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
def search(album, albumlength=None, page=1, resultlist=None):
|
||||
dic = {'...': '', ' & ': ' ', ' = ': ' ', '?': '', '$': 's', ' + ': ' ',
|
||||
'"': '', ',': '', '*': '', '.': '', ':': ''}
|
||||
if resultlist is None:
|
||||
resultlist = []
|
||||
|
||||
cleanalbum = helpers.latinToAscii(
|
||||
helpers.replace_all(album['AlbumTitle'], dic)
|
||||
).strip()
|
||||
cleanartist = helpers.latinToAscii(
|
||||
helpers.replace_all(album['ArtistName'], dic)
|
||||
).strip()
|
||||
|
||||
headers = {'User-Agent': USER_AGENT}
|
||||
params = {
|
||||
"page": page,
|
||||
"q": cleanalbum,
|
||||
}
|
||||
logger.info("Looking up https://bandcamp.com/search with {}".format(
|
||||
params))
|
||||
content = request.request_content(
|
||||
url='https://bandcamp.com/search',
|
||||
params=params,
|
||||
headers=headers
|
||||
).decode('utf8')
|
||||
soup = BeautifulSoup(content, "html5lib")
|
||||
|
||||
for item in soup.find_all("li", class_="searchresult"):
|
||||
type = item.find('div', class_='itemtype').text.strip().lower()
|
||||
if type == "album":
|
||||
data = parse_album(item)
|
||||
|
||||
cleanartist_found = helpers.latinToAscii(data['artist'])
|
||||
cleanalbum_found = helpers.latinToAscii(data['album'])
|
||||
|
||||
logger.debug(u"{} - {}".format(data['album'], cleanalbum_found))
|
||||
|
||||
logger.debug("Comparing {} to {}".format(
|
||||
cleanalbum, cleanalbum_found))
|
||||
if (cleanartist.lower() == cleanartist_found.lower() and
|
||||
cleanalbum.lower() == cleanalbum_found.lower()):
|
||||
resultlist.append((
|
||||
data['title'], data['size'], data['url'],
|
||||
'bandcamp', 'bandcamp', True))
|
||||
else:
|
||||
continue
|
||||
|
||||
if(soup.find('a', class_='next')):
|
||||
page += 1
|
||||
logger.debug("Calling next page ({})".format(page))
|
||||
search(album, albumlength=albumlength,
|
||||
page=page, resultlist=resultlist)
|
||||
|
||||
return resultlist
|
||||
|
||||
|
||||
def download(album, bestqual):
|
||||
html = request.request_content(url=bestqual[2]).decode('utf-8')
|
||||
trackinfo = []
|
||||
try:
|
||||
trackinfo = json.loads(
|
||||
re.search(r"trackinfo":(\[.*?\]),", html)
|
||||
.group(1)
|
||||
.replace('"', '"'))
|
||||
except ValueError as e:
|
||||
logger.warn("Couldn't load json: {}".format(e))
|
||||
|
||||
directory = os.path.join(
|
||||
headphones.CONFIG.BANDCAMP_DIR,
|
||||
u'{} - {}'.format(
|
||||
album['ArtistName'].replace('/', '_'),
|
||||
album['AlbumTitle'].replace('/', '_')))
|
||||
directory = helpers.latinToAscii(directory)
|
||||
|
||||
if not os.path.exists(directory):
|
||||
try:
|
||||
os.makedirs(directory)
|
||||
except Exception as e:
|
||||
logger.warn("Could not create directory ({})".format(e))
|
||||
|
||||
index = 1
|
||||
for track in trackinfo:
|
||||
filename = helpers.replace_illegal_chars(
|
||||
u'{:02d} - {}.mp3'.format(index, track['title']))
|
||||
fullname = os.path.join(directory.encode('utf-8'),
|
||||
filename.encode('utf-8'))
|
||||
logger.debug("Downloading to {}".format(fullname))
|
||||
|
||||
if 'file' in track and track['file'] != None and 'mp3-128' in track['file']:
|
||||
content = request.request_content(track['file']['mp3-128'])
|
||||
open(fullname, 'wb').write(content)
|
||||
try:
|
||||
f = MediaFile(fullname)
|
||||
date, year = metadata._date_year(album)
|
||||
f.update({
|
||||
'artist': album['ArtistName'].encode('utf-8'),
|
||||
'album': album['AlbumTitle'].encode('utf-8'),
|
||||
'title': track['title'].encode('utf-8'),
|
||||
'track': track['track_num'],
|
||||
'tracktotal': len(trackinfo),
|
||||
'year': year,
|
||||
})
|
||||
f.save()
|
||||
except UnreadableFileError as ex:
|
||||
logger.warn("MediaFile couldn't parse: %s (%s)",
|
||||
fullname,
|
||||
str(ex))
|
||||
|
||||
index += 1
|
||||
|
||||
return directory
|
||||
|
||||
|
||||
def parse_album(item):
|
||||
album = item.find('div', class_='heading').text.strip()
|
||||
artist = item.find('div', class_='subhead').text.strip().replace("by ", "")
|
||||
released = item.find('div', class_='released').text.strip().replace(
|
||||
"released ", "")
|
||||
year = re.search(r"(\d{4})", released).group(1)
|
||||
|
||||
url = item.find('div', class_='heading').find('a')['href'].split("?")[0]
|
||||
|
||||
length = item.find('div', class_='length').text.strip()
|
||||
tracks, minutes = length.split(",")
|
||||
tracks = tracks.replace(" tracks", "").replace(" track", "").strip()
|
||||
minutes = minutes.replace(" minutes", "").strip()
|
||||
# bandcamp offers mp3 128b with should be 960KB/minute
|
||||
size = int(minutes) * 983040
|
||||
|
||||
data = {"title": u'{} - {} [{}]'.format(artist, album, year),
|
||||
"artist": artist, "album": album,
|
||||
"url": url, "size": size}
|
||||
|
||||
return data
|
||||
@@ -102,36 +102,6 @@ class Quality:
|
||||
|
||||
return (anyQualities, bestQualities)
|
||||
|
||||
@staticmethod
|
||||
def nameQuality(name):
|
||||
|
||||
def checkName(list, func):
|
||||
return func([re.search(x, name, re.I) for x in list])
|
||||
|
||||
name = os.path.basename(name)
|
||||
|
||||
# if we have our exact text then assume we put it there
|
||||
for x in Quality.qualityStrings:
|
||||
if x == Quality.UNKNOWN:
|
||||
continue
|
||||
|
||||
regex = '\W' + Quality.qualityStrings[x].replace(' ', '\W') + '\W'
|
||||
regex_match = re.search(regex, name, re.I)
|
||||
if regex_match:
|
||||
return x
|
||||
|
||||
# TODO: fix quality checking here
|
||||
if checkName(["mp3", "192"], any) and not checkName(["flac"], all):
|
||||
return Quality.B192
|
||||
elif checkName(["mp3", "256"], any) and not checkName(["flac"], all):
|
||||
return Quality.B256
|
||||
elif checkName(["mp3", "vbr"], any) and not checkName(["flac"], all):
|
||||
return Quality.VBR
|
||||
elif checkName(["mp3", "320"], any) and not checkName(["flac"], all):
|
||||
return Quality.B320
|
||||
else:
|
||||
return Quality.UNKNOWN
|
||||
|
||||
@staticmethod
|
||||
def assumeQuality(name):
|
||||
if name.lower().endswith(".mp3"):
|
||||
@@ -158,13 +128,6 @@ class Quality:
|
||||
|
||||
return (Quality.NONE, status)
|
||||
|
||||
@staticmethod
|
||||
def statusFromName(name, assume=True):
|
||||
quality = Quality.nameQuality(name)
|
||||
if assume and quality == Quality.UNKNOWN:
|
||||
quality = Quality.assumeQuality(name)
|
||||
return Quality.compositeStatus(DOWNLOADED, quality)
|
||||
|
||||
DOWNLOADED = None
|
||||
SNATCHED = None
|
||||
SNATCHED_PROPER = None
|
||||
|
||||
+12
-8
@@ -155,9 +155,10 @@ _CONFIG_DEFINITIONS = {
|
||||
'KEEP_TORRENT_FILES': (int, 'General', 0),
|
||||
'KEEP_TORRENT_FILES_DIR': (path, 'General', ''),
|
||||
'LASTFM_USERNAME': (str, 'General', ''),
|
||||
'LASTFM_APIKEY': (str, 'General', ''),
|
||||
'LAUNCH_BROWSER': (int, 'General', 1),
|
||||
'LIBRARYSCAN': (int, 'General', 1),
|
||||
'LIBRARYSCAN_INTERVAL': (int, 'General', 300),
|
||||
'LIBRARYSCAN_INTERVAL': (int, 'General', 24),
|
||||
'LMS_ENABLED': (int, 'LMS', 0),
|
||||
'LMS_HOST': (str, 'LMS', ''),
|
||||
'LOG_DIR': (path, 'General', ''),
|
||||
@@ -240,6 +241,7 @@ _CONFIG_DEFINITIONS = {
|
||||
'QBITTORRENT_PASSWORD': (str, 'QBitTorrent', ''),
|
||||
'QBITTORRENT_USERNAME': (str, 'QBitTorrent', ''),
|
||||
'RENAME_FILES': (int, 'General', 0),
|
||||
'RENAME_SINGLE_DISC_IGNORE': (int, 'General', 0),
|
||||
'RENAME_UNPROCESSED': (bool_int, 'General', 1),
|
||||
'RENAME_FROZEN': (bool_int, 'General', 1),
|
||||
'REPLACE_EXISTING_FOLDERS': (int, 'General', 0),
|
||||
@@ -315,7 +317,9 @@ _CONFIG_DEFINITIONS = {
|
||||
'XBMC_PASSWORD': (str, 'XBMC', ''),
|
||||
'XBMC_UPDATE': (int, 'XBMC', 0),
|
||||
'XBMC_USERNAME': (str, 'XBMC', ''),
|
||||
'XLDPROFILE': (str, 'General', '')
|
||||
'XLDPROFILE': (str, 'General', ''),
|
||||
'BANDCAMP': (int, 'General', 1),
|
||||
'BANDCAMP_DIR': (path, 'General', '')
|
||||
}
|
||||
|
||||
|
||||
@@ -327,7 +331,7 @@ class Config(object):
|
||||
def __init__(self, config_file):
|
||||
""" Initialize the config with values from a file """
|
||||
self._config_file = config_file
|
||||
self._config = ConfigParser()
|
||||
self._config = ConfigParser(interpolation=None)
|
||||
self._config.read(self._config_file)
|
||||
for key in list(_CONFIG_DEFINITIONS.keys()):
|
||||
self.check_setting(key)
|
||||
@@ -363,12 +367,12 @@ class Config(object):
|
||||
|
||||
try:
|
||||
my_val = definition_type(self._config[section][ini_key])
|
||||
# ConfigParser interprets empty strings in the config
|
||||
# ConfigParser interprets quotes in the config
|
||||
# literally, so we need to sanitize it. It's not really
|
||||
# a config upgrade, since a user can at any time put
|
||||
# some_key = ''
|
||||
if my_val == '""' or my_val == "''":
|
||||
my_val = ''
|
||||
# some_key = 'some_val'
|
||||
if type(my_val) in [str, path]:
|
||||
my_val = my_val.strip('"').strip("'")
|
||||
except Exception:
|
||||
my_val = default
|
||||
self._config[section][ini_key] = str(my_val)
|
||||
@@ -376,7 +380,7 @@ class Config(object):
|
||||
|
||||
def write(self):
|
||||
""" Make a copy of the stored config and write it to the configured file """
|
||||
new_config = ConfigParser()
|
||||
new_config = ConfigParser(interpolation=None)
|
||||
|
||||
# first copy over everything from the old config, even if it is not
|
||||
# correctly defined to keep from losing data
|
||||
|
||||
+1
-1
@@ -117,7 +117,7 @@ class DBConnection:
|
||||
break
|
||||
|
||||
except sqlite3.OperationalError as e:
|
||||
if "unable to open database file" in e.message or "database is locked" in e.message:
|
||||
if "unable to open database file" in str(e) or "database is locked" in str(e):
|
||||
dberror = e
|
||||
if args is None:
|
||||
logger.debug('Database error: %s. Query: %s', e, query)
|
||||
|
||||
@@ -58,12 +58,12 @@ def _scrubber(text):
|
||||
if scrub_logs:
|
||||
try:
|
||||
# URL parameter values
|
||||
text = re.sub('=[0-9a-zA-Z]*', '=REMOVED', text)
|
||||
text = re.sub(r'=[0-9a-zA-Z]*', r'=REMOVED', text)
|
||||
# Local host with port
|
||||
# text = re.sub('\:\/\/.*\:', '://REMOVED:', text) # just host
|
||||
text = re.sub('\:\/\/.*\:[0-9]*', '://REMOVED:', text)
|
||||
text = re.sub(r'\:\/\/.*\:[0-9]*', r'://REMOVED:', text)
|
||||
# Session cookie
|
||||
text = re.sub("_session_id'\: '.*'", "_session_id': 'REMOVED'", text)
|
||||
text = re.sub(r"_session_id'\: '.*'", r"_session_id': 'REMOVED'", text)
|
||||
# Local Windows user path
|
||||
if text.lower().startswith('c:\\users\\'):
|
||||
k = text.split('\\')
|
||||
@@ -128,9 +128,9 @@ def addTorrent(link, data=None, name=None):
|
||||
# Extract torrent name from .torrent
|
||||
try:
|
||||
logger.debug('Deluge: Getting torrent name length')
|
||||
name_length = int(re.findall('name([0-9]*)\:.*?\:', str(torrentfile))[0])
|
||||
name_length = int(re.findall(r'name([0-9]*)\:.*?\:', str(torrentfile))[0])
|
||||
logger.debug('Deluge: Getting torrent name')
|
||||
name = re.findall('name[0-9]*\:(.*?)\:', str(torrentfile))[0][:name_length]
|
||||
name = re.findall(r'name[0-9]*\:(.*?)\:', str(torrentfile))[0][:name_length]
|
||||
except Exception as e:
|
||||
logger.debug('Deluge: Could not get torrent name, getting file name')
|
||||
# get last part of link/path (name only)
|
||||
@@ -160,9 +160,9 @@ def addTorrent(link, data=None, name=None):
|
||||
# Extract torrent name from .torrent
|
||||
try:
|
||||
logger.debug('Deluge: Getting torrent name length')
|
||||
name_length = int(re.findall('name([0-9]*)\:.*?\:', str(torrentfile))[0])
|
||||
name_length = int(re.findall(r'name([0-9]*)\:.*?\:', str(torrentfile))[0])
|
||||
logger.debug('Deluge: Getting torrent name')
|
||||
name = re.findall('name[0-9]*\:(.*?)\:', str(torrentfile))[0][:name_length]
|
||||
name = re.findall(r'name[0-9]*\:(.*?)\:', str(torrentfile))[0][:name_length]
|
||||
except Exception as e:
|
||||
logger.debug('Deluge: Could not get torrent name, getting file name')
|
||||
# get last part of link/path (name only)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os.path
|
||||
|
||||
import biplist
|
||||
import plistlib
|
||||
from headphones import logger
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@ def getXldProfile(xldProfile):
|
||||
|
||||
# Get xld preferences plist
|
||||
try:
|
||||
preferences = biplist.readPlist(expanded)
|
||||
except (biplist.InvalidPlistException, biplist.NotBinaryPlistException) as e:
|
||||
with open(expanded, 'rb') as _f:
|
||||
preferences = plistlib.load(_f)
|
||||
except Exception as e:
|
||||
logger.error("Error reading xld preferences plist: %s", e)
|
||||
return (xldProfileNotFound, None, None)
|
||||
|
||||
|
||||
+47
-29
@@ -14,25 +14,25 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from operator import itemgetter
|
||||
import unicodedata
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
import sys
|
||||
import tempfile
|
||||
import glob
|
||||
import time
|
||||
import unicodedata
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, date
|
||||
from fnmatch import fnmatch
|
||||
from functools import cmp_to_key
|
||||
from glob import glob
|
||||
from operator import itemgetter
|
||||
|
||||
from beets import logging as beetslogging
|
||||
import six
|
||||
from contextlib import contextmanager
|
||||
|
||||
import fnmatch
|
||||
import functools
|
||||
import re
|
||||
import os
|
||||
from mediafile import MediaFile, FileTypeError, UnreadableFileError
|
||||
from six import text_type
|
||||
from unidecode import unidecode
|
||||
|
||||
import headphones
|
||||
|
||||
|
||||
@@ -52,6 +52,13 @@ def cmp(x, y):
|
||||
|
||||
https://portingguide.readthedocs.io/en/latest/comparisons.html#the-cmp-function
|
||||
"""
|
||||
if x is None and y is None:
|
||||
return 0
|
||||
elif x is None:
|
||||
return -1
|
||||
elif y is None:
|
||||
return 1
|
||||
else:
|
||||
return (x > y) - (x < y)
|
||||
|
||||
def multikeysort(items, columns):
|
||||
@@ -67,7 +74,7 @@ def multikeysort(items, columns):
|
||||
else:
|
||||
return 0
|
||||
|
||||
return sorted(items, key=functools.cmp_to_key(comparer))
|
||||
return sorted(items, key=cmp_to_key(comparer))
|
||||
|
||||
|
||||
def checked(variable):
|
||||
@@ -149,28 +156,25 @@ def convert_seconds(s):
|
||||
|
||||
|
||||
def today():
|
||||
today = datetime.date.today()
|
||||
yyyymmdd = datetime.date.isoformat(today)
|
||||
return yyyymmdd
|
||||
return date.isoformat(date.today())
|
||||
|
||||
|
||||
def now():
|
||||
now = datetime.datetime.now()
|
||||
now = datetime.now()
|
||||
return now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def get_age(date):
|
||||
try:
|
||||
split_date = date.split('-')
|
||||
except:
|
||||
def is_valid_date(d):
|
||||
if not d:
|
||||
return False
|
||||
else:
|
||||
return bool(re.match(r'\d{4}-\d{2}-\d{2}', d))
|
||||
|
||||
try:
|
||||
days_old = int(split_date[0]) * 365 + int(split_date[1]) * 30 + int(split_date[2])
|
||||
except (IndexError, ValueError):
|
||||
days_old = False
|
||||
|
||||
return days_old
|
||||
def age(d):
|
||||
'''Requires a valid date'''
|
||||
delta = date.today() - date.fromisoformat(d)
|
||||
return delta.days
|
||||
|
||||
|
||||
def bytes_to_mb(bytes):
|
||||
@@ -502,7 +506,7 @@ def path_match_patterns(path, patterns):
|
||||
"""
|
||||
|
||||
for pattern in patterns:
|
||||
if fnmatch.fnmatch(path, pattern):
|
||||
if fnmatch(path, pattern):
|
||||
return True
|
||||
|
||||
# No match
|
||||
@@ -708,7 +712,7 @@ def preserve_torrent_directory(albumpath, forced=False, single=False):
|
||||
workdir = os.path.join(tempdir, prefix)
|
||||
workdir = re.sub(r'\[', '[[]', workdir)
|
||||
workdir = re.sub(r'(?<!\[)\]', '[]]', workdir)
|
||||
if len(glob.glob(workdir + '*/')) >= 3:
|
||||
if len(glob(workdir + '*/')) >= 3:
|
||||
logger.error(
|
||||
"Looks like a temp directory has previously been created "
|
||||
"for this albumpath, not continuing "
|
||||
@@ -1027,7 +1031,7 @@ class BeetsLogCapture(beetslogging.Handler):
|
||||
self.messages = []
|
||||
|
||||
def emit(self, record):
|
||||
self.messages.append(six.text_type(record.msg))
|
||||
self.messages.append(text_type(record.msg))
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -1039,3 +1043,17 @@ def capture_beets_log(logger='beets'):
|
||||
yield capture.messages
|
||||
finally:
|
||||
log.removeHandler(capture)
|
||||
|
||||
def have_pct_have_total(db_artist):
|
||||
have_tracks = db_artist['HaveTracks'] or 0
|
||||
total_tracks = db_artist['TotalTracks'] or 0
|
||||
have_pct = have_tracks / total_tracks if total_tracks else 0
|
||||
return (have_pct, total_tracks)
|
||||
|
||||
|
||||
def has_token(title, token):
|
||||
return bool(
|
||||
re.search(rf'(?:\W|^)+{token}(?:\W|$)+',
|
||||
title,
|
||||
re.IGNORECASE | re.UNICODE)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from .unittestcompat import TestCase
|
||||
from headphones.helpers import clean_name
|
||||
from headphones.helpers import clean_name, is_valid_date, age, has_token
|
||||
|
||||
|
||||
class HelpersTest(TestCase):
|
||||
@@ -46,3 +46,28 @@ class HelpersTest(TestCase):
|
||||
self.assertEqual(
|
||||
test, expected, "check clean_name() with narrow non-ascii input"
|
||||
)
|
||||
|
||||
def test_is_valid_date(date):
|
||||
test_cases = [
|
||||
('2021-11-12', True, "check is_valid_date returns True for valid date"),
|
||||
(None, False, "check is_valid_date returns False for None"),
|
||||
('2021-11', False, "check is_valid_date returns False for incomplete"),
|
||||
('2021', False, "check is_valid_date returns False for incomplete")
|
||||
]
|
||||
for input, expected, desc in test_cases:
|
||||
self.assertEqual(is_valid_date(input), expected, desc)
|
||||
|
||||
def test_has_token(self):
|
||||
"""helpers: has_token()"""
|
||||
self.assertEqual(
|
||||
has_token("a cat ran", "cat"),
|
||||
True,
|
||||
"return True if token is in string"
|
||||
)
|
||||
self.assertEqual(
|
||||
has_token("acatran", "cat"),
|
||||
False,
|
||||
"return False if token is part of another word"
|
||||
)
|
||||
|
||||
|
||||
|
||||
+8
-18
@@ -102,12 +102,7 @@ def artistlist_to_mbids(artistlist, forced=False):
|
||||
myDB.action('DELETE from newartists WHERE ArtistName=?', [artist])
|
||||
|
||||
# Update the similar artist tag cloud:
|
||||
logger.info('Updating artist information from Last.fm')
|
||||
|
||||
try:
|
||||
lastfm.getSimilar()
|
||||
except Exception as e:
|
||||
logger.warn('Failed to update artist information from Last.fm: %s' % e)
|
||||
|
||||
|
||||
def addArtistIDListToDB(artistidlist):
|
||||
@@ -245,7 +240,7 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
|
||||
rgid = rg['id']
|
||||
skip_log = 0
|
||||
# Make a user configurable variable to skip update of albums with release dates older than this date (in days)
|
||||
pause_delta = headphones.CONFIG.MB_IGNORE_AGE
|
||||
ignore_age = headphones.CONFIG.MB_IGNORE_AGE
|
||||
|
||||
rg_exists = myDB.action("SELECT * from albums WHERE AlbumID=?", [rg['id']]).fetchone()
|
||||
|
||||
@@ -274,18 +269,18 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
|
||||
if len(check_release_date) == 10:
|
||||
release_date = check_release_date
|
||||
elif len(check_release_date) == 7:
|
||||
release_date = check_release_date + "-31"
|
||||
release_date = check_release_date + "-27"
|
||||
elif len(check_release_date) == 4:
|
||||
release_date = check_release_date + "-12-31"
|
||||
release_date = check_release_date + "-12-27"
|
||||
else:
|
||||
release_date = today
|
||||
if helpers.get_age(today) - helpers.get_age(release_date) < pause_delta:
|
||||
if helpers.age(release_date) < ignore_age:
|
||||
logger.info("[%s] Now updating: %s (Release Date <%s Days)",
|
||||
artist['artist_name'], rg['title'], pause_delta)
|
||||
artist['artist_name'], rg['title'], ignore_age)
|
||||
new_releases = mb.get_new_releases(rgid, includeExtras, True)
|
||||
else:
|
||||
logger.info("[%s] Skipping: %s (Release Date >%s Days)",
|
||||
artist['artist_name'], rg['title'], pause_delta)
|
||||
artist['artist_name'], rg['title'], ignore_age)
|
||||
skip_log = 1
|
||||
new_releases = 0
|
||||
|
||||
@@ -450,13 +445,8 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
|
||||
|
||||
if headphones.CONFIG.AUTOWANT_ALL:
|
||||
newValueDict['Status'] = "Wanted"
|
||||
elif album['ReleaseDate'] > today and headphones.CONFIG.AUTOWANT_UPCOMING:
|
||||
newValueDict['Status'] = "Wanted"
|
||||
# Sometimes "new" albums are added to musicbrainz after their release date, so let's try to catch these
|
||||
# The first test just makes sure we have year-month-day
|
||||
elif helpers.get_age(album['ReleaseDate']) and helpers.get_age(
|
||||
today) - helpers.get_age(
|
||||
album['ReleaseDate']) < 21 and headphones.CONFIG.AUTOWANT_UPCOMING:
|
||||
elif headphones.CONFIG.AUTOWANT_UPCOMING:
|
||||
if helpers.is_valid_date(album['ReleaseDate']) and helpers.age(album['ReleaseDate']) < 21:
|
||||
newValueDict['Status'] = "Wanted"
|
||||
else:
|
||||
newValueDict['Status'] = "Skipped"
|
||||
|
||||
+27
-20
@@ -23,7 +23,7 @@ from headphones import db, logger, request
|
||||
TIMEOUT = 60.0 # seconds
|
||||
REQUEST_LIMIT = 1.0 / 5 # seconds
|
||||
ENTRY_POINT = "https://ws.audioscrobbler.com/2.0/"
|
||||
API_KEY = "395e6ec6bb557382fc41fde867bce66f"
|
||||
APP_API_KEY = "395e6ec6bb557382fc41fde867bce66f"
|
||||
|
||||
# Required for API request limit
|
||||
lastfm_lock = headphones.lock.TimedLock(REQUEST_LIMIT)
|
||||
@@ -31,7 +31,7 @@ lastfm_lock = headphones.lock.TimedLock(REQUEST_LIMIT)
|
||||
|
||||
def request_lastfm(method, **kwargs):
|
||||
"""
|
||||
Call a Last.FM API method. Automatically sets the method and API key. Method
|
||||
Call a Last.fm API method. Automatically sets the method and API key. Method
|
||||
will return the result if no error occured.
|
||||
|
||||
By default, this method will request the JSON format, since it is more
|
||||
@@ -40,35 +40,42 @@ def request_lastfm(method, **kwargs):
|
||||
|
||||
# Prepare request
|
||||
kwargs["method"] = method
|
||||
kwargs.setdefault("api_key", API_KEY)
|
||||
kwargs.setdefault("api_key", headphones.CONFIG.LASTFM_APIKEY or APP_API_KEY)
|
||||
kwargs.setdefault("format", "json")
|
||||
|
||||
# Send request
|
||||
logger.debug("Calling Last.FM method: %s", method)
|
||||
logger.debug("Last.FM call parameters: %s", kwargs)
|
||||
logger.debug("Calling Last.fm method: %s", method)
|
||||
logger.debug("Last.fm call parameters: %s", kwargs)
|
||||
|
||||
data = request.request_json(ENTRY_POINT, timeout=TIMEOUT, params=kwargs, lock=lastfm_lock)
|
||||
|
||||
# Parse response and check for errors.
|
||||
if not data:
|
||||
logger.error("Error calling Last.FM method: %s", method)
|
||||
logger.error("Error calling Last.fm method: %s", method)
|
||||
return
|
||||
|
||||
if "error" in data:
|
||||
logger.debug("Last.FM returned an error: %s", data["message"])
|
||||
logger.debug("Last.fm returned an error: %s", data["message"])
|
||||
return
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def getSimilar():
|
||||
myDB = db.DBConnection()
|
||||
results = myDB.select("SELECT ArtistID from artists ORDER BY HaveTracks DESC")
|
||||
if not headphones.CONFIG.LASTFM_APIKEY:
|
||||
logger.info(
|
||||
'To update the Similar Artists cloud tag, create a Last.fm application api key '
|
||||
'and add it under the Advanced config tab'
|
||||
)
|
||||
return
|
||||
|
||||
logger.info("Fetching similar artists from Last.FM for tag cloud")
|
||||
myDB = db.DBConnection()
|
||||
results = myDB.select("SELECT ArtistID from artists ORDER BY HaveTracks DESC LIMIT 10")
|
||||
|
||||
logger.info("Fetching similar artists from Last.fm for tag cloud")
|
||||
artistlist = []
|
||||
|
||||
for result in results[:12]:
|
||||
for result in results:
|
||||
data = request_lastfm("artist.getsimilar", mbid=result["ArtistId"])
|
||||
|
||||
if data and "similarartists" in data:
|
||||
@@ -85,7 +92,7 @@ def getSimilar():
|
||||
artistlist.append((artist_name, artist_mbid))
|
||||
|
||||
# Add new artists to tag cloud
|
||||
logger.debug("Fetched %d artists from Last.FM", len(artistlist))
|
||||
logger.debug("Fetched %d artists from Last.fm", len(artistlist))
|
||||
count = defaultdict(int)
|
||||
|
||||
for artist, mbid in artistlist:
|
||||
@@ -103,7 +110,7 @@ def getSimilar():
|
||||
|
||||
myDB.action("INSERT INTO lastfmcloud VALUES( ?, ?, ?)", [artist_name, artist_mbid, count])
|
||||
|
||||
logger.debug("Inserted %d artists into Last.FM tag cloud", len(top_list))
|
||||
logger.debug("Inserted %d artists into Last.fm tag cloud", len(top_list))
|
||||
|
||||
|
||||
def getArtists():
|
||||
@@ -111,16 +118,16 @@ def getArtists():
|
||||
results = myDB.select("SELECT ArtistID from artists")
|
||||
|
||||
if not headphones.CONFIG.LASTFM_USERNAME:
|
||||
logger.warn("Last.FM username not set, not importing artists.")
|
||||
logger.warn("Last.fm username not set, not importing artists.")
|
||||
return
|
||||
|
||||
logger.info("Fetching artists from Last.FM for username: %s", headphones.CONFIG.LASTFM_USERNAME)
|
||||
logger.info("Fetching artists from Last.fm for username: %s", headphones.CONFIG.LASTFM_USERNAME)
|
||||
data = request_lastfm("library.getartists", limit=1000, user=headphones.CONFIG.LASTFM_USERNAME)
|
||||
|
||||
if data and "artists" in data:
|
||||
artistlist = []
|
||||
artists = data["artists"]["artist"]
|
||||
logger.debug("Fetched %d artists from Last.FM", len(artists))
|
||||
logger.debug("Fetched %d artists from Last.fm", len(artists))
|
||||
|
||||
for artist in artists:
|
||||
artist_mbid = artist["mbid"]
|
||||
@@ -133,20 +140,20 @@ def getArtists():
|
||||
for artistid in artistlist:
|
||||
importer.addArtisttoDB(artistid)
|
||||
|
||||
logger.info("Imported %d new artists from Last.FM", len(artistlist))
|
||||
logger.info("Imported %d new artists from Last.fm", len(artistlist))
|
||||
|
||||
|
||||
def getTagTopArtists(tag, limit=50):
|
||||
myDB = db.DBConnection()
|
||||
results = myDB.select("SELECT ArtistID from artists")
|
||||
|
||||
logger.info("Fetching top artists from Last.FM for tag: %s", tag)
|
||||
logger.info("Fetching top artists from Last.fm for tag: %s", tag)
|
||||
data = request_lastfm("tag.gettopartists", limit=limit, tag=tag)
|
||||
|
||||
if data and "topartists" in data:
|
||||
artistlist = []
|
||||
artists = data["topartists"]["artist"]
|
||||
logger.debug("Fetched %d artists from Last.FM", len(artists))
|
||||
logger.debug("Fetched %d artists from Last.fm", len(artists))
|
||||
|
||||
for artist in artists:
|
||||
try:
|
||||
@@ -162,4 +169,4 @@ def getTagTopArtists(tag, limit=50):
|
||||
for artistid in artistlist:
|
||||
importer.addArtisttoDB(artistid)
|
||||
|
||||
logger.debug("Added %d new artists from Last.FM", len(artistlist))
|
||||
logger.debug("Added %d new artists from Last.fm", len(artistlist))
|
||||
|
||||
@@ -77,9 +77,9 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
|
||||
if track['ArtistName']:
|
||||
# Make sure deleted files get accounted for when updating artist track counts
|
||||
new_artists.append(track['ArtistName'])
|
||||
myDB.action('DELETE FROM have WHERE Location=?', [Track['Location']])
|
||||
myDB.action('DELETE FROM have WHERE Location=?', [track['Location']])
|
||||
logger.info(
|
||||
f"{Track['Location']} removed from Headphones, as it "
|
||||
f"{track['Location']} removed from Headphones, as it "
|
||||
f"is no longer on disk"
|
||||
)
|
||||
|
||||
|
||||
+5
-11
@@ -14,20 +14,14 @@
|
||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from headphones import logger, db, helpers
|
||||
from collections import OrderedDict
|
||||
|
||||
import musicbrainzngs
|
||||
|
||||
import headphones
|
||||
import musicbrainzngs
|
||||
import headphones.lock
|
||||
from headphones import logger, db, helpers
|
||||
|
||||
try:
|
||||
# pylint:disable=E0611
|
||||
# ignore this error because we are catching the ImportError
|
||||
from collections import OrderedDict
|
||||
# pylint:enable=E0611
|
||||
except ImportError:
|
||||
# Python 2.6.x fallback, from libs
|
||||
from ordereddict import OrderedDict
|
||||
|
||||
mb_lock = headphones.lock.TimedLock(0)
|
||||
|
||||
@@ -97,7 +91,7 @@ def findArtist(name, limit=1):
|
||||
try:
|
||||
artistResults = musicbrainzngs.search_artists(limit=limit, **criteria)['artist-list']
|
||||
except ValueError as e:
|
||||
if "at least one query term is required" in e.message:
|
||||
if "at least one query term is required" in str(e):
|
||||
logger.error(
|
||||
"Tried to search without a term, or an empty one. Provided artist (probably emtpy): %s",
|
||||
name)
|
||||
|
||||
+10
-2
@@ -79,6 +79,7 @@ class Vars:
|
||||
Metadata $variable names (only ones set explicitly by headphones).
|
||||
"""
|
||||
DISC = '$Disc'
|
||||
DISC_TOTAL = '$DiscTotal'
|
||||
TRACK = '$Track'
|
||||
TITLE = '$Title'
|
||||
ARTIST = '$Artist'
|
||||
@@ -171,7 +172,7 @@ def _lower(s):
|
||||
return None
|
||||
|
||||
|
||||
def file_metadata(path, release):
|
||||
def file_metadata(path, release, single_disc_ignore=False):
|
||||
# type: (str,sqlite3.Row)->Tuple[Mapping[str,str],bool]
|
||||
"""
|
||||
Prepare metadata dictionary for path substitution, based on file name,
|
||||
@@ -194,7 +195,13 @@ def file_metadata(path, release):
|
||||
_row_to_dict(release, res)
|
||||
|
||||
date, year = _date_year(release)
|
||||
if not f.disc:
|
||||
|
||||
if not f.disctotal or (f.disctotal == 1 and single_disc_ignore):
|
||||
disc_total = ''
|
||||
else:
|
||||
disc_total = '%d' % f.disctotal
|
||||
|
||||
if not f.disc or (f.disctotal == 1 and single_disc_ignore):
|
||||
disc_number = ''
|
||||
else:
|
||||
disc_number = '%d' % f.disc
|
||||
@@ -226,6 +233,7 @@ def file_metadata(path, release):
|
||||
album_title = release['AlbumTitle']
|
||||
override_values = {
|
||||
Vars.DISC: disc_number,
|
||||
Vars.DISC_TOTAL: disc_total,
|
||||
Vars.TRACK: track_number,
|
||||
Vars.TITLE: title,
|
||||
Vars.ARTIST: artist_name,
|
||||
|
||||
@@ -30,7 +30,6 @@ from . import getXldProfile
|
||||
|
||||
|
||||
def encode(albumPath):
|
||||
print(albumPath)
|
||||
use_xld = headphones.CONFIG.ENCODER == 'xld'
|
||||
|
||||
# Return if xld details not found
|
||||
|
||||
@@ -70,7 +70,8 @@ def sendNZB(nzb):
|
||||
nzbcontent64 = None
|
||||
if nzb.resultType == "nzbdata":
|
||||
data = nzb.extraInfo[0]
|
||||
nzbcontent64 = standard_b64encode(data)
|
||||
# NZBGet needs a string, not bytes
|
||||
nzbcontent64 = standard_b64encode(data).decode("utf-8")
|
||||
|
||||
logger.info("Sending NZB to NZBget")
|
||||
logger.debug("URL: " + url)
|
||||
|
||||
@@ -48,6 +48,8 @@ def checkFolder():
|
||||
single = False
|
||||
if album['Kind'] == 'nzb':
|
||||
download_dir = headphones.CONFIG.DOWNLOAD_DIR
|
||||
elif album['Kind'] == 'bandcamp':
|
||||
download_dir = headphones.CONFIG.BANDCAMP_DIR
|
||||
else:
|
||||
if headphones.CONFIG.DELUGE_DONE_DIRECTORY and headphones.CONFIG.TORRENT_DOWNLOADER == 3:
|
||||
download_dir = headphones.CONFIG.DELUGE_DONE_DIRECTORY
|
||||
@@ -65,7 +67,6 @@ def checkFolder():
|
||||
folder_name = torrent_folder_name
|
||||
|
||||
if folder_name:
|
||||
print(folder_name)
|
||||
album_path = os.path.join(download_dir, folder_name)
|
||||
logger.debug("Checking if %s exists" % album_path)
|
||||
|
||||
@@ -80,7 +81,6 @@ def checkFolder():
|
||||
|
||||
|
||||
def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=False, single=False):
|
||||
print(albumpath)
|
||||
myDB = db.DBConnection()
|
||||
release = myDB.action('SELECT * from albums WHERE AlbumID=?', [albumid]).fetchone()
|
||||
tracks = myDB.select('SELECT * from tracks WHERE AlbumID=?', [albumid])
|
||||
@@ -291,7 +291,7 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
|
||||
logger.debug('Metadata check failed. Verifying filenames...')
|
||||
for downloaded_track in downloaded_track_list:
|
||||
track_name = os.path.splitext(downloaded_track)[0]
|
||||
split_track_name = re.sub('[\.\-\_]', ' ', track_name).lower()
|
||||
split_track_name = re.sub(r'[\.\-\_]', r' ', track_name).lower()
|
||||
for track in tracks:
|
||||
|
||||
if not track['TrackTitle']:
|
||||
@@ -1087,7 +1087,11 @@ def renameFiles(albumpath, downloaded_track_list, release):
|
||||
# Until tagging works better I'm going to rely on the already provided metadata
|
||||
|
||||
for downloaded_track in downloaded_track_list:
|
||||
md, from_metadata = metadata.file_metadata(downloaded_track, release)
|
||||
md, from_metadata = metadata.file_metadata(
|
||||
downloaded_track,
|
||||
release,
|
||||
headphones.CONFIG.RENAME_SINGLE_DISC_IGNORE
|
||||
)
|
||||
if md is None:
|
||||
# unable to parse media file, skip file
|
||||
continue
|
||||
@@ -1169,7 +1173,11 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
|
||||
if headphones.CONFIG.DOWNLOAD_DIR and not dir:
|
||||
download_dirs.append(headphones.CONFIG.DOWNLOAD_DIR)
|
||||
if headphones.CONFIG.DOWNLOAD_TORRENT_DIR and not dir:
|
||||
download_dirs.append(headphones.CONFIG.DOWNLOAD_TORRENT_DIR)
|
||||
download_dirs.append(
|
||||
headphones.CONFIG.DOWNLOAD_TORRENT_DIR.encode(headphones.SYS_ENCODING, 'replace'))
|
||||
if headphones.CONFIG.BANDCAMP and not dir:
|
||||
download_dirs.append(
|
||||
headphones.CONFIG.BANDCAMP_DIR.encode(headphones.SYS_ENCODING, 'replace'))
|
||||
|
||||
# If DOWNLOAD_DIR and DOWNLOAD_TORRENT_DIR are the same, remove the duplicate to prevent us from trying to process the same folder twice.
|
||||
download_dirs = list(set(download_dirs))
|
||||
|
||||
+16
-6
@@ -11,6 +11,7 @@ from bs4 import BeautifulSoup
|
||||
|
||||
import headphones
|
||||
from headphones import logger
|
||||
from headphones.types import Result
|
||||
|
||||
|
||||
class Rutracker(object):
|
||||
@@ -41,19 +42,22 @@ class Rutracker(object):
|
||||
'login_password': headphones.CONFIG.RUTRACKER_PASSWORD,
|
||||
'login': b'\xc2\xf5\xee\xe4' # '%C2%F5%EE%E4'
|
||||
}
|
||||
headers = {
|
||||
'User-Agent' : 'Headphones'
|
||||
}
|
||||
|
||||
logger.info("Attempting to log in to rutracker...")
|
||||
|
||||
try:
|
||||
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False)
|
||||
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False, headers=headers)
|
||||
# try again
|
||||
if not self.has_bb_session_cookie(r):
|
||||
time.sleep(10)
|
||||
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})
|
||||
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False, headers=headers, cookies={'bb_session': headphones.CONFIG.RUTRACKER_COOKIE})
|
||||
else:
|
||||
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False)
|
||||
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False, headers=headers)
|
||||
if self.has_bb_session_cookie(r):
|
||||
self.loggedin = True
|
||||
logger.info("Successfully logged in to rutracker")
|
||||
@@ -112,7 +116,10 @@ class Rutracker(object):
|
||||
Parse the search results and return valid torrent list
|
||||
"""
|
||||
try:
|
||||
headers = {'Referer': self.search_referer}
|
||||
headers = {
|
||||
'Referer': self.search_referer,
|
||||
'User-Agent' : 'Headphones'
|
||||
}
|
||||
r = self.session.get(url=searchurl, headers=headers, timeout=self.timeout)
|
||||
soup = BeautifulSoup(r.content, 'html.parser')
|
||||
|
||||
@@ -160,7 +167,7 @@ class Rutracker(object):
|
||||
torrent_id = dict([part.split('=') for part in urlparse(url)[4].split('&')])[
|
||||
't']
|
||||
topicurl = 'https://rutracker.org/forum/viewtopic.php?t=' + torrent_id
|
||||
rulist.append((title, size, topicurl, 'rutracker.org', 'torrent', True))
|
||||
rulist.append(Result(title, size, url, 'rutracker.org', 'torrent', True))
|
||||
else:
|
||||
logger.info("%s is larger than the maxsize or has too little seeders for this category, "
|
||||
"skipping. (Size: %i bytes, Seeders: %i)" % (title, size, int(seeds)))
|
||||
@@ -182,7 +189,10 @@ class Rutracker(object):
|
||||
downloadurl = 'https://rutracker.org/forum/dl.php?t=' + torrent_id
|
||||
cookie = {'bb_dl': torrent_id}
|
||||
try:
|
||||
headers = {'Referer': url}
|
||||
headers = {
|
||||
'Referer': url,
|
||||
'User-Agent' : 'Headphones'
|
||||
}
|
||||
r = self.session.post(url=downloadurl, cookies=cookie, headers=headers,
|
||||
timeout=self.timeout)
|
||||
return r.content
|
||||
|
||||
+314
-201
@@ -15,8 +15,8 @@
|
||||
|
||||
# NZBGet support added by CurlyMo <curlymoo1@gmail.com> as a part of XBian - XBMC on the Raspberry Pi
|
||||
|
||||
from base64 import b16encode, b32decode
|
||||
from hashlib import sha1
|
||||
import os
|
||||
import re
|
||||
import string
|
||||
import random
|
||||
import urllib.request, urllib.parse, urllib.error
|
||||
@@ -24,19 +24,40 @@ import datetime
|
||||
import subprocess
|
||||
import unicodedata
|
||||
import urllib.parse
|
||||
from base64 import b16encode, b32decode
|
||||
from hashlib import sha1
|
||||
|
||||
import os
|
||||
import re
|
||||
from bencode import encode as bencode
|
||||
from bencode import decode as bdecode
|
||||
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
|
||||
from unidecode import unidecode
|
||||
|
||||
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, qbittorrent
|
||||
from bencode import encode as bencode
|
||||
from bencode import decode as bdecode
|
||||
from headphones.helpers import (
|
||||
bytes_to_mb,
|
||||
has_token,
|
||||
piratesize,
|
||||
replace_all,
|
||||
replace_illegal_chars,
|
||||
sab_replace_dots,
|
||||
sab_replace_spaces,
|
||||
sab_sanitize_foldername,
|
||||
)
|
||||
from headphones.types import Result
|
||||
from headphones import logger, db, classes, sab, nzbget, request
|
||||
from headphones import (
|
||||
bandcamp,
|
||||
deluge,
|
||||
notifiers,
|
||||
qbittorrent,
|
||||
rutracker,
|
||||
transmission,
|
||||
utorrent,
|
||||
)
|
||||
|
||||
# Magnet to torrent services, for Black hole. Stolen from CouchPotato.
|
||||
TORRENT_TO_MAGNET_SERVICES = [
|
||||
@@ -52,6 +73,7 @@ ruobj = None
|
||||
redobj = None
|
||||
|
||||
|
||||
|
||||
def fix_url(s, charset="utf-8"):
|
||||
"""
|
||||
Fix the URL so it is proper formatted and encoded.
|
||||
@@ -77,8 +99,8 @@ def torrent_to_file(target_file, data):
|
||||
fp.write(data)
|
||||
except IOError as e:
|
||||
logger.error(
|
||||
"Could not write torrent file '%s': %s. Skipping.",
|
||||
target_file, e.message)
|
||||
f"Could not write `{target_file}`: {str(e)}"
|
||||
)
|
||||
return
|
||||
|
||||
# Try to change permissions
|
||||
@@ -132,11 +154,11 @@ def calculate_torrent_hash(link, data=None):
|
||||
"""
|
||||
|
||||
if link.startswith("magnet:"):
|
||||
torrent_hash = re.findall("urn:btih:([\w]{32,40})", link)[0]
|
||||
torrent_hash = re.findall(r"urn:btih:([\w]{32,40})", link)[0]
|
||||
if len(torrent_hash) == 32:
|
||||
torrent_hash = b16encode(b32decode(torrent_hash)).lower()
|
||||
elif data:
|
||||
info = bdecode(data)["info"]
|
||||
info = bdecode(data)[b"info"]
|
||||
torrent_hash = sha1(bencode(info)).hexdigest()
|
||||
else:
|
||||
raise ValueError("Cannot calculate torrent hash without magnet link "
|
||||
@@ -279,25 +301,29 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False):
|
||||
[album['AlbumID']])[0][0]
|
||||
|
||||
if headphones.CONFIG.PREFER_TORRENTS == 0 and not choose_specific_download:
|
||||
|
||||
if NZB_PROVIDERS and NZB_DOWNLOADERS:
|
||||
results = searchNZB(album, new, losslessOnly, albumlength)
|
||||
|
||||
if not results and TORRENT_PROVIDERS:
|
||||
results = searchTorrent(album, new, losslessOnly, albumlength)
|
||||
|
||||
elif headphones.CONFIG.PREFER_TORRENTS == 1 and not choose_specific_download:
|
||||
if not results and headphones.CONFIG.BANDCAMP:
|
||||
results = searchBandcamp(album, new, albumlength)
|
||||
|
||||
elif headphones.CONFIG.PREFER_TORRENTS == 1 and not choose_specific_download:
|
||||
if TORRENT_PROVIDERS:
|
||||
results = searchTorrent(album, new, losslessOnly, albumlength)
|
||||
|
||||
if not results and NZB_PROVIDERS and NZB_DOWNLOADERS:
|
||||
results = searchNZB(album, new, losslessOnly, albumlength)
|
||||
|
||||
if not results and headphones.CONFIG.BANDCAMP:
|
||||
results = searchBandcamp(album, new, albumlength)
|
||||
else:
|
||||
|
||||
nzb_results = None
|
||||
torrent_results = None
|
||||
bandcamp_results = None
|
||||
|
||||
if NZB_PROVIDERS and NZB_DOWNLOADERS:
|
||||
nzb_results = searchNZB(album, new, losslessOnly, albumlength, choose_specific_download)
|
||||
@@ -306,19 +332,22 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False):
|
||||
torrent_results = searchTorrent(album, new, losslessOnly, albumlength,
|
||||
choose_specific_download)
|
||||
|
||||
if headphones.CONFIG.BANDCAMP:
|
||||
bandcamp_results = searchBandcamp(album, new, albumlength)
|
||||
|
||||
if not nzb_results:
|
||||
nzb_results = []
|
||||
|
||||
if not torrent_results:
|
||||
torrent_results = []
|
||||
|
||||
results = nzb_results + torrent_results
|
||||
results = nzb_results + torrent_results + bandcamp_results
|
||||
|
||||
if choose_specific_download:
|
||||
return results
|
||||
|
||||
# Filter all results that do not comply
|
||||
results = [result for result in results if result[5]]
|
||||
results = [result for result in results if result.matches]
|
||||
|
||||
# Sort the remaining results
|
||||
sorted_search_results = sort_search_results(results, album, new, albumlength)
|
||||
@@ -326,11 +355,14 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False):
|
||||
if not sorted_search_results:
|
||||
return
|
||||
|
||||
logger.info("Making sure we can download the best result")
|
||||
(data, bestqual) = preprocess(sorted_search_results)
|
||||
logger.info(
|
||||
"Making sure we can download the best result: "
|
||||
f"{sorted_search_results[0].title} from {sorted_search_results[0].provider}"
|
||||
)
|
||||
(data, result) = preprocess(sorted_search_results)
|
||||
|
||||
if data and bestqual:
|
||||
send_to_downloader(data, bestqual, album)
|
||||
if data and result:
|
||||
send_to_downloader(data, result, album)
|
||||
|
||||
|
||||
def more_filtering(results, album, albumlength, new):
|
||||
@@ -366,36 +398,46 @@ def more_filtering(results, album, albumlength, new):
|
||||
|
||||
for result in results:
|
||||
|
||||
if low_size_limit and (int(result[1]) < low_size_limit):
|
||||
if low_size_limit and result.size < low_size_limit:
|
||||
logger.info(
|
||||
"%s from %s is too small for this album - not considering it. (Size: %s, Minsize: %s)",
|
||||
result[0], result[3], helpers.bytes_to_mb(result[1]),
|
||||
helpers.bytes_to_mb(low_size_limit))
|
||||
f"{result.title} from {result.provider} is too small for this album. "
|
||||
f"(Size: {result.size}, MinSize: {helpers.bytes_to_mb(low_size_limit)})"
|
||||
)
|
||||
continue
|
||||
|
||||
if high_size_limit and (int(result[1]) > high_size_limit):
|
||||
if high_size_limit and result.size > high_size_limit:
|
||||
logger.info(
|
||||
"%s from %s is too large for this album - not considering it. (Size: %s, Maxsize: %s)",
|
||||
result[0], result[3], helpers.bytes_to_mb(result[1]),
|
||||
helpers.bytes_to_mb(high_size_limit))
|
||||
|
||||
f"{result.title} from {result.provider} is too large for this album. "
|
||||
f"(Size: {result.size}, MaxSize: {helpers.bytes_to_mb(high_size_limit)})"
|
||||
)
|
||||
# Keep lossless results if there are no good lossy matches
|
||||
if not (allow_lossless and 'flac' in result[0].lower()):
|
||||
if not (allow_lossless and 'flac' in result.title.lower()):
|
||||
continue
|
||||
|
||||
if new:
|
||||
alreadydownloaded = myDB.select('SELECT * from snatched WHERE URL=?', [result[2]])
|
||||
|
||||
alreadydownloaded = myDB.select(
|
||||
"SELECT * from snatched WHERE URL=?", [result.url]
|
||||
)
|
||||
if len(alreadydownloaded):
|
||||
logger.info(
|
||||
'%s has already been downloaded from %s. Skipping.' % (result[0], result[3]))
|
||||
f"{result.title} has already been downloaded from "
|
||||
f"{result.provider}. Skipping."
|
||||
)
|
||||
continue
|
||||
|
||||
newlist.append(result)
|
||||
|
||||
results = newlist
|
||||
return newlist
|
||||
|
||||
return results
|
||||
|
||||
def sort_by_priority_then_size(rs):
|
||||
return list(map(lambda x: x[0],
|
||||
sorted(
|
||||
rs,
|
||||
key=lambda x: (x[0].matches, x[1], x[0].size),
|
||||
reverse=True
|
||||
)
|
||||
))
|
||||
|
||||
|
||||
def sort_search_results(resultlist, album, new, albumlength):
|
||||
@@ -405,84 +447,75 @@ def sort_search_results(resultlist, album, new, albumlength):
|
||||
return None
|
||||
|
||||
# Add a priority if it has any of the preferred words
|
||||
temp_list = []
|
||||
preferred_words = None
|
||||
if headphones.CONFIG.PREFERRED_WORDS:
|
||||
results_with_priority = []
|
||||
preferred_words = helpers.split_string(headphones.CONFIG.PREFERRED_WORDS)
|
||||
for result in resultlist:
|
||||
priority = 0
|
||||
if preferred_words:
|
||||
if any(word.lower() in result[0].lower() for word in preferred_words):
|
||||
priority = 1
|
||||
# add a search provider priority (weighted based on position)
|
||||
i = next((i for i, word in enumerate(preferred_words) if word in result[3].lower()),
|
||||
None)
|
||||
if i is not None:
|
||||
priority += round((len(preferred_words) - i) / float(len(preferred_words)), 2)
|
||||
for word in preferred_words:
|
||||
if word.lower() in [result.title.lower(), result.provider.lower()]:
|
||||
priority += len(preferred_words) - preferred_words.index(word)
|
||||
results_with_priority.append((result, priority))
|
||||
|
||||
temp_list.append((result[0], result[1], result[2], result[3], result[4], priority))
|
||||
|
||||
resultlist = temp_list
|
||||
|
||||
# if headphones.CONFIG.PREFERRED_QUALITY == 2 and headphones.CONFIG.PREFERRED_BITRATE and result[3] != 'Orpheus.network':
|
||||
if headphones.CONFIG.PREFERRED_QUALITY == 2 and headphones.CONFIG.PREFERRED_BITRATE:
|
||||
|
||||
try:
|
||||
targetsize = albumlength / 1000 * int(headphones.CONFIG.PREFERRED_BITRATE) * 128
|
||||
|
||||
if not targetsize:
|
||||
logger.info('No track information for %s - %s. Defaulting to highest quality' % (
|
||||
album['ArtistName'], album['AlbumTitle']))
|
||||
finallist = sorted(resultlist, key=lambda title: (title[5], int(title[1])),
|
||||
reverse=True)
|
||||
logger.info(
|
||||
f"No track information for {album['ArtistName']} - "
|
||||
f"{album['AlbumTitle']}. Defaulting to highest quality"
|
||||
)
|
||||
return sort_by_priority_then_size(results_with_priority)
|
||||
|
||||
else:
|
||||
newlist = []
|
||||
flac_list = []
|
||||
lossy_results_with_delta = []
|
||||
lossless_results = []
|
||||
|
||||
for result in resultlist:
|
||||
for result, priority in results_with_priority:
|
||||
|
||||
# Add lossless results to the "flac list" which we can use if there are no good lossy matches
|
||||
if 'flac' in result[0].lower():
|
||||
flac_list.append(
|
||||
(result[0], result[1], result[2], result[3], result[4], result[5]))
|
||||
continue
|
||||
if 'flac' in result.title.lower():
|
||||
lossless_results.append((result, priority))
|
||||
else:
|
||||
delta = abs(targetsize - result.size)
|
||||
lossy_results_with_delta.append((result, priority, delta))
|
||||
|
||||
delta = abs(targetsize - int(result[1]))
|
||||
newlist.append(
|
||||
(result[0], result[1], result[2], result[3], result[4], result[5], delta))
|
||||
return list(map(lambda x: x[0],
|
||||
sorted(
|
||||
lossy_results_with_delta,
|
||||
key=lambda x: (-x[0].matches, -x[1], x[2])
|
||||
)
|
||||
))
|
||||
|
||||
finallist = sorted(newlist, key=lambda title: (-title[5], title[6]))
|
||||
|
||||
if not len(finallist) and len(
|
||||
flac_list) and headphones.CONFIG.PREFERRED_BITRATE_ALLOW_LOSSLESS:
|
||||
if (
|
||||
not len(lossy_results_with_delta)
|
||||
and len(lossless_results)
|
||||
and headphones.CONFIG.PREFERRED_BITRATE_ALLOW_LOSSLESS
|
||||
):
|
||||
logger.info(
|
||||
"Since there were no appropriate lossy matches (and at least one lossless match), going to use lossless instead")
|
||||
finallist = sorted(flac_list, key=lambda title: (title[5], int(title[1])),
|
||||
reverse=True)
|
||||
"Since there were no appropriate lossy matches "
|
||||
"(and at least one lossless match), going to use "
|
||||
"lossless instead"
|
||||
)
|
||||
return sort_by_priority_then_size(results_with_priority)
|
||||
|
||||
except Exception:
|
||||
logger.exception('Unhandled exception')
|
||||
logger.info('No track information for %s - %s. Defaulting to highest quality',
|
||||
album['ArtistName'], album['AlbumTitle'])
|
||||
|
||||
finallist = sorted(resultlist, key=lambda title: (title[5], int(title[1])),
|
||||
reverse=True)
|
||||
logger.info(
|
||||
f"No track information for {album['ArtistName']} - "
|
||||
f"{album['AlbumTitle']}. Defaulting to highest quality"
|
||||
)
|
||||
return sort_by_priority_then_size(results_with_priority)
|
||||
|
||||
else:
|
||||
return sort_by_priority_then_size(results_with_priority)
|
||||
|
||||
finallist = sorted(resultlist, key=lambda title: (title[5], int(title[1])), reverse=True)
|
||||
|
||||
# keep number of seeders order for Orpheus.network
|
||||
# if result[3] == 'Orpheus.network':
|
||||
# finallist = resultlist
|
||||
|
||||
if not len(finallist):
|
||||
logger.info('No appropriate matches found for %s - %s', album['ArtistName'],
|
||||
album['AlbumTitle'])
|
||||
logger.info(
|
||||
f"No appropriate matches found for {album['ArtistName']} - "
|
||||
f"{album['AlbumTitle']}"
|
||||
)
|
||||
return None
|
||||
|
||||
return finallist
|
||||
|
||||
|
||||
def get_year_from_release_date(release_date):
|
||||
try:
|
||||
@@ -493,16 +526,31 @@ def get_year_from_release_date(release_date):
|
||||
return year
|
||||
|
||||
|
||||
def searchBandcamp(album, new=False, albumlength=None):
|
||||
return bandcamp.search(album)
|
||||
|
||||
|
||||
def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
|
||||
choose_specific_download=False):
|
||||
reldate = album['ReleaseDate']
|
||||
year = get_year_from_release_date(reldate)
|
||||
|
||||
dic = {'...': '', ' & ': ' ', ' = ': ' ', '?': '', '$': 's', ' + ': ' ', '"': '', ',': '',
|
||||
'*': '', '.': '', ':': ''}
|
||||
replacements = {
|
||||
'...': '',
|
||||
' & ': ' ',
|
||||
' = ': ' ',
|
||||
'?': '',
|
||||
'$': 's',
|
||||
' + ': ' ',
|
||||
'"': '',
|
||||
',': '',
|
||||
'*': '',
|
||||
'.': '',
|
||||
':': ''
|
||||
}
|
||||
|
||||
cleanalbum = helpers.latinToAscii(helpers.replace_all(album['AlbumTitle'], dic)).strip()
|
||||
cleanartist = helpers.latinToAscii(helpers.replace_all(album['ArtistName'], dic)).strip()
|
||||
cleanalbum = unidecode(helpers.replace_all(album['AlbumTitle'], replacements)).strip()
|
||||
cleanartist = unidecode(helpers.replace_all(album['ArtistName'], replacements)).strip()
|
||||
|
||||
# Use the provided search term if available, otherwise build a search term
|
||||
if album['SearchTerm']:
|
||||
@@ -522,8 +570,8 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
|
||||
term = cleanartist + ' ' + cleanalbum
|
||||
|
||||
# Replace bad characters in the term
|
||||
term = re.sub('[\.\-\/]', ' ', term)
|
||||
artistterm = re.sub('[\.\-\/]', ' ', cleanartist)
|
||||
term = re.sub(r'[\.\-\/]', r' ', term)
|
||||
artistterm = re.sub(r'[\.\-\/]', r' ', cleanartist)
|
||||
|
||||
# If Preferred Bitrate and High Limit and Allow Lossless then get both lossy and lossless
|
||||
if headphones.CONFIG.PREFERRED_QUALITY == 2 and headphones.CONFIG.PREFERRED_BITRATE and headphones.CONFIG.PREFERRED_BITRATE_HIGH_BUFFER and headphones.CONFIG.PREFERRED_BITRATE_ALLOW_LOSSLESS:
|
||||
@@ -578,7 +626,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
|
||||
title = item.title
|
||||
size = int(item.links[1]['length'])
|
||||
|
||||
resultlist.append((title, size, url, provider, 'nzb', True))
|
||||
resultlist.append(Result(title, size, url, provider, 'nzb', True))
|
||||
logger.info('Found %s. Size: %s' % (title, helpers.bytes_to_mb(size)))
|
||||
except Exception as e:
|
||||
logger.error("An unknown error occurred trying to parse the feed: %s" % e)
|
||||
@@ -651,7 +699,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
|
||||
if all(word.lower() in title.lower() for word in term.split()):
|
||||
logger.info(
|
||||
'Found %s. Size: %s' % (title, helpers.bytes_to_mb(size)))
|
||||
resultlist.append((title, size, url, provider, 'nzb', True))
|
||||
resultlist.append(Result(title, size, url, provider, 'nzb', True))
|
||||
else:
|
||||
logger.info('Skipping %s, not all search term words found' % title)
|
||||
|
||||
@@ -699,7 +747,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
|
||||
title = item.title
|
||||
size = int(item.links[1]['length'])
|
||||
|
||||
resultlist.append((title, size, url, provider, 'nzb', True))
|
||||
resultlist.append(Result(title, size, url, provider, 'nzb', True))
|
||||
logger.info('Found %s. Size: %s' % (title, helpers.bytes_to_mb(size)))
|
||||
except Exception as e:
|
||||
logger.exception("Unhandled exception while parsing feed")
|
||||
@@ -746,7 +794,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
|
||||
title = item['release']
|
||||
size = int(item['sizebytes'])
|
||||
|
||||
resultlist.append((title, size, url, provider, 'nzb', True))
|
||||
resultlist.append(Result(title, size, url, provider, 'nzb', True))
|
||||
logger.info('Found %s. Size: %s', title, helpers.bytes_to_mb(size))
|
||||
except Exception as e:
|
||||
logger.exception("Unhandled exception")
|
||||
@@ -758,7 +806,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
|
||||
# Also will filter flac & remix albums if not specifically looking for it
|
||||
# This code also checks the ignored words and required words
|
||||
results = [result for result in resultlist if
|
||||
verifyresult(result[0], artistterm, term, losslessOnly)]
|
||||
verifyresult(result.title, artistterm, term, losslessOnly)]
|
||||
|
||||
# Additional filtering for size etc
|
||||
if results and not choose_specific_download:
|
||||
@@ -767,16 +815,18 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
|
||||
return results
|
||||
|
||||
|
||||
def send_to_downloader(data, bestqual, album):
|
||||
logger.info('Found best result from %s: <a href="%s">%s</a> - %s', bestqual[3], bestqual[2],
|
||||
bestqual[0], helpers.bytes_to_mb(bestqual[1]))
|
||||
def send_to_downloader(data, result, album):
|
||||
logger.info(
|
||||
f"Found best result from {result.provider}: <a href=\"{result.url}\">"
|
||||
f"{result.title}</a> - {helpers.bytes_to_mb(result.size)}"
|
||||
)
|
||||
# Get rid of any dodgy chars here so we can prevent sab from renaming our downloads
|
||||
kind = bestqual[4]
|
||||
kind = result.kind
|
||||
seed_ratio = None
|
||||
torrentid = None
|
||||
|
||||
if kind == 'nzb':
|
||||
folder_name = helpers.sab_sanitize_foldername(bestqual[0])
|
||||
folder_name = helpers.sab_sanitize_foldername(result.title)
|
||||
|
||||
if headphones.CONFIG.NZB_DOWNLOADER == 1:
|
||||
|
||||
@@ -809,7 +859,7 @@ def send_to_downloader(data, bestqual, album):
|
||||
try:
|
||||
prev = os.umask(headphones.UMASK)
|
||||
|
||||
with open(download_path, 'w') as fp:
|
||||
with open(download_path, 'wb') as fp:
|
||||
fp.write(data)
|
||||
|
||||
os.umask(prev)
|
||||
@@ -817,10 +867,15 @@ def send_to_downloader(data, bestqual, album):
|
||||
except Exception as e:
|
||||
logger.error('Couldn\'t write NZB file: %s', e)
|
||||
return
|
||||
|
||||
elif kind == 'bandcamp':
|
||||
folder_name = bandcamp.download(album, bestqual)
|
||||
logger.info("Setting folder_name to: {}".format(folder_name))
|
||||
|
||||
else:
|
||||
folder_name = '%s - %s [%s]' % (
|
||||
helpers.latinToAscii(album['ArtistName']).replace('/', '_'),
|
||||
helpers.latinToAscii(album['AlbumTitle']).replace('/', '_'),
|
||||
unidecode(album['ArtistName']).replace('/', '_'),
|
||||
unidecode(album['AlbumTitle']).replace('/', '_'),
|
||||
get_year_from_release_date(album['ReleaseDate']))
|
||||
|
||||
# Blackhole
|
||||
@@ -830,26 +885,26 @@ 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)
|
||||
|
||||
if bestqual[2].lower().startswith("magnet:"):
|
||||
if result.url.lower().startswith("magnet:"):
|
||||
if headphones.CONFIG.MAGNET_LINKS == 1:
|
||||
try:
|
||||
if headphones.SYS_PLATFORM == 'win32':
|
||||
os.startfile(bestqual[2])
|
||||
os.startfile(result.url)
|
||||
elif headphones.SYS_PLATFORM == 'darwin':
|
||||
subprocess.Popen(["open", bestqual[2]], stdout=subprocess.PIPE,
|
||||
subprocess.Popen(["open", result.url], stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", bestqual[2]], stdout=subprocess.PIPE,
|
||||
subprocess.Popen(["xdg-open", result.url], stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
|
||||
# Gonna just take a guess at this..... Is there a better way to find this out?
|
||||
folder_name = bestqual[0]
|
||||
folder_name = result.title
|
||||
except Exception as e:
|
||||
logger.error("Error opening magnet link: %s" % str(e))
|
||||
return
|
||||
elif headphones.CONFIG.MAGNET_LINKS == 2:
|
||||
# Procedure adapted from CouchPotato
|
||||
torrent_hash = calculate_torrent_hash(bestqual[2])
|
||||
torrent_hash = calculate_torrent_hash(result.url)
|
||||
|
||||
# Randomize list of services
|
||||
services = TORRENT_TO_MAGNET_SERVICES[:]
|
||||
@@ -863,8 +918,9 @@ def send_to_downloader(data, bestqual, album):
|
||||
if not torrent_to_file(download_path, data):
|
||||
return
|
||||
# Extract folder name from torrent
|
||||
folder_name = read_torrent_name(download_path,
|
||||
bestqual[0])
|
||||
folder_name = read_torrent_name(
|
||||
download_path,
|
||||
result.title)
|
||||
|
||||
# Break for loop
|
||||
break
|
||||
@@ -888,7 +944,7 @@ def send_to_downloader(data, bestqual, album):
|
||||
return
|
||||
|
||||
# Extract folder name from torrent
|
||||
folder_name = read_torrent_name(download_path, bestqual[0])
|
||||
folder_name = read_torrent_name(download_path, result.title)
|
||||
if folder_name:
|
||||
logger.info('Torrent folder name: %s' % folder_name)
|
||||
|
||||
@@ -896,10 +952,10 @@ def send_to_downloader(data, bestqual, album):
|
||||
logger.info("Sending torrent to Transmission")
|
||||
|
||||
# Add torrent
|
||||
if bestqual[3] == 'rutracker.org':
|
||||
if result.provider == 'rutracker.org':
|
||||
torrentid = transmission.addTorrent('', data)
|
||||
else:
|
||||
torrentid = transmission.addTorrent(bestqual[2])
|
||||
torrentid = transmission.addTorrent(result.url)
|
||||
|
||||
if not torrentid:
|
||||
logger.error("Error sending torrent to Transmission. Are you sure it's running?")
|
||||
@@ -913,7 +969,7 @@ def send_to_downloader(data, bestqual, album):
|
||||
return
|
||||
|
||||
# Set Seed Ratio
|
||||
seed_ratio = get_seed_ratio(bestqual[3])
|
||||
seed_ratio = get_seed_ratio(result.provider)
|
||||
if seed_ratio is not None:
|
||||
transmission.setSeedRatio(torrentid, seed_ratio)
|
||||
|
||||
@@ -922,10 +978,10 @@ def send_to_downloader(data, bestqual, album):
|
||||
|
||||
try:
|
||||
# Add torrent
|
||||
if bestqual[3] == 'rutracker.org':
|
||||
if result.provider == 'rutracker.org':
|
||||
torrentid = deluge.addTorrent('', data)
|
||||
else:
|
||||
torrentid = deluge.addTorrent(bestqual[2])
|
||||
torrentid = deluge.addTorrent(result.url)
|
||||
|
||||
if not torrentid:
|
||||
logger.error("Error sending torrent to Deluge. Are you sure it's running? Maybe the torrent already exists?")
|
||||
@@ -940,7 +996,7 @@ def send_to_downloader(data, bestqual, album):
|
||||
deluge.setTorrentLabel({'hash': torrentid})
|
||||
|
||||
# Set Seed Ratio
|
||||
seed_ratio = get_seed_ratio(bestqual[3])
|
||||
seed_ratio = get_seed_ratio(result.provider)
|
||||
if seed_ratio is not None:
|
||||
deluge.setSeedRatio({'hash': torrentid, 'ratio': seed_ratio})
|
||||
|
||||
@@ -963,13 +1019,13 @@ def send_to_downloader(data, bestqual, album):
|
||||
logger.info("Sending torrent to uTorrent")
|
||||
|
||||
# Add torrent
|
||||
if bestqual[3] == 'rutracker.org':
|
||||
if result.provider == 'rutracker.org':
|
||||
ruobj.utorrent_add_file(data)
|
||||
else:
|
||||
utorrent.addTorrent(bestqual[2])
|
||||
utorrent.addTorrent(result.url)
|
||||
|
||||
# Get hash
|
||||
torrentid = calculate_torrent_hash(bestqual[2], data)
|
||||
torrentid = calculate_torrent_hash(result.url, data)
|
||||
if not torrentid:
|
||||
logger.error('Torrent id could not be determined')
|
||||
return
|
||||
@@ -987,23 +1043,23 @@ def send_to_downloader(data, bestqual, album):
|
||||
utorrent.labelTorrent(torrentid)
|
||||
|
||||
# Set Seed Ratio
|
||||
seed_ratio = get_seed_ratio(bestqual[3])
|
||||
seed_ratio = get_seed_ratio(result.provider)
|
||||
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':
|
||||
if result.provider == 'rutracker.org':
|
||||
if qbittorrent.apiVersion2:
|
||||
qbittorrent.addFile(data)
|
||||
else:
|
||||
ruobj.qbittorrent_add_file(data)
|
||||
else:
|
||||
qbittorrent.addTorrent(bestqual[2])
|
||||
qbittorrent.addTorrent(result.url)
|
||||
|
||||
# Get hash
|
||||
torrentid = calculate_torrent_hash(bestqual[2], data)
|
||||
torrentid = calculate_torrent_hash(result.url, data)
|
||||
torrentid = torrentid.lower()
|
||||
if not torrentid:
|
||||
logger.error('Torrent id could not be determined')
|
||||
@@ -1018,29 +1074,49 @@ def send_to_downloader(data, bestqual, album):
|
||||
return
|
||||
|
||||
# Set Seed Ratio
|
||||
seed_ratio = get_seed_ratio(bestqual[3])
|
||||
# Oh my god why is this repeated again for the 100th time
|
||||
seed_ratio = get_seed_ratio(result.provider)
|
||||
if seed_ratio is not None:
|
||||
qbittorrent.setSeedRatio(torrentid, seed_ratio)
|
||||
|
||||
myDB = db.DBConnection()
|
||||
myDB.action('UPDATE albums SET status = "Snatched" WHERE AlbumID=?', [album['AlbumID']])
|
||||
myDB.action('INSERT INTO snatched VALUES( ?, ?, ?, ?, DATETIME("NOW", "localtime"), ?, ?, ?, ?)',
|
||||
[album['AlbumID'], bestqual[0], bestqual[1], bestqual[2], "Snatched", folder_name,
|
||||
kind, torrentid])
|
||||
myDB.action(
|
||||
"INSERT INTO snatched VALUES (?, ?, ?, ?, DATETIME('NOW', 'localtime'), "
|
||||
"?, ?, ?, ?)", [
|
||||
album['AlbumID'],
|
||||
result.title,
|
||||
result.size,
|
||||
result.url,
|
||||
"Snatched",
|
||||
folder_name,
|
||||
kind,
|
||||
torrentid
|
||||
]
|
||||
)
|
||||
|
||||
# Store the torrent id so we can check later if it's finished seeding and can be removed
|
||||
# Additional record for post processing or scheduled job to remove the torrent when finished seeding
|
||||
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", folder_name,
|
||||
kind, torrentid])
|
||||
"INSERT INTO snatched VALUES (?, ?, ?, ?, DATETIME('NOW', 'localtime'), "
|
||||
"?, ?, ?, ?)", [
|
||||
album['AlbumID'],
|
||||
result.title,
|
||||
result.size,
|
||||
result.url,
|
||||
"Seed_Snatched",
|
||||
folder_name,
|
||||
kind,
|
||||
torrentid
|
||||
]
|
||||
)
|
||||
|
||||
# notify
|
||||
artist = album[1]
|
||||
albumname = album[2]
|
||||
rgid = album[6]
|
||||
title = artist + ' - ' + albumname
|
||||
provider = bestqual[3]
|
||||
provider = result.provider
|
||||
if provider.startswith(("http://", "https://")):
|
||||
provider = provider.split("//")[1]
|
||||
name = folder_name if folder_name else None
|
||||
@@ -1113,7 +1189,7 @@ def send_to_downloader(data, bestqual, album):
|
||||
|
||||
|
||||
def verifyresult(title, artistterm, term, lossless):
|
||||
title = re.sub('[\.\-\/\_]', ' ', title)
|
||||
title = re.sub(r'[\.\-\/\_]', r' ', title)
|
||||
|
||||
# if artistterm != 'Various Artists':
|
||||
#
|
||||
@@ -1176,23 +1252,23 @@ def verifyresult(title, artistterm, term, lossless):
|
||||
title, each_word)
|
||||
return False
|
||||
|
||||
tokens = re.split('\W', term, re.IGNORECASE | re.UNICODE)
|
||||
tokens = re.split(r'\W', term, re.IGNORECASE | re.UNICODE)
|
||||
|
||||
for token in tokens:
|
||||
|
||||
if not token:
|
||||
continue
|
||||
if token == 'Various' or token == 'Artists' or token == 'VA':
|
||||
continue
|
||||
if not re.search('(?:\W|^)+' + token + '(?:\W|$)+', title, re.IGNORECASE | re.UNICODE):
|
||||
if not has_token(title, token):
|
||||
cleantoken = ''.join(c for c in token if c not in string.punctuation)
|
||||
if not not re.search('(?:\W|^)+' + cleantoken + '(?:\W|$)+', title,
|
||||
re.IGNORECASE | re.UNICODE):
|
||||
if not has_token(title, cleantoken):
|
||||
dic = {'!': 'i', '$': 's'}
|
||||
dumbtoken = helpers.replace_all(token, dic)
|
||||
if not not re.search('(?:\W|^)+' + dumbtoken + '(?:\W|$)+', title,
|
||||
re.IGNORECASE | re.UNICODE):
|
||||
logger.info(" | ||||