mirror of
https://github.com/rembo10/headphones.git
synced 2026-09-10 00:32:52 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abac9b5a15 | ||
|
|
ace2b4f26a | ||
|
|
ebba115443 | ||
|
|
b54218abbd | ||
|
|
3e354ff128 | ||
|
|
b8168ec8eb | ||
|
|
c0c636d545 | ||
|
|
33d1d17c97 | ||
|
|
8fd13621f5 | ||
|
|
56674adfea | ||
|
|
489c6cbe71 | ||
|
|
6afe31bffc | ||
|
|
138d01db4a |
@@ -1,5 +1,23 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.6.3
|
||||||
|
Released 26 May 2024
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Hotfix for searcher not returning results
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.6.2...v0.6.3).
|
||||||
|
|
||||||
|
## v0.6.2
|
||||||
|
Released 26 May 2024
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Added soulseek support
|
||||||
|
* Added bandcamp support
|
||||||
|
* Changes and dependency updates to work with Python >= 3.12
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.6.1...v0.6.2).
|
||||||
|
|
||||||
## v0.6.1
|
## v0.6.1
|
||||||
R eleased 26 November 2023
|
R eleased 26 November 2023
|
||||||
|
|
||||||
|
|||||||
@@ -327,7 +327,7 @@
|
|||||||
<input type="radio" name="torrent_downloader" id="torrent_downloader_blackhole" value="0" ${config['torrent_downloader_blackhole']}> Black Hole
|
<input type="radio" name="torrent_downloader" id="torrent_downloader_blackhole" value="0" ${config['torrent_downloader_blackhole']}> Black Hole
|
||||||
<input type="radio" name="torrent_downloader" id="torrent_downloader_transmission" value="1" ${config['torrent_downloader_transmission']}> Transmission
|
<input type="radio" name="torrent_downloader" id="torrent_downloader_transmission" value="1" ${config['torrent_downloader_transmission']}> Transmission
|
||||||
<input type="radio" name="torrent_downloader" id="torrent_downloader_utorrent" value="2" ${config['torrent_downloader_utorrent']}> uTorrent (Beta)
|
<input type="radio" name="torrent_downloader" id="torrent_downloader_utorrent" value="2" ${config['torrent_downloader_utorrent']}> uTorrent (Beta)
|
||||||
<input type="radio" name="torrent_downloader" id="torrent_downloader_deluge" value="3" ${config['torrent_downloader_deluge']}> Deluge (Beta)
|
<input type="radio" name="torrent_downloader" id="torrent_downloader_deluge" value="3" ${config['torrent_downloader_deluge']}> Deluge
|
||||||
<input type="radio" name="torrent_downloader" id="torrent_downloader_qbittorrent" value="4" ${config['torrent_downloader_qbittorrent']}> QBitTorrent
|
<input type="radio" name="torrent_downloader" id="torrent_downloader_qbittorrent" value="4" ${config['torrent_downloader_qbittorrent']}> QBitTorrent
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset id="torrent_blackhole_options">
|
<fieldset id="torrent_blackhole_options">
|
||||||
@@ -448,6 +448,11 @@
|
|||||||
<input type="text" name="deluge_label" value="${config['deluge_label']}" size="30">
|
<input type="text" name="deluge_label" value="${config['deluge_label']}" size="30">
|
||||||
<small>Labels shouldn't contain spaces (requires Label plugin)</small>
|
<small>Labels shouldn't contain spaces (requires Label plugin)</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Download Directory</label>
|
||||||
|
<input type="text" name="deluge_download_directory" value="${config['deluge_download_directory']}" size="30">
|
||||||
|
<small>Directory where Deluge should download to</small>
|
||||||
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>Move When Completed</label>
|
<label>Move When Completed</label>
|
||||||
<input type="text" name="deluge_done_directory" value="${config['deluge_done_directory']}" size="30">
|
<input type="text" name="deluge_done_directory" value="${config['deluge_done_directory']}" size="30">
|
||||||
@@ -477,7 +482,33 @@
|
|||||||
<label>Prefer</label>
|
<label>Prefer</label>
|
||||||
<input type="radio" name="prefer_torrents" id="prefer_torrents_0" value="0" ${config['prefer_torrents_0']}>NZBs
|
<input type="radio" name="prefer_torrents" id="prefer_torrents_0" value="0" ${config['prefer_torrents_0']}>NZBs
|
||||||
<input type="radio" name="prefer_torrents" id="prefer_torrents_1" value="1" ${config['prefer_torrents_1']}>Torrents
|
<input type="radio" name="prefer_torrents" id="prefer_torrents_1" value="1" ${config['prefer_torrents_1']}>Torrents
|
||||||
<input type="radio" name="prefer_torrents" id="prefer_torrents_2" value="2" ${config['prefer_torrents_2']}>No Preference
|
<input type="radio" name="prefer_torrents" id="prefer_torrents_2" value="2" ${config['prefer_torrents_2']}>Soulseek
|
||||||
|
<input type="radio" name="prefer_torrents" id="prefer_torrents_3" value="3" ${config['prefer_torrents_3']}>No Preference
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Soulseek</legend>
|
||||||
|
<div class="row">
|
||||||
|
<label>Soulseek API URL</label>
|
||||||
|
<input type="text" name="soulseek_api_url" value="${config['soulseek_api_url']}" size="50">
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Soulseek API KEY</label>
|
||||||
|
<input type="text" name="soulseek_api_key" value="${config['soulseek_api_key']}" size="20">
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label title="Path to folder where Headphones can find the downloads.">
|
||||||
|
Soulseek Download Dir:
|
||||||
|
</label>
|
||||||
|
<input type="text" name="soulseek_download_dir" value="${config['soulseek_download_dir']}" size="50">
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label title="Path to folder where Headphones can find the downloads.">
|
||||||
|
Soulseek Incomplete Download Dir:
|
||||||
|
</label>
|
||||||
|
<input type="text" name="soulseek_incomplete_download_dir" value="${config['soulseek_incomplete_download_dir']}" size="50">
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</td>
|
</td>
|
||||||
@@ -589,7 +620,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Other</legend>
|
<legend>Other</legend>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -597,6 +627,11 @@
|
|||||||
<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>
|
<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>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<div class="row checkbox left">
|
||||||
|
<input id="use_soulseek" type="checkbox" class="bigcheck" name="use_soulseek" value="1" ${config['use_soulseek']} /><label for="use_soulseek"><span class="option">Soulseek</span></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ import re
|
|||||||
|
|
||||||
from headphones import logger, helpers, metadata, request
|
from headphones import logger, helpers, metadata, request
|
||||||
from headphones.common import USER_AGENT
|
from headphones.common import USER_AGENT
|
||||||
|
from headphones.types import Result
|
||||||
|
|
||||||
from mediafile import MediaFile, UnreadableFileError
|
from mediafile import MediaFile, UnreadableFileError
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
from bs4 import FeatureNotFound
|
||||||
|
|
||||||
|
|
||||||
def search(album, albumlength=None, page=1, resultlist=None):
|
def search(album, albumlength=None, page=1, resultlist=None):
|
||||||
@@ -50,7 +52,10 @@ def search(album, albumlength=None, page=1, resultlist=None):
|
|||||||
params=params,
|
params=params,
|
||||||
headers=headers
|
headers=headers
|
||||||
).decode('utf8')
|
).decode('utf8')
|
||||||
|
try:
|
||||||
soup = BeautifulSoup(content, "html5lib")
|
soup = BeautifulSoup(content, "html5lib")
|
||||||
|
except FeatureNotFound:
|
||||||
|
soup = BeautifulSoup(content, "html.parser")
|
||||||
|
|
||||||
for item in soup.find_all("li", class_="searchresult"):
|
for item in soup.find_all("li", class_="searchresult"):
|
||||||
type = item.find('div', class_='itemtype').text.strip().lower()
|
type = item.find('div', class_='itemtype').text.strip().lower()
|
||||||
@@ -66,7 +71,7 @@ def search(album, albumlength=None, page=1, resultlist=None):
|
|||||||
cleanalbum, cleanalbum_found))
|
cleanalbum, cleanalbum_found))
|
||||||
if (cleanartist.lower() == cleanartist_found.lower() and
|
if (cleanartist.lower() == cleanartist_found.lower() and
|
||||||
cleanalbum.lower() == cleanalbum_found.lower()):
|
cleanalbum.lower() == cleanalbum_found.lower()):
|
||||||
resultlist.append((
|
resultlist.append(Result(
|
||||||
data['title'], data['size'], data['url'],
|
data['title'], data['size'], data['url'],
|
||||||
'bandcamp', 'bandcamp', True))
|
'bandcamp', 'bandcamp', True))
|
||||||
else:
|
else:
|
||||||
@@ -82,7 +87,7 @@ def search(album, albumlength=None, page=1, resultlist=None):
|
|||||||
|
|
||||||
|
|
||||||
def download(album, bestqual):
|
def download(album, bestqual):
|
||||||
html = request.request_content(url=bestqual[2]).decode('utf-8')
|
html = request.request_content(url=bestqual.url).decode('utf-8')
|
||||||
trackinfo = []
|
trackinfo = []
|
||||||
try:
|
try:
|
||||||
trackinfo = json.loads(
|
trackinfo = json.loads(
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'DELUGE_PASSWORD': (str, 'Deluge', ''),
|
'DELUGE_PASSWORD': (str, 'Deluge', ''),
|
||||||
'DELUGE_LABEL': (str, 'Deluge', ''),
|
'DELUGE_LABEL': (str, 'Deluge', ''),
|
||||||
'DELUGE_DONE_DIRECTORY': (str, 'Deluge', ''),
|
'DELUGE_DONE_DIRECTORY': (str, 'Deluge', ''),
|
||||||
|
'DELUGE_DOWNLOAD_DIRECTORY': (str, 'Deluge', ''),
|
||||||
'DELUGE_PAUSED': (int, 'Deluge', 0),
|
'DELUGE_PAUSED': (int, 'Deluge', 0),
|
||||||
'DESTINATION_DIR': (str, 'General', ''),
|
'DESTINATION_DIR': (str, 'General', ''),
|
||||||
'DETECT_BITRATE': (int, 'General', 0),
|
'DETECT_BITRATE': (int, 'General', 0),
|
||||||
@@ -269,6 +270,11 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'SONGKICK_ENABLED': (int, 'Songkick', 1),
|
'SONGKICK_ENABLED': (int, 'Songkick', 1),
|
||||||
'SONGKICK_FILTER_ENABLED': (int, 'Songkick', 0),
|
'SONGKICK_FILTER_ENABLED': (int, 'Songkick', 0),
|
||||||
'SONGKICK_LOCATION': (str, 'Songkick', ''),
|
'SONGKICK_LOCATION': (str, 'Songkick', ''),
|
||||||
|
'SOULSEEK_API_URL': (str, 'Soulseek', ''),
|
||||||
|
'SOULSEEK_API_KEY': (str, 'Soulseek', ''),
|
||||||
|
'SOULSEEK_DOWNLOAD_DIR': (str, 'Soulseek', ''),
|
||||||
|
'SOULSEEK_INCOMPLETE_DOWNLOAD_DIR': (str, 'Soulseek', ''),
|
||||||
|
'SOULSEEK': (int, 'Soulseek', 0),
|
||||||
'SUBSONIC_ENABLED': (int, 'Subsonic', 0),
|
'SUBSONIC_ENABLED': (int, 'Subsonic', 0),
|
||||||
'SUBSONIC_HOST': (str, 'Subsonic', ''),
|
'SUBSONIC_HOST': (str, 'Subsonic', ''),
|
||||||
'SUBSONIC_PASSWORD': (str, 'Subsonic', ''),
|
'SUBSONIC_PASSWORD': (str, 'Subsonic', ''),
|
||||||
@@ -318,7 +324,7 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'XBMC_UPDATE': (int, 'XBMC', 0),
|
'XBMC_UPDATE': (int, 'XBMC', 0),
|
||||||
'XBMC_USERNAME': (str, 'XBMC', ''),
|
'XBMC_USERNAME': (str, 'XBMC', ''),
|
||||||
'XLDPROFILE': (str, 'General', ''),
|
'XLDPROFILE': (str, 'General', ''),
|
||||||
'BANDCAMP': (int, 'General', 1),
|
'BANDCAMP': (int, 'General', 0),
|
||||||
'BANDCAMP_DIR': (path, 'General', '')
|
'BANDCAMP_DIR': (path, 'General', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+38
-59
@@ -466,19 +466,56 @@ def _add_torrent_url(result):
|
|||||||
|
|
||||||
def _add_torrent_file(result):
|
def _add_torrent_file(result):
|
||||||
logger.debug('Deluge: Adding file')
|
logger.debug('Deluge: Adding file')
|
||||||
|
|
||||||
|
options = {}
|
||||||
|
|
||||||
|
if headphones.CONFIG.DELUGE_DOWNLOAD_DIRECTORY:
|
||||||
|
options['download_location'] = headphones.CONFIG.DELUGE_DOWNLOAD_DIRECTORY
|
||||||
|
|
||||||
|
if headphones.CONFIG.DELUGE_DONE_DIRECTORY or headphones.CONFIG.DOWNLOAD_TORRENT_DIR:
|
||||||
|
options['move_completed'] = 1
|
||||||
|
if headphones.CONFIG.DELUGE_DONE_DIRECTORY:
|
||||||
|
options['move_completed_path'] = headphones.CONFIG.DELUGE_DONE_DIRECTORY
|
||||||
|
else:
|
||||||
|
options['move_completed_path'] = headphones.CONFIG.DOWNLOAD_TORRENT_DIR
|
||||||
|
|
||||||
|
if headphones.CONFIG.DELUGE_PAUSED:
|
||||||
|
options['add_paused'] = headphones.CONFIG.DELUGE_PAUSED
|
||||||
|
|
||||||
if not any(delugeweb_auth):
|
if not any(delugeweb_auth):
|
||||||
_get_auth()
|
_get_auth()
|
||||||
try:
|
try:
|
||||||
# content is torrent file contents that needs to be encoded to base64
|
# content is torrent file contents that needs to be encoded to base64
|
||||||
post_data = json.dumps({"method": "core.add_torrent_file",
|
post_data = json.dumps({"method": "core.add_torrent_file",
|
||||||
"params": [result['name'] + '.torrent',
|
"params": [result['name'] + '.torrent',
|
||||||
b64encode(result['content']).decode(), {}],
|
b64encode(result['content'].encode('utf8')),
|
||||||
|
options],
|
||||||
"id": 2})
|
"id": 2})
|
||||||
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
|
||||||
verify=deluge_verify_cert, headers=headers)
|
verify=deluge_verify_cert, headers=headers)
|
||||||
result['hash'] = json.loads(response.text)['result']
|
result['hash'] = json.loads(response.text)['result']
|
||||||
logger.debug('Deluge: Response was %s' % str(json.loads(response.text)))
|
logger.debug('Deluge: Response was %s' % str(json.loads(response.text)))
|
||||||
return json.loads(response.text)['result']
|
return json.loads(response.text)['result']
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
try:
|
||||||
|
# content is torrent file contents that needs to be encoded to base64
|
||||||
|
# this time let's try leaving the encoding as is
|
||||||
|
logger.debug('Deluge: There was a decoding issue, let\'s try again')
|
||||||
|
post_data = json.dumps({"method": "core.add_torrent_file",
|
||||||
|
"params": [result['name'].decode('utf8') + '.torrent',
|
||||||
|
b64encode(result['content']),
|
||||||
|
options],
|
||||||
|
"id": 22})
|
||||||
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
|
||||||
|
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']
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Deluge: Adding torrent file failed after decode: %s' % str(e))
|
||||||
|
formatted_lines = traceback.format_exc().splitlines()
|
||||||
|
logger.error('; '.join(formatted_lines))
|
||||||
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error('Deluge: Adding torrent file failed: %s' % str(e))
|
logger.error('Deluge: Adding torrent file failed: %s' % str(e))
|
||||||
formatted_lines = traceback.format_exc().splitlines()
|
formatted_lines = traceback.format_exc().splitlines()
|
||||||
@@ -566,61 +603,3 @@ def setSeedRatio(result):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def setTorrentPath(result):
|
|
||||||
logger.debug('Deluge: Setting download path')
|
|
||||||
if not any(delugeweb_auth):
|
|
||||||
_get_auth()
|
|
||||||
|
|
||||||
try:
|
|
||||||
if headphones.CONFIG.DELUGE_DONE_DIRECTORY or headphones.CONFIG.DOWNLOAD_TORRENT_DIR:
|
|
||||||
post_data = json.dumps({"method": "core.set_torrent_move_completed",
|
|
||||||
"params": [result['hash'], True],
|
|
||||||
"id": 7})
|
|
||||||
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
|
|
||||||
verify=deluge_verify_cert, headers=headers)
|
|
||||||
|
|
||||||
if headphones.CONFIG.DELUGE_DONE_DIRECTORY:
|
|
||||||
move_to = headphones.CONFIG.DELUGE_DONE_DIRECTORY
|
|
||||||
else:
|
|
||||||
move_to = headphones.CONFIG.DOWNLOAD_TORRENT_DIR
|
|
||||||
|
|
||||||
if not os.path.exists(move_to):
|
|
||||||
logger.debug('Deluge: %s directory doesn\'t exist, let\'s create it' % move_to)
|
|
||||||
os.makedirs(move_to)
|
|
||||||
post_data = json.dumps({"method": "core.set_torrent_move_completed_path",
|
|
||||||
"params": [result['hash'], move_to],
|
|
||||||
"id": 8})
|
|
||||||
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
|
|
||||||
verify=deluge_verify_cert, headers=headers)
|
|
||||||
|
|
||||||
return not json.loads(response.text)['error']
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Deluge: Setting torrent move-to directory failed: %s' % str(e))
|
|
||||||
formatted_lines = traceback.format_exc().splitlines()
|
|
||||||
logger.error('; '.join(formatted_lines))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def setTorrentPause(result):
|
|
||||||
logger.debug('Deluge: Pausing torrent')
|
|
||||||
if not any(delugeweb_auth):
|
|
||||||
_get_auth()
|
|
||||||
|
|
||||||
try:
|
|
||||||
if headphones.CONFIG.DELUGE_PAUSED:
|
|
||||||
post_data = json.dumps({"method": "core.pause_torrent",
|
|
||||||
"params": [[result['hash']]],
|
|
||||||
"id": 9})
|
|
||||||
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
|
|
||||||
verify=deluge_verify_cert, headers=headers)
|
|
||||||
|
|
||||||
return not json.loads(response.text)['error']
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Deluge: Setting torrent paused failed: %s' % str(e))
|
|
||||||
formatted_lines = traceback.format_exc().splitlines()
|
|
||||||
logger.error('; '.join(formatted_lines))
|
|
||||||
return None
|
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ def bytes_to_mb(bytes):
|
|||||||
|
|
||||||
|
|
||||||
def mb_to_bytes(mb_str):
|
def mb_to_bytes(mb_str):
|
||||||
result = re.search('^(\d+(?:\.\d+)?)\s?(?:mb)?', mb_str, flags=re.I)
|
result = re.search(r"^(\d+(?:\.\d+)?)\s?(?:mb)?", mb_str, flags=re.I)
|
||||||
if result:
|
if result:
|
||||||
return int(float(result.group(1)) * 1048576)
|
return int(float(result.group(1)) * 1048576)
|
||||||
|
|
||||||
@@ -253,9 +253,9 @@ def replace_all(text, dic):
|
|||||||
|
|
||||||
def replace_illegal_chars(string, type="file"):
|
def replace_illegal_chars(string, type="file"):
|
||||||
if type == "file":
|
if type == "file":
|
||||||
string = re.sub('[\?"*:|<>/]', '_', string)
|
string = re.sub(r"[\?\"*:|<>/]", "_", string)
|
||||||
if type == "folder":
|
if type == "folder":
|
||||||
string = re.sub('[:\?<>"|*]', '_', string)
|
string = re.sub(r"[:\?<>\"|*]", "_", string)
|
||||||
return string
|
return string
|
||||||
|
|
||||||
|
|
||||||
@@ -386,7 +386,7 @@ def clean_musicbrainz_name(s, return_as_string=True):
|
|||||||
|
|
||||||
|
|
||||||
def cleanTitle(title):
|
def cleanTitle(title):
|
||||||
title = re.sub('[\.\-\/\_]', ' ', title).lower()
|
title = re.sub(r"[\.\-\/\_]", " ", title).lower()
|
||||||
|
|
||||||
# Strip out extra whitespace
|
# Strip out extra whitespace
|
||||||
title = ' '.join(title.split())
|
title = ' '.join(title.split())
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from beets import config as beetsconfig
|
|||||||
from beets import logging as beetslogging
|
from beets import logging as beetslogging
|
||||||
from mediafile import MediaFile, FileTypeError, UnreadableFileError
|
from mediafile import MediaFile, FileTypeError, UnreadableFileError
|
||||||
from beetsplug import lyrics as beetslyrics
|
from beetsplug import lyrics as beetslyrics
|
||||||
from headphones import notifiers, utorrent, transmission, deluge, qbittorrent
|
from headphones import notifiers, utorrent, transmission, deluge, qbittorrent, soulseek
|
||||||
from headphones import db, albumart, librarysync
|
from headphones import db, albumart, librarysync
|
||||||
from headphones import logger, helpers, mb, music_encoder
|
from headphones import logger, helpers, mb, music_encoder
|
||||||
from headphones import metadata
|
from headphones import metadata
|
||||||
@@ -36,17 +36,41 @@ postprocessor_lock = threading.Lock()
|
|||||||
|
|
||||||
|
|
||||||
def checkFolder():
|
def checkFolder():
|
||||||
logger.debug("Checking download folder for completed downloads (only snatched ones).")
|
logger.info("Checking download folder for completed downloads (only snatched ones).")
|
||||||
|
|
||||||
with postprocessor_lock:
|
with postprocessor_lock:
|
||||||
myDB = db.DBConnection()
|
myDB = db.DBConnection()
|
||||||
snatched = myDB.select('SELECT * from snatched WHERE Status="Snatched"')
|
snatched = myDB.select('SELECT * from snatched WHERE Status="Snatched"')
|
||||||
|
|
||||||
|
# If soulseek is used, this part will get the status from the soulseek api and return completed and errored albums
|
||||||
|
completed_albums, errored_albums = set(), set()
|
||||||
|
if any(album['Kind'] == 'soulseek' for album in snatched):
|
||||||
|
completed_albums, errored_albums = soulseek.download_completed()
|
||||||
|
|
||||||
for album in snatched:
|
for album in snatched:
|
||||||
if album['FolderName']:
|
if album['FolderName']:
|
||||||
folder_name = album['FolderName']
|
folder_name = album['FolderName']
|
||||||
single = False
|
single = False
|
||||||
if album['Kind'] == 'nzb':
|
if album['Kind'] == 'soulseek':
|
||||||
|
if folder_name in errored_albums:
|
||||||
|
# If the album had any tracks with errors in it, the whole download is considered faulty. Status will be reset to wanted.
|
||||||
|
logger.info(f"Album with folder '{folder_name}' had errors during download. Setting status to 'Wanted'.")
|
||||||
|
myDB.action('UPDATE albums SET Status="Wanted" WHERE AlbumID=? AND Status="Snatched"', (album['AlbumID'],))
|
||||||
|
|
||||||
|
# Folder will be removed from configured complete and Incomplete directory
|
||||||
|
complete_path = os.path.join(headphones.CONFIG.SOULSEEK_DOWNLOAD_DIR, folder_name)
|
||||||
|
incomplete_path = os.path.join(headphones.CONFIG.SOULSEEK_INCOMPLETE_DOWNLOAD_DIR, folder_name)
|
||||||
|
for path in [complete_path, incomplete_path]:
|
||||||
|
try:
|
||||||
|
shutil.rmtree(path)
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
elif folder_name in completed_albums:
|
||||||
|
download_dir = headphones.CONFIG.SOULSEEK_DOWNLOAD_DIR
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
elif album['Kind'] == 'nzb':
|
||||||
download_dir = headphones.CONFIG.DOWNLOAD_DIR
|
download_dir = headphones.CONFIG.DOWNLOAD_DIR
|
||||||
elif album['Kind'] == 'bandcamp':
|
elif album['Kind'] == 'bandcamp':
|
||||||
download_dir = headphones.CONFIG.BANDCAMP_DIR
|
download_dir = headphones.CONFIG.BANDCAMP_DIR
|
||||||
@@ -1172,6 +1196,8 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
|
|||||||
download_dirs.append(dir)
|
download_dirs.append(dir)
|
||||||
if headphones.CONFIG.DOWNLOAD_DIR and not dir:
|
if headphones.CONFIG.DOWNLOAD_DIR and not dir:
|
||||||
download_dirs.append(headphones.CONFIG.DOWNLOAD_DIR)
|
download_dirs.append(headphones.CONFIG.DOWNLOAD_DIR)
|
||||||
|
if headphones.CONFIG.SOULSEEK_DOWNLOAD_DIR and not dir:
|
||||||
|
download_dirs.append(headphones.CONFIG.SOULSEEK_DOWNLOAD_DIR)
|
||||||
if headphones.CONFIG.DOWNLOAD_TORRENT_DIR and not dir:
|
if headphones.CONFIG.DOWNLOAD_TORRENT_DIR and not dir:
|
||||||
download_dirs.append(
|
download_dirs.append(
|
||||||
headphones.CONFIG.DOWNLOAD_TORRENT_DIR.encode(headphones.SYS_ENCODING, 'replace'))
|
headphones.CONFIG.DOWNLOAD_TORRENT_DIR.encode(headphones.SYS_ENCODING, 'replace'))
|
||||||
|
|||||||
+95
-60
@@ -46,6 +46,7 @@ from headphones.helpers import (
|
|||||||
sab_replace_dots,
|
sab_replace_dots,
|
||||||
sab_replace_spaces,
|
sab_replace_spaces,
|
||||||
sab_sanitize_foldername,
|
sab_sanitize_foldername,
|
||||||
|
split_string
|
||||||
)
|
)
|
||||||
from headphones.types import Result
|
from headphones.types import Result
|
||||||
from headphones import logger, db, classes, sab, nzbget, request
|
from headphones import logger, db, classes, sab, nzbget, request
|
||||||
@@ -55,8 +56,9 @@ from headphones import (
|
|||||||
notifiers,
|
notifiers,
|
||||||
qbittorrent,
|
qbittorrent,
|
||||||
rutracker,
|
rutracker,
|
||||||
|
soulseek,
|
||||||
transmission,
|
transmission,
|
||||||
utorrent,
|
utorrent
|
||||||
)
|
)
|
||||||
|
|
||||||
# Magnet to torrent services, for Black hole. Stolen from CouchPotato.
|
# Magnet to torrent services, for Black hole. Stolen from CouchPotato.
|
||||||
@@ -278,6 +280,8 @@ def strptime_musicbrainz(date_str):
|
|||||||
|
|
||||||
|
|
||||||
def do_sorted_search(album, new, losslessOnly, choose_specific_download=False):
|
def do_sorted_search(album, new, losslessOnly, choose_specific_download=False):
|
||||||
|
|
||||||
|
|
||||||
NZB_PROVIDERS = (headphones.CONFIG.HEADPHONES_INDEXER or
|
NZB_PROVIDERS = (headphones.CONFIG.HEADPHONES_INDEXER or
|
||||||
headphones.CONFIG.NEWZNAB or
|
headphones.CONFIG.NEWZNAB or
|
||||||
headphones.CONFIG.NZBSORG or
|
headphones.CONFIG.NZBSORG or
|
||||||
@@ -319,28 +323,27 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False):
|
|||||||
|
|
||||||
if not results and headphones.CONFIG.BANDCAMP:
|
if not results and headphones.CONFIG.BANDCAMP:
|
||||||
results = searchBandcamp(album, new, albumlength)
|
results = searchBandcamp(album, new, albumlength)
|
||||||
|
|
||||||
|
elif headphones.CONFIG.PREFER_TORRENTS == 2 and not choose_specific_download:
|
||||||
|
results = searchSoulseek(album, new, losslessOnly, albumlength)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
||||||
nzb_results = None
|
nzb_results = []
|
||||||
torrent_results = None
|
torrent_results = []
|
||||||
bandcamp_results = None
|
bandcamp_results = []
|
||||||
|
|
||||||
if NZB_PROVIDERS and NZB_DOWNLOADERS:
|
if NZB_PROVIDERS and NZB_DOWNLOADERS:
|
||||||
nzb_results = searchNZB(album, new, losslessOnly, albumlength, choose_specific_download)
|
nzb_results = searchNZB(album, new, losslessOnly,
|
||||||
|
albumlength, choose_specific_download)
|
||||||
|
|
||||||
if TORRENT_PROVIDERS:
|
if TORRENT_PROVIDERS:
|
||||||
torrent_results = searchTorrent(album, new, losslessOnly, albumlength,
|
torrent_results = searchTorrent(album, new, losslessOnly,
|
||||||
choose_specific_download)
|
albumlength, choose_specific_download)
|
||||||
|
|
||||||
if headphones.CONFIG.BANDCAMP:
|
if headphones.CONFIG.BANDCAMP:
|
||||||
bandcamp_results = searchBandcamp(album, new, albumlength)
|
bandcamp_results = searchBandcamp(album, new, albumlength)
|
||||||
|
|
||||||
if not nzb_results:
|
|
||||||
nzb_results = []
|
|
||||||
|
|
||||||
if not torrent_results:
|
|
||||||
torrent_results = []
|
|
||||||
|
|
||||||
results = nzb_results + torrent_results + bandcamp_results
|
results = nzb_results + torrent_results + bandcamp_results
|
||||||
|
|
||||||
if choose_specific_download:
|
if choose_specific_download:
|
||||||
@@ -384,7 +387,7 @@ def more_filtering(results, album, albumlength, new):
|
|||||||
logger.debug('Target bitrate: %s kbps' % headphones.CONFIG.PREFERRED_BITRATE)
|
logger.debug('Target bitrate: %s kbps' % headphones.CONFIG.PREFERRED_BITRATE)
|
||||||
if albumlength:
|
if albumlength:
|
||||||
targetsize = albumlength / 1000 * int(headphones.CONFIG.PREFERRED_BITRATE) * 128
|
targetsize = albumlength / 1000 * int(headphones.CONFIG.PREFERRED_BITRATE) * 128
|
||||||
logger.info('Target size: %s' % helpers.bytes_to_mb(targetsize))
|
logger.info('Target size: %s' % bytes_to_mb(targetsize))
|
||||||
if headphones.CONFIG.PREFERRED_BITRATE_LOW_BUFFER:
|
if headphones.CONFIG.PREFERRED_BITRATE_LOW_BUFFER:
|
||||||
low_size_limit = targetsize * int(
|
low_size_limit = targetsize * int(
|
||||||
headphones.CONFIG.PREFERRED_BITRATE_LOW_BUFFER) / 100
|
headphones.CONFIG.PREFERRED_BITRATE_LOW_BUFFER) / 100
|
||||||
@@ -401,14 +404,14 @@ def more_filtering(results, album, albumlength, new):
|
|||||||
if low_size_limit and result.size < low_size_limit:
|
if low_size_limit and result.size < low_size_limit:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"{result.title} from {result.provider} is too small for this album. "
|
f"{result.title} from {result.provider} is too small for this album. "
|
||||||
f"(Size: {result.size}, MinSize: {helpers.bytes_to_mb(low_size_limit)})"
|
f"(Size: {result.size}, MinSize: {bytes_to_mb(low_size_limit)})"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if high_size_limit and result.size > high_size_limit:
|
if high_size_limit and result.size > high_size_limit:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"{result.title} from {result.provider} is too large for this album. "
|
f"{result.title} from {result.provider} is too large for this album. "
|
||||||
f"(Size: {result.size}, MaxSize: {helpers.bytes_to_mb(high_size_limit)})"
|
f"(Size: {result.size}, MaxSize: {bytes_to_mb(high_size_limit)})"
|
||||||
)
|
)
|
||||||
# Keep lossless results if there are no good lossy matches
|
# Keep lossless results if there are no good lossy matches
|
||||||
if not (allow_lossless and 'flac' in result.title.lower()):
|
if not (allow_lossless and 'flac' in result.title.lower()):
|
||||||
@@ -448,7 +451,7 @@ def sort_search_results(resultlist, album, new, albumlength):
|
|||||||
|
|
||||||
# Add a priority if it has any of the preferred words
|
# Add a priority if it has any of the preferred words
|
||||||
results_with_priority = []
|
results_with_priority = []
|
||||||
preferred_words = helpers.split_string(headphones.CONFIG.PREFERRED_WORDS)
|
preferred_words = split_string(headphones.CONFIG.PREFERRED_WORDS)
|
||||||
for result in resultlist:
|
for result in resultlist:
|
||||||
priority = 0
|
priority = 0
|
||||||
for word in preferred_words:
|
for word in preferred_words:
|
||||||
@@ -549,8 +552,8 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
|
|||||||
':': ''
|
':': ''
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanalbum = unidecode(helpers.replace_all(album['AlbumTitle'], replacements)).strip()
|
cleanalbum = unidecode(replace_all(album['AlbumTitle'], replacements)).strip()
|
||||||
cleanartist = unidecode(helpers.replace_all(album['ArtistName'], replacements)).strip()
|
cleanartist = unidecode(replace_all(album['ArtistName'], replacements)).strip()
|
||||||
|
|
||||||
# Use the provided search term if available, otherwise build a search term
|
# Use the provided search term if available, otherwise build a search term
|
||||||
if album['SearchTerm']:
|
if album['SearchTerm']:
|
||||||
@@ -627,7 +630,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
|
|||||||
size = int(item.links[1]['length'])
|
size = int(item.links[1]['length'])
|
||||||
|
|
||||||
resultlist.append(Result(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)))
|
logger.info('Found %s. Size: %s' % (title, bytes_to_mb(size)))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("An unknown error occurred trying to parse the feed: %s" % e)
|
logger.error("An unknown error occurred trying to parse the feed: %s" % e)
|
||||||
|
|
||||||
@@ -698,7 +701,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
|
|||||||
size = int(item.links[1]['length'])
|
size = int(item.links[1]['length'])
|
||||||
if all(word.lower() in title.lower() for word in term.split()):
|
if all(word.lower() in title.lower() for word in term.split()):
|
||||||
logger.info(
|
logger.info(
|
||||||
'Found %s. Size: %s' % (title, helpers.bytes_to_mb(size)))
|
'Found %s. Size: %s' % (title, bytes_to_mb(size)))
|
||||||
resultlist.append(Result(title, size, url, provider, 'nzb', True))
|
resultlist.append(Result(title, size, url, provider, 'nzb', True))
|
||||||
else:
|
else:
|
||||||
logger.info('Skipping %s, not all search term words found' % title)
|
logger.info('Skipping %s, not all search term words found' % title)
|
||||||
@@ -748,7 +751,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
|
|||||||
size = int(item.links[1]['length'])
|
size = int(item.links[1]['length'])
|
||||||
|
|
||||||
resultlist.append(Result(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)))
|
logger.info('Found %s. Size: %s' % (title, bytes_to_mb(size)))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Unhandled exception while parsing feed")
|
logger.exception("Unhandled exception while parsing feed")
|
||||||
|
|
||||||
@@ -795,7 +798,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
|
|||||||
size = int(item['sizebytes'])
|
size = int(item['sizebytes'])
|
||||||
|
|
||||||
resultlist.append(Result(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))
|
logger.info('Found %s. Size: %s', title, bytes_to_mb(size))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Unhandled exception")
|
logger.exception("Unhandled exception")
|
||||||
|
|
||||||
@@ -818,7 +821,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
|
|||||||
def send_to_downloader(data, result, album):
|
def send_to_downloader(data, result, album):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Found best result from {result.provider}: <a href=\"{result.url}\">"
|
f"Found best result from {result.provider}: <a href=\"{result.url}\">"
|
||||||
f"{result.title}</a> - {helpers.bytes_to_mb(result.size)}"
|
f"{result.title}</a> - {bytes_to_mb(result.size)}"
|
||||||
)
|
)
|
||||||
# Get rid of any dodgy chars here so we can prevent sab from renaming our downloads
|
# Get rid of any dodgy chars here so we can prevent sab from renaming our downloads
|
||||||
kind = result.kind
|
kind = result.kind
|
||||||
@@ -826,7 +829,7 @@ def send_to_downloader(data, result, album):
|
|||||||
torrentid = None
|
torrentid = None
|
||||||
|
|
||||||
if kind == 'nzb':
|
if kind == 'nzb':
|
||||||
folder_name = helpers.sab_sanitize_foldername(result.title)
|
folder_name = sab_sanitize_foldername(result.title)
|
||||||
|
|
||||||
if headphones.CONFIG.NZB_DOWNLOADER == 1:
|
if headphones.CONFIG.NZB_DOWNLOADER == 1:
|
||||||
|
|
||||||
@@ -848,9 +851,9 @@ def send_to_downloader(data, result, album):
|
|||||||
(replace_spaces, replace_dots) = sab.checkConfig()
|
(replace_spaces, replace_dots) = sab.checkConfig()
|
||||||
|
|
||||||
if replace_dots:
|
if replace_dots:
|
||||||
folder_name = helpers.sab_replace_dots(folder_name)
|
folder_name = sab_replace_dots(folder_name)
|
||||||
if replace_spaces:
|
if replace_spaces:
|
||||||
folder_name = helpers.sab_replace_spaces(folder_name)
|
folder_name = sab_replace_spaces(folder_name)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
nzb_name = folder_name + '.nzb'
|
nzb_name = folder_name + '.nzb'
|
||||||
@@ -869,9 +872,13 @@ def send_to_downloader(data, result, album):
|
|||||||
return
|
return
|
||||||
|
|
||||||
elif kind == 'bandcamp':
|
elif kind == 'bandcamp':
|
||||||
folder_name = bandcamp.download(album, bestqual)
|
folder_name = bandcamp.download(album, result)
|
||||||
logger.info("Setting folder_name to: {}".format(folder_name))
|
logger.info("Setting folder_name to: {}".format(folder_name))
|
||||||
|
|
||||||
|
elif kind == 'soulseek':
|
||||||
|
soulseek.download(user=result.user, filelist=result.files)
|
||||||
|
folder_name = result.folder
|
||||||
|
|
||||||
else:
|
else:
|
||||||
folder_name = '%s - %s [%s]' % (
|
folder_name = '%s - %s [%s]' % (
|
||||||
unidecode(album['ArtistName']).replace('/', '_'),
|
unidecode(album['ArtistName']).replace('/', '_'),
|
||||||
@@ -882,7 +889,7 @@ def send_to_downloader(data, result, album):
|
|||||||
if headphones.CONFIG.TORRENT_DOWNLOADER == 0:
|
if headphones.CONFIG.TORRENT_DOWNLOADER == 0:
|
||||||
|
|
||||||
# Get torrent name from .torrent, this is usually used by the torrent client as the folder name
|
# Get torrent name from .torrent, this is usually used by the torrent client as the folder name
|
||||||
torrent_name = helpers.replace_illegal_chars(folder_name) + '.torrent'
|
torrent_name = replace_illegal_chars(folder_name) + '.torrent'
|
||||||
download_path = os.path.join(headphones.CONFIG.TORRENTBLACKHOLE_DIR, torrent_name)
|
download_path = os.path.join(headphones.CONFIG.TORRENTBLACKHOLE_DIR, torrent_name)
|
||||||
|
|
||||||
if result.url.lower().startswith("magnet:"):
|
if result.url.lower().startswith("magnet:"):
|
||||||
@@ -987,10 +994,6 @@ def send_to_downloader(data, result, album):
|
|||||||
logger.error("Error sending torrent to Deluge. Are you sure it's running? Maybe the torrent already exists?")
|
logger.error("Error sending torrent to Deluge. Are you sure it's running? Maybe the torrent already exists?")
|
||||||
return
|
return
|
||||||
|
|
||||||
# This pauses the torrent right after it is added
|
|
||||||
if headphones.CONFIG.DELUGE_PAUSED:
|
|
||||||
deluge.setTorrentPause({'hash': torrentid})
|
|
||||||
|
|
||||||
# Set Label
|
# Set Label
|
||||||
if headphones.CONFIG.DELUGE_LABEL:
|
if headphones.CONFIG.DELUGE_LABEL:
|
||||||
deluge.setTorrentLabel({'hash': torrentid})
|
deluge.setTorrentLabel({'hash': torrentid})
|
||||||
@@ -1000,10 +1003,6 @@ def send_to_downloader(data, result, album):
|
|||||||
if seed_ratio is not None:
|
if seed_ratio is not None:
|
||||||
deluge.setSeedRatio({'hash': torrentid, 'ratio': seed_ratio})
|
deluge.setSeedRatio({'hash': torrentid, 'ratio': seed_ratio})
|
||||||
|
|
||||||
# Set move-to directory
|
|
||||||
if headphones.CONFIG.DELUGE_DONE_DIRECTORY or headphones.CONFIG.DOWNLOAD_TORRENT_DIR:
|
|
||||||
deluge.setTorrentPath({'hash': torrentid})
|
|
||||||
|
|
||||||
# Get folder name from Deluge, it's usually the torrent name
|
# Get folder name from Deluge, it's usually the torrent name
|
||||||
folder_name = deluge.getTorrentFolder({'hash': torrentid})
|
folder_name = deluge.getTorrentFolder({'hash': torrentid})
|
||||||
if folder_name:
|
if folder_name:
|
||||||
@@ -1221,16 +1220,16 @@ def verifyresult(title, artistterm, term, lossless):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
if headphones.CONFIG.IGNORED_WORDS:
|
if headphones.CONFIG.IGNORED_WORDS:
|
||||||
for each_word in helpers.split_string(headphones.CONFIG.IGNORED_WORDS):
|
for each_word in split_string(headphones.CONFIG.IGNORED_WORDS):
|
||||||
if each_word.lower() in title.lower():
|
if each_word.lower() in title.lower():
|
||||||
logger.info("Removed '%s' from results because it contains ignored word: '%s'",
|
logger.info("Removed '%s' from results because it contains ignored word: '%s'",
|
||||||
title, each_word)
|
title, each_word)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if headphones.CONFIG.REQUIRED_WORDS:
|
if headphones.CONFIG.REQUIRED_WORDS:
|
||||||
for each_word in helpers.split_string(headphones.CONFIG.REQUIRED_WORDS):
|
for each_word in split_string(headphones.CONFIG.REQUIRED_WORDS):
|
||||||
if ' OR ' in each_word:
|
if ' OR ' in each_word:
|
||||||
or_words = helpers.split_string(each_word, 'OR')
|
or_words = split_string(each_word, 'OR')
|
||||||
if any(word.lower() in title.lower() for word in or_words):
|
if any(word.lower() in title.lower() for word in or_words):
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
@@ -1264,7 +1263,7 @@ def verifyresult(title, artistterm, term, lossless):
|
|||||||
cleantoken = ''.join(c for c in token if c not in string.punctuation)
|
cleantoken = ''.join(c for c in token if c not in string.punctuation)
|
||||||
if not has_token(title, cleantoken):
|
if not has_token(title, cleantoken):
|
||||||
dic = {'!': 'i', '$': 's'}
|
dic = {'!': 'i', '$': 's'}
|
||||||
dumbtoken = helpers.replace_all(token, dic)
|
dumbtoken = replace_all(token, dic)
|
||||||
if not has_token(title, dumbtoken):
|
if not has_token(title, dumbtoken):
|
||||||
logger.info(
|
logger.info(
|
||||||
"Removed from results: %s (missing tokens: [%s, %s, %s])",
|
"Removed from results: %s (missing tokens: [%s, %s, %s])",
|
||||||
@@ -1297,9 +1296,9 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
|
|||||||
'*': ''
|
'*': ''
|
||||||
}
|
}
|
||||||
|
|
||||||
semi_cleanalbum = helpers.replace_all(album['AlbumTitle'], replacements)
|
semi_cleanalbum = replace_all(album['AlbumTitle'], replacements)
|
||||||
cleanalbum = unidecode(semi_cleanalbum)
|
cleanalbum = unidecode(semi_cleanalbum)
|
||||||
semi_cleanartist = helpers.replace_all(album['ArtistName'], replacements)
|
semi_cleanartist = replace_all(album['ArtistName'], replacements)
|
||||||
cleanartist = unidecode(semi_cleanartist)
|
cleanartist = unidecode(semi_cleanartist)
|
||||||
|
|
||||||
# Use provided term if available, otherwise build our own (this code needs to be cleaned up since a lot
|
# Use provided term if available, otherwise build our own (this code needs to be cleaned up since a lot
|
||||||
@@ -1434,7 +1433,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
|
|||||||
|
|
||||||
if all(word.lower() in title.lower() for word in term.split()):
|
if all(word.lower() in title.lower() for word in term.split()):
|
||||||
if size < maxsize and minimumseeders < seeders:
|
if size < maxsize and minimumseeders < seeders:
|
||||||
logger.info('Found %s. Size: %s' % (title, helpers.bytes_to_mb(size)))
|
logger.info('Found %s. Size: %s' % (title, bytes_to_mb(size)))
|
||||||
resultlist.append(Result(title, size, url, provider, 'torrent', True))
|
resultlist.append(Result(title, size, url, provider, 'torrent', True))
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1510,7 +1509,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
|
|||||||
size = int(desc_match.group(1))
|
size = int(desc_match.group(1))
|
||||||
url = item.link
|
url = item.link
|
||||||
resultlist.append(Result(title, size, url, provider, 'torrent', True))
|
resultlist.append(Result(title, size, url, provider, 'torrent', True))
|
||||||
logger.info('Found %s. Size: %s', title, helpers.bytes_to_mb(size))
|
logger.info('Found %s. Size: %s', title, bytes_to_mb(size))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"An error occurred while trying to parse the response from Waffles.ch: %s",
|
"An error occurred while trying to parse the response from Waffles.ch: %s",
|
||||||
@@ -1855,7 +1854,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
|
|||||||
|
|
||||||
formatted_size = re.search('Size (.*),', str(item)).group(1).replace(
|
formatted_size = re.search('Size (.*),', str(item)).group(1).replace(
|
||||||
'\xa0', ' ')
|
'\xa0', ' ')
|
||||||
size = helpers.piratesize(formatted_size)
|
size = piratesize(formatted_size)
|
||||||
|
|
||||||
if size < maxsize and minimumseeders < seeds and url is not None:
|
if size < maxsize and minimumseeders < seeds and url is not None:
|
||||||
match = True
|
match = True
|
||||||
@@ -1909,7 +1908,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
|
|||||||
"href"] # Magnet link. The actual download link is not based on the URL
|
"href"] # Magnet link. The actual download link is not based on the URL
|
||||||
|
|
||||||
formatted_size = item.select("td.size-row")[0].text
|
formatted_size = item.select("td.size-row")[0].text
|
||||||
size = helpers.piratesize(formatted_size)
|
size = piratesize(formatted_size)
|
||||||
|
|
||||||
if size < maxsize and minimumseeders < seeds and url is not None:
|
if size < maxsize and minimumseeders < seeds and url is not None:
|
||||||
match = True
|
match = True
|
||||||
@@ -1936,25 +1935,49 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def searchSoulseek(album, new=False, losslessOnly=False, albumlength=None):
|
||||||
|
# Not using some of the input stuff for now or ever
|
||||||
|
replacements = {
|
||||||
|
'...': '',
|
||||||
|
' & ': ' ',
|
||||||
|
' = ': ' ',
|
||||||
|
'?': '',
|
||||||
|
'$': '',
|
||||||
|
' + ': ' ',
|
||||||
|
'"': '',
|
||||||
|
',': '',
|
||||||
|
'*': '',
|
||||||
|
'.': '',
|
||||||
|
':': ''
|
||||||
|
}
|
||||||
|
|
||||||
|
num_tracks = get_album_track_count(album['AlbumID'])
|
||||||
|
year = get_year_from_release_date(album['ReleaseDate'])
|
||||||
|
cleanalbum = unidecode(helpers.replace_all(album['AlbumTitle'], replacements)).strip()
|
||||||
|
cleanartist = unidecode(helpers.replace_all(album['ArtistName'], replacements)).strip()
|
||||||
|
|
||||||
|
results = soulseek.search(artist=cleanartist, album=cleanalbum, year=year, losslessOnly=losslessOnly, num_tracks=num_tracks)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def get_album_track_count(album_id):
|
||||||
|
# Not sure if this should be considered a helper function.
|
||||||
|
myDB = db.DBConnection()
|
||||||
|
track_count = myDB.select('SELECT COUNT(*) as count FROM tracks WHERE AlbumID=?', [album_id])[0]['count']
|
||||||
|
return track_count
|
||||||
|
|
||||||
|
|
||||||
# THIS IS KIND OF A MESS AND PROBABLY NEEDS TO BE CLEANED UP
|
# THIS IS KIND OF A MESS AND PROBABLY NEEDS TO BE CLEANED UP
|
||||||
|
|
||||||
|
|
||||||
def preprocess(resultlist):
|
def preprocess(resultlist):
|
||||||
for result in resultlist:
|
for result in resultlist:
|
||||||
if result[4] == 'bandcamp':
|
|
||||||
return True, result
|
|
||||||
|
|
||||||
if result[4] == 'torrent' and result.provider in ["The Pirate Bay", "Old Pirate Bay"]:
|
|
||||||
headers = {
|
|
||||||
'User-Agent':
|
|
||||||
'Mozilla/5.0 (Windows NT 6.3; Win64; x64) \
|
|
||||||
AppleWebKit/537.36 (KHTML, like Gecko) \
|
|
||||||
Chrome/41.0.2243.2 Safari/537.36'
|
|
||||||
}
|
|
||||||
|
|
||||||
else:
|
|
||||||
headers = {'User-Agent': USER_AGENT}
|
headers = {'User-Agent': USER_AGENT}
|
||||||
|
|
||||||
|
if result.kind == 'soulseek':
|
||||||
|
return True, result
|
||||||
|
|
||||||
if result.kind == 'torrent':
|
if result.kind == 'torrent':
|
||||||
|
|
||||||
# rutracker always needs the torrent data
|
# rutracker always needs the torrent data
|
||||||
@@ -2000,12 +2023,24 @@ def preprocess(resultlist):
|
|||||||
return True, result
|
return True, result
|
||||||
|
|
||||||
# Download the torrent file
|
# Download the torrent file
|
||||||
|
|
||||||
|
if result.provider in ["The Pirate Bay", "Old Pirate Bay"]:
|
||||||
|
headers = {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 6.3; Win64; x64) \
|
||||||
|
AppleWebKit/537.36 (KHTML, like Gecko) \
|
||||||
|
Chrome/41.0.2243.2 Safari/537.36'
|
||||||
|
}
|
||||||
|
|
||||||
return request.request_content(url=result.url, headers=headers), result
|
return request.request_content(url=result.url, headers=headers), result
|
||||||
|
|
||||||
if result.kind == 'magnet':
|
elif result.kind == 'magnet':
|
||||||
magnet_link = result.url
|
magnet_link = result.url
|
||||||
return "d10:magnet-uri%d:%se" % (len(magnet_link), magnet_link), result
|
return "d10:magnet-uri%d:%se" % (len(magnet_link), magnet_link), result
|
||||||
|
|
||||||
|
elif result.kind == 'bandcamp':
|
||||||
|
return True, result
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if result.provider == 'headphones':
|
if result.provider == 'headphones':
|
||||||
return request.request_content(
|
return request.request_content(
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
from collections import defaultdict, namedtuple
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import slskd_api
|
||||||
|
import headphones
|
||||||
|
from headphones import logger
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
Result = namedtuple('Result', ['title', 'size', 'user', 'provider', 'type', 'matches', 'bandwidth', 'hasFreeUploadSlot', 'queueLength', 'files', 'kind', 'url', 'folder'])
|
||||||
|
|
||||||
|
def initialize_soulseek_client():
|
||||||
|
host = headphones.CONFIG.SOULSEEK_API_URL
|
||||||
|
api_key = headphones.CONFIG.SOULSEEK_API_KEY
|
||||||
|
return slskd_api.SlskdClient(host=host, api_key=api_key)
|
||||||
|
|
||||||
|
# Search logic, calling search and processing fucntions
|
||||||
|
def search(artist, album, year, num_tracks, losslessOnly):
|
||||||
|
client = initialize_soulseek_client()
|
||||||
|
|
||||||
|
# Stage 1: Search with artist, album, year, and num_tracks
|
||||||
|
results = execute_search(client, artist, album, year, losslessOnly)
|
||||||
|
processed_results = process_results(results, losslessOnly, num_tracks)
|
||||||
|
if processed_results:
|
||||||
|
return processed_results
|
||||||
|
|
||||||
|
# Stage 2: If Stage 1 fails, search with artist, album, and num_tracks (excluding year)
|
||||||
|
logger.info("Soulseek search stage 1 did not meet criteria. Retrying without year...")
|
||||||
|
results = execute_search(client, artist, album, None, losslessOnly)
|
||||||
|
processed_results = process_results(results, losslessOnly, num_tracks)
|
||||||
|
if processed_results:
|
||||||
|
return processed_results
|
||||||
|
|
||||||
|
# Stage 3: Final attempt, search only with artist and album
|
||||||
|
logger.info("Soulseek search stage 2 did not meet criteria. Final attempt with only artist and album.")
|
||||||
|
results = execute_search(client, artist, album, None, losslessOnly)
|
||||||
|
processed_results = process_results(results, losslessOnly, num_tracks, ignore_track_count=True)
|
||||||
|
|
||||||
|
return processed_results
|
||||||
|
|
||||||
|
def execute_search(client, artist, album, year, losslessOnly):
|
||||||
|
search_text = f"{artist} {album}"
|
||||||
|
if year:
|
||||||
|
search_text += f" {year}"
|
||||||
|
if losslessOnly:
|
||||||
|
search_text += ".flac"
|
||||||
|
|
||||||
|
# Actual search
|
||||||
|
search_response = client.searches.search_text(searchText=search_text, filterResponses=True)
|
||||||
|
search_id = search_response.get('id')
|
||||||
|
|
||||||
|
# Wait for search completion and return response
|
||||||
|
while not client.searches.state(id=search_id).get('isComplete'):
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
return client.searches.search_responses(id=search_id)
|
||||||
|
|
||||||
|
# Processing the search result passed
|
||||||
|
def process_results(results, losslessOnly, num_tracks, ignore_track_count=False):
|
||||||
|
valid_extensions = {'.flac'} if losslessOnly else {'.mp3', '.flac'}
|
||||||
|
albums = defaultdict(lambda: {'files': [], 'user': None, 'hasFreeUploadSlot': None, 'queueLength': None, 'uploadSpeed': None})
|
||||||
|
|
||||||
|
# Extract info from the api response and combine files at album level
|
||||||
|
for result in results:
|
||||||
|
user = result.get('username')
|
||||||
|
hasFreeUploadSlot = result.get('hasFreeUploadSlot')
|
||||||
|
queueLength = result.get('queueLength')
|
||||||
|
uploadSpeed = result.get('uploadSpeed')
|
||||||
|
|
||||||
|
# Only handle .mp3 and .flac
|
||||||
|
for file in result.get('files', []):
|
||||||
|
filename = file.get('filename')
|
||||||
|
file_extension = os.path.splitext(filename)[1].lower()
|
||||||
|
if file_extension in valid_extensions:
|
||||||
|
album_directory = os.path.dirname(filename)
|
||||||
|
albums[album_directory]['files'].append(file)
|
||||||
|
|
||||||
|
# Update metadata only once per album_directory
|
||||||
|
if albums[album_directory]['user'] is None:
|
||||||
|
albums[album_directory].update({
|
||||||
|
'user': user,
|
||||||
|
'hasFreeUploadSlot': hasFreeUploadSlot,
|
||||||
|
'queueLength': queueLength,
|
||||||
|
'uploadSpeed': uploadSpeed,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Filter albums based on num_tracks, add bunch of useful info to the compiled album
|
||||||
|
final_results = []
|
||||||
|
for directory, album_data in albums.items():
|
||||||
|
if ignore_track_count or len(album_data['files']) == num_tracks:
|
||||||
|
album_title = os.path.basename(directory)
|
||||||
|
total_size = sum(file.get('size', 0) for file in album_data['files'])
|
||||||
|
final_results.append(Result(
|
||||||
|
title=album_title,
|
||||||
|
size=int(total_size),
|
||||||
|
user=album_data['user'],
|
||||||
|
provider="soulseek",
|
||||||
|
type="soulseek",
|
||||||
|
matches=True,
|
||||||
|
bandwidth=album_data['uploadSpeed'],
|
||||||
|
hasFreeUploadSlot=album_data['hasFreeUploadSlot'],
|
||||||
|
queueLength=album_data['queueLength'],
|
||||||
|
files=album_data['files'],
|
||||||
|
kind='soulseek',
|
||||||
|
url='http://thisisnot.needed', # URL is needed in other parts of the program.
|
||||||
|
folder=os.path.basename(directory)
|
||||||
|
))
|
||||||
|
|
||||||
|
return final_results
|
||||||
|
|
||||||
|
|
||||||
|
def download(user, filelist):
|
||||||
|
client = initialize_soulseek_client()
|
||||||
|
client.transfers.enqueue(username=user, files=filelist)
|
||||||
|
|
||||||
|
|
||||||
|
def download_completed():
|
||||||
|
client = initialize_soulseek_client()
|
||||||
|
all_downloads = client.transfers.get_all_downloads(includeRemoved=False)
|
||||||
|
album_completion_tracker = {} # Tracks completion state of each album's songs
|
||||||
|
album_errored_tracker = {} # Tracks albums with errored downloads
|
||||||
|
|
||||||
|
# Anything older than 24 hours will be canceled
|
||||||
|
cutoff_time = datetime.now() - timedelta(hours=24)
|
||||||
|
|
||||||
|
# Identify errored and completed albums
|
||||||
|
for download in all_downloads:
|
||||||
|
directories = download.get('directories', [])
|
||||||
|
for directory in directories:
|
||||||
|
album_part = directory.get('directory', '').split('\\')[-1]
|
||||||
|
files = directory.get('files', [])
|
||||||
|
for file_data in files:
|
||||||
|
state = file_data.get('state', '')
|
||||||
|
requested_at_str = file_data.get('requestedAt', '1900-01-01 00:00:00')
|
||||||
|
requested_at = parse_datetime(requested_at_str)
|
||||||
|
|
||||||
|
# Initialize or update album entry in trackers
|
||||||
|
if album_part not in album_completion_tracker:
|
||||||
|
album_completion_tracker[album_part] = {'total': 0, 'completed': 0, 'errored': 0}
|
||||||
|
if album_part not in album_errored_tracker:
|
||||||
|
album_errored_tracker[album_part] = False
|
||||||
|
|
||||||
|
album_completion_tracker[album_part]['total'] += 1
|
||||||
|
|
||||||
|
if 'Completed, Succeeded' in state:
|
||||||
|
album_completion_tracker[album_part]['completed'] += 1
|
||||||
|
elif 'Completed, Errored' in state or requested_at < cutoff_time:
|
||||||
|
album_completion_tracker[album_part]['errored'] += 1
|
||||||
|
album_errored_tracker[album_part] = True # Mark album as having errored downloads
|
||||||
|
|
||||||
|
# Identify errored albums
|
||||||
|
errored_albums = {album for album, errored in album_errored_tracker.items() if errored}
|
||||||
|
|
||||||
|
# Cancel downloads for errored albums
|
||||||
|
for download in all_downloads:
|
||||||
|
directories = download.get('directories', [])
|
||||||
|
for directory in directories:
|
||||||
|
album_part = directory.get('directory', '').split('\\')[-1]
|
||||||
|
files = directory.get('files', [])
|
||||||
|
for file_data in files:
|
||||||
|
if album_part in errored_albums:
|
||||||
|
# Extract 'id' and 'username' for each file to cancel the download
|
||||||
|
file_id = file_data.get('id', '')
|
||||||
|
username = file_data.get('username', '')
|
||||||
|
success = client.transfers.cancel_download(username, file_id)
|
||||||
|
if not success:
|
||||||
|
print(f"Failed to cancel download for file ID: {file_id}")
|
||||||
|
|
||||||
|
# Clear completed/canceled/errored stuff from client downloads
|
||||||
|
try:
|
||||||
|
client.transfers.remove_completed_downloads()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to remove completed downloads: {e}")
|
||||||
|
|
||||||
|
# Identify completed albums
|
||||||
|
completed_albums = {album for album, counts in album_completion_tracker.items() if counts['total'] == counts['completed']}
|
||||||
|
|
||||||
|
# Return both completed and errored albums
|
||||||
|
return completed_albums, errored_albums
|
||||||
|
|
||||||
|
|
||||||
|
def parse_datetime(datetime_string):
|
||||||
|
# Parse the datetime api response
|
||||||
|
if '.' in datetime_string:
|
||||||
|
datetime_string = datetime_string[:datetime_string.index('.')+7]
|
||||||
|
return datetime.strptime(datetime_string, '%Y-%m-%dT%H:%M:%S.%f')
|
||||||
@@ -1183,6 +1183,7 @@ class WebInterface(object):
|
|||||||
"deluge_password": headphones.CONFIG.DELUGE_PASSWORD,
|
"deluge_password": headphones.CONFIG.DELUGE_PASSWORD,
|
||||||
"deluge_label": headphones.CONFIG.DELUGE_LABEL,
|
"deluge_label": headphones.CONFIG.DELUGE_LABEL,
|
||||||
"deluge_done_directory": headphones.CONFIG.DELUGE_DONE_DIRECTORY,
|
"deluge_done_directory": headphones.CONFIG.DELUGE_DONE_DIRECTORY,
|
||||||
|
"deluge_download_directory": headphones.CONFIG.DELUGE_DOWNLOAD_DIRECTORY,
|
||||||
"deluge_paused": checked(headphones.CONFIG.DELUGE_PAUSED),
|
"deluge_paused": checked(headphones.CONFIG.DELUGE_PAUSED),
|
||||||
"utorrent_host": headphones.CONFIG.UTORRENT_HOST,
|
"utorrent_host": headphones.CONFIG.UTORRENT_HOST,
|
||||||
"utorrent_username": headphones.CONFIG.UTORRENT_USERNAME,
|
"utorrent_username": headphones.CONFIG.UTORRENT_USERNAME,
|
||||||
@@ -1197,6 +1198,8 @@ class WebInterface(object):
|
|||||||
"torrent_downloader_deluge": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 3),
|
"torrent_downloader_deluge": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 3),
|
||||||
"torrent_downloader_qbittorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 4),
|
"torrent_downloader_qbittorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 4),
|
||||||
"download_dir": headphones.CONFIG.DOWNLOAD_DIR,
|
"download_dir": headphones.CONFIG.DOWNLOAD_DIR,
|
||||||
|
"soulseek_download_dir": headphones.CONFIG.SOULSEEK_DOWNLOAD_DIR,
|
||||||
|
"soulseek_incomplete_download_dir": headphones.CONFIG.SOULSEEK_INCOMPLETE_DOWNLOAD_DIR,
|
||||||
"use_blackhole": checked(headphones.CONFIG.BLACKHOLE),
|
"use_blackhole": checked(headphones.CONFIG.BLACKHOLE),
|
||||||
"blackhole_dir": headphones.CONFIG.BLACKHOLE_DIR,
|
"blackhole_dir": headphones.CONFIG.BLACKHOLE_DIR,
|
||||||
"usenet_retention": headphones.CONFIG.USENET_RETENTION,
|
"usenet_retention": headphones.CONFIG.USENET_RETENTION,
|
||||||
@@ -1296,6 +1299,7 @@ class WebInterface(object):
|
|||||||
"prefer_torrents_0": radio(headphones.CONFIG.PREFER_TORRENTS, 0),
|
"prefer_torrents_0": radio(headphones.CONFIG.PREFER_TORRENTS, 0),
|
||||||
"prefer_torrents_1": radio(headphones.CONFIG.PREFER_TORRENTS, 1),
|
"prefer_torrents_1": radio(headphones.CONFIG.PREFER_TORRENTS, 1),
|
||||||
"prefer_torrents_2": radio(headphones.CONFIG.PREFER_TORRENTS, 2),
|
"prefer_torrents_2": radio(headphones.CONFIG.PREFER_TORRENTS, 2),
|
||||||
|
"prefer_torrents_3": radio(headphones.CONFIG.PREFER_TORRENTS, 3),
|
||||||
"magnet_links_0": radio(headphones.CONFIG.MAGNET_LINKS, 0),
|
"magnet_links_0": radio(headphones.CONFIG.MAGNET_LINKS, 0),
|
||||||
"magnet_links_1": radio(headphones.CONFIG.MAGNET_LINKS, 1),
|
"magnet_links_1": radio(headphones.CONFIG.MAGNET_LINKS, 1),
|
||||||
"magnet_links_2": radio(headphones.CONFIG.MAGNET_LINKS, 2),
|
"magnet_links_2": radio(headphones.CONFIG.MAGNET_LINKS, 2),
|
||||||
@@ -1415,7 +1419,10 @@ class WebInterface(object):
|
|||||||
"join_apikey": headphones.CONFIG.JOIN_APIKEY,
|
"join_apikey": headphones.CONFIG.JOIN_APIKEY,
|
||||||
"join_deviceid": headphones.CONFIG.JOIN_DEVICEID,
|
"join_deviceid": headphones.CONFIG.JOIN_DEVICEID,
|
||||||
"use_bandcamp": checked(headphones.CONFIG.BANDCAMP),
|
"use_bandcamp": checked(headphones.CONFIG.BANDCAMP),
|
||||||
"bandcamp_dir": headphones.CONFIG.BANDCAMP_DIR
|
"bandcamp_dir": headphones.CONFIG.BANDCAMP_DIR,
|
||||||
|
'soulseek_api_url': headphones.CONFIG.SOULSEEK_API_URL,
|
||||||
|
'soulseek_api_key': headphones.CONFIG.SOULSEEK_API_KEY,
|
||||||
|
'use_soulseek': checked(headphones.CONFIG.SOULSEEK)
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v in config.items():
|
for k, v in config.items():
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Copyright (C) 2023 bigoulours
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program 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 Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from .client import SlskdClient, MetricsApi
|
||||||
|
|
||||||
|
__all__ = ('SlskdClient', 'MetricsApi')
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Copyright (C) 2023 bigoulours
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program 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 Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from .application import ApplicationApi
|
||||||
|
from .conversations import ConversationsApi
|
||||||
|
from .logs import LogsApi
|
||||||
|
from .options import OptionsApi
|
||||||
|
from .public_chat import PublicChatApi
|
||||||
|
from .relay import RelayApi
|
||||||
|
from .rooms import RoomsApi
|
||||||
|
from .searches import SearchesApi
|
||||||
|
from .server import ServerApi
|
||||||
|
from .session import SessionApi
|
||||||
|
from .shares import SharesApi
|
||||||
|
from .transfers import TransfersApi
|
||||||
|
from .users import UsersApi
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'ApplicationApi',
|
||||||
|
'ConversationsApi',
|
||||||
|
'LogsApi',
|
||||||
|
'OptionsApi',
|
||||||
|
'PublicChatApi',
|
||||||
|
'RelayApi',
|
||||||
|
'RoomsApi',
|
||||||
|
'SearchesApi',
|
||||||
|
'ServerApi',
|
||||||
|
'SessionApi',
|
||||||
|
'SharesApi',
|
||||||
|
'TransfersApi',
|
||||||
|
'UsersApi'
|
||||||
|
)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Copyright (C) 2023 bigoulours
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program 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 Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from .base import *
|
||||||
|
|
||||||
|
class ApplicationApi(BaseApi):
|
||||||
|
"""
|
||||||
|
This class contains the methods to interact with the Application API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def state(self) -> dict:
|
||||||
|
"""
|
||||||
|
Gets the current state of the application.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/application'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def stop(self) -> bool:
|
||||||
|
"""
|
||||||
|
Stops the application. Only works with token (usr/pwd login). 'Unauthorized' with API-Key.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/application'
|
||||||
|
response = self.session.delete(url)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def restart(self) -> bool:
|
||||||
|
"""
|
||||||
|
Restarts the application. Only works with token (usr/pwd login). 'Unauthorized' with API-Key.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/application'
|
||||||
|
response = self.session.put(url)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def version(self) -> str:
|
||||||
|
"""
|
||||||
|
Gets the current application version.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/application/version'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def check_updates(self, forceCheck: bool = False) -> dict:
|
||||||
|
"""
|
||||||
|
Checks for updates.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/application/version/latest'
|
||||||
|
params = dict(
|
||||||
|
forceCheck=forceCheck
|
||||||
|
)
|
||||||
|
response = self.session.get(url, params=params)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def gc(self) -> bool:
|
||||||
|
"""
|
||||||
|
Forces garbage collection.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/application/gc'
|
||||||
|
response = self.session.post(url)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
# Not supposed to be part of the external API
|
||||||
|
# More info in the Github discussion: https://github.com/slskd/slskd/discussions/910
|
||||||
|
# def dump(self):
|
||||||
|
# url = self.api_url + '/application/dump'
|
||||||
|
# response = self.session.get(url)
|
||||||
|
# return response.json()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Copyright (C) 2023 bigoulours
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program 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 Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
class BaseApi:
|
||||||
|
"""
|
||||||
|
Base class where api-url and headers are set for all requests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, api_url: str, session: requests.Session):
|
||||||
|
self.api_url = api_url
|
||||||
|
self.session = session
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Copyright (C) 2023 bigoulours
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program 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 Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from .base import *
|
||||||
|
|
||||||
|
class ConversationsApi(BaseApi):
|
||||||
|
"""
|
||||||
|
This class contains the methods to interact with the Conversations API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def acknowledge(self, username: str, id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Acknowledges the given message id for the given username.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/conversations/{quote(username)}/{id}'
|
||||||
|
response = self.session.put(url)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def acknowledge_all(self, username: str) -> bool:
|
||||||
|
"""
|
||||||
|
Acknowledges all messages from the given username.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/conversations/{quote(username)}'
|
||||||
|
response = self.session.put(url)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def delete(self, username: str) -> bool:
|
||||||
|
"""
|
||||||
|
Closes the conversation associated with the given username.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/conversations/{quote(username)}'
|
||||||
|
response = self.session.delete(url)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def get(self, username: str, includeMessages: bool = True) -> dict:
|
||||||
|
"""
|
||||||
|
Gets the conversation associated with the specified username.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/conversations/{quote(username)}'
|
||||||
|
params = dict(
|
||||||
|
includeMessages=includeMessages
|
||||||
|
)
|
||||||
|
response = self.session.get(url, params=params)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def send(self, username: str, message: str) -> bool:
|
||||||
|
"""
|
||||||
|
Sends a private message to the specified username.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/conversations/{quote(username)}'
|
||||||
|
response = self.session.post(url, json=message)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def get_all(self, includeInactive: bool = False, unAcknowledgedOnly : bool = False) -> list:
|
||||||
|
"""
|
||||||
|
Gets all active conversations.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/conversations'
|
||||||
|
params = dict(
|
||||||
|
includeInactive=includeInactive,
|
||||||
|
unAcknowledgedOnly=unAcknowledgedOnly
|
||||||
|
)
|
||||||
|
response = self.session.get(url, params=params)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def get_messages(self, username: str, unAcknowledgedOnly : bool = False) -> list:
|
||||||
|
"""
|
||||||
|
Gets all messages associated with the specified username.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/conversations/{quote(username)}/messages'
|
||||||
|
params = dict(
|
||||||
|
username=username,
|
||||||
|
unAcknowledgedOnly=unAcknowledgedOnly
|
||||||
|
)
|
||||||
|
response = self.session.get(url, params=params)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Copyright (C) 2023 bigoulours
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program 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 Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from .base import *
|
||||||
|
|
||||||
|
class LogsApi(BaseApi):
|
||||||
|
"""
|
||||||
|
This class contains the methods to interact with the Logs API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self) -> list:
|
||||||
|
"""
|
||||||
|
Gets the last few application logs.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/logs'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Copyright (C) 2023 bigoulours
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program 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 Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from .base import *
|
||||||
|
|
||||||
|
class OptionsApi(BaseApi):
|
||||||
|
"""
|
||||||
|
This class contains the methods to interact with the Options API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self) -> dict:
|
||||||
|
"""
|
||||||
|
Gets the current application options.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/options'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def get_startup(self) -> dict:
|
||||||
|
"""
|
||||||
|
Gets the application options provided at startup.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/options/startup'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def debug(self) -> str:
|
||||||
|
"""
|
||||||
|
Gets the debug view of the current application options.
|
||||||
|
debug and remote_configuration must be set to true.
|
||||||
|
Only works with token (usr/pwd login). 'Unauthorized' with API-Key.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/options/debug'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def yaml_location(self) -> str:
|
||||||
|
"""
|
||||||
|
Gets the path of the yaml config file. remote_configuration must be set to true.
|
||||||
|
Only works with token (usr/pwd login). 'Unauthorized' with API-Key.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/options/yaml/location'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def download_yaml(self) -> str:
|
||||||
|
"""
|
||||||
|
Gets the content of the yaml config file as text. remote_configuration must be set to true.
|
||||||
|
Only works with token (usr/pwd login). 'Unauthorized' with API-Key.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/options/yaml'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def upload_yaml(self, yaml_content: str) -> bool:
|
||||||
|
"""
|
||||||
|
Sets the content of the yaml config file. remote_configuration must be set to true.
|
||||||
|
Only works with token (usr/pwd login). 'Unauthorized' with API-Key.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/options/yaml'
|
||||||
|
response = self.session.post(url, json=yaml_content)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def validate_yaml(self, yaml_content: str) -> str:
|
||||||
|
"""
|
||||||
|
Validates the provided yaml string. remote_configuration must be set to true.
|
||||||
|
Only works with token (usr/pwd login). 'Unauthorized' with API-Key.
|
||||||
|
|
||||||
|
:return: Empty string if validation successful. Error message otherwise.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/options/yaml/validate'
|
||||||
|
response = self.session.post(url, json=yaml_content)
|
||||||
|
return response.text
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Copyright (C) 2023 bigoulours
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program 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 Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from .base import *
|
||||||
|
|
||||||
|
class PublicChatApi(BaseApi):
|
||||||
|
"""
|
||||||
|
[UNTESTED] This class contains the methods to interact with the PublicChat API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def start(self) -> bool:
|
||||||
|
"""
|
||||||
|
Starts public chat.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/publicchat'
|
||||||
|
response = self.session.post(url)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def stop(self) -> bool:
|
||||||
|
"""
|
||||||
|
Stops public chat.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/publicchat'
|
||||||
|
response = self.session.delete(url)
|
||||||
|
return response.ok
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Copyright (C) 2023 bigoulours
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program 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 Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from .base import *
|
||||||
|
|
||||||
|
class RelayApi(BaseApi):
|
||||||
|
"""
|
||||||
|
[UNTESTED] This class contains the methods to interact with the Relay API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def connect(self) -> bool:
|
||||||
|
"""
|
||||||
|
Connects to the configured controller.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/relay/agent'
|
||||||
|
response = self.session.put(url)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def disconnect(self) -> bool:
|
||||||
|
"""
|
||||||
|
Disconnects from the connected controller.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/relay/agent'
|
||||||
|
response = self.session.delete(url)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def download_file(self, token: str) -> bool:
|
||||||
|
"""
|
||||||
|
Downloads a file from the connected controller.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/relay/controller/downloads/{token}'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file(self, token: str) -> bool:
|
||||||
|
"""
|
||||||
|
Uploads a file from the connected controller.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/relay/controller/files/{token}'
|
||||||
|
response = self.session.post(url)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def upload_share_info(self, token: str) -> bool:
|
||||||
|
"""
|
||||||
|
Uploads share information to the connected controller.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/relay/controller/shares/{token}'
|
||||||
|
response = self.session.post(url)
|
||||||
|
return response.ok
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Copyright (C) 2023 bigoulours
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program 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 Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from .base import *
|
||||||
|
|
||||||
|
class RoomsApi(BaseApi):
|
||||||
|
"""
|
||||||
|
This class contains the methods to interact with the Rooms API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_all_joined(self) -> list:
|
||||||
|
"""
|
||||||
|
Gets all joined rooms.
|
||||||
|
|
||||||
|
:return: Names of the joined rooms.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/rooms/joined'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def join(self, roomName: str) -> dict:
|
||||||
|
"""
|
||||||
|
Joins a room.
|
||||||
|
|
||||||
|
:return: room info: name, isPrivate, users, messages
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/rooms/joined'
|
||||||
|
response = self.session.post(url, json=roomName)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def get_joined(self, roomName: str) -> dict:
|
||||||
|
"""
|
||||||
|
Gets the specified room.
|
||||||
|
|
||||||
|
:return: room info: name, isPrivate, users, messages
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/rooms/joined/{quote(roomName)}'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def leave(self, roomName: str) -> bool:
|
||||||
|
"""
|
||||||
|
Leaves a room.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/rooms/joined/{quote(roomName)}'
|
||||||
|
response = self.session.delete(url)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def send(self, roomName: str, message: str) -> bool:
|
||||||
|
"""
|
||||||
|
Sends a message to the specified room.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/rooms/joined/{quote(roomName)}/messages'
|
||||||
|
response = self.session.post(url, json=message)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def get_messages(self, roomName: str) -> list:
|
||||||
|
"""
|
||||||
|
Gets the current list of messages for the specified room.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/rooms/joined/{quote(roomName)}/messages'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def set_ticker(self, roomName: str, ticker: str) -> bool:
|
||||||
|
"""
|
||||||
|
Sets a ticker for the specified room.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/rooms/joined/{quote(roomName)}/ticker'
|
||||||
|
response = self.session.post(url, json=ticker)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def add_member(self, roomName: str, username: str) -> bool:
|
||||||
|
"""
|
||||||
|
Adds a member to a private room.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/rooms/joined/{quote(roomName)}/members'
|
||||||
|
response = self.session.post(url, json=username)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def get_users(self, roomName: str) -> list:
|
||||||
|
"""
|
||||||
|
Gets the current list of users for the specified joined room.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/rooms/joined/{quote(roomName)}/users'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def get_all(self) -> list:
|
||||||
|
"""
|
||||||
|
Gets a list of rooms from the server.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/rooms/available'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Copyright (C) 2023 bigoulours
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program 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 Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from .base import *
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
class SearchesApi(BaseApi):
|
||||||
|
"""
|
||||||
|
Class that handles operations on searches.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def search_text(self,
|
||||||
|
searchText: str,
|
||||||
|
id: Optional[str] = None,
|
||||||
|
fileLimit: int = 10000,
|
||||||
|
filterResponses: bool = True,
|
||||||
|
maximumPeerQueueLength: int = 1000000,
|
||||||
|
minimumPeerUploadSpeed: int = 0,
|
||||||
|
minimumResponseFileCount: int = 1,
|
||||||
|
responseLimit: int = 100,
|
||||||
|
searchTimeout: int = 15000
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Performs a search for the specified request.
|
||||||
|
|
||||||
|
:param searchText: Search query
|
||||||
|
:param id: uuid of the search. One will be generated if None.
|
||||||
|
:param fileLimit: Max number of file results
|
||||||
|
:param filterResponses: Filter unreachable users from the results
|
||||||
|
:param maximumPeerQueueLength: Max queue length
|
||||||
|
:param minimumPeerUploadSpeed: Min upload speed in bit/s
|
||||||
|
:param minimumResponseFileCount: Min number of matching files per user
|
||||||
|
:param responseLimit: Max number of users results
|
||||||
|
:param searchTimeout: Search timeout in ms
|
||||||
|
:return: Info about the search (no results!)
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = self.api_url + '/searches'
|
||||||
|
|
||||||
|
try:
|
||||||
|
id = str(uuid.UUID(id)) # check if given id is a valid uuid
|
||||||
|
except:
|
||||||
|
id = str(uuid.uuid1()) # otherwise generate a new one
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"id": id,
|
||||||
|
"fileLimit": fileLimit,
|
||||||
|
"filterResponses": filterResponses,
|
||||||
|
"maximumPeerQueueLength": maximumPeerQueueLength,
|
||||||
|
"minimumPeerUploadSpeed": minimumPeerUploadSpeed,
|
||||||
|
"minimumResponseFileCount": minimumResponseFileCount,
|
||||||
|
"responseLimit": responseLimit,
|
||||||
|
"searchText": searchText,
|
||||||
|
"searchTimeout": searchTimeout,
|
||||||
|
}
|
||||||
|
response = self.session.post(url, json=data)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def get_all(self) -> list:
|
||||||
|
"""
|
||||||
|
Gets the list of active and completed searches.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/searches'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def state(self, id: str, includeResponses: bool = False) -> dict:
|
||||||
|
"""
|
||||||
|
Gets the state of the search corresponding to the specified id.
|
||||||
|
|
||||||
|
:param id: uuid of the search.
|
||||||
|
:param includeResponses: Include responses (search result list) in the returned dict
|
||||||
|
:return: Info about the search
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/searches/{id}'
|
||||||
|
params = dict(
|
||||||
|
includeResponses=includeResponses
|
||||||
|
)
|
||||||
|
response = self.session.get(url, params=params)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def stop(self, id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Stops the search corresponding to the specified id.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/searches/{id}'
|
||||||
|
response = self.session.put(url)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def delete(self, id: str):
|
||||||
|
"""
|
||||||
|
Deletes the search corresponding to the specified id.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/searches/{id}'
|
||||||
|
response = self.session.delete(url)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def search_responses(self, id: str) -> list:
|
||||||
|
"""
|
||||||
|
Gets search responses corresponding to the specified id.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/searches/{id}/responses'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Copyright (C) 2023 bigoulours
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program 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 Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from .base import *
|
||||||
|
|
||||||
|
class ServerApi(BaseApi):
|
||||||
|
"""
|
||||||
|
This class contains the methods to interact with the Server API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def connect(self) -> bool:
|
||||||
|
"""
|
||||||
|
Connects the client.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/server'
|
||||||
|
response = self.session.put(url)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def disconnect(self) -> bool:
|
||||||
|
"""
|
||||||
|
Disconnects the client.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/server'
|
||||||
|
response = self.session.delete(url, json='')
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def state(self) -> dict:
|
||||||
|
"""
|
||||||
|
Retrieves the current state of the server.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/server'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Copyright (C) 2023 bigoulours
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program 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 Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from .base import *
|
||||||
|
|
||||||
|
class SessionApi(BaseApi):
|
||||||
|
"""
|
||||||
|
This class contains the methods to interact with the Session API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def auth_valid(self) -> bool:
|
||||||
|
"""
|
||||||
|
Checks whether the provided authentication is valid.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/session'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def login(self, username: str, password: str) -> dict:
|
||||||
|
"""
|
||||||
|
Logs in.
|
||||||
|
|
||||||
|
:return: Session info for the given user incl. token.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/session'
|
||||||
|
data = {
|
||||||
|
'username': username,
|
||||||
|
'password': password
|
||||||
|
}
|
||||||
|
response = self.session.post(url, json=data)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def security_enabled(self) -> bool:
|
||||||
|
"""
|
||||||
|
Checks whether security is enabled.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/session/enabled'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Copyright (C) 2023 bigoulours
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program 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 Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from .base import *
|
||||||
|
|
||||||
|
class SharesApi(BaseApi):
|
||||||
|
"""
|
||||||
|
This class contains the methods to interact with the Shares API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_all(self) -> dict:
|
||||||
|
"""
|
||||||
|
Gets the current list of shares.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/shares'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def start_scan(self) -> bool:
|
||||||
|
"""
|
||||||
|
Initiates a scan of the configured shares.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/shares'
|
||||||
|
response = self.session.put(url)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_scan(self) -> bool:
|
||||||
|
"""
|
||||||
|
Cancels a share scan, if one is running.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/shares'
|
||||||
|
response = self.session.delete(url)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def get(self, id: str) -> dict:
|
||||||
|
"""
|
||||||
|
Gets the share associated with the specified id.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/shares/{id}'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def all_contents(self) -> list:
|
||||||
|
"""
|
||||||
|
Returns a list of all shared directories and files.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/shares/contents'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def contents(self, id: str) -> list:
|
||||||
|
"""
|
||||||
|
Gets the contents of the share associated with the specified id.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/shares/{id}/contents'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Copyright (C) 2023 bigoulours
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program 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 Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from .base import *
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
class TransfersApi(BaseApi):
|
||||||
|
"""
|
||||||
|
This class contains the methods to interact with the Transfers API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def cancel_download(self, username: str, id:str, remove: bool = False) -> bool:
|
||||||
|
"""
|
||||||
|
Cancels the specified download.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/transfers/downloads/{quote(username)}/{id}'
|
||||||
|
params = dict(
|
||||||
|
remove=remove
|
||||||
|
)
|
||||||
|
response = self.session.delete(url, params=params)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def get_download(self, username: str, id: str) -> dict:
|
||||||
|
"""
|
||||||
|
Gets the specified download.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/transfers/downloads/{quote(username)}/{id}'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def remove_completed_downloads(self) -> bool:
|
||||||
|
"""
|
||||||
|
Removes all completed downloads, regardless of whether they failed or succeeded.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/transfers/downloads/all/completed'
|
||||||
|
response = self.session.delete(url)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_upload(self, username: str, id: str, remove: bool = False) -> bool:
|
||||||
|
"""
|
||||||
|
Cancels the specified upload.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/transfers/uploads/{quote(username)}/{id}'
|
||||||
|
params = dict(
|
||||||
|
remove=remove
|
||||||
|
)
|
||||||
|
response = self.session.delete(url, params=params)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def get_upload(self, username: str, id: str) -> dict:
|
||||||
|
"""
|
||||||
|
Gets the specified upload.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/transfers/uploads/{quote(username)}/{id}'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def remove_completed_uploads(self) -> bool:
|
||||||
|
"""
|
||||||
|
Removes all completed uploads, regardless of whether they failed or succeeded.
|
||||||
|
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/transfers/uploads/all/completed'
|
||||||
|
response = self.session.delete(url)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue(self, username: str, files: list) -> bool:
|
||||||
|
"""
|
||||||
|
Enqueues the specified download.
|
||||||
|
|
||||||
|
:param username: User to download from.
|
||||||
|
:param files: A list of dictionaries in the same form as what's returned
|
||||||
|
by :py:func:`~slskd_api.apis.SearchesApi.search_responses`:
|
||||||
|
[{'filename': <filename>, 'size': <filesize>}...]
|
||||||
|
:return: True if successful.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/transfers/downloads/{quote(username)}'
|
||||||
|
response = self.session.post(url, json=files)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
|
def get_downloads(self, username: str) -> dict:
|
||||||
|
"""
|
||||||
|
Gets all downloads for the specified username.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/transfers/downloads/{quote(username)}'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_downloads(self, includeRemoved: bool = False) -> list:
|
||||||
|
"""
|
||||||
|
Gets all downloads.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/transfers/downloads/'
|
||||||
|
params = dict(
|
||||||
|
includeRemoved=includeRemoved
|
||||||
|
)
|
||||||
|
response = self.session.get(url, params=params)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def get_queue_position(self, username: str, id: str) -> Union[int,str]:
|
||||||
|
"""
|
||||||
|
Gets the download for the specified username matching the specified filename, and requests the current place in the remote queue of the specified download.
|
||||||
|
|
||||||
|
:return: Queue position or error message
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/transfers/downloads/{quote(username)}/{id}/position'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_uploads(self, includeRemoved: bool = False) -> list:
|
||||||
|
"""
|
||||||
|
Gets all uploads.
|
||||||
|
"""
|
||||||
|
url = self.api_url + '/transfers/uploads/'
|
||||||
|
params = dict(
|
||||||
|
includeRemoved=includeRemoved
|
||||||
|
)
|
||||||
|
response = self.session.get(url, params=params)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def get_uploads(self, username: str) -> dict:
|
||||||
|
"""
|
||||||
|
Gets all uploads for the specified username.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/transfers/uploads/{quote(username)}'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Copyright (C) 2023 bigoulours
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program 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 Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from .base import *
|
||||||
|
|
||||||
|
class UsersApi(BaseApi):
|
||||||
|
"""
|
||||||
|
This class contains the methods to interact with the Users API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def address(self, username: str) -> dict:
|
||||||
|
"""
|
||||||
|
Retrieves the address of the specified username.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/users/{quote(username)}/endpoint'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def browse(self, username: str) -> dict:
|
||||||
|
"""
|
||||||
|
Retrieves the files shared by the specified username.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/users/{quote(username)}/browse'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def browsing_status(self, username: str) -> dict:
|
||||||
|
"""
|
||||||
|
Retrieves the status of the current browse operation for the specified username, if any.
|
||||||
|
Will return error 404 if called after the browsing operation has ended.
|
||||||
|
Best called asynchronously while :py:func:`browse` is still running.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/users/{quote(username)}/browse/status'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def directory(self, username: str, directory: str) -> dict:
|
||||||
|
"""
|
||||||
|
Retrieves the files from the specified directory from the specified username.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/users/{quote(username)}/directory'
|
||||||
|
data = {
|
||||||
|
"directory": directory
|
||||||
|
}
|
||||||
|
response = self.session.post(url, json=data)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def info(self, username: str) -> dict:
|
||||||
|
"""
|
||||||
|
Retrieves information about the specified username.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/users/{quote(username)}/info'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def status(self, username: str) -> dict:
|
||||||
|
"""
|
||||||
|
Retrieves status for the specified username.
|
||||||
|
"""
|
||||||
|
url = self.api_url + f'/users/{quote(username)}/status'
|
||||||
|
response = self.session.get(url)
|
||||||
|
return response.json()
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Copyright (C) 2023 bigoulours
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program 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 Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
API_VERSION = 'v0'
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
from functools import reduce
|
||||||
|
from base64 import b64encode
|
||||||
|
from slskd_api.apis import *
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPAdapterTimeout(requests.adapters.HTTPAdapter):
|
||||||
|
def __init__(self, timeout=None, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def send(self, *args, **kwargs):
|
||||||
|
kwargs['timeout'] = self.timeout
|
||||||
|
return super().send(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class SlskdClient:
|
||||||
|
"""
|
||||||
|
The main class that allows access to the different APIs of a slskd instance.
|
||||||
|
An API-Key with appropriate permissions (`readwrite` for most use cases) must be set in slskd config file.
|
||||||
|
Alternatively, provide your username and password. Requests error status raise corresponding error.
|
||||||
|
Usage::
|
||||||
|
slskd = slskd_api.SlskdClient(host, api_key, url_base)
|
||||||
|
app_status = slskd.application.state()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
host: str,
|
||||||
|
api_key: str = None,
|
||||||
|
url_base: str = '/',
|
||||||
|
username: str = None,
|
||||||
|
password: str = None,
|
||||||
|
token: str = None,
|
||||||
|
verify_ssl: bool = True,
|
||||||
|
timeout: float = None # requests timeout in seconds
|
||||||
|
):
|
||||||
|
api_url = reduce(urljoin, [f'{host}/', f'{url_base}/', f'api/{API_VERSION}'])
|
||||||
|
|
||||||
|
session = requests.Session()
|
||||||
|
session.adapters['http://'] = HTTPAdapterTimeout(timeout=timeout)
|
||||||
|
session.adapters['https://'] = HTTPAdapterTimeout(timeout=timeout)
|
||||||
|
session.hooks = {'response': lambda r, *args, **kwargs: r.raise_for_status()}
|
||||||
|
session.headers.update({'accept': '*/*'})
|
||||||
|
session.verify = verify_ssl
|
||||||
|
|
||||||
|
header = {}
|
||||||
|
|
||||||
|
if api_key:
|
||||||
|
header['X-API-Key'] = api_key
|
||||||
|
elif username and password:
|
||||||
|
header['Authorization'] = 'Bearer ' + \
|
||||||
|
SessionApi(api_url, session).login(username, password).get('token', '')
|
||||||
|
elif token:
|
||||||
|
header['Authorization'] = 'Bearer ' + token
|
||||||
|
else:
|
||||||
|
raise ValueError('Please provide an API-Key, a valid token or username/password.')
|
||||||
|
|
||||||
|
session.headers.update(header)
|
||||||
|
|
||||||
|
base_args = (api_url, session)
|
||||||
|
|
||||||
|
self.application = ApplicationApi(*base_args)
|
||||||
|
self.conversations = ConversationsApi(*base_args)
|
||||||
|
self.logs = LogsApi(*base_args)
|
||||||
|
self.options = OptionsApi(*base_args)
|
||||||
|
self.public_chat = PublicChatApi(*base_args)
|
||||||
|
self.relay = RelayApi(*base_args)
|
||||||
|
self.rooms = RoomsApi(*base_args)
|
||||||
|
self.searches = SearchesApi(*base_args)
|
||||||
|
self.server = ServerApi(*base_args)
|
||||||
|
self.session = SessionApi(*base_args)
|
||||||
|
self.shares = SharesApi(*base_args)
|
||||||
|
self.transfers = TransfersApi(*base_args)
|
||||||
|
self.users = UsersApi(*base_args)
|
||||||
|
|
||||||
|
|
||||||
|
class MetricsApi:
|
||||||
|
"""
|
||||||
|
Getting the metrics works with a different endpoint. Default: <slskd_url>:5030/metrics.
|
||||||
|
Metrics should be first activated in slskd config file.
|
||||||
|
User/pass is independent from the main application and default value (slskd:slskd) should be changed.
|
||||||
|
Usage::
|
||||||
|
metrics_api = slskd_api.MetricsApi(host, metrics_usr='slskd', metrics_pwd='slskd')
|
||||||
|
metrics = metrics_api.get()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
host: str,
|
||||||
|
metrics_usr: str = 'slskd',
|
||||||
|
metrics_pwd: str = 'slskd',
|
||||||
|
metrics_url_base: str = '/metrics'
|
||||||
|
):
|
||||||
|
self.metrics_url = urljoin(host, metrics_url_base)
|
||||||
|
basic_auth = b64encode(bytes(f'{metrics_usr}:{metrics_pwd}', 'utf-8'))
|
||||||
|
self.header = {
|
||||||
|
'accept': '*/*',
|
||||||
|
'Authorization': f'Basic {basic_auth.decode()}'
|
||||||
|
}
|
||||||
|
|
||||||
|
def get(self) -> str:
|
||||||
|
"""
|
||||||
|
Gets the Prometheus metrics as text.
|
||||||
|
"""
|
||||||
|
response = requests.get(self.metrics_url, headers=self.header)
|
||||||
|
return response.text
|
||||||
Reference in New Issue
Block a user