From 62fc4201ca6f671f9a4e8aa78a842c6e1ff1794c Mon Sep 17 00:00:00 2001 From: unfaiyted Date: Thu, 25 Jul 2024 16:34:12 -0500 Subject: [PATCH] chore: update --- .gitignore | 5 + .idea/dataSources.xml | 15 ++ .idea/sqldialects.xml | 6 + .idea/vcs.xml | 6 + emby-scripts.iml | 8 +- src/__pycache__/config.cpython-312.pyc | Bin 979 -> 1701 bytes src/actions/.cache | 2 +- src/actions/find_unmatched_songs.py | 82 +++++++++ src/actions/move_org_tv_shows.py | 173 ++++++++++++++++++ src/actions/move_unorg_tv_to_org.py | 141 ++++++++++++++ src/actions/series_mix_playlist_generator.py | 81 +++++++- src/actions/sort_downloaded_tracks.py | 108 +++++++++++ src/actions/sync_spotify_liked.py | 141 ++++++++++++++ src/actions/sync_spotify_playlists.py | 41 ++++- src/actions/sync_user_playlists.py | 2 +- .../__pycache__/emby_client.cpython-312.pyc | Bin 32121 -> 32036 bytes .../spotify_client.cpython-312.pyc | Bin 3617 -> 4596 bytes src/clients/emby_client.py | 102 +++++------ src/clients/spotify_client.py | 21 ++- src/config.py | 50 +++-- src/main.py | 8 +- .../emby_integration_test.cpython-312.pyc | Bin 8866 -> 10531 bytes src/tests/emby_integration_test.py | 30 ++- src/types/__init__.py | 33 ++++ 24 files changed, 953 insertions(+), 102 deletions(-) create mode 100644 .gitignore create mode 100644 .idea/dataSources.xml create mode 100644 .idea/sqldialects.xml create mode 100644 .idea/vcs.xml create mode 100644 src/actions/find_unmatched_songs.py create mode 100644 src/actions/move_org_tv_shows.py create mode 100644 src/actions/move_unorg_tv_to_org.py create mode 100644 src/actions/sort_downloaded_tracks.py create mode 100644 src/actions/sync_spotify_liked.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7dc0891 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea +src/action/unmatched_songs.db +/.env +.env +/src/actions/unmatched_songs.db diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..a5f6425 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,15 @@ + + + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:C:\Users\Dane Miller\codebase\emby-scripts\src\actions\unmatched_songs.db + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..4ba796d --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/emby-scripts.iml b/emby-scripts.iml index 70d05d5..90fcab9 100644 --- a/emby-scripts.iml +++ b/emby-scripts.iml @@ -1,8 +1,14 @@ + - + + + + + + diff --git a/src/__pycache__/config.cpython-312.pyc b/src/__pycache__/config.cpython-312.pyc index ef1ede1f2d26da8d0465c7958fbe84e7b31567a5..944448e097a4cf86bca91eeefcd0bbe2dbd46c85 100644 GIT binary patch literal 1701 zcmZ{k$#2s@6voGnn`Upbl{T~m1t=J?2?+$HENNnjA)A~8lqh19#s&qYG1vjA5{Dc> zf)mZj?x|FVS!*LL9gudO+%lnb@&VE6?Hc``-J_oAEYZf8Hg*0j;ZWtsiAc%lX*P@_apqbsHsQ$7Hsf|= zTsAZ5$S^kJabzZJ#_PzqZN?}0rGV&O!MET@Pug_Qk@46}$dU2d%#p%s4V(n>n@_VWY^MmOIZ{&(UN?s6wHVN8wVj zQ0DJfVLHom`7-1)D1Ina;?e?FMzL{P;4-|xrOU7)@F+OWmePeHhdf+vwTcT_6c|OR zA`c&NRm)XMoIq9xniO{VRF1Q3F;yx(DF_+lA63o$SSG_g=F=PsjVh)geatl;b3%zP zsfLKzlQzfzVrqX)ZRqLwK#1-<6FTLDUCP*tHWWVYee5$+$d!b_=)<3mAwsfx%O7_-%yTM#i zr+2*D-a+!3F?)T$#E0`|^wk}Hn;$IQHsF(fO^b+Rgq={izYX(90h2huR!&JB%FPN@{GM z)pJJVeDBc_j~5lRYQH)-aoLDp>E#bEuy+IqC$=KmTz^hqF~aA15BC<9bYZ8uT^*dg zYb@OR$|RH%L*zI V2E5;a|6TAeGfSnYKY-|H_CLq)VBi1% delta 669 zcmXYuPfHs?7{+Hen>GGfwQ7o~LeqkURFdr9aaY8HVk>PDq~f7-3A6i7biriP%_?l5 zB!w1wEjjoJdg`a>rI%g=FJVvp2+|%3J(SMI4!pma_j%@h7?{t>&6M(8QDzWde_uGQ zZ=aOHV3jvlNJP&Op!W!bFJfvVL{Kc2GTvQt$ISocSB)R85JN-&M8p$5MKT}~2{I%L z5|JTGrdW#vWE{-Wg}4oU`5VF?R%By# 0: + logging.info(f"Skipping '{track_name}' by {artist_name} - already downloaded") + continue + + logging.info(f"Processing unmatched song: '{track_name}' by {artist_name}") + + # Use spotdl to find and download the song + try: + command = f'"{spotdl_path}" download "{remove_special_characters(artist_name)} - {remove_special_characters(track_name)}" --output "{output_dir}"' + logging.info(f"Running command: {command}") + subprocess.run(command, shell=True, check=True) + logging.info(f"Successfully downloaded '{track_name}' by {artist_name}") + + # Insert the downloaded song into the database + c.execute('INSERT INTO downloaded_songs VALUES (?, ?, ?)', (track_name, artist_name, album_name)) + conn.commit() + except subprocess.CalledProcessError as e: + logging.error(f"Error downloading '{track_name}' by {artist_name}") + logging.error(f"Error message: {str(e)}") + + # Calculate the percentage of songs processed + percentage = (index / total_unmatched_songs) * 100 + logging.info(f"Progress: {index}/{total_unmatched_songs} ({percentage:.2f}%)") + +# Close the database connection +conn.close() \ No newline at end of file diff --git a/src/actions/move_org_tv_shows.py b/src/actions/move_org_tv_shows.py new file mode 100644 index 0000000..f3c7340 --- /dev/null +++ b/src/actions/move_org_tv_shows.py @@ -0,0 +1,173 @@ +import os +import shutil +import requests +import json +from rapidfuzz import fuzz, process + +# Configuration +SONARR_API_KEY = '760b6730464c443ab89bbcc96bc0b581' +SONARR_URL = 'http://192.168.0.120:8989/api/v3' +SOURCE_DIR = r"\\192.168.0.120\i\downloads\ORG_TV" +DEST_BASE_PATH = r'\\192.168.0.120\raid\tv' +MATCH_THRESHOLD = 80 # Confidence for fuzzy finding logic + + +DRY_RUN = False + + +def get_directories(path): + """Get a list of all direct children directories in the given path.""" + return [d for d in os.listdir(path) if os.path.isdir(os.path.join(path, d))] + + +def get_all_shows(): + """Get a list of all TV shows in Sonarr.""" + response = requests.get(f'{SONARR_URL}/series', headers={'X-Api-Key': SONARR_API_KEY}) + response.raise_for_status() + return response.json() + + +def search_sonarr(show_name): + """Search for a TV show in Sonarr by name.""" + response = requests.get(f'{SONARR_URL}/series/lookup', params={'term': show_name}, + headers={'X-Api-Key': SONARR_API_KEY}) + response.raise_for_status() + return response.json() + + +def get_show_path(show_id): + """Get the path of a TV show in Sonarr by its ID.""" + response = requests.get(f'{SONARR_URL}/series/{show_id}', headers={'X-Api-Key': SONARR_API_KEY}) + response.raise_for_status() + return response.json()['path'] + + +def adjust_path_for_network(path): + """Adjust the Sonarr path to the network path.""" + network_path = path.replace('/raid/tv', DEST_BASE_PATH) + return network_path.replace('/', '\\') + +def merge_folders(src, dst): + """Merge source folder into destination folder.""" + if DRY_RUN: + print(f'[DRY RUN] Would merge {src} into {dst}') + return + if not os.path.exists(dst): + shutil.move(src, dst) + print(f'Moved {src} to {dst}') + else: + for item in os.listdir(src): + s = os.path.join(src, item) + d = os.path.join(dst, item) + if os.path.isdir(s): + merge_folders(s, d) + else: + shutil.move(s, d) + print(f'Moved {s} to {d}') + os.rmdir(src) + +def move_folders(source, destination): + """Move season folders from source to destination.""" + for item in os.listdir(source): + s = os.path.join(source, item) + d = os.path.join(destination, item) + if os.path.isdir(s): + merge_folders(s, d) + +def rescan_series(show_id): + """Trigger a rescan of the series in Sonarr.""" + payload = {'name': 'RescanSeries', 'seriesId': show_id} + response = requests.post(f'{SONARR_URL}/command', json=payload, headers={'X-Api-Key': SONARR_API_KEY}) + response.raise_for_status() + return response.json() + +def fuzzy_match(show_name, shows): + """Find the best fuzzy match for a show name from a list of shows.""" + choices = {show['title'].lower(): show for show in shows} + best_match = process.extractOne(show_name.lower(), choices.keys(), scorer=fuzz.ratio) + if best_match and best_match[1] >= MATCH_THRESHOLD: + return choices[best_match[0]] + return None + + +def main(): + all_shows = get_all_shows() + directories = get_directories(SOURCE_DIR) + + for directory in directories: + print(f'Processing directory: {directory}') + + # shows = search_sonarr(directory) + best_match = fuzzy_match(directory, all_shows) + + if not best_match: + # remove 4 digit year if exists. + # redo fuzzy match w/out year + # validate that the year of the tv show is within 2 years of the + # year in sonarr. + # assign match as best_match + + # Remove 4 digit year if exists + name_without_year = ' '.join([word for word in directory.split() if not word.isdigit() or len(word) != 4]) + best_match = fuzzy_match(name_without_year, all_shows) + + if best_match: + show_year = best_match.get('year', None) + dir_year = next((word for word in directory.split() if word.isdigit() and len(word) == 4), None) + + if dir_year and show_year: + dir_year = int(dir_year) + if abs(show_year - dir_year) <= 2: + print(f'Using fuzzy match for show [{directory}] without year, matched with [{best_match["title"]}]') + else: + best_match = None + else: + best_match = None + if best_match: + show = best_match + # print(json.dumps(show, indent=4)) + + if show.get("id"): + print(f'Found show [{show["title"]}] with id [{show["id"]}]') + + show_path = get_show_path(show['id']) + adjusted_show_path = adjust_path_for_network(show_path) + # print(f'Correct path for {show["title"]} is: {adjusted_show_path}') + source_path = os.path.join(SOURCE_DIR, directory) + # print(f'Source path: {source_path}') + # print(f'Destination path: {adjusted_show_path}') + + move_folders(source_path, adjusted_show_path) + # Check if the parent directory is empty and remove it + # if DRY_RUN: + # print(f'[DRY RUN] Would delete {source_path} if empty') + # else: + # if not os.listdir(source_path): + # os.rmdir(source_path) + # print(f'Deleted empty directory: {source_path}') + # else: + # print(f'Directory not empty, did not delete: {source_path}') + + if DRY_RUN: + print(f'[DRY RUN] Would trigger rescan for series ID: {best_match["id"]}') + else: + rescan_response = rescan_series(best_match['id']) + print(f'Triggered rescan for series ID: {best_match["id"]} with response: {rescan_response}') + else: + print(f'No matching show found for: {directory}') + + #TODO: check if the directory is empty and delete it if it is. + # Check if the directory is empty and delete it if it is + if DRY_RUN: + print(f'[DRY RUN] Would delete {os.path.join(SOURCE_DIR, directory)} if empty') + else: + directory_path = os.path.join(SOURCE_DIR, directory) + if not os.listdir(directory_path): + os.rmdir(directory_path) + print(f'Deleted empty directory: {directory_path}') + else: + print(f'Directory not empty, did not delete: {directory_path}') + + +if __name__ == "__main__": + main() diff --git a/src/actions/move_unorg_tv_to_org.py b/src/actions/move_unorg_tv_to_org.py new file mode 100644 index 0000000..15cafa2 --- /dev/null +++ b/src/actions/move_unorg_tv_to_org.py @@ -0,0 +1,141 @@ +import os +import re +import shutil +import logging + + +def organize_episodes(root_folder, destination_root, dry_run=False): + # Configure logging + logging.basicConfig(level=logging.INFO, format="%(message)s") + + # Define known movie file formats + movie_extensions = [".avi", ".mp4", ".mkv", ".mov", ".wmv"] + + for folder_name in os.listdir(root_folder): + print('Looking in', folder_name) + folder_path = os.path.join(root_folder, folder_name) + if os.path.isdir(folder_path): + match_full = re.match(r"(.+)\.S(\d+)E(\d+)\.(.+)", folder_name, flags=re.IGNORECASE | re.MULTILINE) + match_season = re.match(r".*(?:S|Season)\s*(\d+).*", folder_name.replace("_","."), flags=re.IGNORECASE) + + if match_full: + try: + series_name, season_number, episode_number, episode_name = match_full.groups() + series_name = series_name.replace(".", " ").strip().title() + episode_name = episode_name.replace(".", " ").strip() + + # episode_name = folder_name.split(f".S{season_number}E{episode_number}.")[-1].split(".")[0].replace(".", " ").strip() + except AttributeError: + continue + + season_folder = os.path.join(destination_root, series_name, f"Season {int(season_number)}") + + # ( + # series_name, + # season_number, + # episode_number, + # episode_name, + # ) = match_full.groups() + + # series_name = series_name.replace( + # ".", " " + # ).title() # Replace periods with spaces in the series name + # season_folder = os.path.join( + # destination_root, series_name, f"Season {int(season_number)}" + # ) + + print('Found full match', series_name, season_number) + + if not dry_run: + os.makedirs(season_folder, exist_ok=True) + + for file_name in os.listdir(folder_path): + file_path = os.path.join(folder_path, file_name) + if os.path.isfile(file_path): + _, extension = os.path.splitext(file_name) + if extension.lower() in movie_extensions: + new_file_name = f"{series_name} S{season_number.zfill(2)}E{episode_number.zfill(2)} {episode_name.replace('.', ' ')}{extension}" + new_file_path = os.path.join(season_folder, new_file_name) + + if dry_run: + logging.info( + f"[DRY RUN] Would copy {file_name} to {new_file_path}" + ) + else: + print(f'[DRY RUN] Copying {file_name} to {new_file_path}') + shutil.copy2(file_path, new_file_path) + logging.info(f"Copied {file_name} to {new_file_path}") + else: + if dry_run: + logging.info( + f"[DRY RUN] Would delete {file_name} (unsupported format)" + ) + else: + os.remove(file_path) + logging.info( + f"Deleted {file_name} (unsupported format)" + ) + + if dry_run: + logging.info(f"[DRY RUN] Would remove empty folder: {folder_path}") + else: + shutil.rmtree(folder_path) + logging.info(f"Removed empty folder: {folder_path}") + elif match_season and not match_full: + season_number = match_season.groups(1)[0] + series_name = folder_name.split(f"S{season_number}")[0].replace(".", " ").strip().title() + + print(f"Season {season_number} found match.") + + season_folder = os.path.join(destination_root, series_name, f"Season {season_number}") + + if not dry_run: + os.makedirs(season_folder, exist_ok=True) + + for file_name in os.listdir(folder_path): + file_path = os.path.join(folder_path, file_name) + if os.path.isfile(file_path): + _, extension = os.path.splitext(file_name) + if extension.lower() in movie_extensions: + file_name = file_name.replace("_", ".") + episode_match = re.match(r".*S(\d+)E(\d+)\.(.+)", file_name, flags=re.IGNORECASE) + + if episode_match: + print('Found episode match', episode_match) + _, episode_number, episode_name = episode_match.groups() + episode_name = episode_name.replace(".", " ").replace("_", " ").title() + new_file_name = f"{series_name} S{season_number.zfill(2)}E{episode_number.zfill(2)} {episode_name}{extension}" + new_file_path = os.path.join(season_folder, new_file_name) + + if dry_run: + logging.info(f"[DRY RUN] Would copy {file_name} to {new_file_path}") + else: + shutil.copy2(file_path, new_file_path) + logging.info(f"Copied {file_name} to {new_file_path}") + else: + logging.warning(f"File {file_name} does not match episode pattern and was skipped.") + else: + if dry_run: + logging.info(f"[DRY RUN] Would delete {file_name} (unsupported format)") + else: + os.remove(file_path) + logging.info(f"Deleted {file_name} (unsupported format)") + else: + print("No match", match_full, match_season) + + if dry_run: + logging.info(f"[DRY RUN] Would remove empty folder: {folder_path}") + else: + if not os.listdir(folder_path): + shutil.rmtree(folder_path) + logging.info(f"Removed empty folder: {folder_path}") + + +# Usage example +root_folder = r"\\192.168.0.120\i\downloads\TV" +destination_root = r"\\192.168.0.120\i\downloads\ORG_TV" + +dry_run = False # Set False to perform actual file operations + +print("Moving unorganized episodes to organized folder") +organize_episodes(root_folder, destination_root, dry_run) diff --git a/src/actions/series_mix_playlist_generator.py b/src/actions/series_mix_playlist_generator.py index db2a822..039897a 100644 --- a/src/actions/series_mix_playlist_generator.py +++ b/src/actions/series_mix_playlist_generator.py @@ -1,14 +1,64 @@ from src.clients.emby_client import EmbyClient from src.config import Config +import re + + +def parse_episode_string(episode_string): + match = re.match(r"S(\d+)E(\d+)", episode_string) + if match: + season_number = int(match.group(1)) + episode_number = int(match.group(2)) + return season_number, episode_number + else: + raise ValueError(f"Invalid episode string format: {episode_string}") + + # Create an instance of the Emby client + -# Create an instance of the Emby client emby = EmbyClient(Config.EMBY_URL, Config.EMBY_USERNAME, Config.EMBY_PASSWORD) # Define an array of TV show names -tv_shows = ["The Office (US)", "Parks and Recreation", "Bob's Burgers"] +# tv_shows = ["Superstore", "Fresh Off the Boat", "What If...?", "Friends", "Avatar: The Last Airbender"] +# +# starting_episodes = { +# "Superstore": "S03E09", # Start from Season 2, Episode 3 of The Office () +# "Fresh Off the Boat": "S03E08", +# "What If...?": "S01E05", +# "Friends": "S01E01", +# "Avatar: The Last Airbender": "S01E19" +# } +# tv_shows = ["The Following", "Ozark", "Better Call Saul", +# "The Leftovers", "Halo"] +# +# +# starting_episodes = { +# "The Following": "S01E09", # Start from Season 2, Episode 3 of The Office () +# "Ozark": "S01E02", +# "Better Call Saul": "S05E03", +# "The Leftovers": "S01E01", +# "Halo": "S01E01" +# } +# +# Superstore S03E09 +# "The Following": "S01E09" +# "Ozark": "S01E02", +# "Fresh Off the Boat": "S03E08", +# "Better Call Saul": "S05E03", +# "What If...?" "S01E05" + +tv_shows = ["The Office (US)", "Bob's Burgers", "Parks and Recreation"] + +starting_episodes = { + "The Office (US)": "S01E01", # Start from Season 2, Episode 3 of The Office () + "Bob's Burgers": "S01E01", + "Parks and Recreations": "S01E01" +} + +# Toggle variable to control marking episodes as unwatched +mark_as_unwatched = True # Create a new playlist -playlist_name = "Bob's Office Park" +playlist_name = "Bob's Office Park - Mix" playlist_type = "shows" playlist = emby.create_playlist(playlist_name, playlist_type) @@ -31,7 +81,20 @@ for show_name in all_episodes: max_episodes = max(len(episodes) for episodes in all_episodes.values()) # Initialize episode counters for each show -episode_counters = {show_name: 0 for show_name in tv_shows} +episode_counters = {} +for show_name in tv_shows: + start_episode_string = starting_episodes.get(show_name, "S01E01") + start_season, start_episode = parse_episode_string(start_episode_string) + + episode_counter = 0 + for episode in all_episodes[show_name]: + if episode["ParentIndexNumber"] > start_season or ( + episode["ParentIndexNumber"] == start_season and episode["IndexNumber"] >= start_episode + ): + break + episode_counter += 1 + + episode_counters[show_name] = episode_counter # Initialize a set to keep track of added episode IDs added_episode_ids = set() @@ -46,12 +109,18 @@ for i in range(max_episodes): if episode_id not in added_episode_ids: emby.add_item_to_playlist(playlist["Id"], episode_id) print(f"Added episode: {show_name} - S{episode['ParentIndexNumber']:02}E{episode['IndexNumber']:02}") + + if mark_as_unwatched: + emby.mark_as_unwatched(episode_id) + print(f"Marked as unwatched: {show_name} - S{episode['ParentIndexNumber']:02}E{episode['IndexNumber']:02}") + added_episode_ids.add(episode_id) else: - print(f"Skipped duplicate episode: {show_name} - S{episode['ParentIndexNumber']:02}E{episode['IndexNumber']:02}") + print( + f"Skipped duplicate episode: {show_name} - S{episode['ParentIndexNumber']:02}E{episode['IndexNumber']:02}") episode_counters[show_name] += 1 else: print(f"No more episodes for {show_name}") -print("Playlist creation completed.") \ No newline at end of file +print("Playlist creation completed.") diff --git a/src/actions/sort_downloaded_tracks.py b/src/actions/sort_downloaded_tracks.py new file mode 100644 index 0000000..7d5ece0 --- /dev/null +++ b/src/actions/sort_downloaded_tracks.py @@ -0,0 +1,108 @@ +import os +import shutil +import logging +import tinytag +import re +import sys + +# Set up logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) + +# Set the input and output directories +input_dir = r"C:\Users\Dane Miller\Music\spotdl_downloads" +output_dir = r"C:\Users\Dane Miller\Music\org_spotdl_downloads" + +# Set the dry run option based on command-line argument +if len(sys.argv) > 1 and sys.argv[1] == "--dry-run": + dry_run = True +else: + dry_run = False + +# Regex pattern to extract year from date string +year_pattern = r"\d{4}" + + +def sanitize_filename(filename): + # Replace problematic characters with underscores + sanitized = re.sub(r'[<>:"/\\|?*]', "_", filename) + return sanitized + + +# Loop through all files in the input directory +for root, dirs, files in os.walk(input_dir): + for file in files: + if file.endswith(".mp3"): + # Construct the full file path + file_path = os.path.join(root, file) + + try: + # Read the metadata from the MP3 file + tag = tinytag.TinyTag.get(file_path) + artists = [ + artist.strip() + for artist in (tag.artist or "Unknown Artist").split("/") + ] + logging.info(f"Artists: {artists}") + album = tag.album or "Unknown Album" + year_str = str(tag.year) if tag.year else "" + year = re.search(year_pattern, year_str).group() if year_str else "" + track_number = str(tag.track).zfill(2) if tag.track else "00" + title = tag.title or os.path.splitext(file)[0] + + except Exception as e: + # Handle errors reading metadata + artists = ["Unknown Artist"] + album = "Unknown Album" + year = "" + track_number = "00" + title = os.path.splitext(file)[0] + logging.warning(f"Error reading metadata from {file_path}: {e}") + + # Create the output folder structure if it doesn't exist + album_name = f"{album} ({year})" if year else album + album_name = sanitize_filename(album_name) # Sanitize the album name + output_artist_dir = os.path.join( + output_dir, sanitize_filename(artists[0]) + ) # Use only the first artist and sanitize the name + output_album_dir = os.path.join( + output_artist_dir, album_name.replace("_", " ") + ) + if not os.path.exists(output_album_dir): + os.makedirs(output_album_dir) + logging.info(f"Created directory: {output_album_dir}") + + # Rename the file with the new format + new_file_name = f"{album_name.replace('_', ' ')} - {track_number} - {sanitize_filename(title)}.mp3" # Remove artist names from file name and sanitize the title + output_file_path = os.path.join(output_album_dir, new_file_name) + + logging.info(f"Source file path: {file_path}") + logging.info(f"Destination file path: {output_file_path}") + + if os.path.exists(file_path): + logging.info(f'File path exists.') + if dry_run: + logging.info(f"Dry run: Would have moved {file_path} to {output_file_path}") + else: + logging.info(f"Moving {file_path} to {output_file_path}") + # shutil.move(file_path, output_file_path) + + try: + shutil.copy2(file_path, output_file_path) + except Exception as e: + logging.error(f"Error moving {file_path} to {output_file_path}: {e}") + logging.info(f"Moved {file_path} to {output_file_path}") + else: + logging.warning(f"Source file does not exist: {file_path}") + + # Check if a lyrics file exists and copy it to the new directory structure + lyrics_file = os.path.splitext(file)[0] + ".lrc" + lyrics_file_path = os.path.join(root, lyrics_file) + if os.path.exists(lyrics_file_path): + output_lyrics_file_path = os.path.join(output_album_dir, os.path.splitext(new_file_name)[0] + ".lrc") + if dry_run: + logging.info(f"Dry run: Would have copied {lyrics_file_path} to {output_lyrics_file_path}") + else: + shutil.copy2(lyrics_file_path, output_lyrics_file_path) + logging.info(f"Copied {lyrics_file_path} to {output_lyrics_file_path}") \ No newline at end of file diff --git a/src/actions/sync_spotify_liked.py b/src/actions/sync_spotify_liked.py new file mode 100644 index 0000000..2bccb2c --- /dev/null +++ b/src/actions/sync_spotify_liked.py @@ -0,0 +1,141 @@ +import requests +import logging +from src.config import Config +from src.utils.string import StringUtils +from src.clients.emby_client import EmbyClient +from src.clients.spotify_client import SpotifyClient +import sqlite3 + +# Configure logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) + +# Get the user's playlists from Spotify +spot = SpotifyClient(Config.SPOTIFY_CLIENT_ID, Config.SPOTIFY_CLIENT_SECRET, Config.SPOTIFY_REDIRECT_URI, + Config.SPOTIFY_SCOPE) +emby = EmbyClient(Config.EMBY_URL, Config.EMBY_USERNAME, Config.EMBY_PASSWORD) + +# Connect to the SQLite database (it will be created if it doesn't exist) +conn = sqlite3.connect('unmatched_songs.db') +c = conn.cursor() + +# Create the table to store unmatched songs if it doesn't exist +c.execute('''CREATE TABLE IF NOT EXISTS unmatched_songs + (playlist_name TEXT, track_name TEXT, artist_name TEXT, album_name TEXT)''') + +# Get the user's playlists from Spotify +playlists = spot.get_playlists() +# Iterate over each Spotify playlist +playlist_name = "Favorites" +playlist_id = "0" +playlist_owner = "Dane" +logging.info(f"Processing Spotify playlist: {playlist_name} ({playlist_owner})") + +emby_playlist_name = f"{playlist_name} ({playlist_owner})" + +emby_playlist_search_results = emby.search(emby_playlist_name, 'Playlist') + +if emby_playlist_search_results: + # Delete the existing playlist + existing_playlist_id = emby_playlist_search_results[0]["Id"] + + for existing_playlist in emby_playlist_search_results: + if ( + existing_playlist["Name"] == emby_playlist_name + and existing_playlist["Type"] == "Playlist" + ): + emby.delete_playlist(existing_playlist['Id']) + logging.info( + f"Deleted existing Emby playlist: {emby_playlist_name} (ID: {existing_playlist_id})" + ) + +# Create a new playlist in Emby +try: + emby_playlist = emby.create_playlist(emby_playlist_name, 'Audio') + logging.info( + f"Created Emby playlist: {playlist_name} (ID: {emby_playlist["Id"]})" + ) + # Get the playlist image from Spotify + # playlist_image_data = spot.get_playlist_image(playlist_id) + # if playlist_image_data: + # try: + # emby.upload_image_data(emby_playlist['Id'], playlist_image_data) + # logging.info(f"Uploaded playlist cover image for '{playlist_name}'") + # except requests.exceptions.RequestException as e: + # logging.warning(f"Error uploading playlist cover image: {str(e)}") + # else: + # logging.warning(f"No playlist cover image found for '{playlist_name}'") +except (requests.exceptions.RequestException, KeyError) as e: + logging.error(f"Error creating Emby playlist: {playlist_name}") + logging.error(f"Error message: {str(e)}") + +# Get the tracks in the Spotify playlist +tracks = spot.get_liked_songs() + +logging.info(f"Processing {len(tracks)} tracks in Spotify playlist") +# Iterate over each track in the Spotify playlist +added_tracks = 0 +unmatched_tracks = [] +for track in tracks: + track_name = track["track"]["name"] + artist_name = track["track"]["artists"][0]["name"] + album_name = track["track"]["album"]["name"] + clean_track_name = StringUtils.remove_special_characters(track_name) + clean_artist_name = StringUtils.remove_special_characters(artist_name) + + emby_search_results = emby.search_for_track(track_name, artist_name) + emby_search_results_cleaned = emby.search_for_track( + clean_track_name, clean_artist_name + ) + + if emby_search_results: + found_match = False + for result in emby_search_results: + if found_match: + break + + emby_item_id = result["Id"] + + logging.debug(f'Matching {track["track"]["name"]} with {result["Name"]}') + if spot.match_song(track["track"], result): + logging.debug(f"Matched track: {track_name}") + try: + emby.add_item_to_playlist(emby_playlist['Id'], emby_item_id) + logging.info( + f"Added '{track_name}' by {artist_name} to Emby playlist" + ) + found_match = True + added_tracks += 1 + except requests.exceptions.RequestException as e: + logging.warning( + f"Error adding track to Emby playlist: {track_name}" + ) + logging.warning(f"Error message: {str(e)}") + else: + logging.warning( + f"No match found for '{track_name}' by {artist_name} in Emby, failed match_song" + ) + logging.warning(f"SPOTIFY: {track_name} by {artist_name}") + logging.warning( + f"EMBY: {result['Name']} by {result.get('Artists', [])}" + ) + else: + logging.warning( + f"No match found for '{track_name}' by {artist_name} in Emby" + ) + unmatched_tracks.append((playlist_name, track_name, artist_name, album_name)) + +# Insert the unmatched songs into the database +c.executemany('INSERT INTO unmatched_songs VALUES (?, ?, ?, ?)', unmatched_tracks) +conn.commit() + +# Calculate the match percentage for the current playlist +if len(tracks) > 0: + match_percentage = (added_tracks / len(tracks)) * 100 + logging.info(f"Match percentage for playlist '{playlist_name}': {match_percentage:.2f}%") + +added_tracks = 0 + +# Close the database connection +conn.close() diff --git a/src/actions/sync_spotify_playlists.py b/src/actions/sync_spotify_playlists.py index 17f6a4c..38c3e0c 100644 --- a/src/actions/sync_spotify_playlists.py +++ b/src/actions/sync_spotify_playlists.py @@ -8,6 +8,7 @@ from src.config import Config from src.utils.string import StringUtils from src.clients.emby_client import EmbyClient from src.clients.spotify_client import SpotifyClient +import sqlite3 # Configure logging logging.basicConfig( @@ -19,6 +20,14 @@ spot = SpotifyClient(Config.SPOTIFY_CLIENT_ID, Config.SPOTIFY_CLIENT_SECRET, Con Config.SPOTIFY_SCOPE) emby = EmbyClient(Config.EMBY_URL, Config.EMBY_USERNAME, Config.EMBY_PASSWORD) +# Connect to the SQLite database (it will be created if it doesn't exist) +conn = sqlite3.connect('unmatched_songs.db') +c = conn.cursor() + +# Create the table to store unmatched songs if it doesn't exist +c.execute('''CREATE TABLE IF NOT EXISTS unmatched_songs + (playlist_name TEXT, track_name TEXT, artist_name TEXT, album_name TEXT)''') + playlists = spot.get_playlists() # Iterate over each Spotify playlist @@ -52,6 +61,22 @@ for playlist in playlists["items"]: logging.info( f"Created Emby playlist: {playlist_name} (ID: {emby_playlist["Id"]})" ) + # Get the playlist image from Spotify + playlist_image_data = spot.get_playlist_image(playlist_id) + if playlist_image_data: + try: + emby.upload_image_data(emby_playlist['Id'], playlist_image_data) + logging.info(f"Uploaded playlist cover image for '{playlist_name}'") + except requests.exceptions.RequestException as e: + logging.warning(f"Error uploading playlist cover image: {str(e)}") + else: + logging.warning(f"No playlist cover image found for '{playlist_name}'") + + + + + + except (requests.exceptions.RequestException, KeyError) as e: logging.error(f"Error creating Emby playlist: {playlist_name}") logging.error(f"Error message: {str(e)}") @@ -62,6 +87,7 @@ for playlist in playlists["items"]: logging.info(f"Processing {len(tracks)} tracks in Spotify playlist") # Iterate over each track in the Spotify playlist added_tracks = 0 + unmatched_tracks = [] for track in tracks: track_name = track["track"]["name"] artist_name = track["track"]["artists"][0]["name"] @@ -109,5 +135,18 @@ for playlist in playlists["items"]: logging.warning( f"No match found for '{track_name}' by {artist_name} in Emby" ) - logging.info("Added tracks: " + str(added_tracks)) + unmatched_tracks.append((playlist_name, track_name, artist_name, album_name)) + + # Insert the unmatched songs into the database + c.executemany('INSERT INTO unmatched_songs VALUES (?, ?, ?, ?)', unmatched_tracks) + conn.commit() + + # Calculate the match percentage for the current playlist + if len(tracks) > 0: + match_percentage = (added_tracks / len(tracks)) * 100 + logging.info(f"Match percentage for playlist '{playlist_name}': {match_percentage:.2f}%") + added_tracks = 0 + +# Close the database connection +conn.close() \ No newline at end of file diff --git a/src/actions/sync_user_playlists.py b/src/actions/sync_user_playlists.py index 50e29ea..cfd8fea 100644 --- a/src/actions/sync_user_playlists.py +++ b/src/actions/sync_user_playlists.py @@ -6,7 +6,7 @@ from src.config import Config source_emby = EmbyClient(Config.EMBY_URL, Config.EMBY_USERNAME, Config.EMBY_PASSWORD) # Create an array of target usernames -target_usernames = ["Alyssa", "Dalton", "Laura", "Zuko", "Azula"] +target_usernames = ["Alyssa", "Dalton", "Laura", "Zuko", "Azula","Chris"] target_kids_usernames = ["Alyssa", "Azula", "Zuko"] # Get all the playlists for the source user diff --git a/src/clients/__pycache__/emby_client.cpython-312.pyc b/src/clients/__pycache__/emby_client.cpython-312.pyc index 9a6e7e0431571beeddcbea529cd3014211e51d44..86599ea097e31ced3467753744aa698478652a97 100644 GIT binary patch delta 8501 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 VGokP^A>(V)0W*oYDIoIM{69?E04@Lk delta 8710 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 I5V_$03r@k1+yDRo diff --git a/src/clients/__pycache__/spotify_client.cpython-312.pyc b/src/clients/__pycache__/spotify_client.cpython-312.pyc index ecae94bebfb12913497840ae10b8d56e0d0d35f7..f367bd5f4c9de1bcb3c721a2fbb28ac02a46499d 100644 GIT binary patch delta 1063 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 J@L?y_{{mM5tlj_s delta 1345 zcmZXUO>7%Q6vub$U3=qr*B`MQ+l}owaY(ktp_C%kG_<8r)U-|90FfF%?c(lkqAmU? zvztmTNGT|&;ZOAj6`8UoF*aI6qiV>{kwS8OX6@b zh=xytuaYFl(s8zON>a!mY)E4y44+AxWCu*kyW$Z{O0G4|nTDlS&C|3(o3se;%DYJ# zzL5*QC^{%`GpIm3big-=S-%0{)N1>O(57eYaWo6_sHbr@rs$yfX&A@55l~u&}_3&rwKIfWl($+Q=wCcXAx<{5Q9TB%g26)-G{mf#cji~RgJ=(=svQy zogI*bp&SZE5qSp7<4Rn2Iu5gmV{6CI#_p4&3SuY1&AEUh^mnsAFD{g_N8(|Lg8N@z#C7CuiqZ{%)d(E}K}ueWG9Um{1^{i$EQQ7nPue~#Mhu-2j?vgZ0bohEx=C{tuDb~N)M>!kNHC&=sYM`nw} z+>X4Hy;X9HaRQUV>+N3j%WQO=?`D1yS5F~S28(CF{b>~L4xc1%!(6V+qWvY{{8`3nOuOI2whm(f#B@h~`WF7&DZWAZX)b zO9QCOAPypC5Q_*6v5Yu_s3P2sLUp&5uoEc)U+Zaxb?zQ72(#p#40p#LTe}|@gd%kX z=;DZ~TC13KRaN>NakXyR*t*W3MYTz-Y8rLhn#6B{+MQ7MD-m$5jry{=La}U)h%Gr? zu?%->TEjIS!){COPjP9g*3eh0#(sK^IT7|+_;>1f2_Zjoq1)WVZ7%)5r+P^7%EXVU o!p&6S0f)2BQGw%w8|QC%lRxp13$qvJ-