mirror of
https://github.com/rembo10/headphones.git
synced 2026-09-09 16:22:52 +01:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f89fed4dac | ||
|
|
ace2b4f26a | ||
|
|
ebba115443 | ||
|
|
3e354ff128 | ||
|
|
b8168ec8eb | ||
|
|
c0c636d545 | ||
|
|
33d1d17c97 | ||
|
|
8fd13621f5 | ||
|
|
56674adfea | ||
|
|
489c6cbe71 | ||
|
|
6afe31bffc | ||
|
|
138d01db4a |
+19
-1
@@ -1,7 +1,25 @@
|
||||
# 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
|
||||
Released 26 November 2023
|
||||
R eleased 26 November 2023
|
||||
|
||||
Highlights:
|
||||
* Dependency updates to work with > Python 3.11
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
soup = BeautifulSoup(content, "html5lib")
|
||||
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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,20 +36,44 @@ 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':
|
||||
download_dir = headphones.CONFIG.DOWNLOAD_DIR
|
||||
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
|
||||
download_dir = headphones.CONFIG.BANDCAMP_DIR
|
||||
else:
|
||||
if headphones.CONFIG.DELUGE_DONE_DIRECTORY and headphones.CONFIG.TORRENT_DOWNLOADER == 3:
|
||||
download_dir = headphones.CONFIG.DELUGE_DONE_DIRECTORY
|
||||
@@ -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'))
|
||||
@@ -1221,7 +1247,7 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
|
||||
myDB = db.DBConnection()
|
||||
|
||||
for folder in folders:
|
||||
folder_basename = os.path.basename(folder)
|
||||
folder_basename = os.path.basename(folder).decode(headphones.SYS_ENCODING, 'replace')
|
||||
logger.info('Processing: %s', folder_basename)
|
||||
|
||||
# Attempt 1: First try to see if there's a match in the snatched table,
|
||||
|
||||
+107
-72
@@ -38,25 +38,27 @@ from unidecode import unidecode
|
||||
import headphones
|
||||
from headphones.common import USER_AGENT
|
||||
from headphones.helpers import (
|
||||
bytes_to_mb,
|
||||
has_token,
|
||||
piratesize,
|
||||
bytes_to_mb,
|
||||
has_token,
|
||||
piratesize,
|
||||
replace_all,
|
||||
replace_illegal_chars,
|
||||
sab_replace_dots,
|
||||
sab_replace_spaces,
|
||||
replace_illegal_chars,
|
||||
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
|
||||
from headphones import (
|
||||
bandcamp,
|
||||
deluge,
|
||||
notifiers,
|
||||
qbittorrent,
|
||||
rutracker,
|
||||
transmission,
|
||||
utorrent,
|
||||
deluge,
|
||||
notifiers,
|
||||
qbittorrent,
|
||||
rutracker,
|
||||
soulseek,
|
||||
transmission,
|
||||
utorrent
|
||||
)
|
||||
|
||||
# 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):
|
||||
|
||||
|
||||
NZB_PROVIDERS = (headphones.CONFIG.HEADPHONES_INDEXER or
|
||||
headphones.CONFIG.NEWZNAB or
|
||||
headphones.CONFIG.NZBSORG or
|
||||
@@ -318,29 +322,28 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False):
|
||||
results = searchNZB(album, new, losslessOnly, albumlength)
|
||||
|
||||
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:
|
||||
|
||||
nzb_results = None
|
||||
torrent_results = None
|
||||
bandcamp_results = None
|
||||
nzb_results = []
|
||||
torrent_results = []
|
||||
bandcamp_results = []
|
||||
|
||||
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:
|
||||
torrent_results = searchTorrent(album, new, losslessOnly, albumlength,
|
||||
choose_specific_download)
|
||||
torrent_results = searchTorrent(album, new, losslessOnly,
|
||||
albumlength, choose_specific_download)
|
||||
|
||||
if headphones.CONFIG.BANDCAMP:
|
||||
bandcamp_results = searchBandcamp(album, new, albumlength)
|
||||
|
||||
if not nzb_results:
|
||||
nzb_results = []
|
||||
|
||||
if not torrent_results:
|
||||
torrent_results = []
|
||||
|
||||
results = nzb_results + torrent_results + bandcamp_results
|
||||
|
||||
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)
|
||||
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 +404,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 +451,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 +552,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 +630,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 +701,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 +751,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 +798,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 +821,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 +829,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 +851,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'
|
||||
@@ -869,9 +872,13 @@ def send_to_downloader(data, result, album):
|
||||
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 +889,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 +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?")
|
||||
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 +1003,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 +1220,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 +1263,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 +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)
|
||||
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 +1433,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 +1509,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 +1854,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 +1908,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 +1935,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':
|
||||
headers = {'User-Agent': USER_AGENT}
|
||||
|
||||
if result.kind == 'soulseek':
|
||||
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 == 'torrent':
|
||||
|
||||
# rutracker always needs the torrent data
|
||||
@@ -2000,12 +2023,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(
|
||||
|
||||
@@ -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_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():
|
||||
|
||||
@@ -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