Compare commits

...
10 Commits
27 changed files with 1733 additions and 127 deletions
+11 -1
View File
@@ -1,7 +1,17 @@
# Changelog
## 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
Released 26 November 2023
R eleased 26 November 2023
Highlights:
* Dependency updates to work with > Python 3.11
+38 -3
View File
@@ -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_transmission" value="1" ${config['torrent_downloader_transmission']}> Transmission
<input type="radio" name="torrent_downloader" id="torrent_downloader_utorrent" value="2" ${config['torrent_downloader_utorrent']}> uTorrent (Beta)
<input type="radio" name="torrent_downloader" id="torrent_downloader_deluge" value="3" ${config['torrent_downloader_deluge']}> Deluge (Beta)
<input type="radio" name="torrent_downloader" id="torrent_downloader_deluge" value="3" ${config['torrent_downloader_deluge']}> Deluge
<input type="radio" name="torrent_downloader" id="torrent_downloader_qbittorrent" value="4" ${config['torrent_downloader_qbittorrent']}> QBitTorrent
</fieldset>
<fieldset id="torrent_blackhole_options">
@@ -448,6 +448,11 @@
<input type="text" name="deluge_label" value="${config['deluge_label']}" size="30">
<small>Labels shouldn't contain spaces (requires Label plugin)</small>
</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">
<label>Move When Completed</label>
<input type="text" name="deluge_done_directory" value="${config['deluge_done_directory']}" size="30">
@@ -477,7 +482,33 @@
<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_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>
</fieldset>
</td>
@@ -589,7 +620,6 @@
</div>
</div>
</fieldset>
<fieldset>
<legend>Other</legend>
<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>
</div>
</fieldset>
<fieldset>
<div class="row checkbox left">
<input id="use_soulseek" type="checkbox" class="bigcheck" name="use_soulseek" value="1" ${config['use_soulseek']} /><label for="use_soulseek"><span class="option">Soulseek</span></label>
</div>
</fieldset>
</fieldset>
</td>
<td>
+7 -2
View File
@@ -20,9 +20,11 @@ import re
from headphones import logger, helpers, metadata, request
from headphones.common import USER_AGENT
from headphones.types import Result
from mediafile import MediaFile, UnreadableFileError
from bs4 import BeautifulSoup
from bs4 import FeatureNotFound
def search(album, albumlength=None, page=1, resultlist=None):
@@ -50,7 +52,10 @@ def search(album, albumlength=None, page=1, resultlist=None):
params=params,
headers=headers
).decode('utf8')
try:
soup = BeautifulSoup(content, "html5lib")
except FeatureNotFound:
soup = BeautifulSoup(content, "html.parser")
for item in soup.find_all("li", class_="searchresult"):
type = item.find('div', class_='itemtype').text.strip().lower()
@@ -66,7 +71,7 @@ def search(album, albumlength=None, page=1, resultlist=None):
cleanalbum, cleanalbum_found))
if (cleanartist.lower() == cleanartist_found.lower() and
cleanalbum.lower() == cleanalbum_found.lower()):
resultlist.append((
resultlist.append(Result(
data['title'], data['size'], data['url'],
'bandcamp', 'bandcamp', True))
else:
@@ -82,7 +87,7 @@ def search(album, albumlength=None, page=1, resultlist=None):
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 = []
try:
trackinfo = json.loads(
+7 -1
View File
@@ -80,6 +80,7 @@ _CONFIG_DEFINITIONS = {
'DELUGE_PASSWORD': (str, 'Deluge', ''),
'DELUGE_LABEL': (str, 'Deluge', ''),
'DELUGE_DONE_DIRECTORY': (str, 'Deluge', ''),
'DELUGE_DOWNLOAD_DIRECTORY': (str, 'Deluge', ''),
'DELUGE_PAUSED': (int, 'Deluge', 0),
'DESTINATION_DIR': (str, 'General', ''),
'DETECT_BITRATE': (int, 'General', 0),
@@ -269,6 +270,11 @@ _CONFIG_DEFINITIONS = {
'SONGKICK_ENABLED': (int, 'Songkick', 1),
'SONGKICK_FILTER_ENABLED': (int, 'Songkick', 0),
'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_HOST': (str, 'Subsonic', ''),
'SUBSONIC_PASSWORD': (str, 'Subsonic', ''),
@@ -318,7 +324,7 @@ _CONFIG_DEFINITIONS = {
'XBMC_UPDATE': (int, 'XBMC', 0),
'XBMC_USERNAME': (str, 'XBMC', ''),
'XLDPROFILE': (str, 'General', ''),
'BANDCAMP': (int, 'General', 1),
'BANDCAMP': (int, 'General', 0),
'BANDCAMP_DIR': (path, 'General', '')
}
+38 -59
View File
@@ -466,19 +466,56 @@ def _add_torrent_url(result):
def _add_torrent_file(result):
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):
_get_auth()
try:
# content is torrent file contents that needs to be encoded to base64
post_data = json.dumps({"method": "core.add_torrent_file",
"params": [result['name'] + '.torrent',
b64encode(result['content']).decode(), {}],
b64encode(result['content'].encode('utf8')),
options],
"id": 2})
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 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:
logger.error('Deluge: Adding torrent file failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines()
@@ -566,61 +603,3 @@ def setSeedRatio(result):
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
+4 -4
View File
@@ -184,7 +184,7 @@ def bytes_to_mb(bytes):
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:
return int(float(result.group(1)) * 1048576)
@@ -253,9 +253,9 @@ def replace_all(text, dic):
def replace_illegal_chars(string, type="file"):
if type == "file":
string = re.sub('[\?"*:|<>/]', '_', string)
string = re.sub(r"[\?\"*:|<>/]", "_", string)
if type == "folder":
string = re.sub('[:\?<>"|*]', '_', string)
string = re.sub(r"[:\?<>\"|*]", "_", string)
return string
@@ -386,7 +386,7 @@ def clean_musicbrainz_name(s, return_as_string=True):
def cleanTitle(title):
title = re.sub('[\.\-\/\_]', ' ', title).lower()
title = re.sub(r"[\.\-\/\_]", " ", title).lower()
# Strip out extra whitespace
title = ' '.join(title.split())
+29 -3
View File
@@ -27,7 +27,7 @@ from beets import config as beetsconfig
from beets import logging as beetslogging
from mediafile import MediaFile, FileTypeError, UnreadableFileError
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 logger, helpers, mb, music_encoder
from headphones import metadata
@@ -36,17 +36,41 @@ postprocessor_lock = threading.Lock()
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:
myDB = db.DBConnection()
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:
if album['FolderName']:
folder_name = album['FolderName']
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
elif album['Kind'] == 'bandcamp':
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)
if headphones.CONFIG.DOWNLOAD_DIR and not 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:
download_dirs.append(
headphones.CONFIG.DOWNLOAD_TORRENT_DIR.encode(headphones.SYS_ENCODING, 'replace'))
+89 -48
View File
@@ -46,6 +46,7 @@ from headphones.helpers import (
sab_replace_dots,
sab_replace_spaces,
sab_sanitize_foldername,
split_string
)
from headphones.types import Result
from headphones import logger, db, classes, sab, nzbget, request
@@ -55,6 +56,7 @@ from headphones import (
notifiers,
qbittorrent,
rutracker,
soulseek,
transmission,
utorrent,
)
@@ -278,6 +280,8 @@ def strptime_musicbrainz(date_str):
def do_sorted_search(album, new, losslessOnly, choose_specific_download=False):
NZB_PROVIDERS = (headphones.CONFIG.HEADPHONES_INDEXER or
headphones.CONFIG.NEWZNAB or
headphones.CONFIG.NZBSORG or
@@ -319,6 +323,10 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False):
if not results and headphones.CONFIG.BANDCAMP:
results = searchBandcamp(album, new, albumlength)
elif headphones.CONFIG.PREFER_TORRENTS == 2 and not choose_specific_download:
results = searchSoulseek(album, new, losslessOnly, albumlength)
else:
nzb_results = None
@@ -362,6 +370,7 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False):
(data, result) = preprocess(sorted_search_results)
if data and result:
#print(f'going to send stuff to downloader. data: {data}, album: {album}')
send_to_downloader(data, result, album)
@@ -384,7 +393,7 @@ def more_filtering(results, album, albumlength, new):
logger.debug('Target bitrate: %s kbps' % headphones.CONFIG.PREFERRED_BITRATE)
if albumlength:
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:
low_size_limit = targetsize * int(
headphones.CONFIG.PREFERRED_BITRATE_LOW_BUFFER) / 100
@@ -401,14 +410,14 @@ def more_filtering(results, album, albumlength, new):
if low_size_limit and result.size < low_size_limit:
logger.info(
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
if high_size_limit and result.size > high_size_limit:
logger.info(
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
if not (allow_lossless and 'flac' in result.title.lower()):
@@ -448,7 +457,7 @@ def sort_search_results(resultlist, album, new, albumlength):
# Add a priority if it has any of the preferred words
results_with_priority = []
preferred_words = helpers.split_string(headphones.CONFIG.PREFERRED_WORDS)
preferred_words = split_string(headphones.CONFIG.PREFERRED_WORDS)
for result in resultlist:
priority = 0
for word in preferred_words:
@@ -549,8 +558,8 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
':': ''
}
cleanalbum = unidecode(helpers.replace_all(album['AlbumTitle'], replacements)).strip()
cleanartist = unidecode(helpers.replace_all(album['ArtistName'], replacements)).strip()
cleanalbum = unidecode(replace_all(album['AlbumTitle'], replacements)).strip()
cleanartist = unidecode(replace_all(album['ArtistName'], replacements)).strip()
# Use the provided search term if available, otherwise build a search term
if album['SearchTerm']:
@@ -627,7 +636,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
size = int(item.links[1]['length'])
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:
logger.error("An unknown error occurred trying to parse the feed: %s" % e)
@@ -698,7 +707,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
size = int(item.links[1]['length'])
if all(word.lower() in title.lower() for word in term.split()):
logger.info(
'Found %s. Size: %s' % (title, helpers.bytes_to_mb(size)))
'Found %s. Size: %s' % (title, bytes_to_mb(size)))
resultlist.append(Result(title, size, url, provider, 'nzb', True))
else:
logger.info('Skipping %s, not all search term words found' % title)
@@ -748,7 +757,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
size = int(item.links[1]['length'])
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:
logger.exception("Unhandled exception while parsing feed")
@@ -795,7 +804,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
size = int(item['sizebytes'])
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:
logger.exception("Unhandled exception")
@@ -818,7 +827,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
def send_to_downloader(data, result, album):
logger.info(
f"Found best result from {result.provider}: <a href=\"{result.url}\">"
f"{result.title}</a> - {helpers.bytes_to_mb(result.size)}"
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
kind = result.kind
@@ -826,7 +835,7 @@ def send_to_downloader(data, result, album):
torrentid = None
if kind == 'nzb':
folder_name = helpers.sab_sanitize_foldername(result.title)
folder_name = sab_sanitize_foldername(result.title)
if headphones.CONFIG.NZB_DOWNLOADER == 1:
@@ -848,9 +857,9 @@ def send_to_downloader(data, result, album):
(replace_spaces, replace_dots) = sab.checkConfig()
if replace_dots:
folder_name = helpers.sab_replace_dots(folder_name)
folder_name = sab_replace_dots(folder_name)
if replace_spaces:
folder_name = helpers.sab_replace_spaces(folder_name)
folder_name = sab_replace_spaces(folder_name)
else:
nzb_name = folder_name + '.nzb'
@@ -867,11 +876,15 @@ def send_to_downloader(data, result, album):
except Exception as e:
logger.error('Couldn\'t write NZB file: %s', e)
return
elif kind == 'bandcamp':
folder_name = bandcamp.download(album, bestqual)
folder_name = bandcamp.download(album, result)
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:
folder_name = '%s - %s [%s]' % (
unidecode(album['ArtistName']).replace('/', '_'),
@@ -882,7 +895,7 @@ def send_to_downloader(data, result, album):
if headphones.CONFIG.TORRENT_DOWNLOADER == 0:
# 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)
if result.url.lower().startswith("magnet:"):
@@ -987,10 +1000,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?")
return
# This pauses the torrent right after it is added
if headphones.CONFIG.DELUGE_PAUSED:
deluge.setTorrentPause({'hash': torrentid})
# Set Label
if headphones.CONFIG.DELUGE_LABEL:
deluge.setTorrentLabel({'hash': torrentid})
@@ -1000,10 +1009,6 @@ def send_to_downloader(data, result, album):
if seed_ratio is not None:
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
folder_name = deluge.getTorrentFolder({'hash': torrentid})
if folder_name:
@@ -1221,16 +1226,16 @@ def verifyresult(title, artistterm, term, lossless):
return False
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():
logger.info("Removed '%s' from results because it contains ignored word: '%s'",
title, each_word)
return False
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:
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):
continue
else:
@@ -1264,7 +1269,7 @@ def verifyresult(title, artistterm, term, lossless):
cleantoken = ''.join(c for c in token if c not in string.punctuation)
if not has_token(title, cleantoken):
dic = {'!': 'i', '$': 's'}
dumbtoken = helpers.replace_all(token, dic)
dumbtoken = replace_all(token, dic)
if not has_token(title, dumbtoken):
logger.info(
"Removed from results: %s (missing tokens: [%s, %s, %s])",
@@ -1297,9 +1302,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)
semi_cleanartist = helpers.replace_all(album['ArtistName'], replacements)
semi_cleanartist = replace_all(album['ArtistName'], replacements)
cleanartist = unidecode(semi_cleanartist)
# Use provided term if available, otherwise build our own (this code needs to be cleaned up since a lot
@@ -1434,7 +1439,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
if all(word.lower() in title.lower() for word in term.split()):
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))
else:
logger.info(
@@ -1510,7 +1515,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
size = int(desc_match.group(1))
url = item.link
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:
logger.error(
"An error occurred while trying to parse the response from Waffles.ch: %s",
@@ -1855,7 +1860,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
formatted_size = re.search('Size (.*),', str(item)).group(1).replace(
'\xa0', ' ')
size = helpers.piratesize(formatted_size)
size = piratesize(formatted_size)
if size < maxsize and minimumseeders < seeds and url is not None:
match = True
@@ -1909,7 +1914,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
"href"] # Magnet link. The actual download link is not based on the URL
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:
match = True
@@ -1936,25 +1941,49 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
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
def preprocess(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}
if result.kind == 'soulseek':
return True, result
if result.kind == 'torrent':
# rutracker always needs the torrent data
@@ -2000,12 +2029,24 @@ def preprocess(resultlist):
return True, result
# 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
if result.kind == 'magnet':
elif result.kind == 'magnet':
magnet_link = result.url
return "d10:magnet-uri%d:%se" % (len(magnet_link), magnet_link), result
elif result.kind == 'bandcamp':
return True, result
else:
if result.provider == 'headphones':
return request.request_content(
+185
View File
@@ -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')
+8 -1
View File
@@ -1183,6 +1183,7 @@ class WebInterface(object):
"deluge_password": headphones.CONFIG.DELUGE_PASSWORD,
"deluge_label": headphones.CONFIG.DELUGE_LABEL,
"deluge_done_directory": headphones.CONFIG.DELUGE_DONE_DIRECTORY,
"deluge_download_directory": headphones.CONFIG.DELUGE_DOWNLOAD_DIRECTORY,
"deluge_paused": checked(headphones.CONFIG.DELUGE_PAUSED),
"utorrent_host": headphones.CONFIG.UTORRENT_HOST,
"utorrent_username": headphones.CONFIG.UTORRENT_USERNAME,
@@ -1197,6 +1198,8 @@ class WebInterface(object):
"torrent_downloader_deluge": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 3),
"torrent_downloader_qbittorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 4),
"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),
"blackhole_dir": headphones.CONFIG.BLACKHOLE_DIR,
"usenet_retention": headphones.CONFIG.USENET_RETENTION,
@@ -1296,6 +1299,7 @@ class WebInterface(object):
"prefer_torrents_0": radio(headphones.CONFIG.PREFER_TORRENTS, 0),
"prefer_torrents_1": radio(headphones.CONFIG.PREFER_TORRENTS, 1),
"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_1": radio(headphones.CONFIG.MAGNET_LINKS, 1),
"magnet_links_2": radio(headphones.CONFIG.MAGNET_LINKS, 2),
@@ -1415,7 +1419,10 @@ class WebInterface(object):
"join_apikey": headphones.CONFIG.JOIN_APIKEY,
"join_deviceid": headphones.CONFIG.JOIN_DEVICEID,
"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():
+18
View File
@@ -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')
+44
View File
@@ -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'
)
+91
View File
@@ -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()
+26
View File
@@ -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
+103
View File
@@ -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()
+29
View File
@@ -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()
+93
View File
@@ -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
+42
View File
@@ -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
+75
View File
@@ -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
+124
View File
@@ -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()
+126
View File
@@ -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()
+51
View File
@@ -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()
+53
View File
@@ -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()
+78
View File
@@ -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()
+157
View File
@@ -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()
+79
View File
@@ -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()
+123
View File
@@ -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