From 489c6cbe71f2080581000a11b81b99eb360e5cd5 Mon Sep 17 00:00:00 2001 From: Elmer Date: Tue, 27 Feb 2024 18:37:46 +0100 Subject: [PATCH 1/3] Basic soulseek support --- data/interfaces/default/config.html | 34 ++++- headphones/config.py | 5 + headphones/postprocessor.py | 36 +++++- headphones/searcher.py | 55 ++++++++- headphones/soulseek.py | 185 ++++++++++++++++++++++++++++ headphones/webserve.py | 8 +- 6 files changed, 311 insertions(+), 12 deletions(-) create mode 100644 headphones/soulseek.py diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index a74609534..eed8222b7 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -477,7 +477,33 @@

Settings

NZBs Torrents - No Preference + Soulseek + No Preference + + + + +
+ Soulseek +
+ + +
+
+ + +
+
+ + +
+
+ +
@@ -589,7 +615,6 @@

Settings

-
Other
@@ -597,6 +622,11 @@

Settings

+
+
+ +
+
diff --git a/headphones/config.py b/headphones/config.py index 6b8dabac7..6a07c8fca 100644 --- a/headphones/config.py +++ b/headphones/config.py @@ -269,6 +269,11 @@ def __repr__(self): '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', ''), diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index 1c5dd4171..7773c9af3 100755 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -27,7 +27,7 @@ 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 @@ 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')) diff --git a/headphones/searcher.py b/headphones/searcher.py index 303f1cd16..f468085cc 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -39,7 +39,8 @@ from headphones.common import USER_AGENT from headphones.types import Result from headphones import logger, db, helpers, classes, sab, nzbget, request -from headphones import utorrent, transmission, notifiers, rutracker, deluge, qbittorrent, bandcamp +from headphones import utorrent, transmission, notifiers, rutracker, deluge, qbittorrent, bandcamp, soulseek + # Magnet to torrent services, for Black hole. Stolen from CouchPotato. TORRENT_TO_MAGNET_SERVICES = [ @@ -260,6 +261,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 @@ -300,7 +303,11 @@ 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 @@ -344,6 +351,7 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False): (data, result) = preprocess(sorted_search_results) if data and result: + #print(f'going to send stuff to downloader. data: {data}, album: {album}') send_to_downloader(data, result, album) @@ -849,11 +857,15 @@ def send_to_downloader(data, result, album): except Exception as e: logger.error('Couldn\'t write NZB file: %s', e) return - elif kind == 'bandcamp': folder_name = bandcamp.download(album, 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('/', '_'), @@ -1918,14 +1930,49 @@ def set_proxy(proxy_url): 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: - headers = {'User-Agent': USER_AGENT} + if result.kind == 'soulseek': + return True, result + if result.kind == 'torrent': # rutracker always needs the torrent data diff --git a/headphones/soulseek.py b/headphones/soulseek.py new file mode 100644 index 000000000..2802c35cd --- /dev/null +++ b/headphones/soulseek.py @@ -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') \ No newline at end of file diff --git a/headphones/webserve.py b/headphones/webserve.py index 6e564763f..bd1c375d8 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -1197,6 +1197,8 @@ def config(self): "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 +1298,7 @@ def config(self): "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 +1418,10 @@ def config(self): "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(): From 81f6d9e001368ed3052606b7cd6fb6a8161157cd Mon Sep 17 00:00:00 2001 From: Elmer Date: Wed, 28 Feb 2024 15:22:00 +0100 Subject: [PATCH 2/3] Added general maximum bitrate setting --- data/interfaces/default/config.html | 5 +++++ headphones/config.py | 1 + headphones/searcher.py | 7 ++++++- headphones/webserve.py | 3 ++- 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index eed8222b7..6998c3682 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -831,6 +831,11 @@

Settings

Quality
+
+ + + 0 for unlimited +