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 ef1ede1..944448e 100644 Binary files a/src/__pycache__/config.cpython-312.pyc and b/src/__pycache__/config.cpython-312.pyc differ diff --git a/src/actions/.cache b/src/actions/.cache index a936a3f..2e6ce2c 100644 --- a/src/actions/.cache +++ b/src/actions/.cache @@ -1 +1 @@ -{"access_token": "BQCWbHB-kY3mUtocqK43wX8Ljl4wluFQnOIA9zCF3JlvKW0jtZHd65RJN_06wX5pRZdbrjeZfBQcE0--0o89f_lvI2tKiDjynhD4h44GruTSa4a8QFE9JkrdA27MdVJ5IlbPKoBQFpNOrYan6xmeTjme1YCARuF_tkfcy8E-9RcRKDAvH5eThu6Q41H-Rr4", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "AQBij3uDeV9G6Z71rKFATWpcHJLhggDPYabbTEuvebdSKEqRY_5VqS98wkx3u037d7nBlaTHIQpeSqkbbOmLdIut56TZ3Yiva56R4t6PV22LSY0wUdTYfwSjP_nWr_jP1wU", "scope": "playlist-read-private", "expires_at": 1715044235} \ No newline at end of file +{"access_token": "BQAYOPW8HTsv1vW7-DIAmNRqqXqG7XqddSEB9217h1jxSBqIu5yjvh13FB8sE-aW5k0wtycRdTqiUPcj4fLGPXvE3iNgbO059EqDmOzPWrAdzXBIgwal3SUouI2j2z_8tYTcqFh0VzqgcVSnX5AsEyhQcdJA2QusfItEPTI1jkv69RuAXURYtloPoCPjhkfCoum4FFrvya5xDSN06sgB2ZPA9euT0_9_f8oQbXGnZFsBPA", "token_type": "Bearer", "expires_in": 3600, "scope": "playlist-read-private", "expires_at": 1715107319, "refresh_token": "AQBij3uDeV9G6Z71rKFATWpcHJLhggDPYabbTEuvebdSKEqRY_5VqS98wkx3u037d7nBlaTHIQpeSqkbbOmLdIut56TZ3Yiva56R4t6PV22LSY0wUdTYfwSjP_nWr_jP1wU"} \ No newline at end of file diff --git a/src/actions/find_unmatched_songs.py b/src/actions/find_unmatched_songs.py new file mode 100644 index 0000000..885b505 --- /dev/null +++ b/src/actions/find_unmatched_songs.py @@ -0,0 +1,82 @@ +import shlex +import sqlite3 +import logging +import subprocess +import re + +# Configure logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) + +# Set the path to the spotdl.exe file +spotdl_path = "C:\\Users\\Dane Miller\\Downloads\\spotdl-4.2.5-win32.exe" + +# Set the output directory where the songs will be saved +output_dir = "C:\\Users\\Dane Miller\\Music\\spotdl_downloads" + +# Connect to the SQLite database +conn = sqlite3.connect('unmatched_songs.db') +c = conn.cursor() + + +def remove_special_characters(string): + # Remove single quotes, double quotes, slashes, and other special characters + cleaned_string = re.sub(r'[\'"\\/]', '', string) + return cleaned_string + +# Create the table to store downloaded songs if it doesn't exist +c.execute('''CREATE TABLE IF NOT EXISTS downloaded_songs + (track_name TEXT, artist_name TEXT, album_name TEXT)''') + +# Remove duplicates from the unmatched songs table +c.execute('''DELETE FROM unmatched_songs + WHERE rowid NOT IN ( + SELECT MIN(rowid) + FROM unmatched_songs + GROUP BY track_name, artist_name, album_name + )''') +conn.commit() + +# Retrieve the unmatched songs from the database +c.execute('SELECT DISTINCT track_name, artist_name, album_name FROM unmatched_songs') +unmatched_songs = c.fetchall() + +# Get the total number of unmatched songs +total_unmatched_songs = len(unmatched_songs) + +# Process each unmatched song +for index, song in enumerate(unmatched_songs, start=1): + track_name, artist_name, album_name = song + + # Check if the song has already been downloaded + c.execute('SELECT COUNT(*) FROM downloaded_songs WHERE track_name = ? AND artist_name = ? AND album_name = ?', + (track_name, artist_name, album_name)) + count = c.fetchone()[0] + + if count > 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 9a6e7e0..86599ea 100644 Binary files a/src/clients/__pycache__/emby_client.cpython-312.pyc and b/src/clients/__pycache__/emby_client.cpython-312.pyc differ diff --git a/src/clients/__pycache__/spotify_client.cpython-312.pyc b/src/clients/__pycache__/spotify_client.cpython-312.pyc index ecae94b..f367bd5 100644 Binary files a/src/clients/__pycache__/spotify_client.cpython-312.pyc and b/src/clients/__pycache__/spotify_client.cpython-312.pyc differ diff --git a/src/clients/emby_client.py b/src/clients/emby_client.py index 6409469..1faba4d 100644 --- a/src/clients/emby_client.py +++ b/src/clients/emby_client.py @@ -1,3 +1,4 @@ +import base64 import logging import random @@ -7,41 +8,12 @@ import requests from requests.exceptions import Timeout import time from enum import Enum + +from starlette.responses import guess_type + from src.config import Config -class EmbyLibraryItemType(Enum): - AUDIO = "Audio" - VIDEO = "Video" - FOLDER = "Folder" - EPISODE = "Episode" - MOVIE = "Movie" - TRAILER = "Trailer" - ADULT_VIDEO = "AdultVideo" - MUSIC_VIDEO = "MusicVideo" - BOX_SET = "BoxSet" - MUSIC_ALBUM = "MusicAlbum" - MUSIC_ARTIST = "MusicArtist" - SEASON = "Season" - SERIES = "Series" - GAME = "Game" - GAME_SYSTEM = "GameSystem" - BOOK = "Book" - - -class EmbyImageType(Enum): - PRIMARY = "Primary" - ART = "Art" - BACKDROP = "Backdrop" - BANNER = "Banner" - LOGO = "Logo" - THUMB = "Thumb" - DISC = "Disc" - BOX = "Box" - SCREENSHOT = "Screenshot" - MENU = "Menu" - CHAPTER = "Chapter" - class EmbyClient: def __init__(self, server_url, username, password): @@ -419,16 +391,20 @@ class EmbyClient: # response = self._get_request(url) # return response.get('Items', []) - # def upload_image(self, id, image_path, imgType='Primary'): - # mime_type = guess_type(image_path)[0] - # with open(image_path, 'rb') as f: - # image_data = f.read() - # encoded_image_data = base64.b64encode(image_data) - # headers = {'Content-Type': mime_type} - # print('Uploading collection image: ', image_path, mime_type) - # url = self._build_url(f'Items/{id}/Images/{imgType}') - # response = requests.post(url, data=encoded_image_data, headers=headers) - # return response + def upload_image(self, id, image_path, img_type='Primary'): + mime_type = guess_type(image_path)[0] + with open(image_path, 'rb') as f: + image_data = f.read() + return self.upload_image_data(id, image_data, img_type, mime_type) + + def upload_image_data(self, id, image_data, img_type='Primary', mime_type='image/jpeg'): + encoded_image_data = base64.b64encode(image_data) + headers = self.headers + headers['Content-Type'] = mime_type + print('Uploading image') + url = self._build_url(f'Items/{id}/Images/{img_type}') + response = requests.post(url, data=encoded_image_data, headers=headers) + return response def get_item_metadata(self, item_id): url = self._build_url(f'Users/{self.user_id}/Items/{item_id}', {'Fields': 'ChannelMappingInfo'}) @@ -602,6 +578,15 @@ class EmbyClient: def get_unwatched_movies(self, limit=50): return self.get_movies(limit, is_played=False) + def mark_as_unwatched(self, item_id): + url = self._build_url(f'Users/{self.user_id}/PlayedItems/{item_id}/') + response = self._delete_request(url) + + if response.status_code == 204: + print(f'Item {item_id} has been marked as unwatched') + else: + print(f"Failed to mark item {item_id} as unwatched. Status code: {response.status_code}") + def get_watched_series(self, limit=50): return self.get_media(limit, "Series", is_played=True) @@ -693,20 +678,21 @@ class EmbyClient: # self.delete_collection_items(playlist['Id']) # return self.create_playlist_from_list(media_list) - def upload_image_from_url(self, sourceListId, poster, root_path): - if poster is None: - print('no poster provided') - return None + # def upload_image_from_url(self, sourceListId, poster, root_path): + # if poster is None: + # print('no poster provided') + # return None + # + # # if the media_list.poster is a url, download the image and upload it to the provider + # + # print('downloading image from url') + # response = requests.get(poster, stream=True) + # if response.status_code == 200: + # # Assuming _get_request is using the requests library. + # # Use BytesIO to convert the response content into a file-like object so it can be opened by PIL + # img = Image.open(BytesIO(response.content)).convert('RGBA') + # + # poster_location = f'{root_path}/poster.png' + # img.save(poster_location, quality=95) + # self.upload_image(sourceListId, poster_location) - # if the media_list.poster is a url, download the image and upload it to the provider - - print('downloading image from url') - response = requests.get(poster, stream=True) - if response.status_code == 200: - # Assuming _get_request is using the requests library. - # Use BytesIO to convert the response content into a file-like object so it can be opened by PIL - img = Image.open(BytesIO(response.content)).convert('RGBA') - - poster_location = f'{root_path}/poster.png' - img.save(poster_location, quality=95) - self.upload_image(sourceListId, poster_location) diff --git a/src/clients/spotify_client.py b/src/clients/spotify_client.py index cb9e085..b4399e1 100644 --- a/src/clients/spotify_client.py +++ b/src/clients/spotify_client.py @@ -1,5 +1,6 @@ import logging +import requests import spotipy from fuzzywuzzy import fuzz from spotipy import SpotifyOAuth @@ -72,4 +73,22 @@ class SpotifyClient: return self.sp.current_user_playlists() def get_spotify(self): - return self.sp \ No newline at end of file + return self.sp + + def get_playlist_image(self, playlist_id): + results = self.sp.playlist_cover_image(playlist_id) + if results: + image_url = results[0]['url'] + response = requests.get(image_url) + if response.status_code == 200: + image_data = response.content + return image_data + return None + + def get_liked_songs(self): + liked_songs = [] + results = self.sp.current_user_saved_tracks() + while results: + liked_songs.extend(results["items"]) + results = self.sp.next(results) + return liked_songs diff --git a/src/config.py b/src/config.py index f38c2dd..9bcd460 100644 --- a/src/config.py +++ b/src/config.py @@ -1,35 +1,31 @@ # config.py +from dotenv import load_dotenv +import os + +load_dotenv() class Config: + + DRY_RUN = os.getenv('DRY_RUN') + # Spotify API credentials - SPOTIFY_CLIENT_ID = "402b29e6ea6e4296b351d9d95c65b6d9" - SPOTIFY_CLIENT_SECRET = "5ec3c861ca7141d9b01f75e1eca71bdc" - SPOTIFY_REDIRECT_URI = "https://my.home-assistant.io/redirect/oauth" - SPOTIFY_SCOPE = "playlist-read-private" + SPOTIFY_CLIENT_ID = os.getenv('SPOTIFY_CLIENT_ID') + SPOTIFY_CLIENT_SECRET = os.getenv('SPOTIFY_CLIENT_SECRET') + SPOTIFY_REDIRECT_URI =os.getenv('SPOTIFY_REDIRECT_URI') + SPOTIFY_SCOPE = os.getenv('SPOTIFY_SCOPE') # Emby API credentials - EMBY_URL: str = "http://192.168.0.120:8096" - EMBY_API_KEY = "8dc2564d98064333bae3e6eae4a5f8b4" - EMBY_USER_ID = "174eb36806924abdad60d6e18ca6da61" - EMBY_USERNAME = 'Faiyt' - EMBY_PASSWORD = 'dragon' - EMBY_CLIENT = "SpotEmbySync" - EMBY_DEVICE = "PC" - EMBY_DEVICE_ID = "your_emby_device_id" - EMBY_VERSION = "1.0.0" + EMBY_URL: str = os.getenv('EMBY_URL') + EMBY_API_KEY = os.getenv('EMBY_API_KEY') + EMBY_USER_ID = os.getenv('EMBY_USER_ID') + EMBY_USERNAME = os.getenv('EMBY_USERNAME') + EMBY_PASSWORD = os.getenv('EMBY_PASSWORD') + EMBY_CLIENT = os.getenv('EMBY_CLIENT') + EMBY_DEVICE = os.getenv('EMBY_DEVICE') + EMBY_DEVICE_ID = os.getenv('EMBY_DEVICE_ID') + EMBY_VERSION = os.getenv('EMBY_VERSION') + SONARR_API_KEY = os.getenv('SONARR_API_KEY') + SONARR_URL = os.getenv('SONARR_URL') - -# spotify_client_id = "402b29e6ea6e4296b351d9d95c65b6d9" -# spotify_client_secret = "5ec3c861ca7141d9b01f75e1eca71bdc" -# spotify_redirect_uri = "https://my.home-assistant.io/redirect/oauth" -# spotify_scope = "playlist-read-private" -# -# # Emby API credentials -# emby_url = "https://emby.faiyts.media" -# emby_api_key = "8dc2564d98064333bae3e6eae4a5f8b4" -# emby_user_id = "174eb36806924abdad60d6e18ca6da61" -# emby_client = "SpotEmbySync" -# emby_device = "PC" -# emby_device_id = "your_emby_device_id" -# emby_version = "1.0.0" + LOCAL_SONARR_ROOT_PATH = os.getenv('LOCAL_SONARR_ROOT_PATH') # The local path that gets the user to the same path that sonarr uses for root folder. diff --git a/src/main.py b/src/main.py index 5805331..6cdc565 100644 --- a/src/main.py +++ b/src/main.py @@ -1,12 +1,12 @@ -from src.clients.emby_client import Emby +from src.clients.emby_client import EmbyClient from services.playlist_service import PlaylistService from src.config import Config # Create an instance of the Emby client for the source user -source_emby = Emby(Config.EMBY_URL, Config.EMBY_USERNAME, Config.EMBY_PASSWORD) +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","Brittany","Adam"] +target_usernames = ["Alyssa", "Dalton", "Laura", "Zuko", "Azula","Brittany","Adam","Chris"] # Get all the playlists for the source user playlists = source_emby.get_playlists() @@ -19,7 +19,7 @@ for playlist in playlists: if target_username in playlist["Name"]: try: # Create an instance of the Emby client for the target user - target_emby = Emby(Config.EMBY_URL, target_username, '') + target_emby = EmbyClient(Config.EMBY_URL, target_username, '') # Create an instance of the PlaylistService playlist_service = PlaylistService(source_emby, target_emby) diff --git a/src/tests/__pycache__/emby_integration_test.cpython-312.pyc b/src/tests/__pycache__/emby_integration_test.cpython-312.pyc index f35b0fd..9851262 100644 Binary files a/src/tests/__pycache__/emby_integration_test.cpython-312.pyc and b/src/tests/__pycache__/emby_integration_test.cpython-312.pyc differ diff --git a/src/tests/emby_integration_test.py b/src/tests/emby_integration_test.py index 1ccc5ff..4f6ad6a 100644 --- a/src/tests/emby_integration_test.py +++ b/src/tests/emby_integration_test.py @@ -1,5 +1,9 @@ import unittest -from src.clients.emby_client import Emby +from unittest.mock import patch +from io import BytesIO +import requests + +from src.clients.emby_client import EmbyClient from src.config import Config @@ -10,7 +14,20 @@ class TestEmbyIntegration(unittest.TestCase): self.username = Config.EMBY_USERNAME self.api_key = Config.EMBY_API_KEY self.password = Config.EMBY_PASSWORD - self.emby = Emby(self.server_url, self.username, self.password) + self.emby = EmbyClient(self.server_url, self.username, self.password) + + self.test_playlist_name = "Test Playlist" + self.test_playlist = self.emby.create_playlist(self.test_playlist_name, "Audio") + self.test_playlist_id = self.test_playlist["Id"] + + def tearDown(self): + # Clean up by deleting the test playlist + self.emby.delete_playlist(self.test_playlist_id) + # Delete all playlists that have the name Test Playlist + playlists = self.emby.get_playlists() + for playlist in playlists: + if playlist["Name"] == "Test Playlist": + self.emby.delete_playlist(playlist["Id"]) def test_get_collections(self): collections = self.emby.get_collections() @@ -168,5 +185,14 @@ class TestEmbyIntegration(unittest.TestCase): # self.assertGreater(len(search_results), 0) # self.assertEqual(len(search_results), 0) + def test_upload_playlist_cover_image(self): + image_data = BytesIO(b'test image data') + res = self.emby.upload_image_data(self.test_playlist_id, image_data.getvalue(), 'Primary') + print(res, res.status_code) + # Verify that the playlist cover image was uploaded successfully + playlist_details = self.emby.get_list(self.test_playlist_id) + print(playlist_details) + self.assertTrue(playlist_details["ImageTags"].get("Primary")) + if __name__ == '__main__': unittest.main() diff --git a/src/types/__init__.py b/src/types/__init__.py index e69de29..ffdec4a 100644 --- a/src/types/__init__.py +++ b/src/types/__init__.py @@ -0,0 +1,33 @@ +from enum import Enum + +class EmbyLibraryItemType(Enum): + AUDIO = "Audio" + VIDEO = "Video" + FOLDER = "Folder" + EPISODE = "Episode" + MOVIE = "Movie" + TRAILER = "Trailer" + ADULT_VIDEO = "AdultVideo" + MUSIC_VIDEO = "MusicVideo" + BOX_SET = "BoxSet" + MUSIC_ALBUM = "MusicAlbum" + MUSIC_ARTIST = "MusicArtist" + SEASON = "Season" + SERIES = "Series" + GAME = "Game" + GAME_SYSTEM = "GameSystem" + BOOK = "Book" + + +class EmbyImageType(Enum): + PRIMARY = "Primary" + ART = "Art" + BACKDROP = "Backdrop" + BANNER = "Banner" + LOGO = "Logo" + THUMB = "Thumb" + DISC = "Disc" + BOX = "Box" + SCREENSHOT = "Screenshot" + MENU = "Menu" + CHAPTER = "Chapter" \ No newline at end of file